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 booltrue 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. justify positions them along the main axis (flare.START, flare.CENTER, flare.END, flare.BETWEEN); align positions 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():

  • Actionsf.button(txt) -> bool (a secondary button), f.primary(txt) -> bool (the one headline action, in the accent colour), and f.ghost_button(txt) -> bool (borderless, no fill at rest — for toolbars and a message’s Copy/Retry). All return true on 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 for f.button_fill(txt) / f.primary_fill(txt), the variants that fill a stretch parent’s cross axis.
  • Choicef.segmented(key, options, selected) -> int is a single-choice strip (the chosen option filled with the accent, the rest plain) that returns the new index, so it reads mode = f.segmented("mode", opts, mode) — the natural fit for a small settings toggle.
  • Navigationf.nav_item(txt, active) -> bool is a full-width sidebar row that grows to the panel’s width and takes the accent fill when active; pair it in a row with a trailing f.ghost_button("···") for per-item actions. f.avatar(glyph) is a small rounded identity badge.
  • Textf.heading(s), f.label(s), f.text_muted(s) for a quieter line, and f.divider() for a full-width hairline rule.
  • Prosef.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 with std/markdown into the Block enum and renders each block with a match — 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 by std/highlight (whose text you can select and copy). The renderer is one exhaustive match over an enum, which is Chapter 8 doing exactly what it’s for.
  • Inputf.text_field(key, value) -> string is a full single-line editor (caret, selection, horizontal scroll, clipboard, UTF-8-correct cursor motion) that returns the current text; f.submit() -> bool reports true on the frame Enter was pressed, and clears the field. f.text_area(key, value) -> string is 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 through submit() (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 virtualizationf.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:

  • checkbox and slider exist in std/ui but aren’t wrapped into the Flare flexbox model yet — for now a button or a segmented bound to a var does the job.
  • Inline Markdown emphasis renders, but with faux faces. rich_text and markdown now 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 markdown is 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.