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

Paint a frame only when the tree has moved

The terminal loop laid out and painted the whole tree every tick, `fps`
times a second, whether or not anything had changed. `tui_frame` sends
only the cells that differ, but it computes every one of them first — so
a screen standing still with nobody typing cost most of a core, and four
of them left open cost four.

Every change the reconciler makes reaches the library through the five
backend operations here, which makes them the exact answer to whether
this frame differs from the one on the screen. They set a flag, and the
loop paints when it is set. `tick` already blocks for up to a frame
waiting for input, so a loop that skips the paint sleeps there instead of
spinning: 134% of a core on an idle conversation becomes 7.9%.

Two things move without passing through those five. The library writes
its own state back when it handles input — the text in an entry, a list's
cursor — which `tick` reports by answering how much it took, so a nonzero
answer sets the flag too. And anything else that reaches the library some
way this has not thought of would otherwise sit unpainted until the next
keypress, so the loop paints regardless once a second. One frame a second
is not a cost worth saving, and it bounds what a missed flag can do to a
second of staleness rather than a stuck screen.

The flag is cleared before the paint rather than after: work posted from
another thread while this one is inside `frame!` arrives as a mutation on
the next tick's `drain!`, and must not be cleared by this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-03T12:43:36-07:00 Browse files
384390d parent: c2d912f
modified jolt/glimmer-tui/src/glimmer_tui/core.jolt +50 -13
@@ -89,6 +89,26 @@
8989 [v scale]
9090 (long (Math/ceil (- (/ (double v) scale) 0.5))))
9191
92+;; --- what makes a frame worth painting ---------------------------------------
93+;; Every change the reconciler makes to the tree passes through the backend
94+;; operations below, so they are the exact answer to "does this frame differ
95+;; from the one on the screen?". Without asking, the loop laid the whole tree
96+;; out and painted it `fps` times a second whether or not anything had moved,
97+;; which costs most of a core on a screen that is standing still: `tui_frame`
98+;; sends only the cells that changed, but it computes every one of them first.
99+(defonce ^:private dirty (atom true))
100+
101+(defn- touch!
102+ "Say that the tree no longer matches what was painted."
103+ []
104+ (reset! dirty true)
105+ nil)
106+
107+;; A change that reached the library without passing through a backend
108+;; operation would otherwise sit unpainted until the next keypress, so the loop
109+;; paints regardless this often. One frame a second is not a cost worth saving.
110+(def ^:private idle-repaint-ms 1000)
111+
92112 ;; --- props -------------------------------------------------------------------
93113 ;; :hbox and :vbox are one node in the library; the tag only implies an
94114 ;; orientation, and an explicit :orientation prop still wins.
@@ -161,7 +181,7 @@
161181 (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc))
162182 {}
163183 props))
164- nil)
184+ (touch!))
165185
166186 (defn- forget-dead-handlers!
167187 "Drop handler entries for nodes the library has freed.
@@ -193,23 +213,23 @@
193213
194214 (defn- append-child! [_parent-tag parent child]
195215 (ffi/node-append! parent child)
196- nil)
216+ (touch!))
197217
198218 (defn- remove-child! [_parent-tag parent child]
199219 ;; The library frees the subtree; glimmer never mentions it again.
200220 (ffi/node-remove! parent child)
201221 (forget-dead-handlers!)
202- nil)
222+ (touch!))
203223
204224 (defn- replace-child! [_parent-tag parent old-child new-child]
205225 (ffi/node-replace! parent old-child new-child)
206226 (forget-dead-handlers!)
207- nil)
227+ (touch!))
208228
209229 (defn- reorder-child! [_parent-tag parent child sibling]
210230 ;; nil sibling means "first"; the ABI spells that 0.
211231 (ffi/node-insert-after! parent child (or sibling 0))
212- nil)
232+ (touch!))
213233
214234 ;; --- the loop thread ---------------------------------------------------------
215235 (defn- schedule
@@ -444,19 +464,36 @@
444464 (clear-children! root)
445465 (mount-root! root :window)
446466 (reset! b/loop-running? true)
447- (loop []
467+ (touch!)
468+ (loop [painted 0]
448469 (drain!)
449470 (pump-timers!)
450471 ;; Input first, then one call that lays out and paints the whole
451472 ;; tree, then the events both produced — while the frame that caused
452473 ;; them is still the frame the components rendered.
453- (ffi/tick timeout)
454- (ffi/frame!)
455- (dispatch-events!)
456- (when-not (or @quit-requested
457- (ffi/should-close?)
458- (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
459- (recur)))
474+ ;;
475+ ;; `tick` blocks for up to `timeout`, so a loop that paints only when
476+ ;; the tree has moved spends an idle screen asleep in there. What it
477+ ;; handled went into the library's own state — the text in an entry,
478+ ;; a list's cursor — which is a change nothing else here will report.
479+ (when (pos? (ffi/tick timeout))
480+ (touch!))
481+ (let [now (now-ms)
482+ paint? (or @dirty (>= (- now painted) idle-repaint-ms))]
483+ ;; Cleared before the paint, not after: work posted from another
484+ ;; thread while this one is inside `frame!` arrives as a mutation
485+ ;; on the next tick's `drain!`, and must not be cleared by this one.
486+ (when paint?
487+ (reset! dirty false)
488+ (ffi/frame!))
489+ ;; Handlers run here, after the frame they are answering. What they
490+ ;; change is painted by the next pass, which is the pass their
491+ ;; `touch!` has just asked for.
492+ (dispatch-events!)
493+ (when-not (or @quit-requested
494+ (ffi/should-close?)
495+ (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
496+ (recur (if paint? now painted)))))
460497 (finally
461498 (reset! b/loop-running? false)
462499 (cancel-all!)
@@ -89,6 +89,26 @@
89 [v scale]89 [v scale]
90 (long (Math/ceil (- (/ (double v) scale) 0.5))))90 (long (Math/ceil (- (/ (double v) scale) 0.5))))
91 91
92+;; --- what makes a frame worth painting ---------------------------------------
93+;; Every change the reconciler makes to the tree passes through the backend
94+;; operations below, so they are the exact answer to "does this frame differ
95+;; from the one on the screen?". Without asking, the loop laid the whole tree
96+;; out and painted it `fps` times a second whether or not anything had moved,
97+;; which costs most of a core on a screen that is standing still: `tui_frame`
98+;; sends only the cells that changed, but it computes every one of them first.
99+(defonce ^:private dirty (atom true))
100+
101+(defn- touch!
102+ "Say that the tree no longer matches what was painted."
103+ []
104+ (reset! dirty true)
105+ nil)
106+
107+;; A change that reached the library without passing through a backend
108+;; operation would otherwise sit unpainted until the next keypress, so the loop
109+;; paints regardless this often. One frame a second is not a cost worth saving.
110+(def ^:private idle-repaint-ms 1000)
111+
92 ;; --- props -------------------------------------------------------------------112 ;; --- props -------------------------------------------------------------------
93 ;; :hbox and :vbox are one node in the library; the tag only implies an113 ;; :hbox and :vbox are one node in the library; the tag only implies an
94 ;; orientation, and an explicit :orientation prop still wins.114 ;; orientation, and an explicit :orientation prop still wins.
@@ -161,7 +181,7 @@
161 (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc))181 (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc))
162 {}182 {}
163 props))183 props))
164- nil)184+ (touch!))
165 185
166 (defn- forget-dead-handlers!186 (defn- forget-dead-handlers!
167 "Drop handler entries for nodes the library has freed.187 "Drop handler entries for nodes the library has freed.
@@ -193,23 +213,23 @@
193 213
194 (defn- append-child! [_parent-tag parent child]214 (defn- append-child! [_parent-tag parent child]
195 (ffi/node-append! parent child)215 (ffi/node-append! parent child)
196- nil)216+ (touch!))
197 217
198 (defn- remove-child! [_parent-tag parent child]218 (defn- remove-child! [_parent-tag parent child]
199 ;; The library frees the subtree; glimmer never mentions it again.219 ;; The library frees the subtree; glimmer never mentions it again.
200 (ffi/node-remove! parent child)220 (ffi/node-remove! parent child)
201 (forget-dead-handlers!)221 (forget-dead-handlers!)
202- nil)222+ (touch!))
203 223
204 (defn- replace-child! [_parent-tag parent old-child new-child]224 (defn- replace-child! [_parent-tag parent old-child new-child]
205 (ffi/node-replace! parent old-child new-child)225 (ffi/node-replace! parent old-child new-child)
206 (forget-dead-handlers!)226 (forget-dead-handlers!)
207- nil)227+ (touch!))
208 228
209 (defn- reorder-child! [_parent-tag parent child sibling]229 (defn- reorder-child! [_parent-tag parent child sibling]
210 ;; nil sibling means "first"; the ABI spells that 0.230 ;; nil sibling means "first"; the ABI spells that 0.
211 (ffi/node-insert-after! parent child (or sibling 0))231 (ffi/node-insert-after! parent child (or sibling 0))
212- nil)232+ (touch!))
213 233
214 ;; --- the loop thread ---------------------------------------------------------234 ;; --- the loop thread ---------------------------------------------------------
215 (defn- schedule235 (defn- schedule
@@ -444,19 +464,36 @@
444 (clear-children! root)464 (clear-children! root)
445 (mount-root! root :window)465 (mount-root! root :window)
446 (reset! b/loop-running? true)466 (reset! b/loop-running? true)
447- (loop []467+ (touch!)
468+ (loop [painted 0]
448 (drain!)469 (drain!)
449 (pump-timers!)470 (pump-timers!)
450 ;; Input first, then one call that lays out and paints the whole471 ;; Input first, then one call that lays out and paints the whole
451 ;; tree, then the events both produced — while the frame that caused472 ;; tree, then the events both produced — while the frame that caused
452 ;; them is still the frame the components rendered.473 ;; them is still the frame the components rendered.
453- (ffi/tick timeout)474+ ;;
454- (ffi/frame!)475+ ;; `tick` blocks for up to `timeout`, so a loop that paints only when
455- (dispatch-events!)476+ ;; the tree has moved spends an idle screen asleep in there. What it
456- (when-not (or @quit-requested477+ ;; handled went into the library's own state — the text in an entry,
457- (ffi/should-close?)478+ ;; a list's cursor — which is a change nothing else here will report.
458- (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))479+ (when (pos? (ffi/tick timeout))
459- (recur)))480+ (touch!))
481+ (let [now (now-ms)
482+ paint? (or @dirty (>= (- now painted) idle-repaint-ms))]
483+ ;; Cleared before the paint, not after: work posted from another
484+ ;; thread while this one is inside `frame!` arrives as a mutation
485+ ;; on the next tick's `drain!`, and must not be cleared by this one.
486+ (when paint?
487+ (reset! dirty false)
488+ (ffi/frame!))
489+ ;; Handlers run here, after the frame they are answering. What they
490+ ;; change is painted by the next pass, which is the pass their
491+ ;; `touch!` has just asked for.
492+ (dispatch-events!)
493+ (when-not (or @quit-requested
494+ (ffi/should-close?)
495+ (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
496+ (recur (if paint? now painted)))))
460 (finally497 (finally
461 (reset! b/loop-running? false)498 (reset! b/loop-running? false)
462 (cancel-all!)499 (cancel-all!)