iris is the framework alone; the app is one crate in app-rust/
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>
This commit is contained in:
1 parent
e9a6562dc6
commit
6d5a231f5c
100 files changed
+924
-3295
No files matched your search
@@ -0,0 +1,781 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user