mvvm

package module
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2026 License: BSD-3-Clause Imports: 0 Imported by: 0

README

go-widgets/mvvm

A tiny, dependency-free MVVM (Model–View–ViewModel) layer for the go-widgets ecosystem. Generics-only, no reflection, 100% statement coverage.

It provides the three MVVM primitives and the binding glue to wire them to widgets — without importing any widget package, so a single ViewModel drives both the pixel toolkit and the terminal-cell tui.

Primitive Role
Observable[T] a bindable property (Get/Set/Subscribe, skips equal values, re-entrancy-safe)
Command a bindable action with CanExecute + RaiseCanExecuteChanged
ObservableList[T] a bindable collection emitting granular insert/remove/replace/move/reset events

Binding adapters reach a widget only through pointers to its value field and its callback slot — BindField, OneWay, BindCommand, BindList.

Why it's backend-agnostic

go-widgets/toolkit and go-widgets/tui mirror the same field names and callback signatures per widget (Entry.Text + Entry.OnChange, Scale.Value + Scale.OnChange, …). So a binding written against (&w.Text, &w.OnChange) compiles and runs for either backend. This package therefore imports neither — it depends only on that field/callback shape.

Why binding is loop-free

A widget fires its change callback from inside its event handler, and leaves direct field writes silent. So the View→ViewModel edge is the callback, the ViewModel→View edge is a silent field write, and Observable.Set skips equal values — a two-way binding can echo without recursing.

A form in ~10 lines

type FormVM struct {
	Name  *mvvm.Observable[string]
	Names *mvvm.ObservableList[string]
	Save  *mvvm.Command
}

vm := &FormVM{Name: mvvm.NewObservable(""), Names: mvvm.NewObservableList[string]()}
vm.Save = mvvm.NewCommand(
	func() { vm.Names.Append(vm.Name.Get()); vm.Name.Set("") },
	func() bool { return vm.Name.Get() != "" }, // CanExecute
)
mvvm.BindCanExecute(vm.Save, vm.Name) // Save re-greys as the name changes

// View — identical for tui except the widget types + the repaint hook.
mvvm.BindField(vm.Name, &name.Text, &name.OnChange, repaint)
mvvm.BindCommand(vm.Save, &save.OnClick, setEnabled)
mvvm.BindList(vm.Names, &list.Items, func(s string) string { return s }, repaint)

The same FormVM drives the pixel and the cell form verbatim.

Backend adapters

The core package binds any widget whose value + change-callback fit (&field, &hook). Widgets with a multi-argument or oddly-named callback get a small named adapter in a per-backend subpackage — the only packages that import a backend:

  • mvvm/tkbind (imports toolkit) — BindRange for the two-handle RangeSlider (OnChange(low, high)), plus BindContainer / BindCardActive to drive a toolkit.Container / CardLayout from an ObservableList / Observable (data-driven views). BindStore / BindTable (imports github.com/go-widgets/data) wire a toolkit.Table to a typed data.Store: BindStore projects the store's rows into the grid and refreshes on every mutation; BindTable drives sort (header click → Query.Sorts), grouping (GroupByQuery.GroupBy) and inline editing (a validated cell commit → a Record mutation through the store's proxy, so it round-trips identically over the in-process or the remote proxy — native and wasm).
  • mvvm/tuibind (imports tui) — BindDropdown (OnChange(idx, value)) and BindTableSelection (OnSelect(row)).

The core mvvm package itself still imports nothing (verified with go list -deps), so a consumer who only wants observables/commands pays for no backend.

Undo / redo — mvvm/undo

A render-agnostic undo/redo command stack, built only on the core primitives (no backend), so it drives pixel and cell views alike.

s := undo.New() // unlimited; coalescing on. undo.WithLimit(n) / WithCoalescing(false) to tune.

doc := ""
write := func(text string) undo.Command {
	return undo.NewCommand("Write "+text,
		func() { doc += text },              // Do / redo
		func() { doc = doc[:len(doc)-len(text)] }) // Undo (exact inverse)
}

