From de92fccba557f71e85f009e72c45f087bdc42ca1 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 10 Sep 2026 18:35:24 -0400 Subject: [PATCH] Add retained paints and shared text selection --- app-rust/examples/phone.rs | 2 +- app-rust/src/android/bench_client.rs | 16 +- app-rust/src/android/transcript_client.rs | 6 +- app-rust/src/desktop/app.rs | 10 +- app-rust/src/ui/composer.rs | 9 +- app-rust/src/ui/markdown.rs | 115 ++-- app-rust/src/ui/mod.rs | 184 ++---- app-rust/src/ui/row.rs | 104 ++- app-rust/src/ui/selection.rs | 378 ----------- app-rust/src/ui/tap.rs | 23 +- app-rust/src/ui/theme.rs | 70 ++ app-rust/src/ui/tool.rs | 197 +++--- app-rust/tests/phone_screen.rs | 26 + app-rust/tests/top_edge.rs | 2 +- docs/IRIS_TODO.md | 43 +- docs/RUST.md | 36 +- docs/SCROLL.md | 10 +- docs/TEXTURES.md | 20 + iris/benches/message_list.rs | 4 +- iris/core/src/event/controller.rs | 261 ++++++++ iris/core/src/event/manager.rs | 11 +- iris/core/src/event/mod.rs | 2 + iris/core/src/event/rsc.rs | 72 ++- iris/core/src/lib.rs | 2 - iris/core/src/primitive/color.rs | 631 ++++++++++++++---- iris/core/src/primitive/text.rs | 47 +- iris/core/src/render/atlas.rs | 4 +- iris/core/src/render/mod.rs | 83 ++- iris/core/src/render/primitive.rs | 22 +- iris/core/src/render/shader.wgsl | 13 +- iris/core/src/render/texture.rs | 13 +- iris/core/src/ui/active.rs | 7 +- iris/core/src/ui/mod.rs | 4 +- iris/core/src/ui/painter.rs | 25 +- iris/core/src/ui/render_state.rs | 46 +- iris/core/src/widget/mod.rs | 14 + iris/examples/message_list.rs | 8 +- iris/examples/minimal.rs | 2 +- iris/examples/task.rs | 8 +- iris/examples/view.rs | 6 +- iris/src/access_tests.rs | 6 +- iris/src/android/render.rs | 46 +- iris/src/android/view.rs | 3 + iris/src/attr.rs | 8 +- iris/src/default/mod.rs | 29 +- iris/src/default/render.rs | 39 +- iris/src/event.rs | 11 + iris/src/layout_tests.rs | 55 +- iris/src/sense.rs | 2 +- iris/src/sense_tests.rs | 20 +- iris/src/widget/position/lazy_span.rs | 10 +- iris/src/widget/position/scroll_area.rs | 2 +- iris/src/widget/position/span.rs | 4 + iris/src/widget/rect.rs | 29 +- iris/src/widget/text/build.rs | 2 +- iris/src/widget/text/edit.rs | 171 ++--- iris/src/widget/text/mod.rs | 148 ++++- iris/src/widget/text/selection.rs | 756 ++++++++++++++++++++++ iris/tabs-ui/src/lib.rs | 90 +-- iris/tests/color_space.rs | 178 +++++ 60 files changed, 2848 insertions(+), 1297 deletions(-) delete mode 100644 app-rust/src/ui/selection.rs create mode 100644 app-rust/src/ui/theme.rs create mode 100644 iris/core/src/event/controller.rs create mode 100644 iris/src/widget/text/selection.rs create mode 100644 iris/tests/color_space.rs diff --git a/app-rust/examples/phone.rs b/app-rust/examples/phone.rs index c22037a..554a8cf 100644 --- a/app-rust/examples/phone.rs +++ b/app-rust/examples/phone.rs @@ -88,7 +88,7 @@ impl DefaultAppState for Client { } Err(message) => { let text = wtext(format!("Couldn't fold the bench fixture: {message}")) - .color(Color::WHITE) + .color(PaintId::WHITE) .wrap(true) .pad(dp(16)) .add_strong(rsc) diff --git a/app-rust/src/android/bench_client.rs b/app-rust/src/android/bench_client.rs index 698a302..dd2d72a 100644 --- a/app-rust/src/android/bench_client.rs +++ b/app-rust/src/android/bench_client.rs @@ -69,7 +69,7 @@ impl HasAndroidUiState for BenchClient { fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget { wtext(message.to_string()) - .color(Color::WHITE) + .color(PaintId::WHITE) .wrap(true) .pad(16) .add_strong(rsc) @@ -124,7 +124,7 @@ impl AndroidAppState for BenchClient { .text_align(Align::LEFT) .wrap(true) .size(14) - .color(Color::WHITE) + .color(PaintId::WHITE) .attr::(()) .label("Benchmark report") .add(rsc); @@ -277,7 +277,7 @@ fn trace_line(at_start: bool, at_end: bool) -> String { /// why it needs one at all. A dark neutral rather than pure black /// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel /// instead of a hole in the background the buttons happen to float in. -const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255); +const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255); /// `top_pad` is the status-bar inset in physical pixels (0.0 until /// `on_insets_changed` has run once) -- folded in here, rather than @@ -289,7 +289,7 @@ const HEADER_TEXT: f32 = 18.0; const HEADER_ROW_HEIGHT_DP: f32 = 56.0; fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { - let run_rect = rect(Color::rgb(40, 70, 40)) + let run_rect = rect(Srgba8::rgb(40, 70, 40)) .on( CursorSense::click(), |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { @@ -307,7 +307,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { .pad(dp(8)) .add(rsc); - let copy_rect = rect(Color::rgb(50, 50, 60)) + let copy_rect = rect(Srgba8::rgb(50, 50, 60)) .on( CursorSense::click(), |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { @@ -325,7 +325,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { .pad(dp(8)) .add(rsc); - let diag_rect = rect(Color::rgb(60, 45, 70)) + let diag_rect = rect(Srgba8::rgb(60, 45, 70)) .on( CursorSense::click(), |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { @@ -351,9 +351,9 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { // either full of trace or has none. let tracing = iris::diagnostics::trace_enabled(); let trace_rect = rect(if tracing { - Color::rgb(90, 70, 30) + Srgba8::rgb(90, 70, 30) } else { - Color::rgb(50, 50, 60) + Srgba8::rgb(50, 50, 60) }) .on( CursorSense::click(), diff --git a/app-rust/src/android/transcript_client.rs b/app-rust/src/android/transcript_client.rs index 85973de..dca8a3b 100644 --- a/app-rust/src/android/transcript_client.rs +++ b/app-rust/src/android/transcript_client.rs @@ -36,7 +36,7 @@ fn build_transport() -> Result { fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget { wtext(message.to_string()) - .color(Color::WHITE) + .color(PaintId::WHITE) .wrap(true) .pad(16) .add_strong(rsc) @@ -45,7 +45,7 @@ fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget { fn frame_report_controls(rsc: &mut AndroidRsc) -> WeakWidget { type Rsc = AndroidRsc; - let report_rect = rect(Color::rgb(50, 50, 60)) + let report_rect = rect(Srgba8::rgb(50, 50, 60)) .on( CursorSense::click(), |ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx @@ -69,7 +69,7 @@ fn frame_report_controls(rsc: &mut AndroidRsc) -> WeakWidget { .pad(8) .add(rsc); - let reset_rect = rect(Color::rgb(70, 40, 40)) + let reset_rect = rect(Srgba8::rgb(70, 40, 40)) .on( CursorSense::click(), |ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| { diff --git a/app-rust/src/desktop/app.rs b/app-rust/src/desktop/app.rs index fd5d80b..02a9a5f 100644 --- a/app-rust/src/desktop/app.rs +++ b/app-rust/src/desktop/app.rs @@ -193,7 +193,7 @@ impl Client { list(rsc).push(row); } let tree = list - .background(rect(Color::rgb(24, 24, 28))) + .background(rect(Srgba8::rgb(24, 24, 28))) .add_strong(rsc) .any(); (self.list_ptr)(rsc).set(tree); @@ -299,14 +299,14 @@ fn session_row( selected: bool, ) -> StrongWidget { let bg = if selected { - Color::rgb(58, 90, 138) + Srgba8::rgb(58, 90, 138) } else { - Color::rgb(38, 38, 44) + Srgba8::rgb(38, 38, 44) }; let id = session.id.clone(); let label = format!("{}\n{}", session.title, session.status); wtext(label) - .color(Color::WHITE) + .color(PaintId::WHITE) .wrap(true) .pad(10) .width(rest(1)) @@ -323,7 +323,7 @@ fn session_row( fn placeholder(rsc: &mut DefaultRsc, message: &str) -> StrongWidget { wtext(message.to_string()) - .color(Color::WHITE) + .color(PaintId::WHITE) .wrap(true) .pad(16) .add_strong(rsc) diff --git a/app-rust/src/ui/composer.rs b/app-rust/src/ui/composer.rs index 35476a4..78e1d12 100644 --- a/app-rust/src/ui/composer.rs +++ b/app-rust/src/ui/composer.rs @@ -1,11 +1,10 @@ +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; -const BAR_FILL: UiColor = UiColor::new(40, 40, 46, 255); - /// `field` is exposed so the caller can read its content on submit /// (`field.edit(rsc).text()`) and clear it afterward /// (`field.edit(rsc).set("")`). @@ -43,7 +42,7 @@ impl Composer { /// 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: &mut Rsc) -> (Composer, WeakWidget) +pub fn build_composer(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget) where Rsc::State: FocusHost, { @@ -52,7 +51,7 @@ where .text_align(Align::LEFT) .wrap(true) .size(18) - .color(UiColor::WHITE) + .color(theme.text.clone()) .attr::(()) .label("Message") .add(rsc); @@ -66,7 +65,7 @@ where .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(BAR_FILL)) + .masked_by(rect(theme.composer_surface.clone())) .add(rsc); let outer_pad: WeakWidget = content.pad(Padding::ZERO).add(rsc); diff --git a/app-rust/src/ui/markdown.rs b/app-rust/src/ui/markdown.rs index 38c970c..a380363 100644 --- a/app-rust/src/ui/markdown.rs +++ b/app-rust/src/ui/markdown.rs @@ -1,54 +1,36 @@ 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; -const fn mocha(hex: u32) -> UiColor { - UiColor::new( - ((hex >> 16) & 0xff) as u8, - ((hex >> 8) & 0xff) as u8, - (hex & 0xff) as u8, - 255, - ) -} - -pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4); -pub const CODE_COLOR: UiColor = mocha(0xCDD6F4); -pub const LINK_COLOR: UiColor = mocha(0x89B4FA); -pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE); -pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B); -pub const TABLE_BACKGROUND: UiColor = mocha(0x313244); -pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70); -pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8); -const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086); - -fn syntax_color(kind: Kind) -> UiColor { +fn syntax_color(kind: Kind, theme: &Theme) -> PaintId { match kind { - Kind::Keyword => mocha(0xCBA6F7), - Kind::String => mocha(0xA6E3A1), - Kind::Literal => mocha(0xFAB387), - Kind::Comment => mocha(0x6C7086), - Kind::Metadata => mocha(0xF9E2AF), - Kind::Punctuation => mocha(0xA6ADC8), - Kind::Mark => mocha(0x89DCEB), + 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, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BlockFrame { Plain, - Verbatim { fill: UiColor }, + Verbatim { fill: PaintId }, Quote, } -pub fn frame_of(kind: BlockKind) -> BlockFrame { +pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame { match kind { BlockKind::Code => BlockFrame::Verbatim { - fill: VERBATIM_BACKGROUND, + fill: theme.verbatim_surface.clone(), }, BlockKind::Table => BlockFrame::Verbatim { - fill: TABLE_BACKGROUND, + fill: theme.table_surface.clone(), }, BlockKind::Quote => BlockFrame::Quote, BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => { @@ -114,10 +96,10 @@ fn ensure_line(out: &mut String) { } } -pub fn render_block(block: &Block, base_size: f32) -> Rendered { +pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered { match block.kind { - BlockKind::Table => table_text(&block.source), - _ => render_markdown(&block.source, base_size), + BlockKind::Table => table_text(&block.source, theme), + _ => render_markdown(&block.source, base_size, theme), } } @@ -125,7 +107,7 @@ pub fn render_block(block: &Block, base_size: f32) -> Rendered { /// 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) -> Rendered { +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(); @@ -175,7 +157,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered { } _ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]), } - spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR)); + 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), @@ -209,14 +191,18 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered { TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()), TagEnd::Strong => spans.push(SpanStyle::new(range).bold()), TagEnd::Strikethrough => { - spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR)); + 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(LINK_COLOR).underline()); + spans.push( + SpanStyle::new(range.clone()) + .color(theme.link.clone()) + .underline(), + ); if let Some(url) = dest { links.push(Link { range, url }); } @@ -225,10 +211,10 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered { spans.push( SpanStyle::new(range.clone()) .family(Family::Monospace) - .color(CODE_COLOR), + .color(theme.code.clone()), ); if let Some(language) = fence_language.take() { - highlight_into(&mut spans, &out, range, language); + highlight_into(&mut spans, &out, range, language, theme); } } _ => unreachable!("filtered by the outer match arm"), @@ -241,7 +227,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered { spans.push( SpanStyle::new(start..out.len()) .family(Family::Monospace) - .color(CODE_COLOR), + .color(theme.code.clone()), ); } Event::SoftBreak => out.push(' '), @@ -253,7 +239,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered { Event::TaskListMarker(done) => { let start = out.len(); out.push_str(if done { "[x] " } else { "[ ] " }); - spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR)); + spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone())); } Event::End(TagEnd::List(_)) => { lists.pop(); @@ -282,6 +268,7 @@ pub(crate) fn highlight_into( text: &str, range: Range, language: Language, + theme: &Theme, ) { let code = &text[range.clone()]; // char index -> byte offset within `code`, plus the end, so a span's @@ -305,14 +292,14 @@ pub(crate) fn highlight_into( spans.push( SpanStyle::new(range.start + start..range.start + end) .family(Family::Monospace) - .color(syntax_color(span.kind)), + .color(syntax_color(span.kind, theme)), ); } } const TABLE_MAX_COL: usize = 28; -pub fn table_text(src: &str) -> Rendered { +pub fn table_text(src: &str, theme: &Theme) -> Rendered { let rows = table_cells(src); if rows.is_empty() { return Rendered::default(); @@ -363,7 +350,7 @@ pub fn table_text(src: &str) -> Rendered { let rule: usize = widths.iter().sum::() + 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(QUOTE_BAR_COLOR)); + spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone())); } } Rendered { @@ -421,6 +408,36 @@ mod tests { use super::*; use crate::client::markdown_blocks::split_blocks; + fn with_theme(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:?}"); @@ -509,7 +526,7 @@ mod tests { 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)); + assert_eq!(r.spans[0].color, Some(code_color())); } #[test] @@ -550,7 +567,7 @@ mod tests { let markers: Vec<_> = r .spans .iter() - .filter(|s| s.color == Some(MARKER_COLOR)) + .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} "]); @@ -563,7 +580,7 @@ mod tests { let markers: Vec<_> = r .spans .iter() - .filter(|s| s.color == Some(MARKER_COLOR)) + .filter(|s| s.color == Some(marker_color())) .map(|s| r.text[s.range.clone()].to_string()) .collect(); assert_eq!(markers, ["3. ", "4. "]); diff --git a/app-rust/src/ui/mod.rs b/app-rust/src/ui/mod.rs index aeff1c5..2dd3dc0 100644 --- a/app-rust/src/ui/mod.rs +++ b/app-rust/src/ui/mod.rs @@ -7,14 +7,14 @@ pub mod composer; pub mod fixture; pub mod markdown; pub mod row; -pub mod selection; pub(crate) mod tap; +pub mod theme; pub mod tool; use crate::client::transcript_fold::TranscriptRow as FoldedRow; use iris::prelude::*; -use selection::Selection; use std::{cell::RefCell, rc::Rc}; +use theme::Theme; pub struct TranscriptScreen { /// The transcript's own `LazySpan` -- the layout *and* the scroll @@ -25,10 +25,10 @@ pub struct TranscriptScreen { /// `.jump_to_end()` directly. pub list: WeakWidget, pub composer: composer::Composer, - selection: Rc>, rebuilds: std::cell::Cell, tail: RefCell>, session_working: std::cell::Cell, + theme: Rc, } impl TranscriptScreen { @@ -48,10 +48,10 @@ impl TranscriptScreen { let (key, widget, tail) = row::build_row( rsc, self.list, - self.selection.clone(), row, self.session_working.get(), true, + self.theme.clone(), ); (self.list)(rsc).push_back(LazyItem::new(key, widget)); *self.tail.borrow_mut() = tail.map(|t| (key, t)); @@ -122,14 +122,7 @@ impl TranscriptScreen { ) { return false; } - blocks.apply_delta( - rsc, - self.list, - self.selection.clone(), - key, - sender, - &markdown_src, - ) + blocks.apply_delta(rsc, sender, &markdown_src) } (row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => { tools.apply_calls(rsc, calls, self.session_working.get()) @@ -179,14 +172,13 @@ impl TranscriptScreen { return; } - self.selection.borrow_mut().unregister(old_key); let (new_key, widget, kept) = row::build_row( rsc, self.list, - self.selection.clone(), &new_rows[common], self.session_working.get(), 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 @@ -197,7 +189,6 @@ impl TranscriptScreen { } RowDiff::Rebuild => { self.rebuilds.set(self.rebuilds.get() + 1); - self.selection.borrow_mut().clear(); (self.list)(rsc).clear(); *self.tail.borrow_mut() = None; for row in &new_rows { @@ -211,10 +202,23 @@ impl TranscriptScreen { self.rebuilds.replace(0) } + /// 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(&self, rsc: &mut impl UiRsc) -> Option { - self.selection.borrow().selected_text(rsc) + pub fn selected_text(&self, rsc: &mut Rsc) -> Option { + let id = rsc + .events() + .controllers + .id::(self.list.id())?; + rsc.with_controller::(id, |selection, rsc| { + selection.selected_text(rsc) + })? } } @@ -238,8 +242,14 @@ pub fn build_tree( where Rsc::State: FocusHost + OpenUrl, { - let selection = Rc::new(RefCell::new(Selection::new())); + 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 @@ -258,39 +268,24 @@ where // 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 (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false, cap); + let (key, widget, 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 continuation of a row-started drag once it has committed and - // taken pointer capture on `list`'s own id (`row.rs`'s registration is - // only ever the gesture's first frame) -- registered once here, not - // once per row, since `DragGesture`'s single shared instance must see - // each frame of one gesture exactly once. `ctx.data.pos`/`size` are - // already relative to `list`'s own on-screen box (this is what it was - // registered against), which is exactly the viewport-pixel space - // `LazySpan::key_at`/`extent` work in, so the row-under-the-pointer is - // resolved from those instead of a per-row hit test. + // 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. { - let selection = selection.clone(); - list.on( - CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop | CursorSense::Cancel, - move |ctx, rsc| { - let row = selection - .borrow() - .locate(&*rsc, ctx.data.render, ctx.data.cursor.pos); - selection.borrow_mut().drag( - rsc, - list, - row, - ctx.data.cursor.pos, - ctx.data.sense, - ctx.data.cursor.time, - ctx.data.pointer, - ); - }, - ) + list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| { + let input = &ctx.data; + rsc.with_nearest_controller::( + list, + |id, selection, rsc| selection.drag(id, rsc, input), + ); + }) .add(rsc); } @@ -299,9 +294,8 @@ where ctx.widget(rsc).scroll(delta); }) .add(rsc); - selection.borrow_mut().set_scroll_area(list); - let (composer, composer_bar) = composer::build_composer(rsc); + let (composer, composer_bar) = composer::build_composer(rsc, &theme); let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar) .span(Dir::DOWN) @@ -314,8 +308,8 @@ where session_working: std::cell::Cell::new(false), list, composer, - selection, rebuilds: std::cell::Cell::new(0), + theme, }, tree, ) @@ -503,21 +497,6 @@ mod apply_tests { } } - 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(), - } - } - fn assistant(seq: u64, text: &str) -> TranscriptItem { TranscriptItem::AssistantMsg { seq, @@ -582,83 +561,6 @@ mod apply_tests { ); } - #[test] - fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() { - use crate::client::transcript_fold::group_tool_runs; - - let mut rsc = TestRsc { - ui: UiData::default(), - events: EventManager::default(), - }; - - let old_items = vec![tool(1, "run-a"), user(2, "meanwhile"), user(4, "stable")]; - let new_items = vec![tool(1, "run-a"), tool(3, "run-a"), user(4, "stable")]; - assert_eq!( - diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)), - RowDiff::Rebuild, - "test setup must actually exercise the Rebuild arm" - ); - - let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items)); - screen.apply(&mut rsc, &old_items, &new_items); - - let surviving_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(4)); - screen.selection.borrow_mut().begin( - &mut rsc, - (surviving_key, 0), - Vec2::ZERO, - Vec2::new(10.0, 10.0), - ); - } - - #[test] - fn a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection() { - use crate::client::transcript_fold::group_tool_runs; - - let mut rsc = TestRsc { - ui: UiData::default(), - events: EventManager::default(), - }; - let old_items = vec![user(1, "stable"), assistant(2, "# Head\n\npara\n\n- item")]; - let new_items = vec![user(1, "stable"), assistant(2, "short")]; - assert_eq!( - diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)), - RowDiff::ReplaceLast { common: 1 }, - "test setup must actually exercise the ReplaceLast arm" - ); - - let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items)); - let tail_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(2)); - assert_eq!( - screen - .selection - .borrow() - .registered_blocks(tail_key) - .count(), - 3, - "the fixture must start with more blocks than it ends with" - ); - - screen.apply(&mut rsc, &old_items, &new_items); - assert_eq!( - screen - .selection - .borrow() - .registered_blocks(tail_key) - .count(), - 1, - "the blocks the rebuild dropped are still registered" - ); - - let surviving_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(1)); - screen.selection.borrow_mut().begin( - &mut rsc, - (surviving_key, 0), - Vec2::ZERO, - Vec2::new(10.0, 10.0), - ); - } - fn call(id: &str, output: &str, done: bool) -> TranscriptItem { TranscriptItem::ToolRun { seq: 1, diff --git a/app-rust/src/ui/row.rs b/app-rust/src/ui/row.rs index aed4f42..67a4ab6 100644 --- a/app-rust/src/ui/row.rs +++ b/app-rust/src/ui/row.rs @@ -2,8 +2,8 @@ use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_bloc use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label}; use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block}; -use crate::ui::selection::{SelKey, Selection}; use crate::ui::tap::{hold_edge, on_tap}; +use crate::ui::theme::Theme; use crate::ui::tool::ToolRow; use iris::prelude::*; use std::{cell::RefCell, rc::Rc}; @@ -93,7 +93,7 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String { pub struct RowBlocks { blocks: Vec, - fields: Vec>, + fields: Vec>, links: Vec>>>, column: WeakWidget, sender: Option, @@ -108,6 +108,7 @@ pub struct RowBlocks { /// keeps the refusal from costing anything in practice. This field is /// the belt to that braces. capped: bool, + theme: Rc, } /// Split for display: never empty, so a row with nothing in it yet is @@ -181,21 +182,18 @@ const FRAME_RADIUS_DP: f32 = 8.0; fn build_block( rsc: &mut Rsc, - list: WeakWidget, - selection: Rc>, - key: SelKey, block: &Block, -) -> (WeakWidget, StrongWidget, Rc>>) + theme: &Theme, +) -> (WeakWidget, StrongWidget, Rc>>) where Rsc::State: FocusHost + OpenUrl, { - let frame = frame_of(block.kind); - let rendered = render_block(block, BASE_SIZE); + let frame = frame_of(block.kind, theme); + let rendered = render_block(block, BASE_SIZE, theme); let links = Rc::new(RefCell::new(rendered.links)); let verbatim = matches!(frame, BlockFrame::Verbatim { .. }); let field = wtext(rendered.text) .spans(rendered.spans) - .editable(EditMode::MultiLine) .text_align(Align::LEFT) .wrap(!verbatim) .family(if verbatim { @@ -205,31 +203,28 @@ where }) .size(BASE_SIZE) .color(match frame { - BlockFrame::Quote => crate::ui::markdown::QUOTE_TEXT_COLOR, - _ => crate::ui::markdown::TEXT_COLOR, + BlockFrame::Quote => theme.quote_text.clone(), + _ => theme.text.clone(), }) .add(rsc); - selection.borrow_mut().register(key, field); let tap_links = links.clone(); field - .on(CursorSense::drag_senses(), move |ctx, rsc| { - let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos); - let outcome = selection.borrow_mut().drag( - rsc, - list, - Some((key, pos, size)), - cursor, - ctx.data.sense, - ctx.data.cursor.time, - ctx.data.pointer, - ); + .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::( + field, + |id, selection, rsc| selection.drag(id, rsc, input), + ) + .unwrap_or(SelectionInput::Tapped); // A *tap*, decided by the same `DragArbiter` the pan and // the selection are: a gesture that panned the list past // this link, or held long enough to select, must not also // follow it (`GestureOutcome::Tapped`'s doc). - if outcome == GestureOutcome::Tapped { - let byte = field.edit(rsc).byte_at(cursor, size); + if outcome == SelectionInput::Tapped { + let byte = field.selection(rsc).byte_at(pos, size); let url = tap_links .borrow() .iter() @@ -258,7 +253,7 @@ where left: dp(QUOTE_BAR_DP + FRAME_PAD_DP), ..Padding::ZERO }) - .background(rect(crate::ui::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP))) + .background(rect(theme.quote_bar.clone()).width(dp(QUOTE_BAR_DP))) .width(rest(1)) .add_strong(rsc) .any(), @@ -266,14 +261,15 @@ where (field, framed, links) } +#[allow(clippy::too_many_arguments)] fn build_text_row( rsc: &mut Rsc, list: WeakWidget, - selection: Rc>, key: RowKey, sender: Option<&str>, markdown_src: &str, cap: bool, + theme: Rc, ) -> (StrongWidget, RowBlocks) where Rsc::State: FocusHost + OpenUrl, @@ -284,7 +280,7 @@ where }); let strong = WidgetPtr::new().add_strong(rsc); let ptr = strong.weak(); - let (content, blocks) = row_content(rsc, list, selection, key, source, ptr, cap); + let (content, blocks) = row_content(rsc, list, key, source, ptr, cap, theme); ptr(rsc).set(content); (strong.any(), blocks) } @@ -292,14 +288,15 @@ where /// 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: &mut Rsc, list: WeakWidget, - selection: Rc>, key: RowKey, source: Rc, ptr: WeakWidget, cap: bool, + theme: Rc, ) -> (StrongWidget, RowBlocks) where Rsc::State: FocusHost + OpenUrl, @@ -308,9 +305,8 @@ where let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP)); let mut fields = Vec::with_capacity(blocks.len()); let mut links = Vec::with_capacity(blocks.len()); - for (i, block) in blocks.iter().enumerate() { - let (field, framed, block_links) = - build_block(rsc, list, selection.clone(), (key, i as u32), block); + for block in &blocks { + let (field, framed, block_links) = build_block(rsc, block, &theme); fields.push(field); links.push(block_links); column.push(framed); @@ -319,11 +315,11 @@ where column.push(show_all( rsc, list, - selection.clone(), key, source.clone(), ptr, lines, + theme.clone(), )); } let column = column.add(rsc); @@ -339,7 +335,7 @@ where let header: WeakWidget = match &source.sender { Some(name) => wtext(name.clone()) .size(13.0) - .color(UiColor::new(150, 150, 160, 255)) + .color(theme.secondary_text.clone()) .add(rsc), None => Span::empty(Dir::DOWN).add(rsc), }; @@ -359,6 +355,7 @@ where column, sender: source.sender.clone(), capped: hidden.is_some(), + theme, }, ) } @@ -370,11 +367,11 @@ where fn show_all( rsc: &mut Rsc, list: WeakWidget, - selection: Rc>, key: RowKey, source: Rc, ptr: WeakWidget, lines: usize, + theme: Rc, ) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, @@ -384,22 +381,15 @@ where let more = more_strong.weak(); let words = wtext(label.clone()) .size(13.0) - .color(UiColor::new(150, 150, 160, 255)) + .color(theme.secondary_text.clone()) .text_align(Align::LEFT) .label(label) .add_strong(rsc); more(rsc).set(words); - on_tap(rsc, more, list, selection.clone(), move |rsc| { + on_tap(rsc, more, list, move |rsc| { hold_edge(rsc, list, key); - let (content, _blocks) = row_content( - rsc, - list, - selection.clone(), - key, - source.clone(), - ptr, - false, - ); + let (content, _blocks) = + row_content(rsc, list, key, source.clone(), ptr, false, theme.clone()); let _old = ptr(rsc).replace(content); }); more_strong.any() @@ -415,9 +405,6 @@ impl RowBlocks { pub fn apply_delta( &mut self, rsc: &mut Rsc, - list: WeakWidget, - selection: Rc>, - key: RowKey, sender: Option<&str>, markdown_src: &str, ) -> bool @@ -461,15 +448,12 @@ impl RowBlocks { for (i, block) in new_blocks.iter().enumerate().skip(common) { match (self.fields.get(i), self.links.get(i)) { (Some(field), Some(links)) => { - let rendered = render_block(block, BASE_SIZE); - field - .edit(rsc) - .set_with_spans(&rendered.text, rendered.spans); + let rendered = render_block(block, BASE_SIZE, &self.theme); + field(rsc).set_with_spans(rendered.text, rendered.spans); *links.borrow_mut() = rendered.links; } _ => { - let (field, framed, links) = - build_block(rsc, list, selection.clone(), (key, i as u32), block); + let (field, framed, links) = build_block(rsc, block, &self.theme); self.fields.push(field); self.links.push(links); if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) { @@ -486,16 +470,16 @@ impl RowBlocks { fn build_single( rsc: &mut Rsc, list: WeakWidget, - selection: Rc>, key: RowKey, item: &TranscriptItem, cap: bool, + theme: Rc, ) -> (StrongWidget, RowBlocks) where Rsc::State: FocusHost + OpenUrl, { let (sender, markdown_src) = item_content(item); - build_text_row(rsc, list, selection, key, sender, &markdown_src, cap) + build_text_row(rsc, list, key, sender, &markdown_src, cap, theme) } /// Two mechanisms would have been two answers to the same question ("what @@ -515,10 +499,10 @@ pub enum TailRow { pub fn build_row( rsc: &mut Rsc, list: WeakWidget, - selection: Rc>, row: &FoldedRow, working: bool, cap: bool, + theme: Rc, ) -> (RowKey, StrongWidget, Option) where Rsc::State: FocusHost + OpenUrl, @@ -537,14 +521,14 @@ where if let Some(calls) = calls { let key = row_key(&calls[0].key()); let (widget, tools) = - crate::ui::tool::build_tool_row(rsc, list, selection, key, calls.to_vec(), working); + crate::ui::tool::build_tool_row(rsc, list, key, calls.to_vec(), working, theme); return (key, widget, Some(TailRow::Tools(tools))); } let FoldedRow::Single(item) = row else { unreachable!("every Tools row took the branch above"); }; let key = row_key(&item.key()); - let (widget, blocks) = build_single(rsc, list, selection, key, item, cap); + let (widget, blocks) = build_single(rsc, list, key, item, cap, theme); (key, widget, Some(TailRow::Blocks(blocks))) } diff --git a/app-rust/src/ui/selection.rs b/app-rust/src/ui/selection.rs deleted file mode 100644 index 2240614..0000000 --- a/app-rust/src/ui/selection.rs +++ /dev/null @@ -1,378 +0,0 @@ -use iris::prelude::*; -use std::{collections::BTreeMap, time::Instant}; - -pub type SelKey = (RowKey, u32); - -pub struct Selection { - rows: BTreeMap>, - anchor: Option<(SelKey, Vec2)>, - gesture: DragGesture, - scroll: Option>, -} - -impl Default for Selection { - fn default() -> Self { - Self::new() - } -} - -impl Selection { - pub fn new() -> Self { - Self { - rows: BTreeMap::new(), - anchor: None, - gesture: DragGesture::new(), - scroll: None, - } - } - - pub fn set_scroll_area(&mut self, scroll: WeakWidget) { - self.scroll = Some(scroll); - } - - /// A row's selectable text became visible/known. Every addition here - /// needs its removal (`unregister`, or `clear` for all of them at - /// once) -- called when `LazySpan` evicts the row (`pop_front`/ - /// `pop_back`/`clear`), so this map never outgrows however many rows - /// are actually loaded. `LazySpan::place` guards the twin of this same - /// class of bug on the list's own side (`lazy_span.rs`'s `slot_exists` - /// assertion) -- a derived handle that silently outlives what it - /// points to; the next caller adding a third row-keyed side table - /// should read both. - pub fn register(&mut self, key: SelKey, text: WeakWidget) { - self.rows.insert(key, text); - } - - pub fn clear(&mut self) { - self.rows.clear(); - self.anchor = None; - } - - pub fn unregister(&mut self, row: RowKey) { - self.rows.retain(|&(k, _), _| k != row); - if self.anchor.map(|((k, _), _)| k) == Some(row) { - self.anchor = None; - } - } - - /// A fresh press: clears whatever was selected elsewhere (an ordinary - /// click starts a new selection, it does not extend the old one) and - /// gives `key`'s row a collapsed caret at `pos` -- a plain click that - /// never turns into a drag leaves exactly this and nothing else - /// selected. - pub fn begin(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) { - let rows: Vec = self.rows.keys().copied().collect(); - for k in rows { - if k != key - && let Some(w) = self.rows.get(&k) - { - w.edit(ui).deselect(); - } - } - if let Some(w) = self.rows.get(&key) { - w.edit(ui).select(pos, size, false, false); - } - self.anchor = Some((key, pos)); - } - - pub fn extend(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) { - let Some((anchor_key, _anchor_pos)) = self.anchor else { - return; - }; - if key == anchor_key { - if let Some(w) = self.rows.get(&key) { - w.edit(ui).select(pos, size, true, false); - } - return; - } - let (lo, hi) = if anchor_key < key { - (anchor_key, key) - } else { - (key, anchor_key) - }; - let in_range: Vec = self.rows.range(lo..=hi).map(|(&k, _)| k).collect(); - for k in &in_range { - let Some(w) = self.rows.get(k).copied() else { - continue; - }; - if *k == key { - let start = if key > anchor_key { Vec2::ZERO } else { size }; - w.edit(ui).select(start, size, false, false); - w.edit(ui).select(pos, size, true, false); - } else { - w.edit(ui).select_all(); - } - } - let outside: Vec = self - .rows - .keys() - .copied() - .filter(|k| *k < lo || *k > hi) - .collect(); - for k in outside { - if let Some(w) = self.rows.get(&k) { - w.edit(ui).deselect(); - } - } - } - - /// The block indices currently registered for `row`, in order. For a - /// test asserting that a row's removal or rebuild took every one of - /// its blocks with it -- the contract `unregister` states and the one - /// a caller can get wrong silently, since a stale handle only shows - /// up as a panic on some later, unrelated press. - #[cfg(test)] - pub fn registered_blocks(&self, row: RowKey) -> impl Iterator + '_ { - self.rows - .keys() - .filter(move |(k, _)| *k == row) - .map(|&(_, b)| b) - } - - pub fn locate( - &self, - ui: &impl UiRsc, - render: &UiRenderState, - pos_window: Vec2, - ) -> Option<(SelKey, Vec2, Vec2)> { - for (&key, w) in &self.rows { - let Some(px) = render.window_region(w, ui) else { - continue; - }; - if px.contains(pos_window) { - return Some((key, pos_window - px.top_left, px.size())); - } - } - None - } - - fn has_selection(&self, ui: &mut impl UiRsc) -> bool { - self.rows - .values() - .any(|w| w.edit(ui).text.selected_text().is_some()) - } - - /// `row`, if given, is `(key, pos_row, size)` for whichever row the - /// pointer is currently over -- row-local, as `begin`/`extend` want. - /// `None` once the gesture is pointer-captured (`iris::sense`'s - /// pointer-capture doc) and the current position falls outside every - /// row `LazySpan` has loaded (a gap, or off the end of the content); a - /// `Pan` outcome never needs it, so this only actually matters mid- - /// selection, where it is rare and the frame is simply dropped. - /// `pos_window` is in window space, since a pan's delta has to stay - /// meaningful even when this frame's event landed on a different row - /// than the last one. `pointer` is `CursorData`'s own field -- what - /// `DragGesture` needs to take pointer capture. - #[allow(clippy::too_many_arguments)] - /// Returns what the gesture decided this frame, so a caller with its - /// own meaning for a *tap* -- a row's link handler -- reads it from - /// the one arbiter that already knows, rather than timing a second - /// one beside it (which would disagree the moment either changed). - pub fn drag( - &mut self, - ui: &mut impl UiRsc, - list: WeakWidget, - row: Option<(SelKey, Vec2, Vec2)>, - pos_window: Vec2, - sense: CursorSense, - now: Instant, - pointer: &PointerRequests, - ) -> GestureOutcome { - debug_assert!( - self.scroll.is_some(), - "Selection::drag with no scroll area: a committed pan would be silently dropped -- \ - call `set_scroll_area` after building the transcript's `LazySpan`" - ); - let mut press = PressState::default(); - if self.gesture.starts_press(sense) { - press.scrolling = self.scroll.is_some_and(|s| s(ui).is_scrolling()); - if let Some(scroll) = self.scroll { - scroll(ui).cancel_fling(); - } - } - press.already_selected = self.has_selection(ui); - let outcome = self - .gesture - .handle(pointer, list.id(), sense, pos_window, now, press); - match outcome { - // Somebody else took the gesture (a code fence panning - // sideways under the finger). Nothing here acted on it, and - // `DragGesture` has already forgotten it, so there is nothing - // to undo either -- the point is that no tap, fling or - // selection follows from a gesture that was never ours. - GestureOutcome::Cancelled | GestureOutcome::Undecided => {} - GestureOutcome::Pan(dy) => { - if let Some(scroll) = self.scroll { - scroll(ui).scroll(dy); - } - } - GestureOutcome::SelectStart => { - if let Some((key, pos_row, size)) = row { - log::info!("iris selection: begin at row {key:?}"); - self.begin(ui, key, pos_row, size); - } - } - GestureOutcome::SelectExtend => { - if let Some((key, pos_row, size)) = row { - log::info!("iris selection: extend to row {key:?}"); - self.extend(ui, key, pos_row, size); - } - } - // A fling only ever follows a pan -- never a selection that - // happened to end with the finger still moving, and never a - // tap/long-press that never left `Undecided` -- exactly what - // `DragGesture`'s `Some(v)` already encodes. - GestureOutcome::Released(Some(v)) => { - // The half that actually makes it move -- see - // `ScrollController::fling`'s doc. Without it the velocity is - // computed, stored, and never advanced by anything. - if let Some(scroll) = self.scroll - && scroll(ui).fling(v) - { - let id = scroll.id(); - ui.ui_mut().animate(id); - } - } - GestureOutcome::Released(None) | GestureOutcome::Tapped => {} - } - outcome - } - - /// The concatenated selected text, in row order, `None` if nothing is - /// selected -- what a copy command reads. Joins with a blank line - /// between rows, matching how the transcript itself separates them. - pub fn selected_text(&self, ui: &mut impl UiRsc) -> Option { - let mut parts = Vec::new(); - for w in self.rows.values() { - if let Some(text) = w.edit(ui).text.selected_text() { - parts.push(text); - } - } - if parts.is_empty() { - None - } else { - Some(parts.join("\n\n")) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn in_range(anchor: RowKey, current: RowKey, keys: &[RowKey]) -> Vec { - let (lo, hi) = if anchor < current { - (anchor, current) - } else { - (current, anchor) - }; - keys.iter() - .copied() - .filter(|k| *k >= lo && *k <= hi) - .collect() - } - - #[test] - fn selection_spans_forward_across_rows() { - let keys = [1, 2, 3, 4, 5]; - assert_eq!(in_range(2, 4, &keys), vec![2, 3, 4]); - } - - #[test] - fn selection_spans_backward_across_rows() { - let keys = [1, 2, 3, 4, 5]; - assert_eq!(in_range(4, 2, &keys), vec![2, 3, 4]); - } - - #[test] - fn selection_within_one_row_is_just_that_row() { - let keys = [1, 2, 3]; - assert_eq!(in_range(2, 2, &keys), vec![2]); - } - - struct TestRsc { - ui: UiData, - } - impl UiRsc for TestRsc { - fn ui(&self) -> &UiData { - &self.ui - } - fn ui_mut(&mut self) -> &mut UiData { - &mut self.ui - } - } - - #[test] - fn a_missed_press_start_recovers_on_the_next_pressing_frame() { - let mut rsc = TestRsc { - ui: UiData::default(), - }; - let field = rsc - .ui - .widgets - .add_strong(TextEdit::new( - TextView::new(TextBuffer::new_empty(), TextAttrs::default(), None), - EditMode::MultiLine, - )) - .weak(); - let list = rsc - .ui - .widgets - .add_strong(LazySpan::new(Dir::DOWN, Pin::End)); - let list_weak = list.weak(); - let scroll = rsc - .ui - .widgets - .add_strong(LazySpan::new(Dir::DOWN, Pin::End)) - .weak(); - let list = list_weak; - - let mut sel = Selection::new(); - sel.set_scroll_area(scroll); - sel.register((1, 0), field); - assert!(sel.gesture.is_idle()); - - let pointer = PointerRequests::default(); - let now = Instant::now(); - let size = Vec2::new(100.0, 20.0); - sel.drag( - &mut rsc, - list, - Some(((1, 0), Vec2::ZERO, size)), - Vec2::new(540.0, 700.0), - CursorSense::Pressing(CursorButton::Left), - now, - &pointer, - ); - assert!( - !sel.gesture.is_idle(), - "a Pressing frame with the arbiter still Idle must recover \ - the press rather than leaving it stuck" - ); - } - - #[test] - fn unregister_forgets_every_block_of_the_row_and_clears_a_matching_anchor() { - let mut rsc = TestRsc { - ui: UiData::default(), - }; - let field = rsc - .ui - .widgets - .add_strong(TextEdit::new( - TextView::new(TextBuffer::new_empty(), TextAttrs::default(), None), - EditMode::MultiLine, - )) - .weak(); - - let mut sel = Selection::new(); - sel.register((5, 0), field); - sel.register((5, 1), field); - sel.anchor = Some(((5, 1), Vec2::ZERO)); - assert_eq!(sel.rows.len(), 2); - - sel.unregister(5); - assert!(sel.rows.is_empty()); - assert!(sel.anchor.is_none()); - } -} diff --git a/app-rust/src/ui/tap.rs b/app-rust/src/ui/tap.rs index 7fd2722..3bd8b32 100644 --- a/app-rust/src/ui/tap.rs +++ b/app-rust/src/ui/tap.rs @@ -1,27 +1,22 @@ -use crate::ui::selection::Selection; use iris::prelude::*; -use std::{cell::RefCell, rc::Rc}; pub(crate) fn on_tap( rsc: &mut Rsc, ptr: WeakWidget, list: WeakWidget, - selection: Rc>, f: impl Fn(&mut Rsc) + 'static, ) where Rsc::State: FocusHost + OpenUrl, { - ptr.on(CursorSense::drag_senses(), move |ctx, rsc| { - let outcome = selection.borrow_mut().drag( - rsc, - list, - None, - ctx.data.cursor.pos, - ctx.data.sense, - ctx.data.cursor.time, - ctx.data.pointer, - ); - if outcome == GestureOutcome::Tapped { + ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| { + let input = &ctx.data; + let outcome = rsc + .with_nearest_controller::( + list, + |id, selection, rsc| selection.drag(id, rsc, input), + ) + .unwrap_or(SelectionInput::Tapped); + if outcome == SelectionInput::Tapped { f(rsc); } }) diff --git a/app-rust/src/ui/theme.rs b/app-rust/src/ui/theme.rs new file mode 100644 index 0000000..b49f29b --- /dev/null +++ b/app-rust/src/ui/theme.rs @@ -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, + ) +} diff --git a/app-rust/src/ui/tool.rs b/app-rust/src/ui/tool.rs index 94e1126..e0535ef 100644 --- a/app-rust/src/ui/tool.rs +++ b/app-rust/src/ui/tool.rs @@ -1,27 +1,12 @@ 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::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into}; -use crate::ui::selection::Selection; +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, cell::RefCell, collections::HashMap, rc::Rc}; -const CARD_FILL: UiColor = UiColor::new(0x31, 0x32, 0x44, 255); -/// The surface a group's cards sit on: Mantle, one step *below* the page. -/// That surface is the single cue saying these calls belong together, and -/// it goes below rather than above because the cards are already above -- -/// two steps in the same direction render as one flat block. -const GROUP_FILL: UiColor = UiColor::new(0x18, 0x18, 0x25, 255); -const NAME_COLOR: UiColor = TEXT_COLOR; -const MUTED_COLOR: UiColor = UiColor::new(0xA6, 0xAD, 0xC8, 255); - -/// Waiting on a person -- Peach, `Theme.kt`'s `awaitingColor`. The same -/// colour a question card takes, because it is the same fact. -const AWAITING_COLOR: UiColor = UiColor::new(0xFA, 0xB3, 0x87, 255); -const FAILED_COLOR: UiColor = UiColor::new(0xF3, 0x8B, 0xA8, 255); -const UNKNOWN_COLOR: UiColor = UiColor::new(0xF9, 0xE2, 0xAF, 255); - const NAME_SIZE: f32 = 14.0; const BODY_SIZE: f32 = 12.0; const LABEL_SIZE: f32 = 11.0; @@ -58,9 +43,9 @@ struct Shared { cards: RefCell>>, content: RefCell>>, list: WeakWidget, - selection: Rc>, key: RowKey, working: Cell, + theme: Rc, } /// One transcript row's worth of tool calls, kept by the caller for the @@ -71,18 +56,18 @@ pub struct ToolRow { shared: Rc, } -fn text(content: impl Into, size: f32, color: UiColor) -> TextBuilder { +fn text(content: impl Into, size: f32, color: PaintId) -> TextBuilder { wtext(content) .size(size) .color(color) .text_align(Align::LEFT) } -fn disclosure(glyph: &'static str) -> TextBuilder { - text(glyph, MARK_DP, MUTED_COLOR).family(Family::Icons) +fn disclosure(glyph: &'static str, theme: &Theme) -> TextBuilder { + text(glyph, MARK_DP, theme.muted.clone()).family(Family::Icons) } -fn raw_block(rsc: &mut Rsc, body: TextBuilder) -> StrongWidget +fn raw_block(rsc: &mut Rsc, body: TextBuilder, theme: &Theme) -> StrongWidget where Rsc::State: FocusHost, { @@ -94,22 +79,36 @@ where field .scrollable(Axis::X, Pin::Start) .pad(dp(RAW_PAD_DP)) - .masked_by(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP))) + .masked_by(rect(theme.verbatim_surface.clone()).radius(dp(RAW_RADIUS_DP))) .width(rest(1)) .add_strong(rsc) .any() } -fn state_mark(state: ToolState) -> Option<(&'static str, UiColor)> { +fn state_word(state: ToolState) -> Option<&'static str> { match state { - ToolState::Deciding => Some(("your turn", AWAITING_COLOR)), - ToolState::Running => Some(("running", MUTED_COLOR)), - ToolState::Failed => Some(("failed", FAILED_COLOR)), - ToolState::NoResult => Some(("no result", UNKNOWN_COLOR)), + 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" -- the same three things the Compose card's @@ -120,7 +119,7 @@ fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String { name.push_str(": "); name.push_str(title); } - if let Some((word, _)) = state_mark(state) { + if let Some(word) = state_word(state) { name.push_str(" ("); name.push_str(word); name.push(')'); @@ -170,27 +169,21 @@ where 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, MUTED_COLOR) + 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, - shared.selection.clone(), - move |rsc| { - hold_edge(rsc, shared_for_tap.list, shared_for_tap.key); - shared_for_tap - .state - .borrow_mut() - .whole - .insert(key.clone(), true); - redraw_card(rsc, &shared_for_tap, index); - }, - ); + on_tap(rsc, more, shared.list, move |rsc| { + hold_edge(rsc, shared_for_tap.list, shared_for_tap.key); + shared_for_tap + .state + .borrow_mut() + .whole + .insert(key.clone(), true); + redraw_card(rsc, &shared_for_tap, index); + }); more_strong.any() } @@ -207,19 +200,25 @@ where { if output.is_empty() { let (words, colour) = match call_state { - ToolState::Succeeded => ("No output", MUTED_COLOR), - ToolState::Failed => ("Failed, with no output", FAILED_COLOR), - ToolState::NoResult => ("No result ever arrived", UNKNOWN_COLOR), - ToolState::Running | ToolState::Deciding => ("No output yet", MUTED_COLOR), + 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, NAME_COLOR).add_strong(rsc).any()); - let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR); - column.push(raw_block(rsc, body)); + 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)); } @@ -249,12 +248,12 @@ where let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP)); header.push( - disclosure(if open { icon::OPEN } else { icon::CLOSED }) + disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme) .add_strong(rsc) .any(), ); header.push( - text(tool.clone(), NAME_SIZE, NAME_COLOR) + text(tool.clone(), NAME_SIZE, shared.theme.text.clone()) .add_strong(rsc) .any(), ); @@ -263,7 +262,7 @@ where header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any()) } (false, Some(title)) => header.push( - text(title.to_string(), BODY_SIZE, MUTED_COLOR) + text(title.to_string(), BODY_SIZE, shared.theme.muted.clone()) .wrap(false) .masked() .width(rest(1)) @@ -273,12 +272,16 @@ where } if open && let Some(timeout) = &parsed.timeout { header.push( - text(format!("timeout {timeout}"), LABEL_SIZE, MUTED_COLOR) - .add_strong(rsc) - .any(), + text( + format!("timeout {timeout}"), + LABEL_SIZE, + shared.theme.muted.clone(), + ) + .add_strong(rsc) + .any(), ); } - if let Some((word, colour)) = state_mark(call_state) { + if let Some((word, colour)) = state_mark(call_state, &shared.theme) { header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any()); } @@ -287,7 +290,7 @@ where if open { if let Some(description) = &parsed.description { column.push( - text(description.clone(), BODY_SIZE, MUTED_COLOR) + text(description.clone(), BODY_SIZE, shared.theme.muted.clone()) .width(rest(1)) .add_strong(rsc) .any(), @@ -303,13 +306,13 @@ where let spans = match parsed.language { Some(language) => { let mut spans = Vec::new(); - highlight_into(&mut spans, shown, 0..shown.len(), language); + highlight_into(&mut spans, shown, 0..shown.len(), language, &shared.theme); spans } None => Vec::new(), }; - let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR).spans(spans); - column.push(raw_block(rsc, body)); + 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 @@ -320,8 +323,8 @@ where let (shown, lines, was_cut) = capped(&joined, whole); input_lines += lines; input_cut |= was_cut; - let body = text(shown.to_string(), BODY_SIZE, MUTED_COLOR); - column.push(raw_block(rsc, body)); + 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)); @@ -332,7 +335,7 @@ where column .width(rest(1)) .pad(dp(CARD_PAD_DP)) - .background(rect(CARD_FILL).radius(dp(CARD_RADIUS_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) @@ -370,28 +373,22 @@ where let content = build_card(rsc, shared, index); ptr(rsc).set(content); let for_tap = shared.clone(); - on_tap( - rsc, - ptr, - shared.list, - shared.selection.clone(), - 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 - .state - .borrow() - .open - .get(&id) - .copied() - .unwrap_or(false); - for_tap.state.borrow_mut().open.insert(id, !was); - redraw_card(rsc, &for_tap, index); - }, - ); + 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 + .state + .borrow() + .open + .get(&id) + .copied() + .unwrap_or(false); + for_tap.state.borrow_mut().open.insert(id, !was); + redraw_card(rsc, &for_tap, index); + }); (strong.any(), ptr) } @@ -401,7 +398,7 @@ where { let strong = WidgetPtr::new().add_strong(rsc); let ptr = strong.weak(); - let mark = disclosure(icon::COLLAPSE) + let mark = disclosure(icon::COLLAPSE, &shared.theme) .center_text() .width(rest(1)) .pad(dp(CARD_PAD_DP)) @@ -411,13 +408,9 @@ where .add_strong(rsc); ptr(rsc).set(mark); let for_tap = shared.clone(); - on_tap( - rsc, - ptr, - shared.list, - shared.selection.clone(), - move |rsc| toggle_group(rsc, &for_tap), - ); + on_tap(rsc, ptr, shared.list, move |rsc| { + toggle_group(rsc, &for_tap) + }); strong.any() } @@ -438,10 +431,10 @@ where if !shared.state.borrow().group_expanded { let heading = group_label(count); - return text(heading.clone(), NAME_SIZE, NAME_COLOR) + return text(heading.clone(), NAME_SIZE, shared.theme.text.clone()) .pad(dp(CARD_PAD_DP)) .width(rest(1)) - .background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP))) + .background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP))) .width(rest(1)) .label(heading) .add_strong(rsc) @@ -451,7 +444,7 @@ where let heading = group_label(count); let mut group = Span::empty(Dir::DOWN); group.push( - text(heading.clone(), NAME_SIZE, NAME_COLOR) + text(heading.clone(), NAME_SIZE, shared.theme.text.clone()) .pad(dp(CARD_PAD_DP)) .width(rest(1)) .label(heading) @@ -468,7 +461,7 @@ where group.push(collapse_bar(rsc, shared)); group .width(rest(1)) - .background(rect(GROUP_FILL).radius(dp(CARD_RADIUS_DP))) + .background(rect(shared.theme.group_surface.clone()).radius(dp(CARD_RADIUS_DP))) .width(rest(1)) .add_strong(rsc) .any() @@ -516,10 +509,10 @@ impl Shared { pub fn build_tool_row( rsc: &mut Rsc, list: WeakWidget, - selection: Rc>, key: RowKey, calls: Vec, working: bool, + theme: Rc, ) -> (StrongWidget, ToolRow) where Rsc::State: FocusHost + OpenUrl, @@ -530,9 +523,9 @@ where cards: RefCell::new(Vec::new()), content: RefCell::new(None), list, - selection, key, working: Cell::new(working), + theme, }); let content_strong = WidgetPtr::new().add_strong(rsc); let content = content_strong.weak(); diff --git a/app-rust/tests/phone_screen.rs b/app-rust/tests/phone_screen.rs index fd5722d..2e00b60 100644 --- a/app-rust/tests/phone_screen.rs +++ b/app-rust/tests/phone_screen.rs @@ -127,6 +127,32 @@ fn a_long_press_and_drag_selects_text() { ); } +#[test] +fn a_tap_after_selection_deselects_text() { + let (mut h, screen) = opened(); + h.replay(&script( + "select-then-tap", + "0 down 300 1000\n\ + 520 move 300 1000\n\ + 560 move 700 1000\n\ + 600 move 900 1000\n\ + 640 up 900 1000\n\ + 800 down 540 1000\n\ + 880 up 540 1000", + )); + + assert_eq!( + screen.selected_text(&mut h.rsc), + None, + "an ordinary tap after a selection must dismiss it" + ); + assert_eq!( + h.state.opened_urls, + Vec::::new(), + "the deselecting tap must be consumed rather than activating content" + ); +} + #[test] fn the_composer_sits_above_a_simulated_ime_inset() { let (mut h, screen) = opened(); diff --git a/app-rust/tests/top_edge.rs b/app-rust/tests/top_edge.rs index 9cbb155..e6f4a16 100644 --- a/app-rust/tests/top_edge.rs +++ b/app-rust/tests/top_edge.rs @@ -3,7 +3,7 @@ use iris::harness::Harness; use iris::prelude::*; const HEADER_H: f32 = 300.0; -const HEADER: UiColor = UiColor::new(28, 28, 34, 255); +const HEADER: Srgba8 = Srgba8::new(28, 28, 34, 255); fn opened() -> (Harness, ai_app::ui::TranscriptScreen) { let mut h = Harness::new(phone_size(), PHONE_SCALE); diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index 5415cec..877d495 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -2,35 +2,10 @@ Only open Iris framework work lives here. Delete an item when it lands. -## Fix - -- [ ] **Colours are not in a defined colour space. Fix this before a - styling pass.** Both render backends prefer an sRGB surface - (`default/render.rs` and `android/render.rs`), while `fs_main` returns - `unpack4x8unorm` palette and image bytes unchanged. An sRGB attachment - treats those values as linear and encodes them again: Mocha Crust - (17,17,27) became (73,73,91) in a desktop screenshot measured on - 2026-09-06. - - Neither diagnostics nor startup logging records Android's selected surface - format. A device exposing only a non-sRGB surface can hide the shared bug. - - Done means defining one convention for palette bytes, decoded images, - colour emoji and the clear colour, then converting exactly once for the - selected target. Record the selected format in diagnostics, and add a GPU - test that draws known non-black, non-white pixels into an sRGB target and - reads the stored bytes back; screenshots from desktop and Android then - confirm the same Catppuccin values rather than serving as the definition. - ## Build (for the port) Framework capabilities needed by `RUST.md`'s port plan: -- [ ] **Selectable, read-only text.** P0's report and P1's transcript rows - use `TextEdit` because `Selectable` is implemented only for it. That - makes prose focusable and opens the IME over text that cannot be edited. - A display widget needs the same selection geometry and clipboard path - without a text-input accessibility role or keyboard focus. - [ ] **Overflow ellipsis with an explicit retained end.** `TextAttrs` can only wrap or clip, so a tool summary is cut with no mark. Parley has no ellipsis primitive; use its line breaker to find the cut, but keep source @@ -52,9 +27,9 @@ Framework capabilities needed by `RUST.md`'s port plan: - [ ] **Per-range backgrounds for rich text.** (**P1**.) Inline code is already monospace and coloured, but matching Compose's chip also needs the glyph run's boxes so a surface can be drawn behind exactly that byte - range. `TextEdit` already computes the same geometry internally for its - selection highlight; expose one shared primitive rather than giving the - app a second text-layout path. + range. The shared `TextSelection` engine already computes the same geometry + for selection highlights; expose one shared primitive rather than giving + the app a second text-layout path. - [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and **P5**.) Needed for the session settings dialog, `UsageDialog`'s equivalent, and the delete-with-`deleteForeign` confirmation with its @@ -69,6 +44,14 @@ Framework capabilities needed by `RUST.md`'s port plan: ## Later +- [ ] **Intern independently constructed solid paint definitions.** Inline + `rect(Srgba8::...)` values currently receive a new `PaintId` each time. + Cache them by canonical linear RGBA bits, but keep `Paints::add` explicitly + unique so two semantic theme roles that start with the same value can later + change independently. Cache entries must be weak and disappear when the + last real handle releases the slot; gradients and texture paints need their + own identity rules rather than inheriting solid-value interning blindly. + - [ ] **Property/content animations.** Cosmetic, so after correctness and parity. Keep them modular, like input; scrolling already animates through `Widget::tick` and `UiData::animate`. A widget that does not opt in must @@ -85,8 +68,8 @@ Framework capabilities needed by `RUST.md`'s port plan: against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own size and density and the two are identical to the pixel. What the pair cannot express is a clip that is not a box: `Painter::set_mask` writes - a `RectPrimitive::color(Color::NONE)` at the widget's own region, with - no radius, so `.background(rect(fill).radius(r)).masked()` draws a + a `RectPrimitive::color` using `PaintId::NONE` at the widget's own region, + with no radius, so `.background(rect(fill).radius(r)).masked()` draws a rounded panel and then cuts its content square. Both other call sites (`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why the method stands for now. diff --git a/docs/RUST.md b/docs/RUST.md index 4de3a9a..cbe00e7 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -22,8 +22,7 @@ the command and measured value when evidence matters. next session should start. Its box below has the state. - The port is one crate under `app-rust/`; `iris/` is only the UI framework. - **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own - list (colour-space correctness is the live one), `docs/TODO.md` is the - Compose app's. + list; `docs/TODO.md` is the Compose app's. ## Desktop and phone share the code @@ -81,7 +80,7 @@ runs inside `cargo test`. pan, and (e) the composer clears a simulated 1000px IME inset (`Composer::set_bottom_inset`). Each was confirmed to fail without its subject rather than assumed: dropping `animate(id)` from - `Selection::drag` -- the phone's own "fling does nothing" defect -- + `SelectionController::drag` -- the phone's own "fling does nothing" defect -- and starting the fling curve at the wall clock each fail only the flick test; flinging on `Tapped` fails only the tap test; a 5s `LONG_PRESS` fails only the selection test; a `set_bottom_inset` that @@ -269,11 +268,14 @@ already got right (v2 signing, `uses-feature`, ABI splits). Reading the Compose code for what a replacement must be able to express, rather than what it happens to look like: -1. **The transcript is one selectable body of text.** One - `SelectionContainer` around the whole lazy list, so a selection runs from - a reply into the tool output beneath it. The framework needs selectable - read-only rich text across many rows, with the platform's selection - handles and clipboard on the phone. +1. **The transcript is one selectable body of text.** A + `SelectionController` is registered directly on the lazy list, with no + selection widget in the layout tree, so a selection runs from a reply into + the tool output beneath it. Each parent supplies either draw order or one + visual axis for its immediate children; ordering is resolved only when + selection queries it. Iris owns the selection handles because its text is + drawn into one surface, while the platform supplies the clipboard and + related system services. 2. **Rich inline text**: markdown with links (one tap detector per text, not a node per link), inline code chips drawn behind the text, tables with wrapping cells and a sideways scroll, syntax-highlighted fences, @@ -442,7 +444,7 @@ buffer on **every keystroke**. So there is nothing to adopt, and adding it would be upstream work in parley. **And the app already does the thing incremental layout would buy.** -`RowBlocks::apply_delta` keeps one `TextEdit` per top-level markdown +`RowBlocks::apply_delta` keeps one `Text` per top-level markdown block and re-shapes only the block a delta landed in; re-splitting the markdown to find that block is 18µs at 18,000 characters and comparing the blocks is 470ns. Neither is the cost. @@ -664,12 +666,6 @@ pane is neither masked nor scrollable despite its construction comment saying it is both. This is an `app-rust` defect, not an iris framework item. - [ ] **P1 — session screen parity.** Continue in this order: - - [ ] **Before the next parity slice — make iris's colour pipeline - correct.** Both backends currently prefer an sRGB surface while - the shader returns palette/image bytes as - linear values; `IRIS_TODO.md` has the measured mismatch and - pass condition. Do this before judging or centralising the - app's styling. It is correctness, not cosmetic polish. - [ ] **P1c — history paging and jump-to-latest.** Wire `client::transcript_source` into `src/ui`: the opening page, paging back on scroll with the cushion @@ -725,16 +721,6 @@ it is both. This is an `app-rust` defect, not an iris framework item. names are app content applied through iris's existing `.label()` API, not a missing framework widget. - **`app-rust` UI defect still open**: tool-card text is not selectable. - A `TranscriptRow::Tools` has no markdown blocks, so cards can number - their own texts from zero without colliding with the row selection - keys. The risk is lifecycle: every card rebuild path must unregister - the old `TextEdit` handles before registering replacements, or the next - long press can find a freed handle. Cover result arrival, group toggle, - a call joining a run and the per-card widget swap with stale-handle - tests. This uses iris's existing selection API; it is not a framework - widget gap. - **Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by `ui-trace record --do "tap '