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

Measure a node once, and paint only what is on screen

Scrolling a conversation in a terminal was choppy, and the reason was not
the scrolling: a frame cost 0.39ms per message in the backlog whether the
reader could see that message or not. Forty messages and the paint no
longer fits in a frame; four hundred and it takes 157ms, which is six
frames to move the list by three rows.

Two things were wrong, and they multiplied.

Nothing asked a node its size once. A box divides its room by measuring
every child, `children_rects` measures them again to place them, and then
the painter recurses and the child does the whole thing over for its own
children — so a node was measured once for every ancestor that asked, and
the work under a message doubled with every box it was wrapped in. Six
deep, in frq's backlog, that is sixty-odd measurements of a line of text
to draw it once. A size is a pure function of the subtree and the room it
was given, so it is now remembered: `Question` names the four that get
asked, and `remember` answers the second one from the first.

And a scroll painted its whole content, however little of it was showing.
The full column went into a grid as tall as itself — four hundred
messages is sixteen hundred rows of cells, allocated, painted and thrown
away — and a viewport-sized window was copied out of it. Now the rows
worth painting are the visible ones, grown to whole children at each end
so a message straddling an edge is still laid out in one piece and cut by
the copy rather than by the layout.

What makes the first fix hold past a single frame is `Tree::touch`. A
change to a node bumps a revision on it and on every node above it —
upwards, because that is the direction sizes travel — and a remembered
size names the revision it was true of. So a message arriving at the
bottom of a long backlog costs the measurement of that message and the
column it is in, and not of the hundred above it that did not move. That
is what takes the cost of a frame off the length of the conversation:

    messages    before      after
          25    9.57ms     0.69ms
          50   19.92ms     0.71ms
         100   38.51ms     0.75ms
         200   78.71ms     0.93ms
         400  157.38ms     1.48ms

`backlog_cost` is where those come from — ignored by default, since it
asserts nothing; `cargo test --release -- --ignored backlog_cost` runs it.

