jvui
An immediate-mode GUI toolkit in the shape of
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
binds Xlib.
(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
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, 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 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/clipboardandsdl/clipboard!are bound and waiting. - Vertical scrolling only.
scrollclips 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;:alignon text is:left
:center:rightand nothing else.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
|