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 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 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 (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. |