//! One `iris::widget::list::LazyItem` per folded transcript row //! (`crate::client::transcript_fold::TranscriptRow`). A row is a **column of //! one `TextEdit` per top-level markdown block** (paragraph, heading, //! fence, list, table -- `crate::client::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 //! `crate::client::transcript_fold::group_tool_runs`) is the row that proves //! behaviour 3's "hold the edge nearest the tap" on expand: tapping its //! header calls `LazySpan::note_tap` at the row's own on-screen position //! (read back from `LazySpan::extent`, since the tap event only knows its //! position *within* this row) before toggling a `WidgetPtr` between the //! collapsed summary and the full detail -- the same two-step contract //! `lazy_span.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`. use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks}; use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label}; use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block}; use crate::ui::selection::{SelKey, Selection}; use crate::ui::tap::{hold_edge, on_tap}; use crate::ui::tool::ToolRow; use iris::prelude::*; use std::{cell::RefCell, rc::Rc}; /// 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 /// regardless of which row's base size surrounds it. pub const BASE_SIZE: f32 = 16.0; /// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `LazySpan` wants. /// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into /// one -- collisions are not a correctness risk worth guarding against here /// (a `DefaultHasher` collision across the run ids one session produces is /// astronomically unlikely, and the consequence of one would only be two /// tool-call rows sharing a list slot, not data loss), and the high bit is /// forced on so a hashed key can never collide with a real sequence number /// (this build never produces 2^63 events). pub fn row_key(key: &crate::client::transcript_fold::ItemKey) -> RowKey { use crate::client::transcript_fold::ItemKey; use std::hash::{Hash, Hasher}; match key { ItemKey::Seq(seq) => *seq, ItemKey::RunId(id) => { let mut h = std::collections::hash_map::DefaultHasher::new(); id.hash(&mut h); h.finish() | (1 << 63) } } } /// 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. 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()), TranscriptItem::ErrorMsg { message, .. } => (Some("Error"), message.clone()), TranscriptItem::CommandRow { text, .. } => (Some("Command"), format!("`/{text}`")), TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()), TranscriptItem::Note { text, .. } => (None, text.clone()), TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()), // Epoch seconds as-is until the port has a relative-time formatter // (P1); the Compose `LimitRow` draws it as a countdown. TranscriptItem::LimitNote { resets_at, .. } => ( None, match resets_at { Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"), None => "_Usage limit reached._".to_string(), }, ), TranscriptItem::CompactedNote { pre_tokens, post_tokens, .. } => ( None, match (pre_tokens, post_tokens) { (Some(pre), Some(post)) => format!("_Compacted: {pre} -> {post} tokens._"), _ => "_Compacted._".to_string(), }, ), TranscriptItem::ImageItem { r#ref, .. } => (None, format!("_[image: {ref}]_")), TranscriptItem::QuestionCard(card) => (Some("Question"), question_markdown(card)), TranscriptItem::ToolRun { tool, input, output, .. } => (Some(tool.as_str()), tool_call_markdown(tool, input, output)), } } fn question_markdown(card: &QuestionCard) -> String { let mut out = card.prompt.clone(); for opt in &card.options { out.push_str(&format!("\n- {}", opt.label)); } out } fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String { let mut out = format!("**{tool}**\n\n```\n{input}\n```"); if !output.is_empty() { out.push_str(&format!("\n\n```\n{output}\n```")); } out } /// 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 /// `crate::client::markdown_blocks`' module doc for why this is a /// comparison and not an assumption. blocks: Vec, fields: Vec>, /// Each block's links, shared with its own tap handler so a delta /// replaces what the handler reads instead of re-registering it. /// One entry per field, which `apply_delta` asserts. links: Vec>>>, column: WeakWidget, /// 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, /// Whether this row draws less than the whole message /// ([`cap_message`]). A delta cannot be appended to a capped row -- /// the new text would go on *below* the "Show all" that says it is /// hidden -- so [`RowBlocks::apply_delta`] refuses one and the caller /// rebuilds instead. /// /// Never `true` for the row a reply is actually streaming into: the /// live tail is built uncapped ([`build_row`]'s `cap`), which is what /// keeps the refusal from costing anything in practice. This field is /// the belt to that braces. capped: bool, } /// 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 { let blocks = split_blocks(markdown_src); if blocks.is_empty() { vec![Block { kind: BlockKind::Paragraph, source: markdown_src.to_string(), }] } else { blocks } } /// `blocks` cut to what a row draws, with the line count of the **whole** /// message; `None` when all of it fits. /// /// A message is capped for the same reason a tool's output is: one row can /// be a hundred kilobytes of text, all of it shaped and rasterised whether /// or not it is on screen, and a reader scrolling past a wall of it wanted /// the next message anyway. Iris asked for it on 2026-09-08 -- "also do it /// for messages (both user and agent) please, they're desperately needed /// for long messages". /// /// The cut prefers a **block boundary**, because a message is markdown and /// a whole paragraph is a smaller version of a message in a way that half /// a paragraph is not. Where one block is over the bound by itself -- the /// reply that is one enormous fence -- that block is truncated instead of /// being dropped or drawn whole: dropping it would leave a row saying /// nothing, and a truncated fence still renders as a fence, since the /// renderer already knows the block's kind and pulldown-cmark closes an /// unterminated one at the end of its input. fn cap_message(blocks: Vec, cap: bool) -> (Vec, Option) { let total = || blocks.iter().map(|b| b.source.lines().count()).sum(); if !cap { return (blocks, None); } let mut kept = Vec::with_capacity(blocks.len()); let (mut lines_left, mut bytes_left) = (MESSAGE_LINES, MESSAGE_BYTES); for block in &blocks { if lines_left == 0 || bytes_left == 0 { return (kept, Some(total())); } // `cut` is the test as well as the cutter: asking it whether this // block fits in what is left is the same question, answered once, // so the walk cannot disagree with the bound it is walking to. match cut(&block.source, lines_left, bytes_left) { // Over the bound by itself, with nothing kept yet: truncate, // since dropping it would leave the row saying nothing. Some((head, _)) if kept.is_empty() => { kept.push(Block { kind: block.kind, source: head.to_string(), }); return (kept, Some(total())); } // Over the bound with something already kept: stop on the // boundary rather than half-drawing this one. A block // truncated to its opening line is a *worse* answer than no // block -- a fence cut to its own ``` renders as an empty // panel, which reads as a rendering fault rather than as a // cap (seen 2026-09-08 with the bound wound down to three // lines to look at it). Some(_) => return (kept, Some(total())), None => { lines_left -= block.source.lines().count().min(lines_left); bytes_left -= block.source.len().min(bytes_left); kept.push(block.clone()); } } } (kept, None) } /// A message's own text, kept so that asking for the whole of a capped row /// can rebuild it. `Rc` rather than a copy per closure: the source of a /// long message is the largest string in the row, and the tap handler /// would otherwise hold a second one for the lifetime of the row. struct RowSource { sender: Option, markdown: String, } /// The room a fence or a table's text gets inside its panel, and the /// gap between a quote's bar and its words. `CodeFence.kt` charges the /// renderer's `codeBlock` padding inside the tinted box and 8dp above and /// below it; the vertical half is `BLOCK_GAP_DP`'s job here, since the /// column already separates blocks. const FRAME_PAD_DP: f32 = 10.0; /// The bar down a quote's left edge. const QUOTE_BAR_DP: f32 = 3.0; /// A verbatim panel's corner, matching the renderer's own rounded fence. const FRAME_RADIUS_DP: f32 = 8.0; /// One block's own `TextEdit`, registered with `selection` under /// `(row, block)` and wired to `Selection::drag` -- the block is the /// selection unit (`selection::SelKey`) -- plus whatever /// [`BlockFrame`] its kind is drawn in. /// /// Returns the field (which `apply_delta` writes into), the widget the /// column actually holds (the field, or the field inside its frame), and /// the block's links, shared with the tap handler so a delta can replace /// them without rebuilding the handler. fn build_block( rsc: &mut Rsc, list: WeakWidget, selection: Rc>, key: SelKey, block: &Block, ) -> (WeakWidget, StrongWidget, Rc>>) where Rsc::State: FocusHost + OpenUrl, { let frame = frame_of(block.kind); let rendered = render_block(block, BASE_SIZE); let links = Rc::new(RefCell::new(rendered.links)); let verbatim = matches!(frame, BlockFrame::Verbatim { .. }); let field = wtext(rendered.text) .spans(rendered.spans) .editable(EditMode::MultiLine) .text_align(Align::LEFT) // A fence and a table say what they mean by where their // characters sit, so they pan sideways rather than wrap // (`CodeFence.kt`'s `horizontalScroll`) -- and a table is padded // in *characters*, which only lines up in a monospace face. .wrap(!verbatim) .family(if verbatim { Family::Monospace } else { Family::SansSerif }) .size(BASE_SIZE) .color(match frame { BlockFrame::Quote => crate::ui::markdown::QUOTE_TEXT_COLOR, _ => crate::ui::markdown::TEXT_COLOR, }) .add(rsc); selection.borrow_mut().register(key, field); let tap_links = links.clone(); field // The whole `drag_senses()` set, which is what every widget // driving a `DragGesture` registers. This block normally only sees // a gesture's *first* frames (`PressStart`, or a `Pressing` that // missed it -- `DragGesture::handle`'s idle-recovery branch); once // it commits, `DragGesture` takes pointer capture on `list`'s own // id and every further frame, including the terminal `Drop`, // reaches `lib.rs`'s list-level registration instead -- see // `iris::sense`'s pointer-capture doc for why that has to be a // stable id rather than this row's, which `LazySpan` can retire mid- // drag as content scrolls. // // `Cancel` is the one that is *not* optional, and leaving it out // is what made Iris's 2026-09-08 "scroll a horizontal area, then // tap in a vertical one, and it snaps": a cancel is delivered to // the widget that was **pressed**, not to whoever holds the // capture, so when a code fence inside this block panned sideways // and took the pointer, nothing ever told the shared gesture its // press was over. It stayed open with the fence's touch-down as // its origin, and the next press anywhere was measured from there. .on(CursorSense::drag_senses(), move |ctx, rsc| { let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos); let outcome = selection.borrow_mut().drag( rsc, list, Some((key, pos, size)), cursor, ctx.data.sense, ctx.data.cursor.time, ctx.data.pointer, ); // A *tap*, decided by the same `DragArbiter` the pan and // the selection are: a gesture that panned the list past // this link, or held long enough to select, must not also // follow it (`GestureOutcome::Tapped`'s doc). if outcome == GestureOutcome::Tapped { let byte = field.edit(rsc).byte_at(cursor, size); let url = tap_links .borrow() .iter() .find(|l| l.range.contains(&byte)) .map(|l| l.url.clone()); if let Some(url) = url { log::info!("iris link: opening {url}"); ::open_url(ctx.state, &url); } } }) .add(rsc); // The column holds the *framed* widget; the field is what // `apply_delta` writes into and what `Selection` resolves. Keeping // the two apart is what lets a fence gain a background without the // delta path knowing anything about frames. let framed = match frame { BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(), // Masked *by the panel*, not inside it: the fence's own rounded // rect is the clip, so content scrolled sideways is cut on the // curve instead of leaving square pixels in the corners // (Iris, 2026-09-07). BlockFrame::Verbatim { fill } => field .scrollable(Axis::X, Pin::Start) .pad(dp(FRAME_PAD_DP)) .masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP))) .width(rest(1)) .add_strong(rsc) .any(), // A `Stack` (through `background`) rather than a two-child // `Span(Dir::RIGHT)`: the bar is drawn behind text padded past // it, which is the same picture with one widget fewer and // without `Span`'s provisional full-region pass. That pass is // also what first surfaced the `mov`-then-`reposition` assert // docs/RUST.md's P1a box records as still open, so the shape // with fewer passes is the one to prefer here. BlockFrame::Quote => field .width(rest(1)) .pad(Padding { left: dp(QUOTE_BAR_DP + FRAME_PAD_DP), ..Padding::ZERO }) .background(rect(crate::ui::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP))) .width(rest(1)) .add_strong(rsc) .any(), }; (field, framed, links) } /// 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 /// `crate::client::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`). /// /// `cap` draws at most [`cap_message`]'s worth of it with a "Show all" /// under the rest. The row's content sits inside a `WidgetPtr` so that /// answering that offer replaces it in place, which is the same shape a /// tool card uses to open (`tool.rs`'s `build_card_ptr`). fn build_text_row( rsc: &mut Rsc, list: WeakWidget, selection: Rc>, key: RowKey, sender: Option<&str>, markdown_src: &str, cap: bool, ) -> (StrongWidget, RowBlocks) where Rsc::State: FocusHost + OpenUrl, { let source = Rc::new(RowSource { sender: sender.map(str::to_string), markdown: markdown_src.to_string(), }); let strong = WidgetPtr::new().add_strong(rsc); let ptr = strong.weak(); let (content, blocks) = row_content(rsc, list, selection, key, source, ptr, cap); ptr(rsc).set(content); (strong.any(), blocks) } /// One row's header, blocks, and -- when [`cap_message`] left something /// out -- the "Show all" that replaces the lot with the whole message. /// /// Separate from [`build_text_row`] because the tap calls it a second /// time, with `cap` false, and writes the result back into the same /// `WidgetPtr`. fn row_content( rsc: &mut Rsc, list: WeakWidget, selection: Rc>, key: RowKey, source: Rc, ptr: WeakWidget, cap: bool, ) -> (StrongWidget, RowBlocks) where Rsc::State: FocusHost + OpenUrl, { let (blocks, hidden) = cap_message(display_blocks(&source.markdown), cap); let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP)); let mut fields = Vec::with_capacity(blocks.len()); let mut links = Vec::with_capacity(blocks.len()); for (i, block) in blocks.iter().enumerate() { let (field, framed, block_links) = build_block(rsc, list, selection.clone(), (key, i as u32), block); fields.push(field); links.push(block_links); column.push(framed); } if let Some(lines) = hidden { column.push(show_all( rsc, list, selection.clone(), key, source.clone(), ptr, lines, )); } 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 // what performs the *one* real strong registration each child gets. // Calling `.add_strong`/`.upgrade` here too, then feeding a `.weak()` // copy into that composition, tried to strong-register the same id // twice and panicked with "was already added" // (`core/src/widget/like.rs:12`) -- found running this crate's own // `run-headless.sh` example, the first real render of a row. let header: WeakWidget = match &source.sender { Some(name) => wtext(name.clone()) .size(13.0) .color(UiColor::new(150, 150, 160, 255)) .add(rsc), None => Span::empty(Dir::DOWN).add(rsc), }; let widget = (header, column.width(rest(1))) .span(Dir::DOWN) .gap(dp(4)) .pad(dp(10)) .add_strong(rsc) .any(); ( widget, RowBlocks { blocks, fields, links, column, sender: source.sender.clone(), capped: hidden.is_some(), }, ) } /// The "Show all N lines" under a capped message, and the tap that /// replaces the row with the whole of it. /// /// The `RowBlocks` the rebuild produces is **discarded**, because a capped /// row is never the row a reply is streaming into (`build_row`'s `cap`) -- /// so nothing is holding one for it, and there is nothing to keep in step. #[allow(clippy::too_many_arguments)] fn show_all( rsc: &mut Rsc, list: WeakWidget, selection: Rc>, key: RowKey, source: Rc, ptr: WeakWidget, lines: usize, ) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { let label = show_all_label(lines); let more_strong = WidgetPtr::new().add_strong(rsc); let more = more_strong.weak(); let words = wtext(label.clone()) .size(13.0) .color(UiColor::new(150, 150, 160, 255)) .text_align(Align::LEFT) .label(label) .add_strong(rsc); more(rsc).set(words); on_tap(rsc, more, list, selection.clone(), move |rsc| { hold_edge(rsc, list, key); let (content, _blocks) = row_content( rsc, list, selection.clone(), key, source.clone(), ptr, false, ); let _old = ptr(rsc).replace(content); }); more_strong.any() } 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( &mut self, rsc: &mut Rsc, list: WeakWidget, selection: Rc>, key: RowKey, sender: Option<&str>, markdown_src: &str, ) -> bool where Rsc::State: FocusHost + OpenUrl, { if self.sender.as_deref() != sender { return false; } // A capped row draws less than the message it was built from, so // appending to it would put the new text *below* the "Show all" // saying the rest is hidden. The caller rebuilds instead, and // rebuilds uncapped (`TranscriptScreen::apply`), so this refusal // costs one rebuild per message rather than one per delta. if self.capped { 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; } // A block's *frame* is built around its widget once and never // rewritten, so a block whose kind changed under the delta (the // paragraph that a `|---|` line turns into a table) cannot take // this path -- it would keep prose's appearance with a table's // text in it. Only the last block can differ at all, by the check // above. if new_blocks.len() == self.blocks.len() && common < self.blocks.len() && new_blocks[common].kind != self.blocks[common].kind { return false; } debug_assert!( self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(), "one field and one link list per block: {} fields, {} links, {} blocks", self.fields.len(), self.links.len(), self.blocks.len() ); for (i, block) in new_blocks.iter().enumerate().skip(common) { match (self.fields.get(i), self.links.get(i)) { (Some(field), Some(links)) => { let rendered = render_block(block, BASE_SIZE); field .edit(rsc) .set_with_spans(&rendered.text, rendered.spans); // Replaced together with the text: a link range left // over from the previous delta points into a string // that no longer exists. *links.borrow_mut() = rendered.links; } _ => { let (field, framed, links) = build_block(rsc, list, selection.clone(), (key, i as u32), block); self.fields.push(field); self.links.push(links); // `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(framed); } } } } self.blocks = new_blocks; true } } fn build_single( rsc: &mut Rsc, list: WeakWidget, selection: Rc>, key: RowKey, item: &TranscriptItem, cap: bool, ) -> (StrongWidget, RowBlocks) where Rsc::State: FocusHost + OpenUrl, { let (sender, markdown_src) = item_content(item); build_text_row(rsc, list, selection, key, sender, &markdown_src, cap) } /// What a row keeps so the next event can change part of it instead of /// all of it -- one variant per kind of row that has such a path. /// /// Two mechanisms would have been two answers to the same question ("what /// can this row do cheaply?"), so the caller holds one of these for its /// tail row and asks it, rather than holding a `RowBlocks` and a /// `ToolRow` and choosing between them at each call site. pub enum TailRow { /// A message: a column of one text widget per markdown block, so a /// streamed delta costs the last block. Blocks(RowBlocks), /// A tool call or a run of them: a column of cards, so an arriving /// result costs one card. Tools(ToolRow), } /// `cap` draws a long message as [`cap_message`]'s worth of it behind a /// "Show all"; the caller passes `false` for the **live tail**, the row a /// reply is streaming into, because a row that grows while it is capped /// would appear to stop growing (`RowBlocks::capped`). Every other row is /// capped. pub fn build_row( rsc: &mut Rsc, list: WeakWidget, selection: Rc>, row: &FoldedRow, working: bool, cap: bool, ) -> (RowKey, StrongWidget, Option) where Rsc::State: FocusHost + OpenUrl, { // A lone tool call is a card too, not a message with markdown in it: // `group_tool_runs` leaves one call as a `Single` because "Called 1 // tool" hides a card to say the same thing in more words, and the // *card* is what both cases draw (`ToolRows.kt`). let calls = match row { FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => { Some(std::slice::from_ref(item)) } FoldedRow::Tools(calls) => Some(calls.as_slice()), FoldedRow::Single(_) => None, }; if let Some(calls) = calls { let key = row_key(&calls[0].key()); let (widget, tools) = crate::ui::tool::build_tool_row(rsc, list, selection, key, calls.to_vec(), working); return (key, widget, Some(TailRow::Tools(tools))); } let FoldedRow::Single(item) = row else { unreachable!("every Tools row took the branch above"); }; let key = row_key(&item.key()); let (widget, blocks) = build_single(rsc, list, selection, key, item, cap); (key, widget, Some(TailRow::Blocks(blocks))) } #[cfg(test)] mod tests { use super::*; fn blocks(src: &str) -> Vec { display_blocks(src) } #[test] fn a_message_inside_the_bounds_is_not_capped() { let (kept, hidden) = cap_message(blocks("hello\n\nthere"), true); assert_eq!(kept.len(), 2); assert_eq!(hidden, None); } /// Off by default at the call site that matters: the row a reply is /// streaming into is built with `cap` false, and must come back whole /// however long it has got. #[test] fn cap_false_keeps_everything() { let src = "a\n\n".repeat(MESSAGE_LINES * 2); let (kept, hidden) = cap_message(blocks(&src), false); assert_eq!(kept.len(), MESSAGE_LINES * 2); assert_eq!(hidden, None); } /// The ordinary case: the cut lands between two blocks, so every /// block drawn is a whole one. #[test] fn a_long_message_is_cut_on_a_block_boundary() { let src = "a paragraph\n\n".repeat(MESSAGE_LINES * 2); let all = blocks(&src); let (kept, hidden) = cap_message(all.clone(), true); assert!(kept.len() < all.len(), "nothing was left out"); assert!( kept.iter().zip(&all).all(|(k, a)| k == a), "a block was truncated where a boundary was available", ); assert_eq!( hidden, Some(all.iter().map(|b| b.source.lines().count()).sum()), "the offer says the whole message's line count, not the shown part's", ); } /// The half a block boundary cannot answer: one enormous fence, which /// is what a reply pasting a file arrives as. Truncated rather than /// dropped -- a row that drew nothing would say less than the line it /// replaced -- and still a fence, since the kind is decided before the /// truncation and an unterminated one closes at the end of its input. #[test] fn one_block_over_the_bound_by_itself_is_truncated() { let src = format!("```\n{}```", "x\n".repeat(MESSAGE_LINES * 2)); let all = blocks(&src); assert_eq!(all.len(), 1, "the fixture must be a single block"); let (kept, hidden) = cap_message(all.clone(), true); assert_eq!(kept.len(), 1); assert_eq!( kept[0].kind, all[0].kind, "truncation changed the block's kind" ); assert!( kept[0].source.len() < all[0].source.len(), "the one over-long block was drawn whole", ); assert!(hidden.is_some()); } }