s.Push(write("hello")) // applies Do and records the step
s.Undo()               // doc == ""
s.Redo()               // doc == "hello"
  • CommandDo() / Undo() / Label(); Do and Undo are exact inverses so any Do/Undo or Undo/Redo pair round-trips state.
  • StackPush / Undo / Redo, a cursor (Cursor / Len), a divergent push discards the redo tail, an optional retention WithLimit, and coalescing of contiguous same-kind commands (a run of keystrokes undoes in one step) via the Coalescer interface / NewCoalescing.
  • MVVM-readyUndoCommand() / RedoCommand() are mvvm.Commands whose CanExecute tracks availability, and CanUndoBinding() / UndoTextBinding() (+ redo twins) are mvvm.Observables carrying the enabled flag and the live "Undo <label>" caption, so an Undo/Redo button or menu binds with no glue:
mvvm.BindCommand(s.UndoCommand(), &undoBtn.OnClick, setEnabled)
mvvm.OneWay(s.UndoTextBinding(), &undoBtn.Text, repaint)

mvvm/undo depends only on the dependency-free core (verified with go list -deps).

Status

v0.7.0: core + tkbind (incl. BindContainer / BindCardActive and the BindStore / BindTable data-grid binders over go-widgets/data) + tuibind

  • undo (render-agnostic undo/redo stack), all 100% coverage. Built against toolkit v0.150.0 and data v0.1.0.

License

BSD-3-Clause — see LICENSE.

Documentation

Overview

Package mvvm is a tiny, dependency-free MVVM (Model-View-ViewModel) layer for the go-widgets ecosystem. It provides the three MVVM primitives — Observable (a bindable property), Command (a bindable action), and ObservableList (a bindable collection) — plus pointer-based binding adapters (BindField, OneWay, BindCommand, BindList) that wire those primitives to widgets.

Backend-agnostic by construction

This package imports NEITHER go-widgets/toolkit (pixel widgets) nor go-widgets/tui (terminal-cell widgets). The adapters reach a widget only through a pointer to its value field and a pointer to its callback slot, e.g. (&entry.Text, &entry.OnChange). Because the two backends mirror the same field names and callback signatures for each widget, a single ViewModel and a single set of bindings drive both. Multi-argument or hook-less widgets (a two-handle range slider, a plain viewer table) get small bespoke adapters in per-backend subpackages that import their backend; the generic core here does not.

Why binding is loop-free

A widget fires its change callback from inside its event handler AND leaves direct field writes silent. So the View→ViewModel edge is the callback, the ViewModel→View edge is a silent field write, and Observable.Set skips equal values — a two-way binding can echo without recursing.

Threading

The primitives are not safe for concurrent use. Mutate observables on the UI goroutine; for an async producer, hand the value to the app's refresh queue and Set it from the UI tick.

A form in ~10 lines

type FormVM struct {
	Name  *mvvm.Observable[string]
	Names *mvvm.ObservableList[string]
	Save  *mvvm.Command
}

vm := &FormVM{Name: mvvm.NewObservable(""), Names: mvvm.NewObservableList[string]()}
vm.Save = mvvm.NewCommand(
	func() { vm.Names.Append(vm.Name.Get()); vm.Name.Set("") },
	func() bool { return vm.Name.Get() != "" }, // CanExecute
)
mvvm.BindCanExecute(vm.Save, vm.Name)

// View (pixel): identical for tui except the widget types + repaint hook.
mvvm.BindField(vm.Name, &name.Text, &name.OnChange, repaint)
mvvm.BindCommand(vm.Save, &save.OnClick, setEnabled)
mvvm.BindList(vm.Names, &list.Items, func(s string) string { return s }, repaint)

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func BindCanExecute

func BindCanExecute(c *Command, sources ...Changeable) (unbind func())

BindCanExecute makes the command re-raise CanExecuteChanged whenever any of the given sources changes, so a bound button re-greys automatically as the ViewModel state its predicate depends on evolves. Returns an unbind that detaches from every source.

func BindCommand

func BindCommand(c *Command, onClick *func(), setEnabled func(bool)) (unbind func())

