Iris asked for this 2026-09-06 (IRIS_TODO.md, "a third length kind beside relative and pixels ... a unit resolved against the display's density at layout time"): before this, a Len was abs (physical pixels) or rel/rest (a fraction of the parent), and the only way to make a design size look the same physical size on a denser display was a single global multiply applied after layout -- which the previous commit found is also what made text blurry. Len gains a `dp` field, resolved against a `density: f32` (physical pixels per dp) now carried on UiRenderState/Painter (`UiRenderState::set_density`/`density()`, `Painter::density()`) and threaded through every `apply_rest`/`to_uivec2` call site. `len_fns::dp` / `Len::dp` construct one, exactly parallel to the existing `abs`/`rel`/ `rest`. A bare number is unaffected (still `abs`, physical pixels) -- `dp` is opt-in. Text: `TextBuffer::shape` now takes `density` and multiplies `font_size`/`line_height` (and any span override) by it before handing them to parley, so the size that reaches the shaper and the rasteriser (`TextData::place`) is the display's real physical size -- the atlas holds a bitmap at the resolution it is actually shown at, instead of a low-resolution one stretched afterward. `GlyphKey.size` already keys on the resolved `font_size`, so a cache entry is naturally per physical size with no further change. `TextData` also carries its own `density` copy for `TextEditCtx::layout` (cursor movement/hit-testing), which shapes text from an input callback with no `Painter` to read it from. `Span::gap` and `Padding`'s four sides move from bare `f32` to `Len`, so `.gap(dp(4))`/`.pad(dp(10))` work the same way any other size does; a bare number still means physical pixels, unchanged. Migrated transcript-ui's non-text sizes (row gap/padding, composer padding) and one example to the new unit, per IRIS_TODO.md's "done when" list. Android's own density (`DisplayMetrics.density`) is wired to both copies in `new_peer`; the winit backend has no per-monitor density wired up yet and stays at the default (1.0). docs/IRIS.md, docs/LAYOUT.md and IRIS_TODO.md updated next. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
311 lines
11 KiB
Rust
311 lines
11 KiB
Rust
//! 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, time::Instant};
|
|
|
|
/// 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()),
|
|
// 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
|
|
}
|
|
|
|
/// Build one `TextEdit` from a sender label plus markdown source, register
|
|
/// it with `selection` under `key`, and wire the pointer handlers that
|
|
/// drive `Selection::drag` -- shared by every row variant below, since a
|
|
/// selectable row is always "one TextEdit plus this wiring" regardless of
|
|
/// what folded it. `list` is threaded through so that same drag can pan
|
|
/// the list instead of selecting, per `Selection::drag`'s own doc.
|
|
fn build_text_row<Rsc: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<List>,
|
|
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
|
|
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
|
// -- the arbiter inside `Selection::drag` needs the release too,
|
|
// to go back to idle for the next press (`DragArbiter::release`).
|
|
.on(
|
|
CursorSense::click_or_drag() | CursorSense::unclick(),
|
|
move |ctx, rsc| {
|
|
selection.borrow_mut().drag(
|
|
rsc,
|
|
list,
|
|
key,
|
|
ctx.data.pos,
|
|
ctx.data.size,
|
|
ctx.data.cursor.pos,
|
|
ctx.data.sense,
|
|
Instant::now(),
|
|
);
|
|
},
|
|
)
|
|
.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(dp(4))
|
|
.pad(dp(10))
|
|
.add_strong(rsc)
|
|
.any()
|
|
}
|
|
|
|
fn build_single<Rsc: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
list: WeakWidget<List>,
|
|
selection: Rc<RefCell<Selection>>,
|
|
key: RowKey,
|
|
item: &TranscriptItem,
|
|
) -> StrongWidget
|
|
where
|
|
Rsc::State: FocusHost,
|
|
{
|
|
let (sender, markdown_src) = item_content(item);
|
|
build_text_row(rsc, list, 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,
|
|
list: WeakWidget<List>,
|
|
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, list, selection, key, Some("Tools"), text)
|
|
}
|
|
|
|
let content = build_content(
|
|
rsc,
|
|
list,
|
|
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,
|
|
list,
|
|
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, list, selection, key, item))
|
|
}
|
|
FoldedRow::Tools(calls) => {
|
|
let key = row_key(&calls[0].key());
|
|
(key, build_tools(rsc, list, selection, key, calls.clone()))
|
|
}
|
|
}
|
|
}
|