Files
ai-app/iris/transcript-ui/src/lib.rs
T
irisandClaude Fable 5.1 e12c708246 iris: generalize drag arbitration into a default-input DragGesture, with pointer capture and Drop
Iris asked (2026-09-06) that dragging be part of iris's default input
system rather than duplicated per app: "anything that provides good
performance and can be generalized well is part of iris rather than the
app." DragArbiter and VelocityTracker (both already in iris::sense) are
now bundled into a new DragGesture, which also takes exclusive pointer
capture (UiRenderState::capture_pointer/release_pointer/captured_pointer)
the moment a gesture commits to panning or selecting, and delivers a new
CursorSense::Drop -- not PressEnd -- to the captured widget when the
button lifts, wherever on screen that happens to be.

This directly targets the phone bench's "finger flings do nothing":
per-widget hit testing silently drops a gesture the instant the pointer
moves off every registered region, which a fast pan/fling does routinely
(crossing several virtualised rows, or ending off the loaded content
entirely) -- so PressEnd, and the velocity/fling-start decision hanging
off it, was frequently never delivered at all. Capture targets List's own
stable id (List::key_at resolves the row-under-pointer from its
extents), not a row's, since List retires rows mid-drag as content
scrolls.

transcript-ui::Selection::drag now only decides pan-vs-select from
DragGesture's outcome; row.rs's per-row registration is only ever a
gesture's first frame, with lib.rs registering the List-level
continuation once. New tests: sense_tests.rs's two pointer-capture
regressions, list.rs's replacing_the_last_row_many_times_does_not_leak_primitives
(a P0 stale-primitives diagnostic -- passes, pinning the widget-arena
layer as not the leak). MainActivity.java opts into edge-to-edge
(Window::setDecorFitsSystemWindows(false), API 30+, no new dependency)
so window insets are redelivered on every change including a pure IME
toggle -- the named-but-untried fix for the phone bench's "keyboard:
could not be shown" and the emulator's identical non-confirmation.

cargo fmt/clippy/test clean across the iris workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:10:48 -04:00

417 lines
17 KiB
Rust

