Add retained paints and shared text selection

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

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ impl DefaultAppState for Client {
}
Err(message) => {
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(dp(16))
.add_strong(rsc)
+8 -8
View File
@@ -69,7 +69,7 @@ impl HasAndroidUiState for BenchClient {
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
@@ -124,7 +124,7 @@ impl AndroidAppState for BenchClient {
.text_align(Align::LEFT)
.wrap(true)
.size(14)
.color(Color::WHITE)
.color(PaintId::WHITE)
.attr::<Selectable>(())
.label("Benchmark report")
.add(rsc);
@@ -277,7 +277,7 @@ fn trace_line(at_start: bool, at_end: bool) -> String {
/// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
/// instead of a hole in the background the buttons happen to float in.
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than
@@ -289,7 +289,7 @@ const HEADER_TEXT: f32 = 18.0;
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Color::rgb(40, 70, 40))
let run_rect = rect(Srgba8::rgb(40, 70, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
@@ -307,7 +307,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
.pad(dp(8))
.add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60))
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
@@ -325,7 +325,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
.pad(dp(8))
.add(rsc);
let diag_rect = rect(Color::rgb(60, 45, 70))
let diag_rect = rect(Srgba8::rgb(60, 45, 70))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
@@ -351,9 +351,9 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
// either full of trace or has none.
let tracing = iris::diagnostics::trace_enabled();
let trace_rect = rect(if tracing {
Color::rgb(90, 70, 30)
Srgba8::rgb(90, 70, 30)
} else {
Color::rgb(50, 50, 60)
Srgba8::rgb(50, 50, 60)
})
.on(
CursorSense::click(),
+3 -3
View File
@@ -36,7 +36,7 @@ fn build_transport() -> Result<UreqTransport, String> {
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
@@ -45,7 +45,7 @@ fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
type Rsc = AndroidRsc<TranscriptClient>;
let report_rect = rect(Color::rgb(50, 50, 60))
let report_rect = rect(Srgba8::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
@@ -69,7 +69,7 @@ fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
.pad(8)
.add(rsc);
let reset_rect = rect(Color::rgb(70, 40, 40))
let reset_rect = rect(Srgba8::rgb(70, 40, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
+5 -5
View File
@@ -193,7 +193,7 @@ impl Client {
list(rsc).push(row);
}
let tree = list
.background(rect(Color::rgb(24, 24, 28)))
.background(rect(Srgba8::rgb(24, 24, 28)))
.add_strong(rsc)
.any();
(self.list_ptr)(rsc).set(tree);
@@ -299,14 +299,14 @@ fn session_row(
selected: bool,
) -> StrongWidget {
let bg = if selected {
Color::rgb(58, 90, 138)
Srgba8::rgb(58, 90, 138)
} else {
Color::rgb(38, 38, 44)
Srgba8::rgb(38, 38, 44)
};
let id = session.id.clone();
let label = format!("{}\n{}", session.title, session.status);
wtext(label)
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(10)
.width(rest(1))
@@ -323,7 +323,7 @@ fn session_row(
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
+4 -5
View File
@@ -1,11 +1,10 @@
use crate::ui::theme::Theme;
use iris::prelude::*;
const MAX_LINES: f32 = 6.0;
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
const FIELD_PAD_DP: f32 = 12.0;
const BAR_FILL: UiColor = UiColor::new(40, 40, 46, 255);
/// `field` is exposed so the caller can read its content on submit
/// (`field.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`).
@@ -43,7 +42,7 @@ impl Composer {
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
/// mistake this box's `row.rs` first made with its sender-label header, see
/// that file's comment for the fuller account.
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc) -> (Composer, WeakWidget)
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget)
where
Rsc::State: FocusHost,
{
@@ -52,7 +51,7 @@ where
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(UiColor::WHITE)
.color(theme.text.clone())
.attr::<Selectable>(())
.label("Message")
.add(rsc);
@@ -66,7 +65,7 @@ where
.pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.width(rest(1))
.masked_by(rect(BAR_FILL))
.masked_by(rect(theme.composer_surface.clone()))
.add(rsc);
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
+66 -49
View File
@@ -1,54 +1,36 @@
use crate::client::highlight::{self, Kind, Language};
use crate::client::markdown_blocks::{Block, BlockKind};
use crate::ui::theme::Theme;
use iris::prelude::*;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::ops::Range;
const fn mocha(hex: u32) -> UiColor {
UiColor::new(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
255,
)
}
pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
fn syntax_color(kind: Kind) -> UiColor {
fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
match kind {
Kind::Keyword => mocha(0xCBA6F7),
Kind::String => mocha(0xA6E3A1),
Kind::Literal => mocha(0xFAB387),
Kind::Comment => mocha(0x6C7086),
Kind::Metadata => mocha(0xF9E2AF),
Kind::Punctuation => mocha(0xA6ADC8),
Kind::Mark => mocha(0x89DCEB),
Kind::Keyword => theme.syntax_keyword.clone(),
Kind::String => theme.syntax_string.clone(),
Kind::Literal => theme.syntax_literal.clone(),
Kind::Comment => theme.syntax_comment.clone(),
Kind::Metadata => theme.syntax_metadata.clone(),
Kind::Punctuation => theme.syntax_punctuation.clone(),
Kind::Mark => theme.syntax_mark.clone(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockFrame {
Plain,
Verbatim { fill: UiColor },
Verbatim { fill: PaintId },
Quote,
}
pub fn frame_of(kind: BlockKind) -> BlockFrame {
pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
match kind {
BlockKind::Code => BlockFrame::Verbatim {
fill: VERBATIM_BACKGROUND,
fill: theme.verbatim_surface.clone(),
},
BlockKind::Table => BlockFrame::Verbatim {
fill: TABLE_BACKGROUND,
fill: theme.table_surface.clone(),
},
BlockKind::Quote => BlockFrame::Quote,
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
@@ -114,10 +96,10 @@ fn ensure_line(out: &mut String) {
}
}
pub fn render_block(block: &Block, base_size: f32) -> Rendered {
pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered {
match block.kind {
BlockKind::Table => table_text(&block.source),
_ => render_markdown(&block.source, base_size),
BlockKind::Table => table_text(&block.source, theme),
_ => render_markdown(&block.source, base_size, theme),
}
}
@@ -125,7 +107,7 @@ pub fn render_block(block: &Block, base_size: f32) -> Rendered {
/// style it. `base_size` is the row's ordinary paragraph font size, needed
/// only so a heading's override is relative to it rather than a hardcoded
/// absolute the caller cannot retune.
pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
pub fn render_markdown(src: &str, base_size: f32, theme: &Theme) -> Rendered {
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
let mut out = String::new();
let mut spans = Vec::new();
@@ -175,7 +157,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
}
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
}
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
}
Tag::List(first) => lists.push(first),
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
@@ -209,14 +191,18 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
TagEnd::Strikethrough => {
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
spans.push(SpanStyle::new(range).color(theme.strikethrough.clone()));
}
// An image draws as its alt text until the port has a
// transcript image widget (IRIS_TODO's "scaled
// thumbnail"); marked as a link so it is at least
// followable rather than silently inert.
TagEnd::Link | TagEnd::Image => {
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline());
spans.push(
SpanStyle::new(range.clone())
.color(theme.link.clone())
.underline(),
);
if let Some(url) = dest {
links.push(Link { range, url });
}
@@ -225,10 +211,10 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
spans.push(
SpanStyle::new(range.clone())
.family(Family::Monospace)
.color(CODE_COLOR),
.color(theme.code.clone()),
);
if let Some(language) = fence_language.take() {
highlight_into(&mut spans, &out, range, language);
highlight_into(&mut spans, &out, range, language, theme);
}
}
_ => unreachable!("filtered by the outer match arm"),
@@ -241,7 +227,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
spans.push(
SpanStyle::new(start..out.len())
.family(Family::Monospace)
.color(CODE_COLOR),
.color(theme.code.clone()),
);
}
Event::SoftBreak => out.push(' '),
@@ -253,7 +239,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
Event::TaskListMarker(done) => {
let start = out.len();
out.push_str(if done { "[x] " } else { "[ ] " });
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
}
Event::End(TagEnd::List(_)) => {
lists.pop();
@@ -282,6 +268,7 @@ pub(crate) fn highlight_into(
text: &str,
range: Range<usize>,
language: Language,
theme: &Theme,
) {
let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's
@@ -305,14 +292,14 @@ pub(crate) fn highlight_into(
spans.push(
SpanStyle::new(range.start + start..range.start + end)
.family(Family::Monospace)
.color(syntax_color(span.kind)),
.color(syntax_color(span.kind, theme)),
);
}
}
const TABLE_MAX_COL: usize = 28;
pub fn table_text(src: &str) -> Rendered {
pub fn table_text(src: &str, theme: &Theme) -> Rendered {
let rows = table_cells(src);
if rows.is_empty() {
return Rendered::default();
@@ -363,7 +350,7 @@ pub fn table_text(src: &str) -> Rendered {
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
let rule_start = out.len();
out.extend(std::iter::repeat_n('\u{2500}', rule));
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR));
spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone()));
}
}
Rendered {
@@ -421,6 +408,36 @@ mod tests {
use super::*;
use crate::client::markdown_blocks::split_blocks;
fn with_theme<T>(f: impl FnOnce(&Theme) -> T) -> T {
let mut paints = Paints::new();
let theme = Theme::new(&mut paints);
f(&theme)
}
fn render_markdown(src: &str, base_size: f32) -> Rendered {
with_theme(|theme| super::render_markdown(src, base_size, theme))
}
fn render_block(block: &Block, base_size: f32) -> Rendered {
with_theme(|theme| super::render_block(block, base_size, theme))
}
fn frame_of(kind: BlockKind) -> BlockFrame {
with_theme(|theme| super::frame_of(kind, theme))
}
fn syntax_color(kind: Kind) -> PaintId {
with_theme(|theme| super::syntax_color(kind, theme))
}
fn code_color() -> PaintId {
with_theme(|theme| theme.code.clone())
}
fn marker_color() -> PaintId {
with_theme(|theme| theme.marker.clone())
}
fn block(src: &str) -> Rendered {
let blocks = split_blocks(src);
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
@@ -509,7 +526,7 @@ mod tests {
assert_eq!(r.text, "let x = 1;");
assert_eq!(r.spans.len(), 1);
assert!(r.spans[0].family == Some(Family::Monospace));
assert_eq!(r.spans[0].color, Some(CODE_COLOR));
assert_eq!(r.spans[0].color, Some(code_color()));
}
#[test]
@@ -550,7 +567,7 @@ mod tests {
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(MARKER_COLOR))
.filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
@@ -563,7 +580,7 @@ mod tests {
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(MARKER_COLOR))
.filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["3. ", "4. "]);
+43 -141
View File
@@ -7,14 +7,14 @@ pub mod composer;
pub mod fixture;
pub mod markdown;
pub mod row;
pub mod selection;
pub(crate) mod tap;
pub mod theme;
pub mod tool;
use crate::client::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*;
use selection::Selection;
use std::{cell::RefCell, rc::Rc};
use theme::Theme;
pub struct TranscriptScreen {
/// The transcript's own `LazySpan` -- the layout *and* the scroll
@@ -25,10 +25,10 @@ pub struct TranscriptScreen {
/// `.jump_to_end()` directly.
pub list: WeakWidget<LazySpan>,
pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>,
rebuilds: std::cell::Cell<usize>,
tail: RefCell<Option<(RowKey, row::TailRow)>>,
session_working: std::cell::Cell<bool>,
theme: Rc<Theme>,
}
impl TranscriptScreen {
@@ -48,10 +48,10 @@ impl TranscriptScreen {
let (key, widget, tail) = row::build_row(
rsc,
self.list,
self.selection.clone(),
row,
self.session_working.get(),
true,
self.theme.clone(),
);
(self.list)(rsc).push_back(LazyItem::new(key, widget));
*self.tail.borrow_mut() = tail.map(|t| (key, t));
@@ -122,14 +122,7 @@ impl TranscriptScreen {
) {
return false;
}
blocks.apply_delta(
rsc,
self.list,
self.selection.clone(),
key,
sender,
&markdown_src,
)
blocks.apply_delta(rsc, sender, &markdown_src)
}
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
tools.apply_calls(rsc, calls, self.session_working.get())
@@ -179,14 +172,13 @@ impl TranscriptScreen {
return;
}
self.selection.borrow_mut().unregister(old_key);
let (new_key, widget, kept) = row::build_row(
rsc,
self.list,
self.selection.clone(),
&new_rows[common],
self.session_working.get(),
false,
self.theme.clone(),
);
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget));
drop(evicted); // frees the old row's widget, same as a pop would
@@ -197,7 +189,6 @@ impl TranscriptScreen {
}
RowDiff::Rebuild => {
self.rebuilds.set(self.rebuilds.get() + 1);
self.selection.borrow_mut().clear();
(self.list)(rsc).clear();
*self.tail.borrow_mut() = None;
for row in &new_rows {
@@ -211,10 +202,23 @@ impl TranscriptScreen {
self.rebuilds.replace(0)
}
/// The semantic paint IDs used by this screen. A caller can replace
/// their entries through `rsc.ui_mut().paints.set(...)`; retained text
/// and rect primitives keep the IDs and need no widget rebuild.
pub fn theme(&self) -> &Theme {
&self.theme
}
/// The concatenated text of whatever is currently selected across one
/// or more rows, `None` if nothing is -- what a copy command reads.
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> {
self.selection.borrow().selected_text(rsc)
pub fn selected_text<Rsc: HasEvents>(&self, rsc: &mut Rsc) -> Option<String> {
let id = rsc
.events()
.controllers
.id::<SelectionController>(self.list.id())?;
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.selected_text(rsc)
})?
}
}
@@ -238,8 +242,14 @@ pub fn build_tree<Rsc: HasEvents>(
where
Rsc::State: FocusHost + OpenUrl,
{
let selection = Rc::new(RefCell::new(Selection::new()));
let theme = Rc::new(Theme::new(&mut rsc.ui_mut().paints));
let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc);
list.controller(
SelectionController::new()
.with_scroll(list)
.separator("\n\n"),
)
.add(rsc);
// The last row's block widgets are kept for the same reason
// `push_row` keeps them: a reply that is *already* streaming when the
@@ -258,39 +268,24 @@ where
// be a reply already streaming when this screen opened, and a
// capped row cannot take a delta (`RowBlocks::capped`).
let cap = i + 1 < rows.len();
let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false, cap);
let (key, widget, kept) = row::build_row(rsc, list, row, false, cap, theme.clone());
list(rsc).push_back(LazyItem::new(key, widget));
tail = kept.map(|t| (key, t));
}
// The continuation of a row-started drag once it has committed and
// taken pointer capture on `list`'s own id (`row.rs`'s registration is
// only ever the gesture's first frame) -- registered once here, not
// once per row, since `DragGesture`'s single shared instance must see
// each frame of one gesture exactly once. `ctx.data.pos`/`size` are
// already relative to `list`'s own on-screen box (this is what it was
// registered against), which is exactly the viewport-pixel space
// `LazySpan::key_at`/`extent` work in, so the row-under-the-pointer is
// resolved from those instead of a per-row hit test.
// The controller host covers gaps as well as text, so a tap anywhere in
// the transcript can dismiss a selection. Text and link listeners may
// see the same physical sample; `SelectionController` deduplicates it by
// the sample's own timestamp while still returning the same tap decision
// to whichever leaf owns the link action.
{
let selection = selection.clone();
list.on(
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop | CursorSense::Cancel,
move |ctx, rsc| {
let row = selection
.borrow()
.locate(&*rsc, ctx.data.render, ctx.data.cursor.pos);
selection.borrow_mut().drag(
rsc,
list,
row,
ctx.data.cursor.pos,
ctx.data.sense,
ctx.data.cursor.time,
ctx.data.pointer,
);
},
)
list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let input = &ctx.data;
rsc.with_nearest_controller::<SelectionController, _>(
list,
|id, selection, rsc| selection.drag(id, rsc, input),
);
})
.add(rsc);
}
@@ -299,9 +294,8 @@ where
ctx.widget(rsc).scroll(delta);
})
.add(rsc);
selection.borrow_mut().set_scroll_area(list);
let (composer, composer_bar) = composer::build_composer(rsc);
let (composer, composer_bar) = composer::build_composer(rsc, &theme);
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
.span(Dir::DOWN)
@@ -314,8 +308,8 @@ where
session_working: std::cell::Cell::new(false),
list,
composer,
selection,
rebuilds: std::cell::Cell::new(0),
theme,
},
tree,
)
@@ -503,21 +497,6 @@ mod apply_tests {
}
}
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
TranscriptItem::ToolRun {
seq,
id: format!("id{seq}"),
run_id: run_id.to_string(),
tool: "grep".to_string(),
input: "x".to_string(),
output: String::new(),
done: false,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}
}
fn assistant(seq: u64, text: &str) -> TranscriptItem {
TranscriptItem::AssistantMsg {
seq,
@@ -582,83 +561,6 @@ mod apply_tests {
);
}
#[test]
fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() {
use crate::client::transcript_fold::group_tool_runs;
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let old_items = vec![tool(1, "run-a"), user(2, "meanwhile"), user(4, "stable")];
let new_items = vec![tool(1, "run-a"), tool(3, "run-a"), user(4, "stable")];
assert_eq!(
diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)),
RowDiff::Rebuild,
"test setup must actually exercise the Rebuild arm"
);
let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
screen.apply(&mut rsc, &old_items, &new_items);
let surviving_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(4));
screen.selection.borrow_mut().begin(
&mut rsc,
(surviving_key, 0),
Vec2::ZERO,
Vec2::new(10.0, 10.0),
);
}
#[test]
fn a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection() {
use crate::client::transcript_fold::group_tool_runs;
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let old_items = vec![user(1, "stable"), assistant(2, "# Head\n\npara\n\n- item")];
let new_items = vec![user(1, "stable"), assistant(2, "short")];
assert_eq!(
diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)),
RowDiff::ReplaceLast { common: 1 },
"test setup must actually exercise the ReplaceLast arm"
);
let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
let tail_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(2));
assert_eq!(
screen
.selection
.borrow()
.registered_blocks(tail_key)
.count(),
3,
"the fixture must start with more blocks than it ends with"
);
screen.apply(&mut rsc, &old_items, &new_items);
assert_eq!(
screen
.selection
.borrow()
.registered_blocks(tail_key)
.count(),
1,
"the blocks the rebuild dropped are still registered"
);
let surviving_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(1));
screen.selection.borrow_mut().begin(
&mut rsc,
(surviving_key, 0),
Vec2::ZERO,
Vec2::new(10.0, 10.0),
);
}
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 1,
+44 -60
View File
@@ -2,8 +2,8 @@ use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_bloc
use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label};
use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::ui::selection::{SelKey, Selection};
use crate::ui::tap::{hold_edge, on_tap};
use crate::ui::theme::Theme;
use crate::ui::tool::ToolRow;
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
@@ -93,7 +93,7 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
pub struct RowBlocks {
blocks: Vec<Block>,
fields: Vec<WeakWidget<TextEdit>>,
fields: Vec<WeakWidget<Text>>,
links: Vec<Rc<RefCell<Vec<Link>>>>,
column: WeakWidget<Span>,
sender: Option<String>,
@@ -108,6 +108,7 @@ pub struct RowBlocks {
/// keeps the refusal from costing anything in practice. This field is
/// the belt to that braces.
capped: bool,
theme: Rc<Theme>,
}
/// Split for display: never empty, so a row with nothing in it yet is
@@ -181,21 +182,18 @@ const FRAME_RADIUS_DP: f32 = 8.0;
fn build_block<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: SelKey,
block: &Block,
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
theme: &Theme,
) -> (WeakWidget<Text>, StrongWidget, Rc<RefCell<Vec<Link>>>)
where
Rsc::State: FocusHost + OpenUrl,
{
let frame = frame_of(block.kind);
let rendered = render_block(block, BASE_SIZE);
let frame = frame_of(block.kind, theme);
let rendered = render_block(block, BASE_SIZE, theme);
let links = Rc::new(RefCell::new(rendered.links));
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
let field = wtext(rendered.text)
.spans(rendered.spans)
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(!verbatim)
.family(if verbatim {
@@ -205,31 +203,28 @@ where
})
.size(BASE_SIZE)
.color(match frame {
BlockFrame::Quote => crate::ui::markdown::QUOTE_TEXT_COLOR,
_ => crate::ui::markdown::TEXT_COLOR,
BlockFrame::Quote => theme.quote_text.clone(),
_ => theme.text.clone(),
})
.add(rsc);
selection.borrow_mut().register(key, field);
let tap_links = links.clone();
field
.on(CursorSense::drag_senses(), move |ctx, rsc| {
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
let outcome = selection.borrow_mut().drag(
rsc,
list,
Some((key, pos, size)),
cursor,
ctx.data.sense,
ctx.data.cursor.time,
ctx.data.pointer,
);
.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let (pos, size) = (ctx.data.pos, ctx.data.size);
let input = &ctx.data;
let outcome = rsc
.with_nearest_controller::<SelectionController, _>(
field,
|id, selection, rsc| selection.drag(id, rsc, input),
)
.unwrap_or(SelectionInput::Tapped);
// A *tap*, decided by the same `DragArbiter` the pan and
// the selection are: a gesture that panned the list past
// this link, or held long enough to select, must not also
// follow it (`GestureOutcome::Tapped`'s doc).
if outcome == GestureOutcome::Tapped {
let byte = field.edit(rsc).byte_at(cursor, size);
if outcome == SelectionInput::Tapped {
let byte = field.selection(rsc).byte_at(pos, size);
let url = tap_links
.borrow()
.iter()
@@ -258,7 +253,7 @@ where
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
..Padding::ZERO
})
.background(rect(crate::ui::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP)))
.background(rect(theme.quote_bar.clone()).width(dp(QUOTE_BAR_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
@@ -266,14 +261,15 @@ where
(field, framed, links)
}
#[allow(clippy::too_many_arguments)]
fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
markdown_src: &str,
cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
@@ -284,7 +280,7 @@ where
});
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
let (content, blocks) = row_content(rsc, list, selection, key, source, ptr, cap);
let (content, blocks) = row_content(rsc, list, key, source, ptr, cap, theme);
ptr(rsc).set(content);
(strong.any(), blocks)
}
@@ -292,14 +288,15 @@ where
/// Separate from [`build_text_row`] because the tap calls it a second
/// time, with `cap` false, and writes the result back into the same
/// `WidgetPtr`.
#[allow(clippy::too_many_arguments)]
fn row_content<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
@@ -308,9 +305,8 @@ where
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
let mut fields = Vec::with_capacity(blocks.len());
let mut links = Vec::with_capacity(blocks.len());
for (i, block) in blocks.iter().enumerate() {
let (field, framed, block_links) =
build_block(rsc, list, selection.clone(), (key, i as u32), block);
for block in &blocks {
let (field, framed, block_links) = build_block(rsc, block, &theme);
fields.push(field);
links.push(block_links);
column.push(framed);
@@ -319,11 +315,11 @@ where
column.push(show_all(
rsc,
list,
selection.clone(),
key,
source.clone(),
ptr,
lines,
theme.clone(),
));
}
let column = column.add(rsc);
@@ -339,7 +335,7 @@ where
let header: WeakWidget = match &source.sender {
Some(name) => wtext(name.clone())
.size(13.0)
.color(UiColor::new(150, 150, 160, 255))
.color(theme.secondary_text.clone())
.add(rsc),
None => Span::empty(Dir::DOWN).add(rsc),
};
@@ -359,6 +355,7 @@ where
column,
sender: source.sender.clone(),
capped: hidden.is_some(),
theme,
},
)
}
@@ -370,11 +367,11 @@ where
fn show_all<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
lines: usize,
theme: Rc<Theme>,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
@@ -384,22 +381,15 @@ where
let more = more_strong.weak();
let words = wtext(label.clone())
.size(13.0)
.color(UiColor::new(150, 150, 160, 255))
.color(theme.secondary_text.clone())
.text_align(Align::LEFT)
.label(label)
.add_strong(rsc);
more(rsc).set(words);
on_tap(rsc, more, list, selection.clone(), move |rsc| {
on_tap(rsc, more, list, move |rsc| {
hold_edge(rsc, list, key);
let (content, _blocks) = row_content(
rsc,
list,
selection.clone(),
key,
source.clone(),
ptr,
false,
);
let (content, _blocks) =
row_content(rsc, list, key, source.clone(), ptr, false, theme.clone());
let _old = ptr(rsc).replace(content);
});
more_strong.any()
@@ -415,9 +405,6 @@ impl RowBlocks {
pub fn apply_delta<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
markdown_src: &str,
) -> bool
@@ -461,15 +448,12 @@ impl RowBlocks {
for (i, block) in new_blocks.iter().enumerate().skip(common) {
match (self.fields.get(i), self.links.get(i)) {
(Some(field), Some(links)) => {
let rendered = render_block(block, BASE_SIZE);
field
.edit(rsc)
.set_with_spans(&rendered.text, rendered.spans);
let rendered = render_block(block, BASE_SIZE, &self.theme);
field(rsc).set_with_spans(rendered.text, rendered.spans);
*links.borrow_mut() = rendered.links;
}
_ => {
let (field, framed, links) =
build_block(rsc, list, selection.clone(), (key, i as u32), block);
let (field, framed, links) = build_block(rsc, block, &self.theme);
self.fields.push(field);
self.links.push(links);
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
@@ -486,16 +470,16 @@ impl RowBlocks {
fn build_single<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
item: &TranscriptItem,
cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
{
let (sender, markdown_src) = item_content(item);
build_text_row(rsc, list, selection, key, sender, &markdown_src, cap)
build_text_row(rsc, list, key, sender, &markdown_src, cap, theme)
}
/// Two mechanisms would have been two answers to the same question ("what
@@ -515,10 +499,10 @@ pub enum TailRow {
pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
row: &FoldedRow,
working: bool,
cap: bool,
theme: Rc<Theme>,
) -> (RowKey, StrongWidget, Option<TailRow>)
where
Rsc::State: FocusHost + OpenUrl,
@@ -537,14 +521,14 @@ where
if let Some(calls) = calls {
let key = row_key(&calls[0].key());
let (widget, tools) =
crate::ui::tool::build_tool_row(rsc, list, selection, key, calls.to_vec(), working);
crate::ui::tool::build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
return (key, widget, Some(TailRow::Tools(tools)));
}
let FoldedRow::Single(item) = row else {
unreachable!("every Tools row took the branch above");
};
let key = row_key(&item.key());
let (widget, blocks) = build_single(rsc, list, selection, key, item, cap);
let (widget, blocks) = build_single(rsc, list, key, item, cap, theme);
(key, widget, Some(TailRow::Blocks(blocks)))
}
-378
View File
@@ -1,378 +0,0 @@
use iris::prelude::*;
use std::{collections::BTreeMap, time::Instant};
pub type SelKey = (RowKey, u32);
pub struct Selection {
rows: BTreeMap<SelKey, WeakWidget<TextEdit>>,
anchor: Option<(SelKey, Vec2)>,
gesture: DragGesture,
scroll: Option<WeakWidget<LazySpan>>,
}
impl Default for Selection {
fn default() -> Self {
Self::new()
}
}
impl Selection {
pub fn new() -> Self {
Self {
rows: BTreeMap::new(),
anchor: None,
gesture: DragGesture::new(),
scroll: None,
}
}
pub fn set_scroll_area(&mut self, scroll: WeakWidget<LazySpan>) {
self.scroll = Some(scroll);
}
/// A row's selectable text became visible/known. Every addition here
/// needs its removal (`unregister`, or `clear` for all of them at
/// once) -- called when `LazySpan` evicts the row (`pop_front`/
/// `pop_back`/`clear`), so this map never outgrows however many rows
/// are actually loaded. `LazySpan::place` guards the twin of this same
/// class of bug on the list's own side (`lazy_span.rs`'s `slot_exists`
/// assertion) -- a derived handle that silently outlives what it
/// points to; the next caller adding a third row-keyed side table
/// should read both.
pub fn register(&mut self, key: SelKey, text: WeakWidget<TextEdit>) {
self.rows.insert(key, text);
}
pub fn clear(&mut self) {
self.rows.clear();
self.anchor = None;
}
pub fn unregister(&mut self, row: RowKey) {
self.rows.retain(|&(k, _), _| k != row);
if self.anchor.map(|((k, _), _)| k) == Some(row) {
self.anchor = None;
}
}
/// A fresh press: clears whatever was selected elsewhere (an ordinary
/// click starts a new selection, it does not extend the old one) and
/// gives `key`'s row a collapsed caret at `pos` -- a plain click that
/// never turns into a drag leaves exactly this and nothing else
/// selected.
pub fn begin(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
let rows: Vec<SelKey> = self.rows.keys().copied().collect();
for k in rows {
if k != key
&& let Some(w) = self.rows.get(&k)
{
w.edit(ui).deselect();
}
}
if let Some(w) = self.rows.get(&key) {
w.edit(ui).select(pos, size, false, false);
}
self.anchor = Some((key, pos));
}
pub fn extend(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
let Some((anchor_key, _anchor_pos)) = self.anchor else {
return;
};
if key == anchor_key {
if let Some(w) = self.rows.get(&key) {
w.edit(ui).select(pos, size, true, false);
}
return;
}
let (lo, hi) = if anchor_key < key {
(anchor_key, key)
} else {
(key, anchor_key)
};
let in_range: Vec<SelKey> = self.rows.range(lo..=hi).map(|(&k, _)| k).collect();
for k in &in_range {
let Some(w) = self.rows.get(k).copied() else {
continue;
};
if *k == key {
let start = if key > anchor_key { Vec2::ZERO } else { size };
w.edit(ui).select(start, size, false, false);
w.edit(ui).select(pos, size, true, false);
} else {
w.edit(ui).select_all();
}
}
let outside: Vec<SelKey> = self
.rows
.keys()
.copied()
.filter(|k| *k < lo || *k > hi)
.collect();
for k in outside {
if let Some(w) = self.rows.get(&k) {
w.edit(ui).deselect();
}
}
}
/// The block indices currently registered for `row`, in order. For a
/// test asserting that a row's removal or rebuild took every one of
/// its blocks with it -- the contract `unregister` states and the one
/// a caller can get wrong silently, since a stale handle only shows
/// up as a panic on some later, unrelated press.
#[cfg(test)]
pub fn registered_blocks(&self, row: RowKey) -> impl Iterator<Item = u32> + '_ {
self.rows
.keys()
.filter(move |(k, _)| *k == row)
.map(|&(_, b)| b)
}
pub fn locate(
&self,
ui: &impl UiRsc,
render: &UiRenderState,
pos_window: Vec2,
) -> Option<(SelKey, Vec2, Vec2)> {
for (&key, w) in &self.rows {
let Some(px) = render.window_region(w, ui) else {
continue;
};
if px.contains(pos_window) {
return Some((key, pos_window - px.top_left, px.size()));
}
}
None
}
fn has_selection(&self, ui: &mut impl UiRsc) -> bool {
self.rows
.values()
.any(|w| w.edit(ui).text.selected_text().is_some())
}
/// `row`, if given, is `(key, pos_row, size)` for whichever row the
/// pointer is currently over -- row-local, as `begin`/`extend` want.
/// `None` once the gesture is pointer-captured (`iris::sense`'s
/// pointer-capture doc) and the current position falls outside every
/// row `LazySpan` has loaded (a gap, or off the end of the content); a
/// `Pan` outcome never needs it, so this only actually matters mid-
/// selection, where it is rare and the frame is simply dropped.
/// `pos_window` is in window space, since a pan's delta has to stay
/// meaningful even when this frame's event landed on a different row
/// than the last one. `pointer` is `CursorData`'s own field -- what
/// `DragGesture` needs to take pointer capture.
#[allow(clippy::too_many_arguments)]
/// Returns what the gesture decided this frame, so a caller with its
/// own meaning for a *tap* -- a row's link handler -- reads it from
/// the one arbiter that already knows, rather than timing a second
/// one beside it (which would disagree the moment either changed).
pub fn drag(
&mut self,
ui: &mut impl UiRsc,
list: WeakWidget<LazySpan>,
row: Option<(SelKey, Vec2, Vec2)>,
pos_window: Vec2,
sense: CursorSense,
now: Instant,
pointer: &PointerRequests,
) -> GestureOutcome {
debug_assert!(
self.scroll.is_some(),
"Selection::drag with no scroll area: a committed pan would be silently dropped -- \
call `set_scroll_area` after building the transcript's `LazySpan`"
);
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = self.scroll.is_some_and(|s| s(ui).is_scrolling());
if let Some(scroll) = self.scroll {
scroll(ui).cancel_fling();
}
}
press.already_selected = self.has_selection(ui);
let outcome = self
.gesture
.handle(pointer, list.id(), sense, pos_window, now, press);
match outcome {
// Somebody else took the gesture (a code fence panning
// sideways under the finger). Nothing here acted on it, and
// `DragGesture` has already forgotten it, so there is nothing
// to undo either -- the point is that no tap, fling or
// selection follows from a gesture that was never ours.
GestureOutcome::Cancelled | GestureOutcome::Undecided => {}
GestureOutcome::Pan(dy) => {
if let Some(scroll) = self.scroll {
scroll(ui).scroll(dy);
}
}
GestureOutcome::SelectStart => {
if let Some((key, pos_row, size)) = row {
log::info!("iris selection: begin at row {key:?}");
self.begin(ui, key, pos_row, size);
}
}
GestureOutcome::SelectExtend => {
if let Some((key, pos_row, size)) = row {
log::info!("iris selection: extend to row {key:?}");
self.extend(ui, key, pos_row, size);
}
}
// A fling only ever follows a pan -- never a selection that
// happened to end with the finger still moving, and never a
// tap/long-press that never left `Undecided` -- exactly what
// `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => {
// The half that actually makes it move -- see
// `ScrollController::fling`'s doc. Without it the velocity is
// computed, stored, and never advanced by anything.
if let Some(scroll) = self.scroll
&& scroll(ui).fling(v)
{
let id = scroll.id();
ui.ui_mut().animate(id);
}
}
GestureOutcome::Released(None) | GestureOutcome::Tapped => {}
}
outcome
}
/// The concatenated selected text, in row order, `None` if nothing is
/// selected -- what a copy command reads. Joins with a blank line
/// between rows, matching how the transcript itself separates them.
pub fn selected_text(&self, ui: &mut impl UiRsc) -> Option<String> {
let mut parts = Vec::new();
for w in self.rows.values() {
if let Some(text) = w.edit(ui).text.selected_text() {
parts.push(text);
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn in_range(anchor: RowKey, current: RowKey, keys: &[RowKey]) -> Vec<RowKey> {
let (lo, hi) = if anchor < current {
(anchor, current)
} else {
(current, anchor)
};
keys.iter()
.copied()
.filter(|k| *k >= lo && *k <= hi)
.collect()
}
#[test]
fn selection_spans_forward_across_rows() {
let keys = [1, 2, 3, 4, 5];
assert_eq!(in_range(2, 4, &keys), vec![2, 3, 4]);
}
#[test]
fn selection_spans_backward_across_rows() {
let keys = [1, 2, 3, 4, 5];
assert_eq!(in_range(4, 2, &keys), vec![2, 3, 4]);
}
#[test]
fn selection_within_one_row_is_just_that_row() {
let keys = [1, 2, 3];
assert_eq!(in_range(2, 2, &keys), vec![2]);
}
struct TestRsc {
ui: UiData,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
}
#[test]
fn a_missed_press_start_recovers_on_the_next_pressing_frame() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let field = rsc
.ui
.widgets
.add_strong(TextEdit::new(
TextView::new(TextBuffer::new_empty(), TextAttrs::default(), None),
EditMode::MultiLine,
))
.weak();
let list = rsc
.ui
.widgets
.add_strong(LazySpan::new(Dir::DOWN, Pin::End));
let list_weak = list.weak();
let scroll = rsc
.ui
.widgets
.add_strong(LazySpan::new(Dir::DOWN, Pin::End))
.weak();
let list = list_weak;
let mut sel = Selection::new();
sel.set_scroll_area(scroll);
sel.register((1, 0), field);
assert!(sel.gesture.is_idle());
let pointer = PointerRequests::default();
let now = Instant::now();
let size = Vec2::new(100.0, 20.0);
sel.drag(
&mut rsc,
list,
Some(((1, 0), Vec2::ZERO, size)),
Vec2::new(540.0, 700.0),
CursorSense::Pressing(CursorButton::Left),
now,
&pointer,
);
assert!(
!sel.gesture.is_idle(),
"a Pressing frame with the arbiter still Idle must recover \
the press rather than leaving it stuck"
);
}
#[test]
fn unregister_forgets_every_block_of_the_row_and_clears_a_matching_anchor() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let field = rsc
.ui
.widgets
.add_strong(TextEdit::new(
TextView::new(TextBuffer::new_empty(), TextAttrs::default(), None),
EditMode::MultiLine,
))
.weak();
let mut sel = Selection::new();
sel.register((5, 0), field);
sel.register((5, 1), field);
sel.anchor = Some(((5, 1), Vec2::ZERO));
assert_eq!(sel.rows.len(), 2);
sel.unregister(5);
assert!(sel.rows.is_empty());
assert!(sel.anchor.is_none());
}
}
+9 -14
View File
@@ -1,27 +1,22 @@
use crate::ui::selection::Selection;
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
pub(crate) fn on_tap<Rsc: HasEvents>(
rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
f: impl Fn(&mut Rsc) + 'static,
) where
Rsc::State: FocusHost + OpenUrl,
{
ptr.on(CursorSense::drag_senses(), move |ctx, rsc| {
let outcome = selection.borrow_mut().drag(
rsc,
list,
None,
ctx.data.cursor.pos,
ctx.data.sense,
ctx.data.cursor.time,
ctx.data.pointer,
);
if outcome == GestureOutcome::Tapped {
ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let input = &ctx.data;
let outcome = rsc
.with_nearest_controller::<SelectionController, _>(
list,
|id, selection, rsc| selection.drag(id, rsc, input),
)
.unwrap_or(SelectionInput::Tapped);
if outcome == SelectionInput::Tapped {
f(rsc);
}
})
+70
View File
@@ -0,0 +1,70 @@
use iris::prelude::*;
/// The shared phone/desktop paint handles. Replacing their paint-table
/// entries changes the theme without rebuilding widgets or primitives.
#[derive(Clone)]
pub struct Theme {
pub text: PaintId,
pub code: PaintId,
pub link: PaintId,
pub marker: PaintId,
pub verbatim_surface: PaintId,
pub table_surface: PaintId,
pub quote_bar: PaintId,
pub quote_text: PaintId,
pub strikethrough: PaintId,
pub card_surface: PaintId,
pub group_surface: PaintId,
pub muted: PaintId,
pub awaiting: PaintId,
pub failed: PaintId,
pub unknown: PaintId,
pub composer_surface: PaintId,
pub secondary_text: PaintId,
pub syntax_keyword: PaintId,
pub syntax_string: PaintId,
pub syntax_literal: PaintId,
pub syntax_comment: PaintId,
pub syntax_metadata: PaintId,
pub syntax_punctuation: PaintId,
pub syntax_mark: PaintId,
}
impl Theme {
pub fn new(paints: &mut Paints) -> Self {
Self {
text: paints.add(srgb(0xCDD6F4)),
code: paints.add(srgb(0xCDD6F4)),
link: paints.add(srgb(0x89B4FA)),
marker: paints.add(srgb(0xB4BEFE)),
verbatim_surface: paints.add(srgb(0x11111B)),
table_surface: paints.add(srgb(0x313244)),
quote_bar: paints.add(srgb(0x585B70)),
quote_text: paints.add(srgb(0xA6ADC8)),
strikethrough: paints.add(srgb(0x6C7086)),
card_surface: paints.add(srgb(0x313244)),
group_surface: paints.add(srgb(0x181825)),
muted: paints.add(srgb(0xA6ADC8)),
awaiting: paints.add(srgb(0xFAB387)),
failed: paints.add(srgb(0xF38BA8)),
unknown: paints.add(srgb(0xF9E2AF)),
composer_surface: paints.add(Srgba8::rgb(40, 40, 46)),
secondary_text: paints.add(Srgba8::rgb(150, 150, 160)),
syntax_keyword: paints.add(srgb(0xCBA6F7)),
syntax_string: paints.add(srgb(0xA6E3A1)),
syntax_literal: paints.add(srgb(0xFAB387)),
syntax_comment: paints.add(srgb(0x6C7086)),
syntax_metadata: paints.add(srgb(0xF9E2AF)),
syntax_punctuation: paints.add(srgb(0xA6ADC8)),
syntax_mark: paints.add(srgb(0x89DCEB)),
}
}
}
const fn srgb(hex: u32) -> Srgba8 {
Srgba8::rgb(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
+95 -102
View File
@@ -1,27 +1,12 @@
use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label};
use crate::client::tool_summary::{ToolInput, parse_tool_input};
use crate::client::transcript_fold::{ToolState, TranscriptItem};
use crate::ui::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into};
use crate::ui::selection::Selection;
use crate::ui::markdown::highlight_into;
use crate::ui::tap::{hold_edge, on_tap};
use crate::ui::theme::Theme;
use iris::prelude::*;
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
const CARD_FILL: UiColor = UiColor::new(0x31, 0x32, 0x44, 255);
/// The surface a group's cards sit on: Mantle, one step *below* the page.
/// That surface is the single cue saying these calls belong together, and
/// it goes below rather than above because the cards are already above --
/// two steps in the same direction render as one flat block.
const GROUP_FILL: UiColor = UiColor::new(0x18, 0x18, 0x25, 255);
const NAME_COLOR: UiColor = TEXT_COLOR;
const MUTED_COLOR: UiColor = UiColor::new(0xA6, 0xAD, 0xC8, 255);
/// Waiting on a person -- Peach, `Theme.kt`'s `awaitingColor`. The same
/// colour a question card takes, because it is the same fact.
const AWAITING_COLOR: UiColor = UiColor::new(0xFA, 0xB3, 0x87, 255);
const FAILED_COLOR: UiColor = UiColor::new(0xF3, 0x8B, 0xA8, 255);
const UNKNOWN_COLOR: UiColor = UiColor::new(0xF9, 0xE2, 0xAF, 255);
const NAME_SIZE: f32 = 14.0;
const BODY_SIZE: f32 = 12.0;
const LABEL_SIZE: f32 = 11.0;
@@ -58,9 +43,9 @@ struct Shared {
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
working: Cell<bool>,
theme: Rc<Theme>,
}
/// One transcript row's worth of tool calls, kept by the caller for the
@@ -71,18 +56,18 @@ pub struct ToolRow {
shared: Rc<Shared>,
}
fn text<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuilder<Rsc> {
fn text<Rsc>(content: impl Into<String>, size: f32, color: PaintId) -> TextBuilder<Rsc> {
wtext(content)
.size(size)
.color(color)
.text_align(Align::LEFT)
}
fn disclosure<Rsc>(glyph: &'static str) -> TextBuilder<Rsc> {
text(glyph, MARK_DP, MUTED_COLOR).family(Family::Icons)
fn disclosure<Rsc>(glyph: &'static str, theme: &Theme) -> TextBuilder<Rsc> {
text(glyph, MARK_DP, theme.muted.clone()).family(Family::Icons)
}
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>) -> StrongWidget
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, theme: &Theme) -> StrongWidget
where
Rsc::State: FocusHost,
{
@@ -94,22 +79,36 @@ where
field
.scrollable(Axis::X, Pin::Start)
.pad(dp(RAW_PAD_DP))
.masked_by(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
.masked_by(rect(theme.verbatim_surface.clone()).radius(dp(RAW_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
fn state_mark(state: ToolState) -> Option<(&'static str, UiColor)> {
fn state_word(state: ToolState) -> Option<&'static str> {
match state {
ToolState::Deciding => Some(("your turn", AWAITING_COLOR)),
ToolState::Running => Some(("running", MUTED_COLOR)),
ToolState::Failed => Some(("failed", FAILED_COLOR)),
ToolState::NoResult => Some(("no result", UNKNOWN_COLOR)),
ToolState::Deciding => Some("your turn"),
ToolState::Running => Some("running"),
ToolState::Failed => Some("failed"),
ToolState::NoResult => Some("no result"),
ToolState::Succeeded => None,
}
}
fn state_mark(state: ToolState, theme: &Theme) -> Option<(&'static str, PaintId)> {
let color = match state {
ToolState::Deciding => theme.awaiting.clone(),
ToolState::Running => theme.muted.clone(),
ToolState::Failed => theme.failed.clone(),
ToolState::NoResult => theme.unknown.clone(),
ToolState::Succeeded => return None,
};
Some((
state_word(state).expect("non-success state has a label"),
color,
))
}
/// What a screen reader is given for one card, and what a `ui-trace`
/// script taps by: the tool, what the call is for, and how it went when
/// that is anything but "fine" -- the same three things the Compose card's
@@ -120,7 +119,7 @@ fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
name.push_str(": ");
name.push_str(title);
}
if let Some((word, _)) = state_mark(state) {
if let Some(word) = state_word(state) {
name.push_str(" (");
name.push_str(word);
name.push(')');
@@ -170,27 +169,21 @@ where
let label = show_all_label(lines);
let more_strong = WidgetPtr::new().add_strong(rsc);
let more = more_strong.weak();
let words = text(label.clone(), LABEL_SIZE, MUTED_COLOR)
let words = text(label.clone(), LABEL_SIZE, shared.theme.muted.clone())
.label(label)
.add_strong(rsc);
more(rsc).set(words);
let shared_for_tap = shared.clone();
let key = (id.to_string(), part);
on_tap(
rsc,
more,
shared.list,
shared.selection.clone(),
move |rsc| {
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
shared_for_tap
.state
.borrow_mut()
.whole
.insert(key.clone(), true);
redraw_card(rsc, &shared_for_tap, index);
},
);
on_tap(rsc, more, shared.list, move |rsc| {
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
shared_for_tap
.state
.borrow_mut()
.whole
.insert(key.clone(), true);
redraw_card(rsc, &shared_for_tap, index);
});
more_strong.any()
}
@@ -207,19 +200,25 @@ where
{
if output.is_empty() {
let (words, colour) = match call_state {
ToolState::Succeeded => ("No output", MUTED_COLOR),
ToolState::Failed => ("Failed, with no output", FAILED_COLOR),
ToolState::NoResult => ("No result ever arrived", UNKNOWN_COLOR),
ToolState::Running | ToolState::Deciding => ("No output yet", MUTED_COLOR),
ToolState::Succeeded => ("No output", shared.theme.muted.clone()),
ToolState::Failed => ("Failed, with no output", shared.theme.failed.clone()),
ToolState::NoResult => ("No result ever arrived", shared.theme.unknown.clone()),
ToolState::Running | ToolState::Deciding => {
("No output yet", shared.theme.muted.clone())
}
};
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
}
let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
column.push(text("Output", LABEL_SIZE, NAME_COLOR).add_strong(rsc).any());
let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR);
column.push(raw_block(rsc, body));
column.push(
text("Output", LABEL_SIZE, shared.theme.text.clone())
.add_strong(rsc)
.any(),
);
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone());
column.push(raw_block(rsc, body, &shared.theme));
if was_cut {
column.push(show_all(rsc, shared, index, id, Part::Output, lines));
}
@@ -249,12 +248,12 @@ where
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
header.push(
disclosure(if open { icon::OPEN } else { icon::CLOSED })
disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme)
.add_strong(rsc)
.any(),
);
header.push(
text(tool.clone(), NAME_SIZE, NAME_COLOR)
text(tool.clone(), NAME_SIZE, shared.theme.text.clone())
.add_strong(rsc)
.any(),
);
@@ -263,7 +262,7 @@ where
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
}
(false, Some(title)) => header.push(
text(title.to_string(), BODY_SIZE, MUTED_COLOR)
text(title.to_string(), BODY_SIZE, shared.theme.muted.clone())
.wrap(false)
.masked()
.width(rest(1))
@@ -273,12 +272,16 @@ where
}
if open && let Some(timeout) = &parsed.timeout {
header.push(
text(format!("timeout {timeout}"), LABEL_SIZE, MUTED_COLOR)
.add_strong(rsc)
.any(),
text(
format!("timeout {timeout}"),
LABEL_SIZE,
shared.theme.muted.clone(),
)
.add_strong(rsc)
.any(),
);
}
if let Some((word, colour)) = state_mark(call_state) {
if let Some((word, colour)) = state_mark(call_state, &shared.theme) {
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
}
@@ -287,7 +290,7 @@ where
if open {
if let Some(description) = &parsed.description {
column.push(
text(description.clone(), BODY_SIZE, MUTED_COLOR)
text(description.clone(), BODY_SIZE, shared.theme.muted.clone())
.width(rest(1))
.add_strong(rsc)
.any(),
@@ -303,13 +306,13 @@ where
let spans = match parsed.language {
Some(language) => {
let mut spans = Vec::new();
highlight_into(&mut spans, shown, 0..shown.len(), language);
highlight_into(&mut spans, shown, 0..shown.len(), language, &shared.theme);
spans
}
None => Vec::new(),
};
let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR).spans(spans);
column.push(raw_block(rsc, body));
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()).spans(spans);
column.push(raw_block(rsc, body, &shared.theme));
}
if !parsed.rest.is_empty() {
// Never dropped: a field left out would be claiming the tool
@@ -320,8 +323,8 @@ where
let (shown, lines, was_cut) = capped(&joined, whole);
input_lines += lines;
input_cut |= was_cut;
let body = text(shown.to_string(), BODY_SIZE, MUTED_COLOR);
column.push(raw_block(rsc, body));
let body = text(shown.to_string(), BODY_SIZE, shared.theme.muted.clone());
column.push(raw_block(rsc, body, &shared.theme));
}
if input_cut {
column.push(show_all(rsc, shared, index, id, Part::Input, input_lines));
@@ -332,7 +335,7 @@ where
column
.width(rest(1))
.pad(dp(CARD_PAD_DP))
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(card_label(tool, &parsed, call_state))
.add_strong(rsc)
@@ -370,28 +373,22 @@ where
let content = build_card(rsc, shared, index);
ptr(rsc).set(content);
let for_tap = shared.clone();
on_tap(
rsc,
ptr,
shared.list,
shared.selection.clone(),
move |rsc| {
hold_edge(rsc, for_tap.list, for_tap.key);
let Some(id) = for_tap.call_id(index) else {
debug_assert!(false, "tapped card {index} is no longer in the row");
return;
};
let was = for_tap
.state
.borrow()
.open
.get(&id)
.copied()
.unwrap_or(false);
for_tap.state.borrow_mut().open.insert(id, !was);
redraw_card(rsc, &for_tap, index);
},
);
on_tap(rsc, ptr, shared.list, move |rsc| {
hold_edge(rsc, for_tap.list, for_tap.key);
let Some(id) = for_tap.call_id(index) else {
debug_assert!(false, "tapped card {index} is no longer in the row");
return;
};
let was = for_tap
.state
.borrow()
.open
.get(&id)
.copied()
.unwrap_or(false);
for_tap.state.borrow_mut().open.insert(id, !was);
redraw_card(rsc, &for_tap, index);
});
(strong.any(), ptr)
}
@@ -401,7 +398,7 @@ where
{
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
let mark = disclosure(icon::COLLAPSE)
let mark = disclosure(icon::COLLAPSE, &shared.theme)
.center_text()
.width(rest(1))
.pad(dp(CARD_PAD_DP))
@@ -411,13 +408,9 @@ where
.add_strong(rsc);
ptr(rsc).set(mark);
let for_tap = shared.clone();
on_tap(
rsc,
ptr,
shared.list,
shared.selection.clone(),
move |rsc| toggle_group(rsc, &for_tap),
);
on_tap(rsc, ptr, shared.list, move |rsc| {
toggle_group(rsc, &for_tap)
});
strong.any()
}
@@ -438,10 +431,10 @@ where
if !shared.state.borrow().group_expanded {
let heading = group_label(count);
return text(heading.clone(), NAME_SIZE, NAME_COLOR)
return text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(heading)
.add_strong(rsc)
@@ -451,7 +444,7 @@ where
let heading = group_label(count);
let mut group = Span::empty(Dir::DOWN);
group.push(
text(heading.clone(), NAME_SIZE, NAME_COLOR)
text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.label(heading)
@@ -468,7 +461,7 @@ where
group.push(collapse_bar(rsc, shared));
group
.width(rest(1))
.background(rect(GROUP_FILL).radius(dp(CARD_RADIUS_DP)))
.background(rect(shared.theme.group_surface.clone()).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
@@ -516,10 +509,10 @@ impl Shared {
pub fn build_tool_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
calls: Vec<TranscriptItem>,
working: bool,
theme: Rc<Theme>,
) -> (StrongWidget, ToolRow)
where
Rsc::State: FocusHost + OpenUrl,
@@ -530,9 +523,9 @@ where
cards: RefCell::new(Vec::new()),
content: RefCell::new(None),
list,
selection,
key,
working: Cell::new(working),
theme,
});
let content_strong = WidgetPtr::new().add_strong(rsc);
let content = content_strong.weak();
+26
View File
@@ -127,6 +127,32 @@ fn a_long_press_and_drag_selects_text() {
);
}
#[test]
fn a_tap_after_selection_deselects_text() {
let (mut h, screen) = opened();
h.replay(&script(
"select-then-tap",
"0 down 300 1000\n\
520 move 300 1000\n\
560 move 700 1000\n\
600 move 900 1000\n\
640 up 900 1000\n\
800 down 540 1000\n\
880 up 540 1000",
));
assert_eq!(
screen.selected_text(&mut h.rsc),
None,
"an ordinary tap after a selection must dismiss it"
);
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"the deselecting tap must be consumed rather than activating content"
);
}
#[test]
fn the_composer_sits_above_a_simulated_ime_inset() {
let (mut h, screen) = opened();
+1 -1
View File
@@ -3,7 +3,7 @@ use iris::harness::Harness;
use iris::prelude::*;
const HEADER_H: f32 = 300.0;
const HEADER: UiColor = UiColor::new(28, 28, 34, 255);
const HEADER: Srgba8 = Srgba8::new(28, 28, 34, 255);
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
+13 -30
View File
@@ -2,35 +2,10 @@
Only open Iris framework work lives here. Delete an item when it lands.
## Fix
- [ ] **Colours are not in a defined colour space. Fix this before a
styling pass.** Both render backends prefer an sRGB surface
(`default/render.rs` and `android/render.rs`), while `fs_main` returns
`unpack4x8unorm` palette and image bytes unchanged. An sRGB attachment
treats those values as linear and encodes them again: Mocha Crust
(17,17,27) became (73,73,91) in a desktop screenshot measured on
2026-09-06.
Neither diagnostics nor startup logging records Android's selected surface
format. A device exposing only a non-sRGB surface can hide the shared bug.
Done means defining one convention for palette bytes, decoded images,
colour emoji and the clear colour, then converting exactly once for the
selected target. Record the selected format in diagnostics, and add a GPU
test that draws known non-black, non-white pixels into an sRGB target and
reads the stored bytes back; screenshots from desktop and Android then
confirm the same Catppuccin values rather than serving as the definition.
## Build (for the port)
Framework capabilities needed by `RUST.md`'s port plan:
- [ ] **Selectable, read-only text.** P0's report and P1's transcript rows
use `TextEdit` because `Selectable` is implemented only for it. That
makes prose focusable and opens the IME over text that cannot be edited.
A display widget needs the same selection geometry and clipboard path
without a text-input accessibility role or keyboard focus.
- [ ] **Overflow ellipsis with an explicit retained end.** `TextAttrs` can
only wrap or clip, so a tool summary is cut with no mark. Parley has no
ellipsis primitive; use its line breaker to find the cut, but keep source
@@ -52,9 +27,9 @@ Framework capabilities needed by `RUST.md`'s port plan:
- [ ] **Per-range backgrounds for rich text.** (**P1**.) Inline code is
already monospace and coloured, but matching Compose's chip also needs
the glyph run's boxes so a surface can be drawn behind exactly that byte
range. `TextEdit` already computes the same geometry internally for its
selection highlight; expose one shared primitive rather than giving the
app a second text-layout path.
range. The shared `TextSelection` engine already computes the same geometry
for selection highlights; expose one shared primitive rather than giving
the app a second text-layout path.
- [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and
**P5**.) Needed for the session settings dialog, `UsageDialog`'s
equivalent, and the delete-with-`deleteForeign` confirmation with its
@@ -69,6 +44,14 @@ Framework capabilities needed by `RUST.md`'s port plan:
## Later
- [ ] **Intern independently constructed solid paint definitions.** Inline
`rect(Srgba8::...)` values currently receive a new `PaintId` each time.
Cache them by canonical linear RGBA bits, but keep `Paints::add` explicitly
unique so two semantic theme roles that start with the same value can later
change independently. Cache entries must be weak and disappear when the
last real handle releases the slot; gradients and texture paints need their
own identity rules rather than inheriting solid-value interning blindly.
- [ ] **Property/content animations.** Cosmetic, so after correctness and
parity. Keep them modular, like input; scrolling already animates through
`Widget::tick` and `UiData::animate`. A widget that does not opt in must
@@ -85,8 +68,8 @@ Framework capabilities needed by `RUST.md`'s port plan:
against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own
size and density and the two are identical to the pixel. What the pair
cannot express is a clip that is not a box: `Painter::set_mask` writes
a `RectPrimitive::color(Color::NONE)` at the widget's own region, with
no radius, so `.background(rect(fill).radius(r)).masked()` draws a
a `RectPrimitive::color` using `PaintId::NONE` at the widget's own region,
with no radius, so `.background(rect(fill).radius(r)).masked()` draws a
rounded panel and then cuts its content square. Both other call sites
(`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why
the method stands for now.
+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.
- The port is one crate under `app-rust/`; `iris/` is only the UI framework.
- **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own
list (colour-space correctness is the live one), `docs/TODO.md` is the
Compose app's.
list; `docs/TODO.md` is the Compose app's.
## Desktop and phone share the code
@@ -81,7 +80,7 @@ runs inside `cargo test`.
pan, and (e) the composer clears a simulated 1000px IME inset
(`Composer::set_bottom_inset`). Each was confirmed to fail without
its subject rather than assumed: dropping `animate(id)` from
`Selection::drag` -- the phone's own "fling does nothing" defect --
`SelectionController::drag` -- the phone's own "fling does nothing" defect --
and starting the fling curve at the wall clock each fail only the
flick test; flinging on `Tapped` fails only the tap test; a 5s
`LONG_PRESS` fails only the selection test; a `set_bottom_inset` that
@@ -269,11 +268,14 @@ already got right (v2 signing, `uses-feature`, ABI splits).
Reading the Compose code for what a replacement must be able to express,
rather than what it happens to look like:
1. **The transcript is one selectable body of text.** One
`SelectionContainer` around the whole lazy list, so a selection runs from
a reply into the tool output beneath it. The framework needs selectable
read-only rich text across many rows, with the platform's selection
handles and clipboard on the phone.
1. **The transcript is one selectable body of text.** A
`SelectionController` is registered directly on the lazy list, with no
selection widget in the layout tree, so a selection runs from a reply into
the tool output beneath it. Each parent supplies either draw order or one
visual axis for its immediate children; ordering is resolved only when
selection queries it. Iris owns the selection handles because its text is
drawn into one surface, while the platform supplies the clipboard and
related system services.
2. **Rich inline text**: markdown with links (one tap detector per text,
not a node per link), inline code chips drawn behind the text, tables
with wrapping cells and a sideways scroll, syntax-highlighted fences,
@@ -442,7 +444,7 @@ buffer on **every keystroke**. So there is nothing to adopt, and adding
it would be upstream work in parley.
**And the app already does the thing incremental layout would buy.**
`RowBlocks::apply_delta` keeps one `TextEdit` per top-level markdown
`RowBlocks::apply_delta` keeps one `Text` per top-level markdown
block and re-shapes only the block a delta landed in; re-splitting the
markdown to find that block is 18µs at 18,000 characters and comparing
the blocks is 470ns. Neither is the cost.
@@ -664,12 +666,6 @@ pane is neither masked nor scrollable despite its construction comment saying
it is both. This is an `app-rust` defect, not an iris framework item.
- [ ] **P1 — session screen parity.** Continue in this order:
- [ ] **Before the next parity slice — make iris's colour pipeline
correct.** Both backends currently prefer an sRGB surface while
the shader returns palette/image bytes as
linear values; `IRIS_TODO.md` has the measured mismatch and
pass condition. Do this before judging or centralising the
app's styling. It is correctness, not cosmetic polish.
- [ ] **P1c — history paging and jump-to-latest.** Wire
`client::transcript_source` into `src/ui`:
the opening page, paging back on scroll with the cushion
@@ -725,16 +721,6 @@ it is both. This is an `app-rust` defect, not an iris framework item.
names are app content applied through iris's existing `.label()` API,
not a missing framework widget.
**`app-rust` UI defect still open**: tool-card text is not selectable.
A `TranscriptRow::Tools` has no markdown blocks, so cards can number
their own texts from zero without colliding with the row selection
keys. The risk is lifecycle: every card rebuild path must unregister
the old `TextEdit` handles before registering replacements, or the next
long press can find a freed handle. Cover result arrival, group toggle,
a call joining a run and the per-card widget swap with stale-handle
tests. This uses iris's existing selection API; it is not a framework
widget gap.
**Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by
`ui-trace record --do "tap '<label>'"` — a session with the big
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
`LazySpan::scrollable()`**, and this is not an oversight. That helper also
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
the same frames. `DragGesture`'s doc states the rule: one gesture, one
arbiter, each frame delivered exactly once. The wheel handler registered
here is identical to the helper's; only the drag differs.
`Selection` is given the span by `set_scroll_area` after it exists (rows
need a `Selection`, and the span needs the rows), and hands it committed
pans and releases through `Scrollable::scroll`/`fling`. There is no
wrapper widget: `TranscriptScreen::list` is the layout (`extent`,
`SelectionController` is attached directly to the span and holds its weak
handle, then hands committed pans and releases through
`Scrollable::scroll`/`fling`. There is no wrapper widget:
`TranscriptScreen::list` is the layout (`extent`,
`key_at`, `jump_to_end`) *and* the position (`amt`, `fling`,
`is_scrolling`).
+20
View File
@@ -42,6 +42,26 @@ adjacency after a free.
Standalone images currently use `NonFiltering` sampling. Thumbnail scaling
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
`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 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 {
inner: input_rect.any(),
x: None,
@@ -238,7 +238,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut growable = None;
for i in 0..n {
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 {
inner: rect.any(),
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::{
ActiveData, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents, IdLike, LayerId,
WeakWidget, WidgetEventFn, WidgetId,
ActiveData, ControllerManager, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents,
IdLike, LayerId, WeakWidget, WidgetEventFn, WidgetId,
util::{HashMap, HashSet, TypeMap},
};
use std::{any::TypeId, rc::Rc};
@@ -8,13 +8,15 @@ use std::{any::TypeId, rc::Rc};
pub struct EventManager<Rsc> {
widget_to_types: HashMap<WidgetId, HashSet<TypeId>>,
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 {
Self {
widget_to_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() {
self.types.get_mut(t).unwrap().remove(id);
}
self.controllers.remove(id);
}
fn draw(&mut self, active: &ActiveData) {
self.controllers.draw(active);
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
self.types.get_mut(t).unwrap().draw(active);
}
}
fn undraw(&mut self, active: &ActiveData) {
self.controllers.undraw(active);
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
self.types.get_mut(t).unwrap().undraw(active);
}
+2
View File
@@ -1,7 +1,9 @@
mod controller;
mod ctx;
mod manager;
mod rsc;
pub use controller::*;
pub use ctx::*;
pub use manager::*;
pub use rsc::*;
+71 -1
View File
@@ -1,5 +1,6 @@
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 {
@@ -18,6 +19,75 @@ pub trait HasEvents: Sized + UiRsc + HasState {
) {
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 {
-2
View File
@@ -30,5 +30,3 @@ pub use primitive::*;
pub use render::*;
pub use ui::*;
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)]
#[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)]
pub struct Color<T> {
pub r: T,
pub g: T,
pub b: T,
pub a: T,
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Srgba8 {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
/// Required by parley's `Brush`, which every text style is generic over. Opaque
/// black rather than transparent: a brush that was never set should be visible
/// and obviously unstyled, not invisible.
impl<T: ColorNum> Default for Color<T> {
impl Srgba8 {
pub const BLACK: Self = Self::rgb(0, 0, 0);
pub const WHITE: Self = Self::rgb(255, 255, 255);
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 {
Self::BLACK
}
}
impl<T: ColorNum> Color<T> {
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
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]
impl PartialEq for PaintId {
fn eq(&self, other: &Self) -> bool {
self.slot == other.slot
}
}
pub const trait F32Conversion {
fn to(self) -> f32;
fn from(x: f32) -> Self;
impl Eq for PaintId {}
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 {
const MIN: Self;
const MID: Self;
const MAX: Self;
struct PendingPaint {
definition: Box<dyn Paint>,
resolved: RefCell<Option<PaintId>>,
}
macro_rules! map_rgb {
($x:ident,$self:ident, $e:tt) => {
#[allow(unused_braces)]
Self {
r: {
let $x = $self.r;
$e
},
g: {
let $x = $self.g;
$e
},
b: {
let $x = $self.b;
$e
},
a: $self.a,
#[derive(Clone)]
enum PaintValueInner {
Id(PaintId),
Pending(Rc<PendingPaint>),
}
/// 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.
///
/// Pending definitions are shared across clones and registered only once.
/// Each property replaces its own pending variant with the resulting direct
/// ID after that first resolution, so later draws take the direct path.
#[derive(Clone)]
pub struct PaintValue(PaintValueInner);
impl PaintValue {
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);
}
};
}
impl<T: ColorNum + const F32Conversion> Color<T>
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) })
let PaintValueInner::Id(id) = &self.0 else {
unreachable!()
};
id
}
pub const fn add_rgb(self, amt: impl const F32Conversion) -> Self {
let amt = amt.to();
map_rgb!(x, self, { T::from(x.to() + amt) })
}
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,
pub fn is(&self, id: &PaintId) -> bool {
match &self.0 {
PaintValueInner::Id(current) => current == id,
PaintValueInner::Pending(pending) => pending.resolved.borrow().as_ref() == Some(id),
}
}
}
fn s_to_l<T: F32Conversion>(x: T) -> T {
let x = x.to();
T::from(if x <= 0.0405 {
x / 12.92
} else {
((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
impl fmt::Debug for PaintValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
PaintValueInner::Id(id) => f.debug_tuple("PaintValue").field(id).finish(),
PaintValueInner::Pending(_) => f.write_str("PaintValue(Pending)"),
}
}
}
const impl F32Conversion for u8 {
fn to(self) -> f32 {
self as f32 / 255.0
const BUILTIN_PAINTS: [Srgba8; 15] = [
Srgba8::BLACK,
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::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
@@ -32,7 +32,7 @@ pub struct FontDiagnostics {
pub struct TextData {
pub font_cx: FontContext,
pub layout_cx: LayoutContext<UiColor>,
pub layout_cx: LayoutContext<PaintId>,
scale_cx: ScaleContext,
pub atlas: GlyphAtlas,
/// 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 paints = Vec::new();
for line in buffer.layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else {
@@ -250,7 +255,10 @@ impl TextData {
let font = run.run().font();
let font_size = run.run().font_size();
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)
else {
continue;
@@ -301,12 +309,12 @@ impl TextData {
glyph.x.floor() + entry.left as f32,
glyph.y.floor() - entry.top as f32,
),
color: run_color,
paint: run_color.slot(),
});
}
}
}
placed
(placed, paints)
}
pub fn render(
@@ -318,11 +326,12 @@ impl TextData {
density: f32,
) -> RenderedText {
buffer.shape(self, attrs, width, density);
let glyphs = self.place(buffer, textures);
let (glyphs, paints) = self.place(buffer, textures);
RenderedText {
glyphs: std::sync::Arc::new(glyphs),
paints: std::sync::Arc::new(paints),
size: buffer.size(),
color: attrs.color,
color: attrs.color.clone(),
generation: self.atlas.generation(),
}
}
@@ -359,7 +368,7 @@ impl Family {
#[derive(Clone, PartialEq)]
pub struct SpanStyle {
pub range: Range<usize>,
pub color: Option<UiColor>,
pub color: Option<PaintId>,
pub family: Option<Family>,
pub font_size: Option<f32>,
pub bold: bool,
@@ -379,7 +388,7 @@ impl SpanStyle {
underline: false,
}
}
pub fn color(mut self, color: UiColor) -> Self {
pub fn color(mut self, color: PaintId) -> Self {
self.color = Some(color);
self
}
@@ -407,7 +416,7 @@ impl SpanStyle {
#[derive(Clone, PartialEq)]
pub struct TextAttrs {
pub color: UiColor,
pub color: PaintId,
pub font_size: f32,
pub line_height: f32,
pub family: Family,
@@ -421,7 +430,7 @@ impl Default for TextAttrs {
fn default() -> Self {
let size = 16.0;
Self {
color: UiColor::WHITE,
color: PaintId::WHITE,
font_size: size,
line_height: size * LINE_HEIGHT_MULT,
family: Family::SansSerif,
@@ -436,7 +445,7 @@ impl Default for TextAttrs {
/// them apart is how they get out of step.
pub struct TextBuffer {
text: String,
layout: Layout<UiColor>,
layout: Layout<PaintId>,
spans: Vec<SpanStyle>,
shaped: Option<(TextAttrs, Option<f32>, f32)>,
}
@@ -464,7 +473,7 @@ impl TextBuffer {
&self.text
}
pub fn layout(&self) -> &Layout<UiColor> {
pub fn layout(&self) -> &Layout<PaintId> {
&self.layout
}
@@ -515,11 +524,11 @@ impl TextBuffer {
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
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) {
let range = span.range.clone();
if let Some(color) = span.color {
builder.push(StyleProperty::Brush(color), range.clone());
if let Some(color) = &span.color {
builder.push(StyleProperty::Brush(color.clone()), range.clone());
}
if let Some(family) = family {
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
@@ -563,8 +572,10 @@ fn hash_coords(coords: &[i16]) -> u64 {
#[derive(Clone)]
pub struct RenderedText {
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 color: UiColor,
pub color: PaintId,
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
/// A holder must re-render rather than re-emit these quads once the
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::{
PatchRect, TextureHandle, Textures, UiColor,
PatchRect, TextureHandle, Textures,
util::{HashMap, Vec2},
};
use image::RgbaImage;
@@ -202,5 +202,5 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
pub struct PlacedGlyph {
pub entry: GlyphEntry,
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");
/// 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 {
Limits {
max_buffer_size: 1 << 30,
@@ -91,6 +131,7 @@ pub struct UiRenderNode {
instances: ArrBuf<PrimitiveInstance>,
masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>,
paints: ArrBuf<crate::LinearRgba>,
masks_layout: BindGroupLayout,
masks_group: BindGroup,
}
@@ -196,13 +237,16 @@ impl UiRenderNode {
let masks_resized = self.masks.update(device, queue, entries, dirty);
let (entries, dirty) = ui.move_offsets.for_upload();
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(
device,
&self.masks_layout,
&self.masks,
&self.move_offsets,
&self.instances,
&self.paints,
);
}
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
@@ -212,6 +256,7 @@ impl UiRenderNode {
FrameUpdateStats {
masks_resized,
moves_resized,
paints_resized,
}
}
@@ -231,7 +276,7 @@ impl UiRenderNode {
pub fn new(
device: &Device,
queue: &Queue,
config: &SurfaceConfiguration,
target_format: TextureFormat,
window_size: impl Into<Vec2>,
) -> Result<Self, String> {
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
@@ -305,12 +350,23 @@ impl UiRenderNode {
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let paints = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui paints",
);
let rsc_layout = Self::rsc_layout(device);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
let masks_layout = Self::masks_layout(device);
let masks_group =
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances);
let masks_group = Self::masks_group(
device,
&masks_layout,
&masks,
&move_offsets,
&instances,
&paints,
);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"),
@@ -335,7 +391,7 @@ impl UiRenderNode {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState {
format: config.format,
format: target_format,
blend: Some(BlendState::ALPHA_BLENDING),
write_mask: ColorWrites::ALL,
})],
@@ -386,6 +442,7 @@ impl UiRenderNode {
instances,
masks,
move_offsets,
paints,
masks_layout,
masks_group,
})
@@ -522,6 +579,16 @@ impl UiRenderNode {
},
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"),
})
@@ -533,6 +600,7 @@ impl UiRenderNode {
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
instances: &ArrBuf<PrimitiveInstance>,
paints: &ArrBuf<crate::LinearRgba>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -549,6 +617,10 @@ impl UiRenderNode {
binding: 2,
resource: instances.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 3,
resource: paints.buffer.as_entire_binding(),
},
],
label: Some("ui masks"),
})
@@ -576,4 +648,5 @@ impl UiRenderNode {
pub struct FrameUpdateStats {
pub masks_resized: bool,
pub moves_resized: bool,
pub paints_resized: bool,
}
+9 -13
View File
@@ -1,7 +1,7 @@
use std::ops::Deref;
use crate::{
Color, UiRegion, WidgetId,
UiRegion, WidgetId,
render::{
ArrBuf,
data::{MaskIdx, MoveIdx, PrimitiveInstance},
@@ -554,16 +554,18 @@ primitives!(
#[repr(C)]
#[derive(Copy, Clone)]
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 thickness: f32,
pub inner_radius: f32,
}
impl RectPrimitive {
pub fn color(color: Color<u8>) -> Self {
pub fn color(paint: u32) -> Self {
Self {
color,
paint,
radius: 0.0,
thickness: 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
/// own. See TEXTURES.md's "Recommended shape".
pub layer: u32,
pub color: Color<u8>,
pub paint: u32,
pub flags: u32,
_pad: u32,
}
@@ -588,18 +590,12 @@ pub struct GlyphPrimitive {
impl GlyphPrimitive {
pub const IS_COLOR: u32 = 1;
pub fn new(
uv_min: [f32; 2],
uv_max: [f32; 2],
layer: u32,
color: Color<u8>,
flags: u32,
) -> Self {
pub fn new(uv_min: [f32; 2], uv_max: [f32; 2], layer: u32, paint: u32, flags: u32) -> Self {
Self {
uv_min,
uv_max,
layer,
color,
paint,
flags,
_pad: 0,
}
+9 -4
View File
@@ -11,7 +11,7 @@ var<storage> rects: array<Rect>;
var<storage> glyphs: array<GlyphInfo>;
struct Rect {
color: u32,
paint: u32,
radius: f32,
thickness: f32,
inner_radius: f32,
@@ -22,7 +22,7 @@ struct GlyphInfo {
uv_max: vec2<f32>,
// A layer in the shared atlas array, not a bind-group index.
layer: u32,
color: u32,
paint: 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.
@group(3) @binding(2)
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
// 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 {
return texel;
}
var color = unpack4x8unorm(g.color);
var color = paints[g.paint];
color.a *= texel.a;
return color;
}
@@ -242,7 +247,7 @@ fn rounded_rect_coverage(
}
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
var color = unpack4x8unorm(rect.color);
var color = paints[rect.paint];
let edge = 0.5;
+10 -3
View File
@@ -271,7 +271,11 @@ impl GpuTextures {
mip_level_count: 1,
sample_count: 1,
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,
view_formats: &[],
},
@@ -337,7 +341,10 @@ impl GpuTextures {
mip_level_count: 1,
sample_count: 1,
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
| TextureUsages::COPY_DST
| TextureUsages::COPY_SRC,
@@ -430,7 +437,7 @@ pub fn null_texture_view(device: &Device) -> TextureView {
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
+6 -1
View File
@@ -1,5 +1,6 @@
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)]
@@ -8,6 +9,10 @@ pub struct ActiveData {
pub region: UiRegion,
pub parent: Option<WidgetId>,
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 children: Vec<WidgetId>,
pub size_dependencies: Vec<WidgetId>,
+3 -1
View File
@@ -1,5 +1,5 @@
use crate::{
Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
Mask, MoveOffset, Paints, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
};
mod access;
@@ -15,6 +15,7 @@ pub use render_state::*;
#[derive(Default)]
pub struct UiData {
pub widgets: Widgets,
pub paints: Paints,
pub textures: Textures,
pub text: TextData,
pub masks: TrackedArena<Mask, u32>,
@@ -70,5 +71,6 @@ pub trait UiRsc {
self.on_remove(id);
}
self.ui_mut().textures.free();
self.ui_mut().paints.free_released();
}
}
+22 -3
View File
@@ -1,5 +1,5 @@
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,
WidgetId,
render::{
@@ -20,6 +20,7 @@ pub struct Painter<'a> {
pub(super) child_move_slot: Option<MoveIdx>,
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) paints: Vec<PaintId>,
pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
pub(super) children: Vec<WidgetId>,
@@ -118,6 +119,20 @@ impl<'a> Painter<'a> {
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) {
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
/// `ActiveData::own_mask` for what pushing a fresh one cost.
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);
}
@@ -433,6 +449,9 @@ impl<'a> Painter<'a> {
0
}
};
for paint in text.paints.iter() {
self.paint(paint);
}
for glyph in text.glyphs.iter() {
let mut region = origin;
region.x.end = region.x.start;
@@ -445,7 +464,7 @@ impl<'a> Painter<'a> {
glyph.entry.uv_min,
glyph.entry.uv_max,
glyph.entry.layer,
glyph.color,
glyph.paint,
flags_for(glyph.entry.is_color),
),
region,
+44 -2
View File
@@ -2,8 +2,8 @@ use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::{
ActiveData, Axis, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
Size, StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
ActiveData, Axis, ChildOrder, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers,
RegionAlign, Size, StrongWidget, UiRegion, UiRsc, UiVec2, Widget, WidgetId, Widgets,
render::{
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
RectPrimitive, rounded_rect_coverage,
@@ -73,6 +73,7 @@ pub(crate) struct Retained {
pub child_move_slot: Option<MoveIdx>,
pub own_mask: MaskIdx,
pub primitives: Vec<PrimitiveHandle>,
pub paints: Vec<crate::PaintId>,
}
impl Default for Retained {
@@ -84,6 +85,7 @@ impl Default for Retained {
child_move_slot: None,
own_mask: MaskIdx::NONE,
primitives: Vec::new(),
paints: Vec::new(),
}
}
}
@@ -350,6 +352,7 @@ impl UiRenderState {
mut child_move_slot,
mut own_mask,
primitives: mut recycle,
paints: _old_paints,
} = retained;
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
let requires_exact_region = rsc
@@ -447,6 +450,7 @@ impl UiRenderState {
layer,
id,
textures: Vec::new(),
paints: Vec::new(),
primitives: Vec::new(),
recycle: recycle.into_iter().peekable(),
children: Vec::new(),
@@ -498,6 +502,7 @@ impl UiRenderState {
child_move_slot,
own_mask,
textures,
paints,
primitives,
recycle,
children,
@@ -517,6 +522,7 @@ impl UiRenderState {
region,
parent,
textures,
paints,
primitives,
children,
size_dependencies,
@@ -1046,6 +1052,41 @@ impl UiRenderState {
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) {
rsc.widgets_mut().needs_redraw.remove(&id);
if self.draw_started.contains(&id) {
@@ -1075,6 +1116,7 @@ impl UiRenderState {
child_move_slot: active.child_move_slot,
own_mask: active.own_mask,
primitives: active.primitives,
paints: active.paints,
},
rsc,
);
+14
View File
@@ -15,6 +15,16 @@ pub use tag::*;
pub use view::*;
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 {
fn draw(&mut self, painter: &mut Painter);
@@ -34,6 +44,10 @@ pub trait Widget: Any {
accesskit::Role::Unknown
}
fn child_order(&self) -> ChildOrder {
ChildOrder::Draw
}
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
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 {
let tint = if i.is_multiple_of(2) {
Color::rgb(120, 130, 170)
Srgba8::rgb(120, 130, 170)
} 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) {
let text = wtext(row_text(i))
.wrap(true)
@@ -77,7 +77,7 @@ impl DefaultAppState for State {
let root = list
.scrollable()
.masked()
.background(rect(Color::WHITE))
.background(rect(PaintId::WHITE))
.add_strong(rsc);
ui_state.set_root(root.any());
+1 -1
View File
@@ -15,7 +15,7 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
rect(Color::RED).set_root(rsc, &mut ui_state);
rect(PaintId::RED).set_root(rsc, &mut ui_state);
Self { ui_state }
}
}
+4 -4
View File
@@ -16,15 +16,15 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let rect = rect(Color::RED).add(rsc);
let rect = rect(PaintId::RED).add(rsc);
rect.task_on(CursorSense::click(), async move |mut ctx| {
tokio::time::sleep(Duration::from_secs(1)).await;
ctx.update(move |_, rsc| {
let rect = rect(rsc);
if rect.color == Color::RED {
rect.color = Color::BLUE;
if rect.is_paint(&PaintId::RED) {
rect.set_paint(PaintId::BLUE);
} else {
rect.color = Color::RED;
rect.set_paint(PaintId::RED);
}
});
})
+3 -3
View File
@@ -20,7 +20,7 @@ struct Test {
impl Test {
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);
Self { root, cur }
}
@@ -28,9 +28,9 @@ impl Test {
let cur = &mut rsc[self.cur];
*cur = !*cur;
if *cur {
rsc[self.root].color = Color::BLUE;
rsc[self.root].set_paint(PaintId::BLUE);
} 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 {
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 mut render = UiRenderState::new();
render.resize((800.0, 600.0));
@@ -40,7 +40,7 @@ fn a_widget_with_no_label_never_reaches_the_tree() {
let mut rsc = TestRsc {
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();
render.resize((800.0, 600.0));
render.update(&root.any(), &mut rsc);
@@ -58,7 +58,7 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
let mut rsc = TestRsc {
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 offset = rsc.ui.widgets.add_strong(Offset {
inner: leaf_strong,
+29 -17
View File
@@ -4,7 +4,7 @@ use android_view::{
jni::{JavaVM, objects::GlobalRef},
ndk::native_window::NativeWindow,
};
use iris_core::{FrameParts, UiData, UiRenderNode, UiRenderState};
use iris_core::{FrameParts, LinearRgba, UiData, UiRenderNode, UiRenderState};
use pollster::FutureExt;
use std::time::Instant;
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
/// `surfaceChanged`) has a window handle but not a display one -- there is
@@ -46,6 +46,7 @@ pub struct AndroidRenderer {
device: Device,
queue: Queue,
config: SurfaceConfiguration,
view_format: TextureFormat,
encoder: CommandEncoder,
pub ui: UiRenderNode,
pub adapter_name: String,
@@ -73,6 +74,7 @@ pub struct AndroidRenderer {
pub struct FrameDiagnostics {
pub masks_resized: bool,
pub moves_resized: bool,
pub paints_resized: bool,
pub atlas_pages_grown_prev: u64,
pub image_bind_group_creates_prev: u64,
}
@@ -189,30 +191,33 @@ impl AndroidRenderer {
);
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let formats = iris_core::srgb_surface_format(&surface_caps)?;
log::info!(
"iris renderer: surface={:?}, view={:?}, color_space=Srgb",
formats.surface,
formats.view,
);
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
format: formats.surface,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
color_space: SurfaceColorSpace::Srgb,
width,
height,
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
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);
let encoder = Self::create_encoder(&device);
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,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
};
@@ -222,6 +227,7 @@ impl AndroidRenderer {
device,
queue,
config,
view_format: formats.view,
encoder,
ui,
adapter_name,
@@ -289,8 +295,10 @@ impl AndroidRenderer {
format!(
"iris diagnostics. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver}\n\
surface: {surface:?}, view: {view:?}, color_space: Srgb\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:?} \
mono={default_mono_family:?}\n\
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
@@ -301,6 +309,8 @@ impl AndroidRenderer {
name = self.adapter_name,
backend = self.adapter_backend,
driver = self.adapter_driver,
surface = self.config.format,
view = self.view_format,
content_scale = self.content_scale,
views = self.ui.view_count(),
families_found = font.families_found,
@@ -328,6 +338,7 @@ impl AndroidRenderer {
FrameDiagnostics {
masks_resized: stats.masks_resized,
moves_resized: stats.moves_resized,
paints_resized: stats.paints_resized,
atlas_pages_grown_prev,
image_bind_group_creates_prev,
}
@@ -348,9 +359,10 @@ impl AndroidRenderer {
other => panic!("no surface texture to draw into: {other:?}"),
};
let acquire = acquire_start.elapsed();
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let view = output.texture.create_view(&TextureViewDescriptor {
format: Some(self.view_format),
..Default::default()
});
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{
@@ -359,7 +371,7 @@ impl AndroidRenderer {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR),
load: LoadOp::Clear(CLEAR_COLOR.to_wgpu()),
store: StoreOp::Store,
},
depth_slice: None,
+3
View File
@@ -383,10 +383,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
log::info!(
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
paints_resized={} \
atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}",
renderer.frame_count(),
frame_diagnostics.masks_resized,
frame_diagnostics.moves_resized,
frame_diagnostics.paints_resized,
frame_diagnostics.atlas_pages_grown_prev,
frame_diagnostics.image_bind_group_creates_prev,
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.textures.reupload();
self.rsc.ui.paints.reupload();
self.state.android_state_mut().renderer = Some(renderer);
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
/// it, the same as a drag that never touched a selectable field at all.
fn on_press(
rsc: &mut impl UiRsc,
rsc: &mut impl HasEvents,
render: &UiRenderState,
state: &mut impl FocusHost,
id: WeakWidget<TextEdit>,
@@ -107,6 +107,12 @@ fn on_press(
size: Vec2,
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) {
// Already focused, so there is no keyboard to withhold -- but a
// 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();
}
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()
{
let mut text = sel.edit(rsc);
+23 -16
View File
@@ -1,12 +1,12 @@
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 std::sync::Arc;
use std::time::Instant;
use wgpu::*;
use winit::{dpi::PhysicalSize, window::Window};
pub const CLEAR_COLOR: Color = Color::BLACK;
pub const CLEAR_COLOR: LinearRgba = LinearRgba::BLACK;
impl RequestRedraw for Window {
fn request_redraw(&self) {
@@ -20,6 +20,7 @@ pub struct UiRenderer {
device: Device,
queue: Queue,
config: SurfaceConfiguration,
view_format: TextureFormat,
encoder: CommandEncoder,
pub ui: UiRenderNode,
}
@@ -45,9 +46,10 @@ impl UiRenderer {
other => panic!("no surface texture to draw into: {other:?}"),
};
let acquire = acquire_start.elapsed();
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let view = output.texture.create_view(&TextureViewDescriptor {
format: Some(self.view_format),
..Default::default()
});
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{
@@ -56,7 +58,7 @@ impl UiRenderer {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR),
load: LoadOp::Clear(CLEAR_COLOR.to_wgpu()),
store: StoreOp::Store,
},
depth_slice: None,
@@ -170,18 +172,19 @@ impl UiRenderer {
.expect("Could not get device!");
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let formats = iris_core::srgb_surface_format(&surface_caps)
.expect("Could not select an sRGB iris surface format");
log::info!(
"iris renderer: surface={:?}, view={:?}, color_space=Srgb",
formats.surface,
formats.view,
);
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
format: formats.surface,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
color_space: SurfaceColorSpace::Srgb,
width: size.width,
height: size.height,
// Vsync, because a toolkit aiming at battery life must not present
@@ -192,7 +195,10 @@ impl UiRenderer {
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
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);
@@ -210,7 +216,7 @@ impl UiRenderer {
// `default::content_scale` for why this backend stopped dividing
// 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 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!");
Self {
@@ -218,6 +224,7 @@ impl UiRenderer {
device,
queue,
config,
view_format: formats.view,
encoder,
ui,
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 {}
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! {
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
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 {
fn draw(&mut self, painter: &mut Painter) {
let size = Size::from_axis(Axis::Y, Len::abs(self.0), Len::REST);
let paint = painter.paint(&PaintId::WHITE);
painter.primitive_within(
RectPrimitive::color(UiColor::WHITE),
RectPrimitive::color(paint),
size.to_uivec2(painter.density())
.align(RegionAlign::TOP_LEFT),
);
@@ -89,8 +90,8 @@ fn a_widget_retains_its_entry_layer_not_its_child_cursor() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let back = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let front = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let back = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let front = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED));
let stack = rsc.ui.widgets.add_strong(Stack {
children: vec![back.any(), front.any()],
size: StackSize::Default,
@@ -277,7 +278,7 @@ fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
ui: UiData::default(),
};
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 {
inner: fill.any(),
x: None,
@@ -309,7 +310,7 @@ fn scrolled_rects(
let mut span = Span::empty(Dir::DOWN);
let mut rects = Vec::with_capacity(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());
let row = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
@@ -459,13 +460,13 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget)
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(UiColor::WHITE)
.color(PaintId::WHITE)
.add(rsc);
let bar = (field.pad(dp(12)).width(rest(1)),)
.span(Dir::RIGHT)
.background(rect(UiColor::new(40, 40, 46, 255)))
.background(rect(Srgba8::new(40, 40, 46, 255)))
.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();
(field, tree)
}
@@ -517,7 +518,7 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let mut rsc = TestRsc {
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 {
inner: rect.any(),
x: None,
@@ -567,7 +568,7 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
let mut rsc = TestRsc {
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 {
inner: rect.any(),
x: None,
@@ -609,7 +610,7 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
inner: inner_root,
});
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 {
inner: filler.any(),
x: None,
@@ -650,7 +651,7 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
let mut rsc = TestRsc {
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 {
inner: rect.any(),
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)),
});
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 {
inner: filler.any(),
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 {
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 {
inner: top.any(),
x: None,
y: Some(Len::abs(100.0)),
});
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 mut span = Span::empty(Dir::DOWN);
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 {
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 {
inner: rect.any(),
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;
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 shape = rsc
.ui
.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 root = rsc
.ui
@@ -909,13 +910,13 @@ fn nested_masks_multiply_their_coverage() {
let mut rsc = TestRsc {
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 inner_shape = rsc
.ui
.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 = rsc.ui.widgets.add_strong(Masked {
shape: Some(inner_shape.any()),
@@ -924,7 +925,7 @@ fn nested_masks_multiply_their_coverage() {
let outer_shape = rsc
.ui
.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 root = rsc
.ui
@@ -1004,7 +1005,7 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
let mut rsc = TestRsc {
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 {
inner: fill,
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 {
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 = rsc.ui.widgets.add_strong(Sized {
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 rects = Vec::new();
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());
let sized = rsc.ui.widgets.add_strong(Sized {
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(),
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 {
children: vec![fill, padded.any()],
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 {
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 = rsc.ui.widgets.add_strong(Sized {
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.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 = rsc.ui.widgets.add_strong(Sized {
inner: second.any(),
+1 -1
View File
@@ -1986,7 +1986,7 @@ mod drag_gesture_tests {
}
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]
+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.
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 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 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(),
};
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 dropped = Rc::new(Cell::new(false));
@@ -184,9 +184,9 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
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 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_hovered = Rc::new(Cell::new(false));
@@ -229,7 +229,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
events: EventManager::default(),
};
let scroll_strong = rect(UiColor::WHITE)
let scroll_strong = rect(PaintId::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
@@ -319,7 +319,7 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
ui: UiData::default(),
events: EventManager::default(),
};
let scroll_strong = rect(UiColor::WHITE)
let scroll_strong = rect(PaintId::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
@@ -374,7 +374,7 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
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 bystander = rsc.ui.widgets.add_strong(Stack {
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 record = seen.clone();
let outer_strong = rect(UiColor::WHITE)
let outer_strong = rect(PaintId::WHITE)
.width(Len::abs(1000.0))
.height(Len::abs(1000.0))
.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 half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
let record = slot.clone();
rect(UiColor::WHITE)
rect(PaintId::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.with_id(move |_rsc, id| {
+7 -3
View File
@@ -741,6 +741,10 @@ impl Scrollable 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
/// 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
@@ -842,7 +846,7 @@ mod tests {
}
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 {
inner: rect.any(),
x: None,
@@ -1054,9 +1058,9 @@ mod tests {
rsc: &mut TestRsc,
height: f32,
) -> (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 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 {
inner: fg_rect.any(),
x: None,
+1 -1
View File
@@ -98,7 +98,7 @@ mod tests {
let mut rsc = TestRsc {
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 long = Some(Len::abs(1000.0));
let tall = rsc.ui.widgets.add_strong(Sized {
+4
View File
@@ -8,6 +8,10 @@ pub struct Span {
}
impl Widget for Span {
fn child_order(&self) -> ChildOrder {
ChildOrder::Axis(self.dir.axis)
}
fn draw(&mut self, painter: &mut Painter) {
let axis = self.dir.axis;
let gap = self.gap.apply_rest(painter.density()).abs;
+20 -9
View File
@@ -1,26 +1,36 @@
use crate::prelude::*;
#[derive(Clone, Copy)]
#[derive(Clone)]
pub struct Rect {
pub color: UiColor,
paint: PaintValue,
pub radius: Len,
pub thickness: f32,
pub inner_radius: f32,
}
impl Rect {
pub fn new(color: UiColor) -> Self {
pub fn new(paint: impl Paint) -> Self {
Self {
color,
paint: paint.into_value(),
radius: Len::ZERO,
inner_radius: 0.0,
thickness: 0.0,
}
}
pub fn color(mut self, color: UiColor) -> Self {
self.color = color;
pub fn paint(mut self, paint: impl Paint) -> Self {
self.paint = paint.into_value();
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 {
self.radius = radius.into();
self
@@ -29,8 +39,9 @@ impl Rect {
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) {
let paint = painter.paint_value(&mut self.paint);
painter.primitive(RectPrimitive {
color: self.color,
paint,
radius: self.radius.fold_dp(painter.density()).abs,
thickness: self.thickness,
inner_radius: self.inner_radius,
@@ -43,6 +54,6 @@ impl Widget for Rect {
}
}
pub fn rect(color: UiColor) -> Rect {
Rect::new(color)
pub fn rect(paint: impl Paint) -> Rect {
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
}
pub fn color(mut self, color: UiColor) -> Self {
pub fn color(mut self, color: PaintId) -> Self {
self.attrs.color = color;
self
}
+48 -123
View File
@@ -1,7 +1,9 @@
use crate::prelude::*;
use iris_core::{TextData, UiColor};
use iris_core::{PaintId, TextData};
use parley::{Affinity, Layout, Selection};
use std::ops::{Deref, DerefMut};
use super::selection_layout;
#[cfg(not(target_os = "android"))]
use winit::{
event::KeyEvent,
@@ -22,10 +24,8 @@ pub enum Motion {
pub struct TextEdit {
view: TextView,
selection: Option<Selection>,
#[cfg_attr(target_os = "android", allow(dead_code))]
history: Vec<(String, Option<Selection>)>,
double_hit: Option<usize>,
pub(crate) press_origin: Option<Vec2>,
pub mode: EditMode,
}
@@ -40,20 +40,14 @@ impl TextEdit {
pub fn new(view: TextView, mode: EditMode) -> Self {
Self {
view,
selection: None,
history: Default::default(),
double_hit: None,
press_origin: None,
mode,
}
}
pub fn selected_text(&self) -> Option<String> {
let sel = self.selection?;
if sel.is_collapsed() {
return None;
}
Some(self.buf.text()[sel.text_range()].to_string())
self.view.selection.selected_text(self.view.buf.text())
}
/// 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
/// there is no span. `None` when the field is not focused.
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
/// where typing lands regardless of which end of a span it is.
pub fn caret(&self) -> Option<usize> {
Some(self.selection?.focus().index())
self.view.selection.caret()
}
}
impl Widget for TextEdit {
fn draw(&mut self, painter: &mut Painter) {
let base = painter.layer;
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),
);
let used = self.view.draw_selectable(painter, true);
painter.set_size(used);
}
@@ -122,28 +89,26 @@ impl Widget for TextEdit {
}
}
const CARET_WIDTH: f32 = 1.0;
pub struct TextEditCtx<'a> {
pub text: &'a mut TextEdit,
pub data: &'a mut TextData,
}
impl<'a> TextEditCtx<'a> {
fn layout(&mut self) -> &Layout<UiColor> {
let attrs = self.text.view.attrs.clone();
let width = self.text.view.wrap_width();
let density = self.data.density;
self.text.view.buf.shape(self.data, &attrs, width, density);
self.text.view.buf.layout()
fn selection_ctx(&mut self) -> TextSelectionCtx<'_> {
TextSelectionCtx {
view: &mut self.text.view,
data: self.data,
}
}
fn layout(&mut self) -> &Layout<iris_core::PaintId> {
selection_layout(&mut self.text.view, self.data)
}
#[cfg_attr(target_os = "android", allow(dead_code))]
fn refresh(&mut self) {
if let Some(sel) = self.text.selection {
let layout = self.layout();
self.text.selection = Some(sel.refresh(layout));
}
self.selection_ctx().refresh();
}
pub fn take(&mut self) -> String {
@@ -156,18 +121,18 @@ impl<'a> TextEditCtx<'a> {
let text = self.string(text);
self.text.view.buf.set_text(text);
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>) {
let text = self.string(text);
self.text.view.buf.set_text(text);
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) {
let Some(sel) = self.text.selection else {
let Some(sel) = self.text.view.selection.range else {
return;
};
let layout = self.layout();
@@ -184,7 +149,7 @@ impl<'a> TextEditCtx<'a> {
} else {
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) {
@@ -213,7 +178,7 @@ impl<'a> TextEditCtx<'a> {
return;
}
self.clear_span();
let at = match self.text.selection {
let at = match self.text.view.selection.range {
Some(sel) => sel.focus().index(),
// No caret means nowhere to put the text, so this drops 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 {
let Some(sel) = self.text.selection else {
let Some(sel) = self.text.view.selection.range else {
return false;
};
if sel.is_collapsed() {
@@ -252,13 +217,7 @@ impl<'a> TextEditCtx<'a> {
}
fn set_caret(&mut self, index: usize) {
let index = index.min(self.text.view.buf.text().len());
let layout = self.layout();
self.text.selection = Some(Selection::from_byte_index(
layout,
index,
Affinity::default(),
));
self.selection_ctx().set_caret(index);
}
pub fn newline(&mut self) {
@@ -271,7 +230,7 @@ impl<'a> TextEditCtx<'a> {
if self.clear_span() {
return;
}
let Some(sel) = self.text.selection else {
let Some(sel) = self.text.view.selection.range else {
return;
};
let end = sel.focus().index();
@@ -291,7 +250,7 @@ impl<'a> TextEditCtx<'a> {
if self.clear_span() {
return;
}
let Some(sel) = self.text.selection else {
let Some(sel) = self.text.view.selection.range else {
return;
};
let start = sel.focus().index();
@@ -350,67 +309,33 @@ impl<'a> TextEditCtx<'a> {
/// actually *on* something" checks its own ranges, which is what
/// makes a tap in the padding hit no link.
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
let pos = pos - self.text.region().top_left().to_abs(size);
let layout = self.layout();
Selection::from_point(layout, pos.x, pos.y).focus().index()
self.selection_ctx().byte_at(pos, size)
}
pub fn select_all(&mut self) {
let len = self.text.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.text.selection = Some(Selection::new(anchor, focus));
self.selection_ctx().select_all();
}
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
let pos = pos - self.text.region().top_left().to_abs(size);
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;
}
self.selection_ctx().select(pos, size, drag, recent);
}
pub fn deselect(&mut self) {
self.text.selection = None;
self.text.double_hit = None;
self.selection_ctx().deselect();
}
#[cfg(not(target_os = "android"))]
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 res = self.apply_event_inner(event, modifiers, &mut undo);
if undo {
if let Some((old, selection)) = self.text.history.pop() {
self.set(&old);
self.text.selection = selection;
self.text.view.selection.range = selection;
self.refresh();
}
} else if self.text.view.buf.text() != old.0 {
@@ -495,7 +420,7 @@ impl<'a> TextEditCtx<'a> {
fn apply_motion(
sel: Selection,
layout: &Layout<UiColor>,
layout: &Layout<PaintId>,
motion: Motion,
extend: bool,
) -> Selection {
@@ -512,15 +437,15 @@ fn apply_motion(
}
trait RangeCursors {
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
fn start_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor;
fn end_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor;
}
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())
}
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())
}
}
@@ -605,7 +530,7 @@ mod tests {
ctx(&mut t, &mut d).set_caret(1);
ctx(&mut t, &mut d).insert("b");
assert_eq!(content(&t), "abc");
assert_eq!(t.selection.unwrap().focus().index(), 2);
assert_eq!(t.caret(), Some(2));
}
#[test]
@@ -655,14 +580,14 @@ mod tests {
ctx(&mut t, &mut d).select_all();
assert!(ctx(&mut t, &mut d).clear_span());
assert_eq!(content(&t), "");
assert_eq!(t.selection.unwrap().focus().index(), 0);
assert_eq!(t.caret(), Some(0));
}
#[test]
fn tapping_an_empty_field_places_a_caret_so_typing_lands() {
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);
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");
assert_eq!(content(&t), "hi");
}
@@ -671,14 +596,14 @@ mod tests {
fn tapping_past_the_end_of_the_text_clamps_to_the_end() {
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);
assert_eq!(t.selection.unwrap().focus().index(), 3);
assert_eq!(t.caret(), Some(3));
}
#[test]
fn dragging_without_a_previous_selection_selects_nothing() {
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);
assert!(t.selection.is_none());
assert!(t.selection_range().is_none());
}
#[test]
@@ -765,7 +690,7 @@ mod tests {
let (mut t, mut d) = edit("abc", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(0);
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);
assert_eq!(t.selected_text().as_deref(), Some("b"));
}
@@ -775,11 +700,11 @@ mod tests {
let (mut t, mut d) = edit("abcdef", EditMode::SingleLine);
ctx(&mut t, &mut d).select_all();
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).motion(Motion::Right, false);
assert_eq!(t.selection.unwrap().focus().index(), 6);
assert_eq!(t.caret(), Some(6));
}
#[test]
+146 -2
View File
@@ -1,9 +1,11 @@
mod build;
mod edit;
mod selection;
pub use build::*;
pub use edit::*;
use iris_core::util::MutDetect;
pub use selection::*;
use crate::prelude::*;
use std::ops::{Deref, DerefMut};
@@ -19,6 +21,7 @@ pub struct TextView {
tex: Option<RenderedText>,
width: Option<f32>,
pub hint: Option<StrongWidget>,
selection: TextSelection,
}
impl TextView {
@@ -37,6 +40,7 @@ impl TextView {
tex: None,
width: None,
hint,
selection: TextSelection::default(),
}
}
@@ -94,6 +98,39 @@ impl TextView {
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 {
self.buf.text().to_string()
}
@@ -111,14 +148,36 @@ impl Text {
if self.content.changed {
self.content.changed = false;
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 {
fn draw(&mut self, painter: &mut Painter) {
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);
}
@@ -127,6 +186,8 @@ impl Widget for Text {
}
}
pub(super) const CARET_WIDTH: f32 = 1.0;
impl Deref for Text {
type Target = TextAttrs;
@@ -160,6 +221,89 @@ mod tests {
use crate::layout_tests::TestRsc;
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]
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
let mut rsc = TestRsc {
@@ -167,7 +311,7 @@ mod tests {
};
let root = wtext("hello there")
.size(18)
.color(UiColor::WHITE)
.color(PaintId::WHITE)
.add_strong(&mut rsc)
.any();
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
Rsc::State: FocusHost,
{
let rrect = rect(Color::WHITE).radius(20);
let rrect = rect(PaintId::WHITE).radius(20);
let pad_test = (
rrect.color(Color::BLUE),
rrect.clone().color(PaintId::BLUE),
(
rrect
.color(Color::RED)
.clone()
.color(PaintId::RED)
.sized((100, 100))
.center()
.width(rest(2)),
(
rrect.color(Color::ORANGE),
rrect.color(Color::LIME).pad(10.0),
rrect.clone().color(PaintId::ORANGE),
rrect.clone().color(PaintId::LIME).pad(10.0),
)
.span(Dir::RIGHT)
.width(rest(2)),
rrect.color(Color::YELLOW),
rrect.clone().color(PaintId::YELLOW),
)
.span(Dir::RIGHT)
.pad(10)
@@ -34,19 +35,19 @@ where
.add(rsc);
let span_test = (
rrect.color(Color::GREEN).width(100),
rrect.color(Color::ORANGE),
rrect.color(Color::CYAN),
rrect.color(Color::BLUE).width(rel(0.5)),
rrect.color(Color::MAGENTA).width(100),
rrect.color(Color::RED).width(100),
rrect.clone().color(PaintId::GREEN).width(100),
rrect.clone().color(PaintId::ORANGE),
rrect.clone().color(PaintId::CYAN),
rrect.clone().color(PaintId::BLUE).width(rel(0.5)),
rrect.clone().color(PaintId::MAGENTA).width(100),
rrect.color(PaintId::RED).width(100),
)
.span(Dir::LEFT)
.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)
.on(CursorSense::click(), move |_, rsc| {
let child = image(include_bytes!("../assets/sungals.png"))
@@ -57,7 +58,7 @@ where
.sized((150, 150))
.align(Align::BOT_RIGHT);
let del_button = rect(Color::RED)
let del_button = rect(PaintId::RED)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
span_add(rsc).pop();
@@ -79,9 +80,9 @@ where
btext("'").family(Family::Monospace).align(Align::TOP),
btext("'").family(Family::Monospace),
btext(":gamer mode").family(Family::Monospace),
rect(Color::CYAN).sized((10, 10)).center(),
rect(Color::RED).sized((100, 100)).center(),
rect(Color::PURPLE).sized((50, 50)).align(Align::TOP),
rect(PaintId::CYAN).sized((10, 10)).center(),
rect(PaintId::RED).sized((100, 100)).center(),
rect(PaintId::PURPLE).sized((50, 50)).align(Align::TOP),
)
.span(Dir::RIGHT)
.center(),
@@ -94,13 +95,13 @@ where
let msg_area = texts
.scrollable(Axis::Y, Pin::Start)
.masked()
.background(rect(Color::SKY));
.background(rect(PaintId::SKY));
let add_text = wtext("add")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.size(30)
.attr::<Selectable>(())
.on(Submit, move |ctx, rsc| {
.on(Submit, move |ctx, rsc: &mut Rsc| {
let w = ctx.widget;
let content = w.edit(rsc).take();
let text = wtext(content)
@@ -109,9 +110,11 @@ where
.text_align(Align::LEFT)
.wrap(true)
.attr::<Selectable>(());
let msg_box = text
.background(rect(Color::WHITE.darker(0.5)))
.add_strong(rsc);
let fill = rsc
.ui_mut()
.paints
.add(Srgba8::WHITE.to_linear().darker(0.5));
let msg_box = text.background(rect(fill)).add_strong(rsc);
texts(rsc).push(msg_box);
})
.add(rsc);
@@ -119,10 +122,14 @@ where
let text_edit_scroll = (
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)),
Rect::new(Color::GREEN)
Rect::new(PaintId::GREEN)
.on(CursorSense::click(), move |ctx, rsc: &mut Rsc| {
rsc.run_event::<Submit>(add_text, (), ctx.state);
})
@@ -142,7 +149,9 @@ where
let main = WidgetPtr::new().add(rsc);
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 vec = &mut vals.borrow_mut().1;
let i = vec.len();
@@ -153,38 +162,37 @@ where
vec.push(Some(to));
}
let vals = vals.clone();
let rect = rect(color)
.on(CursorSense::click(), move |ctx, rsc| {
let pressed = paint.clone();
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();
if let Some(h) = vec[i].take() {
vec[*prev] = main(rsc).replace(h);
*prev = i;
}
ctx.widget(rsc).color = color.darker(0.3);
rsc.ui_mut().paints.set(&pressed, value.darker(0.3));
})
.on(
CursorSense::HoverStart | CursorSense::unclick(),
move |ctx, rsc| {
ctx.widget(rsc).color = color.brighter(0.2);
move |_ctx, rsc: &mut Rsc| {
rsc.ui_mut().paints.set(&hovered, value.brighter(0.2));
},
)
.on(CursorSense::HoverEnd, move |ctx, rsc| {
ctx.widget(rsc).color = color;
.on(CursorSense::HoverEnd, move |_ctx, rsc: &mut Rsc| {
rsc.ui_mut().paints.set(&normal, value);
})
.label(label);
(rect, wtext(label).size(30).text_align(Align::CENTER)).stack()
};
let tabs = (
switch_button(Color::RED, pad_test, "pad"),
switch_button(Color::GREEN, span_test, "span"),
switch_button(Color::BLUE, span_add_test, "image span"),
switch_button(Color::MAGENTA, text_test, "text layout"),
switch_button(
Color::YELLOW.mul_rgb(0.5),
text_edit_scroll,
"text edit scroll",
),
switch_button(Srgba8::RED, pad_test, "pad"),
switch_button(Srgba8::GREEN, span_test, "span"),
switch_button(Srgba8::BLUE, span_add_test, "image span"),
switch_button(Srgba8::MAGENTA, text_test, "text layout"),
switch_button(Srgba8::YELLOW, text_edit_scroll, "text edit scroll"),
)
.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 }
}
}