diff --git a/benches/message_list.rs b/benches/message_list.rs index d360f6b..2b3295f 100644 --- a/benches/message_list.rs +++ b/benches/message_list.rs @@ -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, diff --git a/core/src/event/controller.rs b/core/src/event/controller.rs new file mode 100644 index 0000000..597ac6e --- /dev/null +++ b/core/src/event/controller.rs @@ -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) -> Box; +} + +impl ControllerValue for T { + fn into_any(self: Box) -> Box { + self + } +} + +pub trait Controller: ControllerValue { + fn command(&mut self, _command: Command, _rsc: &mut Rsc) -> CommandResult { + CommandResult::Unused + } +} + +pub struct ControllerManager { + by_widget: HashMap>>>, + parents: HashMap>, + borrowed: HashSet, + removed_while_borrowed: HashSet, + command_target: Option, + command_target_revision: u64, + command_boundary: Option, +} + +impl Default for ControllerManager { + 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 ControllerManager { + #[track_caller] + pub fn register>(&mut self, host: WidgetId, controller: C) { + let kind = TypeId::of::(); + 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::() + ); + } + + pub fn id>(&self, host: WidgetId) -> Option { + let kind = TypeId::of::(); + self.by_widget + .get(&host)? + .contains_key(&kind) + .then_some(ControllerId { host, kind }) + } + + pub fn nearest_id>(&self, mut origin: WidgetId) -> Option { + let kind = TypeId::of::(); + 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::(origin) { + return Some(id); + } + origin = self.parents.get(&origin).copied().flatten()?; + } + } + + pub fn path_to>( + &self, + mut origin: WidgetId, + ) -> Option<(ControllerId, Vec)> { + let mut path = Vec::new(); + loop { + path.push(origin); + if let Some(id) = self.id::(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>(&mut self, id: ControllerId) -> Option { + if id.kind != TypeId::of::() { + 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>(&mut self, id: ControllerId, controller: C) { + debug_assert_eq!(id.kind, TypeId::of::()); + 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>> { + 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>) { + 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) { + self.command_target = target; + self.command_target_revision = self.command_target_revision.wrapping_add(1); + } + + pub fn command_target(&self) -> Option { + self.command_target + } + + pub(crate) fn command_target_revision(&self) -> u64 { + self.command_target_revision + } + + pub(crate) fn command_boundary(&self) -> Option { + self.command_boundary + } + + pub(crate) fn set_command_boundary(&mut self, boundary: Option) { + 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>)> { + 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>) { + 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 + } +} diff --git a/core/src/event/manager.rs b/core/src/event/manager.rs index cda0ff0..71b944b 100644 --- a/core/src/event/manager.rs +++ b/core/src/event/manager.rs @@ -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 { widget_to_types: HashMap>, types: TypeMap>, + pub controllers: ControllerManager, } -impl Default for EventManager { +impl Default for EventManager { fn default() -> Self { Self { widget_to_types: Default::default(), types: Default::default(), + controllers: Default::default(), } } } @@ -54,15 +56,18 @@ impl EventsLike for EventManager { 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); } diff --git a/core/src/event/mod.rs b/core/src/event/mod.rs index b856850..888cd5e 100644 --- a/core/src/event/mod.rs +++ b/core/src/event/mod.rs @@ -1,7 +1,9 @@ +mod controller; mod ctx; mod manager; mod rsc; +pub use controller::*; pub use ctx::*; pub use manager::*; pub use rsc::*; diff --git a/core/src/event/rsc.rs b/core/src/event/rsc.rs index 4824a99..d422dbb 100644 --- a/core/src/event/rsc.rs +++ b/core/src/event/rsc.rs @@ -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>( + &mut self, + id: WeakWidget, + controller: C, + ) { + self.events_mut().controllers.register(id.id(), controller); + } + + fn with_controller, T>( + &mut self, + id: ControllerId, + f: impl FnOnce(&mut C, &mut Self) -> T, + ) -> Option { + let mut controller = self.events_mut().controllers.take::(id)?; + let result = f(&mut controller, self); + self.events_mut().controllers.put(id, controller); + Some(result) + } + + fn with_nearest_controller, T>( + &mut self, + origin: impl IdLike, + f: impl FnOnce(ControllerId, &mut C, &mut Self) -> T, + ) -> Option { + let id = self.events().controllers.nearest_id::(origin.id())?; + self.with_controller(id, |controller, rsc| f(id, controller, rsc)) + } + + fn set_command_target(&mut self, target: Option) { + 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 { diff --git a/core/src/lib.rs b/core/src/lib.rs index 195706a..f93a844 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -30,5 +30,3 @@ pub use primitive::*; pub use render::*; pub use ui::*; pub use widget::*; - -pub type UiColor = primitive::Color; diff --git a/core/src/primitive/color.rs b/core/src/primitive/color.rs index a7579c0..ed39e7c 100644 --- a/core/src/primitive/color.rs +++ b/core/src/primitive/color.rs @@ -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 { - 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 Default for Color { +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`. +#[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, +} + +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, +} + +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 Color { - 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, + resolved: RefCell>, } -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), +} + +/// 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 Color -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(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 {} - -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, + free: Vec, + dirty: Dirty, + send: Sender, + recv: Receiver, +} + +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()); } } diff --git a/core/src/primitive/text.rs b/core/src/primitive/text.rs index c621c6e..011aa41 100644 --- a/core/src/primitive/text.rs +++ b/core/src/primitive/text.rs @@ -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, + pub layout_cx: LayoutContext, 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 { + pub fn place( + &mut self, + buffer: &TextBuffer, + textures: &mut Textures, + ) -> (Vec, Vec) { 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, - pub color: Option, + pub color: Option, pub family: Option, pub font_size: Option, 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, + layout: Layout, spans: Vec, shaped: Option<(TextAttrs, Option, f32)>, } @@ -464,7 +473,7 @@ impl TextBuffer { &self.text } - pub fn layout(&self) -> &Layout { + pub fn layout(&self) -> &Layout { &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>, + /// The unique handles whose compact slots the glyphs above carry. + pub paints: std::sync::Arc>, 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 diff --git a/core/src/render/atlas.rs b/core/src/render/atlas.rs index 4129e78..e121967 100644 --- a/core/src/render/atlas.rs +++ b/core/src/render/atlas.rs @@ -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, } diff --git a/core/src/render/mod.rs b/core/src/render/mod.rs index 3285c68..bd4f130 100644 --- a/core/src/render/mod.rs +++ b/core/src/render/mod.rs @@ -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 { + 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, masks: ArrBuf, move_offsets: ArrBuf, + paints: ArrBuf, 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, ) -> Result { 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, move_offsets: &ArrBuf, instances: &ArrBuf, + paints: &ArrBuf, ) -> 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, } diff --git a/core/src/render/primitive.rs b/core/src/render/primitive.rs index daf2185..1d8ffd3 100644 --- a/core/src/render/primitive.rs +++ b/core/src/render/primitive.rs @@ -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, + /// 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) -> 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, + 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, - 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, } diff --git a/core/src/render/shader.wgsl b/core/src/render/shader.wgsl index 57aa63a..6d434f8 100644 --- a/core/src/render/shader.wgsl +++ b/core/src/render/shader.wgsl @@ -11,7 +11,7 @@ var rects: array; var glyphs: array; struct Rect { - color: u32, + paint: u32, radius: f32, thickness: f32, inner_radius: f32, @@ -22,7 +22,7 @@ struct GlyphInfo { uv_max: vec2, // 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 move_offsets: array; // Shared by the vertex stage's drawn primitive and the fragment stage's mask shape. @group(3) @binding(2) var instances: array; +// 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 paints: array>; // 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 { 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 { - var color = unpack4x8unorm(rect.color); + var color = paints[rect.paint]; let edge = 0.5; diff --git a/core/src/render/texture.rs b/core/src/render/texture.rs index 7730ebf..e151cac 100644 --- a/core/src/render/texture.rs +++ b/core/src/render/texture.rs @@ -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: &[], }) diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index 9704e78..0d47649 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -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, pub textures: Vec, + /// 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, pub primitives: Vec, pub children: Vec, pub size_dependencies: Vec, diff --git a/core/src/ui/mod.rs b/core/src/ui/mod.rs index 2ad061a..f4ed28c 100644 --- a/core/src/ui/mod.rs +++ b/core/src/ui/mod.rs @@ -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, @@ -70,5 +71,6 @@ pub trait UiRsc { self.on_remove(id); } self.ui_mut().textures.free(); + self.ui_mut().paints.free_released(); } } diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index d98b691..e344ac8 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -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, pub(super) own_mask: MaskIdx, pub(super) textures: Vec, + pub(super) paints: Vec, pub(super) primitives: Vec, pub(super) recycle: std::iter::Peekable>, pub(super) children: Vec, @@ -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(&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, diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 9d43b68..d5e243d 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -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, pub own_mask: MaskIdx, pub primitives: Vec, + pub paints: Vec, } 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 { + 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, ); diff --git a/core/src/widget/mod.rs b/core/src/widget/mod.rs index bac28c0..0e4acd8 100644 --- a/core/src/widget/mod.rs +++ b/core/src/widget/mod.rs @@ -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 diff --git a/examples/message_list.rs b/examples/message_list.rs index da7f0e7..4f98211 100644 --- a/examples/message_list.rs +++ b/examples/message_list.rs @@ -27,11 +27,11 @@ fn row_image(i: usize) -> image::DynamicImage { fn build_row(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()); diff --git a/examples/minimal.rs b/examples/minimal.rs index e98ae5b..4484baf 100644 --- a/examples/minimal.rs +++ b/examples/minimal.rs @@ -15,7 +15,7 @@ impl DefaultAppState for State { rsc: &mut DefaultRsc, _: Proxy, ) -> Self { - rect(Color::RED).set_root(rsc, &mut ui_state); + rect(PaintId::RED).set_root(rsc, &mut ui_state); Self { ui_state } } } diff --git a/examples/task.rs b/examples/task.rs index 63e4d43..b3aca61 100644 --- a/examples/task.rs +++ b/examples/task.rs @@ -16,15 +16,15 @@ impl DefaultAppState for State { rsc: &mut DefaultRsc, _: Proxy, ) -> 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); } }); }) diff --git a/examples/view.rs b/examples/view.rs index b8e06cf..cbe5ea4 100644 --- a/examples/view.rs +++ b/examples/view.rs @@ -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); } } } diff --git a/src/access_tests.rs b/src/access_tests.rs index 1cbc93b..1bd128b 100644 --- a/src/access_tests.rs +++ b/src/access_tests.rs @@ -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(UiColor::WHITE).label("Add task").add(&mut rsc); + let leaf: WeakWidget = 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(UiColor::WHITE).label("thing").add(&mut rsc); + let leaf: WeakWidget = 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, diff --git a/src/android/render.rs b/src/android/render.rs index db38540..0aba573 100644 --- a/src/android/render.rs +++ b/src/android/render.rs @@ -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\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, diff --git a/src/android/view.rs b/src/android/view.rs index cddc95b..48b2de5 100644 --- a/src/android/view.rs +++ b/src/android/view.rs @@ -383,10 +383,12 @@ impl IrisViewPeer { 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 ViewPeer for IrisViewPeer { 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()); } diff --git a/src/attr.rs b/src/attr.rs index 884f8cb..8fbc344 100644 --- a/src/attr.rs +++ b/src/attr.rs @@ -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, @@ -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` diff --git a/src/default/mod.rs b/src/default/mod.rs index f0da693..74efc7d 100644 --- a/src/default/mod.rs +++ b/src/default/mod.rs @@ -344,7 +344,34 @@ impl AppState for DefaultApp { 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); diff --git a/src/default/render.rs b/src/default/render.rs index cc393ec..9b1fcf8 100644 --- a/src/default/render.rs +++ b/src/default/render.rs @@ -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, diff --git a/src/event.rs b/src/event.rs index 2a20e64..f6a317e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -36,6 +36,17 @@ pub trait Eventable: WidgetLike { } impl, Rsc: HasEvents, Tag> Eventable for WL {} +pub trait Controllable: WidgetLike { + fn controller>(self, controller: C) -> impl WidgetIdFn { + move |rsc| { + let id = self.add(rsc); + rsc.register_controller(id, controller); + id + } + } +} +impl, Rsc: HasEvents, Tag> Controllable for WL {} + widget_trait! { pub trait TaskEventable; fn task_on>( diff --git a/src/layout_tests.rs b/src/layout_tests.rs index 85ad7a4..4f75006 100644 --- a/src/layout_tests.rs +++ b/src/layout_tests.rs @@ -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, 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(), diff --git a/src/sense.rs b/src/sense.rs index 61e8d9b..05b66b2 100644 --- a/src/sense.rs +++ b/src/sense.rs @@ -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] diff --git a/src/sense_tests.rs b/src/sense_tests.rs index d8f849b..0c8e3ed 100644 --- a/src/sense_tests.rs +++ b/src/sense_tests.rs @@ -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>>>; 2] = Default::default(); let half = |slot: &Rc>>>| { 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| { diff --git a/src/widget/position/lazy_span.rs b/src/widget/position/lazy_span.rs index 425b6c7..eabb8f4 100644 --- a/src/widget/position/lazy_span.rs +++ b/src/widget/position/lazy_span.rs @@ -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, 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, 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, diff --git a/src/widget/position/scroll_area.rs b/src/widget/position/scroll_area.rs index 5f294a5..87b0933 100644 --- a/src/widget/position/scroll_area.rs +++ b/src/widget/position/scroll_area.rs @@ -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 { diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 38b73e1..e805fc4 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -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; diff --git a/src/widget/rect.rs b/src/widget/rect.rs index 588adfd..0b89ba9 100644 --- a/src/widget/rect.rs +++ b/src/widget/rect.rs @@ -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) -> 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) } diff --git a/src/widget/text/build.rs b/src/widget/text/build.rs index fb826ae..4c835e2 100644 --- a/src/widget/text/build.rs +++ b/src/widget/text/build.rs @@ -16,7 +16,7 @@ impl> TextBuilder { 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 } diff --git a/src/widget/text/edit.rs b/src/widget/text/edit.rs index 999cd64..b0e415d 100644 --- a/src/widget/text/edit.rs +++ b/src/widget/text/edit.rs @@ -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, #[cfg_attr(target_os = "android", allow(dead_code))] history: Vec<(String, Option)>, - double_hit: Option, pub(crate) press_origin: Option, 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 { - 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> { - 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 { - 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(®ion), - ); - } - - 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(®ion), - ); + 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 { - 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 { + 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) { 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, + layout: &Layout, motion: Motion, extend: bool, ) -> Selection { @@ -512,15 +437,15 @@ fn apply_motion( } trait RangeCursors { - fn start_cursor(&self, layout: &Layout) -> parley::Cursor; - fn end_cursor(&self, layout: &Layout) -> parley::Cursor; + fn start_cursor(&self, layout: &Layout) -> parley::Cursor; + fn end_cursor(&self, layout: &Layout) -> parley::Cursor; } impl RangeCursors for std::ops::Range { - fn start_cursor(&self, layout: &Layout) -> parley::Cursor { + fn start_cursor(&self, layout: &Layout) -> parley::Cursor { parley::Cursor::from_byte_index(layout, self.start, Affinity::default()) } - fn end_cursor(&self, layout: &Layout) -> parley::Cursor { + fn end_cursor(&self, layout: &Layout) -> 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] diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 5e85524..8957a1f 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -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, width: Option, pub hint: Option, + 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(®ion), + ); + } + 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(®ion), + ); + } + 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 { + self.view.selection.selected_text(self.view.buf.text()) + } + + pub fn selection_range(&self) -> Option> { + self.view.selection.range() + } + + pub fn set_with_spans(&mut self, content: impl Into, spans: Vec) { + 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, 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(); diff --git a/src/widget/text/selection.rs b/src/widget/text/selection.rs new file mode 100644 index 0000000..63c9e5d --- /dev/null +++ b/src/widget/text/selection.rs @@ -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, + double_hit: Option, +} + +impl TextSelection { + pub(super) fn selected_text(&self, text: &str) -> Option { + let selection = self.range?; + if selection.is_collapsed() { + return None; + } + Some(text[selection.text_range()].to_string()) + } + + pub(super) fn range(&self) -> Option> { + Some(self.range?.text_range()) + } + + pub(super) fn caret(&self) -> Option { + 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 { + 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 { + 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> 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, + selected: Vec, + gesture: DragGesture, + scroll: Option>, + 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) -> Self { + self.scroll = Some(scroll); + self + } + + pub fn separator(mut self, separator: impl Into) -> Self { + self.separator = separator.into(); + self + } + + fn text_order(host: WidgetId, rsc: &impl UiRsc, render: &UiRenderState) -> Vec { + fn visit(id: WidgetId, rsc: &impl UiRsc, render: &UiRenderState, out: &mut Vec) { + if rsc + .widgets() + .get_dyn(id) + .is_some_and(|widget| widget.as_any().is::()) + { + 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::()) + { + 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( + rsc: &mut impl UiRsc, + id: WidgetId, + f: impl FnOnce(&mut TextSelectionCtx<'_>) -> T, + ) -> Option { + let ui = rsc.ui_mut(); + let text = ui + .widgets + .get_dyn_mut(id)? + .as_any_mut() + .downcast_mut::()?; + 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( + &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::()) + .is_some_and(|text| text.selected_text().is_some()) + }) + } + + pub fn selected_text(&self, rsc: &impl UiRsc) -> Option { + let parts: Vec = self + .selected + .iter() + .filter_map(|&id| { + rsc.widgets() + .get_dyn(id) + .and_then(|widget| widget.as_any().downcast_ref::()) + .and_then(Text::selected_text) + }) + .collect(); + (!parts.is_empty()).then(|| parts.join(&self.separator)) + } +} + +impl Controller 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, + } + + 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.events + } + + fn events_mut(&mut self) -> &mut EventManager { + &mut self.events + } + } + + fn two_texts( + dir: Dir, + ) -> ( + TestRsc, + UiRenderState, + WeakWidget, + WeakWidget, + WeakWidget, + 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::(host.id()) + .unwrap(); + rsc.with_controller::(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::(host.id()) + .unwrap(); + rsc.with_controller::(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::(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::(host.id()) + .unwrap(); + rsc.with_controller::(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::(host.id()) + .unwrap(); + rsc.with_controller::(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::(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::(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::(host.id()) + .unwrap(); + rsc.set_command_target(Some(id)); + + rsc.with_controller::(id, |_selection, rsc| { + rsc.events_mut().controllers.remove(host.id()); + }); + + assert!( + rsc.events() + .controllers + .id::(host.id()) + .is_none() + ); + assert_eq!(rsc.events().controllers.command_target(), None); + } +} diff --git a/tabs-ui/src/lib.rs b/tabs-ui/src/lib.rs index a5a2f2a..f661f95 100644 --- a/tabs-ui/src/lib.rs +++ b/tabs-ui/src/lib.rs @@ -9,22 +9,23 @@ pub fn build(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::(()) - .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::(()); - 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::(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); diff --git a/tests/color_space.rs b/tests/color_space.rs new file mode 100644 index 0000000..88d4d52 --- /dev/null +++ b/tests/color_space.rs @@ -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::(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 = 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 } + } +}