Imported from stergiotis/boxer (
doc/skills/imzero2/SKILL.md). Install upstream withnpx skills add stergiotis/boxer --skill imzero2. Copyright stays with the author.
This document provides a technical specification and developer guide for the FFFI-based Immediate Mode GUI (IMZERO2) library. It is designed for Go developers and as a context-injection for LLM agents.
1. Core Architecture: The FFFI Pattern
The library operates on a Framed Foreign Function Interface (FFFI). Unlike standard FFIs that call across language boundaries synchronously, FFFI is optimized for high-frequency, batch-driven UI updates.
- Server-Client Split: The "Server" (Go) contains the business logic and UI description. The "Client" (Presentation Tier) is an interpreter loop that executes rendering and handles low-level input.
- Framed Execution: Commands are not sent individually. Instead, the Go side builds a command buffer for the entire frame. Calling
FinishServersideFrame()flushes this buffer to the client. - State Syncing: Interaction results (clicks, focus, text input) are collected by the Client and synced back to the Server at the start of the next frame (
StartServersideFrame()). - Interpreter Loop: The client-side consumes a linear stream of FFFI instructions, mapping them to draw calls or layout updates.
2. Memory Management
The FFFI architecture avoids the overhead of shared pointers or CGo garbage collection issues through specific strategies:
- Region-Based Mapping: The transport layer typically uses memory-mapped regions where the Go side writes arguments and the Client reads them linearly.
- Retained Holders (
.Keep()):- Fluid builders (e.g.,
AtomsFluid) are transient. - Calling
.Keep()serializes the current configuration into aRetainedFffiHolderTyped[T]. - These holders are "Value Objects"—they contain the data needed for a command and can be reused across frames to reduce Go-side allocations.
- Fluid builders (e.g.,
- No Pointer Sharing: All data passed to the client is copied or serialized. The client does not access Go memory directly.
3. Deterministic ID Management (The XOR Stack)
The library uses a 64-bit XOR-stack to maintain widget identity. Identity is required for the client to know if "Button A" in Frame 1 is the same as "Button A" in Frame 2.
The Formula
$$ID_{effective} = ID_{stack} \oplus ID_{local}$$ Where $\oplus$ is the XOR operator. This allows for efficient namespacing: if you move a group of widgets into a new sub-scope, their relative identities remain stable while their effective IDs change.
ID Categories
| Type | Behavior | Best Use Case |
|---|---|---|
Relative (*WidgetIdStack) |
XORed with the current stack value. | Standard widgets, list items, nested components. |
Absolute (AbsoluteWidgetId) |
Replaces the stack value; ignores parents. | Top-level Windows, Modals, Global Overlays. |
Both types do satisfy the components.WidgetIdCreatorI interface. The WidgetIdStack must be created by the application: |
ids := components.NewWidgetIdStack()
Use of string (absolute) id:
components.Button(c.MakeAbsoluteIdStr("my id"), c.Atoms().Text("button").Keey()).Send()
Use of numerical high-entropy (e.g. hash) (absolute) id:
components.Button(c.MakeAbsoluteIdHighEntropy(0xcaffebabe), c.Atoms().Text("button").Keey()).Send()
Use of numerical low-entropy (e.g. counter) (absolute) id:
components.Button(c.MakeAbsoluteIdSeq(i), c.Atoms().Text("button").Keey()).Send()
Use of string (relative) id:
components.Button(ids.PrepareStr("my id"), c.Atoms().Text("button").Keey()).Send()
Use of numerical high-entropy (e.g. hash) (relative) id:
components.Button(ids.PrepareHighEntropy(0xcaffebabe), c.Atoms().Text("button").Keey()).Send()
Use of numerical low-entropy (e.g. counter) (relative) id:
components.Button(ids.PrepareSeq(i), c.Atoms().Text("button").Keey()).Send()
The id value is carried verbatim — and it is the read-back key
Two properties of the derivation are worth knowing, because response read-back depends on both:
- Derivation is injective. Distinct inputs produce distinct wire ids,
for every creator above. Only one value is rewritten: an id that derives
to exactly
0, which egui rejects (egui::Idis aNonZeroU64), is replaced by a fixed high-entropy stand-in. Adjacent integers (MakeAbsoluteIdHighEntropy(base+0),+1,+2, …) are safe. - An
AbsoluteWidgetId's numeric value is its wire id, souint64(absId)andabsId.Derive()agree. Side tables keyed by an absolute id may use either spelling.
Neither held before 2026-07-30: derivation OR-ed bit 0 into every id, so each even id merged with its odd successor. Losing a bit of a label hash is unobservable, which is why the flaw hid — but for caller-supplied numbers it collapsed adjacent ids in pairs. See §11 "Silent SendResp" for the failure it produced.
Colliding ids fail silently on read-back, not on render
Worth internalising once: egui does not hit-test on the id you supply.
apply_widget calls w.ui(ui), and egui allocates the widget's own
interaction id from ui.next_auto_id(). Your id is used for exactly one
thing — as the key under which the frame's response flags are pushed into
r7 and read back by SendResp / StateManager.GetResponse.
So a duplicate id never breaks rendering or clicking. It breaks only the
read-back, and quietly: Sync compacts duplicate r7 keys newest-wins, so
the later widget keeps the slot and every earlier one reads
NilResponseFlags forever. The symptom is a widget that is visibly
pressed by the click it appears to accept while its handler never runs.
checkId logs id has already been used (WARN, with caller) on the
emitting frame. In a chatty console that line is easy to miss — when a
SendResp never fires, grep the log for it before suspecting anything
else.
Explicit ID Scoping
When the framework cannot derive an ID (e.g., in a loop with identical labels), the user must manage the stack using components.IdScope(id):
for range components.IdScope(ids.PrepareStr("myscope")) {
...
}
4. The Fluid API & Terminal Methods
The API uses a Fluid Builder pattern. Every widget factory returns a "Fluid" type that represents a pending instruction. The instruction is only executed or transformed when a Terminal Method is called.
| Terminal Method | Output | Purpose |
|---|---|---|
.Send() |
void |
Standard fire-and-forget command (e.g., Label). |
.SendResp() |
ResponseFlagsE |
Executes and returns interaction bitmasks (Click, Hover, etc.). |
.KeepIter() |
iter.Seq[...] |
Used for containers. Opens a block in Go; sends "Close" on exit. |
.SendIter() |
iter.Seq[...] |
Similiar to .KeepIter() |
.Keep() |
RetainedHolder |
Converts a builder into a data object to be passed into another widget (evaluated arguments). |
5. .Keep() and Deferred Blocks — Capture-Now-Use-Later Patterns
The framework has two complementary mechanisms for deferring when and where opcode bytes are sent to Rust.
5.1 .Keep() — Retain a Single Builder's Bytes
Calling .Keep() on a fluid builder serializes the builder's accumulated bytes into a RetainedFffiHolderTyped[T]. Nothing is sent to Rust. The retained holder is later spliced into another widget's message via SpliceRetained.
atoms := c.Atoms().RichText("bold").Strong().EndRichText().Keep()
// Serialized bytes stored in Go memory, NOT sent to Rust yet.
c.Button(ids, atoms).Send()
// Button's message now contains: [Button opcode][id][Atoms bytes...][ButtonBuild]
// The Atoms payload is embedded inside the Button message.
On the Rust side, Button's handler calls interpret_inner on the embedded Atoms opcode. Everything is processed within the scope of that one Button message.
Properties
- Content-addressed deduplication:
BuildRetained()interns the byte buffer viaunique.Make. Identical builders across frames produce the sameRetainedElementId— same pointer, zero allocation. - Reusable across frames: A retained holder can be stored in a
varand reused every frame without rebuilding. - Single-widget scope: Captures exactly one builder's output. Used for evaluated arguments (
Atoms,WidgetText,Color32,ScalarSize).
Usage Scenarios
| Pattern | Example | Why .Keep() |
|---|---|---|
| Evaluated arg for a widget | Button(ids, Atoms().Text("x").Keep()) |
Atoms embedded inside Button's message |
| Evaluated arg for a block | Window(ids, WidgetText().Text("title").Keep()) |
Title embedded inside Window's message |
| Pre-built constant | var label = Atoms().Text("OK").Keep() (global) |
Avoid rebuilding identical bytes every frame |
| Color argument (primary) | color.RGB(0, 200, 200).Keep() |
ADR-0003: unified color.Color type; encoder picks transport per-arg |
| Color argument (legacy escape-hatch) | color.FromRetainedHolder(c.Color().FromRgbaUnmultiplied(r,g,b,a).Keep().Untype(), rgba) |
For non-premult / FromBlackAlpha semantics not surfaced by color.* constructors |
5.2 Deferred Blocks — Capture Arbitrary Opcode Sequences
Deferred blocks capture multiple independent .Send() calls into a keyed buffer. A parent widget embeds all captured blocks in its own message, and the Rust side replays them inside callbacks.
et := c.EndETable(ids, numRows, rowHeight, 1, 0)
et.BeginHeaders(0, 0) // redirect SendIntermediate → capture buffer
c.DisplayRichText("Name", ...) // internally calls LabelAtoms(...).Send()
// Send() writes framed message to capture buffer, NOT to Rust
et.EndHeaders() // stop capture, store bytes keyed by (0, 0)
et.BeginCells(row, col) // capture cell content
c.Label(value).Send() // captured
et.EndCells()
et.Send() // SpliceDeferredBlockMap: all blocks embedded in EndETable message
On the Rust side, EndETable reads the block map, stores it, and passes it to the TableDelegate. When egui calls cell_ui(row, col), the delegate calls replay_deferred_block(ctx, ui, block) which feeds the captured bytes back through interpret_outer with a real ui.
Properties
- Multi-message capture: Captures a sequence of
.Send()calls — each becomes a framed message in the buffer. - Keyed storage: Each block is identified by a composite key (e.g.,
(row, col)for cells,(header_row, col)for headers). - Callback replay: Captured opcodes are replayed inside Rust-side callbacks that provide a
uicontext. Required for APIs likeegui_table::TableDelegatewhere Rust calls back into the interpreter. - Fresh each frame: Unlike
.Keep(), deferred block captures are not reused across frames.
IDL Declaration
idl.NewBuilderFactoryNode("endETable").
WithDeferredBlockMap("cells", ctabb.U64, ctabb.U32). // generates BeginCells/EndCells
WithDeferredBlockMap("headers", ctabb.U32, ctabb.U32). // generates BeginHeaders/EndHeaders
How tall an etable is
An etable does not take the room around it: left alone it takes what its rows need, capped at 400 px. Two methods decide otherwise, and a table that owns a pane needs one of them.
et.FillPane(true) // take the room left in the parent, whatever the rows need
et.MaxHeight(252) // a ceiling: what the rows need, and no more than this
FillPane is for a table that owns its pane — a dock tab body, a central
panel, a leaf of a split — and is placed last in it. MaxHeight is for one
that should stay as short as its content. Together they mean "the pane, but
never more than this". Why the default is neither, and what goes wrong
without them: §12, Table Stops Short of Its Pane.
5.3 How They Compose
.Keep() and deferred blocks are complementary layers that can nest:
┌─────────────────────────────────────┐
.Keep() │ Captures ONE builder's bytes │
(evaluated arg) │ Spliced INTO a parent widget message │
│ Processed via interpret_inner │
└─────────────────────────────────────┘
│
can be used inside
│
┌─────────────────────────────────────┐
Deferred Block │ Captures MANY Send() calls │
(BeginX/EndX) │ Spliced into drain-node message │
│ Replayed via replay_deferred_block │
└─────────────────────────────────────┘
A .Keep() holder can appear inside a deferred block capture. When you call .Send() on a widget that contains a spliced .Keep() holder during a BeginCells/EndCells scope, the entire message (including the retained bytes) goes into the capture buffer:
et.BeginHeaders(0, 0)
c.LabelAtoms(c.Atoms().RichText("Name").Strong().EndRichText().Keep()).Send()
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .Keep() → retained, spliced into LabelAtoms
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .Send() → captured by deferred scope
et.EndHeaders()
Two layers of "capture now, use later" working together: .Keep() scopes the Atoms arg inside LabelAtoms, and the deferred block scopes the entire LabelAtoms call inside the table header.
5.4 Nested Deferred Blocks
Deferred blocks can nest inside other deferred blocks. Fffi2.captureStack is a stack of buffers — each BeginCapture pushes, EndCapture pops, and SendIntermediate writes to the innermost. This unlocks compositions like "an etable inside a dock-area tab body":
for dock := range c.DockArea(ids.PrepareStr("main")) {
for range dock.Tab(2, "data") {
// this begins ANOTHER deferred capture (etable cells) inside the
// one opened by Tab() — perfectly fine since the framework's
// capture stack supports arbitrary nesting
et := c.EndETable(ids.PrepareStr("inner"), rows, 20.0, 1, 0)
for row := range rows {
et.BeginCells(row, 0)
c.Label(val).Send()
et.EndCells()
}
et.Send()
}
}
Each inner Send() routes to the innermost active capture buffer. On the wire, the etable's whole message (opcode + args + spliced cells/headers block maps) ends up embedded in the tab body's bytes; on replay the interpreter reads the tab body, sees the etable opcode, reads ITS deferred block maps from the replay stream (which recursively points at the tab body's byte slice), etc.
Historical footnote: an earlier version of Fffi2 stored a single captureBuf and paniced on "nested BeginCapture". Any deferred-block widget inside another deferred-block widget — including the first DockArea attempt to host an etable — tripped it. The stack conversion is the enabling invariant.
There is also a complementary primitive, Fffi2.AppendRawToCapture(raw []byte), that writes already-framed bytes directly into the innermost capture without adding a new frame header. Used when a builder has captured opcodes into a detached buffer at declaration time and wants to flush them into a deferred block at a later Send — see the DockArea iter-scope pattern below.
5.5 Iter-Scope Wrapper for Deferred-Block Widgets
When an IDL-generated deferred-block factory takes upfront arg arrays (dockAreaRaw(ids, titles) — all tabs need to be known before the first BeginTabBody is called on the wire) but you want ergonomic iter-style per-item declaration with (id, title, body) grouped at the call site, wrap the generated factory with a hand-written iter-scope helper. Reference: DockArea in egui2_methods.go.
Pattern
- Rename the IDL node to a
*Rawvariant so the primary name is available for the wrapper. The generatedDockAreaRaw(id, ids, titles) DockAreaRawFluidstays as the low-level entry point; users never call it directly. - Define a hand-written
*Fluidstruct that accumulates the per-item args plus pre-captured body bytes:type DockAreaFluid struct { idGen WidgetIdCreatorI derivedId uint64 ids []uint64 titles []string bodies [][]byte } - Make the primary entry point an
iter.Seq[*Fluid](matchesIdScope/KeepIterlifecycle). On entry:DeriveStackedto consume the prepared id state and push — inner PrepareStr calls then work, and tab-body widget ids are scoped under the dock id. Defer:send()+PopIdFromStackChecked:func DockArea(id WidgetIdCreatorI) iter.Seq[*DockAreaFluid] { return func(yield func(*DockAreaFluid) bool) { fluid := &DockAreaFluid{idGen: id, derivedId: id.DeriveStacked()} defer func() { fluid.send() id.PopIdFromStackChecked(fluid.derivedId) }() yield(fluid) } } - Per-item method (
Tab(id, title)) returnsiter.Seqand captures its body into a detached*bytes.BufferviaBeginCapture/EndCapture, then appends(id, title, buf.Bytes())to the fluid's slices. - Private
send()calls the generated raw factory with the accumulated arrays, then for each item opensBeginTabBody(id)(pushes the deferred block map's temp buf onto the capture stack), flushes the detached bytes viaAppendRawToCapture, closesEndTabBody, and finally calls raw.Send().
Usage shape
for dock := range c.DockArea(ids.PrepareStr("main")) {
for range dock.Tab(1, "widgets") { /* body emits widgets */ }
for range dock.Tab(2, "data") { /* body emits etable, plot, … */ }
// no explicit Send — iter exit emits the opcode and pops the id
}
Why this shape
iter.Seqlifecycle + defer mirrorsIdScopeandKeepIter— the existing idiom for "push an id, do work, pop an id".- Grouping
(id, title, body)at the call site is the whole reason for the wrapper; plainDockAreaRawseparates ids/titles (constructor args) from bodies (Begin/End calls), which reads awkwardly. AppendRawToCapturesidesteps double-framing: bodies captured into detached buffers already carry their inner[u32 len][payload]frames perSendIntermediate; concatenating raw bytes into the deferred block map's temp buf preserves framing.DeriveStackedon entry consumes the "prepared" id-stack state thatids.PrepareStr(…)left behind, so user code inside a tab body can freely callids.PrepareStr(…)/IdScope. Without this step the firstPrepareStrinside a body panics with "invalid state transition — allowed=initial state=prepared".
5.6 Colors — Unified color.Color over Two FFFI Transports (ADR-0003)
Color-bearing widget arguments are surfaced uniformly as a single Go type, egui2/color.Color, regardless of whether the underlying FFFI2 transport for that argument is a PlainArg(U32) or an EvaluatedArg(Color32). The IDL annotates color-shaped args with .AsColor() (scalar) or .AsColors() (slice, literal-only per SD9); the generator emits color.Color / color.Colors in the Go signature and routes the wire encode through color.PutAsU32 / components.PutColorAsRetainedColor32 / color.PutColorsSlice based on the per-arg transport.
Construction
| Need | Call | Notes |
|---|---|---|
| Hex literal | color.Hex(0xRRGGBBAA) |
sRGB non-premultiplied per SD8 |
| RGB (opaque) | color.RGB(r, g, b) |
alpha=0xff |
| RGBA | color.RGBA(r, g, b, a) |
full control |
| Gray (opaque) | color.Gray(v) |
shorthand for RGB(v,v,v) |
| Retained variant | <any literal>.Keep() |
promotes to retained kind; retains the originating u32 so PlainArg-transport flattening is zero-cost |
| Bulk (literal-only) | color.NewColors(n), color.ColorsFromU32(s), color.ColorsFromSlice(cs) |
wire = packed U32h; nil-slice sentinel guarded |
| Escape hatch | color.FromRetainedHolder(c.Color().FromRgbaUnmultiplied(r,g,b,a).Keep().Untype(), rgba) |
for FromBlackAlpha / FromRgbaUnmultiplied semantics not surfaced by color.* constructors |
Key invariants
- Wire format unchanged. A literal
color.Colorover aPlainArg(U32)transport emits the same 4 bytes as the pre-ADRuint32arg. A literal over anEvaluatedArg(Color32)transport emits the same Color32-construction opcodes thatc.Color().FromRgbaUnmultiplied(r,g,b,a).Keep()+ splice produced — verified byte-for-byte bycomponents/egui2_color_splice_test.go. - Retained variant is stateless.
Color.Keep()on a literal flips the kind flag and stashes the originatingu32; no retained holder is built eagerly. The actual opcode splice is synthesised bycomponents.PutColorAsRetainedColor32at wire time. This sidesteps the deferred-block / culling discipline issue that any encoder-side state would face (ADR-0003 SD2). - Arrays are literal-only.
color.Colorsistype Colors []uint32. Retained values cannot enter aColors(noSet(int, Color)overload) — the constraint is enforced by construction (SD9). For "share one color across many calls", use a retained scalar, not an array of retained colors.
When you might still see Color32S / c.Color().Foo().Keep()
The legacy fluent factory at egui2_definition_d_colors.go is unchanged. It remains the right tool when you need:
FromRgbaUnmultipliedfor wire-explicit unmultiplied semantics in a one-off site (most code paths default to non-premult throughcolor.*constructors anyway).FromBlackAlpha,GammaMultiplyU8,LinearMultiplyF32,ToOpaque, named palette constants (ColorCyan,ColorGold, etc.) — egui-side modifiers that thecolor.*constructor surface deliberately does not mirror.- A pre-built retained holder you intend to share across many widget calls without re-emission. Wrap with
color.FromRetainedHolder(holder.Untype(), rgba)before passing to a.AsColor()-typed argument.
The hot path for SQL syntax highlighting in widgets/codeview/sql.go uses this escape hatch to keep its per-category palette as pre-built retained holders, paying zero per-frame synthesis cost.
6. Rich Text (Styled Text in Widgets)
Widgets that display text come in two flavors based on their egui type:
| egui Type | Go Evaluated Arg | Widgets |
|---|---|---|
Atoms (multi-segment, styled) |
Atoms().Keep() |
Button, RadioButton, LabelAtoms, MenuButton |
WidgetText (single string) |
WidgetText().Text("...").Keep() |
Label, Window, CollapsingHeader, ComboBox |
There is no conversion between Atoms and WidgetText in egui — they are distinct types.
6.1 Plain Text
For unstyled text, use Atoms().Text() or WidgetText().Text():
// Button with plain text
c.Button(ids, c.Atoms().Text("Click me").Keep()).Send()
// Window with plain title
for range c.Window(ids, c.WidgetText().Text("My Window").Keep()).KeepIter() { ... }
6.2 Inline Rich Text via the Typed RichTextScope
Styled (rich) text is constructed inline on Atoms() using BeginRichText(text), which returns a RichTextScope. This scope only exposes style methods — calling .Text() or .Keep() inside it is a compile error. Call .End() to close the segment and return to AtomsFluid. Multiple segments can be chained.
// Single styled segment:
c.Button(ids, c.Atoms().BeginRichText("bold").Strong().End().Keep()).Send()
// Multi-segment rich text:
c.Button(ids, c.Atoms().
BeginRichText("bold").Strong().End().
BeginRichText(" normal").End().
BeginRichText(" code").Code().End().
Keep()).Send()
// Mixed plain + styled:
c.Button(ids, c.Atoms().
Text("plain ").
BeginRichText("styled").Italics().End().
Keep()).Send()
Type Safety
BeginRichText / BeginRichTextColored are the only public way to open a
rich-text segment. The raw wire sub-protocol on AtomsFluid — richText,
richTextColored, endRichText, and every style method (strong, code,
heading, …) — is emitted unexported (via the IDL .Unexported() flag in
egui2_definition_d_evaluated.go), so it is callable only from inside the
bindings package. Public code cannot open a segment without the balancing
End(), which means the classic desync footgun
Atoms().RichTextColored(v, fg, bg).Text(...) — emitting a Text opcode inside
an unclosed rich segment — is now a compile error, not a runtime FFI crash.
Only Text(...) and Keep() stay exported on AtomsFluid.
| Expression | Compiles? | Why |
|---|---|---|
Atoms().BeginRichText("x").Strong().End() |
Yes | Strong() is on RichTextScope |
Atoms().Text("x") |
Yes | Text (plain atom) stays exported on AtomsFluid |
Atoms().Strong() |
No | style methods are unexported on AtomsFluid — use BeginRichText(…).Strong() |
Atoms().RichTextColored(v, fg, bg).Text(y) |
No | RichTextColored is unexported — the old runtime-desync footgun, now caught at compile time |
Atoms().BeginRichText("x").Text("y") |
No | Text() not on RichTextScope |
Atoms().BeginRichText("x").Keep() |
No | Keep() not on RichTextScope |
Defense in depth: even if a malformed atoms stream reaches the client (a
hand-written raw byte buffer, a future regression), the Rust richText /
richTextColored sub-loops self-heal — a stray Text closes the open segment
implicitly and is pushed as its own atom (byte-safe), so the frame degrades to
"segment + plain text" and logs a warning instead of crashing.
Available Style Methods (on RichTextScope)
| Method | Argument | Effect |
|---|---|---|
Strong() |
— | Bold |
Weak() |
— | Dimmed |
Italics() |
— | Italic |
Underline() |
— | Underlined |
Strikethrough() |
— | Struck through |
Code() |
— | Monospace code style |
Monospace() |
— | Monospace font |
Heading() |
— | Heading size |
Small() |
— | Small text |
SmallRaised() |
— | Small + raised |
Raised() |
— | Raised baseline |
Size(f32) |
font size | Custom font size |
ExtraLetterSpacing(f32) |
spacing | Additional letter spacing |
LineHeight(f32) |
height | Custom line height |
LineHeightDefault() |
— | Reset to default line height |
Style methods can be chained: .BeginRichText("x").Strong().Italics().Small().End().
6.3 DisplayRichText Convenience
For the common case of displaying a single styled label:
// Shows a bold label
c.DisplayRichText("hello", func(a c.RichTextScope) c.RichTextScope { return a.Strong() })
// No styling — just plain:
c.DisplayRichText("hello", nil)
The closure receives a RichTextScope, so only style methods are available — type-safe by construction.
6.4 Rich Text in Tables
For the register-drain table (Table), use plain text cells (TableCellText). For styled table content, use the deferred-block etable (EndETable) with DisplayRichText inside BeginHeaders/BeginCells blocks:
et := c.EndETable(ids, numRows, rowHeight, 1, 0)
et.BeginHeaders(0, 0)
c.DisplayRichText("Name", func(a c.RichTextScope) c.RichTextScope { return a.Strong() })
et.EndHeaders()
et.BeginCells(row, col)
c.DisplayRichText(value, func(a c.RichTextScope) c.RichTextScope { return a.Monospace() })
et.EndCells()
et.Send()
6.5 Design Note: Why Not PushRichText?
An earlier API used PushRichText("text").Strong().Send() to push styled text into a global register (r0_atoms), which a later widget would drain. This was replaced because:
- Global register coupling — any widget evaluating an Atoms arg would drain whatever happened to be in the register, regardless of intent.
- Cross-widget leaks — if a consuming widget was culled or skipped, atoms could leak to unrelated widgets.
- WidgetText contamination — an attempt to auto-drain atoms into WidgetText affected all WidgetText evaluations.
The inline sub-protocol eliminates these issues: everything is scoped within a single Atoms() builder evaluation.
7. Trees
Hierarchies are drawn by widgets/tree, a Go widget over endETable
(ADR-0176). There is no tree in the bindings and no node register: NodeDir,
NodeLeaf, NodeDirClose and Tree were removed with the egui_ltreeview
crate, and their register r3_node_cmds with them.
The input is columnar, not a pointer tree — one label per node and one
parent index per node, -1 for a root, several roots allowed, any order:
t := tree.Tree{
Labels: []string{"dir 0", "dir 1", "leaf 0", "dir 2", "leaf 1"},
Parents: []int32{-1, 0, 1, 0, 3},
}
res := tree.Render(tree.Input{
Ids: ids, ScopeKey: "files", Tree: t, State: &st.treeState,
Outline: tree.Column{Header: "name", Width: 320, Resizable: true},
MaxHeight: 300,
})
if res.Clicked >= 0 { /* … */ }
Four things follow from the design and are worth knowing before you use it:
- The host owns the state. Expansion, selection and the keyboard cursor
live in a caller-held
tree.State, so expand-all, collapse-all, reveal-a-node and persistence are ordinary calls. Nothing is stashed on the Rust side. Statekeys on node INDICES, which is the only identity a columnar input has. If your tree is rebuilt with a different shape — a filter that runs on every keystroke, a reloaded document — key your own expansion and selection on something stable and project it ontoStatebefore eachRender, reading the widget's own changes back out ofResult. See ADR-0176 SD11; all three in-repo callers do this.- Rows are virtualised and one line high. A collapsed subtree and an off-screen row build nothing. A value that needs two lines wants a second column and a tooltip, not a taller row (SD12).
MaxHeightis the host's job. The widget is an etable underneath and inherits its 400 px auto-fit cap (ADR-0176), which a tree in a tall pane overruns.MaxHeightis a ceiling — a short tree stays short — so feed it the pane's height fromc.CapturePaneSize, with a constant to fall back on for the frame before the probe answers. Same forwidgets/fsbrowser.
8. Interpreted Values (Scalar/Vector Size)
Because the Server (Go) is often decoupled from the Client (Display), Go does not always know the exact pixel dimensions of the UI.
- Concept: Instead of sending
width: 500px, Go sends an Instruction likeScalarSize().AvailableWidth(). - Evaluation: The Client evaluates this instruction locally during the render pass to determine the actual size.
- Usage:
.Keep()these instructions to pass them as constraints to other widgets.
9. Idiomatic Best Practices
ID Management Stability
| Scenario | Approach | Why? |
|---|---|---|
| Dynamic Labels | ids.PrepareStr("fixed_key") |
If the label changes from "Start" to "Stop", the ID stays the same so focus isn't lost. |
| Loops/Lists | components.IdScope(MakeWidgetIdStr(item.UUID)) |
Prevents ID collisions between identical rows. |
| Localized Text | components.PrepareStr("internal_id") |
Ensures the ID doesn't change when the user changes language. |
The "Loop Namespacing" Pattern
Always wrap dynamic content in an IdScope to ensure child widgets (like an "Edit" button) have unique effective IDs.
10. Comprehensive Example
package demo
import (
"fmt"
"time"
"github.com/rs/zerolog/log"
c "github.com/stergiotis/boxer/public/thestack/imzero2/egui2/bindings"
)
var n int
var sliderVal float64
var checkboxVal = false
var frame uint64
var myText string
var myDragFloat float64
var dropDownSelected int = -1
var radioChoice uint8
var ids = c.NewWidgetIdStack()
func RenderDemoWindow() {
c.CurrentApplicationState.StartServersideFrame()
defer c.CurrentApplicationState.FinishServersideFrame()
statemanager := c.CurrentApplicationState.StateManager
incrementLabelAtoms := c.Atoms().Text("increment/decrement").Keep()
for range c.Window(ids.PrepareStr("imzero2"), c.WidgetText().Text("imzero2").Keep()).KeepIter() {
c.Label(time.Now().GoString()).Send()
{
r := c.Button(c.MakeAbsoluteIdSeq(0xdeadbeef), incrementLabelAtoms).SendResp()
if r.HasPrimaryClicked() {
n++
} else if r.HasSecondaryClicked() {
n--
}
}
for range c.IdScope(ids.PrepareStr("myscope")) {
c.TextEdit(ids.PrepareSeq(0xf4f4), myText).SendRespVal(&myText)
c.DragValueF64(ids.PrepareSeq(0x45445), myDragFloat).SendRespVal(&myDragFloat)
}
for range c.ComboBox(ids.PrepareStr("combobox"), c.WidgetText().Text("combobox").Keep(), c.WidgetText().Text(fmt.Sprintf("option %d", dropDownSelected)).Keep()).KeepIter() {
for i := 0; i < 10; i++ {
selected := i == dropDownSelected
if c.Button(ids.PrepareSeq(uint64(0x1111+i)), c.Atoms().Text(fmt.Sprintf("option %d", i)).Keep()).Selected(selected).FrameWhenInactive(!selected).Frame(true).SendResp().HasPrimaryClicked() {
dropDownSelected = i
}
}
}
if c.RadioButton(ids.PrepareStr("radio 1"), c.Atoms().Text("radio 1").Keep(), radioChoice == 1).SendResp().HasPrimaryClicked() {
radioChoice = 1
}
if c.RadioButton(ids.PrepareStr("radio 2"), c.Atoms().Text("radio 2").Keep(), radioChoice == 2).SendResp().HasPrimaryClicked() {
radioChoice = 2
}
if c.RadioButton(ids.PrepareStr("radio 3"), c.Atoms().Text("radio 3").Keep(), radioChoice == 3).SendResp().HasPrimaryClicked() {
radioChoice = 3
}
c.Label(fmt.Sprintf("%d", n)).Selectable(false).Send()
c.Separator().Send()
c.SliderF64(ids.PrepareSeq(0xfefe), sliderVal, 0.0, 100.0).
Text("my text").
SendRespVal(&sliderVal)
c.Label(fmt.Sprintf("checked=%v", checkboxVal)).Send()
if c.Checkbox(ids.PrepareSeq(0x343af), checkboxVal, "my checkbox").SendRespVal(&checkboxVal).HasChanged() {
log.Info().Bool("value", checkboxVal).Msg("checkbox has changed")
}
if c.Button(ids.PrepareSeq(0x33333), c.Atoms().Text("set to true").Keep()).SendResp().HasPrimaryClicked() {
checkboxVal = true
statemanager.OverrideDatabindingWidget(0x343af)
}
if c.Button(ids.PrepareSeq(0x33334), c.Atoms().Text("set to false").Keep()).SendResp().HasPrimaryClicked() {
statemanager.OverrideDatabindingWidget(0x343af)
checkboxVal = false
}
{
c.Label(fmt.Sprintf("frame=%d", frame)).Send()
c.Passthrough(ids.PrepareSeq(123456789), frame)
frame += 2
}
for range c.VerticalCenteredJustified().KeepIter() {
c.Label("A").Send()
c.Label("B").Send()
c.Label("C").Send()
}
for range c.Grid(ids.PrepareSeq(0xfefe)).NumColumns(3).KeepIter() {
c.Label("A").Send()
c.Label("B").Send()
c.Label("C").Send()
c.EndRow()
c.Label("D").Send()
c.Label("E").Send()
c.Label("F").Send()
}
// A hierarchy is a Go widget over an etable — see §7. It draws where
// it is called, so there is no register to drain and no ScrollArea to
// wrap it in; it brings its own.
if res := tree.Render(tree.Input{
Ids: ids, ScopeKey: "smoke-tree", Tree: demoTree,
State: &treeState, MaxHeight: 180,
}); res.Clicked >= 0 {
selected = demoTree.Labels[res.Clicked]
}
for range c.ScrollArea().Vscroll(true).KeepIter() {
for range c.CollapsingHeader(ids.PrepareStr("section 1"), c.WidgetText().Text("section 1").Keep()).KeepIter() {
c.Label("hello section1").Send()
r := c.Button(ids.PrepareSeq(0xcaffe), incrementLabelAtoms).SendResp()
if r.HasPrimaryClicked() {
n++
} else if r.HasSecondaryClicked() {
n--
}
}
}
}
for range c.Window(ids.PrepareSeq(0xffeefe), c.WidgetText().Text("imzero2 debug tools").Keep()).KeepIter() {
c.ShowDebugTools()
}
c.RequestRepaint()
}
11. Debugging & Troubleshooting
- Ghost Interactions: If clicking "Button A" triggers "Button B", you have an ID Collision. Check if you are using identical labels in the same scope without an
IdScope. - Focus Loss: If a text field loses focus as soon as you type, your ID is unstable. Check if your ID is derived from a string that changes based on the input text.
- A tree row shows but does not respond: the row's click sense sits behind its cells so the disclosure control can win its own rect, so a label emitted
Selectable(true)(egui's default) sits over the row and swallows every click on its own rect. Emit cell labelsSelectable(false). A driver hits the same wall from the other side — the row's label is an ordinary node with no accessible name, findable byvalueand deaf to an AccessKit action; press its bounds centre instead (ADR-0176 SD13). - Layout Jumps: Ensure Absolute widgets (like Windows) use
AbsoluteLabelDefinedIdGto avoid being shifted by the relative stack of a parent container. - Silent
SendResp: A widget renders, visibly reacts to the click, and its handler never runs —SendResp()keeps returningNilResponseFlags. The id is not reaching the read-back map under the value you look it up by. In order of likelihood: (a) two widgets share one id — r7 is a flat map andSynckeeps the last writer, so every earlier twin reads nothing; grep the log forid has already been used. (b) The id is not stable across frames — responses arrive one frame late, so an id salted by a per-frame counter is looked up after it has already changed. (c) You are reading a different id than you emitted —sm.GetResponse(widgethandle.Make(...))needs the same value the widget put on the wire; callDerive()on the creator rather than reconstructing the number by hand. Note that clicking is not evidence the id is right: egui hit-tests on its own auto-ids, so a wrong or duplicated id still renders and still accepts input. See §3 for the derivation contract.
12. Pitfalls
Pattern: Stable Pointers for Delayed FFI State (ImZero2)
The Pitfall: Frame-Local Variables
In traditional immediate-mode GUIs (like Dear ImGui in C++), passing a pointer to a local stack variable works because interactions are processed synchronously within the same frame. However, ImZero2 operates across an RPC/FFI boundary, meaning data bindings and event responses suffer a 1-frame delay.
If you pass a pointer to a temporary frame-local variable (e.g., val := state[key]; widget.SendRespVal(&val)), the framework will attempt to write the user's input to a discarded pointer on the next frame. The local UI state will fail to update.
The Solution: Stable Heap Pointers
Widgets that accept pointer bindings (*string, *float64, *bool) must be bound to stable, heap-allocated memory that survives across frame boundaries.
- Store pointers, not values: Change your state maps from
map[string]stringtomap[string]*string. - Initialize stably: If a key doesn't exist, allocate a new string on the heap (
newVal := ""; state[key] = &newVal). - Bind directly: Pass this stable pointer directly to the widget (
widget.SendRespVal(state[key])). - Read on demand: When an action occurs (like a button click), dereference the stable pointer (
*state[key]) to capture the asynchronously updated value.
- The Symptom: Text edits or sliders reset the user's input instantly, or the text cursor behaves erratically.
- The Cause: ImZero2 operates across an FFI (Foreign Function Interface) boundary, meaning user input events are processed with a 1-frame delay. If you bind a widget to a local stack variable (
val := state[key]; widget.SendRespVal(&val)), the backend attempts to write the asynchronous user input into a pointer that died on the previous frame. - The Pattern: Stable Heap Pointers. Always bind input widgets to stable memory that survives frame boundaries.
// WRONG: val := myMap[key] c.TextEdit(id, val).SendRespVal(&val) // RIGHT: valPtr, ok := myMap[key] if !ok { newVal := "default" valPtr = &newVal myMap[key] = valPtr // Store the pointer stably } c.TextEdit(id, *valPtr).SendRespVal(valPtr)
Lost Sends (Host-Skippable Regions)
- The Symptom: A texture/image renders 0×0 (or a one-shot setting never applies) — but only when its dock tab is activated by clicking, or after the tab was hidden for ~10 s. Scripted captures that start ON the tab look perfect.
- The Cause: Go runs every dock-tab body every frame into a detached buffer, but the host interprets only the active tab's buffer and discards the rest (an ungated collapsed block is the same seam). Any protocol where Go remembers "already sent" and then sends less — a content-version tracker shipping empty "use cached" slices, a delta stream advancing its head, a
SetZoombehind aninitedflag — silently desynchronizes: the one full send landed in a discarded buffer. The host's idle texture LRU (~600 unrendered frames) adds the same failure for tabs that were shown once. - The Pattern: A send is not a receipt. Inside any host-skippable region, ops must be idempotent per frame, or Go-side "already sent" memory must be validated against host feedback:
- Content-versioned textures: use
ImageVersionTracker.PixelsToSendFor(notPixelsToSend) — it consults the host's starved-texture report (StateManager.TextureStarved,fetchR22StarvedTextures) and re-ships automatically. Custom version fields (lastSentVersion) must checkTextureStarved(id)themselves. - Delta streams (
scrollingTexture): onTextureStarved(id), reset the ring (head = 0) — the lost columns are unrecoverable; restart honestly instead of desyncing (heatmapscroll does this). - One-shot ops: keep sending until a host register proves receipt (the Map's
SetZoomre-sends until the walkers camera register reports this map's id), or focus the target tab first (DockAreaFluid.ActivateTab) when delivering content into another tab's body (the snippet-library insert). - Small static images: skip trackers entirely and re-send pixels every frame (the markdown widget's choice — fine below ~100 KB).
- Skipping the region entirely (the cost-side complement):
widgets/lazypanegates a heavy body on last frame's rendered-probe report (captureUiRect/GetUiRect) — while the host discards the region, Go emits only a probe + loading placeholder, and the body lands one frame after activation. Send-once ops underneath still re-arm via the starved report on reveal; see the package doc for when not to use it.
- Content-versioned textures: use
Jumping UI (ID Drift)
- The Symptom: Windows reset their positions when you click a button, or scroll areas jump wildly when a new item is added to a list.
- The Cause: Widgets rely on a hash ID to remember their state (position, scroll, focus). If you rely on a single flat auto-incrementing ID stack, dynamically rendering a new element (like an error message or a new list item) shifts the auto-IDs of everything rendered after it. ImZero2 thinks the shifted widgets are completely new elements and resets their state.
- The Pattern: Tree Hashing & ID Scopes. Wrap dynamic lists and conditional blocks in
c.IdScope(). This pushes a namespace onto the hash tree, isolating the auto-ID counter so siblings aren't affected.// WRONG: Flat stack shifting if hasError { c.Label("Error").Send() } c.Window(ids.PrepareStr("win"))... // ID changes if hasError toggles! // RIGHT: Scoped namespaces if hasError { for range c.IdScope(ids.PrepareStr("error_scope")) { c.Label("Error").Send() } } // The Window ID remains perfectly stable for range c.Window(ids.PrepareStr("win"))...
Flickering Widget (Boolean Short-Circuiting)
- The Symptom: Buttons or input fields completely vanish from the screen when a certain condition is met (like a background task starting), causing the layout to collapse and expand.
- The Cause: In Go,
if condition && widget.SendResp().HasClicked()will short-circuit ifconditionis false. In immediate-mode GUIs, if you don't call.Send()or.SendResp()on a widget during the frame loop, it is not added to the render tree at all. - The Pattern: Unconditional Rendering. Always evaluate the widget's send method before or independent of the logical condition.
// WRONG: Widget vanishes when processing if !isProcessing && c.Button(id, "Save").SendResp().HasPrimaryClicked() { ... } // RIGHT: Widget always renders, but click is conditionally ignored btn := c.Button(id, "Save") if btn.SendResp().HasPrimaryClicked() && !isProcessing { ... }
Micro-Flash (Sub-Frame UI Locking)
- The Symptom: When triggering local disk I/O, disabled widgets "flash" or "strobe" erratically instead of looking deliberately locked.
- The Cause: Local tasks (like a simple file write or
pijullocal execution) might complete in 2-5 milliseconds. This meansisProcessingbecomestrueand thenfalsewithin the span of a single 16ms frame (60FPS). The widget drops focus and grays out for exactly one frame, which the human eye perceives as a visual glitch. - The Pattern: Artificial Delays for Micro-Tasks. If you intend to use a global "Locked/Loading" state for the UI, ensure the task takes long enough for the user to register the state change.
func WorkerLoop() { // Lock UI isProcessing = true DoFastLocalDiskIO() // Ensure the "Locked" state is visible to the human eye time.Sleep(300 * time.Millisecond) // Unlock UI isProcessing = false }
Stubborn Text (Frontend State Override)
- The Symptom: You update a variable in the backend (e.g., from a network request or disk read), but the
TextEditwidget immediately reverts it back to what the user last typed. - The Cause: Widgets with internal state (like text cursors) consider the frontend the "source of truth". If you modify the bound pointer from the backend, the frontend simply overwrites it on the next frame with its cached state.
- The Pattern: State Manager Overrides. When you programmatically change a value that is bound to an interactive widget, you must call
OverrideDatabinding...to command the frontend to drop its cache.*valPtr = "new data from network" c.CurrentApplicationState.StateManager.OverrideDatabindingSPtr(valPtr)
Self-Deadlock (Non-Reentrant Mutexes)
- The Symptom: A background worker or UI interaction freezes the entire application indefinitely.
- The Cause: Go’s
sync.Mutexandsync.RWMutexare not reentrant. If a goroutine acquires a lock, it cannot acquire it again. In our app,WorkerLoopheld the lock and calledReloadAllActors, which subsequently calledrunCmd. BecauserunCmdalso tried to acquire the lock to append to the CLI log, the thread deadlocked waiting for itself. - The Pattern: Lock-Free Internal Helpers. Never call a lock-acquiring method from inside another lock-acquiring block. Separate functions into public (locking) methods and private (lock-free) helpers. Alternatively, bypass the locking method entirely (as we did by using a raw
exec.Commandfor silent background logging instead of the UI-boundrunCmd).
Framework Data Race (Thread-Unsafe GUI APIs)
- The Symptom: The Go race detector panics, or the app crashes randomly when background tasks finish and try to wake up the UI or trigger repaints.
- The Cause: All functions in the ImZero2
cpackage are strictly single-threaded and belong exclusively to the main UI frame lifecycle. Calling any framework method (likec.RequestRepaint(), widget builders, or layout scopes) from a backgroundWorkerLoopgoroutine causes a severe data race with the main render thread. - The Pattern: Main-Thread Handoffs. Never invoke UI framework functions from background goroutines. If a background worker needs to trigger a repaint or an override, it must signal the main thread using thread-safe Go primitives (channels, atomic booleans, or a locked pending-overrides map). The main
RenderWindowloop must check this signal and call thec.*functions itself.
FFFI2 Widget Definition Rules
When defining new widgets via idl.NewBuilderFactoryNode():
Argument Naming: Minimum 2 Characters
All argument names in factories and methods must be at least 2 characters long. Single-letter names (e.g. r, s, w) clash with the FFFI2 framework's internal variables in generated Rust code (r is the response, w is the widget instance, u is the UI context, i is the ID, c is the egui context, d is the recursion depth).
// WRONG: single-letter arg name clashes with framework variables
BeginMethod("radius").Arg("r", ctabb.F32)
// RIGHT: use descriptive 2+ character names
BeginMethod("radius").Arg("ra", ctabb.F32)
Non-Scalar PlainArg Types (Homogeneous Arrays)
PlainArg supports non-scalar canonical types like ctabb.F64h (homogeneous []float64 array), ctabb.U64h, ctabb.I64h, ctabb.Sh, etc. These generate:
- Go factory: e.g.
func MapPolyline(mapId uint64, lats []float64, lons []float64)usingruntime.PutFloat64SliceArg(r, lats) - Rust read:
let mut xs = self.io.read_plain_f64h()returningVec<f64> - Wire format:
u32 length+element * length
Return Types Are Required
Every BuilderFactoryNode must have a WithReturnType(...) call. Missing it produces <invalid> in the generated Keep() method signature. Define concrete type helpers in egui2_definition_d_types.go:
func structMyWidget() ir.ConcreteType {
return ir.NewConcreteType("myWidget")
}
Never Edit Generated Files
Files ending in .out.go and enums_out.rs are regenerated by ./generate.sh. All customizations must go into:
- Definition files (
egui2_definition_d_*.go) for widget IDL - Hand-maintained Rust (
interpreter.rsstruct fields/data types,io.rsread methods,fenums.rsconstants) - Hand-maintained Go (non-
.out.gofiles incomponents/)
Register-Drain Pattern for Non-Widget APIs
APIs that take closures with non-egui::Ui contexts (historically egui_plot::Plot::show(|plot_ui|{}) in the retired plot bridge; live examples are the Table and Tree drain nodes) cannot use the BlockIterator pattern. Use register-drain instead:
- Define accumulator nodes that push data into Rust-side
Vecregisters - Define a drain node whose apply code calls
.drain(..)on all registers and renders inside the closure
DeferredBlockMap for Callback APIs
APIs with egui::Ui callbacks (like egui_table::TableDelegate) use WithDeferredBlockMap(name, keyTypes...). Go captures opcode blocks via BeginCells/EndCells, Rust replays them in callbacks via replay_deferred_block().
State Bleed (Unscoped Background Updates)
- The Symptom: User A is actively typing in a text field. User B clicks a button that triggers a background update. When the update finishes, User A's unsaved typing is instantly reverted to the old value on disk.
- The Cause: The background worker blindly synchronized the entire disk state back to the UI state manager, failing to differentiate between the data that actually changed and the data that users were currently modifying in memory.
- The Pattern: Targeted Cache Overrides. Background tasks must return an explicit list of "Affected Targets" (e.g.,
affectedActors []string). The synchronization loop must strictly limit UI pointer overrides to those specific targets, ensuring the unsaved memory of other UI components remains mathematically isolated and protected.
Silent Hover Tooltip (Scope Response Hit-Test Order)
- The Symptom: A block wraps a widget in
ui.scope(...), takes the scope's response, and calls.on_hover_text("tip")or.on_hover_ui(|ui| ...). The tooltip never appears, even though the pointer is clearly over the widget. No panic, no log line — just silence. - The Cause: In egui (verified against 0.34),
ui.scoperegisters its response widget at child-ui construction, i.e. BEFORE the scope body runs and adds its children. In the frame's back-to-front hit-test order the scope therefore sits BEHIND its children. egui's interaction snapshot (interaction.rs) only marks a non-interactive widget (hover-only sense) ashoveredwhen it lies ABOVE the topmost interactive widget — the rule is there so that a label rendered on top of a draggable window still shows a tooltip. For a scope wrapping aButton, the scope is below the button, soscope.response.hovered()stays false whenever the pointer is over the button, andon_hover_text/on_hover_uiearly-return silently viashould_show_tooltip→response.hovered()guard. - The Pattern: Overlay Interact Widget. After the scope body closes, re-register a fresh hover-only widget at the scope's rect via
ui.interact(rect, id.with("suffix"), egui::Sense::hover()). This new widget is inserted AFTER all children, so it sits in front → hit-test sees it →hovered()returns true → tooltip fires.HoverTextandHoverUiuse this pattern. The Frame block uses the same technique forsenseClick.// WRONG: tooltip never shows when pointer is over the button inside let resp = ui.scope(|ui| { /* button here */ }).response; resp.on_hover_text(text); // RIGHT: explicit overlay widget registered post-body let scope_resp = ui.scope(|ui| { /* button here */ }).response; let hover_resp = ui.interact( scope_resp.rect, scope_resp.id.with("imzero2_hover_text"), egui::Sense::hover(), ); hover_resp.on_hover_text(text);
Ragged Control Row (First Item in a Centered Horizontal Row)
- The Symptom: A toolbar of mixed controls — combo boxes, checkboxes, labels — laid out with
c.Horizontal()does not sit on a single baseline. The first widget in the row renders a few pixels higher than everything after it, so the row reads as vertically "unstable" / ragged even though every control is the same height. - The Cause:
c.Horizontal()maps to egui'sui.horizontal()=Layout::left_to_right(Align::Center), which vertically centers each item against the row. In immediate mode egui fixes the row's cross-axis line from the first item and anchors it differently from the items that follow; when the controls are allinteract_size.ytall (combos, checkboxes, sliders) the leading widget lands a few px above its neighbours instead of on the shared centre line. (Observed against egui 0.34; thesccmapapp's control row hit this exact issue.) - The Pattern: Top-Align Equal-Height Control Rows. When every control in the row is the same height, use
c.HorizontalTop()(Align::Min) instead ofc.Horizontal(). Top-aligning skips the per-item cross-axis centering, so identical-height controls all land on one stable line. Reservec.Horizontal()(centered) for rows that deliberately mix tall and short widgets and want them centred relative to one another.// WRONG: centered row — the first control anchors a few px above the rest for range c.Horizontal().KeepIter() { sizeIdx = renderMetricCombo(ids, "size", "Size", sizeIdx) colorIdx = renderMetricCombo(ids, "color", "Color", colorIdx) c.Checkbox(ids.PrepareStr("tests"), incTests, "Include tests").SendRespVal(&incTests) } // RIGHT: equal-height controls top-aligned → one stable baseline for range c.HorizontalTop().KeepIter() { sizeIdx = renderMetricCombo(ids, "size", "Size", sizeIdx) colorIdx = renderMetricCombo(ids, "color", "Color", colorIdx) c.Checkbox(ids.PrepareStr("tests"), incTests, "Include tests").SendRespVal(&incTests) }
Oversized, Off-Centre Glyph (Affordance Text Escaping to the Fallback Font)
-
The Symptom: A glyph used as a control — a disclosure triangle, an arrow on a move button, a status mark — renders visibly larger than the text beside it and sits above or below the line it should share. Everything else in the row is fine. It looks like a layout or padding bug, and no amount of alignment work fixes it, because the box is already centred: it is the ink inside the box that is wrong.
-
The Cause: The codepoint is not in the main font, so it resolves through the client's fallback chain. The host loads a CJK fallback (
--fallbackFontTTF, whichhmi.shalways passes), and a CJK face draws geometric shapes at ideographic full-em and centres them on the ideographic box rather than the Latin baseline. Measured on one scene with and without the fallback, everything else equal:▶(U+25B6, absent from Noto Sans) grew from 8px to 12px of ink and dropped 2px below the label beside it.Two things make this hard to catch. It is invisible on any host with a thinner font stack — the headless lane loads no CJK fallback, so a scene can verify a widget four times over and never show it. And placing the same codepoint inline in a label's own text run (helphost's nav prefixes, schemaview's
◆ entity-id, the leewaywidgets section chevrons) hides half of it: baselines align within a run, so the glyph stops sitting off the line — but the fallback face still decides its SIZE, so it can still read oversized next to the letters beside it. Inline is mitigation, not immunity, and it is why the same glyph can look acceptable in one place and wrong in another. -
The Pattern: An affordance's glyph comes from a font the client loads, not from the fallback chain. Text may fall back — that is what a fallback is for. A control may not, because its size and baseline then belong to whichever face happened to answer, which varies per host. In practice that means the bundled Phosphor font (
icons.Ph*, loaded via--phosphorFontTTF) for anything inside aButtonor standing alone as a control.// WRONG: a control whose glyph is not in the text font — the fallback face // decides its size and baseline, differently on every host. glyph := "▶" if expanded { glyph = "▼" } c.Button(id, c.Atoms().Text(glyph).Keep()).Frame(false).Small().SendResp() // RIGHT: a glyph from the font the client loads explicitly. glyph := icons.PhCaretRight if expanded { glyph = icons.PhCaretDown } c.Button(id, c.Atoms().Text(glyph).Keep()).Frame(false).Small().SendResp()The same root shows up as its other failure mode — a plain tofu box where the fallback has no glyph at all.
◈(U+25C8) does this inschemaview's navigator while rendering correctly in its own legend a few pixels away, because the legend's chips areMonospace()and land on a different face. If a glyph looks wrong in one place and right in another, suspect the font before the layout. -
Diagnosing it: run the same scene twice, once with
--fallbackFontTTFand once without, and measure the glyph's ink bo
Truncated - read the full file at https://github.com/stergiotis/boxer/blob/9250233f936cc4a2c821c7005b5bb6cd2835c674/doc/skills/imzero2/SKILL.md.