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 ¶
- func BindCanExecute(c *Command, sources ...Changeable) (unbind func())
- func BindCommand(c *Command, onClick *func(), setEnabled func(bool)) (unbind func())
- func BindField[T any](obs *Observable[T], field *T, hook *func(T), invalidate func()) (unbind func())
- func BindList[T any](l *ObservableList[T], items *[]string, project func(T) string, ...) (unbind func())
- func BindTwoWay[T any](src, dst *Observable[T], invalidate func()) (unbind func())
- func OneWay[T any](obs *Observable[T], field *T, invalidate func()) (unbind func())
- type Changeable
- type Command
- type ListChangeKind
- type ListEvent
- type Observable
- type ObservableList
- func (l *ObservableList[T]) Append(v ...T)
- func (l *ObservableList[T]) At(i int) T
- func (l *ObservableList[T]) Clear()
- func (l *ObservableList[T]) Insert(i int, v T)
- func (l *ObservableList[T]) Len() int
- func (l *ObservableList[T]) Move(from, to int)
- func (l *ObservableList[T]) RemoveAt(i int)
- func (l *ObservableList[T]) Set(i int, v T)
- func (l *ObservableList[T]) Slice() []T
- func (l *ObservableList[T]) Subscribe(fn func(ListEvent[T])) (unsubscribe func())
- func (l *ObservableList[T]) SubscribeChanged(fn func()) (unsubscribe func())
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 ¶
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 BindTwoWay ¶ added in v0.8.0
func BindTwoWay[T any](src, dst *Observable[T], invalidate func()) (unbind func())
BindTwoWay links two Observables of the same type so that a change to either is reflected in the other, and returns an unbind detaching both directions.
It is the adapter for widgets that expose their state AS an Observable rather than as a value field plus a callback slot — the shape BindField takes. A widget that owns an Observable has no field to seed and no hook to compose; there are simply two properties that must agree, and the binding is symmetric where BindField's is not.
src is the source of truth at bind time: dst is seeded from it, matching BindField's rule that the ViewModel wins over whatever the widget was constructed with. Afterwards neither side is privileged.
It is loop-free by the same mechanism as the rest of this package: Set skips values equal to the current one, so the echo stops at the first hop. That mechanism is the change test, so — as NewObservableEq documents — a two-way binding over an Observable built with a nil eq would never settle. Give anything bound this way a real equality function.
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 ¶
NewCommand builds a Command from an action and an optional CanExecute predicate (pass nil to mean "always executable").
func (*Command) CanExecute ¶
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]) 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]) 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.
|
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. |
|
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. |