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) => { Err(message) => {
let text = wtext(format!("Couldn't fold the bench fixture: {message}")) let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(dp(16)) .pad(dp(16))
.add_strong(rsc) .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 { fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(16) .pad(16)
.add_strong(rsc) .add_strong(rsc)
@@ -124,7 +124,7 @@ impl AndroidAppState for BenchClient {
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) .wrap(true)
.size(14) .size(14)
.color(Color::WHITE) .color(PaintId::WHITE)
.attr::<Selectable>(()) .attr::<Selectable>(())
.label("Benchmark report") .label("Benchmark report")
.add(rsc); .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 /// 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 /// (`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. /// 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 /// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than /// `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; const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { 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( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
@@ -307,7 +307,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
.pad(dp(8)) .pad(dp(8))
.add(rsc); .add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60)) let copy_rect = rect(Srgba8::rgb(50, 50, 60))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
@@ -325,7 +325,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
.pad(dp(8)) .pad(dp(8))
.add(rsc); .add(rsc);
let diag_rect = rect(Color::rgb(60, 45, 70)) let diag_rect = rect(Srgba8::rgb(60, 45, 70))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { |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. // either full of trace or has none.
let tracing = iris::diagnostics::trace_enabled(); let tracing = iris::diagnostics::trace_enabled();
let trace_rect = rect(if tracing { let trace_rect = rect(if tracing {
Color::rgb(90, 70, 30) Srgba8::rgb(90, 70, 30)
} else { } else {
Color::rgb(50, 50, 60) Srgba8::rgb(50, 50, 60)
}) })
.on( .on(
CursorSense::click(), 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 { fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(16) .pad(16)
.add_strong(rsc) .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 { fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
type Rsc = AndroidRsc<TranscriptClient>; type Rsc = AndroidRsc<TranscriptClient>;
let report_rect = rect(Color::rgb(50, 50, 60)) let report_rect = rect(Srgba8::rgb(50, 50, 60))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx |ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
@@ -69,7 +69,7 @@ fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
.pad(8) .pad(8)
.add(rsc); .add(rsc);
let reset_rect = rect(Color::rgb(70, 40, 40)) let reset_rect = rect(Srgba8::rgb(70, 40, 40))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| { |ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
+5 -5
View File
@@ -193,7 +193,7 @@ impl Client {
list(rsc).push(row); list(rsc).push(row);
} }
let tree = list let tree = list
.background(rect(Color::rgb(24, 24, 28))) .background(rect(Srgba8::rgb(24, 24, 28)))
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
(self.list_ptr)(rsc).set(tree); (self.list_ptr)(rsc).set(tree);
@@ -299,14 +299,14 @@ fn session_row(
selected: bool, selected: bool,
) -> StrongWidget { ) -> StrongWidget {
let bg = if selected { let bg = if selected {
Color::rgb(58, 90, 138) Srgba8::rgb(58, 90, 138)
} else { } else {
Color::rgb(38, 38, 44) Srgba8::rgb(38, 38, 44)
}; };
let id = session.id.clone(); let id = session.id.clone();
let label = format!("{}\n{}", session.title, session.status); let label = format!("{}\n{}", session.title, session.status);
wtext(label) wtext(label)
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(10) .pad(10)
.width(rest(1)) .width(rest(1))
@@ -323,7 +323,7 @@ fn session_row(
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget { fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(16) .pad(16)
.add_strong(rsc) .add_strong(rsc)
+4 -5
View File
@@ -1,11 +1,10 @@
use crate::ui::theme::Theme;
use iris::prelude::*; use iris::prelude::*;
const MAX_LINES: f32 = 6.0; const MAX_LINES: f32 = 6.0;
const APPROX_LINE_HEIGHT_DP: f32 = 24.0; const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
const FIELD_PAD_DP: f32 = 12.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` is exposed so the caller can read its content on submit
/// (`field.edit(rsc).text()`) and clear it afterward /// (`field.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`). /// (`field.edit(rsc).set("")`).
@@ -43,7 +42,7 @@ impl Composer {
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same /// 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 /// mistake this box's `row.rs` first made with its sender-label header, see
/// that file's comment for the fuller account. /// 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 where
Rsc::State: FocusHost, Rsc::State: FocusHost,
{ {
@@ -52,7 +51,7 @@ where
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) .wrap(true)
.size(18) .size(18)
.color(UiColor::WHITE) .color(theme.text.clone())
.attr::<Selectable>(()) .attr::<Selectable>(())
.label("Message") .label("Message")
.add(rsc); .add(rsc);
@@ -66,7 +65,7 @@ where
.pad(dp(FIELD_PAD_DP)) .pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0)) .max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.width(rest(1)) .width(rest(1))
.masked_by(rect(BAR_FILL)) .masked_by(rect(theme.composer_surface.clone()))
.add(rsc); .add(rsc);
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).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::highlight::{self, Kind, Language};
use crate::client::markdown_blocks::{Block, BlockKind}; use crate::client::markdown_blocks::{Block, BlockKind};
use crate::ui::theme::Theme;
use iris::prelude::*; use iris::prelude::*;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::ops::Range; use std::ops::Range;
const fn mocha(hex: u32) -> UiColor { fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
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 {
match kind { match kind {
Kind::Keyword => mocha(0xCBA6F7), Kind::Keyword => theme.syntax_keyword.clone(),
Kind::String => mocha(0xA6E3A1), Kind::String => theme.syntax_string.clone(),
Kind::Literal => mocha(0xFAB387), Kind::Literal => theme.syntax_literal.clone(),
Kind::Comment => mocha(0x6C7086), Kind::Comment => theme.syntax_comment.clone(),
Kind::Metadata => mocha(0xF9E2AF), Kind::Metadata => theme.syntax_metadata.clone(),
Kind::Punctuation => mocha(0xA6ADC8), Kind::Punctuation => theme.syntax_punctuation.clone(),
Kind::Mark => mocha(0x89DCEB), Kind::Mark => theme.syntax_mark.clone(),
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockFrame { pub enum BlockFrame {
Plain, Plain,
Verbatim { fill: UiColor }, Verbatim { fill: PaintId },
Quote, Quote,
} }
pub fn frame_of(kind: BlockKind) -> BlockFrame { pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
match kind { match kind {
BlockKind::Code => BlockFrame::Verbatim { BlockKind::Code => BlockFrame::Verbatim {
fill: VERBATIM_BACKGROUND, fill: theme.verbatim_surface.clone(),
}, },
BlockKind::Table => BlockFrame::Verbatim { BlockKind::Table => BlockFrame::Verbatim {
fill: TABLE_BACKGROUND, fill: theme.table_surface.clone(),
}, },
BlockKind::Quote => BlockFrame::Quote, BlockKind::Quote => BlockFrame::Quote,
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => { 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 { match block.kind {
BlockKind::Table => table_text(&block.source), BlockKind::Table => table_text(&block.source, theme),
_ => render_markdown(&block.source, base_size), _ => 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 /// 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 /// only so a heading's override is relative to it rather than a hardcoded
/// absolute the caller cannot retune. /// 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 _ = base_size; // headings use the fixed Material ladder; see `heading_size`
let mut out = String::new(); let mut out = String::new();
let mut spans = Vec::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()]), _ => 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::List(first) => lists.push(first),
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out), 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::Emphasis => spans.push(SpanStyle::new(range).italic()),
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()), TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
TagEnd::Strikethrough => { 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 // An image draws as its alt text until the port has a
// transcript image widget (IRIS_TODO's "scaled // transcript image widget (IRIS_TODO's "scaled
// thumbnail"); marked as a link so it is at least // thumbnail"); marked as a link so it is at least
// followable rather than silently inert. // followable rather than silently inert.
TagEnd::Link | TagEnd::Image => { 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 { if let Some(url) = dest {
links.push(Link { range, url }); links.push(Link { range, url });
} }
@@ -225,10 +211,10 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
spans.push( spans.push(
SpanStyle::new(range.clone()) SpanStyle::new(range.clone())
.family(Family::Monospace) .family(Family::Monospace)
.color(CODE_COLOR), .color(theme.code.clone()),
); );
if let Some(language) = fence_language.take() { 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"), _ => unreachable!("filtered by the outer match arm"),
@@ -241,7 +227,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
spans.push( spans.push(
SpanStyle::new(start..out.len()) SpanStyle::new(start..out.len())
.family(Family::Monospace) .family(Family::Monospace)
.color(CODE_COLOR), .color(theme.code.clone()),
); );
} }
Event::SoftBreak => out.push(' '), Event::SoftBreak => out.push(' '),
@@ -253,7 +239,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
Event::TaskListMarker(done) => { Event::TaskListMarker(done) => {
let start = out.len(); let start = out.len();
out.push_str(if done { "[x] " } else { "[ ] " }); 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(_)) => { Event::End(TagEnd::List(_)) => {
lists.pop(); lists.pop();
@@ -282,6 +268,7 @@ pub(crate) fn highlight_into(
text: &str, text: &str,
range: Range<usize>, range: Range<usize>,
language: Language, language: Language,
theme: &Theme,
) { ) {
let code = &text[range.clone()]; let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's // char index -> byte offset within `code`, plus the end, so a span's
@@ -305,14 +292,14 @@ pub(crate) fn highlight_into(
spans.push( spans.push(
SpanStyle::new(range.start + start..range.start + end) SpanStyle::new(range.start + start..range.start + end)
.family(Family::Monospace) .family(Family::Monospace)
.color(syntax_color(span.kind)), .color(syntax_color(span.kind, theme)),
); );
} }
} }
const TABLE_MAX_COL: usize = 28; 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); let rows = table_cells(src);
if rows.is_empty() { if rows.is_empty() {
return Rendered::default(); 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: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
let rule_start = out.len(); let rule_start = out.len();
out.extend(std::iter::repeat_n('\u{2500}', rule)); 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 { Rendered {
@@ -421,6 +408,36 @@ mod tests {
use super::*; use super::*;
use crate::client::markdown_blocks::split_blocks; 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 { fn block(src: &str) -> Rendered {
let blocks = split_blocks(src); let blocks = split_blocks(src);
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}"); 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.text, "let x = 1;");
assert_eq!(r.spans.len(), 1); assert_eq!(r.spans.len(), 1);
assert!(r.spans[0].family == Some(Family::Monospace)); 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] #[test]
@@ -550,7 +567,7 @@ mod tests {
let markers: Vec<_> = r let markers: Vec<_> = r
.spans .spans
.iter() .iter()
.filter(|s| s.color == Some(MARKER_COLOR)) .filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string()) .map(|s| r.text[s.range.clone()].to_string())
.collect(); .collect();
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]); assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
@@ -563,7 +580,7 @@ mod tests {
let markers: Vec<_> = r let markers: Vec<_> = r
.spans .spans
.iter() .iter()
.filter(|s| s.color == Some(MARKER_COLOR)) .filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string()) .map(|s| r.text[s.range.clone()].to_string())
.collect(); .collect();
assert_eq!(markers, ["3. ", "4. "]); assert_eq!(markers, ["3. ", "4. "]);
+43 -141
View File
@@ -7,14 +7,14 @@ pub mod composer;
pub mod fixture; pub mod fixture;
pub mod markdown; pub mod markdown;
pub mod row; pub mod row;
pub mod selection;
pub(crate) mod tap; pub(crate) mod tap;
pub mod theme;
pub mod tool; pub mod tool;
use crate::client::transcript_fold::TranscriptRow as FoldedRow; use crate::client::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*; use iris::prelude::*;
use selection::Selection;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
use theme::Theme;
pub struct TranscriptScreen { pub struct TranscriptScreen {
/// The transcript's own `LazySpan` -- the layout *and* the scroll /// The transcript's own `LazySpan` -- the layout *and* the scroll
@@ -25,10 +25,10 @@ pub struct TranscriptScreen {
/// `.jump_to_end()` directly. /// `.jump_to_end()` directly.
pub list: WeakWidget<LazySpan>, pub list: WeakWidget<LazySpan>,
pub composer: composer::Composer, pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>,
rebuilds: std::cell::Cell<usize>, rebuilds: std::cell::Cell<usize>,
tail: RefCell<Option<(RowKey, row::TailRow)>>, tail: RefCell<Option<(RowKey, row::TailRow)>>,
session_working: std::cell::Cell<bool>, session_working: std::cell::Cell<bool>,
theme: Rc<Theme>,
} }
impl TranscriptScreen { impl TranscriptScreen {
@@ -48,10 +48,10 @@ impl TranscriptScreen {
let (key, widget, tail) = row::build_row( let (key, widget, tail) = row::build_row(
rsc, rsc,
self.list, self.list,
self.selection.clone(),
row, row,
self.session_working.get(), self.session_working.get(),
true, true,
self.theme.clone(),
); );
(self.list)(rsc).push_back(LazyItem::new(key, widget)); (self.list)(rsc).push_back(LazyItem::new(key, widget));
*self.tail.borrow_mut() = tail.map(|t| (key, t)); *self.tail.borrow_mut() = tail.map(|t| (key, t));
@@ -122,14 +122,7 @@ impl TranscriptScreen {
) { ) {
return false; return false;
} }
blocks.apply_delta( blocks.apply_delta(rsc, sender, &markdown_src)
rsc,
self.list,
self.selection.clone(),
key,
sender,
&markdown_src,
)
} }
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => { (row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
tools.apply_calls(rsc, calls, self.session_working.get()) tools.apply_calls(rsc, calls, self.session_working.get())
@@ -179,14 +172,13 @@ impl TranscriptScreen {
return; return;
} }
self.selection.borrow_mut().unregister(old_key);
let (new_key, widget, kept) = row::build_row( let (new_key, widget, kept) = row::build_row(
rsc, rsc,
self.list, self.list,
self.selection.clone(),
&new_rows[common], &new_rows[common],
self.session_working.get(), self.session_working.get(),
false, false,
self.theme.clone(),
); );
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget)); 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 drop(evicted); // frees the old row's widget, same as a pop would
@@ -197,7 +189,6 @@ impl TranscriptScreen {
} }
RowDiff::Rebuild => { RowDiff::Rebuild => {
self.rebuilds.set(self.rebuilds.get() + 1); self.rebuilds.set(self.rebuilds.get() + 1);
self.selection.borrow_mut().clear();
(self.list)(rsc).clear(); (self.list)(rsc).clear();
*self.tail.borrow_mut() = None; *self.tail.borrow_mut() = None;
for row in &new_rows { for row in &new_rows {
@@ -211,10 +202,23 @@ impl TranscriptScreen {
self.rebuilds.replace(0) 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 /// The concatenated text of whatever is currently selected across one
/// or more rows, `None` if nothing is -- what a copy command reads. /// or more rows, `None` if nothing is -- what a copy command reads.
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> { pub fn selected_text<Rsc: HasEvents>(&self, rsc: &mut Rsc) -> Option<String> {
self.selection.borrow().selected_text(rsc) 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 where
Rsc::State: FocusHost + OpenUrl, 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); 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 // The last row's block widgets are kept for the same reason
// `push_row` keeps them: a reply that is *already* streaming when the // `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 // be a reply already streaming when this screen opened, and a
// capped row cannot take a delta (`RowBlocks::capped`). // capped row cannot take a delta (`RowBlocks::capped`).
let cap = i + 1 < rows.len(); 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)); list(rsc).push_back(LazyItem::new(key, widget));
tail = kept.map(|t| (key, t)); tail = kept.map(|t| (key, t));
} }
// The continuation of a row-started drag once it has committed and // The controller host covers gaps as well as text, so a tap anywhere in
// taken pointer capture on `list`'s own id (`row.rs`'s registration is // the transcript can dismiss a selection. Text and link listeners may
// only ever the gesture's first frame) -- registered once here, not // see the same physical sample; `SelectionController` deduplicates it by
// once per row, since `DragGesture`'s single shared instance must see // the sample's own timestamp while still returning the same tap decision
// each frame of one gesture exactly once. `ctx.data.pos`/`size` are // to whichever leaf owns the link action.
// 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.
{ {
let selection = selection.clone(); list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
list.on( let input = &ctx.data;
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop | CursorSense::Cancel, rsc.with_nearest_controller::<SelectionController, _>(
move |ctx, rsc| { list,
let row = selection |id, selection, rsc| selection.drag(id, rsc, input),
.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,
);
},
)
.add(rsc); .add(rsc);
} }
@@ -299,9 +294,8 @@ where
ctx.widget(rsc).scroll(delta); ctx.widget(rsc).scroll(delta);
}) })
.add(rsc); .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) let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
.span(Dir::DOWN) .span(Dir::DOWN)
@@ -314,8 +308,8 @@ where
session_working: std::cell::Cell::new(false), session_working: std::cell::Cell::new(false),
list, list,
composer, composer,
selection,
rebuilds: std::cell::Cell::new(0), rebuilds: std::cell::Cell::new(0),
theme,
}, },
tree, 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 { fn assistant(seq: u64, text: &str) -> TranscriptItem {
TranscriptItem::AssistantMsg { TranscriptItem::AssistantMsg {
seq, 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 { fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
TranscriptItem::ToolRun { TranscriptItem::ToolRun {
seq: 1, 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::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label};
use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block}; 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::tap::{hold_edge, on_tap};
use crate::ui::theme::Theme;
use crate::ui::tool::ToolRow; use crate::ui::tool::ToolRow;
use iris::prelude::*; use iris::prelude::*;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
@@ -93,7 +93,7 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
pub struct RowBlocks { pub struct RowBlocks {
blocks: Vec<Block>, blocks: Vec<Block>,
fields: Vec<WeakWidget<TextEdit>>, fields: Vec<WeakWidget<Text>>,
links: Vec<Rc<RefCell<Vec<Link>>>>, links: Vec<Rc<RefCell<Vec<Link>>>>,
column: WeakWidget<Span>, column: WeakWidget<Span>,
sender: Option<String>, sender: Option<String>,
@@ -108,6 +108,7 @@ pub struct RowBlocks {
/// keeps the refusal from costing anything in practice. This field is /// keeps the refusal from costing anything in practice. This field is
/// the belt to that braces. /// the belt to that braces.
capped: bool, capped: bool,
theme: Rc<Theme>,
} }
/// Split for display: never empty, so a row with nothing in it yet is /// 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>( fn build_block<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: SelKey,
block: &Block, block: &Block,
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>) theme: &Theme,
) -> (WeakWidget<Text>, StrongWidget, Rc<RefCell<Vec<Link>>>)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let frame = frame_of(block.kind); let frame = frame_of(block.kind, theme);
let rendered = render_block(block, BASE_SIZE); let rendered = render_block(block, BASE_SIZE, theme);
let links = Rc::new(RefCell::new(rendered.links)); let links = Rc::new(RefCell::new(rendered.links));
let verbatim = matches!(frame, BlockFrame::Verbatim { .. }); let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
let field = wtext(rendered.text) let field = wtext(rendered.text)
.spans(rendered.spans) .spans(rendered.spans)
.editable(EditMode::MultiLine)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(!verbatim) .wrap(!verbatim)
.family(if verbatim { .family(if verbatim {
@@ -205,31 +203,28 @@ where
}) })
.size(BASE_SIZE) .size(BASE_SIZE)
.color(match frame { .color(match frame {
BlockFrame::Quote => crate::ui::markdown::QUOTE_TEXT_COLOR, BlockFrame::Quote => theme.quote_text.clone(),
_ => crate::ui::markdown::TEXT_COLOR, _ => theme.text.clone(),
}) })
.add(rsc); .add(rsc);
selection.borrow_mut().register(key, field);
let tap_links = links.clone(); let tap_links = links.clone();
field field
.on(CursorSense::drag_senses(), move |ctx, rsc| { .on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos); let (pos, size) = (ctx.data.pos, ctx.data.size);
let outcome = selection.borrow_mut().drag( let input = &ctx.data;
rsc, let outcome = rsc
list, .with_nearest_controller::<SelectionController, _>(
Some((key, pos, size)), field,
cursor, |id, selection, rsc| selection.drag(id, rsc, input),
ctx.data.sense, )
ctx.data.cursor.time, .unwrap_or(SelectionInput::Tapped);
ctx.data.pointer,
);
// A *tap*, decided by the same `DragArbiter` the pan and // A *tap*, decided by the same `DragArbiter` the pan and
// the selection are: a gesture that panned the list past // the selection are: a gesture that panned the list past
// this link, or held long enough to select, must not also // this link, or held long enough to select, must not also
// follow it (`GestureOutcome::Tapped`'s doc). // follow it (`GestureOutcome::Tapped`'s doc).
if outcome == GestureOutcome::Tapped { if outcome == SelectionInput::Tapped {
let byte = field.edit(rsc).byte_at(cursor, size); let byte = field.selection(rsc).byte_at(pos, size);
let url = tap_links let url = tap_links
.borrow() .borrow()
.iter() .iter()
@@ -258,7 +253,7 @@ where
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP), left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
..Padding::ZERO ..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)) .width(rest(1))
.add_strong(rsc) .add_strong(rsc)
.any(), .any(),
@@ -266,14 +261,15 @@ where
(field, framed, links) (field, framed, links)
} }
#[allow(clippy::too_many_arguments)]
fn build_text_row<Rsc: HasEvents>( fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
sender: Option<&str>, sender: Option<&str>,
markdown_src: &str, markdown_src: &str,
cap: bool, cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks) ) -> (StrongWidget, RowBlocks)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
@@ -284,7 +280,7 @@ where
}); });
let strong = WidgetPtr::new().add_strong(rsc); let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak(); 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); ptr(rsc).set(content);
(strong.any(), blocks) (strong.any(), blocks)
} }
@@ -292,14 +288,15 @@ where
/// Separate from [`build_text_row`] because the tap calls it a second /// Separate from [`build_text_row`] because the tap calls it a second
/// time, with `cap` false, and writes the result back into the same /// time, with `cap` false, and writes the result back into the same
/// `WidgetPtr`. /// `WidgetPtr`.
#[allow(clippy::too_many_arguments)]
fn row_content<Rsc: HasEvents>( fn row_content<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
source: Rc<RowSource>, source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>, ptr: WeakWidget<WidgetPtr>,
cap: bool, cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks) ) -> (StrongWidget, RowBlocks)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
@@ -308,9 +305,8 @@ where
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP)); let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
let mut fields = Vec::with_capacity(blocks.len()); let mut fields = Vec::with_capacity(blocks.len());
let mut links = Vec::with_capacity(blocks.len()); let mut links = Vec::with_capacity(blocks.len());
for (i, block) in blocks.iter().enumerate() { for block in &blocks {
let (field, framed, block_links) = let (field, framed, block_links) = build_block(rsc, block, &theme);
build_block(rsc, list, selection.clone(), (key, i as u32), block);
fields.push(field); fields.push(field);
links.push(block_links); links.push(block_links);
column.push(framed); column.push(framed);
@@ -319,11 +315,11 @@ where
column.push(show_all( column.push(show_all(
rsc, rsc,
list, list,
selection.clone(),
key, key,
source.clone(), source.clone(),
ptr, ptr,
lines, lines,
theme.clone(),
)); ));
} }
let column = column.add(rsc); let column = column.add(rsc);
@@ -339,7 +335,7 @@ where
let header: WeakWidget = match &source.sender { let header: WeakWidget = match &source.sender {
Some(name) => wtext(name.clone()) Some(name) => wtext(name.clone())
.size(13.0) .size(13.0)
.color(UiColor::new(150, 150, 160, 255)) .color(theme.secondary_text.clone())
.add(rsc), .add(rsc),
None => Span::empty(Dir::DOWN).add(rsc), None => Span::empty(Dir::DOWN).add(rsc),
}; };
@@ -359,6 +355,7 @@ where
column, column,
sender: source.sender.clone(), sender: source.sender.clone(),
capped: hidden.is_some(), capped: hidden.is_some(),
theme,
}, },
) )
} }
@@ -370,11 +367,11 @@ where
fn show_all<Rsc: HasEvents>( fn show_all<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
source: Rc<RowSource>, source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>, ptr: WeakWidget<WidgetPtr>,
lines: usize, lines: usize,
theme: Rc<Theme>,
) -> StrongWidget ) -> StrongWidget
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
@@ -384,22 +381,15 @@ where
let more = more_strong.weak(); let more = more_strong.weak();
let words = wtext(label.clone()) let words = wtext(label.clone())
.size(13.0) .size(13.0)
.color(UiColor::new(150, 150, 160, 255)) .color(theme.secondary_text.clone())
.text_align(Align::LEFT) .text_align(Align::LEFT)
.label(label) .label(label)
.add_strong(rsc); .add_strong(rsc);
more(rsc).set(words); 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); hold_edge(rsc, list, key);
let (content, _blocks) = row_content( let (content, _blocks) =
rsc, row_content(rsc, list, key, source.clone(), ptr, false, theme.clone());
list,
selection.clone(),
key,
source.clone(),
ptr,
false,
);
let _old = ptr(rsc).replace(content); let _old = ptr(rsc).replace(content);
}); });
more_strong.any() more_strong.any()
@@ -415,9 +405,6 @@ impl RowBlocks {
pub fn apply_delta<Rsc: HasEvents>( pub fn apply_delta<Rsc: HasEvents>(
&mut self, &mut self,
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>, sender: Option<&str>,
markdown_src: &str, markdown_src: &str,
) -> bool ) -> bool
@@ -461,15 +448,12 @@ impl RowBlocks {
for (i, block) in new_blocks.iter().enumerate().skip(common) { for (i, block) in new_blocks.iter().enumerate().skip(common) {
match (self.fields.get(i), self.links.get(i)) { match (self.fields.get(i), self.links.get(i)) {
(Some(field), Some(links)) => { (Some(field), Some(links)) => {
let rendered = render_block(block, BASE_SIZE); let rendered = render_block(block, BASE_SIZE, &self.theme);
field field(rsc).set_with_spans(rendered.text, rendered.spans);
.edit(rsc)
.set_with_spans(&rendered.text, rendered.spans);
*links.borrow_mut() = rendered.links; *links.borrow_mut() = rendered.links;
} }
_ => { _ => {
let (field, framed, links) = let (field, framed, links) = build_block(rsc, block, &self.theme);
build_block(rsc, list, selection.clone(), (key, i as u32), block);
self.fields.push(field); self.fields.push(field);
self.links.push(links); self.links.push(links);
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) { if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
@@ -486,16 +470,16 @@ impl RowBlocks {
fn build_single<Rsc: HasEvents>( fn build_single<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
item: &TranscriptItem, item: &TranscriptItem,
cap: bool, cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks) ) -> (StrongWidget, RowBlocks)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let (sender, markdown_src) = item_content(item); 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 /// 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>( pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
row: &FoldedRow, row: &FoldedRow,
working: bool, working: bool,
cap: bool, cap: bool,
theme: Rc<Theme>,
) -> (RowKey, StrongWidget, Option<TailRow>) ) -> (RowKey, StrongWidget, Option<TailRow>)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
@@ -537,14 +521,14 @@ where
if let Some(calls) = calls { if let Some(calls) = calls {
let key = row_key(&calls[0].key()); let key = row_key(&calls[0].key());
let (widget, tools) = 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))); return (key, widget, Some(TailRow::Tools(tools)));
} }
let FoldedRow::Single(item) = row else { let FoldedRow::Single(item) = row else {
unreachable!("every Tools row took the branch above"); unreachable!("every Tools row took the branch above");
}; };
let key = row_key(&item.key()); 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))) (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 iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
pub(crate) fn on_tap<Rsc: HasEvents>( pub(crate) fn on_tap<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>, ptr: WeakWidget<WidgetPtr>,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
f: impl Fn(&mut Rsc) + 'static, f: impl Fn(&mut Rsc) + 'static,
) where ) where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
ptr.on(CursorSense::drag_senses(), move |ctx, rsc| { ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let outcome = selection.borrow_mut().drag( let input = &ctx.data;
rsc, let outcome = rsc
list, .with_nearest_controller::<SelectionController, _>(
None, list,
ctx.data.cursor.pos, |id, selection, rsc| selection.drag(id, rsc, input),
ctx.data.sense, )
ctx.data.cursor.time, .unwrap_or(SelectionInput::Tapped);
ctx.data.pointer, if outcome == SelectionInput::Tapped {
);
if outcome == GestureOutcome::Tapped {
f(rsc); 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::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label};
use crate::client::tool_summary::{ToolInput, parse_tool_input}; use crate::client::tool_summary::{ToolInput, parse_tool_input};
use crate::client::transcript_fold::{ToolState, TranscriptItem}; use crate::client::transcript_fold::{ToolState, TranscriptItem};
use crate::ui::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into}; use crate::ui::markdown::highlight_into;
use crate::ui::selection::Selection;
use crate::ui::tap::{hold_edge, on_tap}; use crate::ui::tap::{hold_edge, on_tap};
use crate::ui::theme::Theme;
use iris::prelude::*; use iris::prelude::*;
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc}; 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 NAME_SIZE: f32 = 14.0;
const BODY_SIZE: f32 = 12.0; const BODY_SIZE: f32 = 12.0;
const LABEL_SIZE: f32 = 11.0; const LABEL_SIZE: f32 = 11.0;
@@ -58,9 +43,9 @@ struct Shared {
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>, cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
content: RefCell<Option<WeakWidget<WidgetPtr>>>, content: RefCell<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
working: Cell<bool>, working: Cell<bool>,
theme: Rc<Theme>,
} }
/// One transcript row's worth of tool calls, kept by the caller for the /// One transcript row's worth of tool calls, kept by the caller for the
@@ -71,18 +56,18 @@ pub struct ToolRow {
shared: Rc<Shared>, 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) wtext(content)
.size(size) .size(size)
.color(color) .color(color)
.text_align(Align::LEFT) .text_align(Align::LEFT)
} }
fn disclosure<Rsc>(glyph: &'static str) -> TextBuilder<Rsc> { fn disclosure<Rsc>(glyph: &'static str, theme: &Theme) -> TextBuilder<Rsc> {
text(glyph, MARK_DP, MUTED_COLOR).family(Family::Icons) 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 where
Rsc::State: FocusHost, Rsc::State: FocusHost,
{ {
@@ -94,22 +79,36 @@ where
field field
.scrollable(Axis::X, Pin::Start) .scrollable(Axis::X, Pin::Start)
.pad(dp(RAW_PAD_DP)) .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)) .width(rest(1))
.add_strong(rsc) .add_strong(rsc)
.any() .any()
} }
fn state_mark(state: ToolState) -> Option<(&'static str, UiColor)> { fn state_word(state: ToolState) -> Option<&'static str> {
match state { match state {
ToolState::Deciding => Some(("your turn", AWAITING_COLOR)), ToolState::Deciding => Some("your turn"),
ToolState::Running => Some(("running", MUTED_COLOR)), ToolState::Running => Some("running"),
ToolState::Failed => Some(("failed", FAILED_COLOR)), ToolState::Failed => Some("failed"),
ToolState::NoResult => Some(("no result", UNKNOWN_COLOR)), ToolState::NoResult => Some("no result"),
ToolState::Succeeded => None, 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` /// 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 /// 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 /// 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(": ");
name.push_str(title); name.push_str(title);
} }
if let Some((word, _)) = state_mark(state) { if let Some(word) = state_word(state) {
name.push_str(" ("); name.push_str(" (");
name.push_str(word); name.push_str(word);
name.push(')'); name.push(')');
@@ -170,27 +169,21 @@ where
let label = show_all_label(lines); let label = show_all_label(lines);
let more_strong = WidgetPtr::new().add_strong(rsc); let more_strong = WidgetPtr::new().add_strong(rsc);
let more = more_strong.weak(); 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) .label(label)
.add_strong(rsc); .add_strong(rsc);
more(rsc).set(words); more(rsc).set(words);
let shared_for_tap = shared.clone(); let shared_for_tap = shared.clone();
let key = (id.to_string(), part); let key = (id.to_string(), part);
on_tap( on_tap(rsc, more, shared.list, move |rsc| {
rsc, hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
more, shared_for_tap
shared.list, .state
shared.selection.clone(), .borrow_mut()
move |rsc| { .whole
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key); .insert(key.clone(), true);
shared_for_tap redraw_card(rsc, &shared_for_tap, index);
.state });
.borrow_mut()
.whole
.insert(key.clone(), true);
redraw_card(rsc, &shared_for_tap, index);
},
);
more_strong.any() more_strong.any()
} }
@@ -207,19 +200,25 @@ where
{ {
if output.is_empty() { if output.is_empty() {
let (words, colour) = match call_state { let (words, colour) = match call_state {
ToolState::Succeeded => ("No output", MUTED_COLOR), ToolState::Succeeded => ("No output", shared.theme.muted.clone()),
ToolState::Failed => ("Failed, with no output", FAILED_COLOR), ToolState::Failed => ("Failed, with no output", shared.theme.failed.clone()),
ToolState::NoResult => ("No result ever arrived", UNKNOWN_COLOR), ToolState::NoResult => ("No result ever arrived", shared.theme.unknown.clone()),
ToolState::Running | ToolState::Deciding => ("No output yet", MUTED_COLOR), ToolState::Running | ToolState::Deciding => {
("No output yet", shared.theme.muted.clone())
}
}; };
return text(words, LABEL_SIZE, colour).add_strong(rsc).any(); return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
} }
let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output)); let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
let mut column = Span::empty(Dir::DOWN).gap(dp(2)); let mut column = Span::empty(Dir::DOWN).gap(dp(2));
column.push(text("Output", LABEL_SIZE, NAME_COLOR).add_strong(rsc).any()); column.push(
let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR); text("Output", LABEL_SIZE, shared.theme.text.clone())
column.push(raw_block(rsc, body)); .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 { if was_cut {
column.push(show_all(rsc, shared, index, id, Part::Output, lines)); 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)); let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
header.push( header.push(
disclosure(if open { icon::OPEN } else { icon::CLOSED }) disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme)
.add_strong(rsc) .add_strong(rsc)
.any(), .any(),
); );
header.push( header.push(
text(tool.clone(), NAME_SIZE, NAME_COLOR) text(tool.clone(), NAME_SIZE, shared.theme.text.clone())
.add_strong(rsc) .add_strong(rsc)
.any(), .any(),
); );
@@ -263,7 +262,7 @@ where
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any()) header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
} }
(false, Some(title)) => header.push( (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) .wrap(false)
.masked() .masked()
.width(rest(1)) .width(rest(1))
@@ -273,12 +272,16 @@ where
} }
if open && let Some(timeout) = &parsed.timeout { if open && let Some(timeout) = &parsed.timeout {
header.push( header.push(
text(format!("timeout {timeout}"), LABEL_SIZE, MUTED_COLOR) text(
.add_strong(rsc) format!("timeout {timeout}"),
.any(), 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()); header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
} }
@@ -287,7 +290,7 @@ where
if open { if open {
if let Some(description) = &parsed.description { if let Some(description) = &parsed.description {
column.push( column.push(
text(description.clone(), BODY_SIZE, MUTED_COLOR) text(description.clone(), BODY_SIZE, shared.theme.muted.clone())
.width(rest(1)) .width(rest(1))
.add_strong(rsc) .add_strong(rsc)
.any(), .any(),
@@ -303,13 +306,13 @@ where
let spans = match parsed.language { let spans = match parsed.language {
Some(language) => { Some(language) => {
let mut spans = Vec::new(); 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 spans
} }
None => Vec::new(), None => Vec::new(),
}; };
let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR).spans(spans); let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()).spans(spans);
column.push(raw_block(rsc, body)); column.push(raw_block(rsc, body, &shared.theme));
} }
if !parsed.rest.is_empty() { if !parsed.rest.is_empty() {
// Never dropped: a field left out would be claiming the tool // Never dropped: a field left out would be claiming the tool
@@ -320,8 +323,8 @@ where
let (shown, lines, was_cut) = capped(&joined, whole); let (shown, lines, was_cut) = capped(&joined, whole);
input_lines += lines; input_lines += lines;
input_cut |= was_cut; input_cut |= was_cut;
let body = text(shown.to_string(), BODY_SIZE, MUTED_COLOR); let body = text(shown.to_string(), BODY_SIZE, shared.theme.muted.clone());
column.push(raw_block(rsc, body)); column.push(raw_block(rsc, body, &shared.theme));
} }
if input_cut { if input_cut {
column.push(show_all(rsc, shared, index, id, Part::Input, input_lines)); column.push(show_all(rsc, shared, index, id, Part::Input, input_lines));
@@ -332,7 +335,7 @@ where
column column
.width(rest(1)) .width(rest(1))
.pad(dp(CARD_PAD_DP)) .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)) .width(rest(1))
.label(card_label(tool, &parsed, call_state)) .label(card_label(tool, &parsed, call_state))
.add_strong(rsc) .add_strong(rsc)
@@ -370,28 +373,22 @@ where
let content = build_card(rsc, shared, index); let content = build_card(rsc, shared, index);
ptr(rsc).set(content); ptr(rsc).set(content);
let for_tap = shared.clone(); let for_tap = shared.clone();
on_tap( on_tap(rsc, ptr, shared.list, move |rsc| {
rsc, hold_edge(rsc, for_tap.list, for_tap.key);
ptr, let Some(id) = for_tap.call_id(index) else {
shared.list, debug_assert!(false, "tapped card {index} is no longer in the row");
shared.selection.clone(), return;
move |rsc| { };
hold_edge(rsc, for_tap.list, for_tap.key); let was = for_tap
let Some(id) = for_tap.call_id(index) else { .state
debug_assert!(false, "tapped card {index} is no longer in the row"); .borrow()
return; .open
}; .get(&id)
let was = for_tap .copied()
.state .unwrap_or(false);
.borrow() for_tap.state.borrow_mut().open.insert(id, !was);
.open redraw_card(rsc, &for_tap, index);
.get(&id) });
.copied()
.unwrap_or(false);
for_tap.state.borrow_mut().open.insert(id, !was);
redraw_card(rsc, &for_tap, index);
},
);
(strong.any(), ptr) (strong.any(), ptr)
} }
@@ -401,7 +398,7 @@ where
{ {
let strong = WidgetPtr::new().add_strong(rsc); let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak(); let ptr = strong.weak();
let mark = disclosure(icon::COLLAPSE) let mark = disclosure(icon::COLLAPSE, &shared.theme)
.center_text() .center_text()
.width(rest(1)) .width(rest(1))
.pad(dp(CARD_PAD_DP)) .pad(dp(CARD_PAD_DP))
@@ -411,13 +408,9 @@ where
.add_strong(rsc); .add_strong(rsc);
ptr(rsc).set(mark); ptr(rsc).set(mark);
let for_tap = shared.clone(); let for_tap = shared.clone();
on_tap( on_tap(rsc, ptr, shared.list, move |rsc| {
rsc, toggle_group(rsc, &for_tap)
ptr, });
shared.list,
shared.selection.clone(),
move |rsc| toggle_group(rsc, &for_tap),
);
strong.any() strong.any()
} }
@@ -438,10 +431,10 @@ where
if !shared.state.borrow().group_expanded { if !shared.state.borrow().group_expanded {
let heading = group_label(count); 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)) .pad(dp(CARD_PAD_DP))
.width(rest(1)) .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)) .width(rest(1))
.label(heading) .label(heading)
.add_strong(rsc) .add_strong(rsc)
@@ -451,7 +444,7 @@ where
let heading = group_label(count); let heading = group_label(count);
let mut group = Span::empty(Dir::DOWN); let mut group = Span::empty(Dir::DOWN);
group.push( group.push(
text(heading.clone(), NAME_SIZE, NAME_COLOR) text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
.pad(dp(CARD_PAD_DP)) .pad(dp(CARD_PAD_DP))
.width(rest(1)) .width(rest(1))
.label(heading) .label(heading)
@@ -468,7 +461,7 @@ where
group.push(collapse_bar(rsc, shared)); group.push(collapse_bar(rsc, shared));
group group
.width(rest(1)) .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)) .width(rest(1))
.add_strong(rsc) .add_strong(rsc)
.any() .any()
@@ -516,10 +509,10 @@ impl Shared {
pub fn build_tool_row<Rsc: HasEvents>( pub fn build_tool_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
calls: Vec<TranscriptItem>, calls: Vec<TranscriptItem>,
working: bool, working: bool,
theme: Rc<Theme>,
) -> (StrongWidget, ToolRow) ) -> (StrongWidget, ToolRow)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
@@ -530,9 +523,9 @@ where
cards: RefCell::new(Vec::new()), cards: RefCell::new(Vec::new()),
content: RefCell::new(None), content: RefCell::new(None),
list, list,
selection,
key, key,
working: Cell::new(working), working: Cell::new(working),
theme,
}); });
let content_strong = WidgetPtr::new().add_strong(rsc); let content_strong = WidgetPtr::new().add_strong(rsc);
let content = content_strong.weak(); 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] #[test]
fn the_composer_sits_above_a_simulated_ime_inset() { fn the_composer_sits_above_a_simulated_ime_inset() {
let (mut h, screen) = opened(); let (mut h, screen) = opened();
+1 -1
View File
@@ -3,7 +3,7 @@ use iris::harness::Harness;
use iris::prelude::*; use iris::prelude::*;
const HEADER_H: f32 = 300.0; 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) { fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE); let mut h = Harness::new(phone_size(), PHONE_SCALE);
+13 -30
View File
@@ -2,35 +2,10 @@
Only open Iris framework work lives here. Delete an item when it lands. Only open Iris framework work lives here. Delete an item when it lands.
## Fix
- [ ] **Colours are not in a defined colour space. Fix this before a
styling pass.** Both render backends prefer an sRGB surface
(`default/render.rs` and `android/render.rs`), while `fs_main` returns
`unpack4x8unorm` palette and image bytes unchanged. An sRGB attachment
treats those values as linear and encodes them again: Mocha Crust
(17,17,27) became (73,73,91) in a desktop screenshot measured on
2026-09-06.
Neither diagnostics nor startup logging records Android's selected surface
format. A device exposing only a non-sRGB surface can hide the shared bug.
Done means defining one convention for palette bytes, decoded images,
colour emoji and the clear colour, then converting exactly once for the
selected target. Record the selected format in diagnostics, and add a GPU
test that draws known non-black, non-white pixels into an sRGB target and
reads the stored bytes back; screenshots from desktop and Android then
confirm the same Catppuccin values rather than serving as the definition.
## Build (for the port) ## Build (for the port)
Framework capabilities needed by `RUST.md`'s port plan: Framework capabilities needed by `RUST.md`'s port plan:
- [ ] **Selectable, read-only text.** P0's report and P1's transcript rows
use `TextEdit` because `Selectable` is implemented only for it. That
makes prose focusable and opens the IME over text that cannot be edited.
A display widget needs the same selection geometry and clipboard path
without a text-input accessibility role or keyboard focus.
- [ ] **Overflow ellipsis with an explicit retained end.** `TextAttrs` can - [ ] **Overflow ellipsis with an explicit retained end.** `TextAttrs` can
only wrap or clip, so a tool summary is cut with no mark. Parley has no only wrap or clip, so a tool summary is cut with no mark. Parley has no
ellipsis primitive; use its line breaker to find the cut, but keep source ellipsis primitive; use its line breaker to find the cut, but keep source
@@ -52,9 +27,9 @@ Framework capabilities needed by `RUST.md`'s port plan:
- [ ] **Per-range backgrounds for rich text.** (**P1**.) Inline code is - [ ] **Per-range backgrounds for rich text.** (**P1**.) Inline code is
already monospace and coloured, but matching Compose's chip also needs already monospace and coloured, but matching Compose's chip also needs
the glyph run's boxes so a surface can be drawn behind exactly that byte the glyph run's boxes so a surface can be drawn behind exactly that byte
range. `TextEdit` already computes the same geometry internally for its range. The shared `TextSelection` engine already computes the same geometry
selection highlight; expose one shared primitive rather than giving the for selection highlights; expose one shared primitive rather than giving
app a second text-layout path. the app a second text-layout path.
- [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and - [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and
**P5**.) Needed for the session settings dialog, `UsageDialog`'s **P5**.) Needed for the session settings dialog, `UsageDialog`'s
equivalent, and the delete-with-`deleteForeign` confirmation with its equivalent, and the delete-with-`deleteForeign` confirmation with its
@@ -69,6 +44,14 @@ Framework capabilities needed by `RUST.md`'s port plan:
## Later ## Later
- [ ] **Intern independently constructed solid paint definitions.** Inline
`rect(Srgba8::...)` values currently receive a new `PaintId` each time.
Cache them by canonical linear RGBA bits, but keep `Paints::add` explicitly
unique so two semantic theme roles that start with the same value can later
change independently. Cache entries must be weak and disappear when the
last real handle releases the slot; gradients and texture paints need their
own identity rules rather than inheriting solid-value interning blindly.
- [ ] **Property/content animations.** Cosmetic, so after correctness and - [ ] **Property/content animations.** Cosmetic, so after correctness and
parity. Keep them modular, like input; scrolling already animates through parity. Keep them modular, like input; scrolling already animates through
`Widget::tick` and `UiData::animate`. A widget that does not opt in must `Widget::tick` and `UiData::animate`. A widget that does not opt in must
@@ -85,8 +68,8 @@ Framework capabilities needed by `RUST.md`'s port plan:
against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own
size and density and the two are identical to the pixel. What the pair size and density and the two are identical to the pixel. What the pair
cannot express is a clip that is not a box: `Painter::set_mask` writes cannot express is a clip that is not a box: `Painter::set_mask` writes
a `RectPrimitive::color(Color::NONE)` at the widget's own region, with a `RectPrimitive::color` using `PaintId::NONE` at the widget's own region,
no radius, so `.background(rect(fill).radius(r)).masked()` draws a with no radius, so `.background(rect(fill).radius(r)).masked()` draws a
rounded panel and then cuts its content square. Both other call sites rounded panel and then cuts its content square. Both other call sites
(`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why (`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why
the method stands for now. the method stands for now.
+11 -25
View File
@@ -22,8 +22,7 @@ the command and measured value when evidence matters.
next session should start. Its box below has the state. next session should start. Its box below has the state.
- The port is one crate under `app-rust/`; `iris/` is only the UI framework. - The port is one crate under `app-rust/`; `iris/` is only the UI framework.
- **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own - **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own
list (colour-space correctness is the live one), `docs/TODO.md` is the list; `docs/TODO.md` is the Compose app's.
Compose app's.
## Desktop and phone share the code ## Desktop and phone share the code
@@ -81,7 +80,7 @@ runs inside `cargo test`.
pan, and (e) the composer clears a simulated 1000px IME inset pan, and (e) the composer clears a simulated 1000px IME inset
(`Composer::set_bottom_inset`). Each was confirmed to fail without (`Composer::set_bottom_inset`). Each was confirmed to fail without
its subject rather than assumed: dropping `animate(id)` from its subject rather than assumed: dropping `animate(id)` from
`Selection::drag` -- the phone's own "fling does nothing" defect -- `SelectionController::drag` -- the phone's own "fling does nothing" defect --
and starting the fling curve at the wall clock each fail only the and starting the fling curve at the wall clock each fail only the
flick test; flinging on `Tapped` fails only the tap test; a 5s flick test; flinging on `Tapped` fails only the tap test; a 5s
`LONG_PRESS` fails only the selection test; a `set_bottom_inset` that `LONG_PRESS` fails only the selection test; a `set_bottom_inset` that
@@ -269,11 +268,14 @@ already got right (v2 signing, `uses-feature`, ABI splits).
Reading the Compose code for what a replacement must be able to express, Reading the Compose code for what a replacement must be able to express,
rather than what it happens to look like: rather than what it happens to look like:
1. **The transcript is one selectable body of text.** One 1. **The transcript is one selectable body of text.** A
`SelectionContainer` around the whole lazy list, so a selection runs from `SelectionController` is registered directly on the lazy list, with no
a reply into the tool output beneath it. The framework needs selectable selection widget in the layout tree, so a selection runs from a reply into
read-only rich text across many rows, with the platform's selection the tool output beneath it. Each parent supplies either draw order or one
handles and clipboard on the phone. visual axis for its immediate children; ordering is resolved only when
selection queries it. Iris owns the selection handles because its text is
drawn into one surface, while the platform supplies the clipboard and
related system services.
2. **Rich inline text**: markdown with links (one tap detector per text, 2. **Rich inline text**: markdown with links (one tap detector per text,
not a node per link), inline code chips drawn behind the text, tables not a node per link), inline code chips drawn behind the text, tables
with wrapping cells and a sideways scroll, syntax-highlighted fences, with wrapping cells and a sideways scroll, syntax-highlighted fences,
@@ -442,7 +444,7 @@ buffer on **every keystroke**. So there is nothing to adopt, and adding
it would be upstream work in parley. it would be upstream work in parley.
**And the app already does the thing incremental layout would buy.** **And the app already does the thing incremental layout would buy.**
`RowBlocks::apply_delta` keeps one `TextEdit` per top-level markdown `RowBlocks::apply_delta` keeps one `Text` per top-level markdown
block and re-shapes only the block a delta landed in; re-splitting the block and re-shapes only the block a delta landed in; re-splitting the
markdown to find that block is 18µs at 18,000 characters and comparing markdown to find that block is 18µs at 18,000 characters and comparing
the blocks is 470ns. Neither is the cost. the blocks is 470ns. Neither is the cost.
@@ -664,12 +666,6 @@ pane is neither masked nor scrollable despite its construction comment saying
it is both. This is an `app-rust` defect, not an iris framework item. it is both. This is an `app-rust` defect, not an iris framework item.
- [ ] **P1 — session screen parity.** Continue in this order: - [ ] **P1 — session screen parity.** Continue in this order:
- [ ] **Before the next parity slice — make iris's colour pipeline
correct.** Both backends currently prefer an sRGB surface while
the shader returns palette/image bytes as
linear values; `IRIS_TODO.md` has the measured mismatch and
pass condition. Do this before judging or centralising the
app's styling. It is correctness, not cosmetic polish.
- [ ] **P1c — history paging and jump-to-latest.** Wire - [ ] **P1c — history paging and jump-to-latest.** Wire
`client::transcript_source` into `src/ui`: `client::transcript_source` into `src/ui`:
the opening page, paging back on scroll with the cushion the opening page, paging back on scroll with the cushion
@@ -725,16 +721,6 @@ it is both. This is an `app-rust` defect, not an iris framework item.
names are app content applied through iris's existing `.label()` API, names are app content applied through iris's existing `.label()` API,
not a missing framework widget. not a missing framework widget.
**`app-rust` UI defect still open**: tool-card text is not selectable.
A `TranscriptRow::Tools` has no markdown blocks, so cards can number
their own texts from zero without colliding with the row selection
keys. The risk is lifecycle: every card rebuild path must unregister
the old `TextEdit` handles before registering replacements, or the next
long press can find a freed handle. Cover result arrival, group toggle,
a call joining a run and the per-card widget swap with stale-handle
tests. This uses iris's existing selection API; it is not a framework
widget gap.
**Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by **Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by
`ui-trace record --do "tap '<label>'"` — a session with the big `ui-trace record --do "tap '<label>'"` — a session with the big
transcript (`AI_SANDBOX_BIG_MB`), a paused/slow-spawning one transcript (`AI_SANDBOX_BIG_MB`), a paused/slow-spawning one
+5 -5
View File
@@ -240,16 +240,16 @@ without a scroll-widget special case.
The transcript registers the wheel **by hand rather than calling The transcript registers the wheel **by hand rather than calling
`LazySpan::scrollable()`**, and this is not an oversight. That helper also `LazySpan::scrollable()`**, and this is not an oversight. That helper also
registers a finger drag driving the span's own `DragGesture`, and the registers a finger drag driving the span's own `DragGesture`, and the
transcript already has an arbiter — `Selection`, which must decide between transcript already has an arbiter — `SelectionController`, which must decide between
panning and selecting text and so cannot let a second `DragGesture` see panning and selecting text and so cannot let a second `DragGesture` see
the same frames. `DragGesture`'s doc states the rule: one gesture, one the same frames. `DragGesture`'s doc states the rule: one gesture, one
arbiter, each frame delivered exactly once. The wheel handler registered arbiter, each frame delivered exactly once. The wheel handler registered
here is identical to the helper's; only the drag differs. here is identical to the helper's; only the drag differs.
`Selection` is given the span by `set_scroll_area` after it exists (rows `SelectionController` is attached directly to the span and holds its weak
need a `Selection`, and the span needs the rows), and hands it committed handle, then hands committed pans and releases through
pans and releases through `Scrollable::scroll`/`fling`. There is no `Scrollable::scroll`/`fling`. There is no wrapper widget:
wrapper widget: `TranscriptScreen::list` is the layout (`extent`, `TranscriptScreen::list` is the layout (`extent`,
`key_at`, `jump_to_end`) *and* the position (`amt`, `fling`, `key_at`, `jump_to_end`) *and* the position (`amt`, `fling`,
`is_scrolling`). `is_scrolling`).
+20
View File
@@ -42,6 +42,26 @@ adjacency after a free.
Standalone images currently use `NonFiltering` sampling. Thumbnail scaling Standalone images currently use `NonFiltering` sampling. Thumbnail scaling
and filtering remain image-widget decisions, not texture-storage decisions. and filtering remain image-widget decisions, not texture-storage decisions.
## Colour convention
Palette literals and decoded image bytes enter Iris as straight-alpha sRGB.
Solid paints are converted once when registered and stored in a separate GPU
paint table as linear `vec4<f32>`; rect and glyph primitives carry only the
paint-table index. `Paints::set` rewrites a slot in place, so a shared theme
can change without rebuilding widgets or primitive buffers. A rect may also
hold a `Paint` definition and resolve it to a `PaintId` on its first draw;
independently constructed definitions deliberately receive independent slots.
Standalone images, colour glyphs, and atlas pages use
`Rgba8UnormSrgb`, which makes sampling decode their RGB channels to linear
light. Shaders therefore always return linear values. Both window backends
render through an sRGB texture view in `SurfaceColorSpace::Srgb`, which
encodes exactly once on output; the clear colour is linear too. A surface
without an advertised RGBA/BGRA sRGB view and sRGB output space is rejected
rather than silently displaying a different colour pipeline. The readback
test in `iris/tests/color_space.rs` guards the solid, image, and in-place
theme-update paths end to end.
## Verification rig ## Verification rig
`scripts/rigs/gpu-probe` requests Iris's exact feature and limit set without a `scripts/rigs/gpu-probe` requests Iris's exact feature and limit set without a
+2 -2
View File
@@ -131,7 +131,7 @@ fn bench_input_grows(n: usize, lines: usize) {
}); });
let line_height = 24.0; let line_height = 24.0;
let input_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let input_rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let input_area = rsc.ui.widgets.add_strong(Sized { let input_area = rsc.ui.widgets.add_strong(Sized {
inner: input_rect.any(), inner: input_rect.any(),
x: None, x: None,
@@ -238,7 +238,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut growable = None; let mut growable = None;
for i in 0..n { for i in 0..n {
if i == growable_index { if i == growable_index {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let sized = rsc.ui.widgets.add_strong(Sized { let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(), inner: rect.any(),
x: None, x: None,
+261
View File
@@ -0,0 +1,261 @@
use crate::{
ActiveData, WidgetId,
util::{HashMap, HashSet},
};
use std::any::{Any, TypeId};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ControllerId {
host: WidgetId,
kind: TypeId,
}
impl ControllerId {
pub fn host(self) -> WidgetId {
self.host
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Command {
Copy,
SelectAll,
Escape,
}
#[derive(Debug, Eq, PartialEq)]
pub enum CommandResult {
Unused,
Used,
Copy(String),
}
pub trait ControllerValue: Any {
fn into_any(self: Box<Self>) -> Box<dyn Any>;
}
impl<T: Any> ControllerValue for T {
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
pub trait Controller<Rsc>: ControllerValue {
fn command(&mut self, _command: Command, _rsc: &mut Rsc) -> CommandResult {
CommandResult::Unused
}
}
pub struct ControllerManager<Rsc> {
by_widget: HashMap<WidgetId, HashMap<TypeId, Box<dyn Controller<Rsc>>>>,
parents: HashMap<WidgetId, Option<WidgetId>>,
borrowed: HashSet<ControllerId>,
removed_while_borrowed: HashSet<WidgetId>,
command_target: Option<ControllerId>,
command_target_revision: u64,
command_boundary: Option<WidgetId>,
}
impl<Rsc> Default for ControllerManager<Rsc> {
fn default() -> Self {
Self {
by_widget: Default::default(),
parents: Default::default(),
borrowed: Default::default(),
removed_while_borrowed: Default::default(),
command_target: None,
command_target_revision: 0,
command_boundary: None,
}
}
}
impl<Rsc: 'static> ControllerManager<Rsc> {
#[track_caller]
pub fn register<C: Controller<Rsc>>(&mut self, host: WidgetId, controller: C) {
let kind = TypeId::of::<C>();
let id = ControllerId { host, kind };
assert!(
!self.borrowed.contains(&id),
"a controller cannot be replaced while it is handling input"
);
assert!(
!self.removed_while_borrowed.contains(&host),
"a controller cannot be attached to a removed widget"
);
let old = self
.by_widget
.entry(host)
.or_default()
.insert(kind, Box::new(controller));
assert!(
old.is_none(),
"a widget cannot have two controllers of type {}",
std::any::type_name::<C>()
);
}
pub fn id<C: Controller<Rsc>>(&self, host: WidgetId) -> Option<ControllerId> {
let kind = TypeId::of::<C>();
self.by_widget
.get(&host)?
.contains_key(&kind)
.then_some(ControllerId { host, kind })
}
pub fn nearest_id<C: Controller<Rsc>>(&self, mut origin: WidgetId) -> Option<ControllerId> {
let kind = TypeId::of::<C>();
loop {
let candidate = ControllerId { host: origin, kind };
assert!(
!self.borrowed.contains(&candidate),
"a controller cannot re-enter itself while it is handling input"
);
if let Some(id) = self.id::<C>(origin) {
return Some(id);
}
origin = self.parents.get(&origin).copied().flatten()?;
}
}
pub fn path_to<C: Controller<Rsc>>(
&self,
mut origin: WidgetId,
) -> Option<(ControllerId, Vec<WidgetId>)> {
let mut path = Vec::new();
loop {
path.push(origin);
if let Some(id) = self.id::<C>(origin) {
return Some((id, path));
}
origin = self.parents.get(&origin).copied().flatten()?;
}
}
pub fn draw(&mut self, active: &ActiveData) {
self.parents.insert(active.id, active.parent);
}
pub fn undraw(&mut self, active: &ActiveData) {
self.parents.remove(&active.id);
}
pub fn take<C: Controller<Rsc>>(&mut self, id: ControllerId) -> Option<C> {
if id.kind != TypeId::of::<C>() {
return None;
}
assert!(
!self.borrowed.contains(&id),
"a controller cannot re-enter itself while it is handling input"
);
let boxed = self.by_widget.get_mut(&id.host)?.remove(&id.kind)?;
self.borrowed.insert(id);
let boxed = boxed.into_any();
boxed.downcast().ok().map(|boxed| *boxed)
}
pub fn put<C: Controller<Rsc>>(&mut self, id: ControllerId, controller: C) {
debug_assert_eq!(id.kind, TypeId::of::<C>());
assert!(
self.borrowed.remove(&id),
"restored an unborrowed controller"
);
if self.finish_removed_host(id.host) {
return;
}
let old = self
.by_widget
.entry(id.host)
.or_default()
.insert(id.kind, Box::new(controller));
debug_assert!(old.is_none(), "a controller was re-entered while borrowed");
}
fn take_dyn(&mut self, id: ControllerId) -> Option<Box<dyn Controller<Rsc>>> {
assert!(
!self.borrowed.contains(&id),
"a controller cannot re-enter itself while it is handling input"
);
let controller = self.by_widget.get_mut(&id.host)?.remove(&id.kind)?;
self.borrowed.insert(id);
Some(controller)
}
fn put_dyn(&mut self, id: ControllerId, controller: Box<dyn Controller<Rsc>>) {
assert!(
self.borrowed.remove(&id),
"restored an unborrowed controller"
);
if self.finish_removed_host(id.host) {
return;
}
let old = self
.by_widget
.entry(id.host)
.or_default()
.insert(id.kind, controller);
debug_assert!(old.is_none(), "a controller was re-entered while borrowed");
}
pub fn set_command_target(&mut self, target: Option<ControllerId>) {
self.command_target = target;
self.command_target_revision = self.command_target_revision.wrapping_add(1);
}
pub fn command_target(&self) -> Option<ControllerId> {
self.command_target
}
pub(crate) fn command_target_revision(&self) -> u64 {
self.command_target_revision
}
pub(crate) fn command_boundary(&self) -> Option<WidgetId> {
self.command_boundary
}
pub(crate) fn set_command_boundary(&mut self, boundary: Option<WidgetId>) {
self.command_boundary = boundary;
}
pub fn remove(&mut self, host: WidgetId) {
self.by_widget.remove(&host);
self.parents.remove(&host);
if self.borrowed.iter().any(|id| id.host == host) {
self.removed_while_borrowed.insert(host);
}
if self.command_target.is_some_and(|id| id.host == host) {
self.command_target = None;
}
}
pub(crate) fn take_command_target(
&mut self,
) -> Option<(ControllerId, Box<dyn Controller<Rsc>>)> {
let id = self.command_target?;
match self.take_dyn(id) {
Some(controller) => Some((id, controller)),
None => {
self.command_target = None;
None
}
}
}
pub(crate) fn restore(&mut self, id: ControllerId, controller: Box<dyn Controller<Rsc>>) {
self.put_dyn(id, controller);
}
/// Returns true when a host disappeared during its controller callback,
/// in which case restoring the temporarily extracted value would revive
/// state belonging to a dead widget generation.
fn finish_removed_host(&mut self, host: WidgetId) -> bool {
if !self.removed_while_borrowed.contains(&host) {
return false;
}
if !self.borrowed.iter().any(|id| id.host == host) {
self.removed_while_borrowed.remove(&host);
}
true
}
}
+8 -3
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
ActiveData, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents, IdLike, LayerId, ActiveData, ControllerManager, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents,
WeakWidget, WidgetEventFn, WidgetId, IdLike, LayerId, WeakWidget, WidgetEventFn, WidgetId,
util::{HashMap, HashSet, TypeMap}, util::{HashMap, HashSet, TypeMap},
}; };
use std::{any::TypeId, rc::Rc}; use std::{any::TypeId, rc::Rc};
@@ -8,13 +8,15 @@ use std::{any::TypeId, rc::Rc};
pub struct EventManager<Rsc> { pub struct EventManager<Rsc> {
widget_to_types: HashMap<WidgetId, HashSet<TypeId>>, widget_to_types: HashMap<WidgetId, HashSet<TypeId>>,
types: TypeMap<dyn EventManagerLike<Rsc>>, types: TypeMap<dyn EventManagerLike<Rsc>>,
pub controllers: ControllerManager<Rsc>,
} }
impl<Rsc> Default for EventManager<Rsc> { impl<Rsc: 'static> Default for EventManager<Rsc> {
fn default() -> Self { fn default() -> Self {
Self { Self {
widget_to_types: Default::default(), widget_to_types: Default::default(),
types: Default::default(), types: Default::default(),
controllers: Default::default(),
} }
} }
} }
@@ -54,15 +56,18 @@ impl<Rsc: HasEvents + 'static> EventsLike for EventManager<Rsc> {
for t in self.widget_to_types.get(&id).into_flat_iter() { for t in self.widget_to_types.get(&id).into_flat_iter() {
self.types.get_mut(t).unwrap().remove(id); self.types.get_mut(t).unwrap().remove(id);
} }
self.controllers.remove(id);
} }
fn draw(&mut self, active: &ActiveData) { fn draw(&mut self, active: &ActiveData) {
self.controllers.draw(active);
for t in self.widget_to_types.get(&active.id).into_flat_iter() { for t in self.widget_to_types.get(&active.id).into_flat_iter() {
self.types.get_mut(t).unwrap().draw(active); self.types.get_mut(t).unwrap().draw(active);
} }
} }
fn undraw(&mut self, active: &ActiveData) { fn undraw(&mut self, active: &ActiveData) {
self.controllers.undraw(active);
for t in self.widget_to_types.get(&active.id).into_flat_iter() { for t in self.widget_to_types.get(&active.id).into_flat_iter() {
self.types.get_mut(t).unwrap().undraw(active); self.types.get_mut(t).unwrap().undraw(active);
} }
+2
View File
@@ -1,7 +1,9 @@
mod controller;
mod ctx; mod ctx;
mod manager; mod manager;
mod rsc; mod rsc;
pub use controller::*;
pub use ctx::*; pub use ctx::*;
pub use manager::*; pub use manager::*;
pub use rsc::*; pub use rsc::*;
+71 -1
View File
@@ -1,5 +1,6 @@
use crate::{ use crate::{
Event, EventCtx, EventLike, EventManager, IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn, Command, CommandResult, Controller, ControllerId, Event, EventCtx, EventLike, EventManager,
IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn,
}; };
pub trait HasState: 'static { pub trait HasState: 'static {
@@ -18,6 +19,75 @@ pub trait HasEvents: Sized + UiRsc + HasState {
) { ) {
self.events_mut().register(id, event, f); self.events_mut().register(id, event, f);
} }
fn register_controller<W: ?Sized, C: Controller<Self>>(
&mut self,
id: WeakWidget<W>,
controller: C,
) {
self.events_mut().controllers.register(id.id(), controller);
}
fn with_controller<C: Controller<Self>, T>(
&mut self,
id: ControllerId,
f: impl FnOnce(&mut C, &mut Self) -> T,
) -> Option<T> {
let mut controller = self.events_mut().controllers.take::<C>(id)?;
let result = f(&mut controller, self);
self.events_mut().controllers.put(id, controller);
Some(result)
}
fn with_nearest_controller<C: Controller<Self>, T>(
&mut self,
origin: impl IdLike,
f: impl FnOnce(ControllerId, &mut C, &mut Self) -> T,
) -> Option<T> {
let id = self.events().controllers.nearest_id::<C>(origin.id())?;
self.with_controller(id, |controller, rsc| f(id, controller, rsc))
}
fn set_command_target(&mut self, target: Option<ControllerId>) {
self.events_mut().controllers.set_command_target(target);
}
fn run_command(&mut self, command: Command) -> CommandResult {
let revision = self.events().controllers.command_target_revision();
if self
.events()
.controllers
.command_target()
.is_some_and(|target| {
self.events().controllers.command_boundary() == Some(target.host())
})
{
return CommandResult::Unused;
}
let Some((id, mut controller)) = self.events_mut().controllers.take_command_target() else {
return CommandResult::Unused;
};
let result = controller.command(command, self);
self.events_mut().controllers.restore(id, controller);
if command == Command::Escape
&& result != CommandResult::Unused
&& self.events().controllers.command_target_revision() == revision
{
self.set_command_target(None);
}
result
}
#[doc(hidden)]
fn run_command_before(&mut self, command: Command, boundary: impl IdLike) -> CommandResult {
let old = self.events().controllers.command_boundary();
self.events_mut()
.controllers
.set_command_boundary(Some(boundary.id()));
let result = self.run_command(command);
self.events_mut().controllers.set_command_boundary(old);
result
}
} }
pub trait RunEvents: HasEvents { pub trait RunEvents: HasEvents {
-2
View File
@@ -30,5 +30,3 @@ pub use primitive::*;
pub use render::*; pub use render::*;
pub use ui::*; pub use ui::*;
pub use widget::*; pub use widget::*;
pub type UiColor = primitive::Color<u8>;
+489 -142
View File
@@ -1,171 +1,518 @@
use std::marker::Destruct; use crate::util::{Dirty, RefCounter};
use std::{
cell::RefCell,
fmt,
rc::Rc,
sync::mpsc::{Receiver, Sender, channel},
};
/// Encoded, straight-alpha sRGB at an input boundary.
///
/// Palette literals, decoded images and colour glyph bitmaps use this
/// convention. A solid paint is converted to linear light when it enters the
/// paint table; the renderer never performs colour arithmetic on these bytes.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)] #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Color<T> { pub struct Srgba8 {
pub r: T, pub r: u8,
pub g: T, pub g: u8,
pub b: T, pub b: u8,
pub a: T, pub a: u8,
} }
/// Required by parley's `Brush`, which every text style is generic over. Opaque impl Srgba8 {
/// black rather than transparent: a brush that was never set should be visible pub const BLACK: Self = Self::rgb(0, 0, 0);
/// and obviously unstyled, not invisible. pub const WHITE: Self = Self::rgb(255, 255, 255);
impl<T: ColorNum> Default for Color<T> { pub const GRAY: Self = Self::rgb(127, 127, 127);
pub const RED: Self = Self::rgb(255, 0, 0);
pub const ORANGE: Self = Self::rgb(255, 127, 0);
pub const YELLOW: Self = Self::rgb(255, 255, 0);
pub const LIME: Self = Self::rgb(127, 255, 0);
pub const GREEN: Self = Self::rgb(0, 255, 0);
pub const TURQUOISE: Self = Self::rgb(0, 255, 127);
pub const CYAN: Self = Self::rgb(0, 255, 255);
pub const SKY: Self = Self::rgb(0, 127, 255);
pub const BLUE: Self = Self::rgb(0, 0, 255);
pub const PURPLE: Self = Self::rgb(127, 0, 255);
pub const MAGENTA: Self = Self::rgb(255, 0, 255);
pub const NONE: Self = Self::new(0, 0, 0, 0);
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::new(r, g, b, 255)
}
pub fn to_linear(self) -> LinearRgba {
LinearRgba::new(
srgb_to_linear(self.r as f32 / 255.0),
srgb_to_linear(self.g as f32 / 255.0),
srgb_to_linear(self.b as f32 / 255.0),
self.a as f32 / 255.0,
)
}
}
/// Straight-alpha RGBA in linear-light sRGB primaries.
///
/// This is Iris's working representation: manipulate and interpolate colours
/// here, then put the result in [`Paints`]. The GPU paint buffer stores this
/// exact layout as `vec4<f32>`.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct LinearRgba {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
impl LinearRgba {
pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0);
pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0);
pub const NONE: Self = Self::new(0.0, 0.0, 0.0, 0.0);
pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
Self::new(r, g, b, 1.0)
}
pub fn mul_rgb(self, amount: f32) -> Self {
Self::new(self.r * amount, self.g * amount, self.b * amount, self.a)
}
pub fn darker(self, amount: f32) -> Self {
self.mul_rgb(1.0 - amount)
}
pub fn brighter(self, amount: f32) -> Self {
Self::new(
self.r + (1.0 - self.r) * amount,
self.g + (1.0 - self.g) * amount,
self.b + (1.0 - self.b) * amount,
self.a,
)
}
pub fn to_wgpu(self) -> wgpu::Color {
wgpu::Color {
r: self.r as f64,
g: self.g as f64,
b: self.b as f64,
a: self.a as f64,
}
}
}
fn srgb_to_linear(value: f32) -> f32 {
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
/// A description that can be registered in Iris's paint table.
///
/// Only solid paints exist today. Keeping registration behind this trait and
/// making primitives carry [`PaintId`] leaves one place to add gradient or
/// texture paint records later.
pub trait Paint: private::Sealed + 'static {
#[doc(hidden)]
fn add_to(&self, paints: &mut Paints) -> PaintId;
#[doc(hidden)]
fn replace(&self, paints: &mut Paints, slot: u32);
/// Erases this definition so a widget can register it lazily on its
/// first draw. [`PaintId`] overrides this to stay a direct handle.
#[doc(hidden)]
fn into_value(self) -> PaintValue
where
Self: Sized,
{
PaintValue::pending(self)
}
}
impl Paint for Srgba8 {
fn add_to(&self, paints: &mut Paints) -> PaintId {
paints.add_linear(self.to_linear())
}
fn replace(&self, paints: &mut Paints, slot: u32) {
paints.replace_linear(slot, self.to_linear());
}
}
impl Paint for LinearRgba {
fn add_to(&self, paints: &mut Paints) -> PaintId {
paints.add_linear(*self)
}
fn replace(&self, paints: &mut Paints, slot: u32) {
paints.replace_linear(slot, *self);
}
}
mod private {
pub trait Sealed {}
impl Sealed for super::Srgba8 {}
impl Sealed for super::LinearRgba {}
impl Sealed for super::PaintId {}
}
#[derive(Debug)]
struct PaintLease {
slot: u32,
counter: RefCounter,
send: Sender<u32>,
}
impl Clone for PaintLease {
fn clone(&self) -> Self {
Self {
slot: self.slot,
counter: self.counter.clone(),
send: self.send.clone(),
}
}
}
impl Drop for PaintLease {
fn drop(&mut self) {
if self.counter.drop() {
let _ = self.send.send(self.slot);
}
}
}
/// A stable reference to one entry in a UI's paint table.
///
/// Built-in IDs name the same reserved entries in every [`Paints`]. IDs
/// returned by [`Paints::add`] retain their slot until the last clone held by a
/// widget, shaped text or retained draw is dropped.
#[derive(Clone, Debug)]
pub struct PaintId {
slot: u32,
lease: Option<PaintLease>,
}
impl PaintId {
pub const BLACK: Self = Self::builtin(0);
pub const WHITE: Self = Self::builtin(1);
pub const GRAY: Self = Self::builtin(2);
pub const RED: Self = Self::builtin(3);
pub const ORANGE: Self = Self::builtin(4);
pub const YELLOW: Self = Self::builtin(5);
pub const LIME: Self = Self::builtin(6);
pub const GREEN: Self = Self::builtin(7);
pub const TURQUOISE: Self = Self::builtin(8);
pub const CYAN: Self = Self::builtin(9);
pub const SKY: Self = Self::builtin(10);
pub const BLUE: Self = Self::builtin(11);
pub const PURPLE: Self = Self::builtin(12);
pub const MAGENTA: Self = Self::builtin(13);
pub const NONE: Self = Self::builtin(14);
const fn builtin(slot: u32) -> Self {
Self { slot, lease: None }
}
pub(crate) fn slot(&self) -> u32 {
self.slot
}
fn is_managed(&self) -> bool {
self.lease.is_some()
}
}
impl Default for PaintId {
fn default() -> Self { fn default() -> Self {
Self::BLACK Self::BLACK
} }
} }
impl<T: ColorNum> Color<T> { impl PartialEq for PaintId {
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN); fn eq(&self, other: &Self) -> bool {
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX); self.slot == other.slot
pub const GRAY: Self = Self::rgb(T::MID, T::MID, T::MID);
pub const RED: Self = Self::rgb(T::MAX, T::MIN, T::MIN);
pub const ORANGE: Self = Self::rgb(T::MAX, T::MID, T::MIN);
pub const YELLOW: Self = Self::rgb(T::MAX, T::MAX, T::MIN);
pub const LIME: Self = Self::rgb(T::MID, T::MAX, T::MIN);
pub const GREEN: Self = Self::rgb(T::MIN, T::MAX, T::MIN);
pub const TURQUOISE: Self = Self::rgb(T::MIN, T::MAX, T::MID);
pub const CYAN: Self = Self::rgb(T::MIN, T::MAX, T::MAX);
pub const SKY: Self = Self::rgb(T::MIN, T::MID, T::MAX);
pub const BLUE: Self = Self::rgb(T::MIN, T::MIN, T::MAX);
pub const PURPLE: Self = Self::rgb(T::MID, T::MIN, T::MAX);
pub const MAGENTA: Self = Self::rgb(T::MAX, T::MIN, T::MAX);
pub const NONE: Self = Self::new(T::MIN, T::MIN, T::MIN, T::MIN);
pub const fn new(r: T, g: T, b: T, a: T) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: T, g: T, b: T) -> Self {
Self { r, g, b, a: T::MAX }
}
pub fn alpha(mut self, a: T) -> Self {
self.a = a;
self
}
pub fn as_arr(self) -> [T; 4] {
[self.r, self.g, self.b, self.a]
} }
} }
pub const trait F32Conversion { impl Eq for PaintId {}
fn to(self) -> f32;
fn from(x: f32) -> Self; impl Paint for PaintId {
fn add_to(&self, _paints: &mut Paints) -> PaintId {
self.clone()
}
fn replace(&self, paints: &mut Paints, slot: u32) {
let value = paints.entries[self.slot as usize];
paints.replace_linear(slot, value);
}
fn into_value(self) -> PaintValue {
PaintValue(PaintValueInner::Id(self))
}
} }
pub trait ColorNum { struct PendingPaint {
const MIN: Self; definition: Box<dyn Paint>,
const MID: Self; resolved: RefCell<Option<PaintId>>,
const MAX: Self;
} }
macro_rules! map_rgb { #[derive(Clone)]
($x:ident,$self:ident, $e:tt) => { enum PaintValueInner {
#[allow(unused_braces)] Id(PaintId),
Self { Pending(Rc<PendingPaint>),
r: { }
let $x = $self.r;
$e /// A widget property containing either an existing paint-table ID or a paint
}, /// definition that will receive an ID the first time it is drawn.
g: { ///
let $x = $self.g; /// Pending definitions are shared across clones and registered only once.
$e /// Each property replaces its own pending variant with the resulting direct
}, /// ID after that first resolution, so later draws take the direct path.
b: { #[derive(Clone)]
let $x = $self.b; pub struct PaintValue(PaintValueInner);
$e
}, impl PaintValue {
a: $self.a, fn pending(paint: impl Paint) -> Self {
Self(PaintValueInner::Pending(Rc::new(PendingPaint {
definition: Box::new(paint),
resolved: RefCell::new(None),
})))
}
pub fn resolve(&mut self, paints: &mut Paints) -> &PaintId {
if let PaintValueInner::Pending(pending) = &self.0 {
let resolved = pending.resolved.borrow().clone();
let id = match resolved {
Some(id) => id,
None => {
let id = pending.definition.add_to(paints);
*pending.resolved.borrow_mut() = Some(id.clone());
id
}
};
self.0 = PaintValueInner::Id(id);
} }
}; let PaintValueInner::Id(id) = &self.0 else {
} unreachable!()
};
impl<T: ColorNum + const F32Conversion> Color<T> id
where
Self: const Destruct,
{
pub const fn mul_rgb(self, amt: impl const F32Conversion) -> Self {
let amt = amt.to();
map_rgb!(x, self, { T::from(x.to() * amt) })
} }
pub const fn add_rgb(self, amt: impl const F32Conversion) -> Self { pub fn is(&self, id: &PaintId) -> bool {
let amt = amt.to(); match &self.0 {
map_rgb!(x, self, { T::from(x.to() + amt) }) PaintValueInner::Id(current) => current == id,
} PaintValueInner::Pending(pending) => pending.resolved.borrow().as_ref() == Some(id),
pub const fn darker(self, amt: f32) -> Self {
self.mul_rgb(1.0 - amt)
}
pub const fn brighter(self, amt: f32) -> Self {
map_rgb!(x, self, {
let x = x.to();
T::from(x + (1.0 - x) * amt)
})
}
pub fn map_rgb(self, f: impl Fn(T) -> T) -> Self {
Self {
r: f(self.r),
g: f(self.g),
b: f(self.b),
a: self.a,
}
}
pub fn srgb(r: T, g: T, b: T) -> Self {
Self {
r: s_to_l(r),
g: s_to_l(g),
b: s_to_l(b),
a: T::MAX,
} }
} }
} }
fn s_to_l<T: F32Conversion>(x: T) -> T { impl fmt::Debug for PaintValue {
let x = x.to(); fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
T::from(if x <= 0.0405 { match &self.0 {
x / 12.92 PaintValueInner::Id(id) => f.debug_tuple("PaintValue").field(id).finish(),
} else { PaintValueInner::Pending(_) => f.write_str("PaintValue(Pending)"),
((x + 0.055) / 1.055).powf(2.4) }
})
}
impl ColorNum for u8 {
const MIN: Self = u8::MIN;
const MID: Self = u8::MAX / 2;
const MAX: Self = u8::MAX;
}
impl ColorNum for f32 {
const MIN: Self = 0.0;
const MID: Self = 0.5;
const MAX: Self = 1.0;
}
unsafe impl bytemuck::Pod for Color<u8> {}
const impl F32Conversion for f32 {
fn to(self) -> f32 {
self
}
fn from(x: f32) -> Self {
x
} }
} }
const impl F32Conversion for u8 { const BUILTIN_PAINTS: [Srgba8; 15] = [
fn to(self) -> f32 { Srgba8::BLACK,
self as f32 / 255.0 Srgba8::WHITE,
Srgba8::GRAY,
Srgba8::RED,
Srgba8::ORANGE,
Srgba8::YELLOW,
Srgba8::LIME,
Srgba8::GREEN,
Srgba8::TURQUOISE,
Srgba8::CYAN,
Srgba8::SKY,
Srgba8::BLUE,
Srgba8::PURPLE,
Srgba8::MAGENTA,
Srgba8::NONE,
];
/// CPU-side paint table and the dirty set for its GPU mirror.
pub struct Paints {
entries: Vec<LinearRgba>,
free: Vec<u32>,
dirty: Dirty,
send: Sender<u32>,
recv: Receiver<u32>,
}
impl Paints {
pub fn new() -> Self {
let (send, recv) = channel();
Self {
entries: BUILTIN_PAINTS.map(Srgba8::to_linear).to_vec(),
free: Vec::new(),
dirty: Dirty::new_all(),
send,
recv,
}
} }
fn from(x: f32) -> Self {
(x * 255.0).clamp(0.0, 255.0) as Self pub fn add(&mut self, paint: impl Paint) -> PaintId {
paint.add_to(self)
}
fn add_linear(&mut self, value: LinearRgba) -> PaintId {
self.free_released();
let slot = if let Some(slot) = self.free.pop() {
self.entries[slot as usize] = value;
self.dirty.mark(slot as usize);
slot
} else {
let slot = self.entries.len() as u32;
self.entries.push(value);
self.dirty.mark(slot as usize);
slot
};
PaintId {
slot,
lease: Some(PaintLease {
slot,
counter: RefCounter::new(),
send: self.send.clone(),
}),
}
}
/// Replaces one managed paint in place. Every primitive keeps the same
/// index, so a theme change dirties this table and no primitive buffer.
pub fn set(&mut self, id: &PaintId, paint: impl Paint) {
assert!(
id.is_managed(),
"a reserved built-in paint cannot be replaced; allocate a theme slot with Paints::add"
);
paint.replace(self, id.slot);
}
fn replace_linear(&mut self, slot: u32, value: LinearRgba) {
self.entries[slot as usize] = value;
self.dirty.mark(slot as usize);
}
pub fn get(&self, id: &PaintId) -> LinearRgba {
self.entries[id.slot as usize]
}
pub fn free_released(&mut self) {
for slot in self.recv.try_iter() {
self.entries[slot as usize] = LinearRgba::NONE;
self.dirty.mark(slot as usize);
self.free.push(slot);
}
}
/// A new GPU device has no copy of this table even when the CPU-side UI
/// and its paint IDs survived an Android surface recreation.
pub fn reupload(&mut self) {
self.dirty.mark_all();
}
pub fn for_upload(&mut self) -> (&[LinearRgba], &mut Dirty) {
(&self.entries, &mut self.dirty)
}
}
impl Default for Paints {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn srgb_bytes_become_linear_without_transforming_alpha() {
let got = Srgba8::new(17, 127, 255, 64).to_linear();
assert!((got.r - 0.005605).abs() < 0.000001);
assert!((got.g - 0.212231).abs() < 0.000001);
assert_eq!(got.b, 1.0);
assert!((got.a - 64.0 / 255.0).abs() < f32::EPSILON);
}
#[test]
fn changing_a_paint_keeps_its_id_and_dirties_only_its_slot() {
let mut paints = Paints::new();
let id = paints.add(Srgba8::rgb(17, 17, 27));
let slot = id.slot();
let (_, dirty) = paints.for_upload();
dirty.clear();
paints.set(&id, Srgba8::rgb(205, 214, 244));
let (_, dirty) = paints.for_upload();
assert!(dirty.contains(slot as usize));
assert_eq!(id.slot(), slot);
}
#[test]
fn a_released_paint_slot_is_reused_only_after_the_last_clone() {
let mut paints = Paints::new();
let id = paints.add(Srgba8::RED);
let slot = id.slot();
let held = id.clone();
drop(id);
paints.free_released();
let other = paints.add(Srgba8::GREEN);
assert_ne!(other.slot(), slot);
drop(held);
paints.free_released();
let reused = paints.add(Srgba8::BLUE);
assert_eq!(reused.slot(), slot);
}
#[test]
fn cloned_pending_paints_register_once_and_then_become_direct_ids() {
let mut paints = Paints::new();
let mut first = Srgba8::rgb(17, 17, 27).into_value();
let mut second = first.clone();
let before = paints.entries.len();
let first_id = first.resolve(&mut paints).clone();
let second_id = second.resolve(&mut paints).clone();
assert_eq!(first_id, second_id);
assert_eq!(paints.entries.len(), before + 1);
assert!(first.is(&first_id));
assert!(second.is(&first_id));
}
#[test]
fn independently_constructed_inline_solids_get_independent_slots() {
let mut paints = Paints::new();
let mut first = Srgba8::rgb(23, 42, 71).into_value();
let mut second = Srgba8::rgb(23, 42, 71).into_value();
let before = paints.entries.len();
let first_id = first.resolve(&mut paints).clone();
let second_id = second.resolve(&mut paints).clone();
assert_ne!(first_id, second_id);
assert_eq!(paints.entries.len(), before + 2);
}
#[test]
fn explicit_theme_paints_with_equal_values_remain_independent() {
let mut paints = Paints::new();
let first = paints.add(Srgba8::rgb(23, 42, 71));
let second = paints.add(Srgba8::rgb(23, 42, 71));
assert_ne!(first.slot(), second.slot());
} }
} }
+29 -18
View File
@@ -1,4 +1,4 @@
use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2}; use crate::{Align, GlyphAtlas, GlyphKey, PaintId, PlacedGlyph, RegionAlign, Textures, util::Vec2};
use parley::{ use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight, Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
@@ -32,7 +32,7 @@ pub struct FontDiagnostics {
pub struct TextData { pub struct TextData {
pub font_cx: FontContext, pub font_cx: FontContext,
pub layout_cx: LayoutContext<UiColor>, pub layout_cx: LayoutContext<PaintId>,
scale_cx: ScaleContext, scale_cx: ScaleContext,
pub atlas: GlyphAtlas, pub atlas: GlyphAtlas,
/// Physical pixels per dp -- a second copy of /// Physical pixels per dp -- a second copy of
@@ -240,8 +240,13 @@ impl TextData {
} }
} }
pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec<PlacedGlyph> { pub fn place(
&mut self,
buffer: &TextBuffer,
textures: &mut Textures,
) -> (Vec<PlacedGlyph>, Vec<PaintId>) {
let mut placed = Vec::new(); let mut placed = Vec::new();
let mut paints = Vec::new();
for line in buffer.layout.lines() { for line in buffer.layout.lines() {
for item in line.items() { for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else { let PositionedLayoutItem::GlyphRun(run) = item else {
@@ -250,7 +255,10 @@ impl TextData {
let font = run.run().font(); let font = run.run().font();
let font_size = run.run().font_size(); let font_size = run.run().font_size();
let coords = run.run().normalized_coords(); let coords = run.run().normalized_coords();
let run_color = run.style().brush; let run_color = run.style().brush.clone();
if !paints.contains(&run_color) {
paints.push(run_color.clone());
}
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize) let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
else { else {
continue; continue;
@@ -301,12 +309,12 @@ impl TextData {
glyph.x.floor() + entry.left as f32, glyph.x.floor() + entry.left as f32,
glyph.y.floor() - entry.top as f32, glyph.y.floor() - entry.top as f32,
), ),
color: run_color, paint: run_color.slot(),
}); });
} }
} }
} }
placed (placed, paints)
} }
pub fn render( pub fn render(
@@ -318,11 +326,12 @@ impl TextData {
density: f32, density: f32,
) -> RenderedText { ) -> RenderedText {
buffer.shape(self, attrs, width, density); buffer.shape(self, attrs, width, density);
let glyphs = self.place(buffer, textures); let (glyphs, paints) = self.place(buffer, textures);
RenderedText { RenderedText {
glyphs: std::sync::Arc::new(glyphs), glyphs: std::sync::Arc::new(glyphs),
paints: std::sync::Arc::new(paints),
size: buffer.size(), size: buffer.size(),
color: attrs.color, color: attrs.color.clone(),
generation: self.atlas.generation(), generation: self.atlas.generation(),
} }
} }
@@ -359,7 +368,7 @@ impl Family {
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub struct SpanStyle { pub struct SpanStyle {
pub range: Range<usize>, pub range: Range<usize>,
pub color: Option<UiColor>, pub color: Option<PaintId>,
pub family: Option<Family>, pub family: Option<Family>,
pub font_size: Option<f32>, pub font_size: Option<f32>,
pub bold: bool, pub bold: bool,
@@ -379,7 +388,7 @@ impl SpanStyle {
underline: false, underline: false,
} }
} }
pub fn color(mut self, color: UiColor) -> Self { pub fn color(mut self, color: PaintId) -> Self {
self.color = Some(color); self.color = Some(color);
self self
} }
@@ -407,7 +416,7 @@ impl SpanStyle {
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub struct TextAttrs { pub struct TextAttrs {
pub color: UiColor, pub color: PaintId,
pub font_size: f32, pub font_size: f32,
pub line_height: f32, pub line_height: f32,
pub family: Family, pub family: Family,
@@ -421,7 +430,7 @@ impl Default for TextAttrs {
fn default() -> Self { fn default() -> Self {
let size = 16.0; let size = 16.0;
Self { Self {
color: UiColor::WHITE, color: PaintId::WHITE,
font_size: size, font_size: size,
line_height: size * LINE_HEIGHT_MULT, line_height: size * LINE_HEIGHT_MULT,
family: Family::SansSerif, family: Family::SansSerif,
@@ -436,7 +445,7 @@ impl Default for TextAttrs {
/// them apart is how they get out of step. /// them apart is how they get out of step.
pub struct TextBuffer { pub struct TextBuffer {
text: String, text: String,
layout: Layout<UiColor>, layout: Layout<PaintId>,
spans: Vec<SpanStyle>, spans: Vec<SpanStyle>,
shaped: Option<(TextAttrs, Option<f32>, f32)>, shaped: Option<(TextAttrs, Option<f32>, f32)>,
} }
@@ -464,7 +473,7 @@ impl TextBuffer {
&self.text &self.text
} }
pub fn layout(&self) -> &Layout<UiColor> { pub fn layout(&self) -> &Layout<PaintId> {
&self.layout &self.layout
} }
@@ -515,11 +524,11 @@ impl TextBuffer {
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute( builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height * density, attrs.line_height * density,
))); )));
builder.push_default(StyleProperty::Brush(attrs.color)); builder.push_default(StyleProperty::Brush(attrs.color.clone()));
for (span, family) in self.spans.iter().zip(&span_families) { for (span, family) in self.spans.iter().zip(&span_families) {
let range = span.range.clone(); let range = span.range.clone();
if let Some(color) = span.color { if let Some(color) = &span.color {
builder.push(StyleProperty::Brush(color), range.clone()); builder.push(StyleProperty::Brush(color.clone()), range.clone());
} }
if let Some(family) = family { if let Some(family) = family {
builder.push(StyleProperty::FontFamily(family.family()), range.clone()); builder.push(StyleProperty::FontFamily(family.family()), range.clone());
@@ -563,8 +572,10 @@ fn hash_coords(coords: &[i16]) -> u64 {
#[derive(Clone)] #[derive(Clone)]
pub struct RenderedText { pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>, pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
/// The unique handles whose compact slots the glyphs above carry.
pub paints: std::sync::Arc<Vec<PaintId>>,
pub size: Vec2, pub size: Vec2,
pub color: UiColor, pub color: PaintId,
/// The [`GlyphAtlas::generation`] the glyphs above were placed against. /// The [`GlyphAtlas::generation`] the glyphs above were placed against.
/// A holder must re-render rather than re-emit these quads once the /// A holder must re-render rather than re-emit these quads once the
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens /// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::{ use crate::{
PatchRect, TextureHandle, Textures, UiColor, PatchRect, TextureHandle, Textures,
util::{HashMap, Vec2}, util::{HashMap, Vec2},
}; };
use image::RgbaImage; use image::RgbaImage;
@@ -202,5 +202,5 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
pub struct PlacedGlyph { pub struct PlacedGlyph {
pub entry: GlyphEntry, pub entry: GlyphEntry,
pub offset: Vec2, pub offset: Vec2,
pub color: UiColor, pub paint: u32,
} }
+78 -5
View File
@@ -30,6 +30,46 @@ pub use sdf::{distance_from_rect, rounded_rect_coverage};
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl"); pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
/// The advertised swapchain format and the sRGB view Iris renders through.
/// A backend may advertise only the non-sRGB member of an RGBA/BGRA pair;
/// wgpu permits its sRGB counterpart as a configured view format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SurfaceFormat {
pub surface: TextureFormat,
pub view: TextureFormat,
}
pub fn srgb_surface_format(caps: &SurfaceCapabilities) -> Result<SurfaceFormat, String> {
let supports_srgb_space = |format| caps.color_spaces(format).contains(SurfaceColorSpaces::SRGB);
if let Some(surface) = caps
.formats
.iter()
.copied()
.find(|format| format.is_srgb() && supports_srgb_space(*format))
{
return Ok(SurfaceFormat {
surface,
view: surface,
});
}
if let Some(surface) = caps
.formats
.iter()
.copied()
.find(|format| format.add_srgb_suffix().is_srgb() && supports_srgb_space(*format))
{
return Ok(SurfaceFormat {
surface,
view: surface.add_srgb_suffix(),
});
}
Err(format!(
"the surface has no RGBA/BGRA format with an sRGB render view and sRGB output colour \
space; advertised default formats: {:?}",
caps.formats
))
}
pub fn device_limits() -> Limits { pub fn device_limits() -> Limits {
Limits { Limits {
max_buffer_size: 1 << 30, max_buffer_size: 1 << 30,
@@ -91,6 +131,7 @@ pub struct UiRenderNode {
instances: ArrBuf<PrimitiveInstance>, instances: ArrBuf<PrimitiveInstance>,
masks: ArrBuf<Mask>, masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>, move_offsets: ArrBuf<MoveOffset>,
paints: ArrBuf<crate::LinearRgba>,
masks_layout: BindGroupLayout, masks_layout: BindGroupLayout,
masks_group: BindGroup, masks_group: BindGroup,
} }
@@ -196,13 +237,16 @@ impl UiRenderNode {
let masks_resized = self.masks.update(device, queue, entries, dirty); let masks_resized = self.masks.update(device, queue, entries, dirty);
let (entries, dirty) = ui.move_offsets.for_upload(); let (entries, dirty) = ui.move_offsets.for_upload();
let moves_resized = self.move_offsets.update(device, queue, entries, dirty); let moves_resized = self.move_offsets.update(device, queue, entries, dirty);
if masks_resized || moves_resized || instances_resized { let (entries, dirty) = ui.paints.for_upload();
let paints_resized = self.paints.update(device, queue, entries, dirty);
if masks_resized || moves_resized || instances_resized || paints_resized {
self.masks_group = Self::masks_group( self.masks_group = Self::masks_group(
device, device,
&self.masks_layout, &self.masks_layout,
&self.masks, &self.masks,
&self.move_offsets, &self.move_offsets,
&self.instances, &self.instances,
&self.paints,
); );
} }
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout); let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
@@ -212,6 +256,7 @@ impl UiRenderNode {
FrameUpdateStats { FrameUpdateStats {
masks_resized, masks_resized,
moves_resized, moves_resized,
paints_resized,
} }
} }
@@ -231,7 +276,7 @@ impl UiRenderNode {
pub fn new( pub fn new(
device: &Device, device: &Device,
queue: &Queue, queue: &Queue,
config: &SurfaceConfiguration, target_format: TextureFormat,
window_size: impl Into<Vec2>, window_size: impl Into<Vec2>,
) -> Result<Self, String> { ) -> Result<Self, String> {
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory); let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
@@ -305,12 +350,23 @@ impl UiRenderNode {
BufferUsages::STORAGE | BufferUsages::COPY_DST, BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets", "ui move offsets",
); );
let paints = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui paints",
);
let rsc_layout = Self::rsc_layout(device); let rsc_layout = Self::rsc_layout(device);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager); let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
let masks_layout = Self::masks_layout(device); let masks_layout = Self::masks_layout(device);
let masks_group = let masks_group = Self::masks_group(
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances); device,
&masks_layout,
&masks,
&move_offsets,
&instances,
&paints,
);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"), label: Some("UI Shape Pipeline Layout"),
@@ -335,7 +391,7 @@ impl UiRenderNode {
module: &shader, module: &shader,
entry_point: Some("fs_main"), entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState { targets: &[Some(ColorTargetState {
format: config.format, format: target_format,
blend: Some(BlendState::ALPHA_BLENDING), blend: Some(BlendState::ALPHA_BLENDING),
write_mask: ColorWrites::ALL, write_mask: ColorWrites::ALL,
})], })],
@@ -386,6 +442,7 @@ impl UiRenderNode {
instances, instances,
masks, masks,
move_offsets, move_offsets,
paints,
masks_layout, masks_layout,
masks_group, masks_group,
}) })
@@ -522,6 +579,16 @@ impl UiRenderNode {
}, },
count: None, count: None,
}, },
BindGroupLayoutEntry {
binding: 3,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
], ],
label: Some("ui masks"), label: Some("ui masks"),
}) })
@@ -533,6 +600,7 @@ impl UiRenderNode {
masks: &ArrBuf<Mask>, masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>, move_offsets: &ArrBuf<MoveOffset>,
instances: &ArrBuf<PrimitiveInstance>, instances: &ArrBuf<PrimitiveInstance>,
paints: &ArrBuf<crate::LinearRgba>,
) -> BindGroup { ) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor { device.create_bind_group(&BindGroupDescriptor {
layout, layout,
@@ -549,6 +617,10 @@ impl UiRenderNode {
binding: 2, binding: 2,
resource: instances.buffer.as_entire_binding(), resource: instances.buffer.as_entire_binding(),
}, },
BindGroupEntry {
binding: 3,
resource: paints.buffer.as_entire_binding(),
},
], ],
label: Some("ui masks"), label: Some("ui masks"),
}) })
@@ -576,4 +648,5 @@ impl UiRenderNode {
pub struct FrameUpdateStats { pub struct FrameUpdateStats {
pub masks_resized: bool, pub masks_resized: bool,
pub moves_resized: bool, pub moves_resized: bool,
pub paints_resized: bool,
} }
+9 -13
View File
@@ -1,7 +1,7 @@
use std::ops::Deref; use std::ops::Deref;
use crate::{ use crate::{
Color, UiRegion, WidgetId, UiRegion, WidgetId,
render::{ render::{
ArrBuf, ArrBuf,
data::{MaskIdx, MoveIdx, PrimitiveInstance}, data::{MaskIdx, MoveIdx, PrimitiveInstance},
@@ -554,16 +554,18 @@ primitives!(
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct RectPrimitive { pub struct RectPrimitive {
pub color: Color<u8>, /// Index into the separate paint buffer. Geometry stays untouched when a
/// theme replaces the value at this index.
pub paint: u32,
pub radius: f32, pub radius: f32,
pub thickness: f32, pub thickness: f32,
pub inner_radius: f32, pub inner_radius: f32,
} }
impl RectPrimitive { impl RectPrimitive {
pub fn color(color: Color<u8>) -> Self { pub fn color(paint: u32) -> Self {
Self { Self {
color, paint,
radius: 0.0, radius: 0.0,
thickness: 0.0, thickness: 0.0,
inner_radius: 0.0, inner_radius: 0.0,
@@ -580,7 +582,7 @@ pub struct GlyphPrimitive {
/// not a bind-group or view index, since a page never gets one of its /// not a bind-group or view index, since a page never gets one of its
/// own. See TEXTURES.md's "Recommended shape". /// own. See TEXTURES.md's "Recommended shape".
pub layer: u32, pub layer: u32,
pub color: Color<u8>, pub paint: u32,
pub flags: u32, pub flags: u32,
_pad: u32, _pad: u32,
} }
@@ -588,18 +590,12 @@ pub struct GlyphPrimitive {
impl GlyphPrimitive { impl GlyphPrimitive {
pub const IS_COLOR: u32 = 1; pub const IS_COLOR: u32 = 1;
pub fn new( pub fn new(uv_min: [f32; 2], uv_max: [f32; 2], layer: u32, paint: u32, flags: u32) -> Self {
uv_min: [f32; 2],
uv_max: [f32; 2],
layer: u32,
color: Color<u8>,
flags: u32,
) -> Self {
Self { Self {
uv_min, uv_min,
uv_max, uv_max,
layer, layer,
color, paint,
flags, flags,
_pad: 0, _pad: 0,
} }
+9 -4
View File
@@ -11,7 +11,7 @@ var<storage> rects: array<Rect>;
var<storage> glyphs: array<GlyphInfo>; var<storage> glyphs: array<GlyphInfo>;
struct Rect { struct Rect {
color: u32, paint: u32,
radius: f32, radius: f32,
thickness: f32, thickness: f32,
inner_radius: f32, inner_radius: f32,
@@ -22,7 +22,7 @@ struct GlyphInfo {
uv_max: vec2<f32>, uv_max: vec2<f32>,
// A layer in the shared atlas array, not a bind-group index. // A layer in the shared atlas array, not a bind-group index.
layer: u32, layer: u32,
color: u32, paint: u32,
flags: u32, flags: u32,
} }
@@ -64,6 +64,11 @@ var<storage> move_offsets: array<MoveOffset>;
// Shared by the vertex stage's drawn primitive and the fragment stage's mask shape. // Shared by the vertex stage's drawn primitive and the fragment stage's mask shape.
@group(3) @binding(2) @group(3) @binding(2)
var<storage> instances: array<PrimitiveInstance>; var<storage> instances: array<PrimitiveInstance>;
// Solid linear RGBA today. Primitives already refer to paint records rather
// than embedding colours so gradients and texture fills can extend this
// lookup without rewriting geometry.
@group(3) @binding(3)
var<storage> paints: array<vec4<f32>>;
// Keep synchronized with render_state.rs. The bound prevents a malformed // Keep synchronized with render_state.rs. The bound prevents a malformed
// parent cycle from hanging the GPU; real widget trees have exceeded 16. // parent cycle from hanging the GPU; real widget trees have exceeded 16.
@@ -222,7 +227,7 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
if (g.flags & 1u) != 0u { if (g.flags & 1u) != 0u {
return texel; return texel;
} }
var color = unpack4x8unorm(g.color); var color = paints[g.paint];
color.a *= texel.a; color.a *= texel.a;
return color; return color;
} }
@@ -242,7 +247,7 @@ fn rounded_rect_coverage(
} }
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> { fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
var color = unpack4x8unorm(rect.color); var color = paints[rect.paint];
let edge = 0.5; let edge = 0.5;
+10 -3
View File
@@ -271,7 +271,11 @@ impl GpuTextures {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: TextureDimension::D2, dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm, // `image` and swash colour-glyph bytes are encoded sRGB.
// Sampling this view decodes RGB to the linear-light values
// used by the paint buffer and render pipeline; alpha stays
// linear.
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[], view_formats: &[],
}, },
@@ -337,7 +341,10 @@ impl GpuTextures {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: TextureDimension::D2, dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm, // One array contains both alpha-mask glyphs and colour glyphs.
// sRGB decoding leaves mask pages' white RGB and alpha unchanged
// while correctly decoding colour-glyph RGB.
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING usage: TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_DST | TextureUsages::COPY_DST
| TextureUsages::COPY_SRC, | TextureUsages::COPY_SRC,
@@ -430,7 +437,7 @@ pub fn null_texture_view(device: &Device) -> TextureView {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: TextureDimension::D2, dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm, format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING, usage: TextureUsages::TEXTURE_BINDING,
view_formats: &[], view_formats: &[],
}) })
+6 -1
View File
@@ -1,5 +1,6 @@
use crate::{ use crate::{
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2, LayerId, MaskIdx, MoveIdx, PaintId, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId,
util::Vec2,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -8,6 +9,10 @@ pub struct ActiveData {
pub region: UiRegion, pub region: UiRegion,
pub parent: Option<WidgetId>, pub parent: Option<WidgetId>,
pub textures: Vec<TextureHandle>, pub textures: Vec<TextureHandle>,
/// Paint slots retained by this draw. The GPU primitive stores only the
/// slot index, so these handles are what prevent a live primitive from
/// observing a recycled paint.
pub paints: Vec<PaintId>,
pub primitives: Vec<PrimitiveHandle>, pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>, pub children: Vec<WidgetId>,
pub size_dependencies: Vec<WidgetId>, pub size_dependencies: Vec<WidgetId>,
+3 -1
View File
@@ -1,5 +1,5 @@
use crate::{ use crate::{
Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena, Mask, MoveOffset, Paints, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
}; };
mod access; mod access;
@@ -15,6 +15,7 @@ pub use render_state::*;
#[derive(Default)] #[derive(Default)]
pub struct UiData { pub struct UiData {
pub widgets: Widgets, pub widgets: Widgets,
pub paints: Paints,
pub textures: Textures, pub textures: Textures,
pub text: TextData, pub text: TextData,
pub masks: TrackedArena<Mask, u32>, pub masks: TrackedArena<Mask, u32>,
@@ -70,5 +71,6 @@ pub trait UiRsc {
self.on_remove(id); self.on_remove(id);
} }
self.ui_mut().textures.free(); self.ui_mut().textures.free();
self.ui_mut().paints.free_released();
} }
} }
+22 -3
View File
@@ -1,5 +1,5 @@
use crate::{ use crate::{
Axis, Color, Len, MoveOffset, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, Axis, Len, MoveOffset, PaintId, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs,
TextBuffer, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, TextBuffer, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2,
WidgetId, WidgetId,
render::{ render::{
@@ -20,6 +20,7 @@ pub struct Painter<'a> {
pub(super) child_move_slot: Option<MoveIdx>, pub(super) child_move_slot: Option<MoveIdx>,
pub(super) own_mask: MaskIdx, pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>, pub(super) textures: Vec<TextureHandle>,
pub(super) paints: Vec<PaintId>,
pub(super) primitives: Vec<PrimitiveHandle>, pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>, pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
pub(super) children: Vec<WidgetId>, pub(super) children: Vec<WidgetId>,
@@ -118,6 +119,20 @@ impl<'a> Painter<'a> {
self.primitive_at(primitive, self.region) self.primitive_at(primitive, self.region)
} }
/// Resolves a public paint handle to the compact index stored by a GPU
/// primitive and retains the handle for exactly as long as that draw.
pub fn paint(&mut self, paint: &PaintId) -> u32 {
if !self.paints.contains(paint) {
self.paints.push(paint.clone());
}
paint.slot()
}
pub fn paint_value(&mut self, paint: &mut crate::PaintValue) -> u32 {
let paint = paint.resolve(&mut self.rsc.ui_mut().paints).clone();
self.paint(&paint)
}
pub fn primitive_within<P: Primitive>(&mut self, primitive: P, region: UiRegion) { pub fn primitive_within<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
self.primitive_at(primitive, region.within(&self.region)); self.primitive_at(primitive, region.within(&self.region));
} }
@@ -128,7 +143,8 @@ impl<'a> Painter<'a> {
/// so keeps pointing at whichever slot it was drawn under. See /// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost. /// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) { pub fn set_mask(&mut self, region: UiRegion) {
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No); let paint = self.paint(&PaintId::NONE);
let shape = self.write_primitive(RectPrimitive::color(paint), region, Drawn::No);
self.set_mask_to(shape); self.set_mask_to(shape);
} }
@@ -433,6 +449,9 @@ impl<'a> Painter<'a> {
0 0
} }
}; };
for paint in text.paints.iter() {
self.paint(paint);
}
for glyph in text.glyphs.iter() { for glyph in text.glyphs.iter() {
let mut region = origin; let mut region = origin;
region.x.end = region.x.start; region.x.end = region.x.start;
@@ -445,7 +464,7 @@ impl<'a> Painter<'a> {
glyph.entry.uv_min, glyph.entry.uv_min,
glyph.entry.uv_max, glyph.entry.uv_max,
glyph.entry.layer, glyph.entry.layer,
glyph.color, glyph.paint,
flags_for(glyph.entry.is_color), flags_for(glyph.entry.is_color),
), ),
region, region,
+44 -2
View File
@@ -2,8 +2,8 @@ use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::{ use crate::{
ActiveData, Axis, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign, ActiveData, Axis, ChildOrder, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers,
Size, StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets, RegionAlign, Size, StrongWidget, UiRegion, UiRsc, UiVec2, Widget, WidgetId, Widgets,
render::{ render::{
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives, Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
RectPrimitive, rounded_rect_coverage, RectPrimitive, rounded_rect_coverage,
@@ -73,6 +73,7 @@ pub(crate) struct Retained {
pub child_move_slot: Option<MoveIdx>, pub child_move_slot: Option<MoveIdx>,
pub own_mask: MaskIdx, pub own_mask: MaskIdx,
pub primitives: Vec<PrimitiveHandle>, pub primitives: Vec<PrimitiveHandle>,
pub paints: Vec<crate::PaintId>,
} }
impl Default for Retained { impl Default for Retained {
@@ -84,6 +85,7 @@ impl Default for Retained {
child_move_slot: None, child_move_slot: None,
own_mask: MaskIdx::NONE, own_mask: MaskIdx::NONE,
primitives: Vec::new(), primitives: Vec::new(),
paints: Vec::new(),
} }
} }
} }
@@ -350,6 +352,7 @@ impl UiRenderState {
mut child_move_slot, mut child_move_slot,
mut own_mask, mut own_mask,
primitives: mut recycle, primitives: mut recycle,
paints: _old_paints,
} = retained; } = retained;
let dirty = rsc.widgets_mut().needs_redraw.remove(&id); let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
let requires_exact_region = rsc let requires_exact_region = rsc
@@ -447,6 +450,7 @@ impl UiRenderState {
layer, layer,
id, id,
textures: Vec::new(), textures: Vec::new(),
paints: Vec::new(),
primitives: Vec::new(), primitives: Vec::new(),
recycle: recycle.into_iter().peekable(), recycle: recycle.into_iter().peekable(),
children: Vec::new(), children: Vec::new(),
@@ -498,6 +502,7 @@ impl UiRenderState {
child_move_slot, child_move_slot,
own_mask, own_mask,
textures, textures,
paints,
primitives, primitives,
recycle, recycle,
children, children,
@@ -517,6 +522,7 @@ impl UiRenderState {
region, region,
parent, parent,
textures, textures,
paints,
primitives, primitives,
children, children,
size_dependencies, size_dependencies,
@@ -1046,6 +1052,41 @@ impl UiRenderState {
Some(region.to_px(self.output_size)) Some(region.to_px(self.output_size))
} }
/// This widget's immediate rendered children in the order non-visual
/// consumers should traverse them. The ordinary case costs no sort and
/// exactly preserves draw order; axis-aware layout widgets ask for their
/// resolved screen positions only when somebody actually queries them.
pub fn ordered_children(&self, id: WidgetId, rsc: &dyn UiRsc) -> Vec<WidgetId> {
let Some(active) = self.active.get(&id) else {
return Vec::new();
};
let mut children = active.children.clone();
// A layout may draw a child to learn its size and then place that
// retained drawing. Both operations are recorded for redraw lifetime,
// but a semantic traversal visits the child once.
let mut seen = HashSet::default();
children.retain(|child| seen.insert(*child));
let order = rsc
.widgets()
.get_dyn(id)
.map(Widget::child_order)
.unwrap_or_default();
if let ChildOrder::Axis(axis) = order {
children.sort_by(|a, b| {
let at = self
.window_region(a, rsc)
.map(|r| r.top_left.axis(axis))
.unwrap_or(f32::INFINITY);
let bt = self
.window_region(b, rsc)
.map(|r| r.top_left.axis(axis))
.unwrap_or(f32::INFINITY);
at.total_cmp(&bt)
});
}
children
}
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
rsc.widgets_mut().needs_redraw.remove(&id); rsc.widgets_mut().needs_redraw.remove(&id);
if self.draw_started.contains(&id) { if self.draw_started.contains(&id) {
@@ -1075,6 +1116,7 @@ impl UiRenderState {
child_move_slot: active.child_move_slot, child_move_slot: active.child_move_slot,
own_mask: active.own_mask, own_mask: active.own_mask,
primitives: active.primitives, primitives: active.primitives,
paints: active.paints,
}, },
rsc, rsc,
); );
+14
View File
@@ -15,6 +15,16 @@ pub use tag::*;
pub use view::*; pub use view::*;
pub use widgets::*; pub use widgets::*;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ChildOrder {
/// The order in which the parent drew its children.
#[default]
Draw,
/// Ascending visual position on one screen axis. Equal positions keep
/// draw order; the resolved coordinates are sorted only when queried.
Axis(Axis),
}
pub trait Widget: Any { pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter); fn draw(&mut self, painter: &mut Painter);
@@ -34,6 +44,10 @@ pub trait Widget: Any {
accesskit::Role::Unknown accesskit::Role::Unknown
} }
fn child_order(&self) -> ChildOrder {
ChildOrder::Draw
}
#[allow(unused_variables)] #[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool { fn tick(&mut self, now: std::time::Instant) -> bool {
false false
+4 -4
View File
@@ -27,11 +27,11 @@ fn row_image(i: usize) -> image::DynamicImage {
fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget { fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
let tint = if i.is_multiple_of(2) { let tint = if i.is_multiple_of(2) {
Color::rgb(120, 130, 170) Srgba8::rgb(120, 130, 170)
} else { } else {
Color::rgb(70, 80, 140) Srgba8::rgb(70, 80, 140)
}; };
let text_color = Color::BLACK; let text_color = PaintId::BLACK;
if i.is_multiple_of(IMAGE_EVERY) { if i.is_multiple_of(IMAGE_EVERY) {
let text = wtext(row_text(i)) let text = wtext(row_text(i))
.wrap(true) .wrap(true)
@@ -77,7 +77,7 @@ impl DefaultAppState for State {
let root = list let root = list
.scrollable() .scrollable()
.masked() .masked()
.background(rect(Color::WHITE)) .background(rect(PaintId::WHITE))
.add_strong(rsc); .add_strong(rsc);
ui_state.set_root(root.any()); ui_state.set_root(root.any());
+1 -1
View File
@@ -15,7 +15,7 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>, rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>, _: Proxy<Self::Event>,
) -> Self { ) -> Self {
rect(Color::RED).set_root(rsc, &mut ui_state); rect(PaintId::RED).set_root(rsc, &mut ui_state);
Self { ui_state } Self { ui_state }
} }
} }
+4 -4
View File
@@ -16,15 +16,15 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>, rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>, _: Proxy<Self::Event>,
) -> Self { ) -> Self {
let rect = rect(Color::RED).add(rsc); let rect = rect(PaintId::RED).add(rsc);
rect.task_on(CursorSense::click(), async move |mut ctx| { rect.task_on(CursorSense::click(), async move |mut ctx| {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
ctx.update(move |_, rsc| { ctx.update(move |_, rsc| {
let rect = rect(rsc); let rect = rect(rsc);
if rect.color == Color::RED { if rect.is_paint(&PaintId::RED) {
rect.color = Color::BLUE; rect.set_paint(PaintId::BLUE);
} else { } else {
rect.color = Color::RED; rect.set_paint(PaintId::RED);
} }
}); });
}) })
+3 -3
View File
@@ -20,7 +20,7 @@ struct Test {
impl Test { impl Test {
pub fn new(rsc: &mut Rsc) -> Self { pub fn new(rsc: &mut Rsc) -> Self {
let root = rect(Color::RED).add(rsc); let root = rect(PaintId::RED).add(rsc);
let cur = rsc.create_state(root, false); let cur = rsc.create_state(root, false);
Self { root, cur } Self { root, cur }
} }
@@ -28,9 +28,9 @@ impl Test {
let cur = &mut rsc[self.cur]; let cur = &mut rsc[self.cur];
*cur = !*cur; *cur = !*cur;
if *cur { if *cur {
rsc[self.root].color = Color::BLUE; rsc[self.root].set_paint(PaintId::BLUE);
} else { } else {
rsc[self.root].color = Color::RED; rsc[self.root].set_paint(PaintId::RED);
} }
} }
} }
+3 -3
View File
@@ -6,7 +6,7 @@ fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("Add task").add(&mut rsc); let leaf: WeakWidget<Rect> = rect(PaintId::WHITE).label("Add task").add(&mut rsc);
let root = leaf.upgrade(&mut rsc).any(); let root = leaf.upgrade(&mut rsc).any();
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
render.resize((800.0, 600.0)); render.resize((800.0, 600.0));
@@ -40,7 +40,7 @@ fn a_widget_with_no_label_never_reaches_the_tree() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let root = rsc.ui.widgets.add_strong(rect(UiColor::WHITE)); let root = rsc.ui.widgets.add_strong(rect(PaintId::WHITE));
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
render.resize((800.0, 600.0)); render.resize((800.0, 600.0));
render.update(&root.any(), &mut rsc); render.update(&root.any(), &mut rsc);
@@ -58,7 +58,7 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("thing").add(&mut rsc); let leaf: WeakWidget<Rect> = rect(PaintId::WHITE).label("thing").add(&mut rsc);
let leaf_strong = leaf.upgrade(&mut rsc).any(); let leaf_strong = leaf.upgrade(&mut rsc).any();
let offset = rsc.ui.widgets.add_strong(Offset { let offset = rsc.ui.widgets.add_strong(Offset {
inner: leaf_strong, inner: leaf_strong,
+29 -17
View File
@@ -4,7 +4,7 @@ use android_view::{
jni::{JavaVM, objects::GlobalRef}, jni::{JavaVM, objects::GlobalRef},
ndk::native_window::NativeWindow, ndk::native_window::NativeWindow,
}; };
use iris_core::{FrameParts, UiData, UiRenderNode, UiRenderState}; use iris_core::{FrameParts, LinearRgba, UiData, UiRenderNode, UiRenderState};
use pollster::FutureExt; use pollster::FutureExt;
use std::time::Instant; use std::time::Instant;
use wgpu::{ use wgpu::{
@@ -12,7 +12,7 @@ use wgpu::{
*, *,
}; };
pub const CLEAR_COLOR: Color = Color::BLACK; pub const CLEAR_COLOR: LinearRgba = LinearRgba::BLACK;
/// `NativeWindow` (from the surface android-view hands over in /// `NativeWindow` (from the surface android-view hands over in
/// `surfaceChanged`) has a window handle but not a display one -- there is /// `surfaceChanged`) has a window handle but not a display one -- there is
@@ -46,6 +46,7 @@ pub struct AndroidRenderer {
device: Device, device: Device,
queue: Queue, queue: Queue,
config: SurfaceConfiguration, config: SurfaceConfiguration,
view_format: TextureFormat,
encoder: CommandEncoder, encoder: CommandEncoder,
pub ui: UiRenderNode, pub ui: UiRenderNode,
pub adapter_name: String, pub adapter_name: String,
@@ -73,6 +74,7 @@ pub struct AndroidRenderer {
pub struct FrameDiagnostics { pub struct FrameDiagnostics {
pub masks_resized: bool, pub masks_resized: bool,
pub moves_resized: bool, pub moves_resized: bool,
pub paints_resized: bool,
pub atlas_pages_grown_prev: u64, pub atlas_pages_grown_prev: u64,
pub image_bind_group_creates_prev: u64, pub image_bind_group_creates_prev: u64,
} }
@@ -189,30 +191,33 @@ impl AndroidRenderer {
); );
let surface_caps = surface.get_capabilities(&adapter); let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps let formats = iris_core::srgb_surface_format(&surface_caps)?;
.formats log::info!(
.iter() "iris renderer: surface={:?}, view={:?}, color_space=Srgb",
.copied() formats.surface,
.find(|f| f.is_srgb()) formats.view,
.unwrap_or(surface_caps.formats[0]); );
let config = SurfaceConfiguration { let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT, usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format, format: formats.surface,
// wgpu 30's new field; `Auto` is what every earlier version did. // wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto, color_space: SurfaceColorSpace::Srgb,
width, width,
height, height,
present_mode: PresentMode::AutoVsync, present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0], alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2, desired_maximum_frame_latency: 2,
view_formats: vec![], view_formats: (formats.view != formats.surface)
.then_some(formats.view)
.into_iter()
.collect(),
}; };
surface.configure(&device, &config); surface.configure(&device, &config);
let encoder = Self::create_encoder(&device); let encoder = Self::create_encoder(&device);
let window_size = iris_core::util::Vec2::new(width as f32, height as f32); let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) { let ui = match UiRenderNode::new(&device, &queue, formats.view, window_size) {
Ok(ui) => ui, Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)), Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
}; };
@@ -222,6 +227,7 @@ impl AndroidRenderer {
device, device,
queue, queue,
config, config,
view_format: formats.view,
encoder, encoder,
ui, ui,
adapter_name, adapter_name,
@@ -289,8 +295,10 @@ impl AndroidRenderer {
format!( format!(
"iris diagnostics. Copy this text and send it to Iris.\n\n\ "iris diagnostics. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver}\n\ adapter: {name} ({backend:?}), driver: {driver}\n\
surface: {surface:?}, view: {view:?}, color_space: Srgb\n\
content_scale: {content_scale}\n\ content_scale: {content_scale}\n\
atlas format: Rgba8Unorm, views live: {views}\n\ paint format: linear vec4<f32>\n\
atlas/image format: Rgba8UnormSrgb, views live: {views}\n\
fonts: {families_found} families found, default={default_family:?} \ fonts: {families_found} families found, default={default_family:?} \
mono={default_mono_family:?}\n\ mono={default_mono_family:?}\n\
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \ fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
@@ -301,6 +309,8 @@ impl AndroidRenderer {
name = self.adapter_name, name = self.adapter_name,
backend = self.adapter_backend, backend = self.adapter_backend,
driver = self.adapter_driver, driver = self.adapter_driver,
surface = self.config.format,
view = self.view_format,
content_scale = self.content_scale, content_scale = self.content_scale,
views = self.ui.view_count(), views = self.ui.view_count(),
families_found = font.families_found, families_found = font.families_found,
@@ -328,6 +338,7 @@ impl AndroidRenderer {
FrameDiagnostics { FrameDiagnostics {
masks_resized: stats.masks_resized, masks_resized: stats.masks_resized,
moves_resized: stats.moves_resized, moves_resized: stats.moves_resized,
paints_resized: stats.paints_resized,
atlas_pages_grown_prev, atlas_pages_grown_prev,
image_bind_group_creates_prev, image_bind_group_creates_prev,
} }
@@ -348,9 +359,10 @@ impl AndroidRenderer {
other => panic!("no surface texture to draw into: {other:?}"), other => panic!("no surface texture to draw into: {other:?}"),
}; };
let acquire = acquire_start.elapsed(); let acquire = acquire_start.elapsed();
let view = output let view = output.texture.create_view(&TextureViewDescriptor {
.texture format: Some(self.view_format),
.create_view(&TextureViewDescriptor::default()); ..Default::default()
});
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device)); let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{ {
@@ -359,7 +371,7 @@ impl AndroidRenderer {
view: &view, view: &view,
resolve_target: None, resolve_target: None,
ops: Operations { ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR), load: LoadOp::Clear(CLEAR_COLOR.to_wgpu()),
store: StoreOp::Store, store: StoreOp::Store,
}, },
depth_slice: None, depth_slice: None,
+3
View File
@@ -383,10 +383,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
if renderer.frame_count() <= DIAGNOSTIC_FRAMES { if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
log::info!( log::info!(
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \ "iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
paints_resized={} \
atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}", atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}",
renderer.frame_count(), renderer.frame_count(),
frame_diagnostics.masks_resized, frame_diagnostics.masks_resized,
frame_diagnostics.moves_resized, frame_diagnostics.moves_resized,
frame_diagnostics.paints_resized,
frame_diagnostics.atlas_pages_grown_prev, frame_diagnostics.atlas_pages_grown_prev,
frame_diagnostics.image_bind_group_creates_prev, frame_diagnostics.image_bind_group_creates_prev,
renderer.wgpu_errors.snapshot().len(), renderer.wgpu_errors.snapshot().len(),
@@ -698,6 +700,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
self.rsc.ui.text.atlas.page_count(), self.rsc.ui.text.atlas.page_count(),
); );
self.rsc.ui.textures.reupload(); self.rsc.ui.textures.reupload();
self.rsc.ui.paints.reupload();
self.state.android_state_mut().renderer = Some(renderer); self.state.android_state_mut().renderer = Some(renderer);
self.render(ctx, Instant::now()); self.render(ctx, Instant::now());
} }
+7 -1
View File
@@ -99,7 +99,7 @@ where
/// whatever is behind the field (a list to pan) still sees every frame of /// whatever is behind the field (a list to pan) still sees every frame of
/// it, the same as a drag that never touched a selectable field at all. /// it, the same as a drag that never touched a selectable field at all.
fn on_press( fn on_press(
rsc: &mut impl UiRsc, rsc: &mut impl HasEvents,
render: &UiRenderState, render: &UiRenderState,
state: &mut impl FocusHost, state: &mut impl FocusHost,
id: WeakWidget<TextEdit>, id: WeakWidget<TextEdit>,
@@ -107,6 +107,12 @@ fn on_press(
size: Vec2, size: Vec2,
sense: CursorSense, sense: CursorSense,
) { ) {
if sense == CursorSense::PressStart(CursorButton::Left) {
// An editable field becomes the command destination of the new
// interaction. Dismiss a retained display-text selection first so
// Copy cannot keep going to text the user has visibly left behind.
rsc.run_command(Command::Escape);
}
if state.is_focused(id) { if state.is_focused(id) {
// Already focused, so there is no keyboard to withhold -- but a // Already focused, so there is no keyboard to withhold -- but a
// vertical drag still is not a selection. Android's own `EditText` // vertical drag still is not a selection. Android's own `EditText`
+28 -1
View File
@@ -344,7 +344,34 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state.window.request_redraw(); ui_state.window.request_redraw();
} }
WindowEvent::KeyboardInput { event, .. } => { WindowEvent::KeyboardInput { event, .. } => {
if let Some(sel) = ui_state.focus let requested = event.state.is_pressed().then(|| match &event.logical_key {
winit::keyboard::Key::Character(c) if ui_state.input.modifiers.control => {
match c.as_str().to_ascii_lowercase().as_str() {
"c" => Some(Command::Copy),
"a" => Some(Command::SelectAll),
_ => None,
}
}
winit::keyboard::Key::Named(winit::keyboard::NamedKey::Escape) => {
Some(Command::Escape)
}
_ => None,
});
let command = requested
.flatten()
.map_or(CommandResult::Unused, |command| rsc.run_command(command));
let command_used = match command {
CommandResult::Copy(text) => {
if let Err(err) = ui_state.clipboard.set_text(text) {
eprintln!("failed to copy text to clipboard: {err}")
}
true
}
CommandResult::Used => true,
CommandResult::Unused => false,
};
if !command_used
&& let Some(sel) = ui_state.focus
&& event.state.is_pressed() && event.state.is_pressed()
{ {
let mut text = sel.edit(rsc); let mut text = sel.edit(rsc);
+23 -16
View File
@@ -1,12 +1,12 @@
use crate::task::RequestRedraw; use crate::task::RequestRedraw;
use iris_core::{FrameParts, UiData, UiRenderNode, UiRenderState, util::Vec2}; use iris_core::{FrameParts, LinearRgba, UiData, UiRenderNode, UiRenderState, util::Vec2};
use pollster::FutureExt; use pollster::FutureExt;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use wgpu::*; use wgpu::*;
use winit::{dpi::PhysicalSize, window::Window}; use winit::{dpi::PhysicalSize, window::Window};
pub const CLEAR_COLOR: Color = Color::BLACK; pub const CLEAR_COLOR: LinearRgba = LinearRgba::BLACK;
impl RequestRedraw for Window { impl RequestRedraw for Window {
fn request_redraw(&self) { fn request_redraw(&self) {
@@ -20,6 +20,7 @@ pub struct UiRenderer {
device: Device, device: Device,
queue: Queue, queue: Queue,
config: SurfaceConfiguration, config: SurfaceConfiguration,
view_format: TextureFormat,
encoder: CommandEncoder, encoder: CommandEncoder,
pub ui: UiRenderNode, pub ui: UiRenderNode,
} }
@@ -45,9 +46,10 @@ impl UiRenderer {
other => panic!("no surface texture to draw into: {other:?}"), other => panic!("no surface texture to draw into: {other:?}"),
}; };
let acquire = acquire_start.elapsed(); let acquire = acquire_start.elapsed();
let view = output let view = output.texture.create_view(&TextureViewDescriptor {
.texture format: Some(self.view_format),
.create_view(&TextureViewDescriptor::default()); ..Default::default()
});
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device)); let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{ {
@@ -56,7 +58,7 @@ impl UiRenderer {
view: &view, view: &view,
resolve_target: None, resolve_target: None,
ops: Operations { ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR), load: LoadOp::Clear(CLEAR_COLOR.to_wgpu()),
store: StoreOp::Store, store: StoreOp::Store,
}, },
depth_slice: None, depth_slice: None,
@@ -170,18 +172,19 @@ impl UiRenderer {
.expect("Could not get device!"); .expect("Could not get device!");
let surface_caps = surface.get_capabilities(&adapter); let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps let formats = iris_core::srgb_surface_format(&surface_caps)
.formats .expect("Could not select an sRGB iris surface format");
.iter() log::info!(
.copied() "iris renderer: surface={:?}, view={:?}, color_space=Srgb",
.find(|f| f.is_srgb()) formats.surface,
.unwrap_or(surface_caps.formats[0]); formats.view,
);
let config = SurfaceConfiguration { let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT, usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format, format: formats.surface,
// wgpu 30's new field; `Auto` is what every earlier version did. // wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto, color_space: SurfaceColorSpace::Srgb,
width: size.width, width: size.width,
height: size.height, height: size.height,
// Vsync, because a toolkit aiming at battery life must not present // Vsync, because a toolkit aiming at battery life must not present
@@ -192,7 +195,10 @@ impl UiRenderer {
present_mode: PresentMode::AutoVsync, present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0], alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2, desired_maximum_frame_latency: 2,
view_formats: vec![], view_formats: (formats.view != formats.surface)
.then_some(formats.view)
.into_iter()
.collect(),
}; };
surface.configure(&device, &config); surface.configure(&device, &config);
@@ -210,7 +216,7 @@ impl UiRenderer {
// `default::content_scale` for why this backend stopped dividing // `default::content_scale` for why this backend stopped dividing
// into a separate logical space, and what disagreed while it did. // into a separate logical space, and what disagreed while it did.
let physical_size = Vec2::new(size.width as f32, size.height as f32); let physical_size = Vec2::new(size.width as f32, size.height as f32);
let ui = UiRenderNode::new(&device, &queue, &config, physical_size) let ui = UiRenderNode::new(&device, &queue, formats.view, physical_size)
.expect("Could not create iris render node!"); .expect("Could not create iris render node!");
Self { Self {
@@ -218,6 +224,7 @@ impl UiRenderer {
device, device,
queue, queue,
config, config,
view_format: formats.view,
encoder, encoder,
ui, ui,
window, window,
+11
View File
@@ -36,6 +36,17 @@ pub trait Eventable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
} }
impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {} impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {}
pub trait Controllable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
fn controller<C: Controller<Rsc>>(self, controller: C) -> impl WidgetIdFn<Rsc, Self::Widget> {
move |rsc| {
let id = self.add(rsc);
rsc.register_controller(id, controller);
id
}
}
}
impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Controllable<Rsc, Tag> for WL {}
widget_trait! { widget_trait! {
pub trait TaskEventable<Rsc: HasEvents + HasTasks>; pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
fn task_on<E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>( fn task_on<E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
+28 -27
View File
@@ -19,8 +19,9 @@ struct FixedRect(f32);
impl Widget for FixedRect { impl Widget for FixedRect {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) {
let size = Size::from_axis(Axis::Y, Len::abs(self.0), Len::REST); let size = Size::from_axis(Axis::Y, Len::abs(self.0), Len::REST);
let paint = painter.paint(&PaintId::WHITE);
painter.primitive_within( painter.primitive_within(
RectPrimitive::color(UiColor::WHITE), RectPrimitive::color(paint),
size.to_uivec2(painter.density()) size.to_uivec2(painter.density())
.align(RegionAlign::TOP_LEFT), .align(RegionAlign::TOP_LEFT),
); );
@@ -89,8 +90,8 @@ fn a_widget_retains_its_entry_layer_not_its_child_cursor() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let back = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let back = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let front = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)); let front = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED));
let stack = rsc.ui.widgets.add_strong(Stack { let stack = rsc.ui.widgets.add_strong(Stack {
children: vec![back.any(), front.any()], children: vec![back.any(), front.any()],
size: StackSize::Default, size: StackSize::Default,
@@ -277,7 +278,7 @@ fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
ui: UiData::default(), ui: UiData::default(),
}; };
let first = rsc.ui.widgets.add_strong(FixedRect(40.0)); let first = rsc.ui.widgets.add_strong(FixedRect(40.0));
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let fill = rsc.ui.widgets.add_strong(Sized { let fill = rsc.ui.widgets.add_strong(Sized {
inner: fill.any(), inner: fill.any(),
x: None, x: None,
@@ -309,7 +310,7 @@ fn scrolled_rects(
let mut span = Span::empty(Dir::DOWN); let mut span = Span::empty(Dir::DOWN);
let mut rects = Vec::with_capacity(n); let mut rects = Vec::with_capacity(n);
for _ in 0..n { for _ in 0..n {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
rects.push(rect.weak()); rects.push(rect.weak());
let row = rsc.ui.widgets.add_strong(Sized { let row = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(), inner: rect.any(),
@@ -459,13 +460,13 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) .wrap(true)
.size(18) .size(18)
.color(UiColor::WHITE) .color(PaintId::WHITE)
.add(rsc); .add(rsc);
let bar = (field.pad(dp(12)).width(rest(1)),) let bar = (field.pad(dp(12)).width(rest(1)),)
.span(Dir::RIGHT) .span(Dir::RIGHT)
.background(rect(UiColor::new(40, 40, 46, 255))) .background(rect(Srgba8::new(40, 40, 46, 255)))
.add(rsc); .add(rsc);
let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc); let list_stand_in = rect(PaintId::BLACK).height(rest(1)).add(rsc);
let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any(); let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any();
(field, tree) (field, tree)
} }
@@ -517,7 +518,7 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized { let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(), inner: rect.any(),
x: None, x: None,
@@ -567,7 +568,7 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized { let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(), inner: rect.any(),
x: None, x: None,
@@ -609,7 +610,7 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
inner: inner_root, inner: inner_root,
}); });
let masked_id = masked.id(); let masked_id = masked.id();
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK)); let filler = rsc.ui.widgets.add_strong(Rect::new(PaintId::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized { let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(), inner: filler.any(),
x: None, x: None,
@@ -650,7 +651,7 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized { let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(), inner: rect.any(),
x: None, x: None,
@@ -662,7 +663,7 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
y: Some(Len::dp(100.0)), y: Some(Len::dp(100.0)),
}); });
let capped_w = capped.weak(); let capped_w = capped.weak();
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK)); let filler = rsc.ui.widgets.add_strong(Rect::new(PaintId::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized { let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(), inner: filler.any(),
x: None, x: None,
@@ -692,14 +693,14 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let top = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let spacer = rsc.ui.widgets.add_strong(Sized { let spacer = rsc.ui.widgets.add_strong(Sized {
inner: top.any(), inner: top.any(),
x: None, x: None,
y: Some(Len::abs(100.0)), y: Some(Len::abs(100.0)),
}); });
let spacer_w = spacer.weak(); let spacer_w = spacer.weak();
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let below = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let below_w = below.weak(); let below_w = below.weak();
let mut span = Span::empty(Dir::DOWN); let mut span = Span::empty(Dir::DOWN);
span.push(spacer.any()); span.push(spacer.any());
@@ -759,7 +760,7 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let child = rsc.ui.widgets.add_strong(Sized { let child = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(), inner: rect.any(),
x: None, x: None,
@@ -800,12 +801,12 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
const RADIUS: f32 = 20.0; const RADIUS: f32 = 20.0;
fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) { fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) {
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let child = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let child_id = child.id(); let child_id = child.id();
let shape = rsc let shape = rsc
.ui .ui
.widgets .widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS))); .add_strong(Rect::new(PaintId::BLACK).radius(Len::abs(RADIUS)));
let shape_id = shape.id(); let shape_id = shape.id();
let root = rsc let root = rsc
.ui .ui
@@ -909,13 +910,13 @@ fn nested_masks_multiply_their_coverage() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let child = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let child_id = child.id(); let child_id = child.id();
let inner_shape = rsc let inner_shape = rsc
.ui .ui
.widgets .widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS))); .add_strong(Rect::new(PaintId::BLACK).radius(Len::abs(RADIUS)));
let inner_shape_id = inner_shape.id(); let inner_shape_id = inner_shape.id();
let inner = rsc.ui.widgets.add_strong(Masked { let inner = rsc.ui.widgets.add_strong(Masked {
shape: Some(inner_shape.any()), shape: Some(inner_shape.any()),
@@ -924,7 +925,7 @@ fn nested_masks_multiply_their_coverage() {
let outer_shape = rsc let outer_shape = rsc
.ui .ui
.widgets .widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS))); .add_strong(Rect::new(PaintId::BLACK).radius(Len::abs(RADIUS)));
let outer_shape_id = outer_shape.id(); let outer_shape_id = outer_shape.id();
let root = rsc let root = rsc
.ui .ui
@@ -1004,7 +1005,7 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any();
let tall = rsc.ui.widgets.add_strong(Sized { let tall = rsc.ui.widgets.add_strong(Sized {
inner: fill, inner: fill,
x: None, x: None,
@@ -1039,7 +1040,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let header_fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)).any(); let header_fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED)).any();
let header_id = header_fill.id(); let header_id = header_fill.id();
let header = rsc.ui.widgets.add_strong(Sized { let header = rsc.ui.widgets.add_strong(Sized {
inner: header_fill, inner: header_fill,
@@ -1049,7 +1050,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
let mut inner = Span::empty(Dir::DOWN); let mut inner = Span::empty(Dir::DOWN);
let mut rects = Vec::new(); let mut rects = Vec::new();
for _ in 0..3 { for _ in 0..3 {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
rects.push(rect.weak()); rects.push(rect.weak());
let sized = rsc.ui.widgets.add_strong(Sized { let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(), inner: rect.any(),
@@ -1061,7 +1062,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
inner: sized.any(), inner: sized.any(),
exact_region: false, exact_region: false,
}); });
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any(); let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::BLUE)).any();
let card = rsc.ui.widgets.add_strong(Stack { let card = rsc.ui.widgets.add_strong(Stack {
children: vec![fill, padded.any()], children: vec![fill, padded.any()],
size: StackSize::Child(1), size: StackSize::Child(1),
@@ -1122,7 +1123,7 @@ fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let first = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let first = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let first_id = first.id(); let first_id = first.id();
let first = rsc.ui.widgets.add_strong(Sized { let first = rsc.ui.widgets.add_strong(Sized {
inner: first.any(), inner: first.any(),
@@ -1146,7 +1147,7 @@ fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() {
render.resize((200.0, 200.0)); render.resize((200.0, 200.0));
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let second = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let second = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let second_id = second.id(); let second_id = second.id();
let second = rsc.ui.widgets.add_strong(Sized { let second = rsc.ui.widgets.add_strong(Sized {
inner: second.any(), inner: second.any(),
+1 -1
View File
@@ -1986,7 +1986,7 @@ mod drag_gesture_tests {
} }
fn some_id(ui: &mut UiData) -> WidgetId { fn some_id(ui: &mut UiData) -> WidgetId {
ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id() ui.widgets.add_strong(Rect::new(PaintId::WHITE)).id()
} }
#[test] #[test]
+10 -10
View File
@@ -55,9 +55,9 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
}; };
// the case in IRIS_TODO.md's report. // the case in IRIS_TODO.md's report.
let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let list = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let list_weak = list.weak(); let list_weak = list.weak();
let button = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)); let button = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED));
let button_weak = button.weak(); let button_weak = button.weak();
let scrolled = Rc::new(Cell::new(false)); let scrolled = Rc::new(Cell::new(false));
@@ -125,7 +125,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
events: EventManager::default(), events: EventManager::default(),
}; };
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); let draggable = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any();
let draggable_weak = draggable.weak(); let draggable_weak = draggable.weak();
let dropped = Rc::new(Cell::new(false)); let dropped = Rc::new(Cell::new(false));
@@ -184,9 +184,9 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
events: EventManager::default(), events: EventManager::default(),
}; };
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let a = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let a_weak = a.weak(); let a_weak = a.weak();
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)); let b = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED));
let b_weak = b.weak(); let b_weak = b.weak();
let b_hovered = Rc::new(Cell::new(false)); let b_hovered = Rc::new(Cell::new(false));
@@ -229,7 +229,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
events: EventManager::default(), events: EventManager::default(),
}; };
let scroll_strong = rect(UiColor::WHITE) let scroll_strong = rect(PaintId::WHITE)
.height(Len::abs(1000.0)) .height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start) .scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc); .add_strong(&mut rsc);
@@ -319,7 +319,7 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
ui: UiData::default(), ui: UiData::default(),
events: EventManager::default(), events: EventManager::default(),
}; };
let scroll_strong = rect(UiColor::WHITE) let scroll_strong = rect(PaintId::WHITE)
.height(Len::abs(1000.0)) .height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start) .scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc); .add_strong(&mut rsc);
@@ -374,7 +374,7 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
events: EventManager::default(), events: EventManager::default(),
}; };
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let capturer = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let capturer_weak = capturer.weak(); let capturer_weak = capturer.weak();
let bystander = rsc.ui.widgets.add_strong(Stack { let bystander = rsc.ui.widgets.add_strong(Stack {
children: vec![capturer.any()], children: vec![capturer.any()],
@@ -474,7 +474,7 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
}; };
let seen = Rc::new(Cell::new(None)); let seen = Rc::new(Cell::new(None));
let record = seen.clone(); let record = seen.clone();
let outer_strong = rect(UiColor::WHITE) let outer_strong = rect(PaintId::WHITE)
.width(Len::abs(1000.0)) .width(Len::abs(1000.0))
.height(Len::abs(1000.0)) .height(Len::abs(1000.0))
.scrollable(Axis::X, Pin::Start) .scrollable(Axis::X, Pin::Start)
@@ -533,7 +533,7 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default(); let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| { let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
let record = slot.clone(); let record = slot.clone();
rect(UiColor::WHITE) rect(PaintId::WHITE)
.height(Len::abs(1000.0)) .height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start) .scrollable(Axis::Y, Pin::Start)
.with_id(move |_rsc, id| { .with_id(move |_rsc, id| {
+7 -3
View File
@@ -741,6 +741,10 @@ impl Scrollable for LazySpan {
} }
impl Widget for LazySpan { impl Widget for LazySpan {
fn child_order(&self) -> ChildOrder {
ChildOrder::Axis(self.dir.axis)
}
/// A lazy span animates exactly one thing, its fling -- and it drives /// A lazy span animates exactly one thing, its fling -- and it drives
/// its own rather than being handed deltas by a `ScrollArea` around /// its own rather than being handed deltas by a `ScrollArea` around
/// it, since which rows exist at all is a function of where it is /// it, since which rows exist at all is a function of where it is
@@ -842,7 +846,7 @@ mod tests {
} }
fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget<Sized>, StrongWidget) { fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget<Sized>, StrongWidget) {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let sized = rsc.ui.widgets.add_strong(Sized { let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(), inner: rect.any(),
x: None, x: None,
@@ -1054,9 +1058,9 @@ mod tests {
rsc: &mut TestRsc, rsc: &mut TestRsc,
height: f32, height: f32,
) -> (WidgetId, WeakWidget<Sized>, StrongWidget) { ) -> (WidgetId, WeakWidget<Sized>, StrongWidget) {
let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let bg = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let bg_id = bg.id(); let bg_id = bg.id();
let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let fg_rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let fg = rsc.ui.widgets.add_strong(Sized { let fg = rsc.ui.widgets.add_strong(Sized {
inner: fg_rect.any(), inner: fg_rect.any(),
x: None, x: None,
+1 -1
View File
@@ -98,7 +98,7 @@ mod tests {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any();
let id = fill.id(); let id = fill.id();
let long = Some(Len::abs(1000.0)); let long = Some(Len::abs(1000.0));
let tall = rsc.ui.widgets.add_strong(Sized { let tall = rsc.ui.widgets.add_strong(Sized {
+4
View File
@@ -8,6 +8,10 @@ pub struct Span {
} }
impl Widget for Span { impl Widget for Span {
fn child_order(&self) -> ChildOrder {
ChildOrder::Axis(self.dir.axis)
}
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) {
let axis = self.dir.axis; let axis = self.dir.axis;
let gap = self.gap.apply_rest(painter.density()).abs; let gap = self.gap.apply_rest(painter.density()).abs;
+20 -9
View File
@@ -1,26 +1,36 @@
use crate::prelude::*; use crate::prelude::*;
#[derive(Clone, Copy)] #[derive(Clone)]
pub struct Rect { pub struct Rect {
pub color: UiColor, paint: PaintValue,
pub radius: Len, pub radius: Len,
pub thickness: f32, pub thickness: f32,
pub inner_radius: f32, pub inner_radius: f32,
} }
impl Rect { impl Rect {
pub fn new(color: UiColor) -> Self { pub fn new(paint: impl Paint) -> Self {
Self { Self {
color, paint: paint.into_value(),
radius: Len::ZERO, radius: Len::ZERO,
inner_radius: 0.0, inner_radius: 0.0,
thickness: 0.0, thickness: 0.0,
} }
} }
pub fn color(mut self, color: UiColor) -> Self { pub fn paint(mut self, paint: impl Paint) -> Self {
self.color = color; self.paint = paint.into_value();
self self
} }
pub fn color(self, paint: impl Paint) -> Self {
self.paint(paint)
}
pub fn set_paint(&mut self, paint: impl Paint) {
self.paint = paint.into_value();
}
pub fn is_paint(&self, paint: &PaintId) -> bool {
self.paint.is(paint)
}
pub fn radius(mut self, radius: impl Into<Len>) -> Self { pub fn radius(mut self, radius: impl Into<Len>) -> Self {
self.radius = radius.into(); self.radius = radius.into();
self self
@@ -29,8 +39,9 @@ impl Rect {
impl Widget for Rect { impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) {
let paint = painter.paint_value(&mut self.paint);
painter.primitive(RectPrimitive { painter.primitive(RectPrimitive {
color: self.color, paint,
radius: self.radius.fold_dp(painter.density()).abs, radius: self.radius.fold_dp(painter.density()).abs,
thickness: self.thickness, thickness: self.thickness,
inner_radius: self.inner_radius, inner_radius: self.inner_radius,
@@ -43,6 +54,6 @@ impl Widget for Rect {
} }
} }
pub fn rect(color: UiColor) -> Rect { pub fn rect(paint: impl Paint) -> Rect {
Rect::new(color) Rect::new(paint)
} }
+1 -1
View File
@@ -16,7 +16,7 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
self.attrs.line_height = self.attrs.font_size * LINE_HEIGHT_MULT; self.attrs.line_height = self.attrs.font_size * LINE_HEIGHT_MULT;
self self
} }
pub fn color(mut self, color: UiColor) -> Self { pub fn color(mut self, color: PaintId) -> Self {
self.attrs.color = color; self.attrs.color = color;
self self
} }
+48 -123
View File
@@ -1,7 +1,9 @@
use crate::prelude::*; use crate::prelude::*;
use iris_core::{TextData, UiColor}; use iris_core::{PaintId, TextData};
use parley::{Affinity, Layout, Selection}; use parley::{Affinity, Layout, Selection};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use super::selection_layout;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use winit::{ use winit::{
event::KeyEvent, event::KeyEvent,
@@ -22,10 +24,8 @@ pub enum Motion {
pub struct TextEdit { pub struct TextEdit {
view: TextView, view: TextView,
selection: Option<Selection>,
#[cfg_attr(target_os = "android", allow(dead_code))] #[cfg_attr(target_os = "android", allow(dead_code))]
history: Vec<(String, Option<Selection>)>, history: Vec<(String, Option<Selection>)>,
double_hit: Option<usize>,
pub(crate) press_origin: Option<Vec2>, pub(crate) press_origin: Option<Vec2>,
pub mode: EditMode, pub mode: EditMode,
} }
@@ -40,20 +40,14 @@ impl TextEdit {
pub fn new(view: TextView, mode: EditMode) -> Self { pub fn new(view: TextView, mode: EditMode) -> Self {
Self { Self {
view, view,
selection: None,
history: Default::default(), history: Default::default(),
double_hit: None,
press_origin: None, press_origin: None,
mode, mode,
} }
} }
pub fn selected_text(&self) -> Option<String> { pub fn selected_text(&self) -> Option<String> {
let sel = self.selection?; self.view.selection.selected_text(self.view.buf.text())
if sel.is_collapsed() {
return None;
}
Some(self.buf.text()[sel.text_range()].to_string())
} }
/// The field's content. Byte-indexed, like everything else here since /// The field's content. Byte-indexed, like everything else here since
@@ -67,46 +61,19 @@ impl TextEdit {
/// The selection as a byte range, collapsed to `caret..caret` when /// The selection as a byte range, collapsed to `caret..caret` when
/// there is no span. `None` when the field is not focused. /// there is no span. `None` when the field is not focused.
pub fn selection_range(&self) -> Option<std::ops::Range<usize>> { pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
Some(self.selection?.text_range()) self.view.selection.range()
} }
/// The caret's byte offset -- the focus end of the selection, which is /// The caret's byte offset -- the focus end of the selection, which is
/// where typing lands regardless of which end of a span it is. /// where typing lands regardless of which end of a span it is.
pub fn caret(&self) -> Option<usize> { pub fn caret(&self) -> Option<usize> {
Some(self.selection?.focus().index()) self.view.selection.caret()
} }
} }
impl Widget for TextEdit { impl Widget for TextEdit {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) {
let base = painter.layer; let used = self.view.draw_selectable(painter, true);
painter.child_layer();
let used = self.view.draw(painter);
painter.layer = base;
let region = self.region();
let Some(selection) = self.selection else {
painter.set_size(used);
return;
};
let layout = self.view.buf.layout();
for (rect, _) in selection.geometry(layout) {
let size = vec2(rect.width() as f32, rect.height() as f32);
let top_left = vec2(rect.x0 as f32, rect.y0 as f32);
painter.primitive_within(
RectPrimitive::color(Color::SKY),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
}
let caret = selection.focus().geometry(layout, CARET_WIDTH);
let size = vec2(caret.width() as f32, caret.height() as f32);
let top_left = vec2(caret.x0 as f32, caret.y0 as f32);
painter.primitive_within(
RectPrimitive::color(Color::WHITE),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
painter.set_size(used); painter.set_size(used);
} }
@@ -122,28 +89,26 @@ impl Widget for TextEdit {
} }
} }
const CARET_WIDTH: f32 = 1.0;
pub struct TextEditCtx<'a> { pub struct TextEditCtx<'a> {
pub text: &'a mut TextEdit, pub text: &'a mut TextEdit,
pub data: &'a mut TextData, pub data: &'a mut TextData,
} }
impl<'a> TextEditCtx<'a> { impl<'a> TextEditCtx<'a> {
fn layout(&mut self) -> &Layout<UiColor> { fn selection_ctx(&mut self) -> TextSelectionCtx<'_> {
let attrs = self.text.view.attrs.clone(); TextSelectionCtx {
let width = self.text.view.wrap_width(); view: &mut self.text.view,
let density = self.data.density; data: self.data,
self.text.view.buf.shape(self.data, &attrs, width, density); }
self.text.view.buf.layout() }
fn layout(&mut self) -> &Layout<iris_core::PaintId> {
selection_layout(&mut self.text.view, self.data)
} }
#[cfg_attr(target_os = "android", allow(dead_code))] #[cfg_attr(target_os = "android", allow(dead_code))]
fn refresh(&mut self) { fn refresh(&mut self) {
if let Some(sel) = self.text.selection { self.selection_ctx().refresh();
let layout = self.layout();
self.text.selection = Some(sel.refresh(layout));
}
} }
pub fn take(&mut self) -> String { pub fn take(&mut self) -> String {
@@ -156,18 +121,18 @@ impl<'a> TextEditCtx<'a> {
let text = self.string(text); let text = self.string(text);
self.text.view.buf.set_text(text); self.text.view.buf.set_text(text);
self.text.view.buf.changed = true; self.text.view.buf.changed = true;
self.text.selection = None; self.text.view.selection.deselect();
} }
pub fn set_with_spans(&mut self, text: &str, spans: Vec<SpanStyle>) { pub fn set_with_spans(&mut self, text: &str, spans: Vec<SpanStyle>) {
let text = self.string(text); let text = self.string(text);
self.text.view.buf.set_text(text); self.text.view.buf.set_text(text);
self.text.view.buf.set_spans(spans); self.text.view.buf.set_spans(spans);
self.text.selection = None; self.text.view.selection.deselect();
} }
pub fn motion(&mut self, motion: Motion, select: bool) { pub fn motion(&mut self, motion: Motion, select: bool) {
let Some(sel) = self.text.selection else { let Some(sel) = self.text.view.selection.range else {
return; return;
}; };
let layout = self.layout(); let layout = self.layout();
@@ -184,7 +149,7 @@ impl<'a> TextEditCtx<'a> {
} else { } else {
apply_motion(sel, layout, motion, select) apply_motion(sel, layout, motion, select)
}; };
self.text.selection = Some(sel); self.text.view.selection.range = Some(sel);
} }
pub fn replace(&mut self, len: usize, text: &str) { pub fn replace(&mut self, len: usize, text: &str) {
@@ -213,7 +178,7 @@ impl<'a> TextEditCtx<'a> {
return; return;
} }
self.clear_span(); self.clear_span();
let at = match self.text.selection { let at = match self.text.view.selection.range {
Some(sel) => sel.focus().index(), Some(sel) => sel.focus().index(),
// No caret means nowhere to put the text, so this drops the // No caret means nowhere to put the text, so this drops the
// keystroke -- which is invisible, and was the whole of the // keystroke -- which is invisible, and was the whole of the
@@ -238,7 +203,7 @@ impl<'a> TextEditCtx<'a> {
} }
pub fn clear_span(&mut self) -> bool { pub fn clear_span(&mut self) -> bool {
let Some(sel) = self.text.selection else { let Some(sel) = self.text.view.selection.range else {
return false; return false;
}; };
if sel.is_collapsed() { if sel.is_collapsed() {
@@ -252,13 +217,7 @@ impl<'a> TextEditCtx<'a> {
} }
fn set_caret(&mut self, index: usize) { fn set_caret(&mut self, index: usize) {
let index = index.min(self.text.view.buf.text().len()); self.selection_ctx().set_caret(index);
let layout = self.layout();
self.text.selection = Some(Selection::from_byte_index(
layout,
index,
Affinity::default(),
));
} }
pub fn newline(&mut self) { pub fn newline(&mut self) {
@@ -271,7 +230,7 @@ impl<'a> TextEditCtx<'a> {
if self.clear_span() { if self.clear_span() {
return; return;
} }
let Some(sel) = self.text.selection else { let Some(sel) = self.text.view.selection.range else {
return; return;
}; };
let end = sel.focus().index(); let end = sel.focus().index();
@@ -291,7 +250,7 @@ impl<'a> TextEditCtx<'a> {
if self.clear_span() { if self.clear_span() {
return; return;
} }
let Some(sel) = self.text.selection else { let Some(sel) = self.text.view.selection.range else {
return; return;
}; };
let start = sel.focus().index(); let start = sel.focus().index();
@@ -350,67 +309,33 @@ impl<'a> TextEditCtx<'a> {
/// actually *on* something" checks its own ranges, which is what /// actually *on* something" checks its own ranges, which is what
/// makes a tap in the padding hit no link. /// makes a tap in the padding hit no link.
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize { pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
let pos = pos - self.text.region().top_left().to_abs(size); self.selection_ctx().byte_at(pos, size)
let layout = self.layout();
Selection::from_point(layout, pos.x, pos.y).focus().index()
} }
pub fn select_all(&mut self) { pub fn select_all(&mut self) {
let len = self.text.view.buf.text().len(); self.selection_ctx().select_all();
if len == 0 {
return;
}
let layout = self.layout();
let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default());
let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default());
self.text.selection = Some(Selection::new(anchor, focus));
} }
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
let pos = pos - self.text.region().top_left().to_abs(size); self.selection_ctx().select(pos, size, drag, recent);
let prev_sel = self.text.selection;
let prev_hit = self.text.double_hit;
let outcome = {
let layout = self.layout();
if drag {
prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit))
} else {
let hit = Selection::from_point(layout, pos.x, pos.y);
let index = hit.focus().index();
Some(if recent && prev_hit == Some(index) {
(Some(Selection::line_from_point(layout, pos.x, pos.y)), None)
} else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) {
(
Some(Selection::word_from_point(layout, pos.x, pos.y)),
Some(index),
)
} else {
(Some(hit), None)
})
}
};
if let Some((selection, double_hit)) = outcome {
self.text.selection = selection;
self.text.double_hit = double_hit;
}
} }
pub fn deselect(&mut self) { pub fn deselect(&mut self) {
self.text.selection = None; self.selection_ctx().deselect();
self.text.double_hit = None;
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult { pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult {
let old = (self.text.view.buf.text().to_string(), self.text.selection); let old = (
self.text.view.buf.text().to_string(),
self.text.view.selection.range,
);
let mut undo = false; let mut undo = false;
let res = self.apply_event_inner(event, modifiers, &mut undo); let res = self.apply_event_inner(event, modifiers, &mut undo);
if undo { if undo {
if let Some((old, selection)) = self.text.history.pop() { if let Some((old, selection)) = self.text.history.pop() {
self.set(&old); self.set(&old);
self.text.selection = selection; self.text.view.selection.range = selection;
self.refresh(); self.refresh();
} }
} else if self.text.view.buf.text() != old.0 { } else if self.text.view.buf.text() != old.0 {
@@ -495,7 +420,7 @@ impl<'a> TextEditCtx<'a> {
fn apply_motion( fn apply_motion(
sel: Selection, sel: Selection,
layout: &Layout<UiColor>, layout: &Layout<PaintId>,
motion: Motion, motion: Motion,
extend: bool, extend: bool,
) -> Selection { ) -> Selection {
@@ -512,15 +437,15 @@ fn apply_motion(
} }
trait RangeCursors { trait RangeCursors {
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor; fn start_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor;
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor; fn end_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor;
} }
impl RangeCursors for std::ops::Range<usize> { impl RangeCursors for std::ops::Range<usize> {
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor { fn start_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor {
parley::Cursor::from_byte_index(layout, self.start, Affinity::default()) parley::Cursor::from_byte_index(layout, self.start, Affinity::default())
} }
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor { fn end_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor {
parley::Cursor::from_byte_index(layout, self.end, Affinity::default()) parley::Cursor::from_byte_index(layout, self.end, Affinity::default())
} }
} }
@@ -605,7 +530,7 @@ mod tests {
ctx(&mut t, &mut d).set_caret(1); ctx(&mut t, &mut d).set_caret(1);
ctx(&mut t, &mut d).insert("b"); ctx(&mut t, &mut d).insert("b");
assert_eq!(content(&t), "abc"); assert_eq!(content(&t), "abc");
assert_eq!(t.selection.unwrap().focus().index(), 2); assert_eq!(t.caret(), Some(2));
} }
#[test] #[test]
@@ -655,14 +580,14 @@ mod tests {
ctx(&mut t, &mut d).select_all(); ctx(&mut t, &mut d).select_all();
assert!(ctx(&mut t, &mut d).clear_span()); assert!(ctx(&mut t, &mut d).clear_span());
assert_eq!(content(&t), ""); assert_eq!(content(&t), "");
assert_eq!(t.selection.unwrap().focus().index(), 0); assert_eq!(t.caret(), Some(0));
} }
#[test] #[test]
fn tapping_an_empty_field_places_a_caret_so_typing_lands() { fn tapping_an_empty_field_places_a_caret_so_typing_lands() {
let (mut t, mut d) = edit("", EditMode::MultiLine); let (mut t, mut d) = edit("", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false); ctx(&mut t, &mut d).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false);
assert!(t.selection.is_some(), "a tap must leave a caret behind"); assert!(t.caret().is_some(), "a tap must leave a caret behind");
ctx(&mut t, &mut d).insert("hi"); ctx(&mut t, &mut d).insert("hi");
assert_eq!(content(&t), "hi"); assert_eq!(content(&t), "hi");
} }
@@ -671,14 +596,14 @@ mod tests {
fn tapping_past_the_end_of_the_text_clamps_to_the_end() { fn tapping_past_the_end_of_the_text_clamps_to_the_end() {
let (mut t, mut d) = edit("abc", EditMode::MultiLine); let (mut t, mut d) = edit("abc", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false); ctx(&mut t, &mut d).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false);
assert_eq!(t.selection.unwrap().focus().index(), 3); assert_eq!(t.caret(), Some(3));
} }
#[test] #[test]
fn dragging_without_a_previous_selection_selects_nothing() { fn dragging_without_a_previous_selection_selects_nothing() {
let (mut t, mut d) = edit("abc", EditMode::MultiLine); let (mut t, mut d) = edit("abc", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false); ctx(&mut t, &mut d).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false);
assert!(t.selection.is_none()); assert!(t.selection_range().is_none());
} }
#[test] #[test]
@@ -765,7 +690,7 @@ mod tests {
let (mut t, mut d) = edit("abc", EditMode::SingleLine); let (mut t, mut d) = edit("abc", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(0); ctx(&mut t, &mut d).set_caret(0);
ctx(&mut t, &mut d).motion(Motion::Right, false); ctx(&mut t, &mut d).motion(Motion::Right, false);
assert_eq!(t.selection.unwrap().focus().index(), 1); assert_eq!(t.caret(), Some(1));
ctx(&mut t, &mut d).motion(Motion::Right, true); ctx(&mut t, &mut d).motion(Motion::Right, true);
assert_eq!(t.selected_text().as_deref(), Some("b")); assert_eq!(t.selected_text().as_deref(), Some("b"));
} }
@@ -775,11 +700,11 @@ mod tests {
let (mut t, mut d) = edit("abcdef", EditMode::SingleLine); let (mut t, mut d) = edit("abcdef", EditMode::SingleLine);
ctx(&mut t, &mut d).select_all(); ctx(&mut t, &mut d).select_all();
ctx(&mut t, &mut d).motion(Motion::Left, false); ctx(&mut t, &mut d).motion(Motion::Left, false);
assert_eq!(t.selection.unwrap().focus().index(), 0); assert_eq!(t.caret(), Some(0));
ctx(&mut t, &mut d).select_all(); ctx(&mut t, &mut d).select_all();
ctx(&mut t, &mut d).motion(Motion::Right, false); ctx(&mut t, &mut d).motion(Motion::Right, false);
assert_eq!(t.selection.unwrap().focus().index(), 6); assert_eq!(t.caret(), Some(6));
} }
#[test] #[test]
+146 -2
View File
@@ -1,9 +1,11 @@
mod build; mod build;
mod edit; mod edit;
mod selection;
pub use build::*; pub use build::*;
pub use edit::*; pub use edit::*;
use iris_core::util::MutDetect; use iris_core::util::MutDetect;
pub use selection::*;
use crate::prelude::*; use crate::prelude::*;
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
@@ -19,6 +21,7 @@ pub struct TextView {
tex: Option<RenderedText>, tex: Option<RenderedText>,
width: Option<f32>, width: Option<f32>,
pub hint: Option<StrongWidget>, pub hint: Option<StrongWidget>,
selection: TextSelection,
} }
impl TextView { impl TextView {
@@ -37,6 +40,7 @@ impl TextView {
tex: None, tex: None,
width: None, width: None,
hint, hint,
selection: TextSelection::default(),
} }
} }
@@ -94,6 +98,39 @@ impl TextView {
Size::abs(tex.size) Size::abs(tex.size)
} }
pub(super) fn draw_selectable(&mut self, painter: &mut Painter, caret: bool) -> Size {
let base = painter.layer;
painter.child_layer();
let used = self.draw(painter);
painter.layer = base;
let region = self.region();
let Some(selection) = self.selection.range else {
return used;
};
let layout = self.buf.layout();
for (rect, _) in selection.geometry(layout) {
let size = vec2(rect.width() as f32, rect.height() as f32);
let top_left = vec2(rect.x0 as f32, rect.y0 as f32);
let paint = painter.paint(&PaintId::SKY);
painter.primitive_within(
RectPrimitive::color(paint),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
}
if caret {
let caret = selection.focus().geometry(layout, CARET_WIDTH);
let size = vec2(caret.width() as f32, caret.height() as f32);
let top_left = vec2(caret.x0 as f32, caret.y0 as f32);
let paint = painter.paint(&PaintId::WHITE);
painter.primitive_within(
RectPrimitive::color(paint),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
}
used
}
pub fn content(&self) -> String { pub fn content(&self) -> String {
self.buf.text().to_string() self.buf.text().to_string()
} }
@@ -111,14 +148,36 @@ impl Text {
if self.content.changed { if self.content.changed {
self.content.changed = false; self.content.changed = false;
self.view.buf.set_text(self.content.as_str()); self.view.buf.set_text(self.content.as_str());
self.view.selection.deselect();
} }
} }
pub fn selected_text(&self) -> Option<String> {
self.view.selection.selected_text(self.view.buf.text())
}
pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
self.view.selection.range()
}
pub fn set_with_spans(&mut self, content: impl Into<String>, spans: Vec<SpanStyle>) {
let content = content.into();
*self.content = content.clone();
self.content.changed = false;
self.view.buf.set_text(content);
self.view.buf.set_spans(spans);
self.view.selection.deselect();
}
} }
impl Widget for Text { impl Widget for Text {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) {
self.update_buf(); self.update_buf();
let size = self.view.draw(painter); let size = if self.view.selection.range.is_some() {
self.view.draw_selectable(painter, false)
} else {
self.view.draw(painter)
};
painter.set_size(size); painter.set_size(size);
} }
@@ -127,6 +186,8 @@ impl Widget for Text {
} }
} }
pub(super) const CARET_WIDTH: f32 = 1.0;
impl Deref for Text { impl Deref for Text {
type Target = TextAttrs; type Target = TextAttrs;
@@ -160,6 +221,89 @@ mod tests {
use crate::layout_tests::TestRsc; use crate::layout_tests::TestRsc;
use crate::prelude::*; use crate::prelude::*;
fn rendered_text(content: &str) -> (TestRsc, UiRenderState, WeakWidget<Text>, StrongWidget) {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let text = wtext(content).add_strong(&mut rsc);
let id = text.weak();
let root = text.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
(rsc, render, id, root)
}
#[test]
fn display_text_and_edit_text_use_the_same_selection_engine() {
let (mut rsc, _render, text, _root) = rendered_text("hello there");
text.selection(&mut rsc).select_all();
let view = TextView::new(TextBuffer::new("hello there"), TextAttrs::default(), None);
let mut edit = TextEdit::new(view, EditMode::MultiLine);
let mut data = TextData::default();
TextEditCtx {
text: &mut edit,
data: &mut data,
}
.select_all();
assert_eq!(
rsc.ui.widgets[text].selection_range(),
edit.selection_range()
);
assert_eq!(rsc.ui.widgets[text].selected_text(), edit.selected_text());
}
#[test]
fn changing_display_text_clears_its_now_stale_selection() {
let (mut rsc, mut render, text, root) = rendered_text("before");
text.selection(&mut rsc).select_all();
assert_eq!(
rsc.ui.widgets[text].selected_text().as_deref(),
Some("before")
);
*rsc.ui.widgets[text].content = "after".to_string();
render.update(&root, &mut rsc);
assert_eq!(rsc.ui.widgets[text].selected_text(), None);
assert_eq!(rsc.ui.widgets[text].selection_range(), None);
}
#[test]
fn display_text_draws_the_shared_highlight_without_an_editing_caret() {
let (mut rsc, mut render, text, root) = rendered_text("selected");
let plain = render.active[&text.id()].primitives.len();
text.selection(&mut rsc).select_all();
render.update(&root, &mut rsc);
let selected = render.active[&text.id()].primitives.len();
let view = TextView::new(TextBuffer::new("selected"), TextAttrs::default(), None);
let edit = rsc
.ui
.widgets
.add_strong(TextEdit::new(view, EditMode::MultiLine));
let edit_id = edit.weak();
let edit_root = edit.any();
let mut edit_render = UiRenderState::new();
edit_render.resize((800.0, 600.0));
edit_render.update(&edit_root, &mut rsc);
edit_id.edit(&mut rsc).select_all();
edit_render.update(&edit_root, &mut rsc);
let editable = edit_render.active[&edit_id.id()].primitives.len();
assert!(
selected > plain,
"the selection added no highlight primitive"
);
assert_eq!(
editable,
selected + 1,
"editable text should add exactly its caret to the shared highlight"
);
}
#[test] #[test]
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() { fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
@@ -167,7 +311,7 @@ mod tests {
}; };
let root = wtext("hello there") let root = wtext("hello there")
.size(18) .size(18)
.color(UiColor::WHITE) .color(PaintId::WHITE)
.add_strong(&mut rsc) .add_strong(&mut rsc)
.any(); .any();
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
+756
View File
@@ -0,0 +1,756 @@
use crate::prelude::*;
use iris_core::{PaintId, TextData};
use parley::{Affinity, Layout, Selection as ParleySelection};
use std::time::Instant;
/// The selection state shared by display text and editable text. Editing,
/// focus and IME state deliberately live in `TextEdit`; this owns only the
/// state whose meaning comes from a shaped text layout.
#[derive(Default)]
pub(super) struct TextSelection {
pub(super) range: Option<ParleySelection>,
double_hit: Option<usize>,
}
impl TextSelection {
pub(super) fn selected_text(&self, text: &str) -> Option<String> {
let selection = self.range?;
if selection.is_collapsed() {
return None;
}
Some(text[selection.text_range()].to_string())
}
pub(super) fn range(&self) -> Option<std::ops::Range<usize>> {
Some(self.range?.text_range())
}
pub(super) fn caret(&self) -> Option<usize> {
Some(self.range?.focus().index())
}
pub(super) fn deselect(&mut self) {
self.range = None;
self.double_hit = None;
}
}
/// Selection operations that need both a text widget's shaped buffer and
/// iris's text resources. `TextEditCtx` delegates to this same context rather
/// than maintaining an editable-only copy of the geometry and hit testing.
pub struct TextSelectionCtx<'a> {
pub(super) view: &'a mut TextView,
pub(super) data: &'a mut TextData,
}
impl TextSelectionCtx<'_> {
pub(super) fn layout(&mut self) -> &Layout<PaintId> {
selection_layout(self.view, self.data)
}
pub(crate) fn refresh(&mut self) {
if let Some(selection) = self.view.selection.range {
let layout = self.layout();
self.view.selection.range = Some(selection.refresh(layout));
}
}
/// The byte offset in the text nearest `pos`. Positions and `size` use
/// the same widget-local coordinates as a `CursorSense` event.
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
let pos = pos - self.view.region().top_left().to_abs(size);
let layout = self.layout();
ParleySelection::from_point(layout, pos.x, pos.y)
.focus()
.index()
}
pub fn select_all(&mut self) {
let len = self.view.buf.text().len();
if len == 0 {
return;
}
let layout = self.layout();
let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default());
let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default());
self.view.selection.range = Some(ParleySelection::new(anchor, focus));
}
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
let pos = pos - self.view.region().top_left().to_abs(size);
let previous = self.view.selection.range;
let previous_hit = self.view.selection.double_hit;
let outcome = {
let layout = self.layout();
if drag {
previous.map(|selection| {
(
Some(selection.extend_to_point(layout, pos.x, pos.y)),
previous_hit,
)
})
} else {
let hit = ParleySelection::from_point(layout, pos.x, pos.y);
let index = hit.focus().index();
Some(if recent && previous_hit == Some(index) {
(
Some(ParleySelection::line_from_point(layout, pos.x, pos.y)),
None,
)
} else if recent
&& previous.map(|selection| selection.focus().index()) == Some(index)
{
(
Some(ParleySelection::word_from_point(layout, pos.x, pos.y)),
Some(index),
)
} else {
(Some(hit), None)
})
}
};
if let Some((range, double_hit)) = outcome {
self.view.selection.range = range;
self.view.selection.double_hit = double_hit;
}
}
pub fn deselect(&mut self) {
self.view.selection.deselect();
}
pub(crate) fn set_caret(&mut self, index: usize) {
let index = index.min(self.view.buf.text().len());
let layout = self.layout();
self.view.selection.range = Some(ParleySelection::from_byte_index(
layout,
index,
Affinity::default(),
));
}
fn select_between(&mut self, anchor: usize, focus: usize) {
let len = self.view.buf.text().len();
let layout = self.layout();
let anchor = parley::Cursor::from_byte_index(layout, anchor.min(len), Affinity::default());
let focus = parley::Cursor::from_byte_index(layout, focus.min(len), Affinity::default());
self.view.selection.range = Some(ParleySelection::new(anchor, focus));
}
}
pub(super) fn selection_layout<'a>(
view: &'a mut TextView,
data: &mut TextData,
) -> &'a Layout<PaintId> {
let attrs = view.attrs.clone();
let width = view.wrap_width();
let density = data.density;
view.buf.shape(data, &attrs, width, density);
view.buf.layout()
}
/// Gives an ordinary `Text` handle access to the same selection operations as
/// `TextEditCtx`. Gesture policy is intentionally not part of this trait; a
/// selection controller and an editor's focus handler do different
/// things with the same mechanics.
pub trait TextSelectable {
fn selection<'a>(&self, ui: &'a mut impl UiRsc) -> TextSelectionCtx<'a>;
}
impl<I: IdLike<Widget = Text>> TextSelectable for I {
fn selection<'a>(&self, ui: &'a mut impl UiRsc) -> TextSelectionCtx<'a> {
let ui = ui.ui_mut();
TextSelectionCtx {
view: &mut ui.widgets.get_mut(self).unwrap().view,
data: &mut ui.text,
}
}
}
/// Selection across the ordinary `Text` descendants of the widget this
/// controller is attached to. The controller owns the cross-widget gesture
/// and command state; each text leaf owns only its local Parley selection.
pub struct SelectionController {
anchor: Option<(WidgetId, usize)>,
order: Vec<WidgetId>,
selected: Vec<WidgetId>,
gesture: DragGesture,
scroll: Option<WeakWidget<LazySpan>>,
separator: String,
last_input: Option<(Instant, CursorSense, SelectionInput)>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SelectionInput {
Tapped,
Handled,
}
impl Default for SelectionController {
fn default() -> Self {
Self::new()
}
}
impl SelectionController {
pub fn new() -> Self {
Self {
anchor: None,
order: Vec::new(),
selected: Vec::new(),
gesture: DragGesture::new(),
scroll: None,
separator: String::new(),
last_input: None,
}
}
pub fn with_scroll(mut self, scroll: WeakWidget<LazySpan>) -> Self {
self.scroll = Some(scroll);
self
}
pub fn separator(mut self, separator: impl Into<String>) -> Self {
self.separator = separator.into();
self
}
fn text_order(host: WidgetId, rsc: &impl UiRsc, render: &UiRenderState) -> Vec<WidgetId> {
fn visit(id: WidgetId, rsc: &impl UiRsc, render: &UiRenderState, out: &mut Vec<WidgetId>) {
if rsc
.widgets()
.get_dyn(id)
.is_some_and(|widget| widget.as_any().is::<Text>())
{
out.push(id);
return;
}
// An editor owns its own focus, commands and selection gesture.
if rsc
.widgets()
.get_dyn(id)
.is_some_and(|widget| widget.as_any().is::<TextEdit>())
{
return;
}
for child in render.ordered_children(id, rsc) {
visit(child, rsc, render, out);
}
}
let mut out = Vec::new();
visit(host, rsc, render, &mut out);
out
}
fn with_text<T>(
rsc: &mut impl UiRsc,
id: WidgetId,
f: impl FnOnce(&mut TextSelectionCtx<'_>) -> T,
) -> Option<T> {
let ui = rsc.ui_mut();
let text = ui
.widgets
.get_dyn_mut(id)?
.as_any_mut()
.downcast_mut::<Text>()?;
text.update_buf();
let mut ctx = TextSelectionCtx {
view: &mut text.view,
data: &mut ui.text,
};
Some(f(&mut ctx))
}
fn locate(
&self,
rsc: &impl UiRsc,
render: &UiRenderState,
pos: Vec2,
) -> Option<(WidgetId, Vec2, Vec2)> {
self.order.iter().find_map(|&id| {
let active = render.active.get(&id)?;
let region = render.window_region(&id, rsc)?;
(region.contains(pos) && render.mask_admits(active.mask, pos, rsc)).then(|| {
(
id,
pos - region.top_left,
region.bot_right - region.top_left,
)
})
})
}
fn deselect(&mut self, rsc: &mut impl UiRsc) {
let mut ids = std::mem::take(&mut self.selected);
if let Some((anchor, _)) = self.anchor.take()
&& !ids.contains(&anchor)
{
ids.push(anchor);
}
for id in ids {
Self::with_text(rsc, id, |text| text.deselect());
}
}
fn begin(&mut self, rsc: &mut impl UiRsc, id: WidgetId, pos: Vec2, size: Vec2) {
self.deselect(rsc);
let byte = Self::with_text(rsc, id, |text| {
text.select(pos, size, false, false);
text.byte_at(pos, size)
});
self.anchor = byte.map(|byte| (id, byte));
}
fn extend(&mut self, rsc: &mut impl UiRsc, id: WidgetId, pos: Vec2, size: Vec2) {
let Some((anchor, anchor_byte)) = self.anchor else {
return;
};
let Some(anchor_at) = self.order.iter().position(|&candidate| candidate == anchor) else {
self.deselect(rsc);
return;
};
let Some(focus_at) = self.order.iter().position(|&candidate| candidate == id) else {
return;
};
let Some(focus_byte) = Self::with_text(rsc, id, |text| text.byte_at(pos, size)) else {
return;
};
let (lo, hi) = if anchor_at <= focus_at {
(anchor_at, focus_at)
} else {
(focus_at, anchor_at)
};
let old = std::mem::take(&mut self.selected);
for old_id in old {
if !self.order[lo..=hi].contains(&old_id) {
Self::with_text(rsc, old_id, |text| text.deselect());
}
}
for &text_id in &self.order[lo..=hi] {
let forward = anchor_at <= focus_at;
Self::with_text(rsc, text_id, |text| {
let len = text.view.buf.text().len();
let (start, end) = if text_id == anchor && text_id == id {
(anchor_byte, focus_byte)
} else if text_id == anchor {
(anchor_byte, if forward { len } else { 0 })
} else if text_id == id {
(if forward { 0 } else { len }, focus_byte)
} else {
(0, len)
};
text.select_between(start, end);
});
}
self.selected = self.order[lo..=hi].to_vec();
}
pub fn drag<Rsc: HasEvents>(
&mut self,
id: ControllerId,
rsc: &mut Rsc,
input: &CursorData<'_>,
) -> SelectionInput {
// A leaf listener and the controller host may both cover one point on
// the same layer. They are two routes for one physical sample, not two
// gestures; the second route must observe the first route's decision.
if let Some((last, sense, outcome)) = self.last_input
&& last == input.cursor.time
&& sense == input.sense
{
return outcome;
}
self.order = Self::text_order(id.host(), rsc, input.render);
let hit = self.locate(rsc, input.render, input.cursor.pos);
let mut press = PressState::default();
if self.gesture.starts_press(input.sense) {
press.scrolling = self.scroll.is_some_and(|scroll| scroll(rsc).is_scrolling());
if let Some(scroll) = self.scroll {
scroll(rsc).cancel_fling();
}
}
press.already_selected = self.has_selection(rsc);
let outcome = self.gesture.handle(
input.pointer,
id.host(),
input.sense,
input.cursor.pos,
input.cursor.time,
press,
);
let input_result = match outcome {
GestureOutcome::Pan(delta) => {
if let Some(scroll) = self.scroll {
scroll(rsc).scroll(delta);
}
SelectionInput::Handled
}
GestureOutcome::SelectStart => {
if let Some((text, pos, size)) = hit {
self.begin(rsc, text, pos, size);
rsc.set_command_target(Some(id));
}
SelectionInput::Handled
}
GestureOutcome::SelectExtend => {
if let Some((text, pos, size)) = hit {
self.extend(rsc, text, pos, size);
}
SelectionInput::Handled
}
GestureOutcome::Released(Some(velocity)) => {
if let Some(scroll) = self.scroll
&& scroll(rsc).fling(velocity)
{
rsc.ui_mut().animate(scroll.id());
}
SelectionInput::Handled
}
GestureOutcome::Tapped => {
if self.anchor.is_some() || !self.selected.is_empty() {
self.deselect(rsc);
rsc.set_command_target(None);
SelectionInput::Handled
} else {
SelectionInput::Tapped
}
}
GestureOutcome::Cancelled
| GestureOutcome::Undecided
| GestureOutcome::Released(None) => SelectionInput::Handled,
};
self.last_input = Some((input.cursor.time, input.sense, input_result));
input_result
}
pub fn has_selection(&self, rsc: &impl UiRsc) -> bool {
self.selected.iter().any(|&id| {
rsc.widgets()
.get_dyn(id)
.and_then(|widget| widget.as_any().downcast_ref::<Text>())
.is_some_and(|text| text.selected_text().is_some())
})
}
pub fn selected_text(&self, rsc: &impl UiRsc) -> Option<String> {
let parts: Vec<String> = self
.selected
.iter()
.filter_map(|&id| {
rsc.widgets()
.get_dyn(id)
.and_then(|widget| widget.as_any().downcast_ref::<Text>())
.and_then(Text::selected_text)
})
.collect();
(!parts.is_empty()).then(|| parts.join(&self.separator))
}
}
impl<Rsc: HasEvents> Controller<Rsc> for SelectionController {
fn command(&mut self, command: Command, rsc: &mut Rsc) -> CommandResult {
match command {
Command::Copy => self
.selected_text(rsc)
.map(CommandResult::Copy)
.unwrap_or(CommandResult::Unused),
Command::SelectAll => {
let order = self.order.clone();
self.deselect(rsc);
for &id in &order {
Self::with_text(rsc, id, |text| text.select_all());
}
self.selected = order;
CommandResult::Used
}
Command::Escape => {
self.deselect(rsc);
CommandResult::Used
}
}
}
}
#[cfg(test)]
mod controller_tests {
use super::*;
struct TestRsc {
ui: UiData,
events: EventManager<TestRsc>,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
}
}
impl HasState for TestRsc {
type State = ();
}
impl HasEvents for TestRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
fn two_texts(
dir: Dir,
) -> (
TestRsc,
UiRenderState,
WeakWidget<Span>,
WeakWidget<Text>,
WeakWidget<Text>,
StrongWidget,
) {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let first = wtext("first").add(&mut rsc);
let second = wtext("second").add(&mut rsc);
let host = (first, second)
.span(dir)
.controller(SelectionController::new().separator("|"))
.add(&mut rsc);
let root = host.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((400.0, 200.0));
render.update(&root, &mut rsc);
(rsc, render, host, first, second, root)
}
#[test]
fn a_span_orders_selection_on_its_visual_axis() {
let (mut rsc, render, host, _first, _second, _root) = two_texts(Dir::LEFT);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.order = SelectionController::text_order(host.id(), rsc, &render);
});
rsc.set_command_target(Some(id));
assert_eq!(rsc.run_command(Command::SelectAll), CommandResult::Used);
assert_eq!(
rsc.run_command(Command::Copy),
CommandResult::Copy("second|first".to_string())
);
}
#[test]
fn a_widget_without_an_order_override_keeps_draw_order() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let first = wtext("back").add(&mut rsc);
let second = wtext("front").add(&mut rsc);
let host = (first, second)
.stack()
.controller(SelectionController::new().separator("|"))
.add(&mut rsc);
let root = host.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((400.0, 200.0));
render.update(&root, &mut rsc);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.order = SelectionController::text_order(host.id(), rsc, &render);
});
rsc.set_command_target(Some(id));
assert_eq!(rsc.run_command(Command::SelectAll), CommandResult::Used);
assert_eq!(
rsc.run_command(Command::Copy),
CommandResult::Copy("back|front".to_string())
);
}
#[test]
fn nearest_controller_prefers_the_inner_scope() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let leaf = wtext("leaf").add(&mut rsc);
let inner = (leaf,)
.span(Dir::DOWN)
.controller(SelectionController::new())
.add(&mut rsc);
let outer = (inner,)
.span(Dir::DOWN)
.controller(SelectionController::new())
.add(&mut rsc);
let root = outer.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((400.0, 200.0));
render.update(&root, &mut rsc);
let found = rsc
.events()
.controllers
.nearest_id::<SelectionController>(leaf.id(), &render)
.unwrap();
assert_eq!(found.host(), inner.id());
}
#[test]
fn command_target_outlives_pointer_release_and_copies_the_controller_selection() {
let (mut rsc, render, host, first, second, _root) = two_texts(Dir::DOWN);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.order = SelectionController::text_order(host.id(), rsc, &render);
let first_size = render.window_region(&first, rsc).unwrap().size();
let second_size = render.window_region(&second, rsc).unwrap().size();
selection.begin(rsc, first.id(), Vec2::ZERO, first_size);
selection.extend(rsc, second.id(), second_size, second_size);
});
rsc.set_command_target(Some(id));
assert_eq!(
rsc.run_command(Command::Copy),
CommandResult::Copy("first|second".to_string())
);
}
#[test]
fn tapping_after_selection_deselects_and_releases_the_command_target() {
let (mut rsc, render, host, _first, _second, _root) = two_texts(Dir::DOWN);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.order = SelectionController::text_order(host.id(), rsc, &render);
let order = selection.order.clone();
for &text in &order {
SelectionController::with_text(rsc, text, |text| text.select_all());
}
selection.selected = order;
});
rsc.set_command_target(Some(id));
let pointer = PointerRequests::default();
let now = Instant::now();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
let press = CursorData {
pos: Vec2::ZERO,
size: Vec2::ZERO,
scroll_delta: Vec2::ZERO,
hover: Default::default(),
cursor: CursorState {
pos: Vec2::ZERO,
time: now,
..Default::default()
},
drag_axis: None,
captured: false,
sense: CursorSense::PressStart(CursorButton::Left),
render: &render,
pointer: &pointer,
};
selection.drag(id, rsc, &press);
let release = CursorData {
pos: Vec2::ZERO,
size: Vec2::ZERO,
scroll_delta: Vec2::ZERO,
hover: Default::default(),
cursor: CursorState {
pos: Vec2::ZERO,
time: now + std::time::Duration::from_millis(20),
..Default::default()
},
drag_axis: None,
captured: false,
sense: CursorSense::PressEnd(CursorButton::Left),
render: &render,
pointer: &pointer,
};
assert_eq!(selection.drag(id, rsc, &release), SelectionInput::Handled);
});
assert_eq!(rsc.events().controllers.command_target(), None);
assert_eq!(rsc.run_command(Command::Copy), CommandResult::Unused);
}
#[test]
fn removing_a_controller_host_clears_its_command_target() {
let (mut rsc, mut render, host, _first, _second, root) = two_texts(Dir::DOWN);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.set_command_target(Some(id));
drop(root);
render.update(None, &mut rsc);
rsc.free();
assert_eq!(rsc.events().controllers.command_target(), None);
assert_eq!(rsc.run_command(Command::Copy), CommandResult::Unused);
}
#[test]
fn removing_a_host_during_a_callback_does_not_restore_its_controller() {
let (mut rsc, _render, host, _first, _second, _root) = two_texts(Dir::DOWN);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.set_command_target(Some(id));
rsc.with_controller::<SelectionController, _>(id, |_selection, rsc| {
rsc.events_mut().controllers.remove(host.id());
});
assert!(
rsc.events()
.controllers
.id::<SelectionController>(host.id())
.is_none()
);
assert_eq!(rsc.events().controllers.command_target(), None);
}
}
+49 -41
View File
@@ -9,22 +9,23 @@ pub fn build<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Clie
where where
Rsc::State: FocusHost, Rsc::State: FocusHost,
{ {
let rrect = rect(Color::WHITE).radius(20); let rrect = rect(PaintId::WHITE).radius(20);
let pad_test = ( let pad_test = (
rrect.color(Color::BLUE), rrect.clone().color(PaintId::BLUE),
( (
rrect rrect
.color(Color::RED) .clone()
.color(PaintId::RED)
.sized((100, 100)) .sized((100, 100))
.center() .center()
.width(rest(2)), .width(rest(2)),
( (
rrect.color(Color::ORANGE), rrect.clone().color(PaintId::ORANGE),
rrect.color(Color::LIME).pad(10.0), rrect.clone().color(PaintId::LIME).pad(10.0),
) )
.span(Dir::RIGHT) .span(Dir::RIGHT)
.width(rest(2)), .width(rest(2)),
rrect.color(Color::YELLOW), rrect.clone().color(PaintId::YELLOW),
) )
.span(Dir::RIGHT) .span(Dir::RIGHT)
.pad(10) .pad(10)
@@ -34,19 +35,19 @@ where
.add(rsc); .add(rsc);
let span_test = ( let span_test = (
rrect.color(Color::GREEN).width(100), rrect.clone().color(PaintId::GREEN).width(100),
rrect.color(Color::ORANGE), rrect.clone().color(PaintId::ORANGE),
rrect.color(Color::CYAN), rrect.clone().color(PaintId::CYAN),
rrect.color(Color::BLUE).width(rel(0.5)), rrect.clone().color(PaintId::BLUE).width(rel(0.5)),
rrect.color(Color::MAGENTA).width(100), rrect.clone().color(PaintId::MAGENTA).width(100),
rrect.color(Color::RED).width(100), rrect.color(PaintId::RED).width(100),
) )
.span(Dir::LEFT) .span(Dir::LEFT)
.add(rsc); .add(rsc);
let span_add = Span::empty(Dir::RIGHT).add(rsc); let span_add = Span::empty(Dir::RIGHT).add(rsc);
let add_button = rect(Color::LIME) let add_button = rect(PaintId::LIME)
.radius(30) .radius(30)
.on(CursorSense::click(), move |_, rsc| { .on(CursorSense::click(), move |_, rsc| {
let child = image(include_bytes!("../assets/sungals.png")) let child = image(include_bytes!("../assets/sungals.png"))
@@ -57,7 +58,7 @@ where
.sized((150, 150)) .sized((150, 150))
.align(Align::BOT_RIGHT); .align(Align::BOT_RIGHT);
let del_button = rect(Color::RED) let del_button = rect(PaintId::RED)
.radius(30) .radius(30)
.on(CursorSense::click(), move |_, rsc| { .on(CursorSense::click(), move |_, rsc| {
span_add(rsc).pop(); span_add(rsc).pop();
@@ -79,9 +80,9 @@ where
btext("'").family(Family::Monospace).align(Align::TOP), btext("'").family(Family::Monospace).align(Align::TOP),
btext("'").family(Family::Monospace), btext("'").family(Family::Monospace),
btext(":gamer mode").family(Family::Monospace), btext(":gamer mode").family(Family::Monospace),
rect(Color::CYAN).sized((10, 10)).center(), rect(PaintId::CYAN).sized((10, 10)).center(),
rect(Color::RED).sized((100, 100)).center(), rect(PaintId::RED).sized((100, 100)).center(),
rect(Color::PURPLE).sized((50, 50)).align(Align::TOP), rect(PaintId::PURPLE).sized((50, 50)).align(Align::TOP),
) )
.span(Dir::RIGHT) .span(Dir::RIGHT)
.center(), .center(),
@@ -94,13 +95,13 @@ where
let msg_area = texts let msg_area = texts
.scrollable(Axis::Y, Pin::Start) .scrollable(Axis::Y, Pin::Start)
.masked() .masked()
.background(rect(Color::SKY)); .background(rect(PaintId::SKY));
let add_text = wtext("add") let add_text = wtext("add")
.editable(EditMode::MultiLine) .editable(EditMode::MultiLine)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.size(30) .size(30)
.attr::<Selectable>(()) .attr::<Selectable>(())
.on(Submit, move |ctx, rsc| { .on(Submit, move |ctx, rsc: &mut Rsc| {
let w = ctx.widget; let w = ctx.widget;
let content = w.edit(rsc).take(); let content = w.edit(rsc).take();
let text = wtext(content) let text = wtext(content)
@@ -109,9 +110,11 @@ where
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) .wrap(true)
.attr::<Selectable>(()); .attr::<Selectable>(());
let msg_box = text let fill = rsc
.background(rect(Color::WHITE.darker(0.5))) .ui_mut()
.add_strong(rsc); .paints
.add(Srgba8::WHITE.to_linear().darker(0.5));
let msg_box = text.background(rect(fill)).add_strong(rsc);
texts(rsc).push(msg_box); texts(rsc).push(msg_box);
}) })
.add(rsc); .add(rsc);
@@ -119,10 +122,14 @@ where
let text_edit_scroll = ( let text_edit_scroll = (
msg_area.height(rest(1)), msg_area.height(rest(1)),
( (
Rect::new(Color::WHITE.darker(0.9)), Rect::new(
rsc.ui_mut()
.paints
.add(Srgba8::WHITE.to_linear().darker(0.9)),
),
( (
add_text.width(rest(1)), add_text.width(rest(1)),
Rect::new(Color::GREEN) Rect::new(PaintId::GREEN)
.on(CursorSense::click(), move |ctx, rsc: &mut Rsc| { .on(CursorSense::click(), move |ctx, rsc: &mut Rsc| {
rsc.run_event::<Submit>(add_text, (), ctx.state); rsc.run_event::<Submit>(add_text, (), ctx.state);
}) })
@@ -142,7 +149,9 @@ where
let main = WidgetPtr::new().add(rsc); let main = WidgetPtr::new().add(rsc);
let vals = Rc::new(RefCell::new((0, Vec::new()))); let vals = Rc::new(RefCell::new((0, Vec::new())));
let mut switch_button = |color, to: WeakWidget, label| { let mut switch_button = |solid: Srgba8, to: WeakWidget, label| {
let value = solid.to_linear();
let paint = rsc.ui_mut().paints.add(value);
let to = to.upgrade(rsc); let to = to.upgrade(rsc);
let vec = &mut vals.borrow_mut().1; let vec = &mut vals.borrow_mut().1;
let i = vec.len(); let i = vec.len();
@@ -153,38 +162,37 @@ where
vec.push(Some(to)); vec.push(Some(to));
} }
let vals = vals.clone(); let vals = vals.clone();
let rect = rect(color) let pressed = paint.clone();
.on(CursorSense::click(), move |ctx, rsc| { let hovered = paint.clone();
let normal = paint.clone();
let rect = rect(paint)
.on(CursorSense::click(), move |_ctx, rsc: &mut Rsc| {
let (prev, vec) = &mut *vals.borrow_mut(); let (prev, vec) = &mut *vals.borrow_mut();
if let Some(h) = vec[i].take() { if let Some(h) = vec[i].take() {
vec[*prev] = main(rsc).replace(h); vec[*prev] = main(rsc).replace(h);
*prev = i; *prev = i;
} }
ctx.widget(rsc).color = color.darker(0.3); rsc.ui_mut().paints.set(&pressed, value.darker(0.3));
}) })
.on( .on(
CursorSense::HoverStart | CursorSense::unclick(), CursorSense::HoverStart | CursorSense::unclick(),
move |ctx, rsc| { move |_ctx, rsc: &mut Rsc| {
ctx.widget(rsc).color = color.brighter(0.2); rsc.ui_mut().paints.set(&hovered, value.brighter(0.2));
}, },
) )
.on(CursorSense::HoverEnd, move |ctx, rsc| { .on(CursorSense::HoverEnd, move |_ctx, rsc: &mut Rsc| {
ctx.widget(rsc).color = color; rsc.ui_mut().paints.set(&normal, value);
}) })
.label(label); .label(label);
(rect, wtext(label).size(30).text_align(Align::CENTER)).stack() (rect, wtext(label).size(30).text_align(Align::CENTER)).stack()
}; };
let tabs = ( let tabs = (
switch_button(Color::RED, pad_test, "pad"), switch_button(Srgba8::RED, pad_test, "pad"),
switch_button(Color::GREEN, span_test, "span"), switch_button(Srgba8::GREEN, span_test, "span"),
switch_button(Color::BLUE, span_add_test, "image span"), switch_button(Srgba8::BLUE, span_add_test, "image span"),
switch_button(Color::MAGENTA, text_test, "text layout"), switch_button(Srgba8::MAGENTA, text_test, "text layout"),
switch_button( switch_button(Srgba8::YELLOW, text_edit_scroll, "text edit scroll"),
Color::YELLOW.mul_rgb(0.5),
text_edit_scroll,
"text edit scroll",
),
) )
.span(Dir::RIGHT); .span(Dir::RIGHT);
+178
View File
@@ -0,0 +1,178 @@
#![recursion_limit = "256"]
use iris::{harness::Harness, prelude::*};
use pollster::FutureExt;
use std::sync::OnceLock;
use wgpu::TextureFormat;
const SIZE: Vec2 = Vec2::new(2.0, 1.0);
const SOLID: Srgba8 = Srgba8::rgb(17, 127, 231);
const CHANGED_SOLID: Srgba8 = Srgba8::rgb(243, 139, 168);
const IMAGE: Srgba8 = Srgba8::rgb(205, 214, 244);
/// Covers the whole colour path rather than a conversion helper: an sRGB
/// literal enters the linear paint buffer, an sRGB image is sampled as
/// linear, the Iris shader returns both, and the sRGB attachment encodes the
/// stored bytes. Replacing only the paint-table entry also proves that a
/// theme change reaches an already-retained primitive.
#[test]
fn solid_paints_and_images_round_trip_through_an_srgb_target() {
let gpu = Gpu::open();
let mut harness = Harness::new(SIZE, 1.0);
let solid = harness.rsc.ui.paints.add(SOLID);
let bitmap = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
1,
1,
image::Rgba([IMAGE.r, IMAGE.g, IMAGE.b, IMAGE.a]),
));
let bitmap = image::<iris::harness::HarnessRsc>(bitmap)(&mut harness.rsc);
let root = (rect(solid.clone()).sized((1, 1)), bitmap)
.span(Dir::RIGHT)
.add_strong(&mut harness.rsc)
.any();
harness.state.set_root(root);
harness.frame(0);
let mut renderer =
UiRenderNode::new(&gpu.device, &gpu.queue, TextureFormat::Rgba8UnormSrgb, SIZE)
.expect("the Iris pipeline should accept an sRGB render target");
let first = render(&gpu, &mut renderer, &mut harness);
assert_pixel(first[0], SOLID, "linear paint buffer -> sRGB attachment");
assert_pixel(first[1], IMAGE, "sRGB texture -> shader -> sRGB attachment");
harness.rsc.ui.paints.set(&solid, CHANGED_SOLID);
let changed = render(&gpu, &mut renderer, &mut harness);
assert_pixel(
changed[0],
CHANGED_SOLID,
"updated paint table -> retained primitive",
);
assert_pixel(changed[1], IMAGE, "unchanged image after paint update");
}
fn render(gpu: &Gpu, renderer: &mut UiRenderNode, harness: &mut Harness) -> [[u8; 4]; 2] {
renderer.update(
&gpu.device,
&gpu.queue,
&mut harness.rsc.ui,
&mut harness.render,
);
let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
label: Some("Iris colour-space target"),
size: wgpu::Extent3d {
width: 2,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let readback = gpu.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Iris colour-space readback"),
size: 256,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = gpu.device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Iris colour-space pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(LinearRgba::BLACK.to_wgpu()),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
renderer.draw(&mut pass);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
wgpu::Extent3d {
width: 2,
height: 1,
depth_or_array_layers: 1,
},
);
gpu.queue.submit([encoder.finish()]);
let slice = readback.slice(..);
slice.map_async(wgpu::MapMode::Read, |result| {
result.expect("mapping the colour-space readback")
});
gpu.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("waiting for the colour-space readback");
let mapped = slice
.get_mapped_range()
.expect("reading the mapped colour-space buffer");
let pixels = [
mapped[0..4].try_into().unwrap(),
mapped[4..8].try_into().unwrap(),
];
drop(mapped);
readback.unmap();
pixels
}
fn assert_pixel(got: [u8; 4], want: Srgba8, path: &str) {
let want = [want.r, want.g, want.b, want.a];
assert!(
got.into_iter()
.zip(want)
.all(|(got, want)| got.abs_diff(want) <= 1),
"{path}: stored {got:?}, expected {want:?} (within one code value)",
);
}
fn vulkan_instance() -> &'static wgpu::Instance {
static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
INSTANCE.get_or_init(wgpu::Instance::default)
}
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
fn open() -> Self {
let adapter = vulkan_instance()
.request_adapter(&wgpu::RequestAdapterOptions::default())
.block_on()
.expect("no wgpu adapter, so Iris's colour-space pipeline went unchecked");
let info = adapter.get_info();
eprintln!(
"color_space: {} ({:?}, {})",
info.name, info.backend, info.driver
);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
required_limits: iris_core::device_limits(),
..Default::default()
})
.block_on()
.expect("could not get a device from the adapter");
Self { device, queue }
}
}