mvu

package module
v0.4.4 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 9 Imported by: 0

README

mvu

mvu is a small Go library for building Gio applications with a Model-View-Update architecture and reactive rendering primitives.

It wraps Gio's window/event loop in a lightweight runtime that lets you describe your application as:

  • a model: the current application state,
  • messages: values that describe something that happened,
  • an update function: pure-ish state transition logic that reacts to messages,
  • a view function: a Gio widget derived from the current model, and
  • commands: asynchronous or deferred work that can emit more messages.

The library is intentionally minimal. It does not prescribe a widget toolkit, styling system, or message hierarchy. A message is simply any, a view is a standard layout.Widget, and rendered layers are reactive rx.Observable[layout.Widget] values.

Why use it?

Gio gives you immediate-mode UI primitives and direct access to the application event loop. mvu adds a thin coordination layer for applications that benefit from unidirectional data flow:

  1. the runtime receives a message,
  2. Update produces the next model and an optional command,
  3. the model is mapped to a View,
  4. the window is invalidated and rendered,
  5. commands can emit more messages back into the loop.

This keeps state changes explicit while preserving the flexibility of Gio widgets and operations.

Features

  • Generic Run and Loop functions for typed application state.
  • Elm-style Init, Update, and View functions.
  • Command abstraction backed by github.com/reactivego/rx observables.
  • Helpers for no-op, sequential, and concurrent commands.
  • MessageOp for emitting messages from inside Gio layout code.
  • Reactive window renderer that composes one or more observable widget layers.
  • Direct access to the underlying *app.Window when needed.
  • Compatible with Gio v0.10.1.

Installation

go get github.com/vibrantgio/mvu

Quick start

The following example creates a simple counter. Clicking the button emits an Increment message from the view. The runtime passes that message to Update, which returns the next model.

package main

import (
	"fmt"
	"log"
	"os"

	"gioui.org/app"
	"gioui.org/layout"
	"gioui.org/widget"
	"gioui.org/widget/material"

	"github.com/vibrantgio/mvu"
)

type Model struct {
	Count int
}

type Increment struct{}

var button widget.Clickable
var theme = material.NewTheme()

func main() {
	go run()
	app.Main()
}

func run() {
	w := mvu.NewWindow(app.Title("MVU Counter"))

	update := func(model Model, message mvu.Message) (Model, mvu.Command) {
		switch message.(type) {
		case Increment:
			model.Count++
		}
		return model, mvu.DoNothing()
	}

	view := func(model Model) layout.Widget {
		return func(gtx layout.Context) layout.Dimensions {
			for button.Clicked(gtx) {
				mvu.MessageOp{Message: Increment{}}.Add(gtx.Ops)
			}

			return layout.Center.Layout(gtx,
				material.Button(theme, &button, fmt.Sprintf("Count: %d", model.Count)).Layout,
			)
		}
	}

	init := func() (Model, mvu.Command) {
		return Model{}, mvu.DoNothing()
	}

	if err := mvu.Run(w, init, update, view); err != nil {
		log.Fatal(err)
	}
	os.Exit(0)
}

Run it with:

go run .

Core concepts

Messages

A message is any Go value:

type Message = any

Applications usually define small concrete message types:

type Loaded struct {
	Items []Item
}

type Failed struct {
	Err error
}

Messages can come from several places:

  • Gio layout code, via mvu.MessageOp{Message: ...}.Add(gtx.Ops).
  • Commands, by returning a non-nil message from mvu.Do.
  • The window event stream exposed by Window.Messages().
Model

Your model is the typed application state carried through the loop. It can be a struct, primitive value, pointer, or any other Go type.

type Model struct {
	Loading bool
	Items   []Item
	Err     error
}
Init

Init creates the seed model and an initial command; it is passed to Run or Loop alongside update, and called once when the loop starts. Use mvu.DoNothing() when there is no startup work. (At package level the name init is reserved by Go, so use Init or a local closure.)

func Init() (Model, mvu.Command) {
	return Model{Loading: true}, loadItems()
}

