Chapter 25 — Flare: Interfaces You Can Read
Flare is Ingle’s component-style UI layer. Components are functions, props are arguments, state is local, and the screen is a declarative description you re-evaluate each frame. There’s no retained widget tree, no diffing layer, no separate effect system — and the reason there isn’t is the whole design, so it’s worth a minute.
Immediate mode, not a retained tree
Ingle’s graphics backend redraws the whole frame every tick, cheaply. That’s the immediate-mode
model from Chapter 13: the UI is a pure function of your state,
drawn fresh each frame, with no retained widget tree to keep in sync. There’s nothing to diff and
nothing to reconcile, because nothing is kept between frames to fall out of date. Components, props,
local state, and declarative composition drop straight into Ingle’s loop { …describe the frame… }.
No retained tree means no graph-shaped mutable state, which means the ownership model from
Chapter 12 stays clean even while you build something live
and interactive.
The shape of a Flare program
Here’s a whole one — a window with a toolbar and a counter. It’s examples/graphics/17_flare.ig,
shipped with the compiler:
import "std/draw" as draw
import "std/flare" as flare
fn main() -> int {
draw.window(580, 400, "Flare")
var f = flare.new()
var count = 0
var dark = false
loop {
if draw.closing() { break }
draw.begin(f.bg()) // clear to the theme's background
f.begin() // start this frame's UI
// A toolbar: title on the left, actions pushed right by a flexible spacer.
f.row(flare.BETWEEN, flare.CENTER)
f.heading("Flare")
f.spacer()
if f.button("Theme") {
dark = !dark
if dark { f.use_dark() } else { f.use_light() }
}
if f.primary("Compose") { count = count + 1 }
f.end()
f.text_muted("Composed {count} times.")
f.finish() // solve the layout, paint the frame
draw.finish()
}
draw.close()
return 0
}
Build the graphics compiler and run it:
make graphics && INGLE_STD=./std build/inglec-gfx --emit=run examples/graphics/17_flare.ig
Four things in that loop are the entire model. The next four sections take them one at a time.
Events are return values
Look again at the buttons:
if f.button("Theme") { ... }
if f.primary("Compose") { count = count + 1 }
There’s no separate click handler. f.button("Theme") draws the button and returns a bool —
true on the one frame the user clicked it. So a click is just an if, and the code that reacts to a button sits
right next to the button, in the same place, read top to bottom. That’s the idea that makes
immediate-mode UI legible: no callbacks to register, no handler defined three hundred lines away, no
this to bind.
The loop owns your state
Where does state live? In the vars the loop owns. count is just a var; it survives because the
loop survives. No setter, no stale-closure surprise. You only reach for Flare’s own state
when you want a value encapsulated inside a reusable component, so the caller needn’t hold it for
you — and that’s the next section.
Components are just functions
A component is a function that takes the Flare value (by mutable borrow — see
Chapter 12) and emits some widgets. Here’s a self-contained
counter that keeps its own count:
fn Counter(mut f: flare.Flare, key: string, title: string) {
f.key(key) // give this instance its own identity
var n = f.state_int("n", 0) // read encapsulated state (default 0)
f.row(flare.START, flare.CENTER)
if f.button("-") { n = n - 1 }
f.label("{title}: {n}")
if f.button("+") { n = n + 1 }
f.end()
f.set_int("n", n) // write it back
f.key_clear()
}
Now drop two of them in, and they don’t interfere:
Counter(f, "apples", "Apples")
Counter(f, "pears", "Pears")
Both draw a "-" and a "+" with identical labels, and both keep their own separate count. The
thing that keeps them apart is key.
Identity: the one concept that makes lists work
Immediate-mode widgets are identified by a hash of their label, so two "+" buttons would collide —
same id, same state slot, same hit-test. f.key("apples") opens an id scope that is mixed into
every widget id and every piece of state beneath it, so the two counters’ buttons and their
state_int("n", …) stay distinct. This is the classic immediate-mode id-stack. Open a scope with
f.key(...) before a keyed component or a list row; close it with f.key_clear() after. For a
list, the idiom is just what you’d guess:
var i = 0
loop {
if i == rows.len() { break }
f.key("row{i}") // each row gets a distinct identity
if f.button(rows[i]) { selected = i }
f.key_clear()
i = i + 1
}
Layout is real flexbox
The toolbar used f.row(flare.BETWEEN, flare.CENTER) and f.spacer(), and that’s no metaphor:
underneath Flare sits std/layout, an actual flexbox solver. Each frame, as you declare the UI,
Flare builds an ephemeral tree of boxes, solves it (measure, then place), and paints each widget at
its solved rectangle. Nothing is retained between frames, so the immediate-mode bet still holds; the
value-returning buttons keep working because a click this frame tests against the rectangle the widget
got last frame — stable enough that you never notice.
Containers come in opener/f.end() pairs:
f.row(justify, align)/f.column(justify, align)— lay children along the main axis (a row is horizontal), sized to their content.justifypositions them along the main axis (flare.START,flare.CENTER,flare.END,flare.BETWEEN);alignpositions them on the cross axis (flare.START,flare.CENTER,flare.END,flare.STRETCH).f.row_grow(...)/f.column_grow(...)— the same, but flex to fill the parent’s main axis.f.spacer()— a flexible gap that swallows the leftover space, pushing whatever follows to the far edge (that’s how the toolbar’s actions reached the right).f.strut(w, h)— a fixed minimum size, e.g. to pin a sidebar to a width.f.panel_begin(justify, align)— a container with a painted surface, for a sidebar or a card.f.scroll_begin(key)/f.scroll_end(key)— a clipped, wheel-scrolled viewport;f.scroll_to_bottom(key)jumps it to the end.
A two-pane application skeleton — a pinned sidebar beside a growing, scrollable main pane — falls right out of those:
f.row_grow(flare.START, flare.STRETCH) // the body fills the window
f.panel_begin(flare.START, flare.START) // a painted sidebar…
f.strut(220, 0) // …pinned to 220px wide
f.heading("Notes")
if f.primary("+ New note") { make_note() }
f.text_muted("Recent")
f.end()
f.column_grow(flare.START, flare.STRETCH) // the main pane takes the rest
f.scroll_begin("body")
f.paragraph(current, 600) // wrapped prose, 600px wide
f.scroll_end("body")
f.end()
f.end() // close the body row
Animation: springs and FLIP
Motion rides the same keyed state as everything else, and it steps on a fixed timestep, so an
animation is a pure function of the frame count — deterministic, replayable, golden-testable, never
tied to the wall clock. There are three pieces, and examples/graphics/18_flare_anim.ig runs all of
them.
A spring eases a named value toward a target, one fixed step per frame. f.spring("panel_w",
target) returns the value’s current position; point it at a new target on any frame and it redirects
smoothly with its velocity intact — which is just what an interactive UI needs, since the user keeps
poking at things mid-motion. Drive a width, a scale, an offset:
var tw = 160.0
if expanded { tw = 460.0 }
let w = f.spring("panel_w", tw) // eases between the widths; retargets mid-flight
f.panel_begin(flare.START, flare.CENTER)
f.strut(to_int(w), 56)
f.label("width springs to {to_int(w)}px")
f.end()
(f.spring_with(key, target, stiffness, damping) tunes the feel if the default bounce isn’t right.)
f.at(dx, dy) { … } f.end_at() shifts the paint of everything inside it by a pixel offset
without moving it in the layout solve, so a subtree slides over its neighbours — feed it a spring
and you have a drawer or a toast.
f.animate_layout(key) { … } f.end_animate_layout() animates a widget that moved because the
layout changed — a row was inserted, a list reordered. This is the well-known FLIP trick, and Flare
gets it almost for free: it already re-solves real flexbox every frame and caches each widget’s
last-frame rectangle, so last frame’s position is the “before” and this frame’s is the “after,” and
the spring just rides the difference, at paint time. Give each item a stable key so the motion
follows the item, not the slot it happens to occupy:
var i = 0
loop {
if i == items.len() { break }
f.animate_layout("row:{items[i]}") // stable key → the animation follows this row
f.panel_begin(flare.START, flare.CENTER)
f.label("Row #{items[i]}")
f.end()
f.end_animate_layout()
i = i + 1
}
Add or remove a row at the top and the rows below slide to their new places instead of jumping. The
determinism is the quietly useful part: because motion is a function of frame count, the animation
goldens (tests/graphics/flare_spring.ig, flare_flip.ig) reproduce frame-for-frame.
Appearing and disappearing
Two more keyed-state primitives handle an element’s lifecycle, not just its motion. f.presence(key,
present) -> float springs from 0 toward 1 the first frame a key is seen — so it animates in — and
back toward 0 once you pass present = false, animating out; keep drawing the leaving element until
presence returns near 0, then drop it from your data. Pair it with f.at for a fade-and-slide:
let p = f.presence("row:{id}", !leaving)
f.at(0.0, (1.0 - p) * 16.0)
// ...draw the row...
f.end_at()
if leaving && p < 0.02 { remove(id) } // the exit has finished
f.fade_begin(amount) … f.fade_end() is the opacity bracket: everything painted between them is
composited at amount (0–1), so a whole subtree can dim, disable, or sit behind a scrim as one. At
full opacity it’s a no-op, so an un-faded screen stays byte-identical — the goldens don’t budge.
Toasts
For transient feedback — “Copied”, an error, a confirmation — f.toast(text) enqueues a notification,
and f.toast_layer() (called once per frame, after finish()) draws and ages the stack as fading
pills that dismiss themselves on a timer. A toast can carry an action: f.toast_action(text, label,
token) puts a button on the pill, and f.take_action() returns the token for one frame when it’s
pressed — which is the entire reversible-“Undo” pattern: snapshot the thing, delete it, show
“Deleted · [Undo]”, and re-insert if the token comes back.
The widget catalogue
Everything you emit between f.begin() and f.finish():
- Actions —
f.button(txt) -> bool(a secondary button),f.primary(txt) -> bool(the one headline action, in the accent colour), andf.ghost_button(txt) -> bool(borderless, no fill at rest — for toolbars and a message’s Copy/Retry). All returntrueon the frame they’re clicked, and all size to their content — a bare button doesn’t stretch to fill the column it sits in. When you want a full-width block instead — a sidebar’s “New chat”, a stacked call-to-action — reach forf.button_fill(txt)/f.primary_fill(txt), the variants that fill a stretch parent’s cross axis. - Choice —
f.segmented(key, options, selected) -> intis a single-choice strip (the chosen option filled with the accent, the rest plain) that returns the new index, so it readsmode = f.segmented("mode", opts, mode)— the natural fit for a small settings toggle. - Navigation —
f.nav_item(txt, active) -> boolis a full-width sidebar row that grows to the panel’s width and takes the accent fill whenactive; pair it in arowwith a trailingf.ghost_button("···")for per-item actions.f.avatar(glyph)is a small rounded identity badge. - Text —
f.heading(s),f.label(s),f.text_muted(s)for a quieter line, andf.divider()for a full-width hairline rule. - Prose —
f.paragraph(text, width)word-wraps plain text to a pixel width.f.rich_text(text, width)wraps prose with inline Markdown emphasis —**bold**,*italic*,`code`, and[links](url).f.markdown(text, width)goes the whole way: it parses the text withstd/markdowninto theBlockenum and renders each block with amatch— prose and bullets, size-stepped headings, blockquotes with an accent bar, pipe tables as an aligned grid, and fenced code blocks in a monospace panel syntax-highlighted bystd/highlight(whose text you can select and copy). The renderer is one exhaustivematchover an enum, which is Chapter 8 doing exactly what it’s for. - Input —
f.text_field(key, value) -> stringis a full single-line editor (caret, selection, horizontal scroll, clipboard, UTF-8-correct cursor motion) that returns the current text;f.submit() -> boolreportstrueon the frame Enter was pressed, and clears the field.f.text_area(key, value) -> stringis its multi-line sibling — it auto-grows then scrolls, has a 2-D caret, and takes Shift+Enter for a newline while plain Enter still reports throughsubmit()(the composer convention). The loop owns the string:
input = f.text_field("composer", input)
if f.submit() {
if input.len() > 0 { rows.append(input) }
input = ""
}
A handful of containers earn their place once an app grows up: f.bubble_begin() / f.bubble_end()
wrap a rounded, tinted message bubble; f.page_begin(width) / f.page_end() centre a fixed-width
readable column with flexible margins either side (CSS max-width + margin: auto, in other words);
f.scroll_begin_sticky(key) is a transcript viewport that follows the bottom as content grows but
only while you’re already there, so scrolling up to re-read leaves you put — paired with
f.scroll_fab(key) -> bool, the round “jump to latest” button that appears once you’ve scrolled away;
and f.splitter(key, size, lo, hi, vertical) -> int is a draggable handle between two panes that
returns the pane’s new size for you to store and feed back next frame.
Overlays: modals and popovers
Two floating layers sit above the main UI, and both rest on std/layout’s floating node, so they land
correctly however deep in the tree you declare them.
f.modal_begin(key, w, h) -> bool / f.modal_end() open a centred dialog over a dimmed scrim
(pass h = 0 to size to content). While it’s open the widgets behind it go inert — clicks can’t fall
through — and a press on the scrim outside the panel returns false, the caller’s cue to close it.
Build the contents as a column between the two calls; it’s the reusable basis for a settings dialog, a
confirmation, a picker:
if settings_open {
if !f.modal_begin("settings", 420, 0) { // false → pressed outside; close it
settings_open = false
}
f.heading("Settings")
theme = f.segmented("theme", ["Light", "Dark"], theme)
if f.primary("Done") { settings_open = false }
f.modal_end()
}
f.popover_begin(key, x, y) -> bool / f.popover_end() open an anchored menu at a point — no
scrim — with the same press-outside-to-close behaviour, filled with f.menu_item(txt) -> bool rows.
The cursor position is the usual anchor, which makes it the natural context menu or dropdown.
Theme, zoom, and what it all rests on
f.use_dark() and f.use_light() re-theme the entire app — the default is a warm “parchment and
clay” look — and f.bg() hands you the current background colour to pass to draw.begin.
f.set_zoom(pct) sets the app-wide text size (clamped 60–220 — pick a sensible default at startup,
e.g. f.set_zoom(80)) and f.zoom_by(delta) nudges it for accessibility. None of this is
special-cased: Flare delegates
everything to std/ui, so the theme, the per-frame UI tape (a record of input and draw commands, in
the spirit of the execution tape from
Chapter 19), and contracts all carry over unchanged.
Flare is a few hundred lines of Ingle over std/ui and std/layout — you could have written it.
Staying at sixty
The loop rebuilds every frame, so two costs have to stay flat as content grows — and Flare keeps both
flat. List virtualization — f.virtual_begin(key, count), then f.virtual_item(i) /
f.virtual_item_end() per row, then f.virtual_end() — builds and lays out only the rows whose
extent falls in the scroll viewport (plus a little overscan), with strut spacers preserving the
scrollbar, so a list of ten thousand rows costs about a screenful. And when nothing is happening the
loop stops spinning: it blocks on the OS event queue once there’s no input, nothing animating
(f.is_animating()), and no reply streaming, so a still app falls from ~99% of a CPU core to ~0% while
still waking instantly on the next event. Together they’re why a long, live chat transcript holds 60fps.
What’s not there yet
True to the book’s deal (Chapter 23), here are the edges:
checkboxandsliderexist instd/uibut aren’t wrapped into the Flare flexbox model yet — for now abuttonor asegmentedbound to avardoes the job.- Inline Markdown emphasis renders, but with faux faces.
rich_textandmarkdownnow draw**bold**and*italic*— but only one weight of the UI font is embedded, so bold and italic are synthesised (thickened and slanted) rather than drawn from real face files. It reads fine; it isn’t yet typographically honest (OFI-077, open). - Selection in
markdownis per-block. You can drag-select and copy within a single code or prose block, but not yet across blocks.
Fireside trivia. The vocabulary Flare settled on —
row,column,spacer,grow,justify,align— isn’t an accident. It’s flexbox, the layout model every front-end developer (and every model trained on their code) knows cold. Pair it with events-as-return-values, where the handler sits physically next to the widget, and you get a UI dialect with almost no hidden control flow: no callback indirection, no effect scheduling, no retained tree mutating behind your back. The whole stack, layout solver included, is itself a handful of readable Ingle files.