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>c41903b parent: 384390d modified
crates/jolt-tui/src/layout.rs +164 -30 | @@ -14,6 +14,80 @@ use crate::graphics; | ||
| 14 | 14 | use crate::screen::{glyph_cols, glyphs, text_cols, Rect}; |
| 15 | 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 | 91 | /// How a child that is not filling its cross axis sits in the space it was |
| 18 | 92 | /// given. `:halign` and `:valign` in the props. |
| 19 | 93 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| @@ -170,14 +244,14 @@ pub fn entry_text(props: &Props) -> String { | ||
| 170 | 244 | |
| 171 | 245 | /// A node's content size before its own request or inset is applied. |
| 172 | 246 | 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); | |
| 175 | 249 | let text = props.label(); |
| 176 | 250 | match tag { |
| 177 | 251 | Tag::Button => columns(text).saturating_add(4), |
| 178 | 252 | Tag::CheckButton => columns(text).saturating_add(4), |
| 179 | 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 | 255 | if minimum { |
| 182 | 256 | want.min(6) |
| 183 | 257 | } else { |
| @@ -199,7 +273,7 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | ||
| 199 | 273 | // the face and an `:image`'s is its alt text: words for something that |
| 200 | 274 | // cannot be drawn here, and in frq's case words already on the row |
| 201 | 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 | 277 | if minimum { |
| 204 | 278 | longest_word(props.label()) |
| 205 | 279 | } else { |
| @@ -209,8 +283,8 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | ||
| 209 | 283 | Tag::Separator => 1, |
| 210 | 284 | Tag::Spacer => props.cells("size", 1), |
| 211 | 285 | 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)), | |
| 214 | 288 | Tag::Progress => { |
| 215 | 289 | if minimum { |
| 216 | 290 | 4 |
| @@ -220,7 +294,7 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | ||
| 220 | 294 | } |
| 221 | 295 | Tag::Spinner => 1, |
| 222 | 296 | Tag::Listbox => tree |
| 223 | - .children(id) | |
| 297 | + .children_of(id) | |
| 224 | 298 | .iter() |
| 225 | 299 | .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2)) |
| 226 | 300 | .max() |
| @@ -228,13 +302,13 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | ||
| 228 | 302 | // Every container measures its children the same way; only the axis |
| 229 | 303 | // the sum runs along differs. |
| 230 | 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 | 306 | let spacing = props.cells("spacing", 0); |
| 233 | 307 | let sizes = children |
| 234 | 308 | .iter() |
| 235 | 309 | .map(|c| width(tree, *c, minimum)) |
| 236 | 310 | .collect::<Vec<_>>(); |
| 237 | - let content = if horizontal(&props) && matches!(tag, Tag::Box) { | |
| 311 | + let content = if horizontal(props) && matches!(tag, Tag::Box) { | |
| 238 | 312 | let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16); |
| 239 | 313 | sizes.iter().fold(gaps, |a, b| a.saturating_add(*b)) |
| 240 | 314 | } else { |
| @@ -269,12 +343,18 @@ fn has_picture(props: &Props) -> bool { | ||
| 269 | 343 | /// in. Asking for a width is the caller saying how wide the column is; nothing |
| 270 | 344 | /// else in a terminal can say it for them. |
| 271 | 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 | 353 | let requested = props.cells("width-request", 0); |
| 274 | 354 | if requested > 0 { |
| 275 | 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 | 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 | 363 | /// Height depends on width — that is what wrapping means — so there is no |
| 284 | 364 | /// natural height to ask for on its own. |
| 285 | 365 | 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); | |
| 289 | 375 | let inner = avail.saturating_sub(pad.saturating_mul(2)); |
| 290 | 376 | let content = match tag { |
| 291 | 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 | 379 | wrap(props.label(), inner).len() as u16 |
| 294 | 380 | } |
| 295 | 381 | Tag::Button |
| @@ -299,14 +385,14 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 { | ||
| 299 | 385 | | Tag::Spinner |
| 300 | 386 | | Tag::Reaction |
| 301 | 387 | | Tag::Emoji => 1, |
| 302 | - Tag::Image => image_cells(&props, inner).1, | |
| 388 | + Tag::Image => image_cells(props, inner).1, | |
| 303 | 389 | Tag::Entry => props.cells("rows", 1).max(1), |
| 304 | 390 | Tag::Spacer => props.cells("size", 1), |
| 305 | 391 | Tag::Listbox => tree.child_count(id) as u16, |
| 306 | 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 | 394 | let spacing = props.cells("spacing", 0); |
| 309 | - if horizontal(&props) && matches!(tag, Tag::Box) { | |
| 395 | + if horizontal(props) && matches!(tag, Tag::Box) { | |
| 310 | 396 | // Across: each child is measured at the width it will get. |
| 311 | 397 | let shares = share(tree, id, inner, true, 0); |
| 312 | 398 | children |
| @@ -343,18 +429,24 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 { | ||
| 343 | 429 | /// for and the separator and compose bar under it were painted past the bottom |
| 344 | 430 | /// edge — a conversation you cannot type into. |
| 345 | 431 | 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); | |
| 349 | 441 | let inner = avail.saturating_sub(pad.saturating_mul(2)); |
| 350 | 442 | let content = match tag { |
| 351 | 443 | Tag::Scroll => 1, |
| 352 | 444 | Tag::Box | Tag::Window | Tag::Frame | Tag::Overlay | Tag::Unknown(_) |
| 353 | 445 | if tree.child_count(id) > 0 => |
| 354 | 446 | { |
| 355 | - let children = tree.children(id); | |
| 447 | + let children = tree.children_of(id); | |
| 356 | 448 | let spacing = props.cells("spacing", 0); |
| 357 | - if horizontal(&props) && matches!(tag, Tag::Box) { | |
| 449 | + if horizontal(props) && matches!(tag, Tag::Box) { | |
| 358 | 450 | let shares = share(tree, id, inner, true, 0); |
| 359 | 451 | children |
| 360 | 452 | .iter() |
| @@ -393,11 +485,30 @@ pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 { | ||
| 393 | 485 | /// drops every sibling after it. Unused when `across`, where a width does not |
| 394 | 486 | /// depend on a height. |
| 395 | 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 | 508 | if children.is_empty() { |
| 398 | 509 | return Vec::new(); |
| 399 | 510 | } |
| 400 | - let props = tree.props(id); | |
| 511 | + let props = tree.props_of(id); | |
| 401 | 512 | let spacing = props.cells("spacing", 0); |
| 402 | 513 | let gaps = spacing.saturating_mul((children.len() - 1) as u16); |
| 403 | 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 | 560 | let greedy: Vec<usize> = children |
| 450 | 561 | .iter() |
| 451 | 562 | .enumerate() |
| 452 | - .filter(|(_, c)| tree.props(**c).bool(key, false)) | |
| 563 | + .filter(|(_, c)| tree.props_of(**c).bool(key, false)) | |
| 453 | 564 | .map(|(i, _)| i) |
| 454 | 565 | .collect(); |
| 455 | 566 | if !greedy.is_empty() { |
| @@ -480,10 +591,10 @@ pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) { | ||
| 480 | 591 | |
| 481 | 592 | /// Lay the children of a box out inside `area`. |
| 482 | 593 | 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); | |
| 485 | 596 | let spacing = props.cells("spacing", 0); |
| 486 | - let children = tree.children(id); | |
| 597 | + let children = tree.children_of(id); | |
| 487 | 598 | let shares = share( |
| 488 | 599 | tree, |
| 489 | 600 | id, |
| @@ -495,7 +606,7 @@ pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> { | ||
| 495 | 606 | let mut out = Vec::with_capacity(children.len()); |
| 496 | 607 | let mut at = 0u16; |
| 497 | 608 | for (child, main) in children.iter().zip(shares) { |
| 498 | - let cprops = tree.props(*child); | |
| 609 | + let cprops = tree.props_of(*child); | |
| 499 | 610 | let rect = if across { |
| 500 | 611 | let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0)); |
| 501 | 612 | let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h); |
| @@ -543,6 +654,29 @@ mod tests { | ||
| 543 | 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 | 680 | #[test] |
| 547 | 681 | fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() { |
| 548 | 682 | 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 was | 91 | /// 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 that | 273 | // 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 row | 274 | // 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 | 4 | 290 | 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 => tree | 296 | 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 axis | 302 | // 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 = children | 307 | 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; nothing | 343 | /// 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 no | 363 | /// 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 u16 | 379 | wrap(props.label(), inner).len() as u16 |
| 294 | } | 380 | } |
| 295 | Tag::Button | 381 | Tag::Button |
| @@ -299,14 +385,14 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 { | |||
| 299 | | Tag::Spinner | 385 | | Tag::Spinner |
| 300 | | Tag::Reaction | 386 | | 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 | children | 398 | 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 bottom | 429 | /// 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 | children | 451 | 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 not | 485 | /// 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> = children | 560 | 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 { | ||
| 39 | 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 | 68 | struct Painter<'a> { |
| 43 | 69 | tree: &'a Tree, |
| 44 | 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 | 224 | fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { |
| 179 | 225 | if area.is_empty() { |
| 180 | 226 | return; |
| @@ -422,7 +468,40 @@ impl Painter<'_> { | ||
| 422 | 468 | let offset = props.cells("offset", 0).min(max_offset); |
| 423 | 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 | 505 | let mut inner = Painter { |
| 427 | 506 | tree: self.tree, |
| 428 | 507 | screen: &mut buffer, |
| @@ -432,13 +511,29 @@ impl Painter<'_> { | ||
| 432 | 511 | out: Painted::default(), |
| 433 | 512 | overlays: Vec::new(), |
| 434 | 513 | }; |
| 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); | |
| 438 | 533 | |
| 439 | 534 | for y in 0..area.h { |
| 440 | 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 | 537 | self.screen.put(area.x + x, area.y + y, cell.clone()); |
| 443 | 538 | } |
| 444 | 539 | } |
| @@ -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() { | ||
| 734 | 734 | ui.frame(); |
| 735 | 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 | +} | |
| @@ -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 { | ||
| 139 | 139 | #[derive(Clone, Debug, Default)] |
| 140 | 140 | struct Node { |
| 141 | 141 | tag: Tag, |
| 142 | - props: HashMap<String, Value>, | |
| 142 | + props: Props, | |
| 143 | 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 | 148 | /// 0 when unparented. The root's parent is 0 as well, which is what stops |
| 145 | 149 | /// the ancestor walk in [`Tree::would_cycle`]. |
| 146 | 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 | 155 | /// Reading them through this rather than the map means a missing prop and a |
| 152 | 156 | /// prop of the wrong type answer the same thing: the default. Nothing a caller |
| @@ -250,6 +254,9 @@ pub struct Tree { | ||
| 250 | 254 | root: u32, |
| 251 | 255 | pending: VecDeque<Event>, |
| 252 | 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 | 262 | impl Default for Tree { |
| @@ -266,6 +273,7 @@ impl Tree { | ||
| 266 | 273 | root: 0, |
| 267 | 274 | pending: VecDeque::new(), |
| 268 | 275 | current: None, |
| 276 | + revision: 0, | |
| 269 | 277 | }; |
| 270 | 278 | tree.root = tree.new_node("window"); |
| 271 | 279 | tree |
| @@ -279,17 +287,56 @@ impl Tree { | ||
| 279 | 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 | 294 | fn slot_mut(&mut self, id: u32) -> Option<&mut Node> { |
| 295 | + self.touch(id); | |
| 283 | 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 | 331 | pub fn exists(&self, id: u32) -> bool { |
| 287 | 332 | self.slot(id).is_some() |
| 288 | 333 | } |
| 289 | 334 | |
| 290 | 335 | pub fn new_node(&mut self, tag: &str) -> u32 { |
| 336 | + self.revision = self.revision.wrapping_add(1); | |
| 291 | 337 | let node = Node { |
| 292 | 338 | tag: Tag::parse(tag), |
| 339 | + rev: self.revision, | |
| 293 | 340 | ..Node::default() |
| 294 | 341 | }; |
| 295 | 342 | match self.free.pop() { |
| @@ -328,6 +375,7 @@ impl Tree { | ||
| 328 | 375 | self.free_subtree(child); |
| 329 | 376 | } |
| 330 | 377 | if self.nodes[id as usize].take().is_some() { |
| 378 | + self.revision = self.revision.wrapping_add(1); | |
| 331 | 379 | self.free.push(id); |
| 332 | 380 | } |
| 333 | 381 | // An event queued against a node that has since gone would be routed to |
| @@ -457,24 +505,55 @@ impl Tree { | ||
| 457 | 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 | 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 | 543 | pub fn set(&mut self, id: u32, key: &str, value: Value) { |
| 465 | 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 | 549 | pub fn clear_props(&mut self, id: u32) { |
| 471 | 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 | 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 | 559 | /// The subtree at `id` as pretty-printed hiccup — what the tree *is*, read |
| @@ -496,7 +575,7 @@ impl Tree { | ||
| 496 | 575 | out.push_str("[:"); |
| 497 | 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 | 579 | keys.sort(); |
| 501 | 580 | out.push_str(" {"); |
| 502 | 581 | for (i, key) in keys.iter().enumerate() { |
| @@ -506,7 +585,7 @@ impl Tree { | ||
| 506 | 585 | out.push(':'); |
| 507 | 586 | out.push_str(key); |
| 508 | 587 | out.push(' '); |
| 509 | - write_value(&node.props[*key], out); | |
| 588 | + write_value(&node.props.0[*key], out); | |
| 510 | 589 | } |
| 511 | 590 | out.push('}'); |
| 512 | 591 | |
| @@ -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 stops | 148 | /// 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 a | 155 | /// 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 caller | 156 | /// 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 | tree | 279 | 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 to | 381 | // 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*, read | 559 | /// 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 | ||