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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
|
(ns glimmer-vidya.core
"The Vidya/egui backend for glimmer. Requiring this namespace installs it,
after which glimmer's portable reconciler renders hiccup into a GPU window:
(ns myapp
(:require [glimmer.ratom :refer [atom]]
[glimmer.core :as ui]
[glimmer-vidya.core])) ; installs this backend
(defn -main [& _] (ui/run my-app :title \"myapp\"))
egui is an immediate-mode toolkit: it has no widgets to hand a reconciler,
only calls you make every frame. So the widget tree lives one layer down, in
the Rust library, and this namespace is the thin part — it turns glimmer's
create/patch/append/remove into node mutations, runs the frame loop, and
routes the events that come back to the handlers the components declared.
A widget, from the reconciler's point of view, is an integer node handle. It
never looks inside one, which is exactly why an id is enough.
**Handlers do not cross the FFI.** A jolt closure cannot be a callback in a
library painting at 60fps, so identity travels instead: a node reports that
it was clicked, and the handler map here says whose `:on-click` that was."
(:require [glimmer.backend :as b]
[glimmer-vidya.ffi :as ffi]))
;; Node id -> the :on-* props that node was last rendered with. Kept here
;; rather than sent across because a closure has no C representation.
;;
;; Ids are recycled by the arena, which is safe only because every id is
;; written here by `create!` before anything can raise an event against it — a
;; reused id has its predecessor's handlers overwritten in the same breath.
(defonce ^:private handlers (atom {}))
;; Work posted from other threads, run on the loop thread at the top of a tick.
(defonce ^:private pending (atom []))
;; Set by quit!, read by the loop.
(defonce ^:private quit-requested (atom false))
;; --- props -------------------------------------------------------------------
;; :hbox and :vbox are one node in the library; the tag only implies an
;; orientation, and an explicit :orientation prop still wins.
(def ^:private tag-orientation {:hbox "horizontal" :vbox "vertical"})
(defn- handler-key?
"True for a prop that names an event handler rather than a value."
[k]
(let [s (name k)]
(and (> (count s) 3) (= "on-" (subs s 0 3)))))
(defn- set-prop!
"Write one prop to a node, in the ABI type that fits its value. nil clears
nothing — the prop was already dropped by the clear that precedes a write —
and an unrecognized value is stringified rather than refused, so a prop this
backend has not learned yet still reaches the library."
[node k v]
(let [key (name k)]
(cond
(nil? v) nil
(true? v) (ffi/node-set-bool! node key true)
(false? v) (ffi/node-set-bool! node key false)
(number? v) (ffi/node-set-num! node key (double v))
(string? v) (ffi/node-set-str! node key v)
(keyword? v) (ffi/node-set-str! node key (name v))
:else (ffi/node-set-str! node key (str v)))))
(defn- write-props!
"Replace a node's props with `props`.
Cleared first, deliberately: a re-render that stops setting `:placeholder`
means the placeholder is gone, and patching in place would leave the old one
behind. It also discards the value the library wrote back when the user typed
in an entry or clicked a checkbutton — which is the point. The component's
state is the truth, and this is the frame where it says so."
[node tag props]
(ffi/node-clear-props! node)
(when-let [orientation (tag-orientation tag)]
(when-not (contains? props :orientation)
(ffi/node-set-str! node "orientation" orientation)))
(doseq [[k v] props]
(when-not (handler-key? k)
(set-prop! node k v)))
(swap! handlers assoc node
(reduce (fn [acc [k v]]
(if (and (handler-key? k) (fn? v)) (assoc acc k v) acc))
{}
props))
nil)
(defn- forget-dead-handlers!
"Drop handler entries for nodes the library has freed.
Removing a subtree frees every node under it, and only the library knows
which those were — so rather than mirror the tree here to walk it, the map is
filtered against what still exists. It runs once per removal, over a map the
size of the UI."
[]
(swap! handlers
(fn [m]
(reduce (fn [acc [id hs]]
(if (ffi/node-exists? id) (assoc acc id hs) acc))
{}
m)))
nil)
;; --- the backend operations --------------------------------------------------
(defn- create!
"glimmer.backend's :create!. Children are appended by the reconciler, not
here."
[tag props]
(let [node (ffi/node-new (name tag))]
(when (zero? node)
(throw (ex-info "vidya could not allocate a node" {:tag tag})))
(write-props! node tag props)
node))
(defn- apply-props! [tag node props] (write-props! node tag props))
(defn- append-child! [_parent-tag parent child]
(ffi/node-append! parent child)
nil)
(defn- remove-child! [_parent-tag parent child]
;; The library frees the subtree; glimmer never mentions it again.
(ffi/node-remove! parent child)
(forget-dead-handlers!)
nil)
(defn- replace-child! [_parent-tag parent old-child new-child]
(ffi/node-replace! parent old-child new-child)
(forget-dead-handlers!)
nil)
(defn- reorder-child! [_parent-tag parent child sibling]
;; nil sibling means "first"; the ABI spells that 0.
(ffi/node-insert-after! parent child (or sibling 0))
nil)
;; --- the UI thread -----------------------------------------------------------
(defn- schedule
"glimmer.backend's :schedule. Every node call belongs to the thread that
opened the window, so a ratom mutated on an nREPL worker (or any future)
queues its re-render here and the loop performs it on the next tick."
[work]
(swap! pending conj work)
nil)
(defn- drain!
"Run everything `schedule` queued. compare-and-set! rather than reset!, so
work posted while the queue is being taken is not dropped."
[]
(loop []
(let [q @pending]
(when (seq q)
(if (compare-and-set! pending q [])
(doseq [f q] (f))
(recur))))))
;; --- timers ------------------------------------------------------------------
;; A spinner or a clock has to change with nothing being pressed. The loop
;; already wakes every frame, so a timer is a due time and a thunk. Both
;; entry points are safe to call from another thread, and both run their thunk
;; ON the loop thread, the only one allowed to touch nodes.
(defonce ^:private timers (atom {:next-id 0 :entries {}}))
(defn- now-ms [] (System/currentTimeMillis))
(defn- add-timer! [ms every? f]
(let [id (:next-id (swap! timers update :next-id inc))]
(swap! timers assoc-in [:entries id]
{:due (+ (now-ms) ms) :every (when every? ms) :f f})
id))
(defn after!
"Run `f` on the loop thread in about `ms` milliseconds. Returns an id for
`cancel!`. Resolution is one frame."
[ms f] (add-timer! ms false f))
(defn every!
"Run `f` on the loop thread about every `ms` milliseconds until cancelled:
(every! 80 #(swap! tick inc))"
[ms f] (add-timer! ms true f))
(defn cancel!
"Stop the timer `id`."
[id] (swap! timers update :entries dissoc id) nil)
(defn cancel-all!
"Stop every timer, so a repeating one does not outlive the UI it animated."
[] (swap! timers assoc :entries {}) nil)
(defn- pump-timers! []
(let [t (now-ms)
due (reduce (fn [acc [id e]] (if (<= (:due e) t) (conj acc [id e]) acc))
[]
(:entries @timers))]
(doseq [[id e] due]
(if-let [period (:every e)]
(swap! timers assoc-in [:entries id :due] (+ t period))
(swap! timers update :entries dissoc id))
((:f e)))
nil))
;; --- events ------------------------------------------------------------------
(defn- dispatch-events!
"Drain the frame's interactions and call the handlers they belong to.
An event whose node has no handler for it is dropped, which is what makes a
control that ignores its own event still work: the library wrote the new
state into the node, and the next render either confirms it or overwrites it."
[]
(loop []
(when (ffi/poll-event!)
(let [node (ffi/event-node)
kind (ffi/event-name)
hs (get @handlers node)]
(case kind
"click" (when-let [f (:on-click hs)] (f))
"toggled" (when-let [f (:on-toggled hs)] (f))
;; The text is read before anything else can overwrite the library's
;; one scratch buffer — jolt copies it as it crosses.
"change" (when-let [f (:on-change hs)] (f (ffi/event-text)))
"activate" (when-let [f (:on-activate hs)] (f))
;; Ctrl+V on a clipboard with no text on it. What is on it instead is
;; the caller's to find out — `clipboard-image-png!` is the only
;; question the backend answers about it.
"paste-empty" (when-let [f (:on-paste-empty hs)] (f))
nil))
(recur))))
;; --- the event loop ----------------------------------------------------------
(defn- clear-children!
"Drop everything under `node`. Removing a child frees it, so this walks the
first slot until there is nothing left rather than iterating an index."
[node]
(loop []
(when (pos? (ffi/node-child-count node))
(ffi/node-remove! node (ffi/node-child-at node 0))
(recur)))
(forget-dead-handlers!)
nil)
(defn window-width
"The width in points of the window's content area, as the last painted frame
measured it. 0 before the first frame.
This is how a layout asks how much room it has: nothing else here reports a
size, and the window is not resizable from this side either. Read it from a
timer — `every!` — rather than per render, and hold it in a ratom, so that
the components which switch on it re-render only when it actually changes."
[]
(ffi/node-get-num (ffi/tree-root) "window-width"))
(defn clipboard-image-png!
"Write the picture on the system clipboard to `path` as a PNG. True when
there was one; false for an empty clipboard, text on it, an unwritable path,
or a platform with no clipboard of images (Android).
A paste of a picture reaches no handler of its own — the backend delivers
clipboard text only — so a caller asks for it, from whatever gesture it means
paste by. An `:entry`'s `:on-paste-empty` is that gesture where the reader
expects it: a Ctrl+V the field had no text to answer with."
[path]
(ffi/clipboard-image-png! path))
(defn open-url!
"Hand `url` to whatever shows web pages here — xdg-open or `open` on a
desktop, an ACTION_VIEW intent on Android. True when something took it;
false leaves the caller to show the URL and let the reader carry it across.
A sign-in that goes through a browser is the reason this exists: the app
leaves for a page and the page comes back to it."
[url]
(ffi/open-url! url))
(defn frame-rgba!
"Hand the backend a frame of live pixels under `key`. An `:image` node with
`:feed key` paints the latest one.
`rgba` is a foreign pointer — `width * height * 4` un-premultiplied bytes,
row-major — and is copied before this returns, so whoever owns it may reuse
it immediately. False when the length does not match the dimensions.
This is the source an `:image` `:src` cannot be: a `:src` decodes a file and
caches it by path for the life of the process, which is right for a picture
in a message and wrong for one that is new thirty times a second. Frames
coalesce rather than queue, so a source faster than the window costs nothing.
On the window's thread, like everything else here: a frame produced on a
decoder thread is the caller's to hand across."
[key width height rgba]
(ffi/frame-rgba! key width height rgba))
(defn frame-drop!
"Forget the feed named `key` and release its texture; true when there was
one. A source that has stopped keeps painting its last frame otherwise."
[key]
(ffi/frame-drop! key))
(defn quit!
"Stop the running loop and close the window."
[]
(reset! quit-requested true)
nil)
(defn- run!
"glimmer.backend's :run. Opens a window, mounts the root component into the
library's root node, and paints until the window closes or `quit!` is called.
Blocks, like every UI main loop.
Options (on top of glimmer's own):
:title :width :height the window
:fps frame rate cap (default 60)
:mode :dark (default) or :light
:font path to a TTF/OTF to use for UI text
:auto-quit-ms stop after roughly this long — for smoke tests,
which have nobody to close the window
The window is closed in a finally, so a handler that throws does not leave a
GPU surface and an event loop behind."
[opts mount-root!]
(let [{:keys [title width height fps mode font auto-quit-ms]
:or {title "glimmer" width 900 height 640 fps 60}} opts]
(when-not (ffi/open! width height title)
(throw (ex-info "vidya could not open a window"
{:title title :width width :height height})))
(reset! quit-requested false)
(ffi/set-target-fps! fps)
(ffi/set-mode! (if (= mode :light) ffi/light-mode ffi/dark-mode))
(when font (ffi/load-font! font))
(let [started (now-ms)
root (ffi/tree-root)]
(try
;; The library's root outlives a run — it is process-wide, not per
;; window — so a second `ui/run` in one session (a REPL, a test) would
;; otherwise mount its tree alongside the last one's.
(clear-children! root)
(mount-root! root :window)
(reset! b/loop-running? true)
(loop []
(drain!)
(pump-timers!)
;; One call paints the whole tree; the events it produced are read
;; straight after, while the frame that caused them is still the
;; frame the components rendered.
(ffi/tree-frame!)
(dispatch-events!)
(when-not (or @quit-requested
(ffi/should-close?)
(and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
(recur)))
(finally
(reset! b/loop-running? false)
(cancel-all!)
(ffi/close!)
(reset! handlers {}))))))
;; --- looking at what was rendered --------------------------------------------
(defn dump-str
"The rendered tree as hiccup text, read back out of the library.
With no argument, the whole window; with a node handle, that subtree. This is
the tree as it *is* after the reconciler has run, not what a component
returned, so paste it into a bug report and the two can be compared.
Two things to know when reading one. `:hbox` and `:vbox` are one node down
there and both dump as `:box`, with the orientation in the props; and no
`:on-*` appears, because handlers are held on this side and never sent."
([] (dump-str 0))
([node] (ffi/tree-dump node)))
(defn dump
"`dump-str`, read back as hiccup data — vectors, keywords and maps — for a
test that wants to assert on a subtree rather than on a string."
([] (dump 0))
([node] (read-string (dump-str node))))
(defn dump!
"Print `dump-str` to stdout. The one you want from a handler or the REPL."
([] (dump! 0))
([node] (println (dump-str node)) nil))
;; --- the backend -------------------------------------------------------------
(def backend
"The Vidya backend map handed to glimmer.backend/register!. See that
namespace for the contract each key satisfies."
{:name :vidya
:create! create!
:apply-props! apply-props!
:append-child! append-child!
:remove-child! remove-child!
:replace-child! replace-child!
:reorder-child! reorder-child!
:schedule schedule
:run run!})
(defn install!
"Make Vidya/egui the backend glimmer renders with. Called on load, so
requiring this namespace is enough; exposed for code that wants to be
explicit, or to switch back after another backend was installed."
[]
(b/register! backend)
nil)
(defonce ^:private installed (do (install!) true))
|