nandi/jolt-nativepublic Fork 0
109c7e403bee6fa2ec5a768c39d45341fc10cd6e
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.

Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 · on 109c7e403bee6fa2ec5a768c39d45341fc10cd6e · Veronika Winters · 9d ago
core.clj · 233 lines · 9.4 KBClojure 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
(ns glimmer-jvui.core
  "A glimmer backend that renders through [jvui](../../../jvui).

   glimmer owns the reactive core — ratoms, components, the reconciler — and
   knows nothing about any toolkit. glimmer-gtk fills that in with GtkWidgets,
   glimmer-vidya with a Rust node arena painted by egui, glimmer-gfx with a
   software rasterizer it writes itself. This fills it in with jvui, and so is
   the smallest of the four: everything a backend usually has to supply — the
   measuring, the placing, the hit testing, the painting — is already a toolkit
   one directory over.

   What is left is the half an immediate-mode library does not have: a tree to
   hold still between frames. The reconciler needs somewhere to put a widget it
   created and to append a child to, and jvui's widgets draw and return within
   one call. So a node here is an atom of {:tag :props :children :key}, about
   thirty lines of it, and once a frame `emit!` walks that tree and calls the
   jvui widget each node names.

   # The walk is the closure

   glimmer-vidya's README explains why its tree lives in Rust: `ScrollArea` and
   `Frame` take an `FnOnce(&mut Ui)` and keep their begin/end private, so a
   push/pop ABI cannot scroll a page. jvui's containers take a body function
   for the same reason, and here the recursion *is* that function — `emit!` on
   a container passes `emit-children!` as the body, and the nesting takes care
   of itself.

   # Why every node carries a key

   jvui identifies a widget by its parent and its index among its siblings,
   unless it is given a `:key`, which replaces the index. A reconciler reorders
   children; an identity built on the index would hand each widget after the
   moved one the caret, the scroll offset and the drag of whichever widget used
   to sit at its index. So every node gets a serial number at creation and
   passes it as its key, and the identity follows the node rather than its
   position. That is the bug class zvui's README describes from the backend
   side, closed here at the other end."
  (:require [glimmer.backend :as b]
            [jvui.app :as app]
            [jvui.core :as c]
            [jvui.theme :as theme]
            [jvui.widgets :as w]))

;; --- the retained tree -------------------------------------------------------

(defonce ^:private serial (atom 0))

(defn- create! [tag props]
  (atom {:tag tag :props props :children [] :key (swap! serial inc)}))