err := mvu.Run(w, Init, update, view)
Update

Update receives the current model and the next message. It returns the new model and a command to execute.

func update(model Model, message mvu.Message) (Model, mvu.Command) {
	switch msg := message.(type) {
	case Loaded:
		model.Loading = false
		model.Items = msg.Items
	case Failed:
		model.Loading = false
		model.Err = msg.Err
	}
	return model, mvu.DoNothing()
}
View

View maps the model to a Gio layout.Widget.

func view(model Model) layout.Widget {
	return func(gtx layout.Context) layout.Dimensions {
		// Draw using regular Gio operations and widgets.
		return layout.Dimensions{Size: gtx.Constraints.Max}
	}
}

When the view needs to notify the runtime about user interaction, add a MessageOp to the current op.Ops:

mvu.MessageOp{Message: UserClicked{}}.Add(gtx.Ops)

The window renderer collects message operations during the frame and sends them into the runtime after the frame is submitted.

Commands

A Command represents work that can emit messages. Commands are implemented as reactive observables, so they can be combined or sequenced.

Create a command with mvu.Do:

func loadItems() mvu.Command {
	return mvu.Do(func() (mvu.Message, error) {
		items, err := fetchItems()
		if err != nil {
			return Failed{Err: err}, nil
		}
		return Loaded{Items: items}, nil
	})
}

Available helpers:

  • mvu.Do(fn) runs a function and emits its returned message when non-nil.
  • mvu.DoNothing() returns a command that completes without emitting a message.
  • mvu.DoConcurrent(cmds...) merges multiple commands so they can run concurrently.
  • mvu.DoSequence(cmds...) concatenates commands so they run in order.
  • cmd.Trace(name) logs command start, completion, and failure information.

Command errors are caught by the loop and printed as Command Error: .... If you want errors to affect the model, return an error message value instead of returning a non-nil Go error.

Rendering without the MVU loop

You can also use Window directly when you only need reactive rendering and do not need the full MVU loop.

package main

import (
	"os"

	"gioui.org/app"
	"gioui.org/layout"

	"github.com/reactivego/rx"
	"github.com/vibrantgio/mvu"
)

func main() {
	go func() {
		window := mvu.NewWindow(app.Title("MVU - Minimal"))
		layer := rx.Of[layout.Widget](func(gtx layout.Context) layout.Dimensions {
			return layout.Dimensions{Size: gtx.Constraints.Max}
		})

		window.Render(layer).Wait()
		os.Exit(0)
	}()
	app.Main()
}

Window.Render accepts any number of rx.Observable[layout.Widget] layers. The current value of each layer is rendered every frame, in the order passed to Render.

API overview

Run and Loop
func Run[Model any](w *Window,
	init func() (Model, Command),
	update func(Model, Message) (Model, Command),
	view func(Model) layout.Widget,
	layers ...rx.Observable[layout.Widget]) error

func Loop[Model any](messages rx.Observable[Message],
	init func() (Model, Command),
	update func(Model, Message) (Model, Command),
) (models rx.Observable[Model], runner rx.Subscription)
  • Run starts the MVU loop on a window and renders the view mapped over the models after any additional layers. Additional layers are useful for persistent backgrounds, overlays, animations, or debug UI that are driven by independent observables.
  • Loop is the message/command loop alone, for apps that own their rendering — e.g. a spectrum window whose Render takes a theme-fed layer builder. It returns the cold models observable (seed first; apply Publish().AutoConnect(N) for N consumers) and the command runner subscription (defer func() { runner.Unsubscribe(); runner.Wait() }()). Commands returned by update — including long-running streams — emit messages that feed back into the loop; a command error is contained to that command.
Window
  • NewWindow(options ...app.Option) *Window creates a Gio window wrapper.
  • Window() *app.Window returns the underlying Gio window.
  • Messages() rx.Observable[Message] exposes messages emitted by MessageOp during frames.
  • Render(layers ...rx.Observable[layout.Widget]) rx.Subscription drives the Gio event loop until the window is destroyed.
MessageOp
type MessageOp struct{ Message }

