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
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
|
(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]
[jvui.frames :as frames]
[jvui.host :as host]))
;; --- 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- fills-height?
"Does any child of `n` ask to fill the height?
A row is only as tall as what is in it, and frq marks the PANES with
:fill-height rather than the row that holds them — egui gives a
horizontal layout the available height and the panes fill that, so
there is nothing there to mark. Here the row has to be told, and its
own children are what know: a row holding something that wants the
height wants the height.
Asked of the tree rather than inferred from the layout, because the
layout answers a frame too late — a row that learns it should be tall
from what happened last frame is a row that is the wrong height on
the frame anybody looks at."
[n]
(boolean (some #(:fill-height (:props (deref %))) (:children (deref n)))))
(defn- box-opts
"The container options shared by every container tag."
([props key] (box-opts props key false))
([props key fill-height?]
(cond-> {:key key
:dir (if (= :horizontal (:orientation props)) :horizontal :vertical)
;; A CONTAINER fills its parent's cross axis by default. Without
;; this every box shrink-wraps its children, and frq's chat
;; column came out a couple of hundred points wide in a
;; five-hundred-point window with every message wrapped to
;; match — the tree is nested boxes, and each one only as wide
;; as what is in it.
;;
;; :cross and not :horizontal: in a ROW, :horizontal means take
;; a share of the slack, and a line of buttons would stretch to
;; fill the window.
:expand :cross}
;; :fill-height is frq's way of saying "this is the pane that takes
;; what is left". It is the messages column in the row that also
;; holds the people panel, and without it that column claims no slack
;; at all — the backlog ends up as wide as the widest message and the
;; scrollbar sits in the middle of the window.
;;
;; :both rather than :vertical, despite the name: in a ROW the space
;; to be taken is horizontal, and a pane that fills the height of a
;; row it does not fill the width of is not what anyone means by it.
;; The panes that do NOT ask for it stay :cross and keep their own
;; size, which is what leaves the slack to be taken.
(or (:fill-height props) fill-height?) (assoc :expand :both)
;; A minimum, not a size: the messages column asks for one only while
;; the people panel is beside it.
(:width-request props)
(assoc :min-size [(num (:width-request props) 0.0) 0.0])
(: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))
;; A row whose children start a new line when they run out of room —
;; a line of reaction pills is the case that needs it.
(:wrap props) (assoc :wrap true)
;; Cross-axis placement: :start :center :end, as a gravity.
(:align props) (assoc :gravity (case (:align props)
(:center "center") [0.0 0.5]
(:end "end") [0.0 1.0]
[0.0 0.0])))))
(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)
(def ^:private hovering
"Which widgets the pointer was on last frame.
The toolkit answers `:hover?` as a state — the pointer is over this
rectangle — and a component wants the two EVENTS at its edges. The
difference is a set, and it is kept here rather than on the node because
a node is replaced by the reconciler and the pointer has not moved."
(atom #{}))
(defn- hover!
"Turn `over?` into on-hover and on-unhover, once each per crossing.
Only on the pass that paints: hover is derived from where the pointer is
rather than delivered as an event, so it is true on the settling passes
too, and a handler called from one of those fires two or three times for
one crossing."
[n id over?]
(when (c/draw-pass?)
(let [was (contains? @hovering id)]
(cond
(and over? (not was)) (do (swap! hovering conj id) (fire! n :on-hover))
(and was (not over?)) (do (swap! hovering disj id) (fire! n :on-unhover))))))
(def ^:private skippable
"The tags whose jvui box is keyed by the node's own key, so `c/skip-box!`
can find what that box remembered."
#{:vbox :box :hbox :card :frame})
(defn- emit-children!
"The body a container is walked with.
`:scroll-here` rides along on any container: while it is set, the scroll
area around the node is asked to bring it into view. frq sets it for the
moment of a jump to a message and takes it off again — left on, it would
pin the list there and take scrolling away from the reader."
[n]
(let [here? (:scroll-here (:props @n))]
(fn [_id rect]
(when here? (w/reveal! rect))
(doseq [c (:children @n)]
(let [{:keys [tag props key]} @c]
;; A container scrolled out of sight is not walked at all, only
;; counted at the size it had — see `c/skip-box!`. A backlog is
;; thousands of rows and a window shows twenty; walking the rest
;; every frame was what made a long channel slow to answer a click.
;;
;; Never the one a jump is aiming at: it is off screen by
;; definition, and skipping it would skip the ask to be shown.
(when-not (and (contains? skippable tag)
(not (:scroll-here props))
(c/skip-box! key))
(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))
;; A list that follows what arrives in it. Everything here beyond
;; :height is a prop frq writes and this used to drop on the floor —
;; the chat did not follow new messages, and switching channels
;; carried the previous one's scroll across.
;; :expand is forced rather than left to box-opts, whose default is
;; :cross — and a viewport that fills only the width asks its column
;; for no height, is given none, and shows nothing at all. It is the
;; one container that always fills both ways.
:scroll (w/scroll* (cond-> (assoc (box-opts props key) :expand :both)
(:height props)
(assoc :height (num (:height props) 200.0))
(:reserve props)
(assoc :reserve (num (:reserve props) 0.0))
(:scroll-key props)
(assoc :scroll-key (str (:scroll-key props)))
(:stick-to-bottom props)
(assoc :stick-to-bottom true)
(:scroll-to-bottom props)
(assoc :scroll-to-bottom (num (:scroll-to-bottom props) 0.0))
;; "end" or "away", the STRING libvidya emits —
;; frq's handler is (= "end" %) and a boolean
;; makes it permanently false.
;;
;; :on-scroll is deliberately not fired here.
;; It is the channel for backends that report an
;; OFFSET rather than a place — the terminal's —
;; and frq turns one into the other with
;; `scrolled!`. A window that reports where it
;; ended up has nothing to say on it.
(:on-change props)
(assoc :on-at-end
(fn [at-end?]
(fire! n :on-change (if at-end? "end" "away")))))
(emit-children! n))
:hbox (c/box* (assoc (box-opts props key (fills-height? n)) :dir :horizontal)
(emit-children! n))
(:vbox :box) (c/box* (box-opts props key (fills-height? n))
(emit-children! n))
;; ONE tag for both kinds of picture: `:feed` is live pixels pushed
;; in under a name and re-uploaded as they arrive, `:src` is a file
;; decoded once and kept by path. Everything downstream — the fit, the
;; bounds, the click — is the same, which is why libvidya makes this a
;; prop and not a second tag, and why frq writes [:image {:feed k}]
;; for a call tile and [:image {:src p}] for an attachment.
;;
;; The pixels never go through the reconciler either way: a frame
;; arrives when the network says so, and a props diff at thirty a
;; second would be a re-render per frame per peer.
:image (let [id (c/next-id key)
rect (w/image {:feed (:feed props) :src (:src props)}
{:fit (:fit props)
:max-width (:max-width props)
:max-height (:max-height props)
:size (:size props)
:expand (:expand props)})]
(record! n id)
(when (:clicked? (c/interact! id rect)) (fire! n :on-click)))
:title-2 (w/title-2 s)
:status (w/status s (boolean (:live props)))
:spinner (w/spinner s)
:link (let [id (c/next-id key)]
(record! n id)
(when (w/link s {:key key}) (fire! n :on-click)))
:emoji (w/emoji (or (:emoji props) s) (:size props))
:avatar (w/avatar (or (:label props) s)
(cond-> {}
(:src props) (assoc :src (:src props))
(:size props) (assoc :size (:size props))))
:reaction (let [id (c/next-id key)
glyph (or (:emoji props) s)
r (w/reaction glyph {:count (or (:count props) 0)
:mine? (boolean (:mine props))
:size (:size props)
:key key})]
(record! n id)
(when (:clicked? r) (fire! n :on-click))
(hover! n id (:hover? r))
;; Whatever the client hung under the pill is its hover
;; card, and a card is drawn over the row rather than in
;; it — see c/overlay!. Just under the pill, which is
;; where a pointer resting on the pill is not.
;;
;; On having children and not on :hover?. The client is
;; the one that knows whether a card is wanted — it is
;; already deciding, since it is what puts the child there
;; — and a backend that asked the question a second time
;; would be answering a pointer the client may not be
;; tracking with a pointer of its own.
(when (seq (:children @n))
(let [[x y _ h] (:rect r)]
(c/overlay! id [x (+ y h 4.0)]
#(w/card* {:expand :none :key id}
(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)))
;; :checkbutton is the same widget under GTK's name for it, which is
;; what libvidya calls it too — `"checkbutton" | "checkbox"` is one
;; arm of its tag table. frq writes both.
;; :active is what frq and libvidya call it — `props.bool("active")`
;; in libvidya's tag table — and :checked is what this backend called
;; it first. Both are read, because a client written against either
;; should not render a permanently empty tick; :active wins where
;; both appear.
;;
;; Likewise both events fire. libvidya emits "toggled"; :on-change is
;; what the checkbox here answered to before.
(:checkbox :checkbutton)
(let [was (boolean (if (contains? props :active)
(:active props)
(:checked props)))
id (c/next-id key)
now (w/checkbox was s {:key key})]
(record! n id)
(when (not= now was)
(fire! n :on-toggled now)
(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)
;; :width-request is what frq and
;; libvidya call a minimum width;
;; :hexpand says take the rest of the
;; row, which is this widget's default.
:min-width (:width-request props)
;; How tall it starts and how tall it may
;; grow. :rows is what frq already writes
;; for the terminal, where the field is a
;; fixed block of the screen; :max-rows is
;; the window's answer to the same problem
;; — a compose box that gains a line when
;; the message stops fitting rather than
;; sliding a paragraph past one border.
:rows (:rows props)
:max-rows (:max-rows props)
:expand (if (false? (:hexpand props))
:none :horizontal)})]
(record! n id)
(when (not= now was) (fire! n :on-change now))
;; A field that grew moved everything under it, and a client laying
;; its screen out in points has no other way to hear about it: frq
;; reserves the strip below its message list by hand, and a compose
;; box that got taller without saying so grows down off the window.
(let [lines (w/entry-lines id)]
(when (not= lines (:lines-told (c/data id)))
(c/data! id {:lines-told lines})
(fire! n :on-rows lines)))
;; Enter, which a field must not swallow as input: frq sends its
;; message on it, and without this the compose box accepted text
;; and had no way to say it was finished.
;;
;; NO ARGUMENT. libvidya emits activate with an empty string, and
;; frq's handlers are thunks — `s/send-draft!` takes none, and
;; handing it the text is an arity error the moment somebody
;; presses Enter. The text is already theirs; they got it from
;; :on-change.
(when (w/entry-activated? id) (fire! n :on-activate))
;; A paste that found no text on the clipboard. Also a thunk, and
;; for frq the important one: that is how a picture is pasted — the
;; same Ctrl+V as everything else, reaching `s/paste-image!` because
;; the field had nothing to put in itself. libvidya's name for it.
(when (w/entry-paste-empty? id) (fire! n :on-paste-empty)))
: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)
;; --- timers -----------------------------------------------------------------
;; A client needs somewhere to run work that is not a reaction to anything:
;; frq drives its whole media plane from `(every! 16 pump!)`, and a decoded
;; frame arrives because a timer asked for it rather than because a person
;; clicked. There is no other hook of the right shape — a component body runs
;; when its state changes, which for a video feed is never.
;;
;; Run from the same `:before` as the reconciler's queue, and for the same
;; reason: a callback that patches the tree must not do it mid-walk.
(defonce ^:private timers (atom {}))
(defonce ^:private next-timer (atom 0))
(defn- now-ms [] (System/currentTimeMillis))
(defn after!
"Run `f` once, at least `ms` from now. Answers a handle for `cancel!`."
[ms f]
(let [id (swap! next-timer inc)]
(swap! timers assoc id {:at (+ (now-ms) ms) :every nil :f f})
id))
(defn every!
"Run `f` every `ms`. Answers a handle for `cancel!`.
Every `ms` AT MOST, not exactly: it fires on the first frame after the
deadline, so a 16ms timer on a 60Hz window runs once a frame and on a
slower one runs less often. That is the right failure — a timer that tried
to catch up would run twice in a row on a stutter, and for a pump that
means two frames decoded and one shown."
[ms f]
(let [id (swap! next-timer inc)]
(swap! timers assoc id {:at (+ (now-ms) ms) :every ms :f f})
id))
(defn cancel!
"Stop a timer."
[id]
(swap! timers dissoc id)
nil)
(defn- run-timers! []
(let [t (now-ms)
due (filter (fn [[_ v]] (<= (:at v) t)) @timers)]
(doseq [[id {:keys [every f]}] due]
(if every
(swap! timers assoc-in [id :at] (+ t every))
(swap! timers dissoc id))
;; A throwing timer is cancelled rather than allowed to throw every
;; frame for the rest of the session, which is unreadable and stops
;; the ones behind it.
(try (f)
(catch Exception e
(swap! timers dissoc id)
(println "glimmer-jvui: timer failed, cancelled:" (ex-message e)))))))
(defn- drain-pending!
"Run this frame's timers and the reconciler's queued patches.
Answers whether any patch ran — which the caller turns into
`core/unsettle!`, and which is the whole of this backend's part in keeping
a changed tree from being painted at the sizes of the old one."
[]
(run-timers!)
(let [[ws] (reset-vals! pending [])]
(doseq [w ws] (w))
(boolean (seq ws))))
(defn- before!
"jvui's per-frame hook: patch the tree, then say that we did.
A patch lands between two walks, where nothing jvui measures has moved yet
— every container still holds the size its old children asked for. Without
the `unsettle!` the next walk is the one that paints, and it paints the new
tree at those old sizes: the frame where a card is still the height of the
message it no longer holds and everything under it sits wherever that put
it. With it, that walk is a settling pass and the frame that reaches the
screen is the settled one."
[cx]
(when (drain-pending!) (c/unsettle! cx)))
(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 before!
: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]
(before! cx)
(swap! cx assoc :events evs)
(swap! cx c/apply-input evs)
(binding [*record-rects?* true]
(c/frame! cx (fn [] (emit! root))))
cx))
;; --- feeds ------------------------------------------------------------------
;; The same three calls glimmer-vidya exposes, so a client that paints a call
;; does not care which backend is under it. They are not part of the
;; reconciler and deliberately so: pixels arrive between frames, and the tree
;; only ever holds the key.
(defn frame-rgba!
"Hand feed `key` a new picture: `w` by `h` RGBA at FOREIGN pointer `px`.
The pointer is read during this call and not kept, so a caller may reuse
or free it immediately afterwards — which is what a decoder handing out a
borrowed buffer needs."
[key w h px]
(frames/put! key w h px))
(defn frame-drop!
"Forget a feed and release its texture — someone left, or turned a camera
off."
[key]
(frames/drop! key))
(defn feed-keys
"Every feed with a picture."
[]
(frames/keys*))
;; --- the platform -----------------------------------------------------------
;; The rest of what glimmer-vidya answers, so a client can ask its backend
;; about the window it is in without knowing which backend that is. Thin on
;; purpose: every one of these is jvui.host, and the indirection exists so
;; the client requires one namespace rather than two.
(def set-title! host/set-title!)
(def window-width host/window-width)
(def screen-size host/screen-size)
(def quit! host/quit!)
(def open-url! host/open-url!)
(def clipboard-image-png! host/clipboard-image-png!)
;; False and nil on a desktop, which is the right answer rather than a gap:
;; the chooser exists so a phone can hand back a grant for one picture, and
;; a caller reads the false and offers a file browser instead. glimmer-vidya
;; says the same thing here.
(def pick-image! host/pick-image!)
(def picked-image! host/picked-image!)
|