nandi/jolt-nativepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

README.md · 158 lines · 7.4 KBmarkdown
Blame HistoryOpen raw

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/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.
  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
# 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.**