Call Add(gtx.Ops) during layout to enqueue a message for the runtime/window message stream.

mvu.MessageOp{Message: SomeMessage{}}.Add(gtx.Ops)

Examples

The example module contains small Gio programs demonstrating direct window rendering and reactive layers, including:

  • example/01-minimal — minimal window setup.
  • example/04-hello — layered rendering with a backdrop and text.
  • example/edit — Gio editor widgets inside reactive layers.
  • example/tweening — animated color transitions driven by observables.

To run an example:

cd example/04-hello
go run .

Design notes

  • Gio's frame protocol is handled on a single goroutine inside Window.Render, which avoids deadlocks around FrameEvent handling.
  • Layer observables are subscribed concurrently and stored as an atomic snapshot. Updating a layer invalidates the window so Gio schedules a new frame.
  • MessageOp collection is scoped to the frame's op.Ops, allowing view code to emit messages without direct access to the loop.
  • mvu deliberately keeps messages untyped at the boundary. Use concrete message structs and type switches in your application for clarity.

Requirements

  • Go 1.25.1 or newer for the root module.
  • Gio v0.10.1.
  • github.com/reactivego/rx v0.3.0.

License

MIT — see LICENSE.

Documentation

Overview

Package mvu is the Model-View-Update runtime at the root of the Vibrant Gio stack: a Gio window, an Elm-shaped reducer over it, and rx observables as the wiring between the two. It is tier 0 of ADR-001 and imports nothing else in the organization — spectrum wraps its Window to scope a theme, and prism, pulse, cadence and markdown all draw inside a layer it drives.

You write four things: a Model type, message types, an Init returning the seed model and a startup command, and an Update reducing a message onto a model. Run is the whole application for a single window; Loop is the reducer alone, for applications that own their rendering — wrapping the window in a theme, as spectrum/window does, is the usual reason.

w := mvu.NewWindow(app.Title("Counter"))
if err := mvu.Run(w, Init, Update, View); err != nil { ... }

Side effects are Command values — Do, DoNothing, DoConcurrent, DoSequence — and the loop runs them, feeding the messages they emit back into Update. One command may stream many messages, so a long-running source is a single command, not a goroutine. A command that fails is reported on stdout and torn down alone: the loop keeps reducing and later messages still arrive. An application with no effects returns DoNothing() everywhere and never notices the runner.

Messages come out of a frame, not out of a callback

Widget code hands a message to the loop with MessageOp:

mvu.MessageOp{Message: SelectItem{ID: id}}.Add(gtx.Ops)

The collector is keyed on the exact *op.Ops the current frame is being recorded into, and an Add against any other buffer is dropped silently — no panic, no error, just a message that never arrives. That is a real trap, not a theoretical one: a widget drawn through prism/cache.FrameCache records into the cache's own private op.Ops, so a MessageOp added inside that body goes nowhere, and on a cache hit the body does not run at all. Emit from the widget that owns gtx.Ops, never from inside a cached recording.

AutoConnect counts are load-bearing, and both errors are silent

Loop returns the models observable and the command runner. Models emits the seed first and never replays: a subscriber that attaches later is handed that same seed rather than the current model — with the model already advanced to 5, a freshly attached subscriber was measured receiving 0. A layer topology with N consumers therefore multicasts with models.Publish().AutoConnect(N), which holds the connect back until all N have attached so the seed reaches every one of them.

N must equal the number of cold subscriptions the topology actually makes. Too low and the loop connects early, so the late consumers render a zero Model. Too high and it never connects at all: the window's messages are never drained, and because that channel holds exactly one MessageOp the event goroutine blocks on the second one it tries to hand over, and the window stops painting. Neither failure logs anything. Keep N static — never subscribe the model observable from inside a per-row prism/keyed factory, which attaches after the seed has fired — and let a test count the subscriptions instead of tuning the number by hand.

Threading

