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:
irisandClaude Fable 5.1 committed 2026-09-06 17:33:37 -04:00
1 parent 167862ca1b
commit e1030d69f6
14 files changed
+871 -82

No files matched your search

+1
View File
@@ -721,6 +721,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"pulldown-cmark",
"serde",
"serde_json",
"ureq",
+1
View File
@@ -745,6 +745,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"pulldown-cmark",
"serde",
"serde_json",
"ureq",
+156 -19
View File
@@ -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
View File
@@ -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,
)
}
}
}
+61 -18
View File
@@ -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());