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
+47
@@ -50,6 +50,12 @@ version = "0.23.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -77,6 +83,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -206,6 +213,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -490,6 +506,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"getopts",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -783,12 +818,24 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
Generated
+41
@@ -47,6 +47,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -173,6 +174,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -425,6 +435,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"getopts",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -661,12 +690,24 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
|||||||
# no need of an async runtime, and RUST.md's brief for this port is
|
# no need of an async runtime, and RUST.md's brief for this port is
|
||||||
# "lightweight" throughout.
|
# "lightweight" throughout.
|
||||||
ureq = { version = "3", features = ["json"] }
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
# The markdown block split (`markdown_blocks`), which has to agree with the
|
||||||
|
# renderer in `iris/transcript-ui` about where a block begins -- so it is
|
||||||
|
# the same parser at the same version, rather than a hand-written splitter
|
||||||
|
# that would drift from it.
|
||||||
|
pulldown-cmark = "0.13.4"
|
||||||
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -7,6 +7,7 @@ pub mod api;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod transcript_cache;
|
pub mod transcript_cache;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! Split a markdown message into its top-level **blocks** -- one
|
||||||
|
//! paragraph, heading, fenced code block, list, table or quote each, as a
|
||||||
|
//! byte slice of the original source.
|
||||||
|
//!
|
||||||
|
//! This exists for streaming. A transcript row used to be one text widget
|
||||||
|
//! holding the whole message, so a single streamed delta re-shaped every
|
||||||
|
//! paragraph of it through the text engine again; the phone's bench v2 put
|
||||||
|
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
|
||||||
|
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
|
||||||
|
//! block now, and a delta that lands in the last block leaves every
|
||||||
|
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
|
||||||
|
//! what that rejected and why the split lives here rather than in the UI
|
||||||
|
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
|
||||||
|
//! keeping it here means iris stays a text renderer that knows nothing
|
||||||
|
//! about markdown.
|
||||||
|
//!
|
||||||
|
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
|
||||||
|
//! renderer's own job, per block -- this deliberately does not build a
|
||||||
|
//! full AST, because nothing needs one yet.
|
||||||
|
//!
|
||||||
|
//! ## Appending is not guaranteed to leave earlier blocks alone
|
||||||
|
//!
|
||||||
|
//! It nearly always does, which is what makes the fast path worth having,
|
||||||
|
//! but markdown has no such rule: appending a "```" line can turn text
|
||||||
|
//! that was three paragraphs into one fenced block, and appending "---"
|
||||||
|
//! under a paragraph turns that paragraph into a heading. So a caller
|
||||||
|
//! taking the O(last block) path **must compare the prefix it is about to
|
||||||
|
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
|
||||||
|
//! it is cheap next to laying the text out again.
|
||||||
|
|
||||||
|
use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||||
|
|
||||||
|
/// What a block is, for a renderer that wants to style or space blocks
|
||||||
|
/// differently. `Other` is deliberately present rather than a panic or a
|
||||||
|
/// silent fallback to `Paragraph`: markdown has more block kinds than this
|
||||||
|
/// list and more get added, and a renderer treating an unknown one as
|
||||||
|
/// prose is right, but it should be able to *tell* that is what it is
|
||||||
|
/// doing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BlockKind {
|
||||||
|
Paragraph,
|
||||||
|
Heading,
|
||||||
|
/// A fenced or indented code block.
|
||||||
|
Code,
|
||||||
|
List,
|
||||||
|
Table,
|
||||||
|
Quote,
|
||||||
|
/// A thematic break, raw HTML, a footnote -- anything with no
|
||||||
|
/// distinguished treatment here.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One top-level block: its kind and the exact source that produced it.
|
||||||
|
/// `source` is a slice of the input with trailing whitespace removed, so
|
||||||
|
/// two splits of the same prefix compare equal even when one of them had a
|
||||||
|
/// delta arriving after it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Block {
|
||||||
|
pub kind: BlockKind,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_of(tag: &Tag) -> BlockKind {
|
||||||
|
match tag {
|
||||||
|
Tag::Paragraph => BlockKind::Paragraph,
|
||||||
|
Tag::Heading { .. } => BlockKind::Heading,
|
||||||
|
Tag::CodeBlock(_) => BlockKind::Code,
|
||||||
|
Tag::List(_) => BlockKind::List,
|
||||||
|
Tag::Table(_) => BlockKind::Table,
|
||||||
|
Tag::BlockQuote(_) => BlockKind::Quote,
|
||||||
|
_ => BlockKind::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options() -> Options {
|
||||||
|
// The same set `transcript-ui`'s renderer parses with, so a block
|
||||||
|
// boundary here and the styling there cannot disagree about what the
|
||||||
|
// source means.
|
||||||
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `src` into its top-level blocks, in source order. An empty or
|
||||||
|
/// whitespace-only input gives no blocks; text the parser does not put
|
||||||
|
/// inside any block (a stray fence marker mid-stream) still comes back,
|
||||||
|
/// as `Other`, rather than being dropped.
|
||||||
|
pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||||
|
let mut out: Vec<Block> = Vec::new();
|
||||||
|
let mut depth = 0usize;
|
||||||
|
let mut kind = BlockKind::Other;
|
||||||
|
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
|
||||||
|
match event {
|
||||||
|
Event::Start(tag) => {
|
||||||
|
if depth == 0 {
|
||||||
|
kind = kind_of(&tag);
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
Event::End(_) => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
push(&mut out, kind, &src[range]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A top-level event that is not part of any block -- a
|
||||||
|
// thematic break, a block of raw HTML. Inside one, it is the
|
||||||
|
// enclosing block's business and this does nothing.
|
||||||
|
_ => {
|
||||||
|
if depth == 0 {
|
||||||
|
push(&mut out, BlockKind::Other, &src[range]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
|
||||||
|
let source = source.trim_end();
|
||||||
|
if source.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.push(Block {
|
||||||
|
kind,
|
||||||
|
source: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many leading blocks of `old` and `new` are identical -- what a
|
||||||
|
/// caller may keep the laid-out widgets for. See the module doc for why
|
||||||
|
/// this is a comparison rather than an assumption.
|
||||||
|
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
|
||||||
|
old.iter().zip(new).take_while(|(a, b)| a == b).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn kinds(src: &str) -> Vec<BlockKind> {
|
||||||
|
split_blocks(src).into_iter().map(|b| b.kind).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_splits_into_its_top_level_blocks() {
|
||||||
|
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
|
||||||
|
assert_eq!(
|
||||||
|
kinds(src),
|
||||||
|
vec![
|
||||||
|
BlockKind::Heading,
|
||||||
|
BlockKind::Paragraph,
|
||||||
|
BlockKind::Code,
|
||||||
|
BlockKind::List
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(blocks[1].source, "First para.");
|
||||||
|
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_input_has_no_blocks() {
|
||||||
|
assert!(split_blocks("").is_empty());
|
||||||
|
assert!(split_blocks(" \n\n ").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property the streaming fast path rests on, in its ordinary
|
||||||
|
/// shape: a delta landing in the last paragraph must leave every
|
||||||
|
/// earlier block byte-identical.
|
||||||
|
#[test]
|
||||||
|
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
|
||||||
|
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
|
||||||
|
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
|
||||||
|
assert_eq!(common_prefix(&before, &after), 2);
|
||||||
|
assert_eq!(before.len(), 3);
|
||||||
|
assert_eq!(after.len(), 3);
|
||||||
|
assert_ne!(before[2], after[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delta that starts a *new* block keeps every old block, including
|
||||||
|
/// the one that was last -- so the fast path appends rather than
|
||||||
|
/// replacing.
|
||||||
|
#[test]
|
||||||
|
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
|
||||||
|
let before = split_blocks("First para.\n\nSecond para.");
|
||||||
|
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
|
||||||
|
assert_eq!(common_prefix(&before, &after), 2);
|
||||||
|
assert_eq!(after.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A code fence arrives one delta at a time and is unterminated for
|
||||||
|
/// most of its life. It must still be *one* block the whole way, or
|
||||||
|
/// every delta would re-split the message into a different number of
|
||||||
|
/// pieces.
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_fence_is_one_block_while_it_streams() {
|
||||||
|
for src in [
|
||||||
|
"Here:\n\n```rust\n",
|
||||||
|
"Here:\n\n```rust\nfn main() {\n",
|
||||||
|
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
kinds(src),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code],
|
||||||
|
"{src:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the fast path had no reason to touch, and the reason
|
||||||
|
/// `common_prefix` is a comparison rather than an assumption:
|
||||||
|
/// appending can rewrite what came before. `---` under a paragraph
|
||||||
|
/// turns that paragraph into a setext heading, so the block that was
|
||||||
|
/// already laid out is not the block it is now.
|
||||||
|
#[test]
|
||||||
|
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
|
||||||
|
let before = split_blocks("Not a heading\n\nsecond");
|
||||||
|
let after = split_blocks("Not a heading\n\nsecond\n---");
|
||||||
|
assert_eq!(before[1].kind, BlockKind::Paragraph);
|
||||||
|
assert_eq!(after[1].kind, BlockKind::Heading);
|
||||||
|
assert_eq!(
|
||||||
|
common_prefix(&before, &after),
|
||||||
|
1,
|
||||||
|
"the rewritten block must not be reported as keepable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_thematic_break_is_its_own_block() {
|
||||||
|
assert_eq!(
|
||||||
|
kinds("one\n\n---\n\ntwo"),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-9
@@ -191,14 +191,17 @@ does not repeat it again by hand.
|
|||||||
|
|
||||||
## What is not started at all
|
## What is not started at all
|
||||||
|
|
||||||
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
- **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
|
||||||
colours a `.md` file or fence for the highlighter, but does not build the
|
into its *top-level* blocks -- heading, paragraph, fence, list, table,
|
||||||
block tree (headings, lists, tables, fences as distinct nodes) that a
|
quote -- with each block's own source, which is what a renderer needs to
|
||||||
renderer walks to lay out prose versus code versus a table.
|
lay out prose versus code and what lets a streamed delta re-lay out one
|
||||||
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
block instead of the message (docs/RUST.md's Task B). What it
|
||||||
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
deliberately does **not** build is the tree below that: nested list
|
||||||
UI layer will want its own block parser or a crate for it, decided
|
items, table cells, inline spans. Inline styling is still the renderer's
|
||||||
alongside the framework choice in RUST.md.
|
own job per block (`iris/transcript-ui/src/markdown.rs`), and nothing
|
||||||
|
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
|
||||||
|
for a full CommonMark AST is Compose rendering plumbing, not something
|
||||||
|
to port as-is.
|
||||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||||
it flattens a row into bounded units for a *specific* lazy-list
|
it flattens a row into bounded units for a *specific* lazy-list
|
||||||
framework's composition cost, which is a fact about that framework
|
framework's composition cost, which is a fact about that framework
|
||||||
@@ -209,5 +212,5 @@ does not repeat it again by hand.
|
|||||||
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
||||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||||
same way it always has). From `client-core/` directly: `cargo test`
|
same way it always has). From `client-core/` directly: `cargo test`
|
||||||
(109 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
|
(119 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
|
||||||
this writing (2026-09-06).
|
this writing (2026-09-06).
|
||||||
@@ -8,6 +8,36 @@ capability that moved. Small and trivial changes do not go here.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
it helps judge the change without the session that made it. Newest first.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
|
||||||
|
|
||||||
|
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
|
||||||
|
It makes one per top-level markdown block now -- heading, paragraph,
|
||||||
|
fenced code, list, table -- in a `Span::down`, because a streamed delta
|
||||||
|
into a single buffer re-shaped the whole message through parley on every
|
||||||
|
event. `client_core::markdown_blocks::split_blocks` does the splitting;
|
||||||
|
`row::RowBlocks::apply_delta` updates the block a delta lands in and
|
||||||
|
leaves the rest of the message's layout alone.
|
||||||
|
|
||||||
|
**The change to judge, since it is what a reader feels**:
|
||||||
|
`Selection` is keyed by `SelKey = (RowKey, u32)` -- a row and a block --
|
||||||
|
so **a block, not a row, is the unit a selection steps in**. A drag still
|
||||||
|
runs from a reply into the tool output beneath it and copies as one
|
||||||
|
thing; what changed is that the row under the finger is filled in block by
|
||||||
|
block rather than all at once, which is if anything closer to what the
|
||||||
|
old shortcut in `Selection`'s module doc was apologising for. `register`
|
||||||
|
takes a `SelKey`; `unregister` still takes a `RowKey` and now drops every
|
||||||
|
block of it (dropping only the first is how a freed widget gets left in
|
||||||
|
the map -- the shape docs/REVIEW-2026-09-06.md's finding 1 called out).
|
||||||
|
|
||||||
|
`Selection::locate(ui, render, pos_window)` is new: which block is under a
|
||||||
|
window position, with that block's own local position and size. The
|
||||||
|
list-level handler uses it for the pointer-captured half of a drag,
|
||||||
|
instead of computing a row-local position from `List::extent`.
|
||||||
|
|
||||||
|
`row::build_row` returns `(RowKey, StrongWidget, Option<RowBlocks>)` --
|
||||||
|
the third is the per-block state a caller keeps only for the row a reply
|
||||||
|
is streaming into, and is `None` for a tool run, which never streams.
|
||||||
|
|
||||||
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
|
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
|
||||||
|
|
||||||
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
|
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
|
||||||
|
|||||||
+17
-2
@@ -643,8 +643,23 @@ do not duplicate it there.
|
|||||||
|
|
||||||
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
|
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
|
||||||
|
|
||||||
- [ ] **Streaming a delta into a long message costs a full text layout of
|
- [x] **Streaming a delta into a long message costs a full text layout of
|
||||||
that message.** Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
|
that message.** **Done 2026-09-06** -- a row is a column of one
|
||||||
|
`TextEdit` per markdown block (`client_core::markdown_blocks`,
|
||||||
|
`row::RowBlocks::apply_delta`), so a delta re-shapes the last block and
|
||||||
|
keeps every earlier block's layout. A block is the selection unit now
|
||||||
|
(`Selection`'s `SelKey`); selection across blocks and rows still works,
|
||||||
|
checked on the emulator with a real long-press drag. Pass condition met
|
||||||
|
in `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`:
|
||||||
|
a delta into a 100-paragraph reply redraws the same widget count as one
|
||||||
|
into a one-paragraph reply (30 either way). 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. docs/RUST.md's Task B box has the detail and the two dead
|
||||||
|
ends. **The phone is the measurement that decides it** -- these are
|
||||||
|
emulator numbers and only the ratio transfers.
|
||||||
|
|
||||||
|
The original entry, for the record: Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
|
||||||
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
|
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
|
||||||
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
|
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
|
||||||
the last row, but that row is the growing message, and replacing it
|
the last row, but that row is the growing message, and replacing it
|
||||||
|
|||||||
+66
-5
@@ -107,17 +107,78 @@ Rig fix on the way past: `iris/android-app/run-bench.sh` polled logcat for
|
|||||||
and printed a report that had never been run. It polls for the report's
|
and printed a report that had never been run. It polls for the report's
|
||||||
own first line now.
|
own first line now.
|
||||||
|
|
||||||
### Bench, before Task B (emulator, 2026-09-06)
|
### Task B, closed 2026-09-06: a streamed delta costs one markdown block
|
||||||
|
|
||||||
|
A transcript 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 trailed Compose on Iris's phone. A row is a **column of
|
||||||
|
one `TextEdit` per top-level markdown block** now, and a delta that lands
|
||||||
|
in the last block is one `set_with_spans` on that block.
|
||||||
|
|
||||||
|
- **`client-core/src/markdown_blocks.rs`** is the split: `split_blocks`
|
||||||
|
(top-level blocks with their source, via the same `pulldown-cmark` the
|
||||||
|
renderer parses with, so the two cannot disagree about where a block
|
||||||
|
starts) and `common_prefix`. Seven tests, including the one that says
|
||||||
|
the fast path must **compare** rather than assume: appending `---` under
|
||||||
|
a paragraph turns that paragraph into a heading, so an already
|
||||||
|
laid-out block is not always still what it was.
|
||||||
|
- **`iris/transcript-ui/src/row.rs`** builds the column and owns
|
||||||
|
`RowBlocks::apply_delta`; **`lib.rs`** keeps the *tail* row's blocks
|
||||||
|
(`TranscriptScreen::tail`) since that is the only row a delta reaches.
|
||||||
|
- **A block is the selection unit**, not a row: `Selection` is keyed by
|
||||||
|
`SelKey = (RowKey, u32)`, which compares in reading order at both
|
||||||
|
levels so every range query in that file is unchanged. The list-level
|
||||||
|
(pointer-captured) half of a drag resolves the block under the finger
|
||||||
|
from its drawn box (`Selection::locate`) instead of doing arithmetic
|
||||||
|
from the row's extent.
|
||||||
|
|
||||||
|
**Pass condition, met**: `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`
|
||||||
|
(`transcript-ui/src/lib.rs`) drives a real `UiRenderState` and asserts the
|
||||||
|
`Widget::draw` count for one delta into a 100-paragraph (3,000+ character)
|
||||||
|
reply equals the count for the same delta into a one-paragraph reply.
|
||||||
|
**30 either way.** It is a real test, not a tautology: it read **630
|
||||||
|
against 30** at three points on the way -- once because `Span`'s measure
|
||||||
|
pass redrew every child, and once because `build_tree` did not seed
|
||||||
|
`tail`, so the first delta after opening a screen took the rebuild path
|
||||||
|
with nothing on screen or in `take_rebuilds()` to say so.
|
||||||
|
|
||||||
|
Two things tried and dropped, so the next session does not redo them.
|
||||||
|
`Painter::measure` (a container asking a clean child for its size instead
|
||||||
|
of drawing it provisionally) fixed one of the 630s but the test passes
|
||||||
|
without it once the `tail` seeding is right, so it was removed rather than
|
||||||
|
kept on speculation. And the emulator's own numbers say the remaining
|
||||||
|
cost is not in the block split.
|
||||||
|
|
||||||
|
**Verified on the emulator** beyond the counter: the transcript draws its
|
||||||
|
blocks with their own spacing (heading, prose, fence), and
|
||||||
|
`ui-trace record --do "holddrag 300 700 700 1000 700 600"` logs
|
||||||
|
`iris selection: begin at row (3187, 0)` then `extend to row (3187, 1)`
|
||||||
|
with the highlight crossing from the heading into the code block -- a
|
||||||
|
selection that spans blocks, which is what the re-key had to keep.
|
||||||
|
|
||||||
|
### Bench, stream phase, before and after Task B (emulator, 2026-09-06)
|
||||||
|
|
||||||
`iris/android-app/build-apk.sh debug --abi x86_64 --features
|
`iris/android-app/build-apk.sh debug --abi x86_64 --features
|
||||||
"transcript-screen bench force-gles"` + `run-bench.sh`, this checkout's
|
"transcript-screen bench force-gles"` + `run-bench.sh`, this checkout's
|
||||||
AVD. Emulator absolutes transfer nothing; the before/after ratio on the
|
AVD. Emulator absolutes transfer nothing; the before/after ratio on the
|
||||||
same emulator does.
|
same emulator does.
|
||||||
|
|
||||||
stream: 202 frames over 21.0s
|
Same AVD, same fixture, same build flags, 20 minutes apart. Emulator
|
||||||
late: 197 (97.5%)
|
absolutes transfer nothing; the ratio does.
|
||||||
total p50 61.5ms p90 211.7ms p99 342.6ms
|
|
||||||
worst 403.6ms
|
before after
|
||||||
|
stream: 202 frames over 21.0s stream: 293 frames over 21.0s
|
||||||
|
late: 197 (97.5%) late: 285 (97.3%)
|
||||||
|
p50 61.5ms p50 54.5ms (-11%)
|
||||||
|
p90 211.7ms p90 113.1ms (-47%)
|
||||||
|
p99 342.6ms p99 137.4ms (-60%)
|
||||||
|
worst 403.6ms worst 143.0ms (-65%)
|
||||||
|
|
||||||
|
The tail is where the whole-message re-layout lived, and it is where the
|
||||||
|
change shows: 91 more frames delivered in the same 21 seconds. The p50
|
||||||
|
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
|
||||||
|
statement about her device.
|
||||||
|
|
||||||
|
|
||||||
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit
|
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit
|
||||||
|
|||||||
Generated
+1
@@ -721,6 +721,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
|||||||
Generated
+1
@@ -745,6 +745,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
|||||||
+156
-19
@@ -66,6 +66,14 @@ pub struct TranscriptScreen {
|
|||||||
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
||||||
/// [`Self::take_rebuilds`].
|
/// [`Self::take_rebuilds`].
|
||||||
rebuilds: std::cell::Cell<usize>,
|
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 {
|
impl TranscriptScreen {
|
||||||
@@ -77,8 +85,39 @@ impl TranscriptScreen {
|
|||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
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.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
|
/// Apply the effect of one more folded event without rebuilding the
|
||||||
@@ -134,17 +173,34 @@ impl TranscriptScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RowDiff::ReplaceLast { common } => {
|
RowDiff::ReplaceLast { common } => {
|
||||||
// Only the tail row's content changed -- rebuild that one
|
// Only the tail row's content changed. First try the
|
||||||
// row and swap it in place, keeping every row before it
|
// delta path: the row is a column of one widget per
|
||||||
// untouched.
|
// 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 old_key = row::row_key(&old_rows[common].key());
|
||||||
let (new_key, widget) =
|
let new_key = row::row_key(&new_rows[common].key());
|
||||||
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
|
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
|
||||||
if new_key != old_key {
|
for row in &new_rows[common + 1..] {
|
||||||
self.selection.borrow_mut().unregister(old_key);
|
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));
|
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
|
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..] {
|
for row in &new_rows[common + 1..] {
|
||||||
self.push_row(rsc, row);
|
self.push_row(rsc, row);
|
||||||
}
|
}
|
||||||
@@ -162,6 +218,7 @@ impl TranscriptScreen {
|
|||||||
self.rebuilds.set(self.rebuilds.get() + 1);
|
self.rebuilds.set(self.rebuilds.get() + 1);
|
||||||
self.selection.borrow_mut().clear();
|
self.selection.borrow_mut().clear();
|
||||||
(self.list)(rsc).clear();
|
(self.list)(rsc).clear();
|
||||||
|
*self.tail.borrow_mut() = None;
|
||||||
for row in &new_rows {
|
for row in &new_rows {
|
||||||
self.push_row(rsc, row);
|
self.push_row(rsc, row);
|
||||||
}
|
}
|
||||||
@@ -213,9 +270,18 @@ where
|
|||||||
let selection = Rc::new(RefCell::new(Selection::new()));
|
let selection = Rc::new(RefCell::new(Selection::new()));
|
||||||
let list = List::new(Axis::Y).add(rsc);
|
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 {
|
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));
|
list(rsc).push_back(ListRow::new(key, widget));
|
||||||
|
tail = blocks.map(|b| (key, b));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
|
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
|
||||||
@@ -242,15 +308,13 @@ where
|
|||||||
list.on(
|
list.on(
|
||||||
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop,
|
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop,
|
||||||
move |ctx, rsc| {
|
move |ctx, rsc| {
|
||||||
let pos = ctx.data.pos;
|
// Which *block* the finger is over, resolved from its
|
||||||
let row = list(rsc).key_at(pos.y).and_then(|key| {
|
// drawn box rather than from the row's extent -- a row is
|
||||||
let (top, bottom) = list(rsc).extent(key)?;
|
// a column of one widget per markdown block now, and the
|
||||||
Some((
|
// block is what `Selection` selects (`SelKey`).
|
||||||
key,
|
let row = selection
|
||||||
Vec2::new(pos.x, pos.y - top),
|
.borrow()
|
||||||
Vec2::new(ctx.data.size.x, bottom - top),
|
.locate(&*rsc, ctx.data.render, ctx.data.cursor.pos);
|
||||||
))
|
|
||||||
});
|
|
||||||
selection.borrow_mut().drag(
|
selection.borrow_mut().drag(
|
||||||
rsc,
|
rsc,
|
||||||
list,
|
list,
|
||||||
@@ -274,6 +338,7 @@ where
|
|||||||
|
|
||||||
(
|
(
|
||||||
TranscriptScreen {
|
TranscriptScreen {
|
||||||
|
tail: RefCell::new(tail),
|
||||||
list,
|
list,
|
||||||
composer,
|
composer,
|
||||||
selection,
|
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]
|
#[test]
|
||||||
fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() {
|
fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() {
|
||||||
use client_core::transcript_fold::group_tool_runs;
|
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));
|
let surviving_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(4));
|
||||||
screen.selection.borrow_mut().begin(
|
screen.selection.borrow_mut().begin(
|
||||||
&mut rsc,
|
&mut rsc,
|
||||||
surviving_key,
|
(surviving_key, 0),
|
||||||
Vec2::ZERO,
|
Vec2::ZERO,
|
||||||
Vec2::new(10.0, 10.0),
|
Vec2::new(10.0, 10.0),
|
||||||
);
|
);
|
||||||
|
|||||||
+198
-29
@@ -1,11 +1,18 @@
|
|||||||
//! One `iris::widget::list::ListRow` per folded transcript row
|
//! One `iris::widget::list::ListRow` per folded transcript row
|
||||||
//! (`client_core::transcript_fold::TranscriptRow`). Each row's whole text
|
//! (`client_core::transcript_fold::TranscriptRow`). A row is a **column of
|
||||||
//! -- headings, paragraphs, inline styling -- goes through `markdown` into
|
//! one `TextEdit` per top-level markdown block** (paragraph, heading,
|
||||||
//! **one** `TextEdit`, which is what makes it one thing `Selection`
|
//! fence, list, table -- `client_core::markdown_blocks`), each rendered
|
||||||
//! (`selection.rs`) can select and what lets it wrap and scroll as a
|
//! with `markdown`'s inline spans, so that RUST.md's "hard to get back"
|
||||||
//! single buffer, matching RUST.md's "hard to get back" behaviour 2 (rich
|
//! behaviour 2 (rich inline text) still holds within a block and
|
||||||
//! inline text) and half of behaviour 1 (selectable within a row; across
|
//! behaviour 1 (selection) runs across blocks and rows alike through
|
||||||
//! rows is `selection.rs`'s job).
|
//! `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
|
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
|
||||||
//! `client_core::transcript_fold::group_tool_runs`) is the row that proves
|
//! `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`.
|
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
|
||||||
|
|
||||||
use crate::markdown::render_markdown;
|
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 client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
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
|
/// 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
|
/// 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
|
/// (`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
|
/// 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.
|
/// 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 {
|
match item {
|
||||||
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
|
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
|
||||||
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), 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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build one `TextEdit` from a sender label plus markdown source, register
|
/// The per-block text widgets of one row, kept by `TranscriptScreen` for
|
||||||
/// it with `selection` under `key`, and wire the pointer handlers that
|
/// the row a reply is streaming into, so a delta can replace the block it
|
||||||
/// drive `Selection::drag` -- shared by every row variant below, since a
|
/// lands in instead of re-shaping the whole message
|
||||||
/// selectable row is always "one TextEdit plus this wiring" regardless of
|
/// (docs/DECISIONS.md, 2026-09-06). Nothing else needs it: a row that is
|
||||||
/// what folded it. `list` is threaded through so that same drag can pan
|
/// not the tail never changes.
|
||||||
/// the list instead of selecting, per `Selection::drag`'s own doc.
|
pub struct RowBlocks {
|
||||||
fn build_text_row<Rsc: HasEvents>(
|
/// 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,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<List>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: RowKey,
|
key: SelKey,
|
||||||
sender: Option<&str>,
|
source: &str,
|
||||||
markdown_src: &str,
|
) -> WeakWidget<TextEdit>
|
||||||
) -> StrongWidget
|
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost,
|
||||||
{
|
{
|
||||||
let (text, spans) = render_markdown(markdown_src, BASE_SIZE);
|
let (text, spans) = render_markdown(source, BASE_SIZE);
|
||||||
let field = wtext(text)
|
let field = wtext(text)
|
||||||
.spans(spans)
|
.spans(spans)
|
||||||
.editable(EditMode::MultiLine)
|
.editable(EditMode::MultiLine)
|
||||||
@@ -137,7 +180,7 @@ where
|
|||||||
|
|
||||||
field
|
field
|
||||||
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
// `| 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
|
// gesture's *first* frame (`PressStart`, or a `Pressing` that
|
||||||
// missed it -- `DragGesture::handle`'s idle-recovery branch); once
|
// missed it -- `DragGesture::handle`'s idle-recovery branch); once
|
||||||
// it commits, `DragGesture` takes pointer capture on `list`'s own
|
// it commits, `DragGesture` takes pointer capture on `list`'s own
|
||||||
@@ -161,6 +204,38 @@ where
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.add(rsc);
|
.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
|
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
|
||||||
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
|
// 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),
|
None => Span::empty(Dir::DOWN).add(rsc),
|
||||||
};
|
};
|
||||||
|
|
||||||
(header, field.width(rest(1)))
|
let widget = (header, column.width(rest(1)))
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.gap(dp(4))
|
.gap(dp(4))
|
||||||
.pad(dp(10))
|
.pad(dp(10))
|
||||||
.add_strong(rsc)
|
.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>(
|
fn build_single<Rsc: HasEvents>(
|
||||||
@@ -192,7 +346,7 @@ fn build_single<Rsc: HasEvents>(
|
|||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: RowKey,
|
key: RowKey,
|
||||||
item: &TranscriptItem,
|
item: &TranscriptItem,
|
||||||
) -> StrongWidget
|
) -> (StrongWidget, RowBlocks)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost,
|
||||||
{
|
{
|
||||||
@@ -249,8 +403,15 @@ where
|
|||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
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 };
|
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(
|
let content = build_content(
|
||||||
@@ -299,18 +460,26 @@ pub fn build_row<Rsc: HasEvents>(
|
|||||||
list: WeakWidget<List>,
|
list: WeakWidget<List>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
row: &FoldedRow,
|
row: &FoldedRow,
|
||||||
) -> (RowKey, StrongWidget)
|
) -> (RowKey, StrongWidget, Option<RowBlocks>)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost,
|
||||||
{
|
{
|
||||||
match row {
|
match row {
|
||||||
FoldedRow::Single(item) => {
|
FoldedRow::Single(item) => {
|
||||||
let key = row_key(&item.key());
|
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) => {
|
FoldedRow::Tools(calls) => {
|
||||||
let key = row_key(&calls[0].key());
|
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 iris::prelude::*;
|
||||||
use std::{collections::BTreeMap, time::Instant};
|
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 {
|
pub struct Selection {
|
||||||
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
|
rows: BTreeMap<SelKey, WeakWidget<TextEdit>>,
|
||||||
anchor: Option<(RowKey, Vec2)>,
|
anchor: Option<(SelKey, Vec2)>,
|
||||||
/// One gesture shared by every row's drag handler -- RUST.md's I5
|
/// 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
|
/// gesture conflict (a row's own `click_or_drag()` and a list-level
|
||||||
/// pan wanting the same touch gesture). See `drag` below, and
|
/// 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
|
/// assertion) -- a derived handle that silently outlives what it
|
||||||
/// points to; the next caller adding a third row-keyed side table
|
/// points to; the next caller adding a third row-keyed side table
|
||||||
/// should read both.
|
/// 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);
|
self.rows.insert(key, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +97,12 @@ impl Selection {
|
|||||||
self.anchor = None;
|
self.anchor = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unregister(&mut self, key: RowKey) {
|
/// Forgets every block of one row -- a row is registered block by
|
||||||
self.rows.remove(&key);
|
/// block, so its removal has to take all of them, and taking only the
|
||||||
if self.anchor.map(|(k, _)| k) == Some(key) {
|
/// 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;
|
self.anchor = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,8 +112,8 @@ impl Selection {
|
|||||||
/// gives `key`'s row a collapsed caret at `pos` -- a plain click that
|
/// gives `key`'s row a collapsed caret at `pos` -- a plain click that
|
||||||
/// never turns into a drag leaves exactly this and nothing else
|
/// never turns into a drag leaves exactly this and nothing else
|
||||||
/// selected.
|
/// selected.
|
||||||
pub fn begin(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) {
|
pub fn begin(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
|
||||||
let rows: Vec<RowKey> = self.rows.keys().copied().collect();
|
let rows: Vec<SelKey> = self.rows.keys().copied().collect();
|
||||||
for k in rows {
|
for k in rows {
|
||||||
if k != key
|
if k != key
|
||||||
&& let Some(w) = self.rows.get(&k)
|
&& 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
|
/// The drag continues, now over `key`'s row at `pos`. See the module
|
||||||
/// doc for the anchor-row shortcut.
|
/// 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 {
|
let Some((anchor_key, _anchor_pos)) = self.anchor else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -133,7 +144,7 @@ impl Selection {
|
|||||||
} else {
|
} else {
|
||||||
(key, anchor_key)
|
(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 {
|
for k in &in_range {
|
||||||
let Some(w) = self.rows.get(k).copied() else {
|
let Some(w) = self.rows.get(k).copied() else {
|
||||||
continue;
|
continue;
|
||||||
@@ -149,7 +160,7 @@ impl Selection {
|
|||||||
w.edit(ui).select_all();
|
w.edit(ui).select_all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outside: Vec<RowKey> = self
|
let outside: Vec<SelKey> = self
|
||||||
.rows
|
.rows
|
||||||
.keys()
|
.keys()
|
||||||
.copied()
|
.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
|
/// Whether any row currently has a non-empty selection -- what a fresh
|
||||||
/// press consults so `drag` knows whether an early horizontal move is
|
/// press consults so `drag` knows whether an early horizontal move is
|
||||||
/// "start dragging the selection handle" rather than an ordinary tap.
|
/// "start dragging the selection handle" rather than an ordinary tap.
|
||||||
@@ -197,7 +236,7 @@ impl Selection {
|
|||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut impl UiRsc,
|
ui: &mut impl UiRsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<List>,
|
||||||
row: Option<(RowKey, Vec2, Vec2)>,
|
row: Option<(SelKey, Vec2, Vec2)>,
|
||||||
pos_window: Vec2,
|
pos_window: Vec2,
|
||||||
sense: CursorSense,
|
sense: CursorSense,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
@@ -340,7 +379,7 @@ mod tests {
|
|||||||
let list = rsc.ui.widgets.add_strong(List::new(Axis::Y)).weak();
|
let list = rsc.ui.widgets.add_strong(List::new(Axis::Y)).weak();
|
||||||
|
|
||||||
let mut sel = Selection::new();
|
let mut sel = Selection::new();
|
||||||
sel.register(1, field);
|
sel.register((1, 0), field);
|
||||||
assert!(sel.gesture.is_idle());
|
assert!(sel.gesture.is_idle());
|
||||||
|
|
||||||
let render = UiRenderState::new();
|
let render = UiRenderState::new();
|
||||||
@@ -351,7 +390,7 @@ mod tests {
|
|||||||
sel.drag(
|
sel.drag(
|
||||||
&mut rsc,
|
&mut rsc,
|
||||||
list,
|
list,
|
||||||
Some((1, Vec2::ZERO, size)),
|
Some(((1, 0), Vec2::ZERO, size)),
|
||||||
Vec2::new(540.0, 700.0),
|
Vec2::new(540.0, 700.0),
|
||||||
CursorSense::Pressing(CursorButton::Left),
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
now,
|
now,
|
||||||
@@ -365,7 +404,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
ui: UiData::default(),
|
||||||
};
|
};
|
||||||
@@ -379,9 +418,13 @@ mod tests {
|
|||||||
.weak();
|
.weak();
|
||||||
|
|
||||||
let mut sel = Selection::new();
|
let mut sel = Selection::new();
|
||||||
sel.register(5, field);
|
// Two blocks of the same row, which is what `unregister` has to
|
||||||
sel.anchor = Some((5, Vec2::ZERO));
|
// take together -- removing only the first is how a freed widget
|
||||||
assert_eq!(sel.rows.len(), 1);
|
// 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);
|
sel.unregister(5);
|
||||||
assert!(sel.rows.is_empty());
|
assert!(sel.rows.is_empty());
|
||||||
|
|||||||
Reference in new issue
Block a user