Window.Render reads window events and calls Frame on one goroutine, because Gio's frame protocol deadlocks if a flush is delivered before Frame is called. Layer observables are subscribed concurrently and published to that goroutine as an atomic snapshot, which is then invalidated to schedule a frame: heavy work is free to run on rx goroutines, but nothing it produces is drawn until the next frame event arrives. Never call Gio from a goroutine of your own.

Two more things a first program needs. app.Main() must be the last call on the main goroutine, with the real work started as a goroutine before it. And a hand-built loop is stopped by its runner, not by its window:

models, runner := mvu.Loop(w.Messages(), Init, Update)
defer func() { runner.Unsubscribe(); runner.Wait() }()

The example module — github.com/vibrantgio/mvu/example, tagged in lockstep with this one, so example/v0.4.3 goes with v0.4.3 — holds runnable programs from a bare window upwards. The organization's agent guide carries the full application skeleton and the rules above: https://raw.githubusercontent.com/vibrantgio/.github/master/llms.txt

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Loop

func Loop[Model any](
	messages rx.Observable[Message],
	init func() (Model, Command),
	update func(Model, Message) (Model, Command),
) (models rx.Observable[Model], runner rx.Subscription)

Loop is the MVU message/command loop, independent of any window: init produces the seed model and initial command, and update is scanned over messages merged with the messages emitted by the commands update returns, so long-running commands (streams, sequences) feed back into the loop. init is called once, when Loop is; its command runs as soon as the runner starts.

The returned models observable emits the seed model first (StartWith) and then one model per message. It is never replayed: the scan behind it is already multicast, so a second direct subscriber does not re-run update, but it is handed the seed rather than the model current at the time it attached. A single consumer can subscribe it directly; a layer topology with N consumers applies Publish().AutoConnect(N), which holds the connect back until all N have attached so the seed reaches every one of them. The scan connects — and messages start draining — when the models side is subscribed.

The returned runner executes commands until unsubscribed. A command error is reported and terminates that command only, never the loop. Callers stop the loop with:

defer func() { runner.Unsubscribe(); runner.Wait() }()

func Run

func Run[Model any](
	w *Window,
	init func() (Model, Command),
	update func(Model, Message) (Model, Command),
	view func(Model) layout.Widget,
	layers ...rx.Observable[layout.Widget],
) error

Run drives a window with the MVU loop: models scanned by Loop are mapped through view onto a layer stacked in front of layers, and Run blocks until the window is destroyed.

Run renders on the raw mvu Window; view receives only the Model. An app that needs theme-aware layers (e.g. a spectrum window, whose Render takes a layer builder fed by the theme observable) composes Loop with its own rendering instead — see Loop.

Types

type Command

type Command struct{ rx.Observable[Message] }

Command

func Do

func Do(command func() (Message, error)) Command

func DoConcurrent

func DoConcurrent(cmds ...Command) Command

func DoNothing

func DoNothing() Command

func DoSequence

func DoSequence(cmds ...Command) Command

func (Command) Trace

func (cmd Command) Trace(name string) Command

type Message

type Message = any

type MessageOp

type MessageOp struct{ Message }

func (MessageOp) Add

func (msgOp MessageOp) Add(o *op.Ops)

type Window

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

Window handles the events of a single gioui app Window.

func NewWindow

func NewWindow(options ...app.Option) *Window

func (*Window) Messages

func (w *Window) Messages() rx.Observable[Message]

func (*Window) Render

func (w *Window) Render(layers ...rx.Observable[layout.Widget]) rx.Subscription

Render drives the Gio event loop. The returned subscription terminates when the window emits an `app.DestroyEvent`.

Gio has enforced a synchronous protocol since v0.9: after delivering a `FrameEvent`, the OS-side `deliverEvent` enters a select that can either receive the rendered frame on `e.frames` or send `theFlushEvent` on `e.events`. Whoever completes first wins, and if the flush is delivered before `Frame()` is called, `deliverEvent` returns and the next `Frame()` deadlocks. The fix is to read events and call `Frame()` on the *same* goroutine. Layer state is updated concurrently via an atomic snapshot.

func (*Window) Window

func (w *Window) Window() *app.Window

Jump to

Keyboard shortcuts

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