iris: transcript-ui, the transcript screen (RUST.md's I5)
A new workspace member, iris/transcript-ui/, built the same way tabs-ui is: generic over Rsc: HasEvents + Rsc::State: FocusHost, on client-core/event-model by path (real code, matching E2's precedent). Four modules: - markdown.rs: CommonMark (pulldown-cmark) -> one plain string plus a Vec<SpanStyle>, so a row's headings/bold/italic/inline-code/links render inline inside one wrapped TextEdit rather than one widget per block -- the actual proof that iris can do what E2 found Masonry structurally unable to (masonry/src/widgets/text_area.rs's "TODO: RichTextInput"). - row.rs: one iris::widget::List row per folded TranscriptRow. A TranscriptRow::Tools group collapses to a summary and expands to every call's own tool/input/output on tap, using List::extent + note_tap for hold-the-edge exactly as list.rs's module doc describes. - selection.rs: cross-row selection -- a drag that starts in one row's TextEdit and crosses into another's, coordinating each visible row's own select/select_all/deselect from one pointer gesture. The one Masonry's own text_area.rs cites as impossible (no SelectionContainer-shaped type anywhere in masonry/masonry_core/ xilem). - composer.rs: a growing multi-line composer with no fixed height, wired beside the list with .height(rest(1)) -- the real screen for IRIS_TODO.md's "input box" benchmark case. 9 new tests (5 pure markdown, 4 selection), all passing. Screenshotted via run-headless.sh: real inline rich text visible (bold, italic, inline code, a bigger bold heading, a coloured link, a monospaced fenced block, a collapsed tool-call row). What this box does not close, each recorded at its own point (RUST.md's I5 box, IRIS_TODO.md's dated entries): no Android integration exists yet for this screen (no cdylib/Gradle shell the way iris-android-app wraps tabs-ui), so the emulator-side render-number pass condition was not attempted; a touch-drag pan over a row's own text currently loses gesture arbitration to that row's own drag-select (diagnosed and named, not silently broken); row-level accessibility names, a tappable link, a code-span background chip, and Selection's anchor-row shortcut are scoped shortcuts recorded in place. cargo fmt/build/clippy/test --workspace clean; cargo ndk -t x86_64 -P 26 build/clippy clean for both transcript-ui and iris. Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
1 parent
0af4c88d08
commit
3f25e7ebca
9 files changed
+1421
-5
No files matched your search
@@ -0,0 +1,289 @@
|
||||
//! One `iris::widget::list::ListRow` per folded transcript row
|
||||
//! (`client_core::transcript_fold::TranscriptRow`). Each row's whole text
|
||||
//! -- headings, paragraphs, inline styling -- goes through `markdown` into
|
||||
//! **one** `TextEdit`, which is what makes it one thing `Selection`
|
||||
//! (`selection.rs`) can select and what lets it wrap and scroll as a
|
||||
//! single buffer, matching RUST.md's "hard to get back" behaviour 2 (rich
|
||||
//! inline text) and half of behaviour 1 (selectable within a row; across
|
||||
//! rows is `selection.rs`'s job).
|
||||
//!
|
||||
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
|
||||
//! `client_core::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 `List::note_tap` at the row's own on-screen position
|
||||
//! (read back from `List::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
|
||||
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
|
||||
|
||||
use crate::markdown::render_markdown;
|
||||
use crate::selection::Selection;
|
||||
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||
use iris::prelude::*;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
/// 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 `List` 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: &client_core::transcript_fold::ItemKey) -> RowKey {
|
||||
use client_core::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.
|
||||
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()),
|
||||
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
|
||||
}
|
||||
|
||||
/// Build one `TextEdit` from a sender label plus markdown source, register
|
||||
/// it with `selection` under `key`, and wire the pointer handlers that
|
||||
/// drive `Selection::begin`/`extend` -- shared by every row variant below,
|
||||
/// since a selectable row is always "one TextEdit plus this wiring"
|
||||
/// regardless of what folded it.
|
||||
fn build_text_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: RowKey,
|
||||
sender: Option<&str>,
|
||||
markdown_src: &str,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let (text, spans) = render_markdown(markdown_src, BASE_SIZE);
|
||||
let field = wtext(text)
|
||||
.spans(spans)
|
||||
.editable(EditMode::MultiLine)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.size(BASE_SIZE)
|
||||
.color(UiColor::WHITE)
|
||||
.add(rsc);
|
||||
selection.borrow_mut().register(key, field);
|
||||
|
||||
field
|
||||
.on(CursorSense::click_or_drag(), move |ctx, rsc| {
|
||||
let sel = selection.clone();
|
||||
match ctx.data.sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
sel.borrow_mut()
|
||||
.begin(rsc, key, ctx.data.pos, ctx.data.size)
|
||||
}
|
||||
_ => sel
|
||||
.borrow_mut()
|
||||
.extend(rsc, key, ctx.data.pos, ctx.data.size),
|
||||
}
|
||||
})
|
||||
.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 sender {
|
||||
Some(name) => wtext(name.to_string())
|
||||
.size(13.0)
|
||||
.color(UiColor::new(150, 150, 160, 255))
|
||||
.add(rsc),
|
||||
None => Span::empty(Dir::DOWN).add(rsc),
|
||||
};
|
||||
|
||||
(header, field.width(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.gap(4)
|
||||
.pad(10)
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn build_single<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: RowKey,
|
||||
item: &TranscriptItem,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let (sender, markdown_src) = item_content(item);
|
||||
build_text_row(rsc, selection, key, sender, &markdown_src)
|
||||
}
|
||||
|
||||
/// A run of adjacent tool calls: collapsed to a one-line summary by
|
||||
/// default, expanding in place to every call's own tool/input/output on
|
||||
/// tap -- see the module doc for the hold-the-edge contract this wires
|
||||
/// against `list`.
|
||||
fn build_tools<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<List>,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: RowKey,
|
||||
calls: Vec<TranscriptItem>,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let expanded = Rc::new(RefCell::new(false));
|
||||
// `.add_strong` (not `.add`) because nothing else in the tree holds a
|
||||
// strong reference to this `WidgetPtr` the way a container's own
|
||||
// `add_strong`-on-its-children does for an ordinary child -- this row
|
||||
// *is* the top of its own subtree, so it has to own itself.
|
||||
let ptr_strong = WidgetPtr::new().add_strong(rsc);
|
||||
let ptr = ptr_strong.weak();
|
||||
|
||||
let summary_text = format!("\u{25b8} {} tool calls", calls.len());
|
||||
let full_text = calls
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
TranscriptItem::ToolRun {
|
||||
tool,
|
||||
input,
|
||||
output,
|
||||
..
|
||||
} => tool_call_markdown(tool, input, output),
|
||||
other => item_content(other).1,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
|
||||
fn build_content<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: RowKey,
|
||||
expanded: bool,
|
||||
summary: &str,
|
||||
full: &str,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let text = if expanded { full } else { summary };
|
||||
build_text_row(rsc, selection, key, Some("Tools"), text)
|
||||
}
|
||||
|
||||
let content = build_content(
|
||||
rsc,
|
||||
selection.clone(),
|
||||
key,
|
||||
false,
|
||||
&summary_text,
|
||||
&full_text,
|
||||
);
|
||||
ptr(rsc).set(content);
|
||||
|
||||
ptr.on(CursorSense::click(), move |ctx, rsc| {
|
||||
// `List::note_tap` wants a viewport-relative position, but the
|
||||
// click event only knows where inside *this row* it landed
|
||||
// (`ctx.data.pos`) -- `List::extent` (last frame's on-screen box
|
||||
// for this row's key) is what turns the two into the position
|
||||
// `list.rs`'s hold-the-edge layout pass resolves against, per the
|
||||
// module doc's contract.
|
||||
let (top, _bottom) = list(rsc).extent(key).unwrap_or((0.0, 0.0));
|
||||
list(rsc).note_tap(top + ctx.data.pos.y);
|
||||
|
||||
let was_expanded = *expanded.borrow();
|
||||
*expanded.borrow_mut() = !was_expanded;
|
||||
let content = build_content(
|
||||
rsc,
|
||||
selection.clone(),
|
||||
key,
|
||||
!was_expanded,
|
||||
&summary_text,
|
||||
&full_text,
|
||||
);
|
||||
// The old content's `StrongWidget` is freed when this drops --
|
||||
// the removal half of the row this click just replaced.
|
||||
let _old = ptr(rsc).replace(content);
|
||||
})
|
||||
.add(rsc);
|
||||
|
||||
ptr_strong.any()
|
||||
}
|
||||
|
||||
pub fn build_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<List>,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
row: &FoldedRow,
|
||||
) -> (RowKey, StrongWidget)
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
match row {
|
||||
FoldedRow::Single(item) => {
|
||||
let key = row_key(&item.key());
|
||||
(key, build_single(rsc, selection, key, item))
|
||||
}
|
||||
FoldedRow::Tools(calls) => {
|
||||
let key = row_key(&calls[0].key());
|
||||
(key, build_tools(rsc, list, selection, key, calls.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user