Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
782 lines
32 KiB
Rust
782 lines
32 KiB
Rust
//! 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<Block>,
|
|
fields: Vec<WeakWidget<TextEdit>>,
|
|
/// 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<Rc<RefCell<Vec<Link>>>>,
|
|
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>,
|
|
/// 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<Block> {
|
|
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<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
|
|
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<String>,
|
|
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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<LazySpan>,
|
|
selection: Rc<RefCell<Selection>>,
|
|
key: SelKey,
|
|
block: &Block,
|
|
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
|
|
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}");
|
|
<Rsc::State as OpenUrl>::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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<LazySpan>,
|
|
selection: Rc<RefCell<Selection>>,
|
|
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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<LazySpan>,
|
|
selection: Rc<RefCell<Selection>>,
|
|
key: RowKey,
|
|
source: Rc<RowSource>,
|
|
ptr: WeakWidget<WidgetPtr>,
|
|
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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<LazySpan>,
|
|
selection: Rc<RefCell<Selection>>,
|
|
key: RowKey,
|
|
source: Rc<RowSource>,
|
|
ptr: WeakWidget<WidgetPtr>,
|
|
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<Rsc: HasEvents>(
|
|
&mut self,
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<LazySpan>,
|
|
selection: Rc<RefCell<Selection>>,
|
|
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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<LazySpan>,
|
|
selection: Rc<RefCell<Selection>>,
|
|
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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<LazySpan>,
|
|
selection: Rc<RefCell<Selection>>,
|
|
row: &FoldedRow,
|
|
working: bool,
|
|
cap: bool,
|
|
) -> (RowKey, StrongWidget, Option<TailRow>)
|
|
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<Block> {
|
|
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());
|
|
}
|
|
}
|