# jvui An immediate-mode GUI toolkit in the shape of [dvui](https://github.com/david-vanderson/dvui), written in **jolt**, calling **SDL3** directly. There is no shared object here and nothing in this directory is compiled. The layout, the widget identity, the event routing and every widget are jolt; `jolt.ffi` binds libSDL3 and libSDL3_ttf the way [glimmer-gfx](../glimmer-backends/glimmer-gfx) binds Xlib. ```clojure (ns myapp (:require [jvui.app :as app] [jvui.widgets :as w])) (def n (atom 0)) (defn render [] (w/page {:max-width 420} (w/card {} (w/title "Counter") (w/label (str "Count: " @n)) (w/hbox {:spacing 8} (when (w/button "- 1") (swap! n dec)) (when (w/button "+ 1" {:kind :primary}) (swap! n inc)))))) (defn -main [& _] (app/run! render {:title "counter"})) ``` You call a widget and what it answers is what the person did. There is no widget object to hold, no handler to register and nothing to free — a button that is not called this frame is not on the screen, which is what the whole style is for. ## Running ```bash jolt test # headless: no window, no display, no SDL jolt counter # a window jolt showcase # every widget on one page jolt showcase --shot # three frames, then a BMP in /tmp ``` SDL3 and SDL3_ttf are *system* libraries here, not this repo's crates, and they must be ones the jolt binary can load — on a nix-built jolt the host `/usr/lib` copies are a different glibc and fail before `dlopen` returns. Any of the usual system faces is found automatically; `JVUI_FONT` names another. ## Why this is not a fourth backend `crates/jolt-vidya`, `crates/jolt-tui` and `zig/jolt-zvui` all export the same retained-tree ABI: a tree the caller mutates between frames, one call that walks it, a queue of what the person did. That design puts the widget vocabulary *in* the native object, which is what makes those three interchangeable — and what means a new widget is a Rust or Zig change. jvui inverts it. jolt owns the tree, the layout and the widgets; SDL owns a window, an event pump, a rectangle and a glyph. A new widget is a function in [`src/jvui/widgets.clj`](src/jvui/widgets.clj), and nothing under it has to know. src/jvui/sdl.clj SDL3 and SDL3_ttf; the entire foreign surface src/jvui/paint.clj rectangles, rounded corners, text src/jvui/font.clj one face, some sizes, two caches src/jvui/core.clj identity, layout, event routing, the frame loop src/jvui/widgets.clj the widgets src/jvui/theme.clj colours and metrics, in one map src/jvui/app.clj open a window and drive it ## How a one-pass layout knows a size it has not measured yet An immediate-mode toolkit walks the tree once and must place a container before it has seen the container's children. dvui's answer, kept here, is to remember: every widget stores the size it turned out to need under a stable id, and the *next* frame uses that number. A leaf knows its own size at once — a label measures its string — so only containers lag, and they lag exactly one frame. `core/frame!` closes that gap before anything reaches the screen. When a stored size changes it walks again, up to three times, drawing nothing and delivering no events, and only the settled pass paints. So a new page appears laid out, not laid out on its second frame, and a click cannot be eaten by a pass whose output was thrown away. Once the layout stops moving the extra walks stop too, which is almost every frame. An id is a hash of its parent's id and the widget's index among its siblings — or of the parent and a `:key`, which *replaces* the index rather than joining it. No macro, no call-site capture. Replacing is the whole point: a list that reorders gives every widget after the moved one a new index, and a key that still carried the index would do nothing to stop each of them inheriting the caret, the scroll offset and the drag of whichever widget used to sit there. That is the bug class zvui's README describes from the other side, and `jolt test` reorders a list and checks it. ## Two numbers that decided the design Both were measured here, and both sent the obvious approach back. **A foreign call costs 0.2 µs; `ffi/write-array` costs 0.6 µs per element.** The first draft of `paint.clj` batched everything into a packed `SDL_Vertex` buffer and issued one `SDL_RenderGeometry` per frame, which is how dvui's own backends do it. Filling that buffer from jolt costs six milliseconds for a few thousand vertices — the crossing was never the problem, the copy was. So the unit here is the call, not the vertex: a rectangle is one `SDL_RenderFillRect` (about 3 µs end to end) and text is one blit of a cached texture. A rounded rectangle is three rectangles plus four blits from a single antialiased white disc uploaded at startup, tinted by colour-mod — which is also why nothing here needs a tessellator. **`swap!` costs 4 µs.** The layout walk touches a box's running counters once per widget, and with those counters living in the context map a page of a hundred widgets spent most of a frame re-associng numbers no one else could see. They are now a five-slot `double-array` per box: written by one walker, in order, dead at the end of the frame. That one change took a label from 35 µs to 5 µs and the showcase page from 8.7 ms a frame to 4.4. What is left is honest: about 5 µs per label and 20 µs per interactive widget, so a page of forty widgets is a millisecond or two of jolt per frame. The showcase, with a fifty-row scroll list, is 4.4 ms. That is a toolkit, not a slideshow, but it is also the ceiling — the remaining cost is `swap!` in the event and state paths, and it would come down the same way the counters did. ## Using it under glimmer [`../glimmer-backends/glimmer-jvui`](../glimmer-backends/glimmer-jvui) is a glimmer backend over this: the reconciler's retained tree is thirty lines of atoms, and a walk over it calls the widgets below. It is the smallest of glimmer's four backends, because the toolkit it needs already exists here. ## The vocabulary Containers, all macros over `core/box*`: `box` `vbox` `hbox` `card` `page` `scroll`. Widgets: `label` `title` `dim-label` `button` `checkbox` `slider` `progress` `text-entry` `separator` `spacer`. Options a container takes: `:dir` `:spacing` `:padding` `:margin` `:expand` `:gravity` `:key` `:fill` `:border` `:radius` `:min-size` `:clip?` `:offset` `:fixed`. `:expand` is `:none` `:horizontal` `:vertical` `:both` and `:gravity` is `[gx gy]`, each 0..1, placing a widget in whatever space it did not take. Events reaching a widget: hover, press, click, drag through capture, focus, Tab and shift-Tab, typed text, the editing keys, and the wheel. ## Not done * **One font, one style.** No bold, no italic, no per-run font. `font.clj` opens one face at whatever sizes are asked for. * **No text selection** and no clipboard in `text-entry`, though `sdl/clipboard` and `sdl/clipboard!` are bound and waiting. * **Vertical scrolling only.** `scroll` clips both axes and scrolls one. * **No animation clock**, so no transitions and no cursor blink — the frame loop runs at a fixed 16 ms and does not know what time it is. * **No menus, no dialogs, no floating layers.** Everything is in one painter-ordered pass, and a popup needs a second one. * **`:gravity`'s cross axis is honoured; `:align` on text is `:left` `:center` `:right` and nothing else.**