| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 1 | # jvui |
| 2 | |
| 3 | An immediate-mode GUI toolkit in the shape of |
| 4 | [dvui](https://github.com/david-vanderson/dvui), written in **jolt**, calling |
| 5 | **SDL3** directly. |
| 6 | |
| 7 | There is no shared object here and nothing in this directory is compiled. The |
| 8 | layout, the widget identity, the event routing and every widget are jolt; |
| 9 | `jolt.ffi` binds libSDL3 and libSDL3_ttf the way [glimmer-gfx](../glimmer-backends/glimmer-gfx) |
| 10 | binds Xlib. |
| 11 | |
| 12 | ```clojure |
| 13 | (ns myapp |
| 14 | (:require [jvui.app :as app] [jvui.widgets :as w])) |
| 15 | |
| 16 | (def n (atom 0)) |
| 17 | |
| 18 | (defn render [] |
| 19 | (w/page {:max-width 420} |
| 20 | (w/card {} |
| 21 | (w/title "Counter") |
| 22 | (w/label (str "Count: " @n)) |
| 23 | (w/hbox {:spacing 8} |
| 24 | (when (w/button "- 1") (swap! n dec)) |
| 25 | (when (w/button "+ 1" {:kind :primary}) (swap! n inc)))))) |
| 26 | |
| 27 | (defn -main [& _] (app/run! render {:title "counter"})) |
| 28 | ``` |
| 29 | |
| 30 | You call a widget and what it answers is what the person did. There is no |
| 31 | widget object to hold, no handler to register and nothing to free — a button |
| 32 | that is not called this frame is not on the screen, which is what the whole |
| 33 | style is for. |
| 34 | |
| 35 | ## Running |
| 36 | |
| 37 | ```bash |
| 38 | jolt test # headless: no window, no display, no SDL |
| 39 | jolt counter # a window |
| 40 | jolt showcase # every widget on one page |
| 41 | jolt showcase --shot # three frames, then a BMP in /tmp |
| 42 | ``` |
| 43 | |
| 44 | SDL3 and SDL3_ttf are *system* libraries here, not this repo's crates, and they |
| 45 | must be ones the jolt binary can load — on a nix-built jolt the host |
| 46 | `/usr/lib` copies are a different glibc and fail before `dlopen` returns. Any |
| 47 | of the usual system faces is found automatically; `JVUI_FONT` names another. |
| 48 | |
| 49 | ## Why this is not a fourth backend |
| 50 | |
| 51 | `crates/jolt-vidya`, `crates/jolt-tui` and `zig/jolt-zvui` all export the same |
| 52 | retained-tree ABI: a tree the caller mutates between frames, one call that |
| 53 | walks it, a queue of what the person did. That design puts the widget |
| 54 | vocabulary *in* the native object, which is what makes those three |
| 55 | interchangeable — and what means a new widget is a Rust or Zig change. |
| 56 | |
| 57 | jvui inverts it. jolt owns the tree, the layout and the widgets; SDL owns a |
| 58 | window, an event pump, a rectangle and a glyph. A new widget is a function in |
| 59 | [`src/jvui/widgets.clj`](src/jvui/widgets.clj), and nothing under it has to |
| 60 | know. |
| 61 | |
| 62 | src/jvui/sdl.clj SDL3 and SDL3_ttf; the entire foreign surface |
| 63 | src/jvui/paint.clj rectangles, rounded corners, text |
| 64 | src/jvui/font.clj one face, some sizes, two caches |
| 65 | src/jvui/core.clj identity, layout, event routing, the frame loop |
| 66 | src/jvui/widgets.clj the widgets |
| 67 | src/jvui/theme.clj colours and metrics, in one map |
| 68 | src/jvui/app.clj open a window and drive it |
| 69 | |
| 70 | ## How a one-pass layout knows a size it has not measured yet |
| 71 | |
| 72 | An immediate-mode toolkit walks the tree once and must place a container before |
| 73 | it has seen the container's children. dvui's answer, kept here, is to remember: |
| 74 | every widget stores the size it turned out to need under a stable id, and the |
| 75 | *next* frame uses that number. A leaf knows its own size at once — a label |
| 76 | measures its string — so only containers lag, and they lag exactly one frame. |
| 77 | |
| 78 | `core/frame!` closes that gap before anything reaches the screen. When a stored |
| 79 | size changes it walks again, up to three times, drawing nothing and delivering |
| 80 | no events, and only the settled pass paints. So a new page appears laid out, |
| 81 | not laid out on its second frame, and a click cannot be eaten by a pass whose |
| 82 | output was thrown away. Once the layout stops moving the extra walks stop too, |
| 83 | which is almost every frame. |
| 84 | |
| 85 | An id is a hash of its parent's id and the widget's index among its siblings — |
| 86 | or of the parent and a `:key`, which *replaces* the index rather than joining |
| 87 | it. No macro, no call-site capture. Replacing is the whole point: a list that |
| 88 | reorders gives every widget after the moved one a new index, and a key that |
| 89 | still carried the index would do nothing to stop each of them inheriting the |
| 90 | caret, the scroll offset and the drag of whichever widget used to sit there. |
| 91 | That is the bug class zvui's README describes from the other side, and |
| 92 | `jolt test` reorders a list and checks it. |
| 93 | |
| 94 | ## Two numbers that decided the design |
| 95 | |
| 96 | Both were measured here, and both sent the obvious approach back. |
| 97 | |
| 98 | **A foreign call costs 0.2 µs; `ffi/write-array` costs 0.6 µs per element.** |
| 99 | The first draft of `paint.clj` batched everything into a packed `SDL_Vertex` |
| 100 | buffer and issued one `SDL_RenderGeometry` per frame, which is how dvui's own |
| 101 | backends do it. Filling that buffer from jolt costs six milliseconds for a few |
| 102 | thousand vertices — the crossing was never the problem, the copy was. So the |
| 103 | unit here is the call, not the vertex: a rectangle is one |
| 104 | `SDL_RenderFillRect` (about 3 µs end to end) and text is one blit of a cached |
| 105 | texture. A rounded rectangle is three rectangles plus four blits from a single |
| 106 | antialiased white disc uploaded at startup, tinted by colour-mod — which is |
| 107 | also why nothing here needs a tessellator. |
| 108 | |
| 109 | **`swap!` costs 4 µs.** The layout walk touches a box's running counters once |
| 110 | per widget, and with those counters living in the context map a page of a |
| 111 | hundred widgets spent most of a frame re-associng numbers no one else could |
| 112 | see. They are now a five-slot `double-array` per box: written by one walker, in |
| 113 | order, dead at the end of the frame. That one change took a label from 35 µs to |
| 114 | 5 µs and the showcase page from 8.7 ms a frame to 4.4. |
| 115 | |
| 116 | What is left is honest: about 5 µs per label and 20 µs per interactive widget, |
| 117 | so a page of forty widgets is a millisecond or two of jolt per frame. The |
| 118 | showcase, with a fifty-row scroll list, is 4.4 ms. That is a toolkit, not a |
| 119 | slideshow, but it is also the ceiling — the remaining cost is `swap!` in the |
| 120 | event and state paths, and it would come down the same way the counters did. |
| 121 | |
| 122 | ## Using it under glimmer |
| 123 | |
| 124 | [`../glimmer-backends/glimmer-jvui`](../glimmer-backends/glimmer-jvui) is a |
| 125 | glimmer backend over this: the reconciler's retained tree is thirty lines of |
| 126 | atoms, and a walk over it calls the widgets below. It is the smallest of |
| 127 | glimmer's four backends, because the toolkit it needs already exists here. |
| 128 | |
| 129 | ## The vocabulary |
| 130 | |
| 131 | Containers, all macros over `core/box*`: `box` `vbox` `hbox` `card` `page` |
| 132 | `scroll`. |
| 133 | |
| 134 | Widgets: `label` `title` `dim-label` `button` `checkbox` `slider` `progress` |
| 135 | `text-entry` `separator` `spacer`. |
| 136 | |
| 137 | Options a container takes: `:dir` `:spacing` `:padding` `:margin` `:expand` |
| 138 | `:gravity` `:key` `:fill` `:border` `:radius` `:min-size` `:clip?` `:offset` |
| 139 | `:fixed`. `:expand` is `:none` `:horizontal` `:vertical` `:both` and |
| 140 | `:gravity` is `[gx gy]`, each 0..1, placing a widget in whatever space it did |
| 141 | not take. |
| 142 | |
| 143 | Events reaching a widget: hover, press, click, drag through capture, focus, |
| 144 | Tab and shift-Tab, typed text, the editing keys, and the wheel. |
| 145 | |
| 146 | ## Not done |
| 147 | |
| 148 | * **One font, one style.** No bold, no italic, no per-run font. `font.clj` |
| 149 | opens one face at whatever sizes are asked for. |
| 150 | * **No text selection** and no clipboard in `text-entry`, though |
| 151 | `sdl/clipboard` and `sdl/clipboard!` are bound and waiting. |
| 152 | * **Vertical scrolling only.** `scroll` clips both axes and scrolls one. |
| 153 | * **No animation clock**, so no transitions and no cursor blink — the frame |
| 154 | loop runs at a fixed 16 ms and does not know what time it is. |
| 155 | * **No menus, no dialogs, no floating layers.** Everything is in one |
| 156 | painter-ordered pass, and a popup needs a second one. |
| 157 | * **`:gravity`'s cross axis is honoured; `:align` on text is `:left` |
| 158 | `:center` `:right` and nothing else.** |