Add retained paints and shared text selection

This commit is contained in:
iris committed 2026-09-10 18:35:24 -04:00
1 parent 25370731d0
commit de92fccba5
60 files changed
+2848 -1297

No files matched your search

+1 -1
View File
@@ -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)
+8 -8
View File
@@ -69,7 +69,7 @@ impl HasAndroidUiState for BenchClient {
fn placeholder<Rsc: HasEvents>(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::<Selectable>(())
.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(),
+3 -3
View File
@@ -36,7 +36,7 @@ fn build_transport() -> Result<UreqTransport, String> {
fn placeholder<Rsc: HasEvents>(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: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
type Rsc = AndroidRsc<TranscriptClient>;
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<TranscriptClient>) -> 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| {
+5 -5
View File
@@ -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<Client>, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
+4 -5
View File
@@ -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: HasEvents>(rsc: &mut Rsc) -> (Composer, WeakWidget)
pub fn build_composer<Rsc: HasEvents>(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::<Selectable>(())
.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<Pad> = content.pad(Padding::ZERO).add(rsc);
+66 -49
View File
@@ -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<usize>,
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::<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(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<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:?}");
@@ -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. "]);
+43 -141
View File
@@ -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<LazySpan>,
pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>,
rebuilds: std::cell::Cell<usize>,
tail: RefCell<Option<(RowKey, row::TailRow)>>,
session_working: std::cell::Cell<bool>,
theme: Rc<Theme>,
}
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<String> {
self.selection.borrow().selected_text(rsc)
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)
})?
}
}
@@ -238,8 +242,14 @@ pub fn build_tree<Rsc: HasEvents>(
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::<SelectionController, _>(
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,
+44 -60
View File
@@ -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<Block>,
fields: Vec<WeakWidget<TextEdit>>,
fields: Vec<WeakWidget<Text>>,
links: Vec<Rc<RefCell<Vec<Link>>>>,
column: WeakWidget<Span>,
sender: Option<String>,
@@ -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<Theme>,
}
/// 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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: SelKey,
block: &Block,
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
theme: &Theme,
) -> (WeakWidget<Text>, StrongWidget, Rc<RefCell<Vec<Link>>>)
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::<SelectionController, _>(
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
markdown_src: &str,
cap: bool,
theme: Rc<Theme>,
) -> (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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
cap: bool,
theme: Rc<Theme>,
) -> (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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
lines: usize,
theme: Rc<Theme>,
) -> 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<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
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, 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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
row: &FoldedRow,
working: bool,
cap: bool,
theme: Rc<Theme>,
) -> (RowKey, StrongWidget, Option<TailRow>)
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)))
}
-378
View File
@@ -1,378 +0,0 @@
use iris::prelude::*;
use std::{collections::BTreeMap, time::Instant};
pub type SelKey = (RowKey, u32);
pub struct Selection {
rows: BTreeMap<SelKey, WeakWidget<TextEdit>>,
anchor: Option<(SelKey, Vec2)>,
gesture: DragGesture,
scroll: Option<WeakWidget<LazySpan>>,
}
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<LazySpan>) {
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<TextEdit>) {
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<SelKey> = 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<SelKey> = 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<SelKey> = 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<Item = u32> + '_ {
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<LazySpan>,
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<String> {
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<RowKey> {
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());
}
}
+9 -14
View File
@@ -1,27 +1,22 @@
use crate::ui::selection::Selection;
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
pub(crate) fn on_tap<Rsc: HasEvents>(
rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
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::<SelectionController, _>(
list,
|id, selection, rsc| selection.drag(id, rsc, input),
)
.unwrap_or(SelectionInput::Tapped);
if outcome == SelectionInput::Tapped {
f(rsc);
}
})
+70
View File
@@ -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,
)
}
+95 -102
View File
@@ -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<Vec<WeakWidget<WidgetPtr>>>,
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
working: Cell<bool>,
theme: Rc<Theme>,
}
/// One transcript row's worth of tool calls, kept by the caller for the
@@ -71,18 +56,18 @@ pub struct ToolRow {
shared: Rc<Shared>,
}
fn text<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuilder<Rsc> {
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) -> TextBuilder<Rsc> {
text(glyph, MARK_DP, MUTED_COLOR).family(Family::Icons)
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>) -> StrongWidget
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, 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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
calls: Vec<TranscriptItem>,
working: bool,
theme: Rc<Theme>,
) -> (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();
+26
View File
@@ -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::<String>::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();
+1 -1
View File
@@ -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);