Add retained paints and shared text selection
This commit is contained in:
1 parent
25370731d0
commit
de92fccba5
60 files changed
+2848
-1297
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user