Three things the tests now hold down, because painting less is a way to
paint the wrong thing. A message straddling the top of the viewport shows
the half that is in it. A scrolled viewport paints exactly the rows the
unscrolled list has there, at four different offsets. And a button below
the fold keeps its place in the focus ring: the ring is built while
painting, so a scroll now walks the children it skipped for their
focusable nodes — tabbing onto something out of sight is how a reader
gets to it, and there is nowhere to click what is not on screen, which is
why those take a place in the ring and no rect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-03T14:32:37-07:00 Browse files
c41903b parent: 384390d
modified crates/jolt-tui/src/layout.rs +164 -30
@@ -14,6 +14,80 @@ use crate::graphics;
1414 use crate::screen::{glyph_cols, glyphs, text_cols, Rect};
1515 use crate::tree::{Props, Tag, Tree};
1616
17+use std::cell::RefCell;
18+use std::collections::HashMap;
19+
20+// --- measuring the same node twice -------------------------------------------
21+// A size here is a pure function of the tree, and the tree does not move while
22+// it is being measured: the reconciler's changes arrive between frames, and
23+// painting takes the tree by shared reference. So an answer can be kept.
24+//
25+// It has to be. Nothing below asks a node its size once. A box shares its room
26+// out by measuring every child, then `children_rects` measures them again to
27+// place them, and then the painter recurses and the child repeats the whole
28+// thing for its own children — so a node is measured once for every ancestor
29+// that asks, and the work under a message doubles with every box it is wrapped
30+// in. On frq's backlog, six deep, that is what made a frame cost the best part
31+// of a second: not the wrapping, but the same wrapping done sixty times.
32+//
33+// Keyed on what the answer depends on and nothing else — the node, the room it
34+// was given, and which of the four questions was asked.
35+
36+#[derive(Clone, Copy, PartialEq, Eq, Hash)]
37+enum Question {
38+ Width { minimum: bool },
39+ Height,
40+ MinHeight,
41+}
42+
43+/// A question about a node, and the room it was asked about.
44+type Asked = (Question, u32, u16);
45+/// A box, the room it had, the axis and the extent across it.
46+type Divided = (u32, u16, bool, u16);
47+/// An answer, and the subtree revision it was true of.
48+type Answered<T> = (u64, T);
49+
50+thread_local! {
51+ /// Sizes answered so far, and the shares boxes divided out. Each remembers
52+ /// the subtree revision it was taken at, which is what makes it safe to
53+ /// keep past the frame that asked.
54+ static SIZES: RefCell<HashMap<Asked, Answered<u16>>> = RefCell::new(HashMap::new());
55+ static SHARES: RefCell<HashMap<Divided, Answered<Vec<u16>>>> =
56+ RefCell::new(HashMap::new());
57+}
58+
59+/// An answer for a node that has been freed since is never right again, and its
60+/// handle will be handed out to some other node — which gets a fresh revision,
61+/// so the stale entry is refused rather than believed. It is only the room it
62+/// takes that is worth anything, so it is swept on size rather than on every
63+/// free: a tree of a few thousand nodes asks a handful of questions about each.
64+const KEEP: usize = 1 << 16;
65+
66+/// `f`, unless this exact question has already been answered about this node
67+/// and nothing under it has moved since.
68+///
69+/// The borrow is dropped before `f` runs: `f` measures children, which asks
70+/// again through here, and holding it across the call would panic on the first
71+/// nested box.
72+fn remember(tree: &Tree, question: Question, id: u32, avail: u16, f: impl FnOnce() -> u16) -> u16 {
73+ let rev = tree.revision_of(id);
74+ let key = (question, id, avail);
75+ if let Some((then, known)) = SIZES.with(|m| m.borrow().get(&key).copied()) {
76+ if then == rev {
77+ return known;
78+ }
79+ }
80+ let answer = f();
81+ SIZES.with(|m| {
82+ let mut map = m.borrow_mut();
83+ if map.len() >= KEEP {
84+ map.clear();
85+ }
86+ map.insert(key, (rev, answer));
87+ });
88+ answer
89+}
90+
1791 /// How a child that is not filling its cross axis sits in the space it was
1892 /// given. `:halign` and `:valign` in the props.
1993 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -170,14 +244,14 @@ pub fn entry_text(props: &Props) -> String {
170244
171245 /// A node's content size before its own request or inset is applied.
172246 fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
173- let tag = tree.tag(id);
174- let props = tree.props(id);
247+ let tag = tree.tag_of(id);
248+ let props = tree.props_of(id);
175249 let text = props.label();
176250 match tag {
177251 Tag::Button => columns(text).saturating_add(4),
178252 Tag::CheckButton => columns(text).saturating_add(4),
179253 Tag::Entry => {
180- let want = columns(&entry_text(&props)).saturating_add(1).max(12);
254+ let want = columns(&entry_text(props)).saturating_add(1).max(12);
181255 if minimum {
182256 want.min(6)
183257 } else {
@@ -199,7 +273,7 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
199273 // the face and an `:image`'s is its alt text: words for something that
200274 // cannot be drawn here, and in frq's case words already on the row
201275 // beside it, which is how every sender came out named twice.
202- Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
276+ Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(props) => {
203277 if minimum {
204278 longest_word(props.label())
205279 } else {
@@ -209,8 +283,8 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
209283 Tag::Separator => 1,
210284 Tag::Spacer => props.cells("size", 1),
211285 Tag::Emoji => text_cols(props.str("emoji")),
212- Tag::Image => image_cells(&props, u16::MAX).0,
213- Tag::Reaction => text_cols(&pill_text(&props)),
286+ Tag::Image => image_cells(props, u16::MAX).0,
287+ Tag::Reaction => text_cols(&pill_text(props)),
214288 Tag::Progress => {
215289 if minimum {
216290 4
@@ -220,7 +294,7 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
220294 }
221295 Tag::Spinner => 1,
222296 Tag::Listbox => tree
223- .children(id)
297+ .children_of(id)
224298 .iter()
225299 .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2))
226300 .max()
@@ -228,13 +302,13 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
228302 // Every container measures its children the same way; only the axis
229303 // the sum runs along differs.
230304 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
231- let children = tree.children(id);
305+ let children = tree.children_of(id);
232306 let spacing = props.cells("spacing", 0);
233307 let sizes = children
234308 .iter()
235309 .map(|c| width(tree, *c, minimum))
236310 .collect::<Vec<_>>();
237- let content = if horizontal(&props) && matches!(tag, Tag::Box) {
311+ let content = if horizontal(props) && matches!(tag, Tag::Box) {
238312 let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16);
239313 sizes.iter().fold(gaps, |a, b| a.saturating_add(*b))
240314 } else {
@@ -269,12 +343,18 @@ fn has_picture(props: &Props) -> bool {
269343 /// in. Asking for a width is the caller saying how wide the column is; nothing
270344 /// else in a terminal can say it for them.
271345 pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
272- let props = tree.props(id);
346+ remember(tree, Question::Width { minimum }, id, 0, || {
347+ width_uncached(tree, id, minimum)
348+ })
349+}
350+
351+fn width_uncached(tree: &Tree, id: u32, minimum: bool) -> u16 {
352+ let props = tree.props_of(id);
273353 let requested = props.cells("width-request", 0);
274354 if requested > 0 {
275355 return requested;
276356 }
277- let pad = inset(&tree.tag(id), &props).saturating_mul(2);
357+ let pad = inset(tree.tag_of(id), props).saturating_mul(2);
278358 intrinsic_width(tree, id, minimum).saturating_add(pad)
279359 }
280360
@@ -283,13 +363,19 @@ pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
283363 /// Height depends on width — that is what wrapping means — so there is no
284364 /// natural height to ask for on its own.
285365 pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
286- let tag = tree.tag(id);
287- let props = tree.props(id);
288- let pad = inset(&tag, &props);
366+ remember(tree, Question::Height, id, avail, || {
367+ height_for_width_uncached(tree, id, avail)
368+ })
369+}
370+
371+fn height_for_width_uncached(tree: &Tree, id: u32, avail: u16) -> u16 {
372+ let tag = tree.tag_of(id);
373+ let props = tree.props_of(id);
374+ let pad = inset(tag, props);
289375 let inner = avail.saturating_sub(pad.saturating_mul(2));
290376 let content = match tag {
291377 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
292- Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
378+ Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(props) => {
293379 wrap(props.label(), inner).len() as u16
294380 }
295381 Tag::Button
@@ -299,14 +385,14 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
299385 | Tag::Spinner
300386 | Tag::Reaction
301387 | Tag::Emoji => 1,
302- Tag::Image => image_cells(&props, inner).1,
388+ Tag::Image => image_cells(props, inner).1,
303389 Tag::Entry => props.cells("rows", 1).max(1),
304390 Tag::Spacer => props.cells("size", 1),
305391 Tag::Listbox => tree.child_count(id) as u16,
306392 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
307- let children = tree.children(id);
393+ let children = tree.children_of(id);
308394 let spacing = props.cells("spacing", 0);
309- if horizontal(&props) && matches!(tag, Tag::Box) {
395+ if horizontal(props) && matches!(tag, Tag::Box) {
310396 // Across: each child is measured at the width it will get.
311397 let shares = share(tree, id, inner, true, 0);
312398 children
@@ -343,18 +429,24 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
343429 /// for and the separator and compose bar under it were painted past the bottom
344430 /// edge — a conversation you cannot type into.
345431 pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
346- let tag = tree.tag(id);
347- let props = tree.props(id);
348- let pad = inset(&tag, &props);
432+ remember(tree, Question::MinHeight, id, avail, || {
433+ min_height_for_width_uncached(tree, id, avail)
434+ })
435+}
436+
437+fn min_height_for_width_uncached(tree: &Tree, id: u32, avail: u16) -> u16 {
438+ let tag = tree.tag_of(id);
439+ let props = tree.props_of(id);
440+ let pad = inset(tag, props);
349441 let inner = avail.saturating_sub(pad.saturating_mul(2));
350442 let content = match tag {
351443 Tag::Scroll => 1,
352444 Tag::Box | Tag::Window | Tag::Frame | Tag::Overlay | Tag::Unknown(_)
353445 if tree.child_count(id) > 0 =>
354446 {
355- let children = tree.children(id);
447+ let children = tree.children_of(id);
356448 let spacing = props.cells("spacing", 0);
357- if horizontal(&props) && matches!(tag, Tag::Box) {
449+ if horizontal(props) && matches!(tag, Tag::Box) {
358450 let shares = share(tree, id, inner, true, 0);
359451 children
360452 .iter()
@@ -393,11 +485,30 @@ pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
393485 /// drops every sibling after it. Unused when `across`, where a width does not
394486 /// depend on a height.
395487 pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<u16> {
396- let children = tree.children(id);
488+ let rev = tree.revision_of(id);
489+ let key = (id, avail, across, cross);
490+ if let Some((then, known)) = SHARES.with(|m| m.borrow().get(&key).cloned()) {
491+ if then == rev {
492+ return known;
493+ }
494+ }
495+ let shares = share_uncached(tree, id, avail, across, cross);
496+ SHARES.with(|m| {
497+ let mut map = m.borrow_mut();
498+ if map.len() >= KEEP {
499+ map.clear();
500+ }
501+ map.insert(key, (rev, shares.clone()));
502+ });
503+ shares
504+}
505+
506+fn share_uncached(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<u16> {
507+ let children = tree.children_of(id);
397508 if children.is_empty() {
398509 return Vec::new();
399510 }
400- let props = tree.props(id);
511+ let props = tree.props_of(id);
401512 let spacing = props.cells("spacing", 0);
402513 let gaps = spacing.saturating_mul((children.len() - 1) as u16);
403514 let room = avail.saturating_sub(gaps) as i64;
@@ -449,7 +560,7 @@ pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<
449560 let greedy: Vec<usize> = children
450561 .iter()
451562 .enumerate()
452- .filter(|(_, c)| tree.props(**c).bool(key, false))
563+ .filter(|(_, c)| tree.props_of(**c).bool(key, false))
453564 .map(|(i, _)| i)
454565 .collect();
455566 if !greedy.is_empty() {
@@ -480,10 +591,10 @@ pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) {
480591
481592 /// Lay the children of a box out inside `area`.
482593 pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
483- let props = tree.props(id);
484- let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);
594+ let props = tree.props_of(id);
595+ let across = horizontal(props) && matches!(tree.tag_of(id), Tag::Box);
485596 let spacing = props.cells("spacing", 0);
486- let children = tree.children(id);
597+ let children = tree.children_of(id);
487598 let shares = share(
488599 tree,
489600 id,
@@ -495,7 +606,7 @@ pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
495606 let mut out = Vec::with_capacity(children.len());
496607 let mut at = 0u16;
497608 for (child, main) in children.iter().zip(shares) {
498- let cprops = tree.props(*child);
609+ let cprops = tree.props_of(*child);
499610 let rect = if across {
500611 let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0));
501612 let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h);
@@ -543,6 +654,29 @@ mod tests {
543654 assert_eq!(wrap("a\nb", 10), vec!["a", "b"]);
544655 }
545656
657+ #[test]
658+ fn a_size_measured_before_a_change_is_not_the_answer_after_one() {
659+ // Sizes are kept between frames, so what has to be right is when they
660+ // stop being. A word typed into a label three boxes down changes how
661+ // tall the box at the top is, and the answer taken before it has to go
662+ // for every one of them — which is what the walk up the parents in
663+ // `Tree::touch` is for.
664+ let mut tree = Tree::new();
665+ let root = tree.root();
666+ let outer = tree.new_node("vbox");
667+ tree.append(root, outer);
668+ let inner = tree.new_node("vbox");
669+ tree.append(outer, inner);
670+ let text = label(&mut tree, inner, "one");
671+ assert_eq!(height_for_width(&tree, outer, 10), 1);
672+ tree.set(text, "label", Value::Str("one two three four".into()));
673+ assert_eq!(
674+ height_for_width(&tree, outer, 10),
675+ 2,
676+ "the label now wraps, and the boxes above it are a row taller"
677+ );
678+ }
679+
546680 #[test]
547681 fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() {
548682 let mut tree = Tree::new();
@@ -14,6 +14,80 @@ use crate::graphics;
14 use crate::screen::{glyph_cols, glyphs, text_cols, Rect};14 use crate::screen::{glyph_cols, glyphs, text_cols, Rect};
15 use crate::tree::{Props, Tag, Tree};15 use crate::tree::{Props, Tag, Tree};
16 16
17+use std::cell::RefCell;
18+use std::collections::HashMap;
19+
20+// --- measuring the same node twice -------------------------------------------
21+// A size here is a pure function of the tree, and the tree does not move while
22+// it is being measured: the reconciler's changes arrive between frames, and
23+// painting takes the tree by shared reference. So an answer can be kept.
24+//
25+// It has to be. Nothing below asks a node its size once. A box shares its room
26+// out by measuring every child, then `children_rects` measures them again to
27+// place them, and then the painter recurses and the child repeats the whole
28+// thing for its own children — so a node is measured once for every ancestor
29+// that asks, and the work under a message doubles with every box it is wrapped
30+// in. On frq's backlog, six deep, that is what made a frame cost the best part
31+// of a second: not the wrapping, but the same wrapping done sixty times.
32+//
33+// Keyed on what the answer depends on and nothing else — the node, the room it
34+// was given, and which of the four questions was asked.
35+
36+#[derive(Clone, Copy, PartialEq, Eq, Hash)]
37+enum Question {
38+ Width { minimum: bool },
39+ Height,
40+ MinHeight,
41+}
42+
43+/// A question about a node, and the room it was asked about.
44+type Asked = (Question, u32, u16);
45+/// A box, the room it had, the axis and the extent across it.
46+type Divided = (u32, u16, bool, u16);
47+/// An answer, and the subtree revision it was true of.
48+type Answered<T> = (u64, T);
49+
50+thread_local! {
51+ /// Sizes answered so far, and the shares boxes divided out. Each remembers
52+ /// the subtree revision it was taken at, which is what makes it safe to
53+ /// keep past the frame that asked.
54+ static SIZES: RefCell<HashMap<Asked, Answered<u16>>> = RefCell::new(HashMap::new());
55+ static SHARES: RefCell<HashMap<Divided, Answered<Vec<u16>>>> =
56+ RefCell::new(HashMap::new());
57+}
58+
59+/// An answer for a node that has been freed since is never right again, and its
60+/// handle will be handed out to some other node — which gets a fresh revision,
61+/// so the stale entry is refused rather than believed. It is only the room it
62+/// takes that is worth anything, so it is swept on size rather than on every
63+/// free: a tree of a few thousand nodes asks a handful of questions about each.
64+const KEEP: usize = 1 << 16;
65+
66+/// `f`, unless this exact question has already been answered about this node
67+/// and nothing under it has moved since.
68+///
69+/// The borrow is dropped before `f` runs: `f` measures children, which asks
70+/// again through here, and holding it across the call would panic on the first
71+/// nested box.
72+fn remember(tree: &Tree, question: Question, id: u32, avail: u16, f: impl FnOnce() -> u16) -> u16 {
73+ let rev = tree.revision_of(id);
74+ let key = (question, id, avail);
75+ if let Some((then, known)) = SIZES.with(|m| m.borrow().get(&key).copied()) {
76+ if then == rev {
77+ return known;
78+ }
79+ }
80+ let answer = f();
81+ SIZES.with(|m| {
82+ let mut map = m.borrow_mut();
83+ if map.len() >= KEEP {
84+ map.clear();
85+ }
86+ map.insert(key, (rev, answer));
87+ });
88+ answer
89+}
90+
17 /// How a child that is not filling its cross axis sits in the space it was91 /// How a child that is not filling its cross axis sits in the space it was
18 /// given. `:halign` and `:valign` in the props.92 /// given. `:halign` and `:valign` in the props.
19 #[derive(Clone, Copy, Debug, PartialEq, Eq)]93 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -170,14 +244,14 @@ pub fn entry_text(props: &Props) -> String {
170 244
171 /// A node's content size before its own request or inset is applied.245 /// A node's content size before its own request or inset is applied.
172 fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {246 fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
173- let tag = tree.tag(id);247+ let tag = tree.tag_of(id);
174- let props = tree.props(id);248+ let props = tree.props_of(id);
175 let text = props.label();249 let text = props.label();
176 match tag {250 match tag {
177 Tag::Button => columns(text).saturating_add(4),251 Tag::Button => columns(text).saturating_add(4),
178 Tag::CheckButton => columns(text).saturating_add(4),252 Tag::CheckButton => columns(text).saturating_add(4),
179 Tag::Entry => {253 Tag::Entry => {
180- let want = columns(&entry_text(&props)).saturating_add(1).max(12);254+ let want = columns(&entry_text(props)).saturating_add(1).max(12);
181 if minimum {255 if minimum {
182 want.min(6)256 want.min(6)
183 } else {257 } else {
@@ -199,7 +273,7 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
199 // the face and an `:image`'s is its alt text: words for something that273 // the face and an `:image`'s is its alt text: words for something that
200 // cannot be drawn here, and in frq's case words already on the row274 // cannot be drawn here, and in frq's case words already on the row
201 // beside it, which is how every sender came out named twice.275 // beside it, which is how every sender came out named twice.
202- Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {276+ Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(props) => {
203 if minimum {277 if minimum {
204 longest_word(props.label())278 longest_word(props.label())
205 } else {279 } else {
@@ -209,8 +283,8 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
209 Tag::Separator => 1,283 Tag::Separator => 1,
210 Tag::Spacer => props.cells("size", 1),284 Tag::Spacer => props.cells("size", 1),
211 Tag::Emoji => text_cols(props.str("emoji")),285 Tag::Emoji => text_cols(props.str("emoji")),
212- Tag::Image => image_cells(&props, u16::MAX).0,286+ Tag::Image => image_cells(props, u16::MAX).0,
213- Tag::Reaction => text_cols(&pill_text(&props)),287+ Tag::Reaction => text_cols(&pill_text(props)),
214 Tag::Progress => {288 Tag::Progress => {
215 if minimum {289 if minimum {
216 4290 4
@@ -220,7 +294,7 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
220 }294 }
221 Tag::Spinner => 1,295 Tag::Spinner => 1,
222 Tag::Listbox => tree296 Tag::Listbox => tree
223- .children(id)297+ .children_of(id)
224 .iter()298 .iter()
225 .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2))299 .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2))
226 .max()300 .max()
@@ -228,13 +302,13 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
228 // Every container measures its children the same way; only the axis302 // Every container measures its children the same way; only the axis
229 // the sum runs along differs.303 // the sum runs along differs.
230 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {304 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
231- let children = tree.children(id);305+ let children = tree.children_of(id);
232 let spacing = props.cells("spacing", 0);306 let spacing = props.cells("spacing", 0);
233 let sizes = children307 let sizes = children
234 .iter()308 .iter()
235 .map(|c| width(tree, *c, minimum))309 .map(|c| width(tree, *c, minimum))
236 .collect::<Vec<_>>();310 .collect::<Vec<_>>();
237- let content = if horizontal(&props) && matches!(tag, Tag::Box) {311+ let content = if horizontal(props) && matches!(tag, Tag::Box) {
238 let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16);312 let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16);
239 sizes.iter().fold(gaps, |a, b| a.saturating_add(*b))313 sizes.iter().fold(gaps, |a, b| a.saturating_add(*b))
240 } else {314 } else {
@@ -269,12 +343,18 @@ fn has_picture(props: &Props) -> bool {
269 /// in. Asking for a width is the caller saying how wide the column is; nothing343 /// in. Asking for a width is the caller saying how wide the column is; nothing
270 /// else in a terminal can say it for them.344 /// else in a terminal can say it for them.
271 pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {345 pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
272- let props = tree.props(id);346+ remember(tree, Question::Width { minimum }, id, 0, || {
347+ width_uncached(tree, id, minimum)
348+ })
349+}
350+
351+fn width_uncached(tree: &Tree, id: u32, minimum: bool) -> u16 {
352+ let props = tree.props_of(id);
273 let requested = props.cells("width-request", 0);353 let requested = props.cells("width-request", 0);
274 if requested > 0 {354 if requested > 0 {
275 return requested;355 return requested;
276 }356 }
277- let pad = inset(&tree.tag(id), &props).saturating_mul(2);357+ let pad = inset(tree.tag_of(id), props).saturating_mul(2);
278 intrinsic_width(tree, id, minimum).saturating_add(pad)358 intrinsic_width(tree, id, minimum).saturating_add(pad)
279 }359 }
280 360
@@ -283,13 +363,19 @@ pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
283 /// Height depends on width — that is what wrapping means — so there is no363 /// Height depends on width — that is what wrapping means — so there is no
284 /// natural height to ask for on its own.364 /// natural height to ask for on its own.
285 pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {365 pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
286- let tag = tree.tag(id);366+ remember(tree, Question::Height, id, avail, || {
287- let props = tree.props(id);367+ height_for_width_uncached(tree, id, avail)
288- let pad = inset(&tag, &props);368+ })
369+}
370+
371+fn height_for_width_uncached(tree: &Tree, id: u32, avail: u16) -> u16 {
372+ let tag = tree.tag_of(id);
373+ let props = tree.props_of(id);
374+ let pad = inset(tag, props);
289 let inner = avail.saturating_sub(pad.saturating_mul(2));375 let inner = avail.saturating_sub(pad.saturating_mul(2));
290 let content = match tag {376 let content = match tag {
291 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,377 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
292- Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {378+ Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(props) => {
293 wrap(props.label(), inner).len() as u16379 wrap(props.label(), inner).len() as u16
294 }380 }
295 Tag::Button381 Tag::Button
@@ -299,14 +385,14 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
299 | Tag::Spinner385 | Tag::Spinner
300 | Tag::Reaction386 | Tag::Reaction
301 | Tag::Emoji => 1,387 | Tag::Emoji => 1,
302- Tag::Image => image_cells(&props, inner).1,388+ Tag::Image => image_cells(props, inner).1,
303 Tag::Entry => props.cells("rows", 1).max(1),389 Tag::Entry => props.cells("rows", 1).max(1),
304 Tag::Spacer => props.cells("size", 1),390 Tag::Spacer => props.cells("size", 1),
305 Tag::Listbox => tree.child_count(id) as u16,391 Tag::Listbox => tree.child_count(id) as u16,
306 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {392 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
307- let children = tree.children(id);393+ let children = tree.children_of(id);
308 let spacing = props.cells("spacing", 0);394 let spacing = props.cells("spacing", 0);
309- if horizontal(&props) && matches!(tag, Tag::Box) {395+ if horizontal(props) && matches!(tag, Tag::Box) {
310 // Across: each child is measured at the width it will get.396 // Across: each child is measured at the width it will get.
311 let shares = share(tree, id, inner, true, 0);397 let shares = share(tree, id, inner, true, 0);
312 children398 children
@@ -343,18 +429,24 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
343 /// for and the separator and compose bar under it were painted past the bottom429 /// for and the separator and compose bar under it were painted past the bottom
344 /// edge — a conversation you cannot type into.430 /// edge — a conversation you cannot type into.
345 pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {431 pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
346- let tag = tree.tag(id);432+ remember(tree, Question::MinHeight, id, avail, || {
347- let props = tree.props(id);433+ min_height_for_width_uncached(tree, id, avail)
348- let pad = inset(&tag, &props);434+ })
435+}
436+
437+fn min_height_for_width_uncached(tree: &Tree, id: u32, avail: u16) -> u16 {
438+ let tag = tree.tag_of(id);
439+ let props = tree.props_of(id);
440+ let pad = inset(tag, props);
349 let inner = avail.saturating_sub(pad.saturating_mul(2));441 let inner = avail.saturating_sub(pad.saturating_mul(2));
350 let content = match tag {442 let content = match tag {
351 Tag::Scroll => 1,443 Tag::Scroll => 1,
352 Tag::Box | Tag::Window | Tag::Frame | Tag::Overlay | Tag::Unknown(_)444 Tag::Box | Tag::Window | Tag::Frame | Tag::Overlay | Tag::Unknown(_)
353 if tree.child_count(id) > 0 =>445 if tree.child_count(id) > 0 =>
354 {446 {
355- let children = tree.children(id);447+ let children = tree.children_of(id);
356 let spacing = props.cells("spacing", 0);448 let spacing = props.cells("spacing", 0);
357- if horizontal(&props) && matches!(tag, Tag::Box) {449+ if horizontal(props) && matches!(tag, Tag::Box) {
358 let shares = share(tree, id, inner, true, 0);450 let shares = share(tree, id, inner, true, 0);
359 children451 children
360 .iter()452 .iter()
@@ -393,11 +485,30 @@ pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
393 /// drops every sibling after it. Unused when `across`, where a width does not485 /// drops every sibling after it. Unused when `across`, where a width does not
394 /// depend on a height.486 /// depend on a height.
395 pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<u16> {487 pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<u16> {
396- let children = tree.children(id);488+ let rev = tree.revision_of(id);
489+ let key = (id, avail, across, cross);
490+ if let Some((then, known)) = SHARES.with(|m| m.borrow().get(&key).cloned()) {
491+ if then == rev {
492+ return known;
493+ }
494+ }
495+ let shares = share_uncached(tree, id, avail, across, cross);
496+ SHARES.with(|m| {
497+ let mut map = m.borrow_mut();
498+ if map.len() >= KEEP {
499+ map.clear();
500+ }
501+ map.insert(key, (rev, shares.clone()));
502+ });
503+ shares
504+}
505+
506+fn share_uncached(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<u16> {
507+ let children = tree.children_of(id);
397 if children.is_empty() {508 if children.is_empty() {
398 return Vec::new();509 return Vec::new();
399 }510 }
400- let props = tree.props(id);511+ let props = tree.props_of(id);
401 let spacing = props.cells("spacing", 0);512 let spacing = props.cells("spacing", 0);
402 let gaps = spacing.saturating_mul((children.len() - 1) as u16);513 let gaps = spacing.saturating_mul((children.len() - 1) as u16);
403 let room = avail.saturating_sub(gaps) as i64;514 let room = avail.saturating_sub(gaps) as i64;
@@ -449,7 +560,7 @@ pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<
449 let greedy: Vec<usize> = children560 let greedy: Vec<usize> = children
450 .iter()561 .iter()
451 .enumerate()562 .enumerate()
452- .filter(|(_, c)| tree.props(**c).bool(key, false))563+ .filter(|(_, c)| tree.props_of(**c).bool(key, false))
453 .map(|(i, _)| i)564 .map(|(i, _)| i)
454 .collect();565 .collect();
455 if !greedy.is_empty() {566 if !greedy.is_empty() {
@@ -480,10 +591,10 @@ pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) {
480 591
481 /// Lay the children of a box out inside `area`.592 /// Lay the children of a box out inside `area`.
482 pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {593 pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
483- let props = tree.props(id);594+ let props = tree.props_of(id);
484- let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);595+ let across = horizontal(props) && matches!(tree.tag_of(id), Tag::Box);
485 let spacing = props.cells("spacing", 0);596 let spacing = props.cells("spacing", 0);
486- let children = tree.children(id);597+ let children = tree.children_of(id);
487 let shares = share(598 let shares = share(
488 tree,599 tree,
489 id,600 id,
@@ -495,7 +606,7 @@ pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
495 let mut out = Vec::with_capacity(children.len());606 let mut out = Vec::with_capacity(children.len());
496 let mut at = 0u16;607 let mut at = 0u16;
497 for (child, main) in children.iter().zip(shares) {608 for (child, main) in children.iter().zip(shares) {
498- let cprops = tree.props(*child);609+ let cprops = tree.props_of(*child);
499 let rect = if across {610 let rect = if across {
500 let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0));611 let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0));
501 let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h);612 let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h);
@@ -543,6 +654,29 @@ mod tests {
543 assert_eq!(wrap("a\nb", 10), vec!["a", "b"]);654 assert_eq!(wrap("a\nb", 10), vec!["a", "b"]);
544 }655 }
545 656
657+ #[test]
658+ fn a_size_measured_before_a_change_is_not_the_answer_after_one() {
659+ // Sizes are kept between frames, so what has to be right is when they
660+ // stop being. A word typed into a label three boxes down changes how
661+ // tall the box at the top is, and the answer taken before it has to go
662+ // for every one of them — which is what the walk up the parents in
663+ // `Tree::touch` is for.
664+ let mut tree = Tree::new();
665+ let root = tree.root();
666+ let outer = tree.new_node("vbox");
667+ tree.append(root, outer);
668+ let inner = tree.new_node("vbox");
669+ tree.append(outer, inner);
670+ let text = label(&mut tree, inner, "one");
671+ assert_eq!(height_for_width(&tree, outer, 10), 1);
672+ tree.set(text, "label", Value::Str("one two three four".into()));
673+ assert_eq!(
674+ height_for_width(&tree, outer, 10),
675+ 2,
676+ "the label now wraps, and the boxes above it are a row taller"
677+ );
678+ }
679+
546 #[test]680 #[test]
547 fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() {681 fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() {
548 let mut tree = Tree::new();682 let mut tree = Tree::new();
modified crates/jolt-tui/src/paint.rs +100 -5
@@ -39,6 +39,32 @@ pub struct Painted {
3939 pub images: Vec<graphics::Placement>,
4040 }
4141
42+impl Painted {
43+ /// Move everything down by `rows`.
44+ ///
45+ /// A scroll paints its content into a buffer that starts partway down the
46+ /// column, so what came back is in that buffer's coordinates. This puts it
47+ /// back into the content's, where the viewport's own offset means what it
48+ /// says.
49+ fn shift_down(&mut self, rows: u16) {
50+ if rows == 0 {
51+ return;
52+ }
53+ for (_, rect) in &mut self.hits {
54+ rect.y = rect.y.saturating_add(rows);
55+ }
56+ for (_, _, _, rect) in &mut self.scrolled {
57+ rect.y = rect.y.saturating_add(rows);
58+ }
59+ for placement in &mut self.images {
60+ placement.area.y = placement.area.y.saturating_add(rows);
61+ }
62+ if let Some((_, y)) = &mut self.cursor {
63+ *y = y.saturating_add(rows);
64+ }
65+ }
66+}
67+
4268 struct Painter<'a> {
4369 tree: &'a Tree,
4470 screen: &'a mut Screen,
@@ -175,6 +201,26 @@ impl Painter<'_> {
175201 }
176202 }
177203
204+ /// Put a subtree's focusable nodes into the ring without painting it.
205+ ///
206+ /// What a scroll owes the parts of its content it did not paint. Tab walks
207+ /// the ring, and a reader tabbing onto a button below the fold is how they
208+ /// scroll to it — so a widget being out of sight cannot take it out of the
209+ /// order. It has no rect, which is exactly right: there is nowhere on the
210+ /// screen to click something that is not on the screen.
211+ fn ring_only(&mut self, id: u32, enabled: bool) {
212+ if !self.tree.exists(id) {
213+ return;
214+ }
215+ let enabled = enabled && self.tree.props_of(id).bool("sensitive", true);
216+ if enabled && self.tree.tag_of(id).focusable() {
217+ self.out.ring.push(id);
218+ }
219+ for child in self.tree.children_of(id) {
220+ self.ring_only(*child, enabled);
221+ }
222+ }
223+
178224 fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
179225 if area.is_empty() {
180226 return;
@@ -422,7 +468,40 @@ impl Painter<'_> {
422468 let offset = props.cells("offset", 0).min(max_offset);
423469 self.out.scrolled.push((id, offset, max_offset, area));
424470
425- let mut buffer = Screen::new(area.w, content_h);
471+ // Only the part of the content the viewport is showing is painted.
472+ // A backlog is a hundred messages and a screen holds a dozen; painting
473+ // the whole column into a grid that tall and copying a window out of
474+ // it costs the same on the ninetieth message nobody is looking at as
475+ // on the one they are reading — which is what made scrolling a long
476+ // conversation cost more than scrolling a short one.
477+ //
478+ // `band` is the rows worth painting: the visible window, grown to whole
479+ // children at each end so that a message straddling an edge is laid out
480+ // in one piece and cut by the copy rather than by the layout. Its top
481+ // is where the buffer's row 0 is, and everything the pass below learned
482+ // is in the buffer's coordinates — so it is moved back into the
483+ // content's before the rest of this reads it against `offset`.
484+ let full = Rect::new(0, 0, area.w, content_h);
485+ let rects = layout::children_rects(self.tree, id, full);
486+ let kids = self.tree.children(id);
487+ let seen = offset..offset.saturating_add(area.h);
488+ let mut base = seen.start;
489+ let mut foot = seen.end.min(content_h);
490+ // In order, and every child accounted for: the ones on screen are
491+ // painted, and the ones that are not still take their place in the
492+ // focus ring below.
493+ let mut plan = Vec::with_capacity(kids.len());
494+ for (child, rect) in kids.iter().zip(&rects) {
495+ let shown = rect.y < seen.end && rect.y.saturating_add(rect.h) > seen.start;
496+ if shown {
497+ base = base.min(rect.y);
498+ foot = foot.max(rect.y.saturating_add(rect.h));
499+ }
500+ plan.push((*child, *rect, shown));
501+ }
502+ let band = foot.saturating_sub(base).max(1);
503+
504+ let mut buffer = Screen::new(area.w, band);
426505 let mut inner = Painter {
427506 tree: self.tree,
428507 screen: &mut buffer,
@@ -432,13 +511,29 @@ impl Painter<'_> {
432511 out: Painted::default(),
433512 overlays: Vec::new(),
434513 };
435- let full = Rect::new(0, 0, area.w, content_h);
436- inner.children(id, full, style, enabled);
437- let learned = inner.out;
514+ for (child, rect, shown) in plan {
515+ if !shown {
516+ inner.ring_only(child, enabled);
517+ continue;
518+ }
519+ // The same clip `children` applies, against the content rather than
520+ // the band: a child asking for more than the column has paints what
521+ // fits. Nothing is clipped to the band itself — a child hanging off
522+ // either end of it is what the copy below is for.
523+ let width = rect.w.min(full.w.saturating_sub(rect.x));
524+ inner.node(
525+ child,
526+ Rect::new(rect.x, rect.y - base, width, rect.h),
527+ style,
528+ enabled,
529+ );
530+ }
531+ let mut learned = inner.out;
532+ learned.shift_down(base);
438533
439534 for y in 0..area.h {
440535 for x in 0..area.w {
441- if let Some(cell) = buffer.cell(x, y + offset) {
536+ if let Some(cell) = buffer.cell(x, (y + offset).saturating_sub(base)) {
442537 self.screen.put(area.x + x, area.y + y, cell.clone());
443538 }
444539 }
@@ -39,6 +39,32 @@ pub struct Painted {
39 pub images: Vec<graphics::Placement>,39 pub images: Vec<graphics::Placement>,
40 }40 }
41 41
42+impl Painted {
43+ /// Move everything down by `rows`.
44+ ///
45+ /// A scroll paints its content into a buffer that starts partway down the
46+ /// column, so what came back is in that buffer's coordinates. This puts it
47+ /// back into the content's, where the viewport's own offset means what it
48+ /// says.
49+ fn shift_down(&mut self, rows: u16) {
50+ if rows == 0 {
51+ return;
52+ }
53+ for (_, rect) in &mut self.hits {
54+ rect.y = rect.y.saturating_add(rows);
55+ }
56+ for (_, _, _, rect) in &mut self.scrolled {
57+ rect.y = rect.y.saturating_add(rows);
58+ }
59+ for placement in &mut self.images {
60+ placement.area.y = placement.area.y.saturating_add(rows);
61+ }
62+ if let Some((_, y)) = &mut self.cursor {
63+ *y = y.saturating_add(rows);
64+ }
65+ }
66+}
67+
42 struct Painter<'a> {68 struct Painter<'a> {
43 tree: &'a Tree,69 tree: &'a Tree,
44 screen: &'a mut Screen,70 screen: &'a mut Screen,
@@ -175,6 +201,26 @@ impl Painter<'_> {
175 }201 }
176 }202 }
177 203
204+ /// Put a subtree's focusable nodes into the ring without painting it.
205+ ///
206+ /// What a scroll owes the parts of its content it did not paint. Tab walks
207+ /// the ring, and a reader tabbing onto a button below the fold is how they
208+ /// scroll to it — so a widget being out of sight cannot take it out of the
209+ /// order. It has no rect, which is exactly right: there is nowhere on the
210+ /// screen to click something that is not on the screen.
211+ fn ring_only(&mut self, id: u32, enabled: bool) {
212+ if !self.tree.exists(id) {
213+ return;
214+ }
215+ let enabled = enabled && self.tree.props_of(id).bool("sensitive", true);
216+ if enabled && self.tree.tag_of(id).focusable() {
217+ self.out.ring.push(id);
218+ }
219+ for child in self.tree.children_of(id) {
220+ self.ring_only(*child, enabled);
221+ }
222+ }
223+
178 fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {224 fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
179 if area.is_empty() {225 if area.is_empty() {
180 return;226 return;
@@ -422,7 +468,40 @@ impl Painter<'_> {
422 let offset = props.cells("offset", 0).min(max_offset);468 let offset = props.cells("offset", 0).min(max_offset);
423 self.out.scrolled.push((id, offset, max_offset, area));469 self.out.scrolled.push((id, offset, max_offset, area));
424 470
425- let mut buffer = Screen::new(area.w, content_h);471+ // Only the part of the content the viewport is showing is painted.
472+ // A backlog is a hundred messages and a screen holds a dozen; painting
473+ // the whole column into a grid that tall and copying a window out of
474+ // it costs the same on the ninetieth message nobody is looking at as
475+ // on the one they are reading — which is what made scrolling a long
476+ // conversation cost more than scrolling a short one.
477+ //
478+ // `band` is the rows worth painting: the visible window, grown to whole
479+ // children at each end so that a message straddling an edge is laid out
480+ // in one piece and cut by the copy rather than by the layout. Its top
481+ // is where the buffer's row 0 is, and everything the pass below learned
482+ // is in the buffer's coordinates — so it is moved back into the
483+ // content's before the rest of this reads it against `offset`.
484+ let full = Rect::new(0, 0, area.w, content_h);
485+ let rects = layout::children_rects(self.tree, id, full);
486+ let kids = self.tree.children(id);
487+ let seen = offset..offset.saturating_add(area.h);
488+ let mut base = seen.start;
489+ let mut foot = seen.end.min(content_h);
490+ // In order, and every child accounted for: the ones on screen are
491+ // painted, and the ones that are not still take their place in the
492+ // focus ring below.
493+ let mut plan = Vec::with_capacity(kids.len());
494+ for (child, rect) in kids.iter().zip(&rects) {
495+ let shown = rect.y < seen.end && rect.y.saturating_add(rect.h) > seen.start;
496+ if shown {
497+ base = base.min(rect.y);
498+ foot = foot.max(rect.y.saturating_add(rect.h));
499+ }
500+ plan.push((*child, *rect, shown));
501+ }
502+ let band = foot.saturating_sub(base).max(1);
503+
504+ let mut buffer = Screen::new(area.w, band);
426 let mut inner = Painter {505 let mut inner = Painter {
427 tree: self.tree,506 tree: self.tree,
428 screen: &mut buffer,507 screen: &mut buffer,
@@ -432,13 +511,29 @@ impl Painter<'_> {
432 out: Painted::default(),511 out: Painted::default(),
433 overlays: Vec::new(),512 overlays: Vec::new(),
434 };513 };
435- let full = Rect::new(0, 0, area.w, content_h);514+ for (child, rect, shown) in plan {
436- inner.children(id, full, style, enabled);515+ if !shown {
437- let learned = inner.out;516+ inner.ring_only(child, enabled);
517+ continue;
518+ }
519+ // The same clip `children` applies, against the content rather than
520+ // the band: a child asking for more than the column has paints what
521+ // fits. Nothing is clipped to the band itself — a child hanging off
522+ // either end of it is what the copy below is for.
523+ let width = rect.w.min(full.w.saturating_sub(rect.x));
524+ inner.node(
525+ child,
526+ Rect::new(rect.x, rect.y - base, width, rect.h),
527+ style,
528+ enabled,
529+ );
530+ }
531+ let mut learned = inner.out;
532+ learned.shift_down(base);
438 533
439 for y in 0..area.h {534 for y in 0..area.h {
440 for x in 0..area.w {535 for x in 0..area.w {
441- if let Some(cell) = buffer.cell(x, y + offset) {536+ if let Some(cell) = buffer.cell(x, (y + offset).saturating_sub(base)) {
442 self.screen.put(area.x + x, area.y + y, cell.clone());537 self.screen.put(area.x + x, area.y + y, cell.clone());
443 }538 }
444 }539 }
modified crates/jolt-tui/src/tests.rs +126 -0
@@ -734,3 +734,129 @@ fn a_backlog_taller_than_the_screen_leaves_the_compose_bar_its_row() {
734734 ui.frame();
735735 assert_eq!(ui.screen.line(3), "Message");
736736 }
737+
738+#[test]
739+fn a_message_straddling_the_top_of_a_viewport_shows_the_part_that_is_in_it() {
740+ // Culling paints whole children and lets the copy cut them, so the row a
741+ // reader is half way through is the row they see — not the next one down.
742+ let mut ui = Ui::new(20, 3);
743+ let root = ui.tree.root();
744+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
745+ for n in 1..=6 {
746+ let block = node(&mut ui, scroll, "vbox", &[]);
747+ node(&mut ui, block, "label", &[("label", &format!("head {n}"))]);
748+ node(&mut ui, block, "label", &[("label", &format!("body {n}"))]);
749+ }
750+ ui.tree.set(scroll, "offset", Value::Num(3.0));
751+ ui.frame();
752+ // Two rows a message, so an offset of three lands mid-way through the
753+ // second one: its body, then the third whole.
754+ assert_eq!(ui.screen.line(0), "body 2");
755+ assert_eq!(ui.screen.line(1), "head 3");
756+ assert_eq!(ui.screen.line(2), "body 3");
757+}
758+
759+#[test]
760+fn a_button_below_the_fold_keeps_its_place_in_the_focus_ring() {
761+ // Nothing off screen is painted, and the ring is built while painting —
762+ // so the ring has to be told about the parts that were skipped. Tabbing
763+ // onto something below the fold is how a reader gets to it.
764+ let mut ui = Ui::new(20, 2);
765+ let root = ui.tree.root();
766+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
767+ let first = node(&mut ui, scroll, "button", &[("label", "first")]);
768+ for n in 1..=20 {
769+ node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
770+ }
771+ let below = node(&mut ui, scroll, "button", &[("label", "last")]);
772+ ui.frame();
773+ // Twenty lines down and well out of a two-row viewport, but still next in
774+ // the ring after the button at the top.
775+ assert_eq!(ui.focus(), first);
776+ ui.key("tab");
777+ assert_eq!(ui.focus(), below);
778+}
779+
780+#[test]
781+fn a_scrolled_backlog_paints_what_an_unscrolled_one_would_have_shown() {
782+ // The check that culling changed nothing: paint a viewport onto the middle
783+ // of a long list, and every row is the row that list has there.
784+ let mut ui = Ui::new(20, 5);
785+ let root = ui.tree.root();
786+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
787+ for n in 0..40 {
788+ node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
789+ }
790+ ui.frame();
791+ let mut offset = 0;
792+ for step in [0, 1, 16, 18] {
793+ // Through the wheel rather than the prop: the viewport's position is
794+ // the library's own state, and it writes it back over anything set
795+ // here on the frame after.
796+ ui.wheel(0, 0, step);
797+ offset += step as usize;
798+ ui.frame();
799+ for row in 0..5u16 {
800+ assert_eq!(
801+ ui.screen.line(row),
802+ format!("line {}", offset + row as usize),
803+ "row {row} at offset {offset}"
804+ );
805+ }
806+ }
807+}
808+
809+/// A backlog the shape frq mounts: a scrolling column of messages, each a few
810+/// boxes deep, with a heading row and wrapping text under it.
811+#[cfg(test)]
812+fn backlog(cols: u16, rows: u16, messages: usize) -> Ui {
813+ let mut ui = Ui::new(cols, rows);
814+ let root = ui.tree.root();
815+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
816+ for n in 0..messages {
817+ let row = node(&mut ui, scroll, "hbox", &[("orientation", "horizontal")]);
818+ node(&mut ui, row, "spacer", &[]);
819+ let body = node(&mut ui, row, "vbox", &[]);
820+ let head = node(&mut ui, body, "hbox", &[("orientation", "horizontal")]);
821+ node(&mut ui, head, "label", &[("label", "nandi")]);
822+ node(&mut ui, head, "dim-label", &[("label", "12:01")]);
823+ node(
824+ &mut ui,
825+ body,
826+ "label",
827+ &[(
828+ "label",
829+ &format!("message number {n} with enough words in it to wrap across a line or two"),
830+ )],
831+ );
832+ }
833+ node(&mut ui, root, "separator", &[]);
834+ node(&mut ui, root, "entry", &[("placeholder", "Message")]);
835+ ui
836+}
837+
838+/// What a frame costs on a backlog, which is what scrolling one costs. Not a
839+/// test — it asserts nothing — so it is `--ignored` and run by hand:
840+///
841+/// cargo test --release -p jolt-tui -- --ignored --nocapture backlog_cost
842+#[test]
843+#[ignore]
844+fn backlog_cost() {
845+ for messages in [25, 50, 100, 200, 400] {
846+ let mut ui = backlog(100, 36, messages);
847+ ui.frame();
848+ let mut times = Vec::new();
849+ for i in 0..21 {
850+ ui.wheel(10, 10, if i % 2 == 0 { 3 } else { -3 });
851+ let at = std::time::Instant::now();
852+ ui.frame();
853+ times.push(at.elapsed().as_secs_f64() * 1000.0);
854+ }
855+ times.sort_by(|a, b| a.partial_cmp(b).unwrap());
856+ println!(
857+ "{messages:5} messages median {:8.2}ms max {:8.2}ms",
858+ times[times.len() / 2],
859+ times[times.len() - 1]
860+ );
861+ }
862+}
@@ -734,3 +734,129 @@ fn a_backlog_taller_than_the_screen_leaves_the_compose_bar_its_row() {
734 ui.frame();734 ui.frame();
735 assert_eq!(ui.screen.line(3), "Message");735 assert_eq!(ui.screen.line(3), "Message");
736 }736 }
737+
738+#[test]
739+fn a_message_straddling_the_top_of_a_viewport_shows_the_part_that_is_in_it() {
740+ // Culling paints whole children and lets the copy cut them, so the row a
741+ // reader is half way through is the row they see — not the next one down.
742+ let mut ui = Ui::new(20, 3);
743+ let root = ui.tree.root();
744+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
745+ for n in 1..=6 {
746+ let block = node(&mut ui, scroll, "vbox", &[]);
747+ node(&mut ui, block, "label", &[("label", &format!("head {n}"))]);
748+ node(&mut ui, block, "label", &[("label", &format!("body {n}"))]);
749+ }
750+ ui.tree.set(scroll, "offset", Value::Num(3.0));
751+ ui.frame();
752+ // Two rows a message, so an offset of three lands mid-way through the
753+ // second one: its body, then the third whole.
754+ assert_eq!(ui.screen.line(0), "body 2");
755+ assert_eq!(ui.screen.line(1), "head 3");
756+ assert_eq!(ui.screen.line(2), "body 3");
757+}
758+
759+#[test]
760+fn a_button_below_the_fold_keeps_its_place_in_the_focus_ring() {
761+ // Nothing off screen is painted, and the ring is built while painting —
762+ // so the ring has to be told about the parts that were skipped. Tabbing
763+ // onto something below the fold is how a reader gets to it.
764+ let mut ui = Ui::new(20, 2);
765+ let root = ui.tree.root();
766+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
767+ let first = node(&mut ui, scroll, "button", &[("label", "first")]);
768+ for n in 1..=20 {
769+ node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
770+ }
771+ let below = node(&mut ui, scroll, "button", &[("label", "last")]);
772+ ui.frame();
773+ // Twenty lines down and well out of a two-row viewport, but still next in
774+ // the ring after the button at the top.
775+ assert_eq!(ui.focus(), first);
776+ ui.key("tab");
777+ assert_eq!(ui.focus(), below);
778+}
779+
780+#[test]
781+fn a_scrolled_backlog_paints_what_an_unscrolled_one_would_have_shown() {
782+ // The check that culling changed nothing: paint a viewport onto the middle
783+ // of a long list, and every row is the row that list has there.
784+ let mut ui = Ui::new(20, 5);
785+ let root = ui.tree.root();
786+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
787+ for n in 0..40 {
788+ node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
789+ }
790+ ui.frame();
791+ let mut offset = 0;
792+ for step in [0, 1, 16, 18] {
793+ // Through the wheel rather than the prop: the viewport's position is
794+ // the library's own state, and it writes it back over anything set
795+ // here on the frame after.
796+ ui.wheel(0, 0, step);
797+ offset += step as usize;
798+ ui.frame();
799+ for row in 0..5u16 {
800+ assert_eq!(
801+ ui.screen.line(row),
802+ format!("line {}", offset + row as usize),
803+ "row {row} at offset {offset}"
804+ );
805+ }
806+ }
807+}
808+
809+/// A backlog the shape frq mounts: a scrolling column of messages, each a few
810+/// boxes deep, with a heading row and wrapping text under it.
811+#[cfg(test)]
812+fn backlog(cols: u16, rows: u16, messages: usize) -> Ui {
813+ let mut ui = Ui::new(cols, rows);
814+ let root = ui.tree.root();
815+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
816+ for n in 0..messages {
817+ let row = node(&mut ui, scroll, "hbox", &[("orientation", "horizontal")]);
818+ node(&mut ui, row, "spacer", &[]);
819+ let body = node(&mut ui, row, "vbox", &[]);
820+ let head = node(&mut ui, body, "hbox", &[("orientation", "horizontal")]);
821+ node(&mut ui, head, "label", &[("label", "nandi")]);
822+ node(&mut ui, head, "dim-label", &[("label", "12:01")]);
823+ node(
824+ &mut ui,
825+ body,
826+ "label",
827+ &[(
828+ "label",
829+ &format!("message number {n} with enough words in it to wrap across a line or two"),
830+ )],
831+ );
832+ }
833+ node(&mut ui, root, "separator", &[]);
834+ node(&mut ui, root, "entry", &[("placeholder", "Message")]);
835+ ui
836+}
837+
838+/// What a frame costs on a backlog, which is what scrolling one costs. Not a
839+/// test — it asserts nothing — so it is `--ignored` and run by hand:
840+///
841+/// cargo test --release -p jolt-tui -- --ignored --nocapture backlog_cost
842+#[test]
843+#[ignore]
844+fn backlog_cost() {
845+ for messages in [25, 50, 100, 200, 400] {
846+ let mut ui = backlog(100, 36, messages);
847+ ui.frame();
848+ let mut times = Vec::new();
849+ for i in 0..21 {
850+ ui.wheel(10, 10, if i % 2 == 0 { 3 } else { -3 });
851+ let at = std::time::Instant::now();
852+ ui.frame();
853+ times.push(at.elapsed().as_secs_f64() * 1000.0);
854+ }
855+ times.sort_by(|a, b| a.partial_cmp(b).unwrap());
856+ println!(
857+ "{messages:5} messages median {:8.2}ms max {:8.2}ms",
858+ times[times.len() / 2],
859+ times[times.len() - 1]
860+ );
861+ }
862+}
modified crates/jolt-tui/src/tree.rs +87 -8
@@ -139,14 +139,18 @@ pub struct Event {
139139 #[derive(Clone, Debug, Default)]
140140 struct Node {
141141 tag: Tag,
142- props: HashMap<String, Value>,
142+ props: Props,
143143 children: Vec<u32>,
144+ /// When this node or anything under it last changed. A size measured of
145+ /// this subtree is good for exactly as long as this number holds still,
146+ /// which is what lets one outlive the frame it was taken in.
147+ rev: u64,
144148 /// 0 when unparented. The root's parent is 0 as well, which is what stops
145149 /// the ancestor walk in [`Tree::would_cycle`].
146150 parent: u32,
147151 }
148152
149-/// A node's props, copied out for the duration of one measure or paint.
153+/// A node's props.
150154 ///
151155 /// Reading them through this rather than the map means a missing prop and a
152156 /// prop of the wrong type answer the same thing: the default. Nothing a caller
@@ -250,6 +254,9 @@ pub struct Tree {
250254 root: u32,
251255 pending: VecDeque<Event>,
252256 current: Option<Event>,
257+ /// Counts every change to any node. Never read for itself — it is what
258+ /// stamps `Node::rev`, so that two changes are never confused for one.
259+ revision: u64,
253260 }
254261
255262 impl Default for Tree {
@@ -266,6 +273,7 @@ impl Tree {
266273 root: 0,
267274 pending: VecDeque::new(),
268275 current: None,
276+ revision: 0,
269277 };
270278 tree.root = tree.new_node("window");
271279 tree
@@ -279,17 +287,56 @@ impl Tree {
279287 self.nodes.get(id as usize).and_then(|n| n.as_ref())
280288 }
281289
290+ /// The one way to a node that can be changed — so it is the one place a
291+ /// change has to be recorded. Handing the caller a `&mut Node` is handing
292+ /// them everything a measurement of it depended on; whether they write to
293+ /// it or not, this is where a cache has to assume they did.
282294 fn slot_mut(&mut self, id: u32) -> Option<&mut Node> {
295+ self.touch(id);
283296 self.nodes.get_mut(id as usize).and_then(|n| n.as_mut())
284297 }
285298
299+ /// Mark `id` and every node above it as changed.
300+ ///
301+ /// Upwards, because that is the direction sizes travel: how tall a message
302+ /// is depends on its own words, and how tall the backlog is depends on the
303+ /// message — so a word typed into one line makes every ancestor's measured
304+ /// size a lie, and no sibling's. Walking the parents is what keeps a new
305+ /// message at the bottom of a long backlog from throwing away the
306+ /// measurements of the hundred above it that did not move.
307+ fn touch(&mut self, id: u32) {
308+ self.revision = self.revision.wrapping_add(1);
309+ let now = self.revision;
310+ let mut at = id;
311+ // Bounded by the depth of the tree, and by the node count besides: a
312+ // parent chain cannot revisit a node, since `would_cycle` is what stops
313+ // one being built.
314+ while at != 0 {
315+ match self.nodes.get_mut(at as usize).and_then(|n| n.as_mut()) {
316+ Some(node) => {
317+ node.rev = now;
318+ at = node.parent;
319+ }
320+ None => break,
321+ }
322+ }
323+ }
324+
325+ /// When this node's subtree last changed. A measurement of it taken at the
326+ /// same number is still the right answer.
327+ pub fn revision_of(&self, id: u32) -> u64 {
328+ self.slot(id).map_or(0, |n| n.rev)
329+ }
330+
286331 pub fn exists(&self, id: u32) -> bool {
287332 self.slot(id).is_some()
288333 }
289334
290335 pub fn new_node(&mut self, tag: &str) -> u32 {
336+ self.revision = self.revision.wrapping_add(1);
291337 let node = Node {
292338 tag: Tag::parse(tag),
339+ rev: self.revision,
293340 ..Node::default()
294341 };
295342 match self.free.pop() {
@@ -328,6 +375,7 @@ impl Tree {
328375 self.free_subtree(child);
329376 }
330377 if self.nodes[id as usize].take().is_some() {
378+ self.revision = self.revision.wrapping_add(1);
331379 self.free.push(id);
332380 }
333381 // An event queued against a node that has since gone would be routed to
@@ -457,24 +505,55 @@ impl Tree {
457505 self.slot(id).map_or(0, |n| n.parent)
458506 }
459507
508+ /// A node's props, borrowed. A node that is not there lends the empty set,
509+ /// which reads as every default — the same answer `props` has always given
510+ /// for a missing node, without the copy.
511+ ///
512+ /// The copying `props` below is what a caller that needs to keep them past
513+ /// the borrow uses. Measuring and painting do not: they read a handful of
514+ /// values and are done, and cloning a whole map per node per question was
515+ /// most of what a frame cost.
516+ pub fn props_of(&self, id: u32) -> &Props {
517+ static NONE: std::sync::OnceLock<Props> = std::sync::OnceLock::new();
518+ match self.slot(id) {
519+ Some(node) => &node.props,
520+ None => NONE.get_or_init(Props::default),
521+ }
522+ }
523+
524+ /// A node's tag, borrowed. `Tag::Unknown` carries the name it was given,
525+ /// so `tag` is a string copy per call where this is a look.
526+ pub fn tag_of(&self, id: u32) -> &Tag {
527+ static NONE: std::sync::OnceLock<Tag> = std::sync::OnceLock::new();
528+ match self.slot(id) {
529+ Some(node) => &node.tag,
530+ None => NONE.get_or_init(Tag::default),
531+ }
532+ }
533+
534+ /// A node's children, borrowed.
535+ pub fn children_of(&self, id: u32) -> &[u32] {
536+ self.slot(id).map_or(&[], |n| n.children.as_slice())
537+ }
538+
460539 pub fn props(&self, id: u32) -> Props {
461- Props(self.slot(id).map(|n| n.props.clone()).unwrap_or_default())
540+ self.props_of(id).clone()
462541 }
463542
464543 pub fn set(&mut self, id: u32, key: &str, value: Value) {
465544 if let Some(node) = self.slot_mut(id) {
466- node.props.insert(key.to_owned(), value);
545+ node.props.0.insert(key.to_owned(), value);
467546 }
468547 }
469548
470549 pub fn clear_props(&mut self, id: u32) {
471550 if let Some(node) = self.slot_mut(id) {
472- node.props.clear();
551+ node.props.0.clear();
473552 }
474553 }
475554
476555 pub fn get(&self, id: u32, key: &str) -> Option<&Value> {
477- self.slot(id).and_then(|n| n.props.get(key))
556+ self.slot(id).and_then(|n| n.props.0.get(key))
478557 }
479558
480559 /// The subtree at `id` as pretty-printed hiccup — what the tree *is*, read
@@ -496,7 +575,7 @@ impl Tree {
496575 out.push_str("[:");
497576 out.push_str(node.tag.name());
498577
499- let mut keys: Vec<&String> = node.props.keys().collect();
578+ let mut keys: Vec<&String> = node.props.0.keys().collect();
500579 keys.sort();
501580 out.push_str(" {");
502581 for (i, key) in keys.iter().enumerate() {
@@ -506,7 +585,7 @@ impl Tree {
506585 out.push(':');
507586 out.push_str(key);
508587 out.push(' ');
509- write_value(&node.props[*key], out);
588+ write_value(&node.props.0[*key], out);
510589 }
511590 out.push('}');
512591
@@ -139,14 +139,18 @@ pub struct Event {
139 #[derive(Clone, Debug, Default)]139 #[derive(Clone, Debug, Default)]
140 struct Node {140 struct Node {
141 tag: Tag,141 tag: Tag,
142- props: HashMap<String, Value>,142+ props: Props,
143 children: Vec<u32>,143 children: Vec<u32>,
144+ /// When this node or anything under it last changed. A size measured of
145+ /// this subtree is good for exactly as long as this number holds still,
146+ /// which is what lets one outlive the frame it was taken in.
147+ rev: u64,
144 /// 0 when unparented. The root's parent is 0 as well, which is what stops148 /// 0 when unparented. The root's parent is 0 as well, which is what stops
145 /// the ancestor walk in [`Tree::would_cycle`].149 /// the ancestor walk in [`Tree::would_cycle`].
146 parent: u32,150 parent: u32,
147 }151 }
148 152
149-/// A node's props, copied out for the duration of one measure or paint.153+/// A node's props.
150 ///154 ///
151 /// Reading them through this rather than the map means a missing prop and a155 /// Reading them through this rather than the map means a missing prop and a
152 /// prop of the wrong type answer the same thing: the default. Nothing a caller156 /// prop of the wrong type answer the same thing: the default. Nothing a caller
@@ -250,6 +254,9 @@ pub struct Tree {
250 root: u32,254 root: u32,
251 pending: VecDeque<Event>,255 pending: VecDeque<Event>,
252 current: Option<Event>,256 current: Option<Event>,
257+ /// Counts every change to any node. Never read for itself — it is what
258+ /// stamps `Node::rev`, so that two changes are never confused for one.
259+ revision: u64,
253 }260 }
254 261
255 impl Default for Tree {262 impl Default for Tree {
@@ -266,6 +273,7 @@ impl Tree {
266 root: 0,273 root: 0,
267 pending: VecDeque::new(),274 pending: VecDeque::new(),
268 current: None,275 current: None,
276+ revision: 0,
269 };277 };
270 tree.root = tree.new_node("window");278 tree.root = tree.new_node("window");
271 tree279 tree
@@ -279,17 +287,56 @@ impl Tree {
279 self.nodes.get(id as usize).and_then(|n| n.as_ref())287 self.nodes.get(id as usize).and_then(|n| n.as_ref())
280 }288 }
281 289
290+ /// The one way to a node that can be changed — so it is the one place a
291+ /// change has to be recorded. Handing the caller a `&mut Node` is handing
292+ /// them everything a measurement of it depended on; whether they write to
293+ /// it or not, this is where a cache has to assume they did.
282 fn slot_mut(&mut self, id: u32) -> Option<&mut Node> {294 fn slot_mut(&mut self, id: u32) -> Option<&mut Node> {
295+ self.touch(id);
283 self.nodes.get_mut(id as usize).and_then(|n| n.as_mut())296 self.nodes.get_mut(id as usize).and_then(|n| n.as_mut())
284 }297 }
285 298
299+ /// Mark `id` and every node above it as changed.
300+ ///
301+ /// Upwards, because that is the direction sizes travel: how tall a message
302+ /// is depends on its own words, and how tall the backlog is depends on the
303+ /// message — so a word typed into one line makes every ancestor's measured
304+ /// size a lie, and no sibling's. Walking the parents is what keeps a new
305+ /// message at the bottom of a long backlog from throwing away the
306+ /// measurements of the hundred above it that did not move.
307+ fn touch(&mut self, id: u32) {
308+ self.revision = self.revision.wrapping_add(1);
309+ let now = self.revision;
310+ let mut at = id;
311+ // Bounded by the depth of the tree, and by the node count besides: a
312+ // parent chain cannot revisit a node, since `would_cycle` is what stops
313+ // one being built.
314+ while at != 0 {
315+ match self.nodes.get_mut(at as usize).and_then(|n| n.as_mut()) {
316+ Some(node) => {
317+ node.rev = now;
318+ at = node.parent;
319+ }
320+ None => break,
321+ }
322+ }
323+ }
324+
325+ /// When this node's subtree last changed. A measurement of it taken at the
326+ /// same number is still the right answer.
327+ pub fn revision_of(&self, id: u32) -> u64 {
328+ self.slot(id).map_or(0, |n| n.rev)
329+ }
330+
286 pub fn exists(&self, id: u32) -> bool {331 pub fn exists(&self, id: u32) -> bool {
287 self.slot(id).is_some()332 self.slot(id).is_some()
288 }333 }
289 334
290 pub fn new_node(&mut self, tag: &str) -> u32 {335 pub fn new_node(&mut self, tag: &str) -> u32 {
336+ self.revision = self.revision.wrapping_add(1);
291 let node = Node {337 let node = Node {
292 tag: Tag::parse(tag),338 tag: Tag::parse(tag),
339+ rev: self.revision,
293 ..Node::default()340 ..Node::default()
294 };341 };
295 match self.free.pop() {342 match self.free.pop() {
@@ -328,6 +375,7 @@ impl Tree {
328 self.free_subtree(child);375 self.free_subtree(child);
329 }376 }
330 if self.nodes[id as usize].take().is_some() {377 if self.nodes[id as usize].take().is_some() {
378+ self.revision = self.revision.wrapping_add(1);
331 self.free.push(id);379 self.free.push(id);
332 }380 }
333 // An event queued against a node that has since gone would be routed to381 // An event queued against a node that has since gone would be routed to
@@ -457,24 +505,55 @@ impl Tree {
457 self.slot(id).map_or(0, |n| n.parent)505 self.slot(id).map_or(0, |n| n.parent)
458 }506 }
459 507
508+ /// A node's props, borrowed. A node that is not there lends the empty set,
509+ /// which reads as every default — the same answer `props` has always given
510+ /// for a missing node, without the copy.
511+ ///
512+ /// The copying `props` below is what a caller that needs to keep them past
513+ /// the borrow uses. Measuring and painting do not: they read a handful of
514+ /// values and are done, and cloning a whole map per node per question was
515+ /// most of what a frame cost.
516+ pub fn props_of(&self, id: u32) -> &Props {
517+ static NONE: std::sync::OnceLock<Props> = std::sync::OnceLock::new();
518+ match self.slot(id) {
519+ Some(node) => &node.props,
520+ None => NONE.get_or_init(Props::default),
521+ }
522+ }
523+
524+ /// A node's tag, borrowed. `Tag::Unknown` carries the name it was given,
525+ /// so `tag` is a string copy per call where this is a look.
526+ pub fn tag_of(&self, id: u32) -> &Tag {
527+ static NONE: std::sync::OnceLock<Tag> = std::sync::OnceLock::new();
528+ match self.slot(id) {
529+ Some(node) => &node.tag,
530+ None => NONE.get_or_init(Tag::default),
531+ }
532+ }
533+
534+ /// A node's children, borrowed.
535+ pub fn children_of(&self, id: u32) -> &[u32] {
536+ self.slot(id).map_or(&[], |n| n.children.as_slice())
537+ }
538+
460 pub fn props(&self, id: u32) -> Props {539 pub fn props(&self, id: u32) -> Props {
461- Props(self.slot(id).map(|n| n.props.clone()).unwrap_or_default())540+ self.props_of(id).clone()
462 }541 }
463 542
464 pub fn set(&mut self, id: u32, key: &str, value: Value) {543 pub fn set(&mut self, id: u32, key: &str, value: Value) {
465 if let Some(node) = self.slot_mut(id) {544 if let Some(node) = self.slot_mut(id) {
466- node.props.insert(key.to_owned(), value);545+ node.props.0.insert(key.to_owned(), value);
467 }546 }
468 }547 }
469 548
470 pub fn clear_props(&mut self, id: u32) {549 pub fn clear_props(&mut self, id: u32) {
471 if let Some(node) = self.slot_mut(id) {550 if let Some(node) = self.slot_mut(id) {
472- node.props.clear();551+ node.props.0.clear();
473 }552 }
474 }553 }
475 554
476 pub fn get(&self, id: u32, key: &str) -> Option<&Value> {555 pub fn get(&self, id: u32, key: &str) -> Option<&Value> {
477- self.slot(id).and_then(|n| n.props.get(key))556+ self.slot(id).and_then(|n| n.props.0.get(key))
478 }557 }
479 558
480 /// The subtree at `id` as pretty-printed hiccup — what the tree *is*, read559 /// The subtree at `id` as pretty-printed hiccup — what the tree *is*, read
@@ -496,7 +575,7 @@ impl Tree {
496 out.push_str("[:");575 out.push_str("[:");
497 out.push_str(node.tag.name());576 out.push_str(node.tag.name());
498 577
499- let mut keys: Vec<&String> = node.props.keys().collect();578+ let mut keys: Vec<&String> = node.props.0.keys().collect();
500 keys.sort();579 keys.sort();
501 out.push_str(" {");580 out.push_str(" {");
502 for (i, key) in keys.iter().enumerate() {581 for (i, key) in keys.iter().enumerate() {
@@ -506,7 +585,7 @@ impl Tree {
506 out.push(':');585 out.push(':');
507 out.push_str(key);586 out.push_str(key);
508 out.push(' ');587 out.push(' ');
509- write_value(&node.props[*key], out);588+ write_value(&node.props.0[*key], out);
510 }589 }
511 out.push('}');590 out.push('}');
512 591