Compare commits
6
Commits
0449a324ef
...
20303e0b4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20303e0b4c | ||
|
|
6973a89815 | ||
|
|
c3cfc67bb3 | ||
|
|
155d899e55 | ||
|
|
e63e923d44 | ||
|
|
a56a928b0c |
No files matched your search
@@ -231,4 +231,95 @@ mod tests {
|
|||||||
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shapes a real transcript actually contains, each checked for
|
||||||
|
/// the one property the streaming fast path needs: the *number* of
|
||||||
|
/// blocks and every earlier block's source stay put while the message
|
||||||
|
/// grows. A fence's own blank lines, a `---` inside one, a nested
|
||||||
|
/// list and a table are all places where a naive line-based split
|
||||||
|
/// would break the message into more pieces than there are blocks.
|
||||||
|
#[test]
|
||||||
|
fn the_transcripts_own_block_shapes_survive_a_split() {
|
||||||
|
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
|
||||||
|
assert_eq!(
|
||||||
|
kinds(fence_with_blanks),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
|
||||||
|
"a blank line inside a fence is not a block boundary"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("```\n---\n```"),
|
||||||
|
vec![BlockKind::Code],
|
||||||
|
"a thematic break inside a fence is code, not a break"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("- a\n - a1\n - a2\n- b"),
|
||||||
|
vec![BlockKind::List],
|
||||||
|
"a nested list is one top-level block"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("## Heading\n```sh\nls\n```"),
|
||||||
|
vec![BlockKind::Heading, BlockKind::Code],
|
||||||
|
"a fence directly under a heading, with no blank line"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
|
||||||
|
vec![BlockKind::Table]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("> quoted\n> more\n\nplain"),
|
||||||
|
vec![BlockKind::Quote, BlockKind::Paragraph]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `apply_delta`'s precondition, stated as the property rather than
|
||||||
|
/// the arithmetic: for every prefix of a realistic streamed message,
|
||||||
|
/// the blocks before the last one must be exactly the blocks the
|
||||||
|
/// previous prefix had. Where markdown breaks that (the `---` case
|
||||||
|
/// above), `common_prefix` has to *say* so -- which is what the
|
||||||
|
/// `>= len - 1` assertion below checks: the split may rewrite the
|
||||||
|
/// last block, never an earlier one, or `RowBlocks::apply_delta`
|
||||||
|
/// would keep a widget whose text is no longer what it holds.
|
||||||
|
#[test]
|
||||||
|
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
|
||||||
|
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
|
||||||
|
// Every character boundary, so a delta landing mid-word and one
|
||||||
|
// landing exactly on a fence's closing backtick are both covered.
|
||||||
|
let mut prev = Vec::new();
|
||||||
|
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
|
||||||
|
let now = split_blocks(&full[..end]);
|
||||||
|
let common = common_prefix(&prev, &now);
|
||||||
|
assert!(
|
||||||
|
prev.is_empty() || common + 1 >= prev.len(),
|
||||||
|
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
|
||||||
|
prev.len()
|
||||||
|
);
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half a growing message cannot show: a fence that never closes.
|
||||||
|
/// The stream ends there and the block must still be the code block
|
||||||
|
/// it has been all along, not re-split into paragraphs.
|
||||||
|
#[test]
|
||||||
|
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
|
||||||
|
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(
|
||||||
|
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code]
|
||||||
|
);
|
||||||
|
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delta that closes a fence changes the *last* block only, so the
|
||||||
|
/// fast path takes it -- the case the module doc says is the reason
|
||||||
|
/// `common_prefix` is a comparison.
|
||||||
|
#[test]
|
||||||
|
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
|
||||||
|
let before = split_blocks("Text.\n\n```\ncode\n");
|
||||||
|
let after = split_blocks("Text.\n\n```\ncode\n```");
|
||||||
|
assert_eq!(before.len(), after.len());
|
||||||
|
assert_eq!(common_prefix(&before, &after), 1);
|
||||||
|
assert_ne!(before[1], after[1]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -827,3 +827,23 @@ still not root-caused).
|
|||||||
both CPU-side caches otherwise kept pointing at the old, now-destroyed
|
both CPU-side caches otherwise kept pointing at the old, now-destroyed
|
||||||
device's textures, which is why text used to vanish again after leaving
|
device's textures, which is why text used to vanish again after leaving
|
||||||
and returning to the app.
|
and returning to the app.
|
||||||
|
|
||||||
|
## 2026-09-06: `take_counters` counts text layouts too
|
||||||
|
|
||||||
|
One public API change, from the verification pass over the composer-scroll
|
||||||
|
and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
|
||||||
|
|
||||||
|
- **`UiRenderState::take_counters` returns four numbers, not three**:
|
||||||
|
`(draws, region rewrites, move writes, **text shapes**)`. The new one is
|
||||||
|
bumped in `Painter::render_text`, which `TextView::render` only reaches
|
||||||
|
on a cache miss, so it counts layouts actually computed rather than
|
||||||
|
layouts asked for. Callers destructuring the tuple need one more `_`.
|
||||||
|
|
||||||
|
It exists because a draw counter cannot answer the question the
|
||||||
|
per-block transcript row was built for. 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 expensive half — so "a streamed delta
|
||||||
|
costs one block" was, until now, argued from the code rather than
|
||||||
|
measured. With the counter it is a test: one delta into a 100-paragraph
|
||||||
|
reply shapes exactly **1** text layout, the same as into a
|
||||||
|
one-paragraph one.
|
||||||
+10
-4
@@ -223,10 +223,16 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
`Scroll` itself turned out to measure the right number by a misleading
|
`Scroll` itself turned out to measure the right number by a misleading
|
||||||
route -- it is written against `painter.px_size()` now, and the claim
|
route -- it is written against `painter.px_size()` now, and the claim
|
||||||
below that it "measures against the window" was wrong.
|
below that it "measures against the window" was wrong.
|
||||||
**Still open, and pre-existing:** the bar's own grey background is not
|
**The grey background was not missing** -- that note (written here on
|
||||||
drawn on this build (the `Stack{StackSize::Child(1)}` behind the field),
|
2026-09-06 and repeated as still open) is withdrawn. Re-measured the
|
||||||
so the message reads as white text over the transcript. Present in the
|
same day on the same AVD by decoding the screencap rather than reading
|
||||||
build *before* this change too, so it is not the scroll area's doing.
|
it: the bar is `rgb(41,40,49)`, the declared `UiColor::new(40, 40, 46)`
|
||||||
|
after sRGB rounding, **full width and y2245..y2365** on 1080x2424, with
|
||||||
|
the field at `31,2277..1048,2329` and the 63px nav strip below it. It
|
||||||
|
is dark by design and sits on black, which is very likely what the
|
||||||
|
earlier reading was: at a glance the band and the background are hard
|
||||||
|
to tell apart. If it should read as a bar rather than as a slightly
|
||||||
|
different black, the colour is the thing to change, not the tree.
|
||||||
|
|
||||||
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
|
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,89 @@ moves least, which is consistent -- a delta into a *short* message never
|
|||||||
cost much. **The phone number is Iris's to take**; nothing here is a
|
cost much. **The phone number is Iris's to take**; nothing here is a
|
||||||
statement about her device.
|
statement about her device.
|
||||||
|
|
||||||
|
### Verification pass over Tasks A and B, 2026-09-06
|
||||||
|
|
||||||
|
Read of `git diff fb6b459..HEAD -- iris/ client-core/` against LAYOUT.md,
|
||||||
|
TEXTURES.md, IRIS.md/DECISIONS.md's 2026-09-06 entries and CODE_RULES.md,
|
||||||
|
with the emulator. **Verdict: deliverable to the phone.** One real defect
|
||||||
|
found and fixed, two missing guards added, one open item closed as stale.
|
||||||
|
|
||||||
|
1. **The block model is correct.** `split_blocks` was checked against the
|
||||||
|
shapes a real transcript has -- a fence with blank lines, a `---`
|
||||||
|
inside a fence, a nested list, a fence directly under a heading, a
|
||||||
|
table, a quote -- and against the property `apply_delta` rests on, at
|
||||||
|
**every character boundary** of a message containing all of them:
|
||||||
|
growing a message may rewrite its last block and never an earlier one,
|
||||||
|
or `common_prefix` says so. No defect (`client-core`'s
|
||||||
|
`every_prefix_of_a_streamed_message_keeps_all_but_its_last_block`,
|
||||||
|
commit `a56a928`). A delta closing a fence, a delta mid-word and a
|
||||||
|
stream ending inside an unterminated fence are each their own test.
|
||||||
|
|
||||||
|
2. **`iris/core/src/ui/render_state.rs`, `draw_inner`'s size-independent
|
||||||
|
fast path: fixed** (commit `e63e923`). It rewrites the widget's own
|
||||||
|
primitives in place and writes **no** move-slot delta, so unlike `mov`
|
||||||
|
there is nothing for `move_applied` to count; `167862c` counted one
|
||||||
|
anyway, and `resolved_region` then subtracted a distance the chain
|
||||||
|
never held. Every such widget's hit box sat short of its drawing by
|
||||||
|
its last step, with the drawing correct -- nothing on screen to say
|
||||||
|
so. `Span` reaches this on the **first frame** of any tree containing
|
||||||
|
a `Rect` (the `.background(rect(..))` idiom, list row tints), because
|
||||||
|
it measures each child at the full region and then places it. Pinned
|
||||||
|
by `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`,
|
||||||
|
the sibling of `a_panned_widgets_own_hit_box_moves_exactly_once` on the
|
||||||
|
branch that fix had no reason to touch.
|
||||||
|
|
||||||
|
3. **Selection across blocks is sound; its rebuild path had no test**
|
||||||
|
(commit `155d899`). `SelKey = (RowKey, u32)` orders lexicographically,
|
||||||
|
which is reading order at both levels, so `begin`/`extend`/`locate`
|
||||||
|
and the range queries carry over unchanged; `selected_text` joining
|
||||||
|
with a blank line is right for blocks as well as rows, since that is
|
||||||
|
how markdown separates them. The gap was the tail rebuilt under the
|
||||||
|
**same key with fewer blocks** -- the dropped blocks keep pointing at
|
||||||
|
widgets `replace_back`'s drop frees, and `Selection::begin` resolves
|
||||||
|
every registered handle on an ordinary press, so the next tap anywhere
|
||||||
|
panics. `e1030d6`'s unconditional `unregister` is correct and now has
|
||||||
|
`a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection`,
|
||||||
|
confirmed to fail (3 blocks still registered, expected 1) without it.
|
||||||
|
`Selection::registered_blocks` is the test-only accessor that lets it
|
||||||
|
assert the contract rather than only that nothing panicked.
|
||||||
|
|
||||||
|
4. **The three new `debug_assert!`s are whole-set, not one member.**
|
||||||
|
`Len::fold_dp`'s is in `draw_inner` after *every* `Widget::draw`, so
|
||||||
|
it governs the set by construction; `Pad` and `Span` were checked and
|
||||||
|
already fold through `apply_rest`, and `Sized`/`MaxSize` are the two
|
||||||
|
that reported a caller-written `Len` raw. `own_mask`'s reuse lives
|
||||||
|
inside `Painter::set_mask` itself, whose only caller is
|
||||||
|
`widget/mask.rs`. `move_applied` has exactly two writers, `mov` and
|
||||||
|
`reposition` (now one, after finding 2), and `resolved_region` is the
|
||||||
|
only reader -- `window_region` goes through it.
|
||||||
|
|
||||||
|
5. **The O(last block) claim now holds for parley, by counter**
|
||||||
|
(commit `c3cfc67`). `take_counters` gained a fourth number, 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 either way. Measured: **one delta
|
||||||
|
into a 100-paragraph reply shapes exactly 1 text layout, the same as
|
||||||
|
into a one-paragraph reply.**
|
||||||
|
|
||||||
|
6. **The composer bar's grey background *is* drawn** -- IRIS_TODO.md's
|
||||||
|
"still open, and pre-existing" note is stale and has been corrected.
|
||||||
|
Measured by decoding the screencap rather than eyeballing it: the bar
|
||||||
|
is `rgb(41,40,49)` (the declared `40,40,46` after sRGB rounding),
|
||||||
|
**full width, y2245..y2365** on the 1080x2424 AVD, with the field at
|
||||||
|
`31,2277..1048,2329` and the 63px nav strip below. Whatever the note
|
||||||
|
saw, Task A's `MaxSize`/`own_mask` fixes closed it.
|
||||||
|
|
||||||
|
**Checks run**: `cargo fmt --all --check` clean in both workspaces;
|
||||||
|
`cargo clippy --workspace --all-targets` warning-free (only the
|
||||||
|
pre-existing future-incompat note about `wgpu`/`naga`/`winit`);
|
||||||
|
`cargo test` 81 (iris) + 13 (iris-core) + 20 (transcript-ui) + 123
|
||||||
|
(client-core), all passing. One bench run on this checkout's AVD with the
|
||||||
|
assertions live, debug x86_64 `force-gles`, no abort and nothing in
|
||||||
|
logcat: **stream: 298 frames over 21.0s, late 287 (96.3%), p50 52.8ms,
|
||||||
|
p90 108.1ms, p99 137.3ms, worst 148.9ms** -- reproducing the "after"
|
||||||
|
column above.
|
||||||
|
|
||||||
|
|
||||||
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit
|
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit
|
||||||
`f802de9`, `git merge --no-ff worktree-agent-a754368325fa06839`,
|
`f802de9`, `git merge --no-ff worktree-agent-a754368325fa06839`,
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ fn bench_first_frame(n: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
report(
|
report(
|
||||||
&format!("(a) first frame, N={n}"),
|
&format!("(a) first frame, N={n}"),
|
||||||
elapsed,
|
elapsed,
|
||||||
@@ -177,7 +177,7 @@ fn bench_scroll(n: usize, ticks: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -245,7 +245,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -302,7 +302,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -384,7 +384,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ impl<'a> Painter<'a> {
|
|||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
let density = self.state.density;
|
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();
|
let ui = self.rsc.ui_mut();
|
||||||
ui.text
|
ui.text
|
||||||
.render(buffer, attrs, width, &mut ui.textures, density)
|
.render(buffer, attrs, width, &mut ui.textures, density)
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ pub struct UiRenderState {
|
|||||||
draw_count: u64,
|
draw_count: u64,
|
||||||
region_mut_count: u64,
|
region_mut_count: u64,
|
||||||
mov_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
|
/// A move chain more than this deep would mean something else is wrong
|
||||||
@@ -76,17 +79,25 @@ impl UiRenderState {
|
|||||||
draw_count: 0,
|
draw_count: 0,
|
||||||
region_mut_count: 0,
|
region_mut_count: 0,
|
||||||
mov_count: 0,
|
mov_count: 0,
|
||||||
|
shape_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
||||||
/// writes) counters -- call once per frame before `update()` to
|
/// writes, text shapes) counters -- call once per frame before
|
||||||
/// measure exactly that frame, per LAYOUT.md section 8.
|
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
|
||||||
pub fn take_counters(&mut self) -> (u64, u64, u64) {
|
///
|
||||||
|
/// 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.draw_count),
|
||||||
std::mem::take(&mut self.region_mut_count),
|
std::mem::take(&mut self.region_mut_count),
|
||||||
std::mem::take(&mut self.mov_count),
|
std::mem::take(&mut self.mov_count),
|
||||||
|
std::mem::take(&mut self.shape_count),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +221,6 @@ impl UiRenderState {
|
|||||||
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
|
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
|
||||||
// The same shape reaches any dirty widget an ancestor redraws first.
|
// The same shape reaches any dirty widget an ancestor redraws first.
|
||||||
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
let output_size = self.output_size;
|
|
||||||
if let Some(active) = self.active.get_mut(&id)
|
if let Some(active) = self.active.get_mut(&id)
|
||||||
&& !dirty
|
&& !dirty
|
||||||
{
|
{
|
||||||
@@ -239,13 +249,15 @@ impl UiRenderState {
|
|||||||
*r = r.outside(&from).within(®ion);
|
*r = r.outside(&from).within(®ion);
|
||||||
self.region_mut_count += 1;
|
self.region_mut_count += 1;
|
||||||
}
|
}
|
||||||
// Same bookkeeping `mov` does below and for the same
|
// `move_applied` is deliberately **not** touched here,
|
||||||
// reason: `region` moves, this widget's own slot delta
|
// unlike in `mov`: it counts the part of this widget's own
|
||||||
// does not, so the part of that delta `region` accounts
|
// move-slot delta that `region` has already absorbed, and
|
||||||
// for grows by exactly this step. See
|
// this branch writes no delta at all -- the primitives were
|
||||||
// `ActiveData::move_applied`.
|
// moved directly. Counting one would make
|
||||||
active.move_applied +=
|
// `resolved_region` subtract a distance the chain never
|
||||||
region.top_left().to_abs(output_size) - from.top_left().to_abs(output_size);
|
// held, putting the hit box short of the drawing by
|
||||||
|
// exactly this step. See `ActiveData::move_applied`, and
|
||||||
|
// `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`.
|
||||||
active.region = region;
|
active.region = region;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
|
|||||||
render.take_counters(); // discard the first, real draw
|
render.take_counters(); // discard the first, real draw
|
||||||
|
|
||||||
render.update(&root, &mut rsc);
|
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));
|
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.
|
// already clamped) rather than actually moving anything.
|
||||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
|
||||||
render.update(&root, &mut rsc);
|
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
|
// 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
|
// 1 move_offsets write, independent of how many rects are in the
|
||||||
@@ -508,3 +508,57 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
|
|||||||
"expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})"
|
"expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on
|
||||||
|
/// the branch that fix had no reason to touch: `draw_inner`'s
|
||||||
|
/// size-independent fast path rewrites a widget's primitives *in place*
|
||||||
|
/// and leaves its move slot alone, so unlike `mov` there is no slot delta
|
||||||
|
/// for `region` to have absorbed. Counting one there anyway makes
|
||||||
|
/// `resolved_region` subtract a delta the chain never held, and the
|
||||||
|
/// widget's hit box lands short of where it is drawn by exactly the
|
||||||
|
/// distance it just moved -- with nothing on screen to say so, since the
|
||||||
|
/// primitives are in the right place.
|
||||||
|
#[test]
|
||||||
|
fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let spacer = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: top.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(100.0)),
|
||||||
|
});
|
||||||
|
let spacer_w = spacer.weak();
|
||||||
|
// `Rect` is `is_size_independent`, so growing the spacer above it
|
||||||
|
// offers this one a region that changed *both* position and size --
|
||||||
|
// the one shape that reaches the branch under test.
|
||||||
|
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let below_w = below.weak();
|
||||||
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
|
span.push(spacer.any());
|
||||||
|
span.push(below.any());
|
||||||
|
let root = rsc.ui.widgets.add_strong(span).any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
// `Span` draws each child once at the full region to measure it and
|
||||||
|
// then places it, so this widget has already been through the branch
|
||||||
|
// once by the end of the very first frame.
|
||||||
|
let first = render.window_region(&below_w, &rsc).unwrap();
|
||||||
|
assert!(
|
||||||
|
(first.top_left.y - 100.0).abs() < 0.01,
|
||||||
|
"hit box at {:?}, drawn at y=100",
|
||||||
|
first.top_left
|
||||||
|
);
|
||||||
|
|
||||||
|
rsc.ui.widgets.get_mut(&spacer_w).unwrap().y = Some(Len::abs(250.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let after = render.window_region(&below_w, &rsc).unwrap();
|
||||||
|
assert!(
|
||||||
|
(after.top_left.y - 250.0).abs() < 0.01,
|
||||||
|
"hit box at {:?}, drawn at y=250",
|
||||||
|
after.top_left
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1104,7 +1104,7 @@ mod tests {
|
|||||||
.push_front(ListRow::new(key, w));
|
.push_front(ListRow::new(key, w));
|
||||||
}
|
}
|
||||||
render.update(&root, &mut rsc);
|
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
|
// None of the already-visible rows (11, 12) were touched: the
|
||||||
// extents for those keys are numerically unchanged, and the only
|
// 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);
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
|
||||||
render.update(&root, &mut rsc);
|
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
|
// The visible window is a fixed ~10 rows regardless of n; an
|
||||||
// O(n) regression would show up as draws/moves scaling with
|
// O(n) regression would show up as draws/moves scaling with
|
||||||
|
|||||||
@@ -590,9 +590,10 @@ mod apply_tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Widget::draw` calls caused by one streamed delta landing in the
|
/// `(Widget::draw` calls, text layouts) caused by one streamed delta
|
||||||
/// last paragraph of a reply that already has `paragraphs` of them.
|
/// landing in the last paragraph of a reply that already has
|
||||||
fn draws_for_one_delta(paragraphs: usize) -> u64 {
|
/// `paragraphs` of them.
|
||||||
|
fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
ui: UiData::default(),
|
||||||
events: EventManager::default(),
|
events: EventManager::default(),
|
||||||
@@ -614,7 +615,8 @@ mod apply_tests {
|
|||||||
screen.apply(&mut rsc, &old_items, &new_items);
|
screen.apply(&mut rsc, &old_items, &new_items);
|
||||||
render.update(&tree, &mut rsc);
|
render.update(&tree, &mut rsc);
|
||||||
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
|
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
|
||||||
render.take_counters().0
|
let (draws, _, _, shapes) = render.take_counters();
|
||||||
|
(draws, shapes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pass condition for docs/DECISIONS.md's per-block row: a delta
|
/// The pass condition for docs/DECISIONS.md's per-block row: a delta
|
||||||
@@ -635,12 +637,24 @@ mod apply_tests {
|
|||||||
reply(100, "").len() > 3_000,
|
reply(100, "").len() > 3_000,
|
||||||
"the long case must actually be a long message"
|
"the long case must actually be a long message"
|
||||||
);
|
);
|
||||||
let short = draws_for_one_delta(1);
|
let (short_draws, short_shapes) = cost_of_one_delta(1);
|
||||||
let long = draws_for_one_delta(100);
|
let (long_draws, long_shapes) = cost_of_one_delta(100);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
short, long,
|
short_draws, long_draws,
|
||||||
"a delta into a 100-paragraph reply redrew {long} widgets against {short} for a \
|
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
|
||||||
one-paragraph reply -- the earlier blocks are not being kept"
|
{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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,4 +693,66 @@ mod apply_tests {
|
|||||||
Vec2::new(10.0, 10.0),
|
Vec2::new(10.0, 10.0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The failure half of the per-block row, and the one
|
||||||
|
/// `a_row_dropped_by_a_regroup_...` cannot reach: the tail row is
|
||||||
|
/// rebuilt under the **same key** with *fewer* blocks than it had.
|
||||||
|
/// `Selection` is keyed by `(row, block)`, so the blocks that no
|
||||||
|
/// longer exist are left pointing at widgets `replace_back`'s drop
|
||||||
|
/// frees -- and `begin` resolves every registered handle on an
|
||||||
|
/// ordinary press, so the next tap anywhere in the transcript
|
||||||
|
/// panics. Nothing about the key changed, which is why the
|
||||||
|
/// `if new_key != old_key` guard this replaced could not see it.
|
||||||
|
#[test]
|
||||||
|
fn a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection() {
|
||||||
|
use client_core::transcript_fold::group_tool_runs;
|
||||||
|
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
// Three blocks, then one. The rewrite is of an *earlier* block
|
||||||
|
// (the heading), so `RowBlocks::apply_delta` refuses it and the
|
||||||
|
// rebuild path is the one taken -- assert that below.
|
||||||
|
let old_items = vec![user(1, "stable"), assistant(2, "# Head\n\npara\n\n- item")];
|
||||||
|
let new_items = vec![user(1, "stable"), assistant(2, "short")];
|
||||||
|
assert_eq!(
|
||||||
|
diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)),
|
||||||
|
RowDiff::ReplaceLast { common: 1 },
|
||||||
|
"test setup must actually exercise the ReplaceLast arm"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
|
||||||
|
let tail_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(2));
|
||||||
|
assert_eq!(
|
||||||
|
screen
|
||||||
|
.selection
|
||||||
|
.borrow()
|
||||||
|
.registered_blocks(tail_key)
|
||||||
|
.count(),
|
||||||
|
3,
|
||||||
|
"the fixture must start with more blocks than it ends with"
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.apply(&mut rsc, &old_items, &new_items);
|
||||||
|
assert_eq!(
|
||||||
|
screen
|
||||||
|
.selection
|
||||||
|
.borrow()
|
||||||
|
.registered_blocks(tail_key)
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"the blocks the rebuild dropped are still registered"
|
||||||
|
);
|
||||||
|
|
||||||
|
// What a reader does next: press the row that survived. `begin`
|
||||||
|
// resolves every registered handle, so a stale one panics here.
|
||||||
|
let surviving_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(1));
|
||||||
|
screen.selection.borrow_mut().begin(
|
||||||
|
&mut rsc,
|
||||||
|
(surviving_key, 0),
|
||||||
|
Vec2::ZERO,
|
||||||
|
Vec2::new(10.0, 10.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +173,19 @@ impl Selection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The block indices currently registered for `row`, in order. For a
|
||||||
|
/// test asserting that a row's removal or rebuild took every one of
|
||||||
|
/// its blocks with it -- the contract `unregister` states and the one
|
||||||
|
/// a caller can get wrong silently, since a stale handle only shows
|
||||||
|
/// up as a panic on some later, unrelated press.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn registered_blocks(&self, row: RowKey) -> impl Iterator<Item = u32> + '_ {
|
||||||
|
self.rows
|
||||||
|
.keys()
|
||||||
|
.filter(move |(k, _)| *k == row)
|
||||||
|
.map(|&(_, b)| b)
|
||||||
|
}
|
||||||
|
|
||||||
/// Which registered block is under `pos_window`, with the position
|
/// Which registered block is under `pos_window`, with the position
|
||||||
/// and size that block's own `TextEdit` wants (block-local, the way
|
/// and size that block's own `TextEdit` wants (block-local, the way
|
||||||
/// `begin`/`extend` are given them by a block's own pointer handler).
|
/// `begin`/`extend` are given them by a block's own pointer handler).
|
||||||
|
|||||||
Reference in new issue
Block a user