nandi/jolt-nativepublic Fork 0
65272e3
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.

wip hover card

nandi committed 2026-09-10T10:41:34-07:00 Browse files
65272e3 parent: ce4d910
modified glimmer-backends/glimmer-jvui/src/glimmer_jvui/core.clj +39 -5
@@ -153,6 +153,29 @@
153153 (when *record-rects?* (swap! n assoc :rect (c/rect-of id)))
154154 nil)
155155
156+(def ^:private hovering
157+ "Which widgets the pointer was on last frame.
158+
159+ The toolkit answers `:hover?` as a state — the pointer is over this
160+ rectangle — and a component wants the two EVENTS at its edges. The
161+ difference is a set, and it is kept here rather than on the node because
162+ a node is replaced by the reconciler and the pointer has not moved."
163+ (atom #{}))
164+
165+(defn- hover!
166+ "Turn `over?` into on-hover and on-unhover, once each per crossing.
167+
168+ Only on the pass that paints: hover is derived from where the pointer is
169+ rather than delivered as an event, so it is true on the settling passes
170+ too, and a handler called from one of those fires two or three times for
171+ one crossing."
172+ [n id over?]
173+ (when (c/draw-pass?)
174+ (let [was (contains? @hovering id)]
175+ (cond
176+ (and over? (not was)) (do (swap! hovering conj id) (fire! n :on-hover))
177+ (and was (not over?)) (do (swap! hovering disj id) (fire! n :on-unhover))))))
178+
156179 (defn- emit-children! [n]
157180 (fn [_id _rect] (doseq [c (:children @n)] (emit! c))))
158181
@@ -251,13 +274,24 @@
251274 (:size props) (assoc :size (:size props))))
252275
253276 :reaction (let [id (c/next-id key)
254- glyph (or (:emoji props) s)]
255- (record! n id)
256- (when (w/reaction glyph {:count (or (:count props) 0)
277+ glyph (or (:emoji props) s)
278+ r (w/reaction glyph {:count (or (:count props) 0)
257279 :mine? (boolean (:mine props))
258280 :size (:size props)
259- :key key})
260- (fire! n :on-click)))
281+ :key key})]
282+ (record! n id)
283+ (when (:clicked? r) (fire! n :on-click))
284+ (hover! n id (:hover? r))
285+ ;; Whatever the client hung under the pill is its hover
286+ ;; card, and a card is drawn over the row rather than in
287+ ;; it — see c/overlay!. Under the pill and a little to its
288+ ;; right, which is where a pointer that is on the pill is
289+ ;; not.
290+ (when (and (:hover? r) (seq (:children @n)))
291+ (let [[x y _ h] (:rect r)]
292+ (c/overlay! id [x (+ y h 4.0)]
293+ #(w/card* {:expand :none :key id}
294+ (emit-children! n))))))
261295
262296 :title (w/title s)
263297
@@ -153,6 +153,29 @@
153 (when *record-rects?* (swap! n assoc :rect (c/rect-of id)))153 (when *record-rects?* (swap! n assoc :rect (c/rect-of id)))
154 nil)154 nil)
155 155
156+(def ^:private hovering
157+ "Which widgets the pointer was on last frame.
158+
159+ The toolkit answers `:hover?` as a state — the pointer is over this
160+ rectangle — and a component wants the two EVENTS at its edges. The
161+ difference is a set, and it is kept here rather than on the node because
162+ a node is replaced by the reconciler and the pointer has not moved."
163+ (atom #{}))
164+
165+(defn- hover!
166+ "Turn `over?` into on-hover and on-unhover, once each per crossing.
167+
168+ Only on the pass that paints: hover is derived from where the pointer is
169+ rather than delivered as an event, so it is true on the settling passes
170+ too, and a handler called from one of those fires two or three times for
171+ one crossing."
172+ [n id over?]
173+ (when (c/draw-pass?)
174+ (let [was (contains? @hovering id)]
175+ (cond
176+ (and over? (not was)) (do (swap! hovering conj id) (fire! n :on-hover))
177+ (and was (not over?)) (do (swap! hovering disj id) (fire! n :on-unhover))))))
178+
156 (defn- emit-children! [n]179 (defn- emit-children! [n]
157 (fn [_id _rect] (doseq [c (:children @n)] (emit! c))))180 (fn [_id _rect] (doseq [c (:children @n)] (emit! c))))
158 181
@@ -251,13 +274,24 @@
251 (:size props) (assoc :size (:size props))))274 (:size props) (assoc :size (:size props))))
252 275
253 :reaction (let [id (c/next-id key)276 :reaction (let [id (c/next-id key)
254- glyph (or (:emoji props) s)]277+ glyph (or (:emoji props) s)
255- (record! n id)278+ r (w/reaction glyph {:count (or (:count props) 0)
256- (when (w/reaction glyph {:count (or (:count props) 0)
257 :mine? (boolean (:mine props))279 :mine? (boolean (:mine props))
258 :size (:size props)280 :size (:size props)
259- :key key})281+ :key key})]
260- (fire! n :on-click)))282+ (record! n id)
283+ (when (:clicked? r) (fire! n :on-click))
284+ (hover! n id (:hover? r))
285+ ;; Whatever the client hung under the pill is its hover
286+ ;; card, and a card is drawn over the row rather than in
287+ ;; it — see c/overlay!. Under the pill and a little to its
288+ ;; right, which is where a pointer that is on the pill is
289+ ;; not.
290+ (when (and (:hover? r) (seq (:children @n)))
291+ (let [[x y _ h] (:rect r)]
292+ (c/overlay! id [x (+ y h 4.0)]
293+ #(w/card* {:expand :none :key id}
294+ (emit-children! n))))))
261 295
262 :title (w/title s)296 :title (w/title s)
263 297
modified jvui/src/jvui/core.clj +64 -1
@@ -461,6 +461,31 @@
461461 :rect box-rect :content (:rect f)})
462462 result))))
463463
464+(defn draw-pass?
465+ "True on the walk that paints. Unlike `drawing?` this does not also ask for
466+ a painter, so a headless walk answers it — which is what lets an event
467+ that is derived from state rather than delivered as one, hover being the
468+ case, fire exactly once a frame in a test as well as in a window."
469+ [] (boolean (:draw? (ui))))
470+
471+(defn overlay!
472+ "Draw `f` after the rest of the walk, anchored at [x y] and outside the
473+ layout.
474+
475+ A hover card is the case this exists for: it belongs on top of whatever it
476+ is over and must cost the thing it is attached to no space at all. Placed
477+ inline it would push the row it is in aside the moment the pointer arrived,
478+ which moves the pill out from under the pointer, which takes the card away
479+ again — a flicker rather than a card.
480+
481+ So it is deferred instead: collected during the walk and run afterwards on
482+ a root frame of its own, clear of every clip the walk had set. `key` names
483+ it so its size is remembered between frames, which is what lets it be kept
484+ on screen — a card anchored near the bottom is moved up by its own height
485+ rather than drawn off the edge."
486+ [key [x y] f]
487+ (upd! update :overlays (fnil conj []) {:key key :at [x y] :f f}))
488+
464489 (defn leaf
465490 "Place a widget that already knows its size. Answers its rectangle."
466491 ([min-size] (leaf min-size :none [0.0 0.0]))
@@ -572,13 +597,50 @@
572597 nxt (if (neg? cur) 0 (mod (+ cur step) n))]
573598 (focus! (nth fs nxt))))))))
574599
600+(defn- drain-overlays!
601+ "Run whatever the walk deferred, each on a root frame of its own.
602+
603+ After the walk and not during it, so an overlay is painted over what it
604+ overlaps rather than under it, and so it is placed against the window
605+ rather than against whatever box happened to be open when it was asked
606+ for. An overlay may ask for another — a card with a chip in it — so this
607+ loops, with a bound: a cycle would otherwise be a hung frame rather than a
608+ wrong one."
609+ [ctx-atom w h]
610+ (set-clip! nil)
611+ (loop [depth 0]
612+ (let [os (:overlays @ctx-atom)]
613+ (when (and (seq os) (< depth 4))
614+ (swap! ctx-atom assoc :overlays [])
615+ (doseq [{:keys [key at f]} os]
616+ (let [id [:overlay key]
617+ [ox oy] at
618+ ;; Last frame's size. Zero on the first, which puts the card
619+ ;; exactly where it was asked for and lets the next frame
620+ ;; move it — one frame of overhang the first time a card is
621+ ;; ever shown near an edge, against a measuring pass for
622+ ;; every card that is not.
623+ [pw ph] (or (:size (get-in @ctx-atom [:data id])) [0.0 0.0])
624+ x (max 0.0 (min (double ox) (- (double w) (double pw))))
625+ y (max 0.0 (min (double oy) (- (double h) (double ph))))
626+ fr {:id id :dir :vertical
627+ :rect [x y (- (double w) x) (- (double h) y)]
628+ :spacing 0.0 :share 0.0 :c (counters) :offset [0.0 0.0]}]
629+ (swap! ctx-atom update :stack conj fr)
630+ (f)
631+ (let [ff (peek (:stack @ctx-atom))]
632+ (swap! ctx-atom update :stack pop)
633+ (swap! ctx-atom assoc-in [:data id]
634+ {:size [(ctr ff CROSS) (ctr ff ALONG)]}))))
635+ (recur (inc depth))))))
636+
575637 (defn- one-pass!
576638 [ctx-atom render draw?]
577639 (swap! ctx-atom
578640 (fn [c]
579641 (assoc c :stack [] :refresh? false :focusables []
580642 :handled #{} :draw? draw?
581- :clip nil)))
643+ :overlays [] :clip nil)))
582644 (let [[w h] (:size @ctx-atom)
583645 ;; The root is a box like any other, and it remembers under id 0. It has
584646 ;; to: without last frame's counts it cannot know how much of the window
@@ -595,6 +657,7 @@
595657 (swap! ctx-atom update :stack conj root)
596658 (binding [*ui* ctx-atom]
597659 (render)
660+ (drain-overlays! ctx-atom w h)
598661 (when draw? (move-focus!)))
599662 (let [f (peek (:stack @ctx-atom))]
600663 (when (not= [(ctr f N) (ctr f EXPANDERS) (ctr f ALONG)]
@@ -461,6 +461,31 @@
461 :rect box-rect :content (:rect f)})461 :rect box-rect :content (:rect f)})
462 result))))462 result))))
463 463
464+(defn draw-pass?
465+ "True on the walk that paints. Unlike `drawing?` this does not also ask for
466+ a painter, so a headless walk answers it — which is what lets an event
467+ that is derived from state rather than delivered as one, hover being the
468+ case, fire exactly once a frame in a test as well as in a window."
469+ [] (boolean (:draw? (ui))))
470+
471+(defn overlay!
472+ "Draw `f` after the rest of the walk, anchored at [x y] and outside the
473+ layout.
474+
475+ A hover card is the case this exists for: it belongs on top of whatever it
476+ is over and must cost the thing it is attached to no space at all. Placed
477+ inline it would push the row it is in aside the moment the pointer arrived,
478+ which moves the pill out from under the pointer, which takes the card away
479+ again — a flicker rather than a card.
480+
481+ So it is deferred instead: collected during the walk and run afterwards on
482+ a root frame of its own, clear of every clip the walk had set. `key` names
483+ it so its size is remembered between frames, which is what lets it be kept
484+ on screen — a card anchored near the bottom is moved up by its own height
485+ rather than drawn off the edge."
486+ [key [x y] f]
487+ (upd! update :overlays (fnil conj []) {:key key :at [x y] :f f}))
488+
464 (defn leaf489 (defn leaf
465 "Place a widget that already knows its size. Answers its rectangle."490 "Place a widget that already knows its size. Answers its rectangle."
466 ([min-size] (leaf min-size :none [0.0 0.0]))491 ([min-size] (leaf min-size :none [0.0 0.0]))
@@ -572,13 +597,50 @@
572 nxt (if (neg? cur) 0 (mod (+ cur step) n))]597 nxt (if (neg? cur) 0 (mod (+ cur step) n))]
573 (focus! (nth fs nxt))))))))598 (focus! (nth fs nxt))))))))
574 599
600+(defn- drain-overlays!
601+ "Run whatever the walk deferred, each on a root frame of its own.
602+
603+ After the walk and not during it, so an overlay is painted over what it
604+ overlaps rather than under it, and so it is placed against the window
605+ rather than against whatever box happened to be open when it was asked
606+ for. An overlay may ask for another — a card with a chip in it — so this
607+ loops, with a bound: a cycle would otherwise be a hung frame rather than a
608+ wrong one."
609+ [ctx-atom w h]
610+ (set-clip! nil)
611+ (loop [depth 0]
612+ (let [os (:overlays @ctx-atom)]
613+ (when (and (seq os) (< depth 4))
614+ (swap! ctx-atom assoc :overlays [])
615+ (doseq [{:keys [key at f]} os]
616+ (let [id [:overlay key]
617+ [ox oy] at
618+ ;; Last frame's size. Zero on the first, which puts the card
619+ ;; exactly where it was asked for and lets the next frame
620+ ;; move it — one frame of overhang the first time a card is
621+ ;; ever shown near an edge, against a measuring pass for
622+ ;; every card that is not.
623+ [pw ph] (or (:size (get-in @ctx-atom [:data id])) [0.0 0.0])
624+ x (max 0.0 (min (double ox) (- (double w) (double pw))))
625+ y (max 0.0 (min (double oy) (- (double h) (double ph))))
626+ fr {:id id :dir :vertical
627+ :rect [x y (- (double w) x) (- (double h) y)]
628+ :spacing 0.0 :share 0.0 :c (counters) :offset [0.0 0.0]}]
629+ (swap! ctx-atom update :stack conj fr)
630+ (f)
631+ (let [ff (peek (:stack @ctx-atom))]
632+ (swap! ctx-atom update :stack pop)
633+ (swap! ctx-atom assoc-in [:data id]
634+ {:size [(ctr ff CROSS) (ctr ff ALONG)]}))))
635+ (recur (inc depth))))))
636+
575 (defn- one-pass!637 (defn- one-pass!
576 [ctx-atom render draw?]638 [ctx-atom render draw?]
577 (swap! ctx-atom639 (swap! ctx-atom
578 (fn [c]640 (fn [c]
579 (assoc c :stack [] :refresh? false :focusables []641 (assoc c :stack [] :refresh? false :focusables []
580 :handled #{} :draw? draw?642 :handled #{} :draw? draw?
581- :clip nil)))643+ :overlays [] :clip nil)))
582 (let [[w h] (:size @ctx-atom)644 (let [[w h] (:size @ctx-atom)
583 ;; The root is a box like any other, and it remembers under id 0. It has645 ;; The root is a box like any other, and it remembers under id 0. It has
584 ;; to: without last frame's counts it cannot know how much of the window646 ;; to: without last frame's counts it cannot know how much of the window
@@ -595,6 +657,7 @@
595 (swap! ctx-atom update :stack conj root)657 (swap! ctx-atom update :stack conj root)
596 (binding [*ui* ctx-atom]658 (binding [*ui* ctx-atom]
597 (render)659 (render)
660+ (drain-overlays! ctx-atom w h)
598 (when draw? (move-focus!)))661 (when draw? (move-focus!)))
599 (let [f (peek (:stack @ctx-atom))]662 (let [f (peek (:stack @ctx-atom))]
600 (when (not= [(ctr f N) (ctr f EXPANDERS) (ctr f ALONG)]663 (when (not= [(ctr f N) (ctr f EXPANDERS) (ctr f ALONG)]
modified jvui/src/jvui/widgets.clj +9 -1
@@ -633,7 +633,12 @@
633633 ([s {:keys [key]}]
634634 (let [id (c/next-id key)
635635 rect (label s {:colour (c/th :accent)})]
636- (:clicked? (c/interact! id rect)))))
636+ ;; The whole interaction and not just the click: a reaction is the one
637+ ;; chip that answers the pointer resting on it as well as pressing it —
638+ ;; who put it there is what the pill's number will not say — so the
639+ ;; caller needs `:hover?` too. It also needs the rectangle, to know
640+ ;; where to hang the card.
641+ (assoc (c/interact! id rect) :rect rect))))
637642
638643 (defn emoji
639644 "One emoji, drawn as a character and sized to sit level with the words
@@ -685,6 +690,9 @@
685690 "A tally wearing a pill: an emoji, how many people, and whether you are one
686691 of them.
687692
693+ Answers the interaction `:clicked?`, `:hover?` and the `:rect` it was
694+ given rather than a bare click.
695+
688696 The same glyph `emoji` draws, and the same two ways of drawing it: a
689697 colour face gives a square picture, and everything else is text. So the
690698 glyph and the tally are measured and drawn apart rather than as one
@@ -633,7 +633,12 @@
633 ([s {:keys [key]}]633 ([s {:keys [key]}]
634 (let [id (c/next-id key)634 (let [id (c/next-id key)
635 rect (label s {:colour (c/th :accent)})]635 rect (label s {:colour (c/th :accent)})]
636- (:clicked? (c/interact! id rect)))))636+ ;; The whole interaction and not just the click: a reaction is the one
637+ ;; chip that answers the pointer resting on it as well as pressing it —
638+ ;; who put it there is what the pill's number will not say — so the
639+ ;; caller needs `:hover?` too. It also needs the rectangle, to know
640+ ;; where to hang the card.
641+ (assoc (c/interact! id rect) :rect rect))))
637 642
638 (defn emoji643 (defn emoji
639 "One emoji, drawn as a character and sized to sit level with the words644 "One emoji, drawn as a character and sized to sit level with the words
@@ -685,6 +690,9 @@
685 "A tally wearing a pill: an emoji, how many people, and whether you are one690 "A tally wearing a pill: an emoji, how many people, and whether you are one
686 of them.691 of them.
687 692
693+ Answers the interaction `:clicked?`, `:hover?` and the `:rect` it was
694+ given rather than a bare click.
695+
688 The same glyph `emoji` draws, and the same two ways of drawing it: a696 The same glyph `emoji` draws, and the same two ways of drawing it: a
689 colour face gives a square picture, and everything else is text. So the697 colour face gives a square picture, and everything else is text. So the
690 glyph and the tally are measured and drawn apart rather than as one698 glyph and the tally are measured and drawn apart rather than as one