Make the Rust client the sole app
This commit is contained in:
1 parent
a8602c1626
commit
d8bb1699a8
230 files changed
+762
-27300
No files matched your search
@@ -0,0 +1,82 @@
|
||||
use crate::ui::theme::Theme;
|
||||
use iris::prelude::*;
|
||||
|
||||
const MAX_LINES: f32 = 6.0;
|
||||
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
|
||||
const FIELD_PAD_DP: f32 = 12.0;
|
||||
|
||||
/// `field` is exposed so the caller can read its content on submit
|
||||
/// (`field.edit(rsc).text()`) and clear it afterward
|
||||
/// (`field.edit(rsc).set("")`).
|
||||
pub struct Composer {
|
||||
pub field: WeakWidget<TextEdit>,
|
||||
/// The bar's own outer padding -- only `bottom` is ever changed, by
|
||||
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
|
||||
/// a rebuilt tree, because `field` lives inside it and cannot be
|
||||
/// re-added to a new wrapper once it is strongly owned here.
|
||||
outer_pad: WeakWidget<Pad>,
|
||||
}
|
||||
|
||||
pub struct BuiltComposer {
|
||||
pub composer: Composer,
|
||||
pub widget: WeakWidget,
|
||||
}
|
||||
|
||||
impl Composer {
|
||||
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
|
||||
/// whenever the space below the bar changes: the IME's own inset while
|
||||
/// it is open, the navigation-bar inset otherwise. Takes a plain
|
||||
/// `f32` in the caller's own physical-pixel units rather than an
|
||||
/// Android-specific insets type, so this crate stays usable from the
|
||||
/// winit backend too, which has no navigation bar to report.
|
||||
/// Rewrites the existing `Pad` in place (marking it dirty through the
|
||||
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
|
||||
/// so the field's focus, selection and in-progress text are untouched.
|
||||
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
|
||||
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
|
||||
pad.padding.bottom = Len::abs(inset);
|
||||
pad.exact_region = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the composer plus its own bar as a **weak** id -- the caller
|
||||
/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose
|
||||
/// `set_root` performs the one real strong registration. Calling
|
||||
/// `.add_strong`/`.upgrade` a second time on an id already strong-owned
|
||||
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
|
||||
/// mistake this box's `row.rs` first made with its sender-label header, see
|
||||
/// that file's comment for the fuller account.
|
||||
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc, theme: &Theme) -> BuiltComposer
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let field = wtext("")
|
||||
.editable(EditMode::MultiLine)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.size(18)
|
||||
.color(theme.text.clone())
|
||||
.attr::<Selectable>(())
|
||||
.label("Message")
|
||||
.add(rsc);
|
||||
|
||||
// Without any mask at all the overflow paints *above* the bar, over
|
||||
// the transcript: measured at 58px of stray text for a 475px message
|
||||
// in a 417px box.
|
||||
let content = field
|
||||
.width(rest(1))
|
||||
.scrollable(Axis::Y, Pin::End)
|
||||
.pad(dp(FIELD_PAD_DP))
|
||||
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||
.width(rest(1))
|
||||
.masked_by(rect(theme.composer_surface.clone()))
|
||||
.add(rsc);
|
||||
|
||||
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
|
||||
|
||||
BuiltComposer {
|
||||
composer: Composer { field, outer_pad },
|
||||
widget: outer_pad,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
|
||||
use event_model::SeqEvent;
|
||||
use iris::prelude::*;
|
||||
|
||||
pub const BACKLOG_COUNT: usize = 3202;
|
||||
|
||||
const FIXTURE_JSONL: &str = include_str!("../../bench-fixture/assets/transcript.jsonl");
|
||||
|
||||
pub const PHONE_WIDTH: f32 = 1080.0;
|
||||
pub const PHONE_HEIGHT: f32 = 2424.0;
|
||||
pub const PHONE_SCALE: f32 = 2.55;
|
||||
pub const PHONE_FRAME_MS: u64 = 8;
|
||||
|
||||
pub fn phone_size() -> Vec2 {
|
||||
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
|
||||
}
|
||||
|
||||
pub struct Fixture {
|
||||
pub backlog: Vec<serde_json::Value>,
|
||||
pub stream_tail: Vec<SeqEvent>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
/// Parses the whole fixture. Panics on malformed input: this is a
|
||||
/// generated file compiled into the binary, so a parse failure is a
|
||||
/// broken build rather than a condition a caller could recover from
|
||||
/// (CODE_RULES: separate recoverable conditions from programmer
|
||||
/// error).
|
||||
pub fn parse() -> Self {
|
||||
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
|
||||
let mut stream_tail = Vec::new();
|
||||
for (i, line) in FIXTURE_JSONL
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.enumerate()
|
||||
{
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
||||
if i < BACKLOG_COUNT {
|
||||
backlog.push(value);
|
||||
} else {
|
||||
stream_tail.push(
|
||||
serde_json::from_value(value)
|
||||
.expect("bench fixture event matches event-model's SeqEvent"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
backlog,
|
||||
stream_tail,
|
||||
}
|
||||
}
|
||||
|
||||
/// The opening page folded into transcript items -- the same
|
||||
/// `fold_page` a real first load runs. `Err` carries the fold's own
|
||||
/// message, which a caller shows on screen rather than panicking, so
|
||||
/// a fixture that stops folding is visible in the app instead of
|
||||
/// being a crash on launch.
|
||||
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
|
||||
fold_page(&self.backlog)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||
group_tool_runs(items)
|
||||
}
|
||||
|
||||
/// Everything a caller needs to run the fixture as an app screen would:
|
||||
/// the screen, the folded items behind it, and the events not yet
|
||||
/// streamed. The tree itself comes back separately from
|
||||
/// [`build_screen`], since whoever takes it owns it.
|
||||
pub struct Opened {
|
||||
pub screen: crate::ui::TranscriptScreen,
|
||||
pub items: Vec<TranscriptItem>,
|
||||
/// The tail, for a caller that goes on replaying it one event at a
|
||||
/// time through `fold_event`/`TranscriptScreen::apply` -- the
|
||||
/// streaming phase of either app's benchmark.
|
||||
pub stream_tail: Vec<SeqEvent>,
|
||||
}
|
||||
|
||||
/// Build the transcript screen over the fixture's opening page, without
|
||||
/// claiming the window's root -- `crate::ui::build_tree`'s own split,
|
||||
/// for a caller (the Android bench) that puts the screen inside a shell
|
||||
/// of its own.
|
||||
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let fixture = Fixture::parse();
|
||||
let items = fixture.backlog_items()?;
|
||||
let (screen, tree) = crate::ui::build_tree(rsc, rows(&items));
|
||||
Ok((
|
||||
Opened {
|
||||
screen,
|
||||
items,
|
||||
stream_tail: fixture.stream_tail,
|
||||
},
|
||||
tree,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn open<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
ui_state: &mut impl HasRoot<Rsc>,
|
||||
) -> Result<Opened, String>
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let (opened, tree) = build_screen(rsc)?;
|
||||
ui_state.set_root(rsc, tree);
|
||||
Ok(opened)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
|
||||
let fixture = Fixture::parse();
|
||||
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
|
||||
assert!(
|
||||
fixture.stream_tail.len() >= 400,
|
||||
"the stream phase replays 400 events; the fixture has {}",
|
||||
fixture.stream_tail.len()
|
||||
);
|
||||
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
use crate::client::highlight::{self, Kind, Language};
|
||||
use crate::client::markdown_blocks::{Block, BlockKind};
|
||||
use crate::ui::theme::Theme;
|
||||
use iris::prelude::*;
|
||||
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use std::ops::Range;
|
||||
|
||||
fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
|
||||
match kind {
|
||||
Kind::Keyword => theme.syntax_keyword.clone(),
|
||||
Kind::String => theme.syntax_string.clone(),
|
||||
Kind::Literal => theme.syntax_literal.clone(),
|
||||
Kind::Comment => theme.syntax_comment.clone(),
|
||||
Kind::Metadata => theme.syntax_metadata.clone(),
|
||||
Kind::Punctuation => theme.syntax_punctuation.clone(),
|
||||
Kind::Mark => theme.syntax_mark.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BlockFrame {
|
||||
Plain,
|
||||
Verbatim { fill: PaintId },
|
||||
Quote,
|
||||
}
|
||||
|
||||
pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
|
||||
match kind {
|
||||
BlockKind::Code => BlockFrame::Verbatim {
|
||||
fill: theme.verbatim_surface.clone(),
|
||||
},
|
||||
BlockKind::Table => BlockFrame::Verbatim {
|
||||
fill: theme.table_surface.clone(),
|
||||
},
|
||||
BlockKind::Quote => BlockFrame::Quote,
|
||||
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
|
||||
BlockFrame::Plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Link {
|
||||
pub range: Range<usize>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Rendered {
|
||||
pub text: String,
|
||||
pub spans: Vec<SpanStyle>,
|
||||
pub links: Vec<Link>,
|
||||
}
|
||||
|
||||
impl Rendered {
|
||||
pub fn link_at(&self, byte: usize) -> Option<&Link> {
|
||||
self.links.iter().find(|l| l.range.contains(&byte))
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading ladder, in points at a 16pt body: it starts near the body
|
||||
/// text and descends, because these are headings inside a chat message
|
||||
/// rather than the top of a document. The numbers are Material's
|
||||
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
|
||||
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
|
||||
/// -- kept as literals rather than derived from `base_size` so the two
|
||||
/// apps agree exactly.
|
||||
fn heading_size(level: HeadingLevel) -> f32 {
|
||||
match level {
|
||||
HeadingLevel::H1 => 24.0,
|
||||
HeadingLevel::H2 => 22.0,
|
||||
HeadingLevel::H3 => 16.0,
|
||||
HeadingLevel::H4 => 14.0,
|
||||
HeadingLevel::H5 => 12.0,
|
||||
HeadingLevel::H6 => 11.0,
|
||||
}
|
||||
}
|
||||
|
||||
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
|
||||
|
||||
/// A block-level separator inside one block's own text (a list item's
|
||||
/// paragraphs, a quote's): two never run into each other with no gap, but
|
||||
/// an empty `out` gets no leading blank.
|
||||
fn ensure_blank_line(out: &mut String) {
|
||||
if !out.is_empty() && !out.ends_with("\n\n") {
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_line(out: &mut String) {
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered {
|
||||
match block.kind {
|
||||
BlockKind::Table => table_text(&block.source, theme),
|
||||
_ => render_markdown(&block.source, base_size, theme),
|
||||
}
|
||||
}
|
||||
|
||||
/// One markdown source string rendered into plain text plus the spans that
|
||||
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
||||
/// only so a heading's override is relative to it rather than a hardcoded
|
||||
/// absolute the caller cannot retune.
|
||||
pub fn render_markdown(src: &str, base_size: f32, theme: &Theme) -> Rendered {
|
||||
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
|
||||
let mut out = String::new();
|
||||
let mut spans = Vec::new();
|
||||
let mut links = Vec::new();
|
||||
// Stack of start byte offsets for whatever inline/block styling is
|
||||
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
||||
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
||||
// plain offset stack (rather than a tree, or repeating the kind here
|
||||
// too) is enough. A link's destination rides along beside its offset,
|
||||
// since `TagEnd::Link` does not carry it.
|
||||
let mut open: Vec<(usize, Option<String>)> = Vec::new();
|
||||
// One entry per open list: `Some(next number)` for an ordered list,
|
||||
// `None` for a bulleted one. Depth is this vector's length, which is
|
||||
// what picks the bullet glyph.
|
||||
let mut lists: Vec<Option<u64>> = Vec::new();
|
||||
let mut fence_language: Option<Language> = None;
|
||||
|
||||
let parser = Parser::new_ext(src, options());
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Heading { .. }
|
||||
| Tag::Emphasis
|
||||
| Tag::Strong
|
||||
| Tag::Strikethrough
|
||||
| Tag::Image { .. } => open.push((out.len(), None)),
|
||||
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
|
||||
Tag::CodeBlock(kind) => {
|
||||
fence_language = match &kind {
|
||||
CodeBlockKind::Fenced(info) => {
|
||||
highlight::fence_language(info.split_whitespace().next())
|
||||
}
|
||||
CodeBlockKind::Indented => None,
|
||||
};
|
||||
ensure_blank_line(&mut out);
|
||||
open.push((out.len(), None));
|
||||
}
|
||||
Tag::Item => {
|
||||
ensure_line(&mut out);
|
||||
let depth = lists.len().max(1);
|
||||
out.push_str(&" ".repeat(depth - 1));
|
||||
let start = out.len();
|
||||
match lists.last_mut() {
|
||||
Some(Some(n)) => {
|
||||
out.push_str(&format!("{n}. "));
|
||||
*n += 1;
|
||||
}
|
||||
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
|
||||
}
|
||||
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
|
||||
}
|
||||
Tag::List(first) => lists.push(first),
|
||||
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
||||
_ => {}
|
||||
},
|
||||
Event::End(
|
||||
tag_end @ (TagEnd::Heading(_)
|
||||
| TagEnd::Emphasis
|
||||
| TagEnd::Strong
|
||||
| TagEnd::Strikethrough
|
||||
| TagEnd::Link
|
||||
| TagEnd::Image
|
||||
| TagEnd::CodeBlock),
|
||||
) => {
|
||||
let Some((start, dest)) = open.pop() else {
|
||||
continue;
|
||||
};
|
||||
if matches!(tag_end, TagEnd::CodeBlock) {
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
}
|
||||
let range = start..out.len();
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match tag_end {
|
||||
TagEnd::Heading(level) => {
|
||||
spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold());
|
||||
}
|
||||
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
|
||||
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
|
||||
TagEnd::Strikethrough => {
|
||||
spans.push(SpanStyle::new(range).color(theme.strikethrough.clone()));
|
||||
}
|
||||
// An image draws as its alt text until the port has a
|
||||
// transcript image widget (IRIS_TODO's "scaled
|
||||
// thumbnail"); marked as a link so it is at least
|
||||
// followable rather than silently inert.
|
||||
TagEnd::Link | TagEnd::Image => {
|
||||
spans.push(
|
||||
SpanStyle::new(range.clone())
|
||||
.color(theme.link.clone())
|
||||
.underline(),
|
||||
);
|
||||
if let Some(url) = dest {
|
||||
links.push(Link { range, url });
|
||||
}
|
||||
}
|
||||
TagEnd::CodeBlock => {
|
||||
spans.push(
|
||||
SpanStyle::new(range.clone())
|
||||
.family(Family::Monospace)
|
||||
.color(theme.code.clone()),
|
||||
);
|
||||
if let Some(language) = fence_language.take() {
|
||||
highlight_into(&mut spans, &out, range, language, theme);
|
||||
}
|
||||
}
|
||||
_ => unreachable!("filtered by the outer match arm"),
|
||||
}
|
||||
}
|
||||
Event::Text(text) => out.push_str(&text),
|
||||
Event::Code(text) => {
|
||||
let start = out.len();
|
||||
out.push_str(&text);
|
||||
spans.push(
|
||||
SpanStyle::new(start..out.len())
|
||||
.family(Family::Monospace)
|
||||
.color(theme.code.clone()),
|
||||
);
|
||||
}
|
||||
Event::SoftBreak => out.push(' '),
|
||||
Event::HardBreak => out.push('\n'),
|
||||
Event::Rule => {
|
||||
ensure_line(&mut out);
|
||||
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
||||
}
|
||||
Event::TaskListMarker(done) => {
|
||||
let start = out.len();
|
||||
out.push_str(if done { "[x] " } else { "[ ] " });
|
||||
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
lists.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
spans.retain(|s| s.range.end <= out.len());
|
||||
links.retain(|l| l.range.end <= out.len());
|
||||
Rendered {
|
||||
text: out,
|
||||
spans,
|
||||
links,
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||
}
|
||||
|
||||
pub(crate) fn highlight_into(
|
||||
spans: &mut Vec<SpanStyle>,
|
||||
text: &str,
|
||||
range: Range<usize>,
|
||||
language: Language,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let code = &text[range.clone()];
|
||||
// char index -> byte offset within `code`, plus the end, so a span's
|
||||
// `end` is always in range.
|
||||
let bytes: Vec<usize> = code
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(code.len()))
|
||||
.collect();
|
||||
for span in highlight::spans_of(code, language) {
|
||||
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
|
||||
debug_assert!(
|
||||
false,
|
||||
"highlight span {}..{} outside {} chars of code",
|
||||
span.start,
|
||||
span.end,
|
||||
bytes.len() - 1
|
||||
);
|
||||
continue;
|
||||
};
|
||||
spans.push(
|
||||
SpanStyle::new(range.start + start..range.start + end)
|
||||
.family(Family::Monospace)
|
||||
.color(syntax_color(span.kind, theme)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const TABLE_MAX_COL: usize = 28;
|
||||
|
||||
pub fn table_text(src: &str, theme: &Theme) -> Rendered {
|
||||
let rows = table_cells(src);
|
||||
if rows.is_empty() {
|
||||
return Rendered::default();
|
||||
}
|
||||
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let wrapped: Vec<Vec<Vec<String>>> = rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
||||
.collect();
|
||||
let widths: Vec<usize> = (0..columns)
|
||||
.map(|c| {
|
||||
wrapped
|
||||
.iter()
|
||||
.filter_map(|row| row.get(c))
|
||||
.flat_map(|lines| lines.iter())
|
||||
.map(|l| l.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut out = String::new();
|
||||
let mut spans = Vec::new();
|
||||
for (r, row) in wrapped.iter().enumerate() {
|
||||
let height = row.iter().map(Vec::len).max().unwrap_or(1);
|
||||
let start = out.len();
|
||||
for line in 0..height {
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
for (c, width) in widths.iter().enumerate() {
|
||||
if c > 0 {
|
||||
out.push_str(" ");
|
||||
}
|
||||
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
|
||||
let text = text.unwrap_or("");
|
||||
out.push_str(text);
|
||||
if c + 1 < widths.len() {
|
||||
for _ in text.chars().count()..*width {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r == 0 {
|
||||
spans.push(SpanStyle::new(start..out.len()).bold());
|
||||
out.push('\n');
|
||||
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
|
||||
let rule_start = out.len();
|
||||
out.extend(std::iter::repeat_n('\u{2500}', rule));
|
||||
spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone()));
|
||||
}
|
||||
}
|
||||
Rendered {
|
||||
text: out,
|
||||
spans,
|
||||
links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_cells(src: &str) -> Vec<Vec<String>> {
|
||||
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||
let mut cell = String::new();
|
||||
let mut in_cell = false;
|
||||
for event in Parser::new_ext(src, options()) {
|
||||
match event {
|
||||
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
|
||||
Event::Start(Tag::TableCell) => {
|
||||
cell.clear();
|
||||
in_cell = true;
|
||||
}
|
||||
Event::End(TagEnd::TableCell) => {
|
||||
in_cell = false;
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push(cell.trim().to_string());
|
||||
}
|
||||
}
|
||||
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
|
||||
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rows.retain(|r| !r.is_empty());
|
||||
rows
|
||||
}
|
||||
|
||||
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
let extra = if line.is_empty() { 0 } else { 1 };
|
||||
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
}
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
}
|
||||
lines.push(line);
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::markdown_blocks::split_blocks;
|
||||
|
||||
fn with_theme<T>(f: impl FnOnce(&Theme) -> T) -> T {
|
||||
let mut paints = Paints::new();
|
||||
let theme = Theme::new(&mut paints);
|
||||
f(&theme)
|
||||
}
|
||||
|
||||
fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
with_theme(|theme| super::render_markdown(src, base_size, theme))
|
||||
}
|
||||
|
||||
fn render_block(block: &Block, base_size: f32) -> Rendered {
|
||||
with_theme(|theme| super::render_block(block, base_size, theme))
|
||||
}
|
||||
|
||||
fn frame_of(kind: BlockKind) -> BlockFrame {
|
||||
with_theme(|theme| super::frame_of(kind, theme))
|
||||
}
|
||||
|
||||
fn syntax_color(kind: Kind) -> PaintId {
|
||||
with_theme(|theme| super::syntax_color(kind, theme))
|
||||
}
|
||||
|
||||
fn code_color() -> PaintId {
|
||||
with_theme(|theme| theme.code.clone())
|
||||
}
|
||||
|
||||
fn marker_color() -> PaintId {
|
||||
with_theme(|theme| theme.marker.clone())
|
||||
}
|
||||
|
||||
fn block(src: &str) -> Rendered {
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
|
||||
render_block(&blocks[0], 16.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_paragraph_has_no_spans() {
|
||||
let r = render_markdown("just some words", 16.0);
|
||||
assert_eq!(r.text, "just some words");
|
||||
assert!(r.spans.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_and_italic_produce_spans_over_the_right_range() {
|
||||
let r = render_markdown("a **bold** and *italic* word", 16.0);
|
||||
assert_eq!(r.text, "a bold and italic word");
|
||||
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
||||
assert_eq!(&r.text[bold.range.clone()], "bold");
|
||||
let italic = r.spans.iter().find(|s| s.italic).unwrap();
|
||||
assert_eq!(&r.text[italic.range.clone()], "italic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_gets_a_bigger_font_size_span() {
|
||||
let r = render_markdown("# A Title", 16.0);
|
||||
assert!(r.text.starts_with("A Title"));
|
||||
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
||||
assert_eq!(&r.text[heading.range.clone()], "A Title");
|
||||
assert_eq!(heading.font_size, Some(24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_heading_level_is_a_different_size() {
|
||||
let mut sizes = Vec::new();
|
||||
for level in 1..=6 {
|
||||
let src = format!("{} h", "#".repeat(level));
|
||||
let r = render_markdown(&src, 16.0);
|
||||
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
|
||||
}
|
||||
let mut sorted = sizes.clone();
|
||||
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||
sorted.dedup();
|
||||
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
|
||||
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
||||
assert!(r.text.contains("the docs"));
|
||||
assert!(
|
||||
!r.text.contains("example.com"),
|
||||
"the URL should not leak into the visible text"
|
||||
);
|
||||
let link = r.spans.iter().find(|s| s.underline).unwrap();
|
||||
assert_eq!(&r.text[link.range.clone()], "the docs");
|
||||
let at = r.text.find("docs").unwrap();
|
||||
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
|
||||
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
|
||||
let past = r.text.find("for").unwrap();
|
||||
assert!(r.link_at(past).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
|
||||
let r = block("```rust\nlet x = 1; // note\n```");
|
||||
assert_eq!(r.text, "let x = 1; // note");
|
||||
let keyword = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||
.expect("a rust fence colours its keywords");
|
||||
assert_eq!(&r.text[keyword.range.clone()], "let");
|
||||
let comment = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
|
||||
.unwrap();
|
||||
assert_eq!(&r.text[comment.range.clone()], "// note");
|
||||
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
|
||||
let r = block("```brainfuck\nlet x = 1;\n```");
|
||||
assert_eq!(r.text, "let x = 1;");
|
||||
assert_eq!(r.spans.len(), 1);
|
||||
assert!(r.spans[0].family == Some(Family::Monospace));
|
||||
assert_eq!(r.spans[0].color, Some(code_color()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
|
||||
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
|
||||
for span in &r.spans {
|
||||
assert!(
|
||||
r.text.is_char_boundary(span.range.start)
|
||||
&& r.text.is_char_boundary(span.range.end),
|
||||
"span {:?} is not on a char boundary of {:?}",
|
||||
span.range,
|
||||
r.text
|
||||
);
|
||||
}
|
||||
let string = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::String)))
|
||||
.unwrap();
|
||||
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_fence_still_renders_what_arrived() {
|
||||
let r = block("```rust\nlet x = 1;");
|
||||
assert_eq!(r.text, "let x = 1;");
|
||||
assert!(
|
||||
r.spans
|
||||
.iter()
|
||||
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
|
||||
let r = block("- one\n- two\n - deep");
|
||||
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
|
||||
let markers: Vec<_> = r
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.color == Some(marker_color()))
|
||||
.map(|s| r.text[s.range.clone()].to_string())
|
||||
.collect();
|
||||
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
|
||||
let r = block("3. three\n4. four");
|
||||
assert_eq!(r.text, "3. three\n4. four");
|
||||
let markers: Vec<_> = r
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.color == Some(marker_color()))
|
||||
.map(|s| r.text[s.range.clone()].to_string())
|
||||
.collect();
|
||||
assert_eq!(markers, ["3. ", "4. "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_is_its_text_and_takes_the_quote_frame() {
|
||||
let blocks = split_blocks("> quoted words\n> still quoted");
|
||||
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
|
||||
let r = render_block(&blocks[0], 16.0);
|
||||
assert_eq!(r.text, "quoted words still quoted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
|
||||
use BlockKind::*;
|
||||
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Heading), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(List), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Other), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Quote), BlockFrame::Quote);
|
||||
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
|
||||
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
|
||||
assert_ne!(
|
||||
frame_of(Code),
|
||||
frame_of(Table),
|
||||
"a fence and a table sit on different fills"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_pads_its_columns_to_the_widest_cell() {
|
||||
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
|
||||
let lines: Vec<&str> = r.text.lines().collect();
|
||||
assert_eq!(lines[0], "a bb");
|
||||
assert_eq!(lines[1], "\u{2500}".repeat(8));
|
||||
assert_eq!(lines[2], "cccc d");
|
||||
let bold = r.spans.iter().find(|s| s.bold).unwrap();
|
||||
assert_eq!(&r.text[bold.range.clone()], "a bb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_table_cell_wraps_inside_its_column() {
|
||||
let long = "one two three four five six seven eight nine ten eleven twelve";
|
||||
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
|
||||
for line in r.text.lines() {
|
||||
assert!(
|
||||
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
|
||||
"line too wide: {line:?}"
|
||||
);
|
||||
}
|
||||
assert!(r.text.contains("twelve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_task_list_marks_its_boxes() {
|
||||
let r = block("- [x] done\n- [ ] not");
|
||||
assert!(r.text.contains("[x] done"));
|
||||
assert!(r.text.contains("[ ] not"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
pub mod composer;
|
||||
// Keep the 1.9 MB fixture out of ordinary APKs.
|
||||
#[cfg(feature = "fixture")]
|
||||
pub mod fixture;
|
||||
pub mod markdown;
|
||||
pub mod row;
|
||||
pub(crate) mod tap;
|
||||
pub mod theme;
|
||||
pub mod tool;
|
||||
|
||||
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow, group_tool_runs};
|
||||
use iris::prelude::*;
|
||||
use std::{mem, rc::Rc};
|
||||
use theme::Theme;
|
||||
|
||||
pub struct TranscriptScreen {
|
||||
/// The transcript's own `LazySpan` -- the layout *and* the scroll
|
||||
/// position, since a lazy span owns a `ScrollController` of its own
|
||||
/// rather than being wrapped in a `ScrollArea` (`docs/SCROLL.md`).
|
||||
/// Exposed so a caller can read `.extent()`, drive it through
|
||||
/// `Scrollable` (`.scroll()`, `.fling()`, `.amt()`) or call
|
||||
/// `.jump_to_end()` directly.
|
||||
pub list: WeakWidget<LazySpan>,
|
||||
pub composer: composer::Composer,
|
||||
rebuilds: usize,
|
||||
tail: Option<(RowKey, row::TailRow)>,
|
||||
session_working: bool,
|
||||
theme: Rc<Theme>,
|
||||
}
|
||||
|
||||
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. `LazySpan::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>(&mut self, rsc: &mut Rsc, row: &FoldedRow)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
// Capped like any other row (`row::build_row`'s `cap`). A reply
|
||||
// that goes on to *grow* past the cap is never capped, because it
|
||||
// grows through `RowBlocks::apply_delta`, which appends to what is
|
||||
// already drawn -- so the cap only ever catches a row that arrived
|
||||
// long, which is the one nobody is watching arrive.
|
||||
let row::BuiltRow { key, widget, tail } = row::build_row(
|
||||
rsc,
|
||||
self.list,
|
||||
row,
|
||||
self.session_working,
|
||||
true,
|
||||
self.theme.clone(),
|
||||
);
|
||||
(self.list)(rsc).push_back(LazyItem::new(key, widget));
|
||||
self.tail = tail.map(|t| (key, t));
|
||||
}
|
||||
|
||||
pub fn set_session_working<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, working: bool)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if self.session_working == working {
|
||||
return;
|
||||
}
|
||||
self.session_working = working;
|
||||
if let Some((_, row::TailRow::Tools(tools))) = self.tail.as_mut() {
|
||||
let calls = tools.calls();
|
||||
tools.apply_calls(rsc, &calls, working);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn tail_card_count(&self) -> usize {
|
||||
match self.tail.as_ref() {
|
||||
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open or close the newest row's tool run, when it is one -- what a
|
||||
/// caller with no finger needs (`run-headless.sh`'s screenshot on this
|
||||
/// displayless machine, and the tests below). Answers whether there
|
||||
/// was such a row to act on, so a caller that expected one can say so
|
||||
/// rather than silently producing the collapsed picture.
|
||||
pub fn expand_tail_tools<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, expanded: bool) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let Some((_, row::TailRow::Tools(tools))) = self.tail.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
tools.set_group_expanded(rsc, expanded);
|
||||
true
|
||||
}
|
||||
|
||||
/// The `ReplaceLast` fast path: update the tail row in place if this
|
||||
/// really is a change to the same row, and say whether that worked.
|
||||
/// `false` for anything the caller must rebuild instead.
|
||||
fn apply_tail_delta<Rsc: HasEvents>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
key: RowKey,
|
||||
row: &FoldedRow,
|
||||
) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let Some((tail_key, kept)) = self.tail.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if *tail_key != key {
|
||||
return false;
|
||||
}
|
||||
match (kept, row) {
|
||||
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
|
||||
let (sender, markdown_src) = row::item_content(item);
|
||||
// A tool call is drawn as a card, never as markdown, so a
|
||||
// row that kept blocks and now holds one is a different
|
||||
// row -- rebuild it.
|
||||
if matches!(item, TranscriptItem::ToolRun { .. }) {
|
||||
return false;
|
||||
}
|
||||
blocks.apply_delta(rsc, sender, &markdown_src)
|
||||
}
|
||||
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
|
||||
tools.apply_calls(rsc, calls, self.session_working)
|
||||
}
|
||||
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
|
||||
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working)
|
||||
}
|
||||
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// (`LazySpan::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>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
old: &[TranscriptItem],
|
||||
new: &[TranscriptItem],
|
||||
) where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
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 } => {
|
||||
for row in &new_rows[common..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
RowDiff::ReplaceLast { common } => {
|
||||
let old_key = row::row_key(&old_rows[common].key());
|
||||
let new_key = row::row_key(&new_rows[common].key());
|
||||
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
|
||||
for row in &new_rows[common + 1..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let row::BuiltRow {
|
||||
key: new_key,
|
||||
widget,
|
||||
tail: kept,
|
||||
} = row::build_row(
|
||||
rsc,
|
||||
self.list,
|
||||
&new_rows[common],
|
||||
self.session_working,
|
||||
false,
|
||||
self.theme.clone(),
|
||||
);
|
||||
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget));
|
||||
drop(evicted); // frees the old row's widget, same as a pop would
|
||||
self.tail = kept.map(|t| (new_key, t));
|
||||
for row in &new_rows[common + 1..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
RowDiff::Rebuild => {
|
||||
self.rebuilds += 1;
|
||||
(self.list)(rsc).clear();
|
||||
self.tail = None;
|
||||
for row in &new_rows {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_rebuilds(&mut self) -> usize {
|
||||
mem::take(&mut self.rebuilds)
|
||||
}
|
||||
|
||||
/// The semantic paint IDs used by this screen. A caller can replace
|
||||
/// their entries through `rsc.ui_mut().paints.set(...)`; retained text
|
||||
/// and rect primitives keep the IDs and need no widget rebuild.
|
||||
pub fn theme(&self) -> &Theme {
|
||||
&self.theme
|
||||
}
|
||||
|
||||
/// 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<Rsc: HasEvents>(&self, rsc: &mut Rsc) -> Option<String> {
|
||||
let id = rsc
|
||||
.events()
|
||||
.controllers
|
||||
.id::<SelectionController>(self.list.id())?;
|
||||
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
|
||||
selection.selected_text(rsc)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
ui_state: &mut impl HasRoot<Rsc>,
|
||||
rows: Vec<FoldedRow>,
|
||||
) -> TranscriptScreen
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let (screen, tree) = build_tree(rsc, rows);
|
||||
ui_state.set_root(rsc, tree);
|
||||
screen
|
||||
}
|
||||
|
||||
pub fn build_tree<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
rows: Vec<FoldedRow>,
|
||||
) -> (TranscriptScreen, StrongWidget)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let theme = Rc::new(Theme::new(&mut rsc.ui_mut().paints));
|
||||
let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc);
|
||||
list.controller(
|
||||
SelectionController::new()
|
||||
.with_scroll(list)
|
||||
.separator("\n\n"),
|
||||
)
|
||||
.add(rsc);
|
||||
|
||||
// The last row's block widgets are kept for the same reason
|
||||
// `push_row` keeps them: a reply that is *already* streaming when the
|
||||
// screen is built takes its next delta through `apply`, and a `None`
|
||||
// here would send that delta down the rebuild path instead -- the
|
||||
// whole message re-shaped, which is exactly what the per-block column
|
||||
// exists to avoid, and nothing on screen or in `take_rebuilds` would
|
||||
// say so.
|
||||
let mut tail = None;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
// `false`: a row built here is history until the caller says the
|
||||
// session is working (`TranscriptScreen::set_session_working`),
|
||||
// and claiming a call is running because the screen happens to be
|
||||
// opening is exactly the inferred-as-measured mistake.
|
||||
// `cap`: every row but the last. The last is the tail, which may
|
||||
// be a reply already streaming when this screen opened, and a
|
||||
// capped row cannot take a delta (`RowBlocks::capped`).
|
||||
let cap = i + 1 < rows.len();
|
||||
let row::BuiltRow {
|
||||
key,
|
||||
widget,
|
||||
tail: kept,
|
||||
} = row::build_row(rsc, list, row, false, cap, theme.clone());
|
||||
list(rsc).push_back(LazyItem::new(key, widget));
|
||||
tail = kept.map(|t| (key, t));
|
||||
}
|
||||
|
||||
// The controller host covers gaps as well as text, so a tap anywhere in
|
||||
// the transcript can dismiss a selection. Text and link listeners may
|
||||
// see the same physical sample; `SelectionController` deduplicates it by
|
||||
// the sample's own timestamp while still returning the same tap decision
|
||||
// to whichever leaf owns the link action.
|
||||
{
|
||||
list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||
let input = &ctx.data;
|
||||
rsc.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
|
||||
selection.drag(id, rsc, input)
|
||||
});
|
||||
})
|
||||
.add(rsc);
|
||||
}
|
||||
|
||||
list.on(CursorSense::Scroll(Axis::Y), |ctx, rsc| {
|
||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||
ctx.widget(rsc).scroll(delta);
|
||||
})
|
||||
.add(rsc);
|
||||
|
||||
let composer::BuiltComposer {
|
||||
composer,
|
||||
widget: composer_bar,
|
||||
} = composer::build_composer(rsc, &theme);
|
||||
|
||||
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
|
||||
(
|
||||
TranscriptScreen {
|
||||
tail,
|
||||
session_working: false,
|
||||
list,
|
||||
composer,
|
||||
rebuilds: 0,
|
||||
theme,
|
||||
},
|
||||
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 {
|
||||
Unchanged,
|
||||
Appended { common: usize },
|
||||
ReplaceLast { common: usize },
|
||||
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() {
|
||||
RowDiff::ReplaceLast { common }
|
||||
} else {
|
||||
RowDiff::Rebuild
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod diff_tests {
|
||||
use super::*;
|
||||
|
||||
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,
|
||||
failed: 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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod apply_tests {
|
||||
use super::*;
|
||||
use crate::client::text_cap::MESSAGE_LINES;
|
||||
use std::iter;
|
||||
|
||||
struct TestFocus {
|
||||
focus: Option<WeakWidget<TextEdit>>,
|
||||
}
|
||||
impl OpenUrl for TestFocus {
|
||||
fn open_url(&mut self, _url: &str) {}
|
||||
}
|
||||
|
||||
impl FocusHost for TestFocus {
|
||||
fn recent_click(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||
self.focus = id;
|
||||
}
|
||||
fn focus_gained(&mut self, _region: Option<PixelRegion>) {}
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.focus == Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
struct TestRsc {
|
||||
ui: Ui,
|
||||
events: EventManager<TestRsc>,
|
||||
}
|
||||
impl UiRsc for TestRsc {
|
||||
fn ui(&self) -> &Ui {
|
||||
&self.ui
|
||||
}
|
||||
fn ui_mut(&mut self) -> &mut Ui {
|
||||
&mut self.ui
|
||||
}
|
||||
fn on_draw(&mut self, active: &ActiveData) {
|
||||
self.events.draw(active);
|
||||
}
|
||||
fn on_undraw(&mut self, active: &ActiveData) {
|
||||
self.events.undraw(active);
|
||||
}
|
||||
fn on_remove(&mut self, id: WidgetId) {
|
||||
self.events.remove(id);
|
||||
}
|
||||
}
|
||||
impl HasState for TestRsc {
|
||||
type State = TestFocus;
|
||||
}
|
||||
impl HasEvents for TestRsc {
|
||||
fn events(&self) -> &EventManager<Self> {
|
||||
&self.events
|
||||
}
|
||||
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||
&mut self.events
|
||||
}
|
||||
}
|
||||
|
||||
fn user(seq: u64, text: &str) -> TranscriptItem {
|
||||
TranscriptItem::UserMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant(seq: u64, text: &str) -> TranscriptItem {
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
settled: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(paragraphs: usize, tail: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for i in 0..paragraphs {
|
||||
out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n"));
|
||||
}
|
||||
out.push_str(tail);
|
||||
out
|
||||
}
|
||||
|
||||
fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) {
|
||||
let mut rsc = TestRsc {
|
||||
ui: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))];
|
||||
let new_items = vec![assistant(
|
||||
1,
|
||||
&reply(paragraphs, "and the last one is still going."),
|
||||
)];
|
||||
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
render.take_counters();
|
||||
|
||||
screen.apply(&mut rsc, &old_items, &new_items);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
|
||||
let RenderCounters { draws, shapes, .. } = render.take_counters();
|
||||
(draws, shapes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() {
|
||||
assert!(
|
||||
reply(100, "").len() > 3_000,
|
||||
"the long case must actually be a long message"
|
||||
);
|
||||
let (short_draws, short_shapes) = cost_of_one_delta(1);
|
||||
let (long_draws, long_shapes) = cost_of_one_delta(100);
|
||||
assert_eq!(
|
||||
short_draws, long_draws,
|
||||
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
|
||||
{short_draws} for a one-paragraph reply -- the earlier blocks are not being kept"
|
||||
);
|
||||
assert_eq!(
|
||||
(short_shapes, long_shapes),
|
||||
(1, 1),
|
||||
"a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \
|
||||
{short_shapes} in a one-paragraph one; it must be the last block and nothing else"
|
||||
);
|
||||
}
|
||||
|
||||
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 1,
|
||||
id: id.to_string(),
|
||||
run_id: "run".to_string(),
|
||||
tool: "Bash".to_string(),
|
||||
input: format!(r#"{{"command":"grep -rn {id} ."}}"#),
|
||||
output: output.to_string(),
|
||||
done,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_of(count: usize, output: &str, done: bool) -> Vec<TranscriptItem> {
|
||||
(0..count)
|
||||
.map(|i| call(&format!("t{i}"), output, done))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn open_run(
|
||||
rsc: &mut TestRsc,
|
||||
items: &[TranscriptItem],
|
||||
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
|
||||
let (mut screen, tree) = build_tree(rsc, group_tool_runs(items));
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, rsc);
|
||||
assert!(
|
||||
screen.expand_tail_tools(rsc, true),
|
||||
"the fixture's only row must be the tool run"
|
||||
);
|
||||
render.update(&tree, rsc);
|
||||
render.take_counters();
|
||||
(screen, tree, render)
|
||||
}
|
||||
|
||||
fn shapes_to_open(output: &str) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let items = run_of(3, output, true);
|
||||
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&items));
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
render.take_counters();
|
||||
|
||||
assert!(
|
||||
screen.expand_tail_tools(&mut rsc, true),
|
||||
"the fixture's only row must be the tool run"
|
||||
);
|
||||
render.update(&tree, &mut rsc);
|
||||
render.take_counters().shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_cards_shape_only_their_summary_lines() {
|
||||
let long: String = iter::repeat_n("a line of tool output\n", 4_000).collect();
|
||||
assert!(long.len() > 80_000, "the long case must actually be long");
|
||||
|
||||
let short_shapes = shapes_to_open("ok\n");
|
||||
let long_shapes = shapes_to_open(&long);
|
||||
assert!(
|
||||
short_shapes > 0,
|
||||
"opening a group must shape something, or this compares two zeroes"
|
||||
);
|
||||
assert_eq!(
|
||||
short_shapes, long_shapes,
|
||||
"three collapsed cards shaped {long_shapes} text layouts over 80 kB of output \
|
||||
against {short_shapes} over three bytes -- a collapsed card is laying out \
|
||||
something it does not draw"
|
||||
);
|
||||
}
|
||||
|
||||
fn shapes_for_message(text: &str) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let items = vec![
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq: 1,
|
||||
text: text.to_string(),
|
||||
settled: true,
|
||||
},
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq: 2,
|
||||
text: "ok".to_string(),
|
||||
settled: true,
|
||||
},
|
||||
];
|
||||
let (_screen, tree) = build_tree(&mut rsc, group_tool_runs(&items));
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
render.take_counters().shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_message_is_drawn_only_as_far_as_the_cap() {
|
||||
let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n);
|
||||
let capped = shapes_for_message(¶graphs(MESSAGE_LINES * 4));
|
||||
let bigger = shapes_for_message(¶graphs(MESSAGE_LINES * 40));
|
||||
assert!(
|
||||
capped > 0,
|
||||
"the screen shaped nothing, so this compares zeroes"
|
||||
);
|
||||
assert_eq!(
|
||||
capped, bigger,
|
||||
"a message ten times longer cost {bigger} text layouts against {capped} -- the cap \
|
||||
is not bounding what gets laid out",
|
||||
);
|
||||
}
|
||||
|
||||
fn cost_of_one_result(count: usize) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(count, "", false);
|
||||
let mut after = before.clone();
|
||||
after[0] = call("t0", "the result", true);
|
||||
|
||||
let (mut screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(
|
||||
screen.take_rebuilds(),
|
||||
0,
|
||||
"a result arriving must not rebuild the whole screen"
|
||||
);
|
||||
render.take_counters().draws
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_arriving_redraws_one_card_whatever_the_run_holds() {
|
||||
let small = cost_of_one_result(3);
|
||||
let large = cost_of_one_result(12);
|
||||
assert!(
|
||||
small > 0,
|
||||
"a result must redraw *something*, or this compares two zeroes"
|
||||
);
|
||||
assert_eq!(
|
||||
small, large,
|
||||
"one result redrew {large} widgets in a twelve-call run against {small} in a \
|
||||
three-call one -- the other cards are being rebuilt with it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_opens_and_closes_and_keeps_its_state_across_a_result() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(3, "", false);
|
||||
let mut after = before.clone();
|
||||
after[1] = call("t1", "done", true);
|
||||
|
||||
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&before));
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
assert!(screen.expand_tail_tools(&mut rsc, true));
|
||||
assert_eq!(screen.tail_card_count(), 3);
|
||||
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(screen.take_rebuilds(), 0);
|
||||
assert_eq!(
|
||||
screen.tail_card_count(),
|
||||
3,
|
||||
"the group closed under a result"
|
||||
);
|
||||
|
||||
assert!(screen.expand_tail_tools(&mut rsc, false));
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_joining_an_open_run_appends_one_card() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(2, "ok", true);
|
||||
let mut after = before.clone();
|
||||
after.push(call("t2", "", false));
|
||||
|
||||
let (mut screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||
assert_eq!(screen.tail_card_count(), 2);
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(
|
||||
screen.take_rebuilds(),
|
||||
0,
|
||||
"an appended call is not a rebuild"
|
||||
);
|
||||
assert_eq!(screen.tail_card_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tail_that_stops_being_tool_calls_falls_back_to_a_rebuild() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = vec![user(1, "stable"), call("t0", "", false)];
|
||||
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
|
||||
let (mut screen, _tree) = build_tree(&mut rsc, group_tool_runs(&before));
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
assert_eq!(
|
||||
screen.take_rebuilds(),
|
||||
0,
|
||||
"this is a ReplaceLast, not a whole-screen rebuild"
|
||||
);
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
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::{
|
||||
ItemKey, QuestionCard, TranscriptItem, TranscriptRow as FoldedRow,
|
||||
};
|
||||
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block};
|
||||
use crate::ui::tap::{hold_edge, on_tap};
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::ui::tool::{BuiltToolRow, ToolRow, build_tool_row};
|
||||
use iris::prelude::*;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
rc::Rc,
|
||||
slice,
|
||||
};
|
||||
|
||||
const BLOCK_GAP_DP: f32 = 8.0;
|
||||
|
||||
pub const BASE_SIZE: f32 = 16.0;
|
||||
|
||||
/// Maps string run IDs above the sequence-number range used by transcripts.
|
||||
pub fn row_key(key: &ItemKey) -> RowKey {
|
||||
match key {
|
||||
ItemKey::Seq(seq) => *seq,
|
||||
ItemKey::RunId(id) => {
|
||||
let mut h = 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 eventual limit control 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
|
||||
}
|
||||
|
||||
pub struct RowBlocks {
|
||||
blocks: Vec<Block>,
|
||||
fields: Vec<WeakWidget<Text>>,
|
||||
links: Vec<LinkTargets>,
|
||||
column: WeakWidget<Span>,
|
||||
sender: Option<String>,
|
||||
/// A capped row must be rebuilt before accepting a delta.
|
||||
capped: bool,
|
||||
theme: Rc<Theme>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Caps at a block boundary when possible, or within the first oversized block.
|
||||
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()));
|
||||
}
|
||||
match cut(&block.source, lines_left, bytes_left) {
|
||||
Some((head, _)) if kept.is_empty() => {
|
||||
kept.push(Block {
|
||||
kind: block.kind,
|
||||
source: head.to_string(),
|
||||
});
|
||||
return (kept, Some(total()));
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/// Shared with the "Show all" callback to avoid copying a long message.
|
||||
struct RowSource {
|
||||
sender: Option<String>,
|
||||
markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
// Rebuilt markdown replaces link ranges while the retained tap callback keeps
|
||||
// the same handle. Iris callbacks are single-threaded, so Rc/RefCell is enough.
|
||||
struct LinkTargets(Rc<RefCell<Vec<Link>>>);
|
||||
|
||||
impl LinkTargets {
|
||||
fn new(links: Vec<Link>) -> Self {
|
||||
Self(Rc::new(RefCell::new(links)))
|
||||
}
|
||||
|
||||
fn replace(&self, links: Vec<Link>) {
|
||||
*self.0.borrow_mut() = links;
|
||||
}
|
||||
|
||||
fn url_at(&self, byte: usize) -> Option<String> {
|
||||
self.0
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|link| link.range.contains(&byte))
|
||||
.map(|link| link.url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct BuiltBlock {
|
||||
field: WeakWidget<Text>,
|
||||
widget: StrongWidget,
|
||||
links: LinkTargets,
|
||||
}
|
||||
|
||||
const FRAME_PAD_DP: f32 = 10.0;
|
||||
const QUOTE_BAR_DP: f32 = 3.0;
|
||||
const FRAME_RADIUS_DP: f32 = 8.0;
|
||||
|
||||
fn build_block<Rsc: HasEvents>(rsc: &mut Rsc, block: &Block, theme: &Theme) -> BuiltBlock
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let frame = frame_of(block.kind, theme);
|
||||
let rendered = render_block(block, BASE_SIZE, theme);
|
||||
let links = LinkTargets::new(rendered.links);
|
||||
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
|
||||
let field = wtext(rendered.text)
|
||||
.spans(rendered.spans)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(!verbatim)
|
||||
.family(if verbatim {
|
||||
Family::Monospace
|
||||
} else {
|
||||
Family::SansSerif
|
||||
})
|
||||
.size(BASE_SIZE)
|
||||
.color(match frame {
|
||||
BlockFrame::Quote => theme.quote_text.clone(),
|
||||
_ => theme.text.clone(),
|
||||
})
|
||||
.add(rsc);
|
||||
|
||||
let tap_links = links.clone();
|
||||
field
|
||||
.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||
let (pos, size) = (ctx.data.pos, ctx.data.size);
|
||||
let input = &ctx.data;
|
||||
let outcome = rsc
|
||||
.with_nearest_controller::<SelectionController, _>(field, |id, selection, rsc| {
|
||||
selection.drag(id, rsc, input)
|
||||
})
|
||||
.unwrap_or(SelectionInput::Tapped);
|
||||
// Panning or selecting across a link must not open it.
|
||||
if outcome == SelectionInput::Tapped {
|
||||
let byte = field.selection(rsc).byte_at(pos, size);
|
||||
let url = tap_links.url_at(byte);
|
||||
if let Some(url) = url {
|
||||
log::info!("iris link: opening {url}");
|
||||
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.add(rsc);
|
||||
|
||||
let framed = match frame {
|
||||
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
|
||||
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(),
|
||||
BlockFrame::Quote => field
|
||||
.width(rest(1))
|
||||
.pad(Padding {
|
||||
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
|
||||
..Padding::ZERO
|
||||
})
|
||||
.background(rect(theme.quote_bar.clone()).width(dp(QUOTE_BAR_DP)))
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
};
|
||||
BuiltBlock {
|
||||
field,
|
||||
widget: framed,
|
||||
links,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_text_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
sender: Option<&str>,
|
||||
markdown_src: &str,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> (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, key, source, ptr, cap, theme);
|
||||
ptr(rsc).set(content);
|
||||
(strong.any(), blocks)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn row_content<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
source: Rc<RowSource>,
|
||||
ptr: WeakWidget<WidgetPtr>,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> (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 block in &blocks {
|
||||
let built = build_block(rsc, block, &theme);
|
||||
fields.push(built.field);
|
||||
links.push(built.links);
|
||||
column.push(built.widget);
|
||||
}
|
||||
if let Some(lines) = hidden {
|
||||
column.push(show_all(
|
||||
rsc,
|
||||
list,
|
||||
key,
|
||||
source.clone(),
|
||||
ptr,
|
||||
lines,
|
||||
theme.clone(),
|
||||
));
|
||||
}
|
||||
let column = column.add(rsc);
|
||||
|
||||
// The parent composition performs the header's single strong registration.
|
||||
let header: WeakWidget = match &source.sender {
|
||||
Some(name) => wtext(name.clone())
|
||||
.size(13.0)
|
||||
.color(theme.secondary_text.clone())
|
||||
.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(),
|
||||
theme,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Rebuilds a capped row uncapped; its incremental state is intentionally discarded.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn show_all<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
source: Rc<RowSource>,
|
||||
ptr: WeakWidget<WidgetPtr>,
|
||||
lines: usize,
|
||||
theme: Rc<Theme>,
|
||||
) -> 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(theme.secondary_text.clone())
|
||||
.text_align(Align::LEFT)
|
||||
.label(label)
|
||||
.add_strong(rsc);
|
||||
more(rsc).set(words);
|
||||
on_tap(rsc, more, list, move |rsc| {
|
||||
hold_edge(rsc, list, key);
|
||||
let (content, _blocks) =
|
||||
row_content(rsc, list, key, source.clone(), ptr, false, theme.clone());
|
||||
let _old = ptr(rsc).replace(content);
|
||||
});
|
||||
more_strong.any()
|
||||
}
|
||||
|
||||
impl RowBlocks {
|
||||
/// Updates only the changed tail blocks, or returns `false` when a rebuild is required.
|
||||
pub fn apply_delta<Rsc: HasEvents>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
sender: Option<&str>,
|
||||
markdown_src: &str,
|
||||
) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if self.sender.as_deref() != sender {
|
||||
return false;
|
||||
}
|
||||
if self.capped {
|
||||
return false;
|
||||
}
|
||||
let new_blocks = display_blocks(markdown_src);
|
||||
let common = common_prefix(&self.blocks, &new_blocks);
|
||||
// A delta may append or rewrite only the current final block.
|
||||
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
|
||||
return false;
|
||||
}
|
||||
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, &self.theme);
|
||||
field(rsc).set_with_spans(rendered.text, rendered.spans);
|
||||
links.replace(rendered.links);
|
||||
}
|
||||
_ => {
|
||||
let built = build_block(rsc, block, &self.theme);
|
||||
self.fields.push(built.field);
|
||||
self.links.push(built.links);
|
||||
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
||||
column.push(built.widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.blocks = new_blocks;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn build_single<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
item: &TranscriptItem,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> (StrongWidget, RowBlocks)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let (sender, markdown_src) = item_content(item);
|
||||
build_text_row(rsc, list, key, sender, &markdown_src, cap, theme)
|
||||
}
|
||||
|
||||
/// Incremental state retained for whichever kind of row is currently last.
|
||||
pub enum TailRow {
|
||||
Blocks(RowBlocks),
|
||||
Tools(ToolRow),
|
||||
}
|
||||
|
||||
pub struct BuiltRow {
|
||||
pub key: RowKey,
|
||||
pub widget: StrongWidget,
|
||||
pub tail: Option<TailRow>,
|
||||
}
|
||||
|
||||
/// `cap` limits historical rows; a live tail must remain uncapped.
|
||||
pub fn build_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
row: &FoldedRow,
|
||||
working: bool,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> BuiltRow
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
// A single tool call still draws as a card, without a redundant group wrapper.
|
||||
let calls = match row {
|
||||
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => Some(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 BuiltToolRow { widget, row: tools } =
|
||||
build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
|
||||
return BuiltRow {
|
||||
key,
|
||||
widget,
|
||||
tail: 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, key, item, cap, theme);
|
||||
BuiltRow {
|
||||
key,
|
||||
widget,
|
||||
tail: 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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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",
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use iris::prelude::*;
|
||||
|
||||
pub(crate) fn on_tap<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
ptr: WeakWidget<WidgetPtr>,
|
||||
list: WeakWidget<LazySpan>,
|
||||
f: impl Fn(&mut Rsc) + 'static,
|
||||
) where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||
let input = &ctx.data;
|
||||
let outcome = rsc
|
||||
.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
|
||||
selection.drag(id, rsc, input)
|
||||
})
|
||||
.unwrap_or(SelectionInput::Tapped);
|
||||
if outcome == SelectionInput::Tapped {
|
||||
f(rsc);
|
||||
}
|
||||
})
|
||||
.add(rsc);
|
||||
}
|
||||
|
||||
pub(crate) fn hold_edge(rsc: &mut impl UiRsc, list: WeakWidget<LazySpan>, key: RowKey) {
|
||||
if let Some((top, _bottom)) = list(rsc).extent(key) {
|
||||
list(rsc).note_tap(top);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use iris::prelude::*;
|
||||
|
||||
/// The shared phone/desktop paint handles. Replacing their paint-table
|
||||
/// entries changes the theme without rebuilding widgets or primitives.
|
||||
#[derive(Clone)]
|
||||
pub struct Theme {
|
||||
pub text: PaintId,
|
||||
pub code: PaintId,
|
||||
pub link: PaintId,
|
||||
pub marker: PaintId,
|
||||
pub verbatim_surface: PaintId,
|
||||
pub table_surface: PaintId,
|
||||
pub quote_bar: PaintId,
|
||||
pub quote_text: PaintId,
|
||||
pub strikethrough: PaintId,
|
||||
pub card_surface: PaintId,
|
||||
pub group_surface: PaintId,
|
||||
pub muted: PaintId,
|
||||
pub awaiting: PaintId,
|
||||
pub failed: PaintId,
|
||||
pub unknown: PaintId,
|
||||
pub composer_surface: PaintId,
|
||||
pub secondary_text: PaintId,
|
||||
pub syntax_keyword: PaintId,
|
||||
pub syntax_string: PaintId,
|
||||
pub syntax_literal: PaintId,
|
||||
pub syntax_comment: PaintId,
|
||||
pub syntax_metadata: PaintId,
|
||||
pub syntax_punctuation: PaintId,
|
||||
pub syntax_mark: PaintId,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn new(paints: &mut Paints) -> Self {
|
||||
Self {
|
||||
text: paints.add(srgb(0xCDD6F4)),
|
||||
code: paints.add(srgb(0xCDD6F4)),
|
||||
link: paints.add(srgb(0x89B4FA)),
|
||||
marker: paints.add(srgb(0xB4BEFE)),
|
||||
verbatim_surface: paints.add(srgb(0x11111B)),
|
||||
table_surface: paints.add(srgb(0x313244)),
|
||||
quote_bar: paints.add(srgb(0x585B70)),
|
||||
quote_text: paints.add(srgb(0xA6ADC8)),
|
||||
strikethrough: paints.add(srgb(0x6C7086)),
|
||||
card_surface: paints.add(srgb(0x313244)),
|
||||
group_surface: paints.add(srgb(0x181825)),
|
||||
muted: paints.add(srgb(0xA6ADC8)),
|
||||
awaiting: paints.add(srgb(0xFAB387)),
|
||||
failed: paints.add(srgb(0xF38BA8)),
|
||||
unknown: paints.add(srgb(0xF9E2AF)),
|
||||
composer_surface: paints.add(Srgba8::rgb(40, 40, 46)),
|
||||
secondary_text: paints.add(Srgba8::rgb(150, 150, 160)),
|
||||
syntax_keyword: paints.add(srgb(0xCBA6F7)),
|
||||
syntax_string: paints.add(srgb(0xA6E3A1)),
|
||||
syntax_literal: paints.add(srgb(0xFAB387)),
|
||||
syntax_comment: paints.add(srgb(0x6C7086)),
|
||||
syntax_metadata: paints.add(srgb(0xF9E2AF)),
|
||||
syntax_punctuation: paints.add(srgb(0xA6ADC8)),
|
||||
syntax_mark: paints.add(srgb(0x89DCEB)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn srgb(hex: u32) -> Srgba8 {
|
||||
Srgba8::rgb(
|
||||
((hex >> 16) & 0xff) as u8,
|
||||
((hex >> 8) & 0xff) as u8,
|
||||
(hex & 0xff) as u8,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label};
|
||||
use crate::client::tool_summary::{ToolInput, parse_tool_input};
|
||||
use crate::client::transcript_fold::{ToolState, TranscriptItem};
|
||||
use crate::ui::markdown::highlight_into;
|
||||
use crate::ui::tap::{hold_edge, on_tap};
|
||||
use crate::ui::theme::Theme;
|
||||
use iris::prelude::*;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
const NAME_SIZE: f32 = 14.0;
|
||||
const BODY_SIZE: f32 = 12.0;
|
||||
const LABEL_SIZE: f32 = 11.0;
|
||||
|
||||
const CARD_PAD_DP: f32 = 12.0;
|
||||
const CARD_RADIUS_DP: f32 = 12.0;
|
||||
const GAP_DP: f32 = 8.0;
|
||||
const RAW_RADIUS_DP: f32 = 4.0;
|
||||
const RAW_PAD_DP: f32 = 8.0;
|
||||
const GROUP_INSET_DP: f32 = 4.0;
|
||||
|
||||
const MARK_DP: f32 = 9.0;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ToolRowState {
|
||||
group_expanded: bool,
|
||||
open: HashMap<String, bool>,
|
||||
whole: HashMap<(String, Part), bool>,
|
||||
}
|
||||
|
||||
struct ToolRowData {
|
||||
calls: Vec<TranscriptItem>,
|
||||
view: ToolRowState,
|
||||
working: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
enum Part {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
struct ToolRowShared {
|
||||
// The row owner and `'static` tap callbacks share this model. Callbacks
|
||||
// are single-threaded but cannot borrow from `ToolRow`, hence Rc/RefCell.
|
||||
data: RefCell<ToolRowData>,
|
||||
/// One `WidgetPtr` per call, in order -- what makes a result cost one
|
||||
/// card. Empty while the group is collapsed, because a collapsed group
|
||||
/// draws no cards at all. Its path out is [`build_content`], which
|
||||
/// clears it before building whatever replaces them.
|
||||
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
|
||||
content: Cell<Option<WeakWidget<WidgetPtr>>>,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
theme: Rc<Theme>,
|
||||
}
|
||||
|
||||
/// One transcript row's worth of tool calls, kept by the caller for the
|
||||
/// row a result can still land in -- the tool-call counterpart of
|
||||
/// [`crate::ui::row::RowBlocks`], and the reason a `ToolEnd` costs one card
|
||||
/// rather than a row.
|
||||
pub struct ToolRow {
|
||||
shared: Rc<ToolRowShared>,
|
||||
}
|
||||
|
||||
pub struct BuiltToolRow {
|
||||
pub widget: StrongWidget,
|
||||
pub row: ToolRow,
|
||||
}
|
||||
|
||||
fn text<Rsc>(content: impl Into<String>, size: f32, color: PaintId) -> TextBuilder<Rsc> {
|
||||
wtext(content)
|
||||
.size(size)
|
||||
.color(color)
|
||||
.text_align(Align::LEFT)
|
||||
}
|
||||
|
||||
fn disclosure<Rsc>(glyph: &'static str, theme: &Theme) -> TextBuilder<Rsc> {
|
||||
text(glyph, MARK_DP, theme.muted.clone()).family(Family::Icons)
|
||||
}
|
||||
|
||||
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, theme: &Theme) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let field = body
|
||||
.family(Family::Monospace)
|
||||
.size(BODY_SIZE)
|
||||
.wrap(false)
|
||||
.add(rsc);
|
||||
field
|
||||
.scrollable(Axis::X, Pin::Start)
|
||||
.pad(dp(RAW_PAD_DP))
|
||||
.masked_by(rect(theme.verbatim_surface.clone()).radius(dp(RAW_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn state_word(state: ToolState) -> Option<&'static str> {
|
||||
match state {
|
||||
ToolState::Deciding => Some("your turn"),
|
||||
ToolState::Running => Some("running"),
|
||||
ToolState::Failed => Some("failed"),
|
||||
ToolState::NoResult => Some("no result"),
|
||||
ToolState::Succeeded => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_mark(state: ToolState, theme: &Theme) -> Option<(&'static str, PaintId)> {
|
||||
let color = match state {
|
||||
ToolState::Deciding => theme.awaiting.clone(),
|
||||
ToolState::Running => theme.muted.clone(),
|
||||
ToolState::Failed => theme.failed.clone(),
|
||||
ToolState::NoResult => theme.unknown.clone(),
|
||||
ToolState::Succeeded => return None,
|
||||
};
|
||||
Some((
|
||||
state_word(state).expect("non-success state has a label"),
|
||||
color,
|
||||
))
|
||||
}
|
||||
|
||||
/// What a screen reader is given for one card, and what a `ui-trace`
|
||||
/// script taps by: the tool, what the call is for, and how it went when
|
||||
/// that is anything but "fine".
|
||||
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
|
||||
let mut name = tool.to_string();
|
||||
if let Some(title) = parsed.title() {
|
||||
name.push_str(": ");
|
||||
name.push_str(title);
|
||||
}
|
||||
if let Some(word) = state_word(state) {
|
||||
name.push_str(" (");
|
||||
name.push_str(word);
|
||||
name.push(')');
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// The heading a group carries, also used as its accessibility label.
|
||||
fn group_label(count: usize) -> String {
|
||||
format!("Called {count} tools")
|
||||
}
|
||||
|
||||
fn capped(body: &str, whole: bool) -> (&str, usize, bool) {
|
||||
match cut(body, VERBATIM_LINES, VERBATIM_BYTES) {
|
||||
Some((head, lines)) if !whole => (head, lines, true),
|
||||
Some((_, lines)) => (body, lines, false),
|
||||
None => (body, body.lines().count(), false),
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_whole(shared: &ToolRowShared, id: &str, part: Part) -> bool {
|
||||
shared
|
||||
.data
|
||||
.borrow()
|
||||
.view
|
||||
.whole
|
||||
.get(&(id.to_string(), part))
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A control rather than a note, and it says the count rather than "more",
|
||||
/// because the reader is deciding whether to ask for it: "Show all 4,000
|
||||
/// lines" and "Show all 12 lines" are different decisions and the word
|
||||
/// "more" tells them apart not at all.
|
||||
fn show_all<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
shared: &Rc<ToolRowShared>,
|
||||
index: usize,
|
||||
id: &str,
|
||||
part: Part,
|
||||
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 = text(label.clone(), LABEL_SIZE, shared.theme.muted.clone())
|
||||
.label(label)
|
||||
.add_strong(rsc);
|
||||
more(rsc).set(words);
|
||||
let shared_for_tap = shared.clone();
|
||||
let key = (id.to_string(), part);
|
||||
on_tap(rsc, more, shared.list, move |rsc| {
|
||||
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
|
||||
shared_for_tap
|
||||
.data
|
||||
.borrow_mut()
|
||||
.view
|
||||
.whole
|
||||
.insert(key.clone(), true);
|
||||
redraw_card(rsc, &shared_for_tap, index);
|
||||
});
|
||||
more_strong.any()
|
||||
}
|
||||
|
||||
fn output_block<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
shared: &Rc<ToolRowShared>,
|
||||
index: usize,
|
||||
id: &str,
|
||||
output: &str,
|
||||
call_state: ToolState,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if output.is_empty() {
|
||||
let (words, colour) = match call_state {
|
||||
ToolState::Succeeded => ("No output", shared.theme.muted.clone()),
|
||||
ToolState::Failed => ("Failed, with no output", shared.theme.failed.clone()),
|
||||
ToolState::NoResult => ("No result ever arrived", shared.theme.unknown.clone()),
|
||||
ToolState::Running | ToolState::Deciding => {
|
||||
("No output yet", shared.theme.muted.clone())
|
||||
}
|
||||
};
|
||||
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
|
||||
}
|
||||
|
||||
let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
|
||||
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
|
||||
column.push(
|
||||
text("Output", LABEL_SIZE, shared.theme.text.clone())
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone());
|
||||
column.push(raw_block(rsc, body, &shared.theme));
|
||||
if was_cut {
|
||||
column.push(show_all(rsc, shared, index, id, Part::Output, lines));
|
||||
}
|
||||
column.width(rest(1)).add_strong(rsc).any()
|
||||
}
|
||||
|
||||
fn build_card<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
shared: &Rc<ToolRowShared>,
|
||||
index: usize,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let call = shared.data.borrow().calls[index].clone();
|
||||
let TranscriptItem::ToolRun {
|
||||
id,
|
||||
tool,
|
||||
input,
|
||||
output,
|
||||
..
|
||||
} = &call
|
||||
else {
|
||||
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
|
||||
return Span::empty(Dir::DOWN).add_strong(rsc).any();
|
||||
};
|
||||
let parsed = parse_tool_input(tool, input);
|
||||
let data = shared.data.borrow();
|
||||
let call_state = ToolState::of(&call, data.working).expect("matched ToolRun above");
|
||||
let open =
|
||||
data.view.open.get(id).copied().unwrap_or(false) || call_state == ToolState::Deciding;
|
||||
drop(data);
|
||||
|
||||
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
|
||||
header.push(
|
||||
disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme)
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
header.push(
|
||||
text(tool.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
match (open, parsed.title()) {
|
||||
(true, _) | (false, None) => {
|
||||
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
|
||||
}
|
||||
(false, Some(title)) => header.push(
|
||||
text(title.to_string(), BODY_SIZE, shared.theme.muted.clone())
|
||||
.wrap(false)
|
||||
.masked()
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
),
|
||||
}
|
||||
if open && let Some(timeout) = &parsed.timeout {
|
||||
header.push(
|
||||
text(
|
||||
format!("timeout {timeout}"),
|
||||
LABEL_SIZE,
|
||||
shared.theme.muted.clone(),
|
||||
)
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
}
|
||||
if let Some((word, colour)) = state_mark(call_state, &shared.theme) {
|
||||
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
|
||||
}
|
||||
|
||||
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
|
||||
column.push(header.width(rest(1)).add_strong(rsc).any());
|
||||
if open {
|
||||
if let Some(description) = &parsed.description {
|
||||
column.push(
|
||||
text(description.clone(), BODY_SIZE, shared.theme.muted.clone())
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
}
|
||||
let whole = wants_whole(shared, id, Part::Input);
|
||||
let mut input_lines = 0usize;
|
||||
let mut input_cut = false;
|
||||
if let Some(subject) = &parsed.subject {
|
||||
let (shown, lines, was_cut) = capped(subject, whole);
|
||||
input_lines += lines;
|
||||
input_cut |= was_cut;
|
||||
let spans = match parsed.language {
|
||||
Some(language) => {
|
||||
let mut spans = Vec::new();
|
||||
highlight_into(&mut spans, shown, 0..shown.len(), language, &shared.theme);
|
||||
spans
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()).spans(spans);
|
||||
column.push(raw_block(rsc, body, &shared.theme));
|
||||
}
|
||||
if !parsed.rest.is_empty() {
|
||||
// Never dropped: a field left out would be claiming the tool
|
||||
// had no other input when it might (`ToolInput.kt`). Capped is
|
||||
// not dropped -- the field is still there, with its size said
|
||||
// out loud.
|
||||
let joined = parsed.rest.join("\n");
|
||||
let (shown, lines, was_cut) = capped(&joined, whole);
|
||||
input_lines += lines;
|
||||
input_cut |= was_cut;
|
||||
let body = text(shown.to_string(), BODY_SIZE, shared.theme.muted.clone());
|
||||
column.push(raw_block(rsc, body, &shared.theme));
|
||||
}
|
||||
if input_cut {
|
||||
column.push(show_all(rsc, shared, index, id, Part::Input, input_lines));
|
||||
}
|
||||
column.push(output_block(rsc, shared, index, id, output, call_state));
|
||||
}
|
||||
|
||||
column
|
||||
.width(rest(1))
|
||||
.pad(dp(CARD_PAD_DP))
|
||||
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.label(card_label(tool, &parsed, call_state))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<ToolRowShared>, index: usize)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let Some(ptr) = shared.card_ptr(index) else {
|
||||
debug_assert!(false, "card {index} has no widget to redraw");
|
||||
return;
|
||||
};
|
||||
let content = build_card(rsc, shared, index);
|
||||
let _old = ptr(rsc).replace(content);
|
||||
}
|
||||
|
||||
fn build_card_ptr<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
shared: &Rc<ToolRowShared>,
|
||||
index: usize,
|
||||
) -> (StrongWidget, WeakWidget<WidgetPtr>)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let strong = WidgetPtr::new().add_strong(rsc);
|
||||
let ptr = strong.weak();
|
||||
shared.cards.borrow_mut().push(ptr);
|
||||
debug_assert_eq!(
|
||||
shared.cards.borrow().len(),
|
||||
index + 1,
|
||||
"a card's index is its position, and both are the call's"
|
||||
);
|
||||
let content = build_card(rsc, shared, index);
|
||||
ptr(rsc).set(content);
|
||||
let for_tap = shared.clone();
|
||||
on_tap(rsc, ptr, shared.list, move |rsc| {
|
||||
hold_edge(rsc, for_tap.list, for_tap.key);
|
||||
let Some(id) = for_tap.call_id(index) else {
|
||||
debug_assert!(false, "tapped card {index} is no longer in the row");
|
||||
return;
|
||||
};
|
||||
let was = for_tap
|
||||
.data
|
||||
.borrow()
|
||||
.view
|
||||
.open
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
for_tap.data.borrow_mut().view.open.insert(id, !was);
|
||||
redraw_card(rsc, &for_tap, index);
|
||||
});
|
||||
(strong.any(), ptr)
|
||||
}
|
||||
|
||||
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<ToolRowShared>) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let strong = WidgetPtr::new().add_strong(rsc);
|
||||
let ptr = strong.weak();
|
||||
let mark = disclosure(icon::COLLAPSE, &shared.theme)
|
||||
.center_text()
|
||||
.width(rest(1))
|
||||
.pad(dp(CARD_PAD_DP))
|
||||
// Anything shown only as a mark still needs a name: this is what
|
||||
// a screen reader reads and what a `ui-trace` script taps.
|
||||
.label("Collapse these tool calls")
|
||||
.add_strong(rsc);
|
||||
ptr(rsc).set(mark);
|
||||
let for_tap = shared.clone();
|
||||
on_tap(rsc, ptr, shared.list, move |rsc| {
|
||||
toggle_group(rsc, &for_tap)
|
||||
});
|
||||
strong.any()
|
||||
}
|
||||
|
||||
/// Rebuilt whole when the group opens or closes, because that is a change
|
||||
/// of what the row *is* rather than of one card in it. Everything a single
|
||||
/// card's tap does goes through [`redraw_card`] instead.
|
||||
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<ToolRowShared>) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
shared.cards.borrow_mut().clear();
|
||||
let count = shared.data.borrow().calls.len();
|
||||
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
|
||||
|
||||
if count == 1 {
|
||||
return build_card_ptr(rsc, shared, 0).0;
|
||||
}
|
||||
|
||||
if !shared.data.borrow().view.group_expanded {
|
||||
let heading = group_label(count);
|
||||
return text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||
.pad(dp(CARD_PAD_DP))
|
||||
.width(rest(1))
|
||||
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.label(heading)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
}
|
||||
|
||||
let heading = group_label(count);
|
||||
let mut group = Span::empty(Dir::DOWN);
|
||||
group.push(
|
||||
text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||
.pad(dp(CARD_PAD_DP))
|
||||
.width(rest(1))
|
||||
.label(heading)
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
{
|
||||
let mut cards = Span::empty(Dir::DOWN);
|
||||
for index in 0..count {
|
||||
cards.push(build_card_ptr(rsc, shared, index).0);
|
||||
}
|
||||
group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any());
|
||||
}
|
||||
group.push(collapse_bar(rsc, shared));
|
||||
group
|
||||
.width(rest(1))
|
||||
.background(rect(shared.theme.group_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<ToolRowShared>)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
hold_edge(rsc, shared.list, shared.key);
|
||||
let mut data = shared.data.borrow_mut();
|
||||
data.view.group_expanded = !data.view.group_expanded;
|
||||
drop(data);
|
||||
let content = build_content(rsc, shared);
|
||||
shared.set_content(rsc, content);
|
||||
}
|
||||
|
||||
impl ToolRowShared {
|
||||
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
|
||||
let Some(ptr) = self.content.get() else {
|
||||
debug_assert!(
|
||||
false,
|
||||
"the row's content pointer is set before anything can tap it"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let _old = ptr(rsc).replace(content);
|
||||
}
|
||||
|
||||
fn call_id(&self, index: usize) -> Option<String> {
|
||||
match self.data.borrow().calls.get(index) {
|
||||
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
|
||||
self.cards.borrow().get(index).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// `working` is the caller's `session_working` **for this row** -- true
|
||||
/// only for the newest row of a session that is still doing something.
|
||||
/// Every row behind it belongs to a turn that has ended, so a call in one
|
||||
/// with no result never came back rather than still running.
|
||||
pub fn build_tool_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
calls: Vec<TranscriptItem>,
|
||||
working: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> BuiltToolRow
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let shared = Rc::new(ToolRowShared {
|
||||
data: RefCell::new(ToolRowData {
|
||||
calls,
|
||||
view: ToolRowState::default(),
|
||||
working,
|
||||
}),
|
||||
cards: RefCell::new(Vec::new()),
|
||||
content: Cell::new(None),
|
||||
list,
|
||||
key,
|
||||
theme,
|
||||
});
|
||||
let content_strong = WidgetPtr::new().add_strong(rsc);
|
||||
let content = content_strong.weak();
|
||||
shared.content.set(Some(content));
|
||||
let inner = build_content(rsc, &shared);
|
||||
content(rsc).set(inner);
|
||||
BuiltToolRow {
|
||||
widget: content_strong.any(),
|
||||
row: ToolRow { shared },
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolRow {
|
||||
/// The calls this row is currently drawing -- what a caller passes
|
||||
/// back to [`Self::apply_calls`] when something other than the calls
|
||||
/// themselves changed (the session's status).
|
||||
pub fn calls(&self) -> Vec<TranscriptItem> {
|
||||
self.shared.data.borrow().calls.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn card_count(&self) -> usize {
|
||||
self.shared.cards.borrow().len()
|
||||
}
|
||||
|
||||
/// Exists because the expanded appearance is otherwise unreachable
|
||||
/// from anything that cannot press the screen -- a headless
|
||||
/// screenshot on this displayless machine, and a test. Same path a tap
|
||||
/// takes, including `LazySpan::note_tap`, so what it produces is what a
|
||||
/// reader would have got.
|
||||
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if self.shared.data.borrow().view.group_expanded != expanded {
|
||||
toggle_group(rsc, &self.shared);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring this row up to date with `calls` **without** rebuilding the
|
||||
/// cards that did not change, and say whether that was possible.
|
||||
/// `false` means the caller must rebuild the row the ordinary way.
|
||||
pub fn apply_calls<Rsc: HasEvents>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
calls: &[TranscriptItem],
|
||||
working: bool,
|
||||
) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if calls.is_empty()
|
||||
|| !calls
|
||||
.iter()
|
||||
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let old = self.shared.data.borrow().calls.clone();
|
||||
if calls.len() < old.len() {
|
||||
return false;
|
||||
}
|
||||
if (old.len() == 1) != (calls.len() == 1) {
|
||||
return false;
|
||||
}
|
||||
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
|
||||
let ids: HashSet<String> = calls
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
{
|
||||
let mut data = self.shared.data.borrow_mut();
|
||||
data.working = working;
|
||||
data.calls = calls.to_vec();
|
||||
data.view.open.retain(|id, _| ids.contains(id));
|
||||
data.view.whole.retain(|(id, _), _| ids.contains(id));
|
||||
}
|
||||
|
||||
if self.shared.cards.borrow().is_empty() {
|
||||
if calls.len() != old.len() {
|
||||
let content = build_content(rsc, &self.shared);
|
||||
self.shared.set_content(rsc, content);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
debug_assert_eq!(
|
||||
self.shared.cards.borrow().len(),
|
||||
old.len(),
|
||||
"an open row draws exactly one card per call"
|
||||
);
|
||||
|
||||
for index in changed {
|
||||
redraw_card(rsc, &self.shared, index);
|
||||
}
|
||||
if calls.len() > old.len() {
|
||||
let content = build_content(rsc, &self.shared);
|
||||
self.shared.set_content(rsc, content);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user