//! The transcript screen, in iris -- RUST.md's I5. Built the same way
//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` +
//! `Rsc::State: FocusHost`, so the winit example (`iris/examples/
//! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the
//! same [`build`]. See RUST.md's I5 box for the full account of what is
//! and is not proved yet, and this doc for the shape.
//!
//! ```text
//! +------------------------------------------+
//! | iris::widget::List (transcript_ui::row) | <- .height(rest(1))
//! | row 1: sender label + one TextEdit |
//! | row 2: sender label + one TextEdit |
//! | row 3 (Tools): collapsed/expanded |
//! | ... |
//! +------------------------------------------+
//! | composer bar (transcript_ui::composer) | <- natural height
//! +------------------------------------------+
//! ```
//!
//! **What this crate does not do itself**: fetch anything over the network
//! or read the transcript cache. [`build`] takes an already-folded
//! `Vec<client_core::transcript_fold::TranscriptRow>` and
//! [`TranscriptScreen::push_row`] takes one more as it arrives -- the
//! caller (an app's own `main`, or a future `iris-android-app`-shaped
//! cdylib) owns `client_core::ApiClient`/
//! `event_stream::follow_session_events` and the transcript cache, per the
//! code rules' "ask for the least you need": a widget-tree builder that
//! also knew how to make an HTTPS request would be untestable without a
//! server and unable to be driven by `run-headless.sh` with synthetic rows.
//!
//! **Gap closed, 2026-09-05**: a touch-drag that starts on a row's
//! rendered text used to always begin a cross-row *selection* (`row.rs`'s
//! `CursorSense::click_or_drag()` on each row's `TextEdit`), never a
//! *scroll* of the list, because both wanted the same gesture over the
//! same screen region and `core/src/sense.rs`'s `run_sensors` gave the
//! widget in the *inner* layer (a row's own `TextEdit`) first refusal
//! every frame it was pressed. `row.rs` now routes every row's drag
//! through one shared `iris::sense::DragArbiter`
//! (`Selection::drag`, `selection.rs`), which decides pan vs. select the
//! way Android itself does -- see `DragArbiter`'s own doc and
//! `DECISIONS.md` for the exact rule. `List` scrolls correctly when
//! driven programmatically (I3's benchmark), via the mouse wheel (wired
//! below, `CursorSense::Scroll`), and now via a touch pan starting on a
//! row's own text too.
pub mod composer;
pub mod markdown;
pub mod row;
pub mod selection;
use client_core::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*;
use selection::Selection;
use std::{cell::RefCell, rc::Rc, time::Instant};
pub struct TranscriptScreen {
/// The transcript's own `List` -- exposed so a caller can read
/// `.extent()`/call `.jump_to_end()` etc. directly for anything this
/// crate does not already wrap.
pub list: WeakWidget<List>,
pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>,
/// How many times [`Self::apply`] has fallen back to a full rebuild --
/// `Cell` rather than requiring `&mut self`, matching every other
/// method here (the real state lives behind `list`/`selection`'s own
/// interior mutability, per `push_row`'s existing `&self`). Drained by
/// [`Self::take_rebuilds`].
rebuilds: std::cell::Cell<usize>,
}
impl TranscriptScreen {
/// Append one more folded row at the live end of the transcript --
/// what a caller's SSE loop or a sent message calls as new events
/// arrive. `List::push_back` is O(1) and keeps the view pinned to the
/// newest content when it already was (I3).
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
where
Rsc::State: FocusHost,
{
let (key, widget) = row::build_row(rsc, self.list, self.selection.clone(), row);
(self.list)(rsc).push_back(ListRow::new(key, widget));
}
/// Apply the effect of one more folded event without rebuilding the
/// whole screen -- RUST.md's "streaming still costs a full rebuild"
/// fix. `old`/`new` are `client_core::transcript_fold::fold_event`'s
/// own before/after item lists (never grouped into rows -- that
/// happens here, over both, so the common tail cases can be told
/// apart; `group_tool_runs` is pure bookkeeping over already-folded
/// items, no widget is built doing it).
///
/// Three cases, cheapest first:
/// - **nothing changed**: no-op.
/// - **pure append** (a still-open reply's row now closed and stable,
/// a new tool call, a new message): every new row is `push_back`ed,
/// same cost as [`Self::push_row`].
/// - **only the last row's content changed** (the common case: a delta
/// folded into a still-open assistant message): that one row is
/// rebuilt (`row::build_row`, the same path a fresh row goes
/// through) and swapped in with [`List::replace_back`] -- every
/// other row is untouched, so nothing else redraws or moves. Any
/// further new rows are appended after it, for the (also common)
/// case of a delta that both finishes the open reply and starts the
/// next row in the same event.
///
/// Anything else -- a row *before* the tail changed, which only
/// happens when `group_tool_runs` regroups already-seen items (a tool
/// run's calls that used to be separate rows join once the run closes)
/// -- falls back to a full rebuild: every row is dropped
/// (`List::clear`) and rebuilt from `new`. Counted in
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
/// often the fallback actually fires rather than assuming it never
/// does.
pub fn apply<Rsc: HasEvents>(
&self,
rsc: &mut Rsc,
old: &[client_core::transcript_fold::TranscriptItem],
new: &[client_core::transcript_fold::TranscriptItem],
) where
Rsc::State: FocusHost,
{
use client_core::transcript_fold::group_tool_runs;
let old_rows = group_tool_runs(old);
let new_rows = group_tool_runs(new);
match diff_rows(&old_rows, &new_rows) {
RowDiff::Unchanged => {}
RowDiff::Appended { common } => {
// Pure append: every already-drawn row is byte-for-byte the
// same `FoldedRow` it was last time.
for row in &new_rows[common..] {
self.push_row(rsc, row);
}
}
RowDiff::ReplaceLast { common } => {
// Only the tail row's content changed -- rebuild that one
// row and swap it in place, keeping every row before it
// untouched.
let old_key = row::row_key(&old_rows[common].key());
let (new_key, widget) =
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
if new_key != old_key {
self.selection.borrow_mut().unregister(old_key);
}
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
drop(evicted); // frees the old row's widget, same as a pop would
for row in &new_rows[common + 1..] {
self.push_row(rsc, row);
}
}
RowDiff::Rebuild => {
// A row before the tail changed (a regroup) -- nothing
// short of a full rebuild expresses that.
self.rebuilds.set(self.rebuilds.get() + 1);
(self.list)(rsc).clear();
for row in &new_rows {
self.push_row(rsc, row);
}
}
}
}
/// How many times [`Self::apply`] has fallen back to a full rebuild
/// since the last call, reset to 0 by reading it -- the same
/// take-and-reset shape `AccessTree::take_rebuilds` already uses (I4).
pub fn take_rebuilds(&self) -> usize {
self.rebuilds.replace(0)
}
/// The concatenated text of whatever is currently selected across one
/// or more rows, `None` if nothing is -- what a copy command reads.
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> {
self.selection.borrow().selected_text(rsc)
}
}
pub fn build<Rsc: HasEvents>(
rsc: &mut Rsc,
ui_state: &mut impl HasRoot,
rows: Vec<FoldedRow>,
) -> TranscriptScreen
where
Rsc::State: FocusHost,
{
let (screen, tree) = build_tree(rsc, rows);
ui_state.set_root(tree);
screen
}
/// The same widget tree [`build`] makes, without claiming the window's
/// whole root -- what a caller embedding this screen alongside something
/// else of its own needs (RUST.md's E4: a session list beside the
/// transcript on the desktop). `build` is `build_tree` plus
/// `ui_state.set_root(tree)`; kept as its own function since most callers
/// (the winit example, an eventual Android cdylib) want the screen to *be*
/// the window and don't need the strong handle back.
pub fn build_tree<Rsc: HasEvents>(
rsc: &mut Rsc,
rows: Vec<FoldedRow>,
) -> (TranscriptScreen, StrongWidget)
where
Rsc::State: FocusHost,
{
let selection = Rc::new(RefCell::new(Selection::new()));
let list = List::new(Axis::Y).add(rsc);
for row in &rows {
let (key, widget) = row::build_row(rsc, list, selection.clone(), row);
list(rsc).push_back(ListRow::new(key, widget));
}
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
// `scrollable()` uses for `Scroll`, applied directly to `List` since
// `List` already does its own placement and needs no `Scroll` wrapper.
// Real touch-drag panning is the known gap in this module's doc.
list.on(CursorSense::Scroll, |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0;
ctx.widget(rsc).scroll(delta);
})
.add(rsc);
// The continuation of a row-started drag once it has committed and
// taken pointer capture on `list`'s own id (`row.rs`'s registration is
// only ever the gesture's first frame) -- registered once here, not
// once per row, since `DragGesture`'s single shared instance must see
// each frame of one gesture exactly once. `ctx.data.pos`/`size` are
// already relative to `list`'s own on-screen box (this is what it was
// registered against), which is exactly the viewport-pixel space
// `List::key_at`/`extent` work in, so the row-under-the-pointer is
// resolved from those instead of a per-row hit test.
{
let selection = selection.clone();
list.on(
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop,
move |ctx, rsc| {
let pos = ctx.data.pos;
let row = list(rsc).key_at(pos.y).and_then(|key| {
let (top, bottom) = list(rsc).extent(key)?;
Some((
key,
Vec2::new(pos.x, pos.y - top),
Vec2::new(ctx.data.size.x, bottom - top),
))
});
selection.borrow_mut().drag(
rsc,
list,
row,
ctx.data.cursor.pos,
ctx.data.sense,
Instant::now(),
ctx.data.render,
);
},
)
.add(rsc);
}
let (composer, composer_bar) = composer::build_composer(rsc);
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
(
TranscriptScreen {
list,
composer,
selection,
rebuilds: std::cell::Cell::new(0),
},
tree,
)
}
/// What changed at the tail between two folded row lists -- the decision
/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no
/// widget and no `Rsc`, so the three cases can be tested directly against
/// synthetic `Vec<FoldedRow>`s (below) rather than needing a full widget
/// harness to exercise logic that never touches one.
#[derive(Debug, PartialEq, Eq)]
enum RowDiff {
/// `old` and `new` are the same length and every row is identical.
Unchanged,
/// Rows `[common..]` of `new` are new; everything before `common` is
/// byte-for-byte the same `FoldedRow` `old` already had.
Appended { common: usize },
/// Row `common` is the only one whose content differs; anything past
/// it in `new` is a pure append after the replacement.
ReplaceLast { common: usize },
/// A row *before* the tail differs -- only `group_tool_runs` regrouping
/// an earlier run does this, and nothing short of a full rebuild
/// expresses it.
Rebuild,
}
fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
let common = old
.iter()
.zip(new.iter())
.take_while(|(a, b)| a == b)
.count();
if common == old.len() && common == new.len() {
RowDiff::Unchanged
} else if common == old.len() {
RowDiff::Appended { common }
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
// The `common < new.len()` guard is what tells "the tail row's
// content changed" apart from "the tail row was removed and
// nothing replaced it" (a shrinking list) -- the latter has
// nothing at `new[common]` to rebuild into place.
RowDiff::ReplaceLast { common }
} else {
RowDiff::Rebuild
}
}
#[cfg(test)]
mod diff_tests {
use super::*;
use client_core::transcript_fold::TranscriptItem;
fn user(seq: u64, text: &str) -> FoldedRow {
FoldedRow::Single(TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
})
}
fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow {
FoldedRow::Single(TranscriptItem::AssistantMsg {
seq,
text: text.to_string(),
settled,
})
}
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
TranscriptItem::ToolRun {
seq,
id: format!("id{seq}"),
run_id: run_id.to_string(),
tool: "grep".to_string(),
input: "x".to_string(),
output: String::new(),
done: false,
asks: Vec::new(),
images: Vec::new(),
}
}
#[test]
fn identical_lists_are_unchanged() {
let rows = vec![user(1, "hi"), assistant(2, "hello", true)];
assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged);
}
#[test]
fn an_empty_list_growing_by_one_is_an_append_from_zero() {
let old: Vec<FoldedRow> = Vec::new();
let new = vec![user(1, "hi")];
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 });
}
#[test]
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
// The row that used to be the tail (a now-closed assistant
// message) is unchanged; a new user message is appended after it
// -- the transition every reply's *last* delta makes once the
// next turn starts.
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
}
#[test]
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
// The common streaming case: the assistant message's key (its
// first delta's seq) never changes, only its text grows.
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
}
#[test]
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
// `ReplaceLast` only claims the row it names; `apply` appends
// whatever comes after it separately -- this just confirms the
// diff still recognises the replace even with a trailing append.
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
}
#[test]
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
// Two separate `Single` rows for the same run id become one
// `Tools` row once `group_tool_runs` sees them adjacent -- that
// changes row 0, not just the tail, so nothing short of a full
// rebuild expresses it.
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
}
#[test]
fn shrinking_the_list_is_a_rebuild() {
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
let new = vec![user(1, "hi")];
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
}
}