| name | using-guigui |
| description | Use this skill when writing or modifying GUI code with the Guigui framework (github.com/guigui-gui/guigui): creating or editing a widget, composing a layout, wiring buttons / text inputs / lists, passing shared state down the widget tree, handling pointer or keyboard input, or figuring out why a change to a field does not show up on screen. Covers the widget lifecycle (Build / Layout / Measure / Draw / input), LinearLayout sizing, Env-based dependency injection, the event-key callback pattern, dynamic child lists, and when state changes trigger a rebuild. Not for general Ebitengine rendering questions that do not involve Guigui widgets.
|
Using Guigui
Guigui is an immediate-mode-inspired GUI framework for Go on top of Ebitengine.
"Inspired" is the key word: widgets are retained Go structs that you own and
keep across ticks, but their child tree and presentation are reconstructed
by re-running Build whenever relevant state changes — the way Compose or
SwiftUI re-run a view function. You hold the state in struct fields; the
framework decides when to rebuild, lay out, and redraw.
Read this whole file before writing widget code; the lifecycle rules below are
the part people get wrong.
Freshness. Verified against Guigui at commit c1ffb301 (2026-07-10).
Guigui is alpha and its API may change — if anything here disagrees with the
source, the source wins: trust *.go in the module root and the programs
under example/ over this file, and update this skill when you find drift.
The mental model
- A widget is a struct that embeds
guigui.DefaultWidget and overrides the
methods it needs. DefaultWidget supplies no-op defaults for every method, so
override only what matters.
- A widget must work from its zero value — declare children as plain
(non-pointer) fields and reference them as
&w.child. Do not allocate them in
a constructor; var w Foo must already be usable.
- The framework runs two decoupled cycles. Each tick (Ebitengine's
Update,
at the app's TPS — 60×/sec by default, or whatever TPS is configured) first
settles the tree: it may run Build (reconstruct the child tree),
Layout (position the children), and the Handle*Input methods before
calling Tick exactly once. A state change during input can cause another
Build/Layout pass in the same tick before Tick runs. Rely only on these
guarantees: Build precedes Layout within a pass, input sees the current
layout, and Tick runs once, after input handling. Do not assume a fixed count
of Build or Layout passes per tick. Each frame (Ebitengine's Draw, at
the display's refresh rate) does Draw. Ticks and frames are not
one-to-one — there may be more or fewer frames than ticks — so put per-step
logic in Tick, never in Draw.
- You never invoke a widget's framework-driven methods (
Build, Layout,
Tick, Draw, the Handle*Input methods, Env, …) yourself — you set fields
and register handlers, and the framework calls back. The one exception is
Measure: a parent or composite widget calls a child's Measure directly
to size it (it is what LinearLayout and the shared layout() helper do).
Minimal application
package main
import (
"image"
"log"
"github.com/guigui-gui/guigui"
"github.com/guigui-gui/guigui/basicwidget"
)
type Root struct {
guigui.DefaultWidget
background basicwidget.Background
hello basicwidget.Text
}
func (r *Root) Build(context *guigui.Context, adder *guigui.ChildAdder) error {
adder.AddWidget(&r.background)
adder.AddWidget(&r.hello)
r.hello.SetValue("Hello, Guigui")
return nil
}
func (r *Root) Layout(context *guigui.Context, widgetBounds *guigui.WidgetBounds, layouter *guigui.ChildLayouter) {
layouter.LayoutWidget(&r.background, widgetBounds.Bounds())
layouter.LayoutWidget(&r.hello, widgetBounds.Bounds())
}
func main() {
if err := guigui.Run(&Root{}, &guigui.RunOptions{
Title: "Example",
WindowMinSize: image.Pt(480, 320),
}); err != nil {
log.Fatal(err)
}
}
guigui.Run(root, *RunOptions) starts the app. RunOptions carries Title,
WindowSize / WindowMinSize / WindowMaxSize, WindowFloating, AppScale,
and an optional RunGameOptions passed through to Ebitengine.
The Widget interface
Embed guigui.DefaultWidget, then override as needed:
| Method | Signature | Override when |
|---|
Build | Build(*Context, *ChildAdder) error | Almost always — declare and configure children. |
Layout | Layout(*Context, *WidgetBounds, *ChildLayouter) | You have children to position (i.e. almost always). |
Measure | Measure(*Context, Constraints) image.Point | The widget has an intrinsic preferred size (list rows, leaf widgets). |
HandlePointingInput | HandlePointingInput(*Context, *WidgetBounds) HandleInputResult | Custom mouse/touch handling. |
HandleButtonInput | HandleButtonInput(*Context, *WidgetBounds) HandleInputResult | Custom keyboard/gamepad handling — only delivered under focus/receptiveness conditions (see "Handling input directly"). |
Env | Env(*Context, EnvKey, *EnvSource) (any, bool) | The widget provides shared values to descendants. |
WriteStateKey | WriteStateKey(*Context, *StateKeyWriter) | State changes outside input/events must trigger a rebuild (see below). |
Tick | Tick(*Context, *WidgetBounds) error | Per-tick updates (animation, timers); runs at the app's TPS. |
Draw | Draw(*Context, *WidgetBounds, *ebiten.Image) | Custom rendering (most widgets compose children instead). |
CursorShape | CursorShape(*Context, *WidgetBounds) (ebiten.CursorShapeType, bool) | The widget wants a non-default cursor. |
Build vs. Layout — the rule that matters most
Build runs before any bounds are known. Use it to add children
(adder.AddWidget(&w.child)) and to push current state into them
(SetText, SetEnabled, registering handlers). Build is re-run on every
rebuild, so it must be idempotent — write it as "given my current state,
configure my children," not "do this once."
- Conventional order inside
Build: add first, configure second. Call all
the adder.AddWidget(...) you need, then push state into the children with
Set* / On*. Configuring a child you did not add is harmless, so you do not
need to guard every setter behind the same condition as its AddWidget —
unconditionally configuring for simplicity is fine and idiomatic.
- Only add children you actually use this rebuild. Skip the
AddWidget for a
child that is not currently shown (e.g. the contents of a closed popup, a
collapsed panel, a hidden tab). A widget that is not added is not laid out,
drawn, or sent input, which is cheaper than adding it and hiding it. popup
is the canonical example — it only adds its background/shadow/content while it
is opening or open, yet still runs their setters unconditionally afterward.
Layout runs after Build, with bounds known. Use it only to position
children via layouter.LayoutWidget(child, bounds) or a layout helper. Do not
read bounds in Build; do not configure widget content in Layout.
widgetBounds.Bounds() gives this widget's own rectangle inside Layout,
input, Tick, and Draw. A WidgetBounds only ever describes the widget it
was handed to — there is no API to ask it for another widget's bounds. If a
parent needs to size or place a child relative to a sibling, drive that from the
layout (LinearLayout / layouter), not by trying to read the sibling's
rectangle.
widgetBounds.VisibleBounds() gives the part left after ancestor clipping.
Use Bounds() for layout coordinates and VisibleBounds() to cull custom
drawing. For ordinary cursor hit testing, use widgetBounds.IsHitAtCursor();
it accounts for clipping and higher-layer occlusion. Use VisibleBounds()
geometrically only for an additional custom-shape check or a point other than
the cursor. In Draw, dst is a clipped subimage whose coordinates are still
screen coordinates, so draw at Bounds().Min (or other screen-space
coordinates), not at an assumed (0, 0) origin.
Layout with LinearLayout
LinearLayout is the workhorse. Build a slice of LinearLayoutItem, then call
LayoutWidgets. Reuse the backing slice across ticks with slices.Delete(s, 0, len(s)) to avoid per-tick allocation (the framework calls Layout often).
func (w *Panel) Layout(context *guigui.Context, widgetBounds *guigui.WidgetBounds, layouter *guigui.ChildLayouter) {
u := basicwidget.UnitSize(context)
w.items = slices.Delete(w.items, 0, len(w.items))
w.items = append(w.items,
guigui.LinearLayoutItem{Widget: &w.header, Size: guigui.FixedSize(u)},
guigui.LinearLayoutItem{Widget: &w.body, Size: guigui.FlexibleSize(1)},
guigui.LinearLayoutItem{Widget: &w.footer},
)
(guigui.LinearLayout{
Direction: guigui.LayoutDirectionVertical,
Items: w.items,
Gap: u / 2,
Padding: guigui.Padding{Start: u / 2, Top: u / 2, End: u / 2, Bottom: u / 2},
}).LayoutWidgets(context, widgetBounds.Bounds(), layouter)
}
Sizing per item (LinearLayoutItem.Size):
guigui.FixedSize(px) — exact pixel length along the layout direction.
guigui.FlexibleSize(weight) — shares the leftover space proportionally to
the weight (after fixed/intrinsic items are placed). A bare
LinearLayoutItem{Size: guigui.FlexibleSize(1)} with no Widget is a spacer.
- Zero
Size{} — the item is sized by the widget's own Measure
(intrinsic size).
Nesting: an item can carry a sub-layout instead of a widget via
LinearLayoutItem{Size: ..., Layout: &subLayout} where subLayout is another
guigui.LinearLayout. The cross-axis always fills the available extent.
Size things in multiples of basicwidget.UnitSize(context) rather than raw
pixels so layouts scale correctly on Hi-DPI and at different app scales.
Share one layout between Layout and Measure
A composite widget usually needs the same LinearLayout in two places:
Layout feeds it to LayoutWidgets, and Measure asks it for a size. Build it
once in an unexported helper — conventionally named layout — and call that
helper from both. The structure then has a single source of truth, and Measure
can never drift from what Layout actually does:
func (w *Field) layout(context *guigui.Context) guigui.LinearLayout {
u := basicwidget.UnitSize(context)
w.items = slices.Delete(w.items, 0, len(w.items))
w.items = append(w.items,
guigui.LinearLayoutItem{Widget: &w.input, Size: guigui.FlexibleSize(1)},
guigui.LinearLayoutItem{Widget: &w.button, Size: guigui.FixedSize(u)},
)
return guigui.LinearLayout{
Direction: guigui.LayoutDirectionHorizontal,
Gap: u / 4,
Items: w.items,
}
}
func (w *Field) Layout(context *guigui.Context, widgetBounds *guigui.WidgetBounds, layouter *guigui.ChildLayouter) {
w.layout(context).LayoutWidgets(context, widgetBounds.Bounds(), layouter)
}
func (w *Field) Measure(context *guigui.Context, constraints guigui.Constraints) image.Point {
return w.layout(context).Measure(context, constraints)
}
LinearLayout is a plain value, so returning one by value is cheap; the helper
still reuses the items slice across ticks as before.
Composition and built-in widgets
Compose by embedding child widgets as fields and adding them in Build. The
basicwidget package provides the standard catalog — Background, Button,
Text, TextInput, NumberInput, Checkbox, Toggle, RadioButton,
Slider, Select/Combobox, List[T], Table[T], Panel, Popup,
PopupMenu, ContextMenuArea[T], SegmentedControl, Form, Image,
Divider, Expander, and more. Configure them with their Set* methods in
Build and subscribe to their On* callbacks.
r.createButton.SetText("Create")
r.createButton.OnDown(func(context *guigui.Context) {
r.addItem(r.textInput.Value())
})
context.SetEnabled(&r.createButton, r.canAdd())
For button actions, default to OnDown; reach for OnUp only when release
semantics genuinely matter.
Use guigui.WidgetWithSize[*T] or guigui.WidgetWithPadding[*T] when a child
only needs an intrinsic-size override or padding. These wrappers lazily create
their zero-value child, forward layout and focus, and expose it through
Widget(). Use guigui.LayerWidget[*T] for floating content that must escape
ancestor clipping or sit above other input targets: add and lay out the wrapper,
then call BringToFrontLayer(context) during Build. Higher layers draw and
receive input first.
A widget instance can have only one parent in a build. Do not reuse the same
content widget in two lists, a source view and a popup, or any other two places;
create distinct widget instances that render the same data. Sharing the model is
fine, but sharing the widget corrupts its parent chain, env lookup, bounds, and
focus/input behavior.
Keep in-progress edits separate from model synchronization
For an editable basicwidget.TextInput, call SetValue(modelValue) during
Build as usual. It preserves an active user edit instead of overwriting the
live buffer on every whole-tree rebuild. Reserve ForceSetValue for an explicit
replacement/reset: it immediately replaces the buffer and can synchronously
invoke OnValueChanged as a committed change. If a forced seed must not be
treated as user input, guard the callback with a short-lived seeding flag.
When replacing a subtree or switching records, commit or deliberately discard
active edits before resetting the widget. Otherwise the model and the retained
editing buffer can silently diverge.
For the full list and runnable demos, read basicwidget/ and the programs under
example/ (start with example/counter and example/todo; example/gallery
exercises most widgets).
Passing shared state down: Env
Env is Guigui's dependency injection. A value provided by an ancestor is
visible to every descendant without threading it through constructors. Generate
a key once at package scope:
var envKeyModel = guigui.GenerateEnvKey()
func (r *Root) Env(context *guigui.Context, key guigui.EnvKey, source *guigui.EnvSource) (any, bool) {
switch key {
case envKeyModel:
return &r.model, true
default:
return nil, false
}
}
func (c *Child) Build(context *guigui.Context, adder *guigui.ChildAdder) error {
v, ok := context.Env(c, envKeyModel)
if !ok {
return nil
}
model := v.(*Model)
return nil
}
context.Env(self, key) walks up the parent chain calling each ancestor's Env
until one returns true. Use it for app-wide models / view-state; use explicit
fields and setters for parent→direct-child wiring.
Env is not only for your own values — basicwidget itself uses it to push
presentation state down to descendant widgets. For example a List advertises
its item color scheme through basicwidget.EnvKeyListItemColorType, and a row
inside the list reads it with context.Env(self, basicwidget.EnvKeyListItemColorType)
to draw itself correctly. So when composing a custom widget that lives inside a
built-in container, check whether that container exposes an EnvKey… you should
honor.
Env lookups on a just-created widget silently find nothing
context.Env(widget, key) resolves by walking up widget's parent pointers,
and a widget only gets its parent pointer when some ancestor's Build adds it
with adder.AddWidget. A widget that no Build has added yet has no parent, so
the lookup returns (nil, false) — no panic, no error, just a missing value.
Two consequences:
- On yourself, inside your own
Build / Layout, env lookups are safe. By
the time your Build runs, the parent's Build has already added you, so
your parent chain is intact.
- A parent's
Build must not resolve env through a child that may have
just been created. The classic sequence: an add-item handler grows a
WidgetSlice pool and selects the new element. On the very next Build of
the owner, that element is still unparented — it only gains a parent later in
the same build pass, when the container hosting it builds. Any env lookup
routed through it fails, and code assuming a non-nil result panics.
w.addButton.OnDown(func(context *guigui.Context) {
w.rowContents.SetLen(w.rowContents.Len() + 1)
w.selectedRow = w.rowContents.Len() - 1
})
content := w.rowContents.At(w.selectedRow)
v, _ := context.Env(content, envKeyModel)
model, _ := v.(*Model)
Resolve shared state through the widget whose Build is running — its own env
is always available there — and hand data to children via their plain fields:
v, ok := context.Env(w, envKeyModel)
if !ok {
return nil
}
model := v.(*Model)
If a widget keeps a helper that resolves a model via its own env (e.g.
func (c *Content) model(context) *Model built on context.Env(c, …)), make it
return nil when the lookup fails and guard at every call site — the lookup can
legitimately fail while the widget is not (yet) in the tree.
Sending events up: the event-key pattern
A child should not know its parent's type. To notify the parent, declare an
EventKey, expose an On… setter, and dispatch:
var eventItemDeleted = guigui.GenerateEventKey()
func (w *Item) OnDeleted(f func(context *guigui.Context, id int)) {
guigui.SetEventHandler(w, eventItemDeleted, f)
}
func (w *Item) Build(context *guigui.Context, adder *guigui.ChildAdder) error {
adder.AddWidget(&w.deleteButton)
w.deleteButton.SetText("Delete")
w.deleteButton.OnDown(func(context *guigui.Context) {
guigui.DispatchEvent(w, eventItemDeleted, w.id)
})
return nil
}
The parent calls child.OnDeleted(func(context, id){ ... }) during its own
Build. Handlers are cleared and re-registered on every rebuild, so always
(re)register inside Build. This is exactly how basicwidget buttons expose
OnDown and OnUp.
Re-registering a handler does not require allocating a new closure each time.
In a large tree, keep hot handlers in widget fields, initialize them once when
nil, and pass the same function back to On... during every Build. A cached
closure should capture only the stable widget receiver (or another stable key):
never retain the Context argument or a Build-local model/item whose meaning can
change. Use the context passed to the callback and resolve current data from the
receiver or Env when the event actually runs.
Dynamic lists of children
For a variable number of children, use guigui.WidgetSlice[*T]: set its length
to match the data, then add and configure each element.
func (l *List) Build(context *guigui.Context, adder *guigui.ChildAdder) error {
n := l.model.Count()
l.rows.SetLen(n)
for i := range n {
adder.AddWidget(l.rows.At(i))
}
for i := range n {
item := l.model.At(i)
l.rows.At(i).SetText(item.Text)
l.rows.At(i).OnActivated(func(context *guigui.Context) {
l.model.Activate(item.ID)
})
}
return nil
}
Use WidgetSlice, not a hand-rolled []Row you append to. A widget's
identity is the address of its embedded DefaultWidget, and Guigui forbids
moving or copying a widget by value — it panics with "illegal use of
DefaultWidget copied by value". Appending to a value slice reallocates and
relocates its elements, which trips that guard or silently churns widget
identities (losing each row's focus, scroll position, and animation state).
WidgetSlice[*T] keeps every element at a stable address across SetLen, so
identity survives a resize.
Give the row a Measure returning its intrinsic height so the parent layout can
size it; lay rows out with FixedSize(rowHeight) items in a vertical
LinearLayout. (basicwidget.List[T] / Table[T] handle scrolling lists for
you — prefer them for large collections.)
WidgetSlice preserves widget addresses, not the logical identity of the data
shown in each slot. For filterable or reorderable data, track selection and
per-item state by a stable item value/key, reconfigure every slot from current
data in Build, and validate the selection after deletion. A row index can
start referring to a different item after a rebuild.
Resetting a widget's cached state
A widget accumulates state that is not re-derived from its inputs each Build —
a selected index, a scroll offset, a cache keyed by IDs, an in-progress edit.
When the data behind it is swapped wholesale (a document reloaded, a different
record opened), that state points at the old data and won't self-heal. Drop it by
assigning the widget its zero value, at the top of Build, before re-adding
the child:
func (r *Root) Build(context *guigui.Context, adder *guigui.ChildAdder) error {
if doc := r.model.Document(); doc != r.lastDoc {
r.lastDoc = doc
r.editor = Editor{}
}
}
This is safe as far as the framework is concerned: every widget must be usable
from its zero value, so the field keeps its address, copyCheck re-pins, identity
holds, and the next Build re-adds and reconfigures the fresh child. It drops only
in-memory state, though — a widget that owns an external resource (a live
connection, a goroutine, an open file) must release it before zeroing, since
Guigui has no teardown hook and a bare reset would leak it. Key the trigger on
object identity (or a generation counter), not a name/path, so a reload that
yields a fresh object at the same path still fires.
Reset in Build, not Tick: Tick runs after that tick's Layout, so a tree
change there is inconsistent with the frame's already-computed layout for one
Draw. And never zero the application root widget's own struct this way — the
framework marks the root once at startup and never re-establishes it, so wiping
its embedded DefaultWidget unmarks it. Reset the root's children, not the
root value itself.
State changes and when the screen updates
This is the second thing people get wrong. Tree/content changes need a rebuild;
paint-only changes need only a redraw.
Rebuilds happen when:
- an input handler or event handler runs (the framework assumes it may have
mutated state),
- a widget's state key changes (see
WriteStateKey), or
- you explicitly call
guigui.RequestRebuild().
A rebuild is whole-tree, never scoped. Any of these re-runs Build on
every widget from the root — there is no per-widget or per-subtree rebuild.
RequestRebuild() takes no argument; a changed state key likewise rebuilds the
whole tree, not just the widget that owns the key. So place a WriteStateKey
on whichever widget owns the self-changing state — a different widget
that renders from it still re-runs its own Build and reflects the change, so
the key need not live on the widget that displays it.
A rebuild re-runs Build; it does not repaint by itself. The pixels that
get redrawn come from state-key changes (which auto-repaint the changed
widget's region), from tree/layout diffs, or from an explicit RequestRedraw.
A bare RequestRebuild() seeds no redraw region, so for a paint-only change
that alters a widget's appearance without changing its bounds or a state key,
pair it with guigui.RequestRedraw(widget).
So a counter mutated inside a button's OnUp updates with no extra work. But a
field mutated outside any handler — from a Tick, a goroutine result
applied on the main goroutine, or an ancestor reacting to an external model —
will not repaint unless you do one of:
-
Implement WriteStateKey to hash the state that should drive rebuilds.
The framework re-hashes after each build and rebuilds when the hash changes:
func (w *Foo) WriteStateKey(context *guigui.Context, s *guigui.StateKeyWriter) {
s.WriteInt(w.count)
s.WriteBool(w.open)
s.WriteString(w.title)
}
StateKeyWriter has WriteBool, WriteInt/WriteUint (and sized
variants), WriteFloat32/64, WriteString, WriteWidget, and raw Write.
Writing nothing opts out (the default).
-
Call guigui.RequestRebuild() at the mutation site if you do not want to
maintain a state key. It re-runs Build but does not repaint on its own, so
for a paint-only change also call guigui.RequestRedraw(widget).
guigui.RequestRedraw(widget) forces a repaint without a rebuild — use it
only when nothing in the tree structure changed (e.g. an animation frame).
A state key rebuilds only when its hashed bytes change, so a value that holds
steady across ticks (e.g. an isPlaying flag that stays true for the whole
animation) costs nothing per tick. Pair the two: drive the per-frame animation
with RequestRedraw each tick, and let a WriteStateKey fire the one rebuild
on the on/off transition (when the animation finishes and a button must flip
from stop back to play).
For a large mutable model, prefer hashing a cheap monotonic generation counter
in WriteStateKey over serializing every collection. Increment the generation
for every mutation that affects the UI. State deliberately omitted from the key
still needs an explicit RequestRebuild at its mutation sites.
Context utilities
*guigui.Context (passed to most methods) also exposes per-widget state setters,
all called from Build:
context.SetEnabled(w, bool) / IsEnabled(w)
context.SetVisible(w, bool) / IsVisible(w)
context.SetFocused(w, bool) / IsFocused(w)
context.SetOpacity(w, float64)
Handling input directly
Override HandlePointingInput / HandleButtonInput and return a result:
func (w *Foo) HandlePointingInput(context *guigui.Context, widgetBounds *guigui.WidgetBounds) guigui.HandleInputResult {
if widgetBounds.IsHitAtCursor() && inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
return guigui.HandleInputByWidget(w)
}
return guigui.HandleInputResult{}
}
Pointer/button input is delivered post-order (innermost/topmost widget
first), so a child can consume an event before its ancestors. Return
guigui.HandleInputByWidget(w) to consume, guigui.AbortHandlingInputByWidget(w)
to stop propagation without claiming the event, or the zero
guigui.HandleInputResult{} to pass. widgetBounds.IsHitAtCursor() reports
whether this widget is the topmost one under the cursor.
HandleButtonInput is not delivered to every widget. It runs only when the
widget is focused, has a focused ancestor or descendant, or is itself marked
button-input-receptive with context.SetButtonInputReceptive(w, true). Focus
lights up a whole path — the focused widget, its ancestors, and its descendants
all receive button input. Receptiveness is strictly self-only: it grants
delivery to that one widget, not to its ancestors or descendants (the framework
traverses through ancestors only to reach the receptive widget). Overriding
HandleButtonInput on a widget that is never focused and never marked
receptive silently does nothing — focus the widget (context.SetFocused) or
mark it receptive.
Rich content nested inside a button or list item can intercept pointing input
before the interactive parent. If that content is decorative, call
context.SetPassthrough(content, true) in Build; the decorative subtree stops
receiving input, allowing the container to handle the click.
A visually modal popup does not automatically isolate keyboard shortcuts. Make
its content button-input-receptive, handle intended keys such as Escape, and
return AbortHandlingInputByWidget for the rest so input cannot leak to widgets
or global shortcut handlers behind it.
Checklist when adding a widget
- Embed
guigui.DefaultWidget; keep children as plain fields; ensure the zero
value is usable.
- In
Build: first add the children you use this rebuild with
adder.AddWidget(&w.child) (skip ones that are not shown, like a closed
popup's contents), then configure children from current state (idempotently).
Register On… handlers here. Configuring a child you did not add is fine.
- In
Layout: position children with a LinearLayout (reuse the items slice)
or layouter.LayoutWidget. Size in UnitSize multiples.
- Add
Measure if the widget has an intrinsic size (especially list rows). For
a composite, share one layout() helper between Layout and Measure.
- Read shared models via
context.Env; bubble events up with a generated
EventKey + On… setter + DispatchEvent.
- If a field can change outside input/event handlers, expose it via
WriteStateKey or call RequestRebuild when you mutate it (add
RequestRedraw too for a paint-only change).
Verify your work — do not trust this file alone
This skill is a primer; it does not enumerate every widget method and it can
drift from an alpha API. Before considering a change done:
- Look up real signatures, don't guess. When you need a
basicwidget
method (SetText, OnUp, a Set*/On* you half-remember), search the actual
package: rg -n "func \\(.*Button\\)" basicwidget/. The catalog here is
intentionally not exhaustive.
- Copy from a working example.
example/counter (state + buttons),
example/todo (Env, events, dynamic list), and example/gallery (most
widgets) are canonical, compiling usage. Prefer adapting them to inventing.
- Build and vet.
go build ./... and go vet ./.... Guigui code that
misuses the lifecycle often still compiles, so also run the program (or the
relevant example/) and confirm it renders and reacts.
- Sanity-check the lifecycle, not just the compile. If a change does not
show up, re-read "State changes and when the screen updates" — a clean build
with a stale screen is the signature of a missing rebuild trigger.
- A reduced repro passing is not proof the real app works. A widget hosted
in an isolated debug harness can render and behave correctly while the full
application still fails: popups, overlay layers, the surrounding shell, and
real pointer-input timing all differ. Confirm a UI fix in the actual app, not
only in a harness.
Common pitfalls
- Reading bounds in
Build. Bounds are unknown there. Move it to Layout.
- Configuring content in
Layout. Setters belong in Build; Layout only
positions.
- Registering handlers once / outside
Build. Handlers are wiped each
rebuild — register them in Build every time.
- Resetting a widget's cached state in
Tick. Zeroing a child to drop stale
state belongs in Build (the rebuild boundary); Tick runs after Layout,
so a tree change there is inconsistent with the frame's layout for one Draw.
Never zero the application root's own struct (see "Resetting a widget's cached
state").
- Expecting a field write to repaint. Only handler-driven or
state-key-driven changes auto-rebuild; otherwise call
RequestRebuild (plus
RequestRedraw if the change is paint-only).
- Allocating a fresh items slice every
Layout. Reuse with
slices.Delete(s, 0, len(s)); Layout runs frequently.
- Hard-coded pixel sizes. Use
basicwidget.UnitSize(context) so layouts
scale with DPI and app scale.
- Holding children in a plain value slice.
append to a []Row moves its
elements, which churns widget identity or panics. Use guigui.WidgetSlice[*T]
for a variable number of children (see "Dynamic lists of children").
- Reusing one widget in multiple containers. A widget has one parent chain.
Use distinct widget instances backed by shared data.
- Forcing a text input's value on every Build.
ForceSetValue overwrites an
active edit and can synchronously emit a committed change. Use SetValue for
normal model synchronization and force only explicit resets.
- Letting decorative content consume its container's click. Mark rich
button/list content passthrough so the interactive ancestor handles input.
- Resolving env through a widget that was just created. A widget nobody has
built yet has no parent, so the lookup silently returns
(nil, false) and code
assuming a non-nil value crashes. Resolve shared state through the widget whose
Build is running, not through a possibly-new child (see "Env lookups on a
just-created widget silently find nothing").
- Overriding
HandleButtonInput on a never-focused widget. It reaches only
focused widgets (plus their ancestors/descendants) or button-input-receptive
ones. Focus the widget or call context.SetButtonInputReceptive (see
"Handling input directly").
- Trying to scroll a panel past its content.
basicwidget.Panel clamps the
scroll offset to [min(viewport − content, 0), 0]: positive offsets are
discarded, and when the content fits the viewport the offset stays pinned at
the origin. SetScrollOffset / ForceSetScrollOffset cannot push content to a
positive offset — so e.g. centering only takes effect on an axis that actually
overflows.