iris: a transcript row is a column of markdown blocks, so a streamed delta costs one block
A row was one TextEdit holding the whole message, so every delta re-shaped every paragraph of a long reply through parley -- the one phase where iris trails Compose on the phone (p50 18.2ms vs 13.4ms, bench v2). - client-core/src/markdown_blocks.rs: split a message into its top-level blocks with their source, through the same pulldown-cmark the renderer parses with so the two cannot disagree about where a block starts, plus common_prefix. Appending markdown can rewrite an earlier block (a trailing --- turns the paragraph above into a heading), so the fast path compares the prefix it keeps rather than assuming it -- with the test that says so. - transcript-ui: a row is a Span of one TextEdit per block; RowBlocks::apply_delta replaces the block a delta lands in; TranscriptScreen keeps the tail row's blocks, seeded in build_tree as well as push_row (a screen opened onto a streaming reply took the rebuild path for its first delta otherwise, with nothing to say so). - A block is the selection unit: Selection is keyed by (RowKey, u32), which is reading order at both levels, and the pointer-captured half of a drag resolves the block under the finger from its drawn box (Selection::locate) instead of from the row's extent. Pass condition: a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one drives a real UiRenderState and asserts the draw count for a delta into a 100-paragraph (3,000+ char) reply equals the count for a one-paragraph one. 30 either way; it read 630 against 30 twice on the way there. Emulator stream phase, same AVD before and after: p50 61.5 -> 54.5ms, p90 211.7 -> 113.1ms, p99 342.6 -> 137.4ms, worst 403.6 -> 143.0ms, 202 -> 293 frames in the same 21 seconds. Selection across blocks verified with a real long-press drag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
167862ca1b
commit
e1030d69f6
14 files changed
+871
-82
No files matched your search
Generated
+1
@@ -721,6 +721,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
|
||||
Generated
+1
@@ -745,6 +745,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
|
||||
+156
-19
@@ -66,6 +66,14 @@ pub struct TranscriptScreen {
|
||||
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
||||
/// [`Self::take_rebuilds`].
|
||||
rebuilds: std::cell::Cell<usize>,
|
||||
/// The per-block widgets of the row at the live end of the list --
|
||||
/// the only row a streamed delta ever lands in -- so
|
||||
/// [`Self::apply`]'s `ReplaceLast` can replace one markdown block
|
||||
/// instead of rebuilding the message
|
||||
/// (`row::RowBlocks::apply_delta`). `None` for a tail that has no
|
||||
/// delta path (a tool run) or before anything has been pushed. Its
|
||||
/// removal is every path that replaces or drops the tail row, below.
|
||||
tail: RefCell<Option<(RowKey, row::RowBlocks)>>,
|
||||
}
|
||||
|
||||
impl TranscriptScreen {
|
||||
@@ -77,8 +85,39 @@ impl TranscriptScreen {
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let (key, widget) = row::build_row(rsc, self.list, self.selection.clone(), row);
|
||||
let (key, widget, blocks) = row::build_row(rsc, self.list, self.selection.clone(), row);
|
||||
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
||||
*self.tail.borrow_mut() = blocks.map(|b| (key, b));
|
||||
}
|
||||
|
||||
/// The `ReplaceLast` fast path: update the tail row's blocks in place
|
||||
/// if this really is a delta into the same message, and say whether
|
||||
/// that worked. `false` for anything the caller must rebuild instead
|
||||
/// -- a tail with no block state (a tool run), a row that is not a
|
||||
/// `Single`, or a change `RowBlocks::apply_delta` will not take.
|
||||
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let FoldedRow::Single(item) = row else {
|
||||
return false;
|
||||
};
|
||||
let mut tail = self.tail.borrow_mut();
|
||||
let Some((tail_key, blocks)) = tail.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if *tail_key != key {
|
||||
return false;
|
||||
}
|
||||
let (sender, markdown_src) = row::item_content(item);
|
||||
blocks.apply_delta(
|
||||
rsc,
|
||||
self.list,
|
||||
self.selection.clone(),
|
||||
key,
|
||||
sender,
|
||||
&markdown_src,
|
||||
)
|
||||
}
|
||||
|
||||
/// Apply the effect of one more folded event without rebuilding the
|
||||
@@ -134,17 +173,34 @@ impl TranscriptScreen {
|
||||
}
|
||||
}
|
||||
RowDiff::ReplaceLast { common } => {
|
||||
// Only the tail row's content changed -- rebuild that one
|
||||
// row and swap it in place, keeping every row before it
|
||||
// untouched.
|
||||
// Only the tail row's content changed. First try the
|
||||
// delta path: the row is a column of one widget per
|
||||
// markdown block, so a delta that lands in the last block
|
||||
// is one `set_with_spans` and the earlier blocks keep
|
||||
// their layouts (`row::RowBlocks::apply_delta`, and
|
||||
// docs/DECISIONS.md for why the row is shaped that way).
|
||||
let old_key = row::row_key(&old_rows[common].key());
|
||||
let (new_key, widget) =
|
||||
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
|
||||
if new_key != old_key {
|
||||
self.selection.borrow_mut().unregister(old_key);
|
||||
let new_key = row::row_key(&new_rows[common].key());
|
||||
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
|
||||
for row in &new_rows[common + 1..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise rebuild that one row and swap it in place,
|
||||
// keeping every row before it untouched. `unregister`
|
||||
// unconditionally, not only when the key changed: a
|
||||
// rebuild with *fewer* blocks under the same key would
|
||||
// otherwise leave the extra blocks in `Selection`
|
||||
// pointing at widgets the `drop` below frees (the shape
|
||||
// docs/REVIEW-2026-09-06.md's finding 1 called out).
|
||||
self.selection.borrow_mut().unregister(old_key);
|
||||
let (new_key, widget, blocks) =
|
||||
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
|
||||
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
|
||||
drop(evicted); // frees the old row's widget, same as a pop would
|
||||
*self.tail.borrow_mut() = blocks.map(|b| (new_key, b));
|
||||
for row in &new_rows[common + 1..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
@@ -162,6 +218,7 @@ impl TranscriptScreen {
|
||||
self.rebuilds.set(self.rebuilds.get() + 1);
|
||||
self.selection.borrow_mut().clear();
|
||||
(self.list)(rsc).clear();
|
||||
*self.tail.borrow_mut() = None;
|
||||
for row in &new_rows {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
@@ -213,9 +270,18 @@ where
|
||||
let selection = Rc::new(RefCell::new(Selection::new()));
|
||||
let list = List::new(Axis::Y).add(rsc);
|
||||
|
||||
// The last row's block widgets are kept for the same reason
|
||||
// `push_row` keeps them: a reply that is *already* streaming when the
|
||||
// screen is built takes its next delta through `apply`, and a `None`
|
||||
// here would send that delta down the rebuild path instead -- the
|
||||
// whole message re-shaped, which is exactly what the per-block column
|
||||
// exists to avoid, and nothing on screen or in `take_rebuilds` would
|
||||
// say so.
|
||||
let mut tail = None;
|
||||
for row in &rows {
|
||||
let (key, widget) = row::build_row(rsc, list, selection.clone(), row);
|
||||
let (key, widget, blocks) = row::build_row(rsc, list, selection.clone(), row);
|
||||
list(rsc).push_back(ListRow::new(key, widget));
|
||||
tail = blocks.map(|b| (key, b));
|
||||
}
|
||||
|
||||
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
|
||||
@@ -242,15 +308,13 @@ where
|
||||
list.on(
|
||||
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop,
|
||||
move |ctx, rsc| {
|
||||
let pos = ctx.data.pos;
|
||||
let row = list(rsc).key_at(pos.y).and_then(|key| {
|
||||
let (top, bottom) = list(rsc).extent(key)?;
|
||||
Some((
|
||||
key,
|
||||
Vec2::new(pos.x, pos.y - top),
|
||||
Vec2::new(ctx.data.size.x, bottom - top),
|
||||
))
|
||||
});
|
||||
// Which *block* the finger is over, resolved from its
|
||||
// drawn box rather than from the row's extent -- a row is
|
||||
// a column of one widget per markdown block now, and the
|
||||
// block is what `Selection` selects (`SelKey`).
|
||||
let row = selection
|
||||
.borrow()
|
||||
.locate(&*rsc, ctx.data.render, ctx.data.cursor.pos);
|
||||
selection.borrow_mut().drag(
|
||||
rsc,
|
||||
list,
|
||||
@@ -274,6 +338,7 @@ where
|
||||
|
||||
(
|
||||
TranscriptScreen {
|
||||
tail: RefCell::new(tail),
|
||||
list,
|
||||
composer,
|
||||
selection,
|
||||
@@ -507,6 +572,78 @@ mod apply_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant(seq: u64, text: &str) -> TranscriptItem {
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
settled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply of `paragraphs` paragraphs, the last one still growing.
|
||||
fn reply(paragraphs: usize, tail: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for i in 0..paragraphs {
|
||||
out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n"));
|
||||
}
|
||||
out.push_str(tail);
|
||||
out
|
||||
}
|
||||
|
||||
/// `Widget::draw` calls caused by one streamed delta landing in the
|
||||
/// last paragraph of a reply that already has `paragraphs` of them.
|
||||
fn draws_for_one_delta(paragraphs: usize) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))];
|
||||
let new_items = vec![assistant(
|
||||
1,
|
||||
&reply(paragraphs, "and the last one is still going."),
|
||||
)];
|
||||
let (screen, tree) = build_tree(
|
||||
&mut rsc,
|
||||
client_core::transcript_fold::group_tool_runs(&old_items),
|
||||
);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
render.take_counters();
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// The pass condition for docs/DECISIONS.md's per-block row: a delta
|
||||
/// costs the **last block**, not the message. A 3,000-character reply
|
||||
/// has a hundred paragraphs already laid out; redrawing one delta into it
|
||||
/// must cost exactly what the same delta costs in a one-paragraph
|
||||
/// reply, or the earlier blocks are being re-shaped.
|
||||
///
|
||||
/// Before the split this was one `TextEdit` for the whole message, so
|
||||
/// the count was the same *number* of widgets but each redraw
|
||||
/// re-shaped every paragraph through parley -- which a draw counter
|
||||
/// cannot see. What it can see is that the count does not *grow* with
|
||||
/// the message, which it now does not and could not before, since the
|
||||
/// one widget's own layout was O(message).
|
||||
#[test]
|
||||
fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() {
|
||||
assert!(
|
||||
reply(100, "").len() > 3_000,
|
||||
"the long case must actually be a long message"
|
||||
);
|
||||
let short = draws_for_one_delta(1);
|
||||
let long = draws_for_one_delta(100);
|
||||
assert_eq!(
|
||||
short, long,
|
||||
"a delta into a 100-paragraph reply redrew {long} widgets against {short} for a \
|
||||
one-paragraph reply -- the earlier blocks are not being kept"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() {
|
||||
use client_core::transcript_fold::group_tool_runs;
|
||||
@@ -537,7 +674,7 @@ mod apply_tests {
|
||||
let surviving_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(4));
|
||||
screen.selection.borrow_mut().begin(
|
||||
&mut rsc,
|
||||
surviving_key,
|
||||
(surviving_key, 0),
|
||||
Vec2::ZERO,
|
||||
Vec2::new(10.0, 10.0),
|
||||
);
|
||||
|
||||
+198
-29
@@ -1,11 +1,18 @@
|
||||
//! One `iris::widget::list::ListRow` per folded transcript row
|
||||
//! (`client_core::transcript_fold::TranscriptRow`). Each row's whole text
|
||||
//! -- headings, paragraphs, inline styling -- goes through `markdown` into
|
||||
//! **one** `TextEdit`, which is what makes it one thing `Selection`
|
||||
//! (`selection.rs`) can select and what lets it wrap and scroll as a
|
||||
//! single buffer, matching RUST.md's "hard to get back" behaviour 2 (rich
|
||||
//! inline text) and half of behaviour 1 (selectable within a row; across
|
||||
//! rows is `selection.rs`'s job).
|
||||
//! (`client_core::transcript_fold::TranscriptRow`). A row is a **column of
|
||||
//! one `TextEdit` per top-level markdown block** (paragraph, heading,
|
||||
//! fence, list, table -- `client_core::markdown_blocks`), each rendered
|
||||
//! with `markdown`'s inline spans, so that RUST.md's "hard to get back"
|
||||
//! behaviour 2 (rich inline text) still holds within a block and
|
||||
//! behaviour 1 (selection) runs across blocks and rows alike through
|
||||
//! `selection.rs`.
|
||||
//!
|
||||
//! It was one `TextEdit` for the whole message until 2026-09-06, which
|
||||
//! meant a streamed delta re-shaped every paragraph of a long reply
|
||||
//! through parley again -- the stream phase was the one place iris trailed
|
||||
//! Compose on Iris's phone. [`RowBlocks::apply_delta`] is the other half
|
||||
//! of the fix; docs/DECISIONS.md's entry has what the alternative shapes
|
||||
//! were and why this one.
|
||||
//!
|
||||
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
|
||||
//! `client_core::transcript_fold::group_tool_runs`) is the row that proves
|
||||
@@ -17,11 +24,18 @@
|
||||
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
|
||||
|
||||
use crate::markdown::render_markdown;
|
||||
use crate::selection::Selection;
|
||||
use crate::selection::{SelKey, Selection};
|
||||
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||
use iris::prelude::*;
|
||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
||||
|
||||
/// The gap drawn between two markdown blocks of one message. A block used
|
||||
/// to be separated by the blank line `markdown::render_markdown` put in
|
||||
/// the single buffer; now that each block is its own widget, that spacing
|
||||
/// has to be the column's.
|
||||
const BLOCK_GAP_DP: f32 = 8.0;
|
||||
|
||||
/// The paragraph size every row's `TextEdit` is built at; markdown headings
|
||||
/// inside a row scale relative to a fixed set of sizes rather than this one
|
||||
/// (`markdown::heading_size`), since a heading is meant to look the same
|
||||
@@ -51,7 +65,7 @@ pub fn row_key(key: &client_core::transcript_fold::ItemKey) -> RowKey {
|
||||
|
||||
/// The sender label shown above a row's text, and the markdown source to
|
||||
/// render below it. `None` for a system-style note that has no sender.
|
||||
fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
|
||||
pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
|
||||
match item {
|
||||
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
|
||||
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
|
||||
@@ -107,24 +121,53 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Build one `TextEdit` from a sender label plus markdown source, register
|
||||
/// it with `selection` under `key`, and wire the pointer handlers that
|
||||
/// drive `Selection::drag` -- shared by every row variant below, since a
|
||||
/// selectable row is always "one TextEdit plus this wiring" regardless of
|
||||
/// what folded it. `list` is threaded through so that same drag can pan
|
||||
/// the list instead of selecting, per `Selection::drag`'s own doc.
|
||||
fn build_text_row<Rsc: HasEvents>(
|
||||
/// The per-block text widgets of one row, kept by `TranscriptScreen` for
|
||||
/// the row a reply is streaming into, so a delta can replace the block it
|
||||
/// lands in instead of re-shaping the whole message
|
||||
/// (docs/DECISIONS.md, 2026-09-06). Nothing else needs it: a row that is
|
||||
/// not the tail never changes.
|
||||
pub struct RowBlocks {
|
||||
/// What each field was built from, in order -- compared against a
|
||||
/// fresh split to decide what may be kept. See
|
||||
/// `client_core::markdown_blocks`' module doc for why this is a
|
||||
/// comparison and not an assumption.
|
||||
blocks: Vec<Block>,
|
||||
fields: Vec<WeakWidget<TextEdit>>,
|
||||
column: WeakWidget<Span>,
|
||||
/// The sender label the row was built with. A delta that changes it is
|
||||
/// not a delta into the same message, so it falls back to a rebuild.
|
||||
sender: Option<String>,
|
||||
}
|
||||
|
||||
/// Split for display: never empty, so a row with nothing in it yet is
|
||||
/// still one (empty) text widget rather than no widget at all -- an empty
|
||||
/// column reports a zero size and the row would vanish from the list.
|
||||
fn display_blocks(markdown_src: &str) -> Vec<Block> {
|
||||
let blocks = split_blocks(markdown_src);
|
||||
if blocks.is_empty() {
|
||||
vec![Block {
|
||||
kind: BlockKind::Paragraph,
|
||||
source: markdown_src.to_string(),
|
||||
}]
|
||||
} else {
|
||||
blocks
|
||||
}
|
||||
}
|
||||
|
||||
/// One block's own `TextEdit`, registered with `selection` under
|
||||
/// `(row, block)` and wired to `Selection::drag` -- the block is the
|
||||
/// selection unit (`selection::SelKey`).
|
||||
fn build_block_field<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<List>,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: RowKey,
|
||||
sender: Option<&str>,
|
||||
markdown_src: &str,
|
||||
) -> StrongWidget
|
||||
key: SelKey,
|
||||
source: &str,
|
||||
) -> WeakWidget<TextEdit>
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let (text, spans) = render_markdown(markdown_src, BASE_SIZE);
|
||||
let (text, spans) = render_markdown(source, BASE_SIZE);
|
||||
let field = wtext(text)
|
||||
.spans(spans)
|
||||
.editable(EditMode::MultiLine)
|
||||
@@ -137,7 +180,7 @@ where
|
||||
|
||||
field
|
||||
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
||||
// -- this row's own registration only ever needs to see a
|
||||
// -- this block's own registration only ever needs to see a
|
||||
// gesture's *first* frame (`PressStart`, or a `Pressing` that
|
||||
// missed it -- `DragGesture::handle`'s idle-recovery branch); once
|
||||
// it commits, `DragGesture` takes pointer capture on `list`'s own
|
||||
@@ -161,6 +204,38 @@ where
|
||||
},
|
||||
)
|
||||
.add(rsc);
|
||||
field
|
||||
}
|
||||
|
||||
/// Build a row from a sender label plus markdown source: a column of one
|
||||
/// `TextEdit` per top-level markdown block, under the sender's own label.
|
||||
///
|
||||
/// One widget per block rather than one per message is what makes a
|
||||
/// streamed delta cost the last block instead of the whole reply -- see
|
||||
/// [`RowBlocks::apply_delta`] for the other half, and
|
||||
/// `client_core::markdown_blocks` for the split. Selection still runs
|
||||
/// across the whole transcript; the unit it steps in is a block now rather
|
||||
/// than a row (`selection::SelKey`).
|
||||
fn build_text_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<List>,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: RowKey,
|
||||
sender: Option<&str>,
|
||||
markdown_src: &str,
|
||||
) -> (StrongWidget, RowBlocks)
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let blocks = display_blocks(markdown_src);
|
||||
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
|
||||
let mut fields = Vec::with_capacity(blocks.len());
|
||||
for (i, block) in blocks.iter().enumerate() {
|
||||
let field = build_block_field(rsc, list, selection.clone(), (key, i as u32), &block.source);
|
||||
fields.push(field);
|
||||
column.push(field.width(rest(1)).add_strong(rsc).any());
|
||||
}
|
||||
let column = column.add(rsc);
|
||||
|
||||
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
|
||||
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
|
||||
@@ -178,12 +253,91 @@ where
|
||||
None => Span::empty(Dir::DOWN).add(rsc),
|
||||
};
|
||||
|
||||
(header, field.width(rest(1)))
|
||||
let widget = (header, column.width(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.gap(dp(4))
|
||||
.pad(dp(10))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
.any();
|
||||
(
|
||||
widget,
|
||||
RowBlocks {
|
||||
blocks,
|
||||
fields,
|
||||
column,
|
||||
sender: sender.map(str::to_string),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
impl RowBlocks {
|
||||
/// Bring this row up to date with `markdown_src` **without** re-laying
|
||||
/// out the blocks that did not change, and say whether that was
|
||||
/// possible. `false` means the caller must rebuild the row the
|
||||
/// ordinary way: an earlier block was rewritten (markdown allows it --
|
||||
/// a trailing `---` turns the paragraph above into a heading), the
|
||||
/// sender changed, or the message got shorter.
|
||||
///
|
||||
/// This is the whole point of the per-block column: a delta arriving
|
||||
/// in a 3,000-character reply touches one `set_with_spans` on the last
|
||||
/// block, so parley re-shapes that block and nothing else.
|
||||
pub fn apply_delta<Rsc: HasEvents>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<List>,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: RowKey,
|
||||
sender: Option<&str>,
|
||||
markdown_src: &str,
|
||||
) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
if self.sender.as_deref() != sender {
|
||||
return false;
|
||||
}
|
||||
let new_blocks = display_blocks(markdown_src);
|
||||
let common = common_prefix(&self.blocks, &new_blocks);
|
||||
// Everything already drawn must either be kept whole (`common ==
|
||||
// len`, a pure append) or be kept except for the last block, which
|
||||
// is the one a delta lands in. Anything else means an already
|
||||
// laid-out block is no longer what it was.
|
||||
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
|
||||
return false;
|
||||
}
|
||||
debug_assert!(
|
||||
self.fields.len() == self.blocks.len(),
|
||||
"one field per block: {} fields, {} blocks",
|
||||
self.fields.len(),
|
||||
self.blocks.len()
|
||||
);
|
||||
|
||||
for (i, block) in new_blocks.iter().enumerate().skip(common) {
|
||||
let (text, spans) = render_markdown(&block.source, BASE_SIZE);
|
||||
match self.fields.get(i) {
|
||||
Some(field) => field.edit(rsc).set_with_spans(&text, spans),
|
||||
None => {
|
||||
let field = build_block_field(
|
||||
rsc,
|
||||
list,
|
||||
selection.clone(),
|
||||
(key, i as u32),
|
||||
&block.source,
|
||||
);
|
||||
self.fields.push(field);
|
||||
let child = field.width(rest(1)).add_strong(rsc).any();
|
||||
// `get_mut` marks the column dirty, which is what gets
|
||||
// the new block drawn; its removal half is the row's
|
||||
// own, since the column owns the child strongly.
|
||||
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
||||
column.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.blocks = new_blocks;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn build_single<Rsc: HasEvents>(
|
||||
@@ -192,7 +346,7 @@ fn build_single<Rsc: HasEvents>(
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: RowKey,
|
||||
item: &TranscriptItem,
|
||||
) -> StrongWidget
|
||||
) -> (StrongWidget, RowBlocks)
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
@@ -249,8 +403,15 @@ where
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
// Every block of the previous content goes first: collapsing a
|
||||
// five-block expansion back to a one-line summary registers only
|
||||
// `(key, 0)`, and blocks 1..5 would be left in `Selection`
|
||||
// pointing at widgets `ptr.replace` is about to free -- the same
|
||||
// class of bug docs/REVIEW-2026-09-06.md's finding 1 found in the
|
||||
// `Rebuild` arm, reached the other way.
|
||||
selection.borrow_mut().unregister(key);
|
||||
let text = if expanded { full } else { summary };
|
||||
build_text_row(rsc, list, selection, key, Some("Tools"), text)
|
||||
build_text_row(rsc, list, selection, key, Some("Tools"), text).0
|
||||
}
|
||||
|
||||
let content = build_content(
|
||||
@@ -299,18 +460,26 @@ pub fn build_row<Rsc: HasEvents>(
|
||||
list: WeakWidget<List>,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
row: &FoldedRow,
|
||||
) -> (RowKey, StrongWidget)
|
||||
) -> (RowKey, StrongWidget, Option<RowBlocks>)
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
match row {
|
||||
FoldedRow::Single(item) => {
|
||||
let key = row_key(&item.key());
|
||||
(key, build_single(rsc, list, selection, key, item))
|
||||
let (widget, blocks) = build_single(rsc, list, selection, key, item);
|
||||
(key, widget, Some(blocks))
|
||||
}
|
||||
FoldedRow::Tools(calls) => {
|
||||
let key = row_key(&calls[0].key());
|
||||
(key, build_tools(rsc, list, selection, key, calls.clone()))
|
||||
// `None`: a run of tool calls is never what a reply streams
|
||||
// into, and its own expand/collapse replaces the whole
|
||||
// content anyway, so there is no delta path to keep state for.
|
||||
(
|
||||
key,
|
||||
build_tools(rsc, list, selection, key, calls.clone()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,17 @@
|
||||
use iris::prelude::*;
|
||||
use std::{collections::BTreeMap, time::Instant};
|
||||
|
||||
/// What this selects between: a row's `RowKey` and the index of one
|
||||
/// markdown **block** inside it. A row is a column of one text widget per
|
||||
/// block since 2026-09-06 (`client_core::markdown_blocks`, and
|
||||
/// docs/DECISIONS.md for why), so the block, not the row, is the unit --
|
||||
/// `(row, block)` compares lexicographically, which is reading order for
|
||||
/// both levels, so every range query below is unchanged.
|
||||
pub type SelKey = (RowKey, u32);
|
||||
|
||||
pub struct Selection {
|
||||
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
|
||||
anchor: Option<(RowKey, Vec2)>,
|
||||
rows: BTreeMap<SelKey, WeakWidget<TextEdit>>,
|
||||
anchor: Option<(SelKey, Vec2)>,
|
||||
/// One gesture shared by every row's drag handler -- RUST.md's I5
|
||||
/// gesture conflict (a row's own `click_or_drag()` and a list-level
|
||||
/// pan wanting the same touch gesture). See `drag` below, and
|
||||
@@ -71,7 +79,7 @@ impl Selection {
|
||||
/// assertion) -- a derived handle that silently outlives what it
|
||||
/// points to; the next caller adding a third row-keyed side table
|
||||
/// should read both.
|
||||
pub fn register(&mut self, key: RowKey, text: WeakWidget<TextEdit>) {
|
||||
pub fn register(&mut self, key: SelKey, text: WeakWidget<TextEdit>) {
|
||||
self.rows.insert(key, text);
|
||||
}
|
||||
|
||||
@@ -89,9 +97,12 @@ impl Selection {
|
||||
self.anchor = None;
|
||||
}
|
||||
|
||||
pub fn unregister(&mut self, key: RowKey) {
|
||||
self.rows.remove(&key);
|
||||
if self.anchor.map(|(k, _)| k) == Some(key) {
|
||||
/// Forgets every block of one row -- a row is registered block by
|
||||
/// block, so its removal has to take all of them, and taking only the
|
||||
/// first is how a freed widget would be left behind in this map.
|
||||
pub fn unregister(&mut self, row: RowKey) {
|
||||
self.rows.retain(|&(k, _), _| k != row);
|
||||
if self.anchor.map(|((k, _), _)| k) == Some(row) {
|
||||
self.anchor = None;
|
||||
}
|
||||
}
|
||||
@@ -101,8 +112,8 @@ impl Selection {
|
||||
/// gives `key`'s row a collapsed caret at `pos` -- a plain click that
|
||||
/// never turns into a drag leaves exactly this and nothing else
|
||||
/// selected.
|
||||
pub fn begin(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) {
|
||||
let rows: Vec<RowKey> = self.rows.keys().copied().collect();
|
||||
pub fn begin(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
|
||||
let rows: Vec<SelKey> = self.rows.keys().copied().collect();
|
||||
for k in rows {
|
||||
if k != key
|
||||
&& let Some(w) = self.rows.get(&k)
|
||||
@@ -118,7 +129,7 @@ impl Selection {
|
||||
|
||||
/// The drag continues, now over `key`'s row at `pos`. See the module
|
||||
/// doc for the anchor-row shortcut.
|
||||
pub fn extend(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) {
|
||||
pub fn extend(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
|
||||
let Some((anchor_key, _anchor_pos)) = self.anchor else {
|
||||
return;
|
||||
};
|
||||
@@ -133,7 +144,7 @@ impl Selection {
|
||||
} else {
|
||||
(key, anchor_key)
|
||||
};
|
||||
let in_range: Vec<RowKey> = self.rows.range(lo..=hi).map(|(&k, _)| k).collect();
|
||||
let in_range: Vec<SelKey> = self.rows.range(lo..=hi).map(|(&k, _)| k).collect();
|
||||
for k in &in_range {
|
||||
let Some(w) = self.rows.get(k).copied() else {
|
||||
continue;
|
||||
@@ -149,7 +160,7 @@ impl Selection {
|
||||
w.edit(ui).select_all();
|
||||
}
|
||||
}
|
||||
let outside: Vec<RowKey> = self
|
||||
let outside: Vec<SelKey> = self
|
||||
.rows
|
||||
.keys()
|
||||
.copied()
|
||||
@@ -162,6 +173,34 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which registered block is under `pos_window`, with the position
|
||||
/// and size that block's own `TextEdit` wants (block-local, the way
|
||||
/// `begin`/`extend` are given them by a block's own pointer handler).
|
||||
///
|
||||
/// For the pointer-captured half of a drag, where the event no longer
|
||||
/// reaches the widget under the finger and the list-level handler has
|
||||
/// to say where the finger is. It asks the render state for each
|
||||
/// block's drawn box rather than doing the arithmetic from the row's
|
||||
/// extent -- the box is what a hit test resolves against anyway, and
|
||||
/// it means this and a block's own handler cannot disagree about
|
||||
/// where a block is. O(blocks loaded), on one frame of a drag.
|
||||
pub fn locate(
|
||||
&self,
|
||||
ui: &impl UiRsc,
|
||||
render: &UiRenderState,
|
||||
pos_window: Vec2,
|
||||
) -> Option<(SelKey, Vec2, Vec2)> {
|
||||
for (&key, w) in &self.rows {
|
||||
let Some(px) = render.window_region(w, ui) else {
|
||||
continue;
|
||||
};
|
||||
if px.contains(pos_window) {
|
||||
return Some((key, pos_window - px.top_left, px.size()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether any row currently has a non-empty selection -- what a fresh
|
||||
/// press consults so `drag` knows whether an early horizontal move is
|
||||
/// "start dragging the selection handle" rather than an ordinary tap.
|
||||
@@ -197,7 +236,7 @@ impl Selection {
|
||||
&mut self,
|
||||
ui: &mut impl UiRsc,
|
||||
list: WeakWidget<List>,
|
||||
row: Option<(RowKey, Vec2, Vec2)>,
|
||||
row: Option<(SelKey, Vec2, Vec2)>,
|
||||
pos_window: Vec2,
|
||||
sense: CursorSense,
|
||||
now: Instant,
|
||||
@@ -340,7 +379,7 @@ mod tests {
|
||||
let list = rsc.ui.widgets.add_strong(List::new(Axis::Y)).weak();
|
||||
|
||||
let mut sel = Selection::new();
|
||||
sel.register(1, field);
|
||||
sel.register((1, 0), field);
|
||||
assert!(sel.gesture.is_idle());
|
||||
|
||||
let render = UiRenderState::new();
|
||||
@@ -351,7 +390,7 @@ mod tests {
|
||||
sel.drag(
|
||||
&mut rsc,
|
||||
list,
|
||||
Some((1, Vec2::ZERO, size)),
|
||||
Some(((1, 0), Vec2::ZERO, size)),
|
||||
Vec2::new(540.0, 700.0),
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
now,
|
||||
@@ -365,7 +404,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_forgets_the_row_and_clears_a_matching_anchor() {
|
||||
fn unregister_forgets_every_block_of_the_row_and_clears_a_matching_anchor() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
@@ -379,9 +418,13 @@ mod tests {
|
||||
.weak();
|
||||
|
||||
let mut sel = Selection::new();
|
||||
sel.register(5, field);
|
||||
sel.anchor = Some((5, Vec2::ZERO));
|
||||
assert_eq!(sel.rows.len(), 1);
|
||||
// Two blocks of the same row, which is what `unregister` has to
|
||||
// take together -- removing only the first is how a freed widget
|
||||
// gets left in this map.
|
||||
sel.register((5, 0), field);
|
||||
sel.register((5, 1), field);
|
||||
sel.anchor = Some(((5, 1), Vec2::ZERO));
|
||||
assert_eq!(sel.rows.len(), 2);
|
||||
|
||||
sel.unregister(5);
|
||||
assert!(sel.rows.is_empty());
|
||||
|
||||
Reference in new issue
Block a user