BindCommand wires a Command to a button-shaped widget: it composes Execute into *onClick, and — when setEnabled is non-nil — calls it now and on every CanExecuteChanged so the widget reflects executability (a real disable, or a backend-specific greying such as swapping a Button style). Returns an unbind that restores the previous click handler and detaches.

func BindField

func BindField[T any](obs *Observable[T], field *T, hook *func(T), invalidate func()) (unbind func())

BindField binds obs two-way to a widget value field and its change-callback slot:

  • seeds the field from the observable (the ViewModel is the source of truth),
  • composes (does not clobber) any callback already in *hook, so a user edit flows callback → obs.Set,
  • pushes obs → field on change and calls invalidate.

It is loop-free: the field write is silent (widgets do not notify on direct field writes) and Observable.Set skips equal values. The returned unbind restores the previous callback and detaches the subscription.

Example

ExampleBindField shows the two-way binding an app's View layer sets up: the ViewModel's observable and a widget's (Text, OnChange) drive each other, with no backend import.

package main

import (
	"fmt"

	"github.com/go-widgets/mvvm"
)

// widget stands in for a real toolkit.Entry / tui.Entry — the same field+hook
// surface both backends expose, so a binding written against pointers works for
// either without this package importing a backend.
type widget struct {
	Text     string
	OnChange func(string)
}

func main() {
	vmName := mvvm.NewObservable("seed")
	w := &widget{}
	mvvm.BindField(vmName, &w.Text, &w.OnChange, nil)

	fmt.Println("seeded:", w.Text) // View seeded from the ViewModel
	w.OnChange("typed by user")    // View → ViewModel
	fmt.Println("vm:", vmName.Get())
	vmName.Set("set by code") // ViewModel → View
	fmt.Println("view:", w.Text)
}
Output:
seeded: seed
vm: typed by user
view: set by code

func BindList

func BindList[T any](l *ObservableList[T], items *[]string, project func(T) string, invalidate func()) (unbind func())

BindList projects an ObservableList[T] into a widget's []string backing slice (ListBox.Items, or a row source) via project, rebuilding it on every change and calling invalidate. Rebuild is O(n) — n is a viewport-scale list a view already walks each frame; the granular ListEvent is available for callers who want an incremental variant. Returns an unbind that detaches.

func OneWay

func OneWay[T any](obs *Observable[T], field *T, invalidate func()) (unbind func())

