nandi/jolt-nativepublic Fork 0
258bbc5161b93d580a4c84363acabe63b0624e88
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.

Let a face answer the pointer fa4ecdf · on 258bbc5161b93d580a4c84363acabe63b0624e88 · nandi · 19d ago
core.jolt · 454 lines · 17.9 KBGDScript3 Blame HistoryRaw
  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
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
(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))
          ;; The two edges of a pointer hover, not the middle of one: the
          ;; backend says so when it starts and again when it ends, so a
          ;; handler can put something on screen and take it away again.
          "hover"    (when-let [f (:on-hover hs)] (f))
          "unhover"  (when-let [f (:on-unhover 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 set-title!
  "Rename the open window. `run` names it once at startup; this is for a title
  that has something to say later  who is signed in, which document is open,
  what a second window of the same app is for.

  A no-op before the window exists and after it closes."
  [title]
  (ffi/set-title! (str title))
  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 pick-image!
  "Open the platform's own picture chooser. True when one opened, false where
  there is none  a desktop, or an Android activity that does not offer it 
  and a false is the caller's cue to browse the filesystem itself.

  It does not answer with the picture. The reader is in another screen by then,
  so what they chose arrives at `picked-image!`, which the caller polls."
  []
  (ffi/pick-image!))

(defn picked-image!
  "Move the picture chosen since the last call to `path`; true when there was
  one. The answer is handed over once, so a poll still running does not take
  the same picture twice."
  [path]
  (ffi/picked-image! path))

(defn screen-size
  "The window's size in points, as `[width height]`. `[0 0]` before the first
  frame has been painted.

  For laying something out as a share of the window  a row of tiles that
  should divide the width between them  where how many there are and how much
  gap goes between them is the caller's arithmetic, and not something one
  widget can work out from the space it was handed.

  Follows the window as it is dragged, so a component that reads it wants to be
  re-rendered when it changes: keep it in a ratom rather than asking here at
  render time, or the layout will be whatever it was on the first frame."
  []
  [(ffi/screen-width) (ffi/screen-height)])

(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))