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

Walk only the rows a scroll area can show

A channel's backlog is every message it has, and jvui walked all of them
every frame whether or not they were on screen - several times over after
any change, while the layout settled. A long channel was slow to answer a
click.

A box that would land wholly outside the clip is now folded into its column
at the size it reported last time, without walking it: c/skip-box!, which
glimmer-jvui asks before walking each container child. It only does that
with a size it can trust - a box whose last walk reported what it reported
before, with nothing inside it still settling, in a non-wrapping column,
taking no share of the slack, at the width it was measured against. A row
carrying :scroll-here is always walked, since a jump is to somewhere off
screen.

Headless, message-shaped rows: a settled frame at 3000 rows goes from about
760ms to about 21ms, and the settle-and-paint after a change from several
seconds to about 110ms. What is left after a change is the reconciler's
re-render, not the walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-10T19:46:02-07:00 Browse files
b48e404 parent: e0d9029
modified glimmer-backends/glimmer-jvui/src/glimmer_jvui/core.clj +18 -1
@@ -176,6 +176,11 @@
176176 (and over? (not was)) (do (swap! hovering conj id) (fire! n :on-hover))
177177 (and was (not over?)) (do (swap! hovering disj id) (fire! n :on-unhover))))))
178178
179+(def ^:private skippable
180+ "The tags whose jvui box is keyed by the node's own key, so `c/skip-box!`
181+ can find what that box remembered."
182+ #{:vbox :box :hbox :card :frame})
183+
179184 (defn- emit-children!
180185 "The body a container is walked with.
181186
@@ -187,7 +192,19 @@
187192 (let [here? (:scroll-here (:props @n))]
188193 (fn [_id rect]
189194 (when here? (w/reveal! rect))
190- (doseq [c (:children @n)] (emit! c)))))
195+ (doseq [c (:children @n)]
196+ (let [{:keys [tag props key]} @c]
197+ ;; A container scrolled out of sight is not walked at all, only
198+ ;; counted at the size it had — see `c/skip-box!`. A backlog is
199+ ;; thousands of rows and a window shows twenty; walking the rest
200+ ;; every frame was what made a long channel slow to answer a click.
201+ ;;
202+ ;; Never the one a jump is aiming at: it is off screen by
203+ ;; definition, and skipping it would skip the ask to be shown.
204+ (when-not (and (contains? skippable tag)
205+ (not (:scroll-here props))
206+ (c/skip-box! key))
207+ (emit! c)))))))
191208
192209 (defn- emit!
193210 "Render one node, and through it everything below it.
@@ -176,6 +176,11 @@
176 (and over? (not was)) (do (swap! hovering conj id) (fire! n :on-hover))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))))))177 (and was (not over?)) (do (swap! hovering disj id) (fire! n :on-unhover))))))
178 178
179+(def ^:private skippable
180+ "The tags whose jvui box is keyed by the node's own key, so `c/skip-box!`
181+ can find what that box remembered."
182+ #{:vbox :box :hbox :card :frame})
183+
179 (defn- emit-children!184 (defn- emit-children!
180 "The body a container is walked with.185 "The body a container is walked with.
181 186
@@ -187,7 +192,19 @@
187 (let [here? (:scroll-here (:props @n))]192 (let [here? (:scroll-here (:props @n))]
188 (fn [_id rect]193 (fn [_id rect]
189 (when here? (w/reveal! rect))194 (when here? (w/reveal! rect))
190- (doseq [c (:children @n)] (emit! c)))))195+ (doseq [c (:children @n)]
196+ (let [{:keys [tag props key]} @c]
197+ ;; A container scrolled out of sight is not walked at all, only
198+ ;; counted at the size it had — see `c/skip-box!`. A backlog is
199+ ;; thousands of rows and a window shows twenty; walking the rest
200+ ;; every frame was what made a long channel slow to answer a click.
201+ ;;
202+ ;; Never the one a jump is aiming at: it is off screen by
203+ ;; definition, and skipping it would skip the ask to be shown.
204+ (when-not (and (contains? skippable tag)
205+ (not (:scroll-here props))
206+ (c/skip-box! key))
207+ (emit! c)))))))
191 208
192 (defn- emit!209 (defn- emit!
193 "Render one node, and through it everything below it.210 "Render one node, and through it everything below it.
modified jvui/src/jvui/core.clj +62 -4
@@ -84,7 +84,10 @@
8484
8585 (defn refresh!
8686 "Ask for another layout pass before anything is drawn."
87- [] (upd! assoc :refresh? true))
87+ ;; Counted as well as flagged: the flag says the pass as a whole moved, and
88+ ;; the count lets a box ask whether anything moved INSIDE it — see
89+ ;; `:stable?` in `box*`.
90+ [] (upd! (fn [s] (-> s (assoc :refresh? true) (update :changes (fnil inc 0))))))
8891
8992 (defn unsettle!
9093 "Say that the tree changed under us, so the next frame settles before it
@@ -412,6 +415,10 @@
412415 [(max (first min-size) (first remembered))
413416 (max (second min-size) (second remembered))]
414417 remembered)
418+ ;; What the parent had to give across, before this box takes its
419+ ;; share — remembered so `skip-box!` can tell a size that still holds
420+ ;; from one the window has since rewrapped.
421+ parent-w (nth (:rect (top)) 2)
415422 outer (rect-for [(+ (first want) (* 2 margin))
416423 (+ (second want) (* 2 margin))]
417424 expand gravity)
@@ -450,7 +457,15 @@
450457 ;; on a line of its own.
451458 :wrap (boolean (and wrap (= dir :horizontal)))
452459 :offset (or offset [0.0 0.0])})
453- (let [result (when body (body id box-rect))
460+ (let [;; Whether anything under this box asked for another pass while it
461+ ;; was walked: then the sizes it is about to report are still
462+ ;; moving, which is what `:stable?` below keeps track of. Only what
463+ ;; is INSIDE it counts — a change earlier in the pass, in some other
464+ ;; row, says nothing about this one, and counting it would walk the
465+ ;; whole of a long list again after every change.
466+ changes-before (:changes (ui) 0)
467+ result (when body (body id box-rect))
468+ moved-inside? (not= changes-before (:changes (ui) 0))
454469 f (pop-frame!)]
455470 (when clip? (set-clip! outer-clip))
456471 ;; A box measures its cross axis by its widest child and its own axis by
@@ -472,13 +487,56 @@
472487 ;; scroll, so both numbers are kept.
473488 reported (if (and fixed min-size) min-size computed)]
474489 (when (not= reported (:min-size prev)) (refresh!))
475- (data! id {:min-size reported
490+ (data! id {;; A size `skip-box!` may fold in without walking: the box
491+ ;; reported what it reported last time, and nothing inside
492+ ;; it was still settling. A box measured on its first walk
493+ ;; is the case this is for — it reports nothing, and skipped
494+ ;; at that size it would never be walked to learn better.
495+ :stable? (and (not moved-inside?)
496+ (= reported (:min-size prev)))
497+ :min-size reported
476498 :content-min computed
477499 :n (ctr f N) :expanders (ctr f EXPANDERS)
478500 :along (ctr f ALONG)
479- :rect box-rect :content (:rect f)})
501+ :rect box-rect :content (:rect f)
502+ :margin margin :expand expand :parent-w parent-w})
480503 result))))
481504
505+(defn skip-box!
506+ "Count the box the next child keyed `key` would be, without walking it, when
507+ it would land wholly outside the clip. Answers true when it did.
508+
509+ A scrolled list is where this pays: thousands of rows, twenty on screen,
510+ and a walk of every one of them every frame several, after any change,
511+ while the layout settles. A row out of sight is folded into its column at
512+ the size it reported the last time it was walked, so the column comes out
513+ as tall as ever and every row after it lands where it would have.
514+
515+ Only where that remembered size can be trusted: a box that has been walked
516+ before, in a column that does not wrap, taking no share of the column's
517+ slack, and given the same width it was measured against a window that
518+ has been resized has rewrapped every line, and a row skipped at its old
519+ height would put everything under it in the wrong place. What it cannot
520+ see is a box whose contents changed while it was out of sight; that one
521+ comes back at its new size the first walk it is on screen."
522+ [key]
523+ (let [{:keys [dir rect offset wrap] :as f} (top)
524+ id (child-id (:id f) (ctr f N) key)
525+ {:keys [min-size margin expand parent-w stable?]} (data id)]
526+ (when (and stable? min-size parent-w
527+ (= dir :vertical) (not wrap)
528+ (not (and (not= expand :cross) (expands? expand :vertical)))
529+ (== (double parent-w) (double (nth rect 2))))
530+ (let [[_ ry _ _] rect
531+ [_ oy] (or offset [0.0 0.0])
532+ y (+ ry oy (ctr f CURSOR))
533+ h (+ (double (second min-size)) (* 2.0 (double margin)))
534+ [_ cy _ ch] (clip-rect)]
535+ ;; Strictly outside: a box that touches the edge is still walked.
536+ (when (or (< (+ y h) cy) (> y (+ cy ch)))
537+ (tally! h h (+ (double (first min-size)) (* 2.0 (double margin))) false)
538+ true)))))
539+
482540 (defn draw-pass?
483541 "True on the walk that paints. Unlike `drawing?` this does not also ask for
484542 a painter, so a headless walk answers it which is what lets an event
@@ -84,7 +84,10 @@
84 84
85 (defn refresh!85 (defn refresh!
86 "Ask for another layout pass before anything is drawn."86 "Ask for another layout pass before anything is drawn."
87- [] (upd! assoc :refresh? true))87+ ;; Counted as well as flagged: the flag says the pass as a whole moved, and
88+ ;; the count lets a box ask whether anything moved INSIDE it — see
89+ ;; `:stable?` in `box*`.
90+ [] (upd! (fn [s] (-> s (assoc :refresh? true) (update :changes (fnil inc 0))))))
88 91
89 (defn unsettle!92 (defn unsettle!
90 "Say that the tree changed under us, so the next frame settles before it93 "Say that the tree changed under us, so the next frame settles before it
@@ -412,6 +415,10 @@
412 [(max (first min-size) (first remembered))415 [(max (first min-size) (first remembered))
413 (max (second min-size) (second remembered))]416 (max (second min-size) (second remembered))]
414 remembered)417 remembered)
418+ ;; What the parent had to give across, before this box takes its
419+ ;; share — remembered so `skip-box!` can tell a size that still holds
420+ ;; from one the window has since rewrapped.
421+ parent-w (nth (:rect (top)) 2)
415 outer (rect-for [(+ (first want) (* 2 margin))422 outer (rect-for [(+ (first want) (* 2 margin))
416 (+ (second want) (* 2 margin))]423 (+ (second want) (* 2 margin))]
417 expand gravity)424 expand gravity)
@@ -450,7 +457,15 @@
450 ;; on a line of its own.457 ;; on a line of its own.
451 :wrap (boolean (and wrap (= dir :horizontal)))458 :wrap (boolean (and wrap (= dir :horizontal)))
452 :offset (or offset [0.0 0.0])})459 :offset (or offset [0.0 0.0])})
453- (let [result (when body (body id box-rect))460+ (let [;; Whether anything under this box asked for another pass while it
461+ ;; was walked: then the sizes it is about to report are still
462+ ;; moving, which is what `:stable?` below keeps track of. Only what
463+ ;; is INSIDE it counts — a change earlier in the pass, in some other
464+ ;; row, says nothing about this one, and counting it would walk the
465+ ;; whole of a long list again after every change.
466+ changes-before (:changes (ui) 0)
467+ result (when body (body id box-rect))
468+ moved-inside? (not= changes-before (:changes (ui) 0))
454 f (pop-frame!)]469 f (pop-frame!)]
455 (when clip? (set-clip! outer-clip))470 (when clip? (set-clip! outer-clip))
456 ;; A box measures its cross axis by its widest child and its own axis by471 ;; A box measures its cross axis by its widest child and its own axis by
@@ -472,13 +487,56 @@
472 ;; scroll, so both numbers are kept.487 ;; scroll, so both numbers are kept.
473 reported (if (and fixed min-size) min-size computed)]488 reported (if (and fixed min-size) min-size computed)]
474 (when (not= reported (:min-size prev)) (refresh!))489 (when (not= reported (:min-size prev)) (refresh!))
475- (data! id {:min-size reported490+ (data! id {;; A size `skip-box!` may fold in without walking: the box
491+ ;; reported what it reported last time, and nothing inside
492+ ;; it was still settling. A box measured on its first walk
493+ ;; is the case this is for — it reports nothing, and skipped
494+ ;; at that size it would never be walked to learn better.
495+ :stable? (and (not moved-inside?)
496+ (= reported (:min-size prev)))
497+ :min-size reported
476 :content-min computed498 :content-min computed
477 :n (ctr f N) :expanders (ctr f EXPANDERS)499 :n (ctr f N) :expanders (ctr f EXPANDERS)
478 :along (ctr f ALONG)500 :along (ctr f ALONG)
479- :rect box-rect :content (:rect f)})501+ :rect box-rect :content (:rect f)
502+ :margin margin :expand expand :parent-w parent-w})
480 result))))503 result))))
481 504
505+(defn skip-box!
506+ "Count the box the next child keyed `key` would be, without walking it, when
507+ it would land wholly outside the clip. Answers true when it did.
508+
509+ A scrolled list is where this pays: thousands of rows, twenty on screen,
510+ and a walk of every one of them every frame several, after any change,
511+ while the layout settles. A row out of sight is folded into its column at
512+ the size it reported the last time it was walked, so the column comes out
513+ as tall as ever and every row after it lands where it would have.
514+
515+ Only where that remembered size can be trusted: a box that has been walked
516+ before, in a column that does not wrap, taking no share of the column's
517+ slack, and given the same width it was measured against a window that
518+ has been resized has rewrapped every line, and a row skipped at its old
519+ height would put everything under it in the wrong place. What it cannot
520+ see is a box whose contents changed while it was out of sight; that one
521+ comes back at its new size the first walk it is on screen."
522+ [key]
523+ (let [{:keys [dir rect offset wrap] :as f} (top)
524+ id (child-id (:id f) (ctr f N) key)
525+ {:keys [min-size margin expand parent-w stable?]} (data id)]
526+ (when (and stable? min-size parent-w
527+ (= dir :vertical) (not wrap)
528+ (not (and (not= expand :cross) (expands? expand :vertical)))
529+ (== (double parent-w) (double (nth rect 2))))
530+ (let [[_ ry _ _] rect
531+ [_ oy] (or offset [0.0 0.0])
532+ y (+ ry oy (ctr f CURSOR))
533+ h (+ (double (second min-size)) (* 2.0 (double margin)))
534+ [_ cy _ ch] (clip-rect)]
535+ ;; Strictly outside: a box that touches the edge is still walked.
536+ (when (or (< (+ y h) cy) (> y (+ cy ch)))
537+ (tally! h h (+ (double (first min-size)) (* 2.0 (double margin))) false)
538+ true)))))
539+
482 (defn draw-pass?540 (defn draw-pass?
483 "True on the walk that paints. Unlike `drawing?` this does not also ask for541 "True on the walk that paints. Unlike `drawing?` this does not also ask for
484 a painter, so a headless walk answers it which is what lets an event542 a painter, so a headless walk answers it which is what lets an event
modified jvui/test/jvui/tests.clj +45 -0
@@ -413,6 +413,50 @@
413413 (check! (> (w/scroll-offset "reveal") off)
414414 "and the wheel moves it again once nothing asks"))))
415415
416+(defn- check-offscreen-rows-are-skipped! []
417+ ;; A channel's backlog is thousands of rows in a viewport that shows twenty.
418+ ;; Walking all of them every frame is what made a long channel slow, so a
419+ ;; row out of sight is counted at its remembered size and not walked — and
420+ ;; the list must come out exactly as tall, with every row that comes back
421+ ;; into view exactly where it would have been.
422+ (let [cx (ctx) walked (atom #{}) ys (atom {})
423+ render (fn []
424+ (w/scroll* {:height 100 :scroll-key "cull"}
425+ (fn [_ _]
426+ ;; As glimmer-jvui walks a container's children: ask
427+ ;; first, and walk only what was not skipped.
428+ (doseq [i (range 200)]
429+ (when-not (c/skip-box! i)
430+ (c/box* {:dir :vertical :key i}
431+ (fn [_ _]
432+ (swap! walked conj i)
433+ (swap! ys assoc i
434+ (second (w/label (str "row " i)
435+ {:expand :none}))))))))))
436+ content (fn []
437+ (->> (vals (:data @cx))
438+ (filter #(= [0.0 100.0] (:min-size %)))
439+ first :content-min second))]
440+ (dotimes [_ 3] (frame! cx render))
441+ (let [full (content)
442+ y30 (get @ys 30)]
443+ (reset! walked #{})
444+ (frame! cx render)
445+ (check! (< (count @walked) 20)
446+ (str "only the rows near the viewport are walked: " (count @walked)))
447+ (check! (close? full (content))
448+ (str "and the list is as tall as it was: " full " -> " (content)))
449+ (frame! cx render [{:kind :motion :x 50 :y 50}
450+ {:kind :wheel :dx 0.0 :dy -15.0}])
451+ (reset! walked #{})
452+ (frame! cx render [{:kind :motion :x 50 :y 50}])
453+ (let [off (w/scroll-offset "cull")]
454+ (check! (contains? @walked 30)
455+ (str "a row scrolled into view is walked again: offset=" off))
456+ (check! (close? (get @ys 30) (- y30 off))
457+ (str "at the place it would have had: " (get @ys 30)
458+ " vs " (- y30 off)))))))
459+
416460 ;; --- theme -------------------------------------------------------------------
417461
418462 (defn- check-theme! []
@@ -535,6 +579,7 @@
535579 ["tab moves focus" check-tab-moves-focus!]
536580 ["a viewport scrolls" check-scroll!]
537581 ["a row asks to be seen" check-reveal!]
582+ ["rows out of sight are skipped" check-offscreen-rows-are-skipped!]
538583 ["the theme mixes" check-theme!]])
539584
540585 (defn -main [& _]
@@ -413,6 +413,50 @@
413 (check! (> (w/scroll-offset "reveal") off)413 (check! (> (w/scroll-offset "reveal") off)
414 "and the wheel moves it again once nothing asks"))))414 "and the wheel moves it again once nothing asks"))))
415 415
416+(defn- check-offscreen-rows-are-skipped! []
417+ ;; A channel's backlog is thousands of rows in a viewport that shows twenty.
418+ ;; Walking all of them every frame is what made a long channel slow, so a
419+ ;; row out of sight is counted at its remembered size and not walked — and
420+ ;; the list must come out exactly as tall, with every row that comes back
421+ ;; into view exactly where it would have been.
422+ (let [cx (ctx) walked (atom #{}) ys (atom {})
423+ render (fn []
424+ (w/scroll* {:height 100 :scroll-key "cull"}
425+ (fn [_ _]
426+ ;; As glimmer-jvui walks a container's children: ask
427+ ;; first, and walk only what was not skipped.
428+ (doseq [i (range 200)]
429+ (when-not (c/skip-box! i)
430+ (c/box* {:dir :vertical :key i}
431+ (fn [_ _]
432+ (swap! walked conj i)
433+ (swap! ys assoc i
434+ (second (w/label (str "row " i)
435+ {:expand :none}))))))))))
436+ content (fn []
437+ (->> (vals (:data @cx))
438+ (filter #(= [0.0 100.0] (:min-size %)))
439+ first :content-min second))]
440+ (dotimes [_ 3] (frame! cx render))
441+ (let [full (content)
442+ y30 (get @ys 30)]
443+ (reset! walked #{})
444+ (frame! cx render)
445+ (check! (< (count @walked) 20)
446+ (str "only the rows near the viewport are walked: " (count @walked)))
447+ (check! (close? full (content))
448+ (str "and the list is as tall as it was: " full " -> " (content)))
449+ (frame! cx render [{:kind :motion :x 50 :y 50}
450+ {:kind :wheel :dx 0.0 :dy -15.0}])
451+ (reset! walked #{})
452+ (frame! cx render [{:kind :motion :x 50 :y 50}])
453+ (let [off (w/scroll-offset "cull")]
454+ (check! (contains? @walked 30)
455+ (str "a row scrolled into view is walked again: offset=" off))
456+ (check! (close? (get @ys 30) (- y30 off))
457+ (str "at the place it would have had: " (get @ys 30)
458+ " vs " (- y30 off)))))))
459+
416 ;; --- theme -------------------------------------------------------------------460 ;; --- theme -------------------------------------------------------------------
417 461
418 (defn- check-theme! []462 (defn- check-theme! []
@@ -535,6 +579,7 @@
535 ["tab moves focus" check-tab-moves-focus!]579 ["tab moves focus" check-tab-moves-focus!]
536 ["a viewport scrolls" check-scroll!]580 ["a viewport scrolls" check-scroll!]
537 ["a row asks to be seen" check-reveal!]581 ["a row asks to be seen" check-reveal!]
582+ ["rows out of sight are skipped" check-offscreen-rows-are-skipped!]
538 ["the theme mixes" check-theme!]])583 ["the theme mixes" check-theme!]])
539 584
540 (defn -main [& _]585 (defn -main [& _]