iris: count text layouts, so "a delta shapes one block" is measured rather than argued

take_counters gains a fourth counter, text shapes, bumped in
Painter::render_text -- which TextView::render only reaches on a cache
miss, so it counts shapes and not requests. A draw counter cannot stand
in for it in either direction: a widget can be redrawn without
re-shaping (the layout is memoized by width) and re-shaped without any
extra draw, and re-shaping is the whole thing the per-block transcript
row exists to avoid.

With it, a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one
asserts the number docs/DECISIONS.md's 2026-09-06 entry actually claims:
one delta into a 100-paragraph reply shapes exactly one text layout, the
same as into a one-paragraph one. Before the split that was necessarily
O(message), since the reply was one buffer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 18:32:31 -04:00
1 parent 155d899e55
commit c3cfc67bb3
6 files changed
+43 -15

No files matched your search

+5 -5
View File
@@ -142,7 +142,7 @@ fn bench_first_frame(n: usize) {
let start = Instant::now();
render.update(&root, &mut rsc);
let elapsed = start.elapsed();
let (draws, rewrites, moves) = render.take_counters();
let (draws, rewrites, moves, _shapes) = render.take_counters();
report(
&format!("(a) first frame, N={n}"),
elapsed,
@@ -177,7 +177,7 @@ fn bench_scroll(n: usize, ticks: usize) {
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves) = render.take_counters();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
@@ -245,7 +245,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves) = render.take_counters();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
@@ -302,7 +302,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves) = render.take_counters();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
@@ -384,7 +384,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves) = render.take_counters();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
+4
View File
@@ -191,6 +191,10 @@ impl<'a> Painter<'a> {
width: Option<f32>,
) -> RenderedText {
let density = self.state.density;
// Counted here rather than in `TextView::render`, which returns
// its memoized layout without reaching this -- so this counts
// shapes, not requests. `UiRenderState::take_counters`.
self.state.shape_count += 1;
let ui = self.rsc.ui_mut();
ui.text
.render(buffer, attrs, width, &mut ui.textures, density)
+14 -3
View File
@@ -55,6 +55,9 @@ pub struct UiRenderState {
draw_count: u64,
region_mut_count: u64,
mov_count: u64,
/// Text layouts actually computed -- bumped by `Painter::render_text`,
/// which `TextView::render` only reaches on a cache miss.
pub(super) shape_count: u64,
}
/// A move chain more than this deep would mean something else is wrong
@@ -76,17 +79,25 @@ impl UiRenderState {
draw_count: 0,
region_mut_count: 0,
mov_count: 0,
shape_count: 0,
}
}
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
/// writes) counters -- call once per frame before `update()` to
/// measure exactly that frame, per LAYOUT.md section 8.
pub fn take_counters(&mut self) -> (u64, u64, u64) {
/// writes, text shapes) counters -- call once per frame before
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
///
/// The fourth is the one a draw count cannot stand in for: a widget
/// can be redrawn without re-shaping (`TextView::render` memoizes by
/// width) and re-shaped without any extra draw, and it is re-shaping
/// that the per-block transcript row exists to avoid -- see
/// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`.
pub fn take_counters(&mut self) -> (u64, u64, u64, u64) {
(
std::mem::take(&mut self.draw_count),
std::mem::take(&mut self.region_mut_count),
std::mem::take(&mut self.mov_count),
std::mem::take(&mut self.shape_count),
)
}
+2 -2
View File
@@ -68,7 +68,7 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
render.take_counters(); // discard the first, real draw
render.update(&root, &mut rsc);
let (draws, rewrites, moves) = render.take_counters();
let (draws, rewrites, moves, _shapes) = render.take_counters();
assert_eq!((draws, rewrites, moves), (0, 0, 0));
}
@@ -101,7 +101,7 @@ fn scrolling_moves_in_o1_without_a_redraw() {
// already clamped) rather than actually moving anything.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves) = render.take_counters();
let (draws, _rewrites, moves, _shapes) = render.take_counters();
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
// 1 move_offsets write, independent of how many rects are in the
+2 -2
View File
@@ -1104,7 +1104,7 @@ mod tests {
.push_front(ListRow::new(key, w));
}
render.update(&root, &mut rsc);
let (draws, _rewrites, _moves) = render.take_counters();
let (draws, _rewrites, _moves, _shapes) = render.take_counters();
// None of the already-visible rows (11, 12) were touched: the
// extents for those keys are numerically unchanged, and the only
@@ -1242,7 +1242,7 @@ mod tests {
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves) = render.take_counters();
let (draws, _rewrites, moves, _shapes) = render.take_counters();
// The visible window is a fixed ~10 rows regardless of n; an
// O(n) regression would show up as draws/moves scaling with
+16 -3
View File
@@ -615,7 +615,8 @@ mod apply_tests {
screen.apply(&mut rsc, &old_items, &new_items);
render.update(&tree, &mut rsc);
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
(render.take_counters().0, 0)
let (draws, _, _, shapes) = render.take_counters();
(draws, shapes)
}
/// The pass condition for docs/DECISIONS.md's per-block row: a delta
@@ -636,13 +637,25 @@ mod apply_tests {
reply(100, "").len() > 3_000,
"the long case must actually be a long message"
);
let (short_draws, _) = cost_of_one_delta(1);
let (long_draws, _) = cost_of_one_delta(100);
let (short_draws, short_shapes) = cost_of_one_delta(1);
let (long_draws, long_shapes) = cost_of_one_delta(100);
assert_eq!(
short_draws, long_draws,
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
{short_draws} for a one-paragraph reply -- the earlier blocks are not being kept"
);
// The half a draw counter cannot see, and the one the per-block
// row actually exists for: a redraw is free if the text engine
// hits its memo, and a re-shape is the expensive thing. One
// shape, whatever the message is worth -- the block the delta
// landed in. Before the split this was necessarily O(message),
// since the whole reply was one buffer.
assert_eq!(
(short_shapes, long_shapes),
(1, 1),
"a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \
{short_shapes} in a one-paragraph one; it must be the last block and nothing else"
);
}
#[test]