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>
384390d parent: c2d912f modified
jolt/glimmer-tui/src/glimmer_tui/core.jolt +50 -13 | @@ -89,6 +89,26 @@ | ||
| 89 | 89 | [v scale] |
| 90 | 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 | 112 | ;; --- props ------------------------------------------------------------------- |
| 93 | 113 | ;; :hbox and :vbox are one node in the library; the tag only implies an |
| 94 | 114 | ;; orientation, and an explicit :orientation prop still wins. |
| @@ -161,7 +181,7 @@ | ||
| 161 | 181 | (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc)) |
| 162 | 182 | {} |
| 163 | 183 | props)) |
| 164 | - nil) | |
| 184 | + (touch!)) | |
| 165 | 185 | |
| 166 | 186 | (defn- forget-dead-handlers! |
| 167 | 187 | "Drop handler entries for nodes the library has freed. |
| @@ -193,23 +213,23 @@ | ||
| 193 | 213 | |
| 194 | 214 | (defn- append-child! [_parent-tag parent child] |
| 195 | 215 | (ffi/node-append! parent child) |
| 196 | - nil) | |
| 216 | + (touch!)) | |
| 197 | 217 | |
| 198 | 218 | (defn- remove-child! [_parent-tag parent child] |
| 199 | 219 | ;; The library frees the subtree; glimmer never mentions it again. |
| 200 | 220 | (ffi/node-remove! parent child) |
| 201 | 221 | (forget-dead-handlers!) |
| 202 | - nil) | |
| 222 | + (touch!)) | |
| 203 | 223 | |
| 204 | 224 | (defn- replace-child! [_parent-tag parent old-child new-child] |
| 205 | 225 | (ffi/node-replace! parent old-child new-child) |
| 206 | 226 | (forget-dead-handlers!) |
| 207 | - nil) | |
| 227 | + (touch!)) | |
| 208 | 228 | |
| 209 | 229 | (defn- reorder-child! [_parent-tag parent child sibling] |
| 210 | 230 | ;; nil sibling means "first"; the ABI spells that 0. |
| 211 | 231 | (ffi/node-insert-after! parent child (or sibling 0)) |
| 212 | - nil) | |
| 232 | + (touch!)) | |
| 213 | 233 | |
| 214 | 234 | ;; --- the loop thread --------------------------------------------------------- |
| 215 | 235 | (defn- schedule |
| @@ -444,19 +464,36 @@ | ||
| 444 | 464 | (clear-children! root) |
| 445 | 465 | (mount-root! root :window) |
| 446 | 466 | (reset! b/loop-running? true) |
| 447 | - (loop [] | |
| 467 | + (touch!) | |
| 468 | + (loop [painted 0] | |
| 448 | 469 | (drain!) |
| 449 | 470 | (pump-timers!) |
| 450 | 471 | ;; Input first, then one call that lays out and paints the whole |
| 451 | 472 | ;; tree, then the events both produced — while the frame that caused |
| 452 | 473 | ;; 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))))) | |
| 460 | 497 | (finally |
| 461 | 498 | (reset! b/loop-running? false) |
| 462 | 499 | (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 an | 113 | ;; :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- schedule | 235 | (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 whole | 471 | ;; Input first, then one call that lays out and paints the whole |
| 451 | ;; tree, then the events both produced — while the frame that caused | 472 | ;; 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-requested | 477 | + ;; 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 | (finally | 497 | (finally |
| 461 | (reset! b/loop-running? false) | 498 | (reset! b/loop-running? false) |
| 462 | (cancel-all!) | 499 | (cancel-all!) |