OneWay binds obs → field only, for view-only sinks that have no user-edit callback (a Label's text, a ProgressBar's fraction, a passive Table's selection). Returns an unbind that detaches the subscription.

Types

type Changeable

type Changeable interface {
	SubscribeChanged(fn func()) (unsubscribe func())
}

Changeable is any source that can notify on change without exposing its value's type — implemented by Observable[T] and ObservableList[T]. It is the type-erased seam BindCanExecute uses to combine mixed-typed sources.

type Command

type Command struct {
	// contains filtered or unexported fields
}

Command is an invocable action with an optional CanExecute predicate — the "command" primitive of MVVM. Bind it to a button-shaped widget with BindCommand: the widget invokes Execute, and a change in CanExecute re-greys or re-enables the widget.

Example
package main

import (
	"fmt"

	"github.com/go-widgets/mvvm"
)

func main() {
	name := mvvm.NewObservable("")
	save := mvvm.NewCommand(
		func() { fmt.Println("saved", name.Get()) },
		func() bool { return name.Get() != "" }, // CanExecute
	)
	save.Execute() // gated off (name empty) → nothing happens
	name.Set("Ada")
	save.Execute()
}
Output:
saved Ada

func NewCommand

func NewCommand(exec func(), canExec func() bool) *Command

NewCommand builds a Command from an action and an optional CanExecute predicate (pass nil to mean "always executable").

func (*Command) CanExecute

func (c *Command) CanExecute() bool

CanExecute reports whether the command may run right now.

func (*Command) Execute

func (c *Command) Execute()

Execute runs the action, but only when CanExecute is true — so it is safe to wire straight to a widget's OnClick without a guard at the call site.

func (*Command) RaiseCanExecuteChanged

func (c *Command) RaiseCanExecuteChanged()

RaiseCanExecuteChanged notifies every binding to re-query CanExecute. Call it when whatever the predicate reads has changed; BindCanExecute wires this up automatically for Observable sources.

func (*Command) SubscribeCanExecuteChanged

func (c *Command) SubscribeCanExecuteChanged(fn func()) (unsubscribe func())

SubscribeCanExecuteChanged registers fn, called on each RaiseCanExecuteChanged, and returns an unsubscribe.

type ListChangeKind

type ListChangeKind int

ListChangeKind classifies an ObservableList change so a bound view can update incrementally instead of rebuilding.

const (
	// ListInsert: Count items were inserted starting at Index.
	ListInsert ListChangeKind = iota
	// ListRemove: Count items were removed starting at Index.
	ListRemove
	// ListReplace: the item at Index was replaced.
	ListReplace
	// ListMove: one item moved from Index to To.
	ListMove
	// ListReset: a wholesale change; re-read the whole list.
	ListReset
)

type ListEvent

type ListEvent[T any] struct {
	Kind  ListChangeKind
	Index int
	To    int // ListMove only: the destination index
	Count int // ListInsert / ListRemove
	Items []T // ListInsert / ListReplace: the affected items
}

ListEvent describes one change to an ObservableList.

type Observable

type Observable[T any] struct {
	// contains filtered or unexported fields
}

Observable is a single value that notifies its subscribers whenever it changes. It is the "property" primitive of the MVVM layer: a ViewModel holds Observables, a View binds widgets to them, and neither side references the other directly.

Observable is intentionally NOT safe for concurrent use — mutate it on the UI goroutine. For an async producer, hand the value to the app's refresh/queue and Set it from the UI tick. Keeping it lock-free matches the single-threaded UI model and stays allocation-light.

Example
package main

import (
	"fmt"

	"github.com/go-widgets/mvvm"
)

func main() {
	name := mvvm.NewObservable("")
	name.Subscribe(func(v string) { fmt.Println("name is now", v) })
	name.Set("Ada")
	name.Set("Ada") // equal → no notification
	name.Set("Bob")
}
Output:
name is now Ada
name is now Bob

func NewObservable

func NewObservable[T comparable](v T) *Observable[T]

NewObservable seeds a value using == as the change test (T must be comparable). Setting an equal value is a no-op, which is what keeps two-way bindings loop-free.

func NewObservableEq

func NewObservableEq[T any](v T, eq func(a, b T) bool) *Observable[T]

NewObservableEq seeds a value with an explicit equality function — use it for slice- or struct-valued observables where == does not apply. A nil eq means "never equal": every Set notifies (and two-way loop-suppression is defeated, so supply an eq for anything you bind two-way).

func (*Observable[T]) Get

func (o *Observable[T]) Get() T

Get returns the current value.

func (*Observable[T]) Set

func (o *Observable[T]) Set(v T)

Set assigns v and, when it differs from the current value (per the change test), notifies every subscriber with the new value.

Re-entrant Sets are safe: if a subscriber calls Set again (e.g. to normalise or clamp), the value is updated and the notification loop runs another pass with the latest value rather than recursing — so a validating subscriber converges instead of overflowing the stack. Subscribers are expected to converge; a pair that endlessly sets each other to different values is a caller bug, not a case Observable defends against.

func (*Observable[T]) Subscribe

func (o *Observable[T]) Subscribe(fn func(T)) (unsubscribe func())

Subscribe registers fn (which is NOT called immediately) and returns a function that removes it. Subscribers run in an unspecified order.

func (*Observable[T]) SubscribeChanged

func (o *Observable[T]) SubscribeChanged(fn func()) (unsubscribe func())

SubscribeChanged registers a value-less observer, called on every change. It lets an Observable satisfy Changeable so heterogeneous sources (a string and a bool observable, say) can jointly drive a Command's CanExecute.

type ObservableList

type ObservableList[T any] struct {
	// contains filtered or unexported fields
}

ObservableList is an ordered collection that emits a granular ListEvent on every mutation — the collection primitive of MVVM, bound to a ListBox / Table / TreeView. Out-of-range indices are clamped or ignored rather than panicking, matching the forgiving contract of a UI list.

Example
package main

import (
	"fmt"

	"github.com/go-widgets/mvvm"
)

func main() {
	todos := mvvm.NewObservableList[string]("buy milk")
	todos.Subscribe(func(e mvvm.ListEvent[string]) {
		fmt.Printf("change kind=%d at %d\n", e.Kind, e.Index)
	})
	todos.Append("walk dog")
	todos.RemoveAt(0)
	fmt.Println(todos.Slice())
}
Output:
change kind=0 at 1
change kind=1 at 0
[walk dog]

func NewObservableList

func NewObservableList[T any](initial ...T) *ObservableList[T]

NewObservableList builds a list seeded with the given items.

func (*ObservableList[T]) Append

func (l *ObservableList[T]) Append(v ...T)

Append adds items at the end and emits one ListInsert. Appending nothing is a no-op (no event).

func (*ObservableList[T]) At

func (l *ObservableList[T]) At(i int) T

At returns the item at i (panics on out-of-range, like a slice index — At is a read used with a valid Len()-bounded index).

func (*ObservableList[T]) Clear

func (l *ObservableList[T]) Clear()

Clear removes every item and emits a ListReset.

func (*ObservableList[T]) Insert

func (l *ObservableList[T]) Insert(i int, v T)

Insert places v at index i (clamped to [0, Len]) and emits a ListInsert.

func (*ObservableList[T]) Len

func (l *ObservableList[T]) Len() int

Len returns the item count.

func (*ObservableList[T]) Move

func (l *ObservableList[T]) Move(from, to int)

Move relocates the item at from to index to and emits a ListMove. Either index out of range, or from == to, is ignored (no event).

func (*ObservableList[T]) RemoveAt

func (l *ObservableList[T]) RemoveAt(i int)

RemoveAt removes the item at i and emits a ListRemove. An out-of-range index is ignored (no event).

func (*ObservableList[T]) Set

func (l *ObservableList[T]) Set(i int, v T)

Set replaces the item at i and emits a ListReplace. An out-of-range index is ignored (no event).

func (*ObservableList[T]) Slice

func (l *ObservableList[T]) Slice() []T

Slice returns a defensive copy of the items.

func (*ObservableList[T]) Subscribe

func (l *ObservableList[T]) Subscribe(fn func(ListEvent[T])) (unsubscribe func())

Subscribe registers fn (not called immediately) and returns an unsubscribe.

func (*ObservableList[T]) SubscribeChanged

func (l *ObservableList[T]) SubscribeChanged(fn func()) (unsubscribe func())

SubscribeChanged registers a value-less observer called on every change, so ObservableList satisfies Changeable (usable as a Command CanExecute source).

Directories

Path Synopsis
Package tkbind holds the MVVM binding adapters that are specific to the pixel toolkit (github.com/go-widgets/toolkit) — the widgets whose value/callback shape the generic mvvm adapters can't express, such as a two-handle range slider (a multi-argument OnChange).
Package tkbind holds the MVVM binding adapters that are specific to the pixel toolkit (github.com/go-widgets/toolkit) — the widgets whose value/callback shape the generic mvvm adapters can't express, such as a two-handle range slider (a multi-argument OnChange).
Package tuibind holds the MVVM binding adapters specific to the terminal-cell toolkit (github.com/go-widgets/tui) — widgets whose callback shape the generic mvvm adapters can't express, such as a Dropdown whose OnChange carries both the index and the value string.
Package tuibind holds the MVVM binding adapters specific to the terminal-cell toolkit (github.com/go-widgets/tui) — widgets whose callback shape the generic mvvm adapters can't express, such as a Dropdown whose OnChange carries both the index and the value string.
Package undo is a render-agnostic undo/redo command stack for the go-widgets MVVM layer.
Package undo is a render-agnostic undo/redo command stack for the go-widgets MVVM layer.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL