Views and control flow
How a screen is built, broken into pieces, and driven by what the app holds.
Writing views
View answers one element. An element is a value: the constructor
takes what the element cannot do without — the text of a Text, the
label of a Button, the children of a Column — and every other
property is a method on it, so a chain reads the way a keyword list
would:
Text("Badges").Size(20).Bold(true)
Button("save").Width(120).Background("#313244")
Column(a, b, c).Spacing(12).Padding(16)
Nothing is written to the engine until it asks for the tree, so a view can build pieces in any order and hand them on. A piece of the screen with a name is a method that answers an element, and one that wraps other elements takes them as arguments:
package main
import (
"fmt"
. "github.com/i2y/yokan/gomamochi"
)
type Cards struct {
a, b int
}
func (c *Cards) card(title string, kids ...Element) Element {
cells := []Element{Text(title).Size(18)}
cells = append(cells, kids...)
return Column(cells...).Spacing(4).Padding(8).
BorderWidth(1).BorderColor("accent").BorderRadius(8)
}
func (c *Cards) View() Element {
return Column(
c.card("counters",
Row(Text(fmt.Sprintf("a: %d", c.a)), Button("+1").OnClick(func() { c.a += 1 })).Spacing(6),
Row(Text(fmt.Sprintf("b: %d", c.b)), Button("+10").OnClick(func() { c.b += 10 })).Spacing(6)),
Text("outside the card").Size(12),
).Spacing(10).Padding(16)
}
func main() {
Run(&Cards{}, Title("cards"))
}
A view only reads. It is built again from the same state whenever
anything changes, so it may not write a field, start a goroutine, read
the clock, the environment, a file, the keyboard or a random number,
or start work: check names each of those with the line, and a
handler or a timer is where they belong. A function that answers an
Element, and a closure handed a *Painter, are views too; the
closures on buttons and fields are handlers, and are where writing
happens.
Control flow in a view
A view is ordinary Go: if, switch, a loop that appends to a slice
of elements, and a method that answers part of the screen.
package main
import (
"fmt"
. "github.com/i2y/yokan/gomamochi"
)
type Control struct {
items []string
picked int
showHint bool
tab int
}
func (c *Control) line(name string, i int) Element {
label := " " + name
if i == c.picked {
label = "▸ " + name
}
return Row(Text(label), Button(fmt.Sprintf("pick %d", i)).OnClick(func() { c.picked = i })).Spacing(8)
}
func (c *Control) View() Element {
kids := []Element{Text("control flow").Size(18).Bold(true)}
if c.showHint {
kids = append(kids, Text("pick one").Size(12).Color("#8a8f98"))
} else {
kids = append(kids, Text("hidden").Size(12))
}
for i, name := range c.items {
kids = append(kids, c.line(name, i))
}
if c.picked >= 0 {
kids = append(kids, Text(fmt.Sprintf("picked %s", c.items[c.picked])).Color("accent"))
}
tabs := []Element{Button("hint").OnClick(func() { c.showHint = !c.showHint })}
for n := 0; n < 3; n++ {
tab := n
tabs = append(tabs, Button(fmt.Sprintf("tab %d", n)).OnClick(func() { c.tab = tab }))
}
kids = append(kids, Row(tabs...).Spacing(6), Text(fmt.Sprintf("tab %d", c.tab)))
return Column(kids...).Spacing(10).Padding(14)
}
func main() {
Run(&Control{items: []string{"milk", "eggs", "rice"}, picked: -1, showHint: true}, Title("control"))
}
The tab := n inside that loop is not needed in Go 1.22 or newer,
where every iteration has a variable of its own, and Gomamochi keeps
that rule in both runs: the interpreter still has the older one, so
before it reads a file, every loop variable a closure captures is given
a copy of its own at the top of the body, on the same line. Write the
loop the way you would for the compiler. The one shape refused is a
three-clause loop that assigns to its own variable inside the body
while a closure captures it, because the copy would hide the
assignment from the loop.
Form controls
The controls a person changes, and what each hands its handler:
| Element | Handler | What it carries |
|---|---|---|
Checkbox(label), Switch(label) |
OnChange(func(bool)) |
the new state |
Slider(), NumberField(value) |
OnChange(func(float64)) |
the new number |
IntField(value) |
OnChange(func(int)) |
the new whole number |
Select(), RadioGroup(), Segmented(), TabBar() |
OnChange(func(int)) |
the chosen index |
TextField(value) |
OnChange(func(string)), OnSubmit(func(string)) |
the text |
Button(label) |
OnClick(func()) |
nothing |
Table(…) |
OnSelect(func(int)), OnSort(func(int)) |
the row, the column |
package main
import (
"fmt"
. "github.com/i2y/yokan/gomamochi"
)
type Forms struct {
dark bool
volume float64
fruits []string
fruit int
note string
}
func (f *Forms) View() Element {
return Column(
Checkbox("Dark mode").Checked(f.dark).OnChange(func(on bool) { f.dark = on }),
Slider().Value(f.volume).Min(0).Max(10).Step(1).OnChange(func(v float64) { f.volume = v }),
Select().Options(f.fruits...).Selected(f.fruit).OnChange(func(i int) { f.fruit = i }),
TextField(f.note).Placeholder("notes").Multiline(true).Rows(3).OnChange(func(t string) { f.note = t }),
Text(fmt.Sprintf("dark=%v vol=%.1f fruit#%d", f.dark, f.volume, f.fruit)),
).Spacing(10).Padding(14)
}
func main() {
Run(&Forms{volume: 5, fruits: []string{"apple", "banana", "cherry"}}, Title("forms"))
}
A field shows what the app holds and nothing else. Type into a
TextField whose OnChange does not write the field back, and the
next build shows the old value again — which is the point: the app's
fields are the one truth, and a control is a view of them.
Handlers
A handler is a Go closure, typed by what the event carries: func()
for a click, func(string) for text, func(bool), func(int) or
func(float64) for the rest. It closes over the app through the
pointer receiver, so c.count += 1 inside one is the whole of the
update, and the view is built again afterwards. A method value does
the same thing with a name:
Handlers run on the window's thread, one at a time, between builds. A
handler that would take a while — a fetch, a query over a large table,
a dialog that waits for a person — belongs in a Task, which is what
Timers and work off the window's thread
is about.
Lists, charts, and rows built on demand
A ListView is asked for the rows in view, not for all of them: the
count, and a function from a row number to its element.
package main
import (
"fmt"
. "github.com/i2y/yokan/gomamochi"
)
type Todo struct {
items []string
draft string
done int
}
func (t *Todo) add(s string) {
t.items = append(t.items, s)
t.draft = ""
}
func (t *Todo) line(i int) Element {
cells := []Element{Text(fmt.Sprintf("%d. %s", i+1, t.items[i]))}
if i == t.done {
cells = append(cells, Text("done").Color("accent"))
}
cells = append(cells, Button("done").OnClick(func() { t.done = i }))
return Row(cells...).Spacing(8)
}
func (t *Todo) View() Element {
return Column(
Text(fmt.Sprintf("todo — %d items", len(t.items))).Size(16),
TextField(t.draft).Placeholder("add and press enter").
OnChange(func(s string) { t.draft = s }).OnSubmit(t.add),
ListView(len(t.items), t.line).ItemHeight(26).Height(280),
Button("clear").OnClick(func() { t.items = nil }),
).Spacing(10).Padding(14)
}
func main() {
Run(&Todo{items: []string{"milk"}, done: -1}, Title("todo"))
}
Table(columns, count, row) is the same idea with a header, tracks
whose shares are Widths, a Selected row, and Sort and
Descending the app keeps, with OnSelect and OnSort telling it
which row and which column a person chose. DataTable is the simpler
one: its first child is the header, the rest are rows, and the frame
comes with it.
Charts take their numbers as their first argument. BarChart(data)
and LineChart(data) share Labels, Axis, Min and Max (both
zero takes the range from the data), and Series draws several lines
or groups with one color each from Colors:
BarChart(profit).Labels(months...).Axis(true).Height(150)
LineChart(nil).Series([][]float64{requests, errors}).Colors("accent", "#f38ba8").Max(90).Height(150)
Moving the pointer over a chart shows the value under it: the bar's
or the sample's label and one number per series. A script puts the
pointer there with hover:<i> and takes it away with hover:, and
the dump carries the readout, so what a person sees on hover is
checked the way a click is.
Maps
A map is a field like any other: read a key with the second result, count it, add to it in a handler.
package main
import (
"fmt"
. "github.com/i2y/yokan/gomamochi"
)
type Lookup struct {
prices map[string]int
picked int
label string
}
func (l *Lookup) price(name string, fallback int) int {
if v, ok := l.prices[name]; ok {
return v
}
return fallback
}
func (l *Lookup) View() Element {
return Column(
Text(fmt.Sprintf("picked=%d n=%d %s", l.picked, len(l.prices), l.label)),
Row(
Button("apple").OnClick(func() { l.picked = l.price("apple", -1) }),
Button("cherry").OnClick(func() {
l.prices["cherry"] = 200
l.picked = l.price("cherry", -1)
l.label = "cherry known"
}),
).Spacing(6),
).Spacing(8).Padding(12)
}
func main() {
Run(&Lookup{prices: map[string]int{"apple": 120}, label: "none"}, Title("lookup"))
}
What a view may not do is range over one. Go walks a map in a
different order every run, so a view that did would draw a different
screen in the two runs — and the gate would say so, on some runs.
check refuses it with the line. A handler may range over a map,
which is how it collects the keys to sort them; what it then keeps on
the app, a sorted slice, is what the view reads.
Value structs
A struct of your own is a value the app holds, and a slice of them is a list of values. The games rebuild theirs every tick and hand the new slice to the field; nothing needs to be marked, shared or observed.
package main
import (
"fmt"
. "github.com/i2y/yokan/gomamochi"
)
type Point struct {
x, y int
}
type Points struct {
sel Point
dist int
}
func (p *Points) View() Element {
return Column(
Text(fmt.Sprintf("p=(%d, %d) d2=%d", p.sel.x, p.sel.y, p.dist)),
Row(
Button("right").OnClick(func() { p.sel = Point{p.sel.x + 5, p.sel.y} }),
Button("swap").OnClick(func() { p.sel = Point{p.sel.y, p.sel.x} }),
Button("measure").OnClick(func() { p.dist = p.sel.x*p.sel.x + p.sel.y*p.sel.y }),
).Spacing(6),
).Spacing(8).Padding(12)
}
func main() {
Run(&Points{sel: Point{3, 4}}, Title("points"))
}
A value that may be nothing at all is a pointer that may be nil, and objects that point at one another are pointers both ways: Go's collector takes a cycle in its stride, so a tree is written the way it reads.