(defn- apply-props! [_tag n props] (swap! n assoc :props props) nil)
(defn- append-child! [_t parent child] (swap! parent update :children conj child) nil)
(defn- remove-child! [_t parent child]
  (swap! parent update :children #(vec (remove #{child} %))) nil)
(defn- replace-child! [_t parent old new]
  (swap! parent update :children #(mapv (fn [c] (if (= c old) new c)) %)) nil)
(defn- reorder-child! [_t parent child sibling]
  (swap! parent update :children
         (fn [cs]
           (let [cs (vec (remove #{child} cs))
                 i (if (nil? sibling) 0 (inc (.indexOf cs sibling)))]
             (vec (concat (subvec cs 0 i) [child] (subvec cs i))))))
  nil)

;; --- props -------------------------------------------------------------------

(defn- txt [props] (str (or (:label props) (:text props) "")))

(defn- num [v default] (if (number? v) (double v) default))

(defn- box-opts
  "The container options shared by every container tag."
  [props key]
  (cond-> {:key key
           :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)}
    (:spacing props) (assoc :spacing (num (:spacing props) 0.0))
    (:padding props) (assoc :padding (num (:padding props) 0.0))
    (:margin props)  (assoc :margin (num (:margin props) 0.0))
    (:expand props)  (assoc :expand (:expand props))))

(defn- fire! [n k & args]
  (when-let [f (get (:props @n) k)] (apply f args)))

;; --- the walk ----------------------------------------------------------------

(def ^:dynamic *record-rects?*
  "When true, each node keeps the rectangle jvui gave it, under `:rect`.

   Off in a running window, where it would be a `swap!` per node per frame for
   nobody's benefit. On under `render-once`, so a test can click the centre of
   a button the way a person would, rather than guessing at a coordinate and
   re-guessing every time a padding changes."
  false)

(declare emit!)

(defn- record! [n id]
  (when *record-rects?* (swap! n assoc :rect (c/rect-of id)))
  nil)

(defn- emit-children! [n]
  (fn [_id _rect] (doseq [c (:children @n)] (emit! c))))

(defn- emit!
  "Render one node, and through it everything below it.

   A widget answers what the person did to it, and that answer is turned back
   into the callback prop the component registered — which is the whole seam
   between an immediate-mode toolkit and a retained, callback-shaped one."
  [n]
  (let [{:keys [tag props key]} @n
        s (txt props)]
    (case tag
      :page (w/page* (cond-> {:key key}
                       (:max-width props) (assoc :max-width (:max-width props)))
                     (emit-children! n))

      (:card :frame) (w/card* (box-opts props key) (emit-children! n))

      :scroll (w/scroll* (assoc (box-opts props key)
                                :height (num (:height props) 200.0))
                         (emit-children! n))

      :hbox (c/box* (assoc (box-opts props key) :dir :horizontal)
                    (emit-children! n))

      (:vbox :box) (c/box* (box-opts props key) (emit-children! n))

      :title (w/title s)

      :label (if (:dim props) (w/dim-label s) (w/label s))
      :dim-label (w/dim-label s)

      :button (let [id (c/next-id key)
                    hit? (w/button s {:key key :kind (or (:kind props) :normal)})]
                (record! n id)
                (when hit? (fire! n :on-click)))

      :checkbox (let [was (boolean (:checked props))
                      id (c/next-id key)
                      now (w/checkbox was s {:key key})]
                  (record! n id)
                  (when (not= now was) (fire! n :on-change now)))

      :slider (let [was (num (:value props) 0.0)
                    id (c/next-id key)
                    now (w/slider was {:key key
                                       :min (num (:min props) 0.0)
                                       :max (num (:max props) 100.0)})]
                ;; == and not not=, because a component holding a long 0 must
                ;; not be told every frame that its slider moved to 0.0
                (record! n id)
                (when-not (== now was) (fire! n :on-change now)))

      (:entry :text-entry)
      (let [was (str (or (:value props) (:text props) ""))
            id (c/next-id key)
            now (w/text-entry was {:key key :placeholder (:placeholder props)})]
        (record! n id)
        (when (not= now was) (fire! n :on-change now)))

      :progress (w/progress (num (:value props) 0.0))
      :separator (w/separator)
      (:spacer :gap) (w/spacer {:size (num (:size props) 8.0)
                                :expand (:expand props :none)})

      ;; An unknown tag is a container rather than an error, so a tree written
      ;; against a richer backend still shows its contents here — the same
      ;; bargain jolt-zvui makes with the tags it does not know.
      (c/box* (box-opts props key) (emit-children! n)))))

;; --- the loop ----------------------------------------------------------------

(defonce ^:private pending (atom []))

(defn- schedule! [work] (swap! pending conj work) nil)

(defn- drain-pending! []
  (let [[ws] (reset-vals! pending [])]
    (doseq [w ws] (w))))

(defn- run!
  "glimmer.backend's :run. Creates the root page, mounts into it, then hands the
   loop to jvui.

   The reconciler's queued work is drained by jvui's `:before` hook rather than
   inside the walk: a re-render patches the tree, and patching a tree while it
   is being walked is how a frame ends up half old and half new."
  [opts mount-root!]
  (let [{:keys [title width height max-width theme frames auto-quit-ms shot]
         :or {title "glimmer" width 720 height 520}} opts
        root (create! :page (cond-> {} max-width (assoc :max-width max-width)))]
    (mount-root! root :page)
    (reset! b/loop-running? true)
    (try
      (app/run! (fn [] (emit! root))
                {:title title :width width :height height
                 :theme (or theme theme/dark)
                 :before drain-pending!
                 :frames frames :auto-quit-ms auto-quit-ms :shot shot})
      (finally (reset! b/loop-running? false)))))

;; --- registration ------------------------------------------------------------

(def backend
  {:name :jvui
   :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!})

(b/register! backend)

;; --- headless driving, for tests ---------------------------------------------

(defn root-node
  "A bare root page, for mounting into without a window."
  [] (create! :page {}))

(defn render-once
  "Walk `root` through jvui with no window, no font and no display.

   `cx` is a `jvui.core/context`; `evs` the events that frame. Answers the
   context, whose `:data` is every rectangle the walk placed — which is enough
   for a test to assert about a layout and to click on it."
  ([root cx] (render-once root cx []))
  ([root cx evs]
   (drain-pending!)
   (swap! cx assoc :events evs)
   (swap! cx c/apply-input evs)
   (binding [*record-rects?* true]
     (c/frame! cx (fn [] (emit! root))))
   cx))