Add retained paints and shared text selection

This commit is contained in:
iris committed 2026-09-10 18:35:24 -04:00
1 parent 1e6d3b1edd
commit a33fbca966
42 files changed
+2421 -467

No files matched your search

+2 -2
View File
@@ -131,7 +131,7 @@ fn bench_input_grows(n: usize, lines: usize) {
});
let line_height = 24.0;
let input_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let input_rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let input_area = rsc.ui.widgets.add_strong(Sized {
inner: input_rect.any(),
x: None,
@@ -238,7 +238,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut growable = None;
for i in 0..n {
if i == growable_index {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
+261
View File
@@ -0,0 +1,261 @@
use crate::{
ActiveData, WidgetId,
util::{HashMap, HashSet},
};
use std::any::{Any, TypeId};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ControllerId {
host: WidgetId,
kind: TypeId,
}
impl ControllerId {
pub fn host(self) -> WidgetId {
self.host
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Command {
Copy,
SelectAll,
Escape,
}
#[derive(Debug, Eq, PartialEq)]
pub enum CommandResult {
Unused,
Used,
Copy(String),
}
pub trait ControllerValue: Any {
fn into_any(self: Box<Self>) -> Box<dyn Any>;
}
impl<T: Any> ControllerValue for T {
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
pub trait Controller<Rsc>: ControllerValue {
fn command(&mut self, _command: Command, _rsc: &mut Rsc) -> CommandResult {
CommandResult::Unused
}
}
pub struct ControllerManager<Rsc> {
by_widget: HashMap<WidgetId, HashMap<TypeId, Box<dyn Controller<Rsc>>>>,
parents: HashMap<WidgetId, Option<WidgetId>>,
borrowed: HashSet<ControllerId>,
removed_while_borrowed: HashSet<WidgetId>,
command_target: Option<ControllerId>,
command_target_revision: u64,
command_boundary: Option<WidgetId>,
}
impl<Rsc> Default for ControllerManager<Rsc> {
fn default() -> Self {
Self {
by_widget: Default::default(),
parents: Default::default(),
borrowed: Default::default(),
removed_while_borrowed: Default::default(),
command_target: None,
command_target_revision: 0,
command_boundary: None,
}
}
}
impl<Rsc: 'static> ControllerManager<Rsc> {
#[track_caller]
pub fn register<C: Controller<Rsc>>(&mut self, host: WidgetId, controller: C) {
let kind = TypeId::of::<C>();
let id = ControllerId { host, kind };
assert!(
!self.borrowed.contains(&id),
"a controller cannot be replaced while it is handling input"
);
assert!(
!self.removed_while_borrowed.contains(&host),
"a controller cannot be attached to a removed widget"
);
let old = self
.by_widget
.entry(host)
.or_default()
.insert(kind, Box::new(controller));
assert!(
old.is_none(),
"a widget cannot have two controllers of type {}",
std::any::type_name::<C>()
);
}
pub fn id<C: Controller<Rsc>>(&self, host: WidgetId) -> Option<ControllerId> {
let kind = TypeId::of::<C>();
self.by_widget
.get(&host)?
.contains_key(&kind)
.then_some(ControllerId { host, kind })
}
pub fn nearest_id<C: Controller<Rsc>>(&self, mut origin: WidgetId) -> Option<ControllerId> {
let kind = TypeId::of::<C>();
loop {
let candidate = ControllerId { host: origin, kind };
assert!(
!self.borrowed.contains(&candidate),
"a controller cannot re-enter itself while it is handling input"
);
if let Some(id) = self.id::<C>(origin) {
return Some(id);
}
origin = self.parents.get(&origin).copied().flatten()?;
}
}
pub fn path_to<C: Controller<Rsc>>(
&self,
mut origin: WidgetId,
) -> Option<(ControllerId, Vec<WidgetId>)> {
let mut path = Vec::new();
loop {
path.push(origin);
if let Some(id) = self.id::<C>(origin) {
return Some((id, path));
}
origin = self.parents.get(&origin).copied().flatten()?;
}
}
pub fn draw(&mut self, active: &ActiveData) {
self.parents.insert(active.id, active.parent);
}
pub fn undraw(&mut self, active: &ActiveData) {
self.parents.remove(&active.id);
}
pub fn take<C: Controller<Rsc>>(&mut self, id: ControllerId) -> Option<C> {
if id.kind != TypeId::of::<C>() {
return None;
}
assert!(
!self.borrowed.contains(&id),
"a controller cannot re-enter itself while it is handling input"
);
let boxed = self.by_widget.get_mut(&id.host)?.remove(&id.kind)?;
self.borrowed.insert(id);
let boxed = boxed.into_any();
boxed.downcast().ok().map(|boxed| *boxed)
}
pub fn put<C: Controller<Rsc>>(&mut self, id: ControllerId, controller: C) {
debug_assert_eq!(id.kind, TypeId::of::<C>());
assert!(
self.borrowed.remove(&id),
"restored an unborrowed controller"
);
if self.finish_removed_host(id.host) {
return;
}
let old = self
.by_widget
.entry(id.host)
.or_default()
.insert(id.kind, Box::new(controller));
debug_assert!(old.is_none(), "a controller was re-entered while borrowed");
}
fn take_dyn(&mut self, id: ControllerId) -> Option<Box<dyn Controller<Rsc>>> {
assert!(
!self.borrowed.contains(&id),
"a controller cannot re-enter itself while it is handling input"
);
let controller = self.by_widget.get_mut(&id.host)?.remove(&id.kind)?;
self.borrowed.insert(id);
Some(controller)
}
fn put_dyn(&mut self, id: ControllerId, controller: Box<dyn Controller<Rsc>>) {
assert!(
self.borrowed.remove(&id),
"restored an unborrowed controller"
);
if self.finish_removed_host(id.host) {
return;
}
let old = self
.by_widget
.entry(id.host)
.or_default()
.insert(id.kind, controller);
debug_assert!(old.is_none(), "a controller was re-entered while borrowed");
}
pub fn set_command_target(&mut self, target: Option<ControllerId>) {
self.command_target = target;
self.command_target_revision = self.command_target_revision.wrapping_add(1);
}
pub fn command_target(&self) -> Option<ControllerId> {
self.command_target
}
pub(crate) fn command_target_revision(&self) -> u64 {
self.command_target_revision
}
pub(crate) fn command_boundary(&self) -> Option<WidgetId> {
self.command_boundary
}
pub(crate) fn set_command_boundary(&mut self, boundary: Option<WidgetId>) {
self.command_boundary = boundary;
}
pub fn remove(&mut self, host: WidgetId) {
self.by_widget.remove(&host);
self.parents.remove(&host);
if self.borrowed.iter().any(|id| id.host == host) {
self.removed_while_borrowed.insert(host);
}
if self.command_target.is_some_and(|id| id.host == host) {
self.command_target = None;
}
}
pub(crate) fn take_command_target(
&mut self,
) -> Option<(ControllerId, Box<dyn Controller<Rsc>>)> {
let id = self.command_target?;
match self.take_dyn(id) {
Some(controller) => Some((id, controller)),
None => {
self.command_target = None;
None
}
}
}
pub(crate) fn restore(&mut self, id: ControllerId, controller: Box<dyn Controller<Rsc>>) {
self.put_dyn(id, controller);
}
/// Returns true when a host disappeared during its controller callback,
/// in which case restoring the temporarily extracted value would revive
/// state belonging to a dead widget generation.
fn finish_removed_host(&mut self, host: WidgetId) -> bool {
if !self.removed_while_borrowed.contains(&host) {
return false;
}
if !self.borrowed.iter().any(|id| id.host == host) {
self.removed_while_borrowed.remove(&host);
}
true
}
}
+8 -3
View File
@@ -1,6 +1,6 @@
use crate::{
ActiveData, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents, IdLike, LayerId,
WeakWidget, WidgetEventFn, WidgetId,
ActiveData, ControllerManager, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents,
IdLike, LayerId, WeakWidget, WidgetEventFn, WidgetId,
util::{HashMap, HashSet, TypeMap},
};
use std::{any::TypeId, rc::Rc};
@@ -8,13 +8,15 @@ use std::{any::TypeId, rc::Rc};
pub struct EventManager<Rsc> {
widget_to_types: HashMap<WidgetId, HashSet<TypeId>>,
types: TypeMap<dyn EventManagerLike<Rsc>>,
pub controllers: ControllerManager<Rsc>,
}
impl<Rsc> Default for EventManager<Rsc> {
impl<Rsc: 'static> Default for EventManager<Rsc> {
fn default() -> Self {
Self {
widget_to_types: Default::default(),
types: Default::default(),
controllers: Default::default(),
}
}
}
@@ -54,15 +56,18 @@ impl<Rsc: HasEvents + 'static> EventsLike for EventManager<Rsc> {
for t in self.widget_to_types.get(&id).into_flat_iter() {
self.types.get_mut(t).unwrap().remove(id);
}
self.controllers.remove(id);
}
fn draw(&mut self, active: &ActiveData) {
self.controllers.draw(active);
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
self.types.get_mut(t).unwrap().draw(active);
}
}
fn undraw(&mut self, active: &ActiveData) {
self.controllers.undraw(active);
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
self.types.get_mut(t).unwrap().undraw(active);
}
+2
View File
@@ -1,7 +1,9 @@
mod controller;
mod ctx;
mod manager;
mod rsc;
pub use controller::*;
pub use ctx::*;
pub use manager::*;
pub use rsc::*;
+71 -1
View File
@@ -1,5 +1,6 @@
use crate::{
Event, EventCtx, EventLike, EventManager, IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn,
Command, CommandResult, Controller, ControllerId, Event, EventCtx, EventLike, EventManager,
IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn,
};
pub trait HasState: 'static {
@@ -18,6 +19,75 @@ pub trait HasEvents: Sized + UiRsc + HasState {
) {
self.events_mut().register(id, event, f);
}
fn register_controller<W: ?Sized, C: Controller<Self>>(
&mut self,
id: WeakWidget<W>,
controller: C,
) {
self.events_mut().controllers.register(id.id(), controller);
}
fn with_controller<C: Controller<Self>, T>(
&mut self,
id: ControllerId,
f: impl FnOnce(&mut C, &mut Self) -> T,
) -> Option<T> {
let mut controller = self.events_mut().controllers.take::<C>(id)?;
let result = f(&mut controller, self);
self.events_mut().controllers.put(id, controller);
Some(result)
}
fn with_nearest_controller<C: Controller<Self>, T>(
&mut self,
origin: impl IdLike,
f: impl FnOnce(ControllerId, &mut C, &mut Self) -> T,
) -> Option<T> {
let id = self.events().controllers.nearest_id::<C>(origin.id())?;
self.with_controller(id, |controller, rsc| f(id, controller, rsc))
}
fn set_command_target(&mut self, target: Option<ControllerId>) {
self.events_mut().controllers.set_command_target(target);
}
fn run_command(&mut self, command: Command) -> CommandResult {
let revision = self.events().controllers.command_target_revision();
if self
.events()
.controllers
.command_target()
.is_some_and(|target| {
self.events().controllers.command_boundary() == Some(target.host())
})
{
return CommandResult::Unused;
}
let Some((id, mut controller)) = self.events_mut().controllers.take_command_target() else {
return CommandResult::Unused;
};
let result = controller.command(command, self);
self.events_mut().controllers.restore(id, controller);
if command == Command::Escape
&& result != CommandResult::Unused
&& self.events().controllers.command_target_revision() == revision
{
self.set_command_target(None);
}
result
}
#[doc(hidden)]
fn run_command_before(&mut self, command: Command, boundary: impl IdLike) -> CommandResult {
let old = self.events().controllers.command_boundary();
self.events_mut()
.controllers
.set_command_boundary(Some(boundary.id()));
let result = self.run_command(command);
self.events_mut().controllers.set_command_boundary(old);
result
}
}
pub trait RunEvents: HasEvents {
-2
View File
@@ -30,5 +30,3 @@ pub use primitive::*;
pub use render::*;
pub use ui::*;
pub use widget::*;
pub type UiColor = primitive::Color<u8>;
+486 -139
View File
@@ -1,171 +1,518 @@
use std::marker::Destruct;
use crate::util::{Dirty, RefCounter};
use std::{
cell::RefCell,
fmt,
rc::Rc,
sync::mpsc::{Receiver, Sender, channel},
};
/// Encoded, straight-alpha sRGB at an input boundary.
///
/// Palette literals, decoded images and colour glyph bitmaps use this
/// convention. A solid paint is converted to linear light when it enters the
/// paint table; the renderer never performs colour arithmetic on these bytes.
#[repr(C)]
#[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)]
pub struct Color<T> {
pub r: T,
pub g: T,
pub b: T,
pub a: T,
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Srgba8 {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
/// Required by parley's `Brush`, which every text style is generic over. Opaque
/// black rather than transparent: a brush that was never set should be visible
/// and obviously unstyled, not invisible.
impl<T: ColorNum> Default for Color<T> {
impl Srgba8 {
pub const BLACK: Self = Self::rgb(0, 0, 0);
pub const WHITE: Self = Self::rgb(255, 255, 255);
pub const GRAY: Self = Self::rgb(127, 127, 127);
pub const RED: Self = Self::rgb(255, 0, 0);
pub const ORANGE: Self = Self::rgb(255, 127, 0);
pub const YELLOW: Self = Self::rgb(255, 255, 0);
pub const LIME: Self = Self::rgb(127, 255, 0);
pub const GREEN: Self = Self::rgb(0, 255, 0);
pub const TURQUOISE: Self = Self::rgb(0, 255, 127);
pub const CYAN: Self = Self::rgb(0, 255, 255);
pub const SKY: Self = Self::rgb(0, 127, 255);
pub const BLUE: Self = Self::rgb(0, 0, 255);
pub const PURPLE: Self = Self::rgb(127, 0, 255);
pub const MAGENTA: Self = Self::rgb(255, 0, 255);
pub const NONE: Self = Self::new(0, 0, 0, 0);
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::new(r, g, b, 255)
}
pub fn to_linear(self) -> LinearRgba {
LinearRgba::new(
srgb_to_linear(self.r as f32 / 255.0),
srgb_to_linear(self.g as f32 / 255.0),
srgb_to_linear(self.b as f32 / 255.0),
self.a as f32 / 255.0,
)
}
}
/// Straight-alpha RGBA in linear-light sRGB primaries.
///
/// This is Iris's working representation: manipulate and interpolate colours
/// here, then put the result in [`Paints`]. The GPU paint buffer stores this
/// exact layout as `vec4<f32>`.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct LinearRgba {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
impl LinearRgba {
pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0);
pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0);
pub const NONE: Self = Self::new(0.0, 0.0, 0.0, 0.0);
pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
Self::new(r, g, b, 1.0)
}
pub fn mul_rgb(self, amount: f32) -> Self {
Self::new(self.r * amount, self.g * amount, self.b * amount, self.a)
}
pub fn darker(self, amount: f32) -> Self {
self.mul_rgb(1.0 - amount)
}
pub fn brighter(self, amount: f32) -> Self {
Self::new(
self.r + (1.0 - self.r) * amount,
self.g + (1.0 - self.g) * amount,
self.b + (1.0 - self.b) * amount,
self.a,
)
}
pub fn to_wgpu(self) -> wgpu::Color {
wgpu::Color {
r: self.r as f64,
g: self.g as f64,
b: self.b as f64,
a: self.a as f64,
}
}
}
fn srgb_to_linear(value: f32) -> f32 {
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
/// A description that can be registered in Iris's paint table.
///
/// Only solid paints exist today. Keeping registration behind this trait and
/// making primitives carry [`PaintId`] leaves one place to add gradient or
/// texture paint records later.
pub trait Paint: private::Sealed + 'static {
#[doc(hidden)]
fn add_to(&self, paints: &mut Paints) -> PaintId;
#[doc(hidden)]
fn replace(&self, paints: &mut Paints, slot: u32);
/// Erases this definition so a widget can register it lazily on its
/// first draw. [`PaintId`] overrides this to stay a direct handle.
#[doc(hidden)]
fn into_value(self) -> PaintValue
where
Self: Sized,
{
PaintValue::pending(self)
}
}
impl Paint for Srgba8 {
fn add_to(&self, paints: &mut Paints) -> PaintId {
paints.add_linear(self.to_linear())
}
fn replace(&self, paints: &mut Paints, slot: u32) {
paints.replace_linear(slot, self.to_linear());
}
}
impl Paint for LinearRgba {
fn add_to(&self, paints: &mut Paints) -> PaintId {
paints.add_linear(*self)
}
fn replace(&self, paints: &mut Paints, slot: u32) {
paints.replace_linear(slot, *self);
}
}
mod private {
pub trait Sealed {}
impl Sealed for super::Srgba8 {}
impl Sealed for super::LinearRgba {}
impl Sealed for super::PaintId {}
}
#[derive(Debug)]
struct PaintLease {
slot: u32,
counter: RefCounter,
send: Sender<u32>,
}
impl Clone for PaintLease {
fn clone(&self) -> Self {
Self {
slot: self.slot,
counter: self.counter.clone(),
send: self.send.clone(),
}
}
}
impl Drop for PaintLease {
fn drop(&mut self) {
if self.counter.drop() {
let _ = self.send.send(self.slot);
}
}
}
/// A stable reference to one entry in a UI's paint table.
///
/// Built-in IDs name the same reserved entries in every [`Paints`]. IDs
/// returned by [`Paints::add`] retain their slot until the last clone held by a
/// widget, shaped text or retained draw is dropped.
#[derive(Clone, Debug)]
pub struct PaintId {
slot: u32,
lease: Option<PaintLease>,
}
impl PaintId {
pub const BLACK: Self = Self::builtin(0);
pub const WHITE: Self = Self::builtin(1);
pub const GRAY: Self = Self::builtin(2);
pub const RED: Self = Self::builtin(3);
pub const ORANGE: Self = Self::builtin(4);
pub const YELLOW: Self = Self::builtin(5);
pub const LIME: Self = Self::builtin(6);
pub const GREEN: Self = Self::builtin(7);
pub const TURQUOISE: Self = Self::builtin(8);
pub const CYAN: Self = Self::builtin(9);
pub const SKY: Self = Self::builtin(10);
pub const BLUE: Self = Self::builtin(11);
pub const PURPLE: Self = Self::builtin(12);
pub const MAGENTA: Self = Self::builtin(13);
pub const NONE: Self = Self::builtin(14);
const fn builtin(slot: u32) -> Self {
Self { slot, lease: None }
}
pub(crate) fn slot(&self) -> u32 {
self.slot
}
fn is_managed(&self) -> bool {
self.lease.is_some()
}
}
impl Default for PaintId {
fn default() -> Self {
Self::BLACK
}
}
impl<T: ColorNum> Color<T> {
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
pub const GRAY: Self = Self::rgb(T::MID, T::MID, T::MID);
pub const RED: Self = Self::rgb(T::MAX, T::MIN, T::MIN);
pub const ORANGE: Self = Self::rgb(T::MAX, T::MID, T::MIN);
pub const YELLOW: Self = Self::rgb(T::MAX, T::MAX, T::MIN);
pub const LIME: Self = Self::rgb(T::MID, T::MAX, T::MIN);
pub const GREEN: Self = Self::rgb(T::MIN, T::MAX, T::MIN);
pub const TURQUOISE: Self = Self::rgb(T::MIN, T::MAX, T::MID);
pub const CYAN: Self = Self::rgb(T::MIN, T::MAX, T::MAX);
pub const SKY: Self = Self::rgb(T::MIN, T::MID, T::MAX);
pub const BLUE: Self = Self::rgb(T::MIN, T::MIN, T::MAX);
pub const PURPLE: Self = Self::rgb(T::MID, T::MIN, T::MAX);
pub const MAGENTA: Self = Self::rgb(T::MAX, T::MIN, T::MAX);
pub const NONE: Self = Self::new(T::MIN, T::MIN, T::MIN, T::MIN);
pub const fn new(r: T, g: T, b: T, a: T) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: T, g: T, b: T) -> Self {
Self { r, g, b, a: T::MAX }
}
pub fn alpha(mut self, a: T) -> Self {
self.a = a;
self
}
pub fn as_arr(self) -> [T; 4] {
[self.r, self.g, self.b, self.a]
impl PartialEq for PaintId {
fn eq(&self, other: &Self) -> bool {
self.slot == other.slot
}
}
pub const trait F32Conversion {
fn to(self) -> f32;
fn from(x: f32) -> Self;
impl Eq for PaintId {}
impl Paint for PaintId {
fn add_to(&self, _paints: &mut Paints) -> PaintId {
self.clone()
}
fn replace(&self, paints: &mut Paints, slot: u32) {
let value = paints.entries[self.slot as usize];
paints.replace_linear(slot, value);
}
fn into_value(self) -> PaintValue {
PaintValue(PaintValueInner::Id(self))
}
}
pub trait ColorNum {
const MIN: Self;
const MID: Self;
const MAX: Self;
struct PendingPaint {
definition: Box<dyn Paint>,
resolved: RefCell<Option<PaintId>>,
}
macro_rules! map_rgb {
($x:ident,$self:ident, $e:tt) => {
#[allow(unused_braces)]
Self {
r: {
let $x = $self.r;
$e
},
g: {
let $x = $self.g;
$e
},
b: {
let $x = $self.b;
$e
},
a: $self.a,
#[derive(Clone)]
enum PaintValueInner {
Id(PaintId),
Pending(Rc<PendingPaint>),
}
/// A widget property containing either an existing paint-table ID or a paint
/// definition that will receive an ID the first time it is drawn.
///
/// Pending definitions are shared across clones and registered only once.
/// Each property replaces its own pending variant with the resulting direct
/// ID after that first resolution, so later draws take the direct path.
#[derive(Clone)]
pub struct PaintValue(PaintValueInner);
impl PaintValue {
fn pending(paint: impl Paint) -> Self {
Self(PaintValueInner::Pending(Rc::new(PendingPaint {
definition: Box::new(paint),
resolved: RefCell::new(None),
})))
}
pub fn resolve(&mut self, paints: &mut Paints) -> &PaintId {
if let PaintValueInner::Pending(pending) = &self.0 {
let resolved = pending.resolved.borrow().clone();
let id = match resolved {
Some(id) => id,
None => {
let id = pending.definition.add_to(paints);
*pending.resolved.borrow_mut() = Some(id.clone());
id
}
};
}
impl<T: ColorNum + const F32Conversion> Color<T>
where
Self: const Destruct,
{
pub const fn mul_rgb(self, amt: impl const F32Conversion) -> Self {
let amt = amt.to();
map_rgb!(x, self, { T::from(x.to() * amt) })
self.0 = PaintValueInner::Id(id);
}
let PaintValueInner::Id(id) = &self.0 else {
unreachable!()
};
id
}
pub const fn add_rgb(self, amt: impl const F32Conversion) -> Self {
let amt = amt.to();
map_rgb!(x, self, { T::from(x.to() + amt) })
}
pub const fn darker(self, amt: f32) -> Self {
self.mul_rgb(1.0 - amt)
}
pub const fn brighter(self, amt: f32) -> Self {
map_rgb!(x, self, {
let x = x.to();
T::from(x + (1.0 - x) * amt)
})
}
pub fn map_rgb(self, f: impl Fn(T) -> T) -> Self {
Self {
r: f(self.r),
g: f(self.g),
b: f(self.b),
a: self.a,
}
}
pub fn srgb(r: T, g: T, b: T) -> Self {
Self {
r: s_to_l(r),
g: s_to_l(g),
b: s_to_l(b),
a: T::MAX,
pub fn is(&self, id: &PaintId) -> bool {
match &self.0 {
PaintValueInner::Id(current) => current == id,
PaintValueInner::Pending(pending) => pending.resolved.borrow().as_ref() == Some(id),
}
}
}
fn s_to_l<T: F32Conversion>(x: T) -> T {
let x = x.to();
T::from(if x <= 0.0405 {
x / 12.92
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 BUILTIN_PAINTS: [Srgba8; 15] = [
Srgba8::BLACK,
Srgba8::WHITE,
Srgba8::GRAY,
Srgba8::RED,
Srgba8::ORANGE,
Srgba8::YELLOW,
Srgba8::LIME,
Srgba8::GREEN,
Srgba8::TURQUOISE,
Srgba8::CYAN,
Srgba8::SKY,
Srgba8::BLUE,
Srgba8::PURPLE,
Srgba8::MAGENTA,
Srgba8::NONE,
];
/// CPU-side paint table and the dirty set for its GPU mirror.
pub struct Paints {
entries: Vec<LinearRgba>,
free: Vec<u32>,
dirty: Dirty,
send: Sender<u32>,
recv: Receiver<u32>,
}
impl Paints {
pub fn new() -> Self {
let (send, recv) = channel();
Self {
entries: BUILTIN_PAINTS.map(Srgba8::to_linear).to_vec(),
free: Vec::new(),
dirty: Dirty::new_all(),
send,
recv,
}
}
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 {
((x + 0.055) / 1.055).powf(2.4)
})
}
impl ColorNum for u8 {
const MIN: Self = u8::MIN;
const MID: Self = u8::MAX / 2;
const MAX: Self = u8::MAX;
}
impl ColorNum for f32 {
const MIN: Self = 0.0;
const MID: Self = 0.5;
const MAX: Self = 1.0;
}
unsafe impl bytemuck::Pod for Color<u8> {}
const impl F32Conversion for f32 {
fn to(self) -> f32 {
self
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(),
}),
}
fn from(x: f32) -> Self {
x
}
/// 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)
}
}
const impl F32Conversion for u8 {
fn to(self) -> f32 {
self as f32 / 255.0
impl Default for Paints {
fn default() -> Self {
Self::new()
}
fn from(x: f32) -> Self {
(x * 255.0).clamp(0.0, 255.0) as Self
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn srgb_bytes_become_linear_without_transforming_alpha() {
let got = Srgba8::new(17, 127, 255, 64).to_linear();
assert!((got.r - 0.005605).abs() < 0.000001);
assert!((got.g - 0.212231).abs() < 0.000001);
assert_eq!(got.b, 1.0);
assert!((got.a - 64.0 / 255.0).abs() < f32::EPSILON);
}
#[test]
fn changing_a_paint_keeps_its_id_and_dirties_only_its_slot() {
let mut paints = Paints::new();
let id = paints.add(Srgba8::rgb(17, 17, 27));
let slot = id.slot();
let (_, dirty) = paints.for_upload();
dirty.clear();
paints.set(&id, Srgba8::rgb(205, 214, 244));
let (_, dirty) = paints.for_upload();
assert!(dirty.contains(slot as usize));
assert_eq!(id.slot(), slot);
}
#[test]
fn a_released_paint_slot_is_reused_only_after_the_last_clone() {
let mut paints = Paints::new();
let id = paints.add(Srgba8::RED);
let slot = id.slot();
let held = id.clone();
drop(id);
paints.free_released();
let other = paints.add(Srgba8::GREEN);
assert_ne!(other.slot(), slot);
drop(held);
paints.free_released();
let reused = paints.add(Srgba8::BLUE);
assert_eq!(reused.slot(), slot);
}
#[test]
fn cloned_pending_paints_register_once_and_then_become_direct_ids() {
let mut paints = Paints::new();
let mut first = Srgba8::rgb(17, 17, 27).into_value();
let mut second = first.clone();
let before = paints.entries.len();
let first_id = first.resolve(&mut paints).clone();
let second_id = second.resolve(&mut paints).clone();
assert_eq!(first_id, second_id);
assert_eq!(paints.entries.len(), before + 1);
assert!(first.is(&first_id));
assert!(second.is(&first_id));
}
#[test]
fn independently_constructed_inline_solids_get_independent_slots() {
let mut paints = Paints::new();
let mut first = Srgba8::rgb(23, 42, 71).into_value();
let mut second = Srgba8::rgb(23, 42, 71).into_value();
let before = paints.entries.len();
let first_id = first.resolve(&mut paints).clone();
let second_id = second.resolve(&mut paints).clone();
assert_ne!(first_id, second_id);
assert_eq!(paints.entries.len(), before + 2);
}
#[test]
fn explicit_theme_paints_with_equal_values_remain_independent() {
let mut paints = Paints::new();
let first = paints.add(Srgba8::rgb(23, 42, 71));
let second = paints.add(Srgba8::rgb(23, 42, 71));
assert_ne!(first.slot(), second.slot());
}
}
+29 -18
View File
@@ -1,4 +1,4 @@
use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2};
use crate::{Align, GlyphAtlas, GlyphKey, PaintId, PlacedGlyph, RegionAlign, Textures, util::Vec2};
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
@@ -32,7 +32,7 @@ pub struct FontDiagnostics {
pub struct TextData {
pub font_cx: FontContext,
pub layout_cx: LayoutContext<UiColor>,
pub layout_cx: LayoutContext<PaintId>,
scale_cx: ScaleContext,
pub atlas: GlyphAtlas,
/// Physical pixels per dp -- a second copy of
@@ -240,8 +240,13 @@ impl TextData {
}
}
pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec<PlacedGlyph> {
pub fn place(
&mut self,
buffer: &TextBuffer,
textures: &mut Textures,
) -> (Vec<PlacedGlyph>, Vec<PaintId>) {
let mut placed = Vec::new();
let mut paints = Vec::new();
for line in buffer.layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else {
@@ -250,7 +255,10 @@ impl TextData {
let font = run.run().font();
let font_size = run.run().font_size();
let coords = run.run().normalized_coords();
let run_color = run.style().brush;
let run_color = run.style().brush.clone();
if !paints.contains(&run_color) {
paints.push(run_color.clone());
}
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
else {
continue;
@@ -301,12 +309,12 @@ impl TextData {
glyph.x.floor() + entry.left as f32,
glyph.y.floor() - entry.top as f32,
),
color: run_color,
paint: run_color.slot(),
});
}
}
}
placed
(placed, paints)
}
pub fn render(
@@ -318,11 +326,12 @@ impl TextData {
density: f32,
) -> RenderedText {
buffer.shape(self, attrs, width, density);
let glyphs = self.place(buffer, textures);
let (glyphs, paints) = self.place(buffer, textures);
RenderedText {
glyphs: std::sync::Arc::new(glyphs),
paints: std::sync::Arc::new(paints),
size: buffer.size(),
color: attrs.color,
color: attrs.color.clone(),
generation: self.atlas.generation(),
}
}
@@ -359,7 +368,7 @@ impl Family {
#[derive(Clone, PartialEq)]
pub struct SpanStyle {
pub range: Range<usize>,
pub color: Option<UiColor>,
pub color: Option<PaintId>,
pub family: Option<Family>,
pub font_size: Option<f32>,
pub bold: bool,
@@ -379,7 +388,7 @@ impl SpanStyle {
underline: false,
}
}
pub fn color(mut self, color: UiColor) -> Self {
pub fn color(mut self, color: PaintId) -> Self {
self.color = Some(color);
self
}
@@ -407,7 +416,7 @@ impl SpanStyle {
#[derive(Clone, PartialEq)]
pub struct TextAttrs {
pub color: UiColor,
pub color: PaintId,
pub font_size: f32,
pub line_height: f32,
pub family: Family,
@@ -421,7 +430,7 @@ impl Default for TextAttrs {
fn default() -> Self {
let size = 16.0;
Self {
color: UiColor::WHITE,
color: PaintId::WHITE,
font_size: size,
line_height: size * LINE_HEIGHT_MULT,
family: Family::SansSerif,
@@ -436,7 +445,7 @@ impl Default for TextAttrs {
/// them apart is how they get out of step.
pub struct TextBuffer {
text: String,
layout: Layout<UiColor>,
layout: Layout<PaintId>,
spans: Vec<SpanStyle>,
shaped: Option<(TextAttrs, Option<f32>, f32)>,
}
@@ -464,7 +473,7 @@ impl TextBuffer {
&self.text
}
pub fn layout(&self) -> &Layout<UiColor> {
pub fn layout(&self) -> &Layout<PaintId> {
&self.layout
}
@@ -515,11 +524,11 @@ impl TextBuffer {
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height * density,
)));
builder.push_default(StyleProperty::Brush(attrs.color));
builder.push_default(StyleProperty::Brush(attrs.color.clone()));
for (span, family) in self.spans.iter().zip(&span_families) {
let range = span.range.clone();
if let Some(color) = span.color {
builder.push(StyleProperty::Brush(color), range.clone());
if let Some(color) = &span.color {
builder.push(StyleProperty::Brush(color.clone()), range.clone());
}
if let Some(family) = family {
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
@@ -563,8 +572,10 @@ fn hash_coords(coords: &[i16]) -> u64 {
#[derive(Clone)]
pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
/// The unique handles whose compact slots the glyphs above carry.
pub paints: std::sync::Arc<Vec<PaintId>>,
pub size: Vec2,
pub color: UiColor,
pub color: PaintId,
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
/// A holder must re-render rather than re-emit these quads once the
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::{
PatchRect, TextureHandle, Textures, UiColor,
PatchRect, TextureHandle, Textures,
util::{HashMap, Vec2},
};
use image::RgbaImage;
@@ -202,5 +202,5 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
pub struct PlacedGlyph {
pub entry: GlyphEntry,
pub offset: Vec2,
pub color: UiColor,
pub paint: u32,
}
+78 -5
View File
@@ -30,6 +30,46 @@ pub use sdf::{distance_from_rect, rounded_rect_coverage};
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
/// The advertised swapchain format and the sRGB view Iris renders through.
/// A backend may advertise only the non-sRGB member of an RGBA/BGRA pair;
/// wgpu permits its sRGB counterpart as a configured view format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SurfaceFormat {
pub surface: TextureFormat,
pub view: TextureFormat,
}
pub fn srgb_surface_format(caps: &SurfaceCapabilities) -> Result<SurfaceFormat, String> {
let supports_srgb_space = |format| caps.color_spaces(format).contains(SurfaceColorSpaces::SRGB);
if let Some(surface) = caps
.formats
.iter()
.copied()
.find(|format| format.is_srgb() && supports_srgb_space(*format))
{
return Ok(SurfaceFormat {
surface,
view: surface,
});
}
if let Some(surface) = caps
.formats
.iter()
.copied()
.find(|format| format.add_srgb_suffix().is_srgb() && supports_srgb_space(*format))
{
return Ok(SurfaceFormat {
surface,
view: surface.add_srgb_suffix(),
});
}
Err(format!(
"the surface has no RGBA/BGRA format with an sRGB render view and sRGB output colour \
space; advertised default formats: {:?}",
caps.formats
))
}
pub fn device_limits() -> Limits {
Limits {
max_buffer_size: 1 << 30,
@@ -91,6 +131,7 @@ pub struct UiRenderNode {
instances: ArrBuf<PrimitiveInstance>,
masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>,
paints: ArrBuf<crate::LinearRgba>,
masks_layout: BindGroupLayout,
masks_group: BindGroup,
}
@@ -196,13 +237,16 @@ impl UiRenderNode {
let masks_resized = self.masks.update(device, queue, entries, dirty);
let (entries, dirty) = ui.move_offsets.for_upload();
let moves_resized = self.move_offsets.update(device, queue, entries, dirty);
if masks_resized || moves_resized || instances_resized {
let (entries, dirty) = ui.paints.for_upload();
let paints_resized = self.paints.update(device, queue, entries, dirty);
if masks_resized || moves_resized || instances_resized || paints_resized {
self.masks_group = Self::masks_group(
device,
&self.masks_layout,
&self.masks,
&self.move_offsets,
&self.instances,
&self.paints,
);
}
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
@@ -212,6 +256,7 @@ impl UiRenderNode {
FrameUpdateStats {
masks_resized,
moves_resized,
paints_resized,
}
}
@@ -231,7 +276,7 @@ impl UiRenderNode {
pub fn new(
device: &Device,
queue: &Queue,
config: &SurfaceConfiguration,
target_format: TextureFormat,
window_size: impl Into<Vec2>,
) -> Result<Self, String> {
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
@@ -305,12 +350,23 @@ impl UiRenderNode {
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let paints = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui paints",
);
let rsc_layout = Self::rsc_layout(device);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
let masks_layout = Self::masks_layout(device);
let masks_group =
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances);
let masks_group = Self::masks_group(
device,
&masks_layout,
&masks,
&move_offsets,
&instances,
&paints,
);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"),
@@ -335,7 +391,7 @@ impl UiRenderNode {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState {
format: config.format,
format: target_format,
blend: Some(BlendState::ALPHA_BLENDING),
write_mask: ColorWrites::ALL,
})],
@@ -386,6 +442,7 @@ impl UiRenderNode {
instances,
masks,
move_offsets,
paints,
masks_layout,
masks_group,
})
@@ -522,6 +579,16 @@ impl UiRenderNode {
},
count: None,
},
BindGroupLayoutEntry {
binding: 3,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
label: Some("ui masks"),
})
@@ -533,6 +600,7 @@ impl UiRenderNode {
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
instances: &ArrBuf<PrimitiveInstance>,
paints: &ArrBuf<crate::LinearRgba>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -549,6 +617,10 @@ impl UiRenderNode {
binding: 2,
resource: instances.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 3,
resource: paints.buffer.as_entire_binding(),
},
],
label: Some("ui masks"),
})
@@ -576,4 +648,5 @@ impl UiRenderNode {
pub struct FrameUpdateStats {
pub masks_resized: bool,
pub moves_resized: bool,
pub paints_resized: bool,
}
+9 -13
View File
@@ -1,7 +1,7 @@
use std::ops::Deref;
use crate::{
Color, UiRegion, WidgetId,
UiRegion, WidgetId,
render::{
ArrBuf,
data::{MaskIdx, MoveIdx, PrimitiveInstance},
@@ -554,16 +554,18 @@ primitives!(
#[repr(C)]
#[derive(Copy, Clone)]
pub struct RectPrimitive {
pub color: Color<u8>,
/// Index into the separate paint buffer. Geometry stays untouched when a
/// theme replaces the value at this index.
pub paint: u32,
pub radius: f32,
pub thickness: f32,
pub inner_radius: f32,
}
impl RectPrimitive {
pub fn color(color: Color<u8>) -> Self {
pub fn color(paint: u32) -> Self {
Self {
color,
paint,
radius: 0.0,
thickness: 0.0,
inner_radius: 0.0,
@@ -580,7 +582,7 @@ pub struct GlyphPrimitive {
/// not a bind-group or view index, since a page never gets one of its
/// own. See TEXTURES.md's "Recommended shape".
pub layer: u32,
pub color: Color<u8>,
pub paint: u32,
pub flags: u32,
_pad: u32,
}
@@ -588,18 +590,12 @@ pub struct GlyphPrimitive {
impl GlyphPrimitive {
pub const IS_COLOR: u32 = 1;
pub fn new(
uv_min: [f32; 2],
uv_max: [f32; 2],
layer: u32,
color: Color<u8>,
flags: u32,
) -> Self {
pub fn new(uv_min: [f32; 2], uv_max: [f32; 2], layer: u32, paint: u32, flags: u32) -> Self {
Self {
uv_min,
uv_max,
layer,
color,
paint,
flags,
_pad: 0,
}
+9 -4
View File
@@ -11,7 +11,7 @@ var<storage> rects: array<Rect>;
var<storage> glyphs: array<GlyphInfo>;
struct Rect {
color: u32,
paint: u32,
radius: f32,
thickness: f32,
inner_radius: f32,
@@ -22,7 +22,7 @@ struct GlyphInfo {
uv_max: vec2<f32>,
// A layer in the shared atlas array, not a bind-group index.
layer: u32,
color: u32,
paint: u32,
flags: u32,
}
@@ -64,6 +64,11 @@ var<storage> move_offsets: array<MoveOffset>;
// Shared by the vertex stage's drawn primitive and the fragment stage's mask shape.
@group(3) @binding(2)
var<storage> instances: array<PrimitiveInstance>;
// Solid linear RGBA today. Primitives already refer to paint records rather
// than embedding colours so gradients and texture fills can extend this
// lookup without rewriting geometry.
@group(3) @binding(3)
var<storage> paints: array<vec4<f32>>;
// Keep synchronized with render_state.rs. The bound prevents a malformed
// parent cycle from hanging the GPU; real widget trees have exceeded 16.
@@ -222,7 +227,7 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
if (g.flags & 1u) != 0u {
return texel;
}
var color = unpack4x8unorm(g.color);
var color = paints[g.paint];
color.a *= texel.a;
return color;
}
@@ -242,7 +247,7 @@ fn rounded_rect_coverage(
}
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
var color = unpack4x8unorm(rect.color);
var color = paints[rect.paint];
let edge = 0.5;
+10 -3
View File
@@ -271,7 +271,11 @@ impl GpuTextures {
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
// `image` and swash colour-glyph bytes are encoded sRGB.
// Sampling this view decodes RGB to the linear-light values
// used by the paint buffer and render pipeline; alpha stays
// linear.
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
},
@@ -337,7 +341,10 @@ impl GpuTextures {
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
// One array contains both alpha-mask glyphs and colour glyphs.
// sRGB decoding leaves mask pages' white RGB and alpha unchanged
// while correctly decoding colour-glyph RGB.
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_DST
| TextureUsages::COPY_SRC,
@@ -430,7 +437,7 @@ pub fn null_texture_view(device: &Device) -> TextureView {
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
+6 -1
View File
@@ -1,5 +1,6 @@
use crate::{
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
LayerId, MaskIdx, MoveIdx, PaintId, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId,
util::Vec2,
};
#[derive(Debug)]
@@ -8,6 +9,10 @@ pub struct ActiveData {
pub region: UiRegion,
pub parent: Option<WidgetId>,
pub textures: Vec<TextureHandle>,
/// Paint slots retained by this draw. The GPU primitive stores only the
/// slot index, so these handles are what prevent a live primitive from
/// observing a recycled paint.
pub paints: Vec<PaintId>,
pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>,
pub size_dependencies: Vec<WidgetId>,
+3 -1
View File
@@ -1,5 +1,5 @@
use crate::{
Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
Mask, MoveOffset, Paints, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
};
mod access;
@@ -15,6 +15,7 @@ pub use render_state::*;
#[derive(Default)]
pub struct UiData {
pub widgets: Widgets,
pub paints: Paints,
pub textures: Textures,
pub text: TextData,
pub masks: TrackedArena<Mask, u32>,
@@ -70,5 +71,6 @@ pub trait UiRsc {
self.on_remove(id);
}
self.ui_mut().textures.free();
self.ui_mut().paints.free_released();
}
}
+22 -3
View File
@@ -1,5 +1,5 @@
use crate::{
Axis, Color, Len, MoveOffset, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs,
Axis, Len, MoveOffset, PaintId, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs,
TextBuffer, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2,
WidgetId,
render::{
@@ -20,6 +20,7 @@ pub struct Painter<'a> {
pub(super) child_move_slot: Option<MoveIdx>,
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) paints: Vec<PaintId>,
pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
pub(super) children: Vec<WidgetId>,
@@ -118,6 +119,20 @@ impl<'a> Painter<'a> {
self.primitive_at(primitive, self.region)
}
/// Resolves a public paint handle to the compact index stored by a GPU
/// primitive and retains the handle for exactly as long as that draw.
pub fn paint(&mut self, paint: &PaintId) -> u32 {
if !self.paints.contains(paint) {
self.paints.push(paint.clone());
}
paint.slot()
}
pub fn paint_value(&mut self, paint: &mut crate::PaintValue) -> u32 {
let paint = paint.resolve(&mut self.rsc.ui_mut().paints).clone();
self.paint(&paint)
}
pub fn primitive_within<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
self.primitive_at(primitive, region.within(&self.region));
}
@@ -128,7 +143,8 @@ impl<'a> Painter<'a> {
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) {
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
let paint = self.paint(&PaintId::NONE);
let shape = self.write_primitive(RectPrimitive::color(paint), region, Drawn::No);
self.set_mask_to(shape);
}
@@ -433,6 +449,9 @@ impl<'a> Painter<'a> {
0
}
};
for paint in text.paints.iter() {
self.paint(paint);
}
for glyph in text.glyphs.iter() {
let mut region = origin;
region.x.end = region.x.start;
@@ -445,7 +464,7 @@ impl<'a> Painter<'a> {
glyph.entry.uv_min,
glyph.entry.uv_max,
glyph.entry.layer,
glyph.color,
glyph.paint,
flags_for(glyph.entry.is_color),
),
region,
+44 -2
View File
@@ -2,8 +2,8 @@ use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::{
ActiveData, Axis, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
Size, StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
ActiveData, Axis, ChildOrder, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers,
RegionAlign, Size, StrongWidget, UiRegion, UiRsc, UiVec2, Widget, WidgetId, Widgets,
render::{
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
RectPrimitive, rounded_rect_coverage,
@@ -73,6 +73,7 @@ pub(crate) struct Retained {
pub child_move_slot: Option<MoveIdx>,
pub own_mask: MaskIdx,
pub primitives: Vec<PrimitiveHandle>,
pub paints: Vec<crate::PaintId>,
}
impl Default for Retained {
@@ -84,6 +85,7 @@ impl Default for Retained {
child_move_slot: None,
own_mask: MaskIdx::NONE,
primitives: Vec::new(),
paints: Vec::new(),
}
}
}
@@ -350,6 +352,7 @@ impl UiRenderState {
mut child_move_slot,
mut own_mask,
primitives: mut recycle,
paints: _old_paints,
} = retained;
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
let requires_exact_region = rsc
@@ -447,6 +450,7 @@ impl UiRenderState {
layer,
id,
textures: Vec::new(),
paints: Vec::new(),
primitives: Vec::new(),
recycle: recycle.into_iter().peekable(),
children: Vec::new(),
@@ -498,6 +502,7 @@ impl UiRenderState {
child_move_slot,
own_mask,
textures,
paints,
primitives,
recycle,
children,
@@ -517,6 +522,7 @@ impl UiRenderState {
region,
parent,
textures,
paints,
primitives,
children,
size_dependencies,
@@ -1046,6 +1052,41 @@ impl UiRenderState {
Some(region.to_px(self.output_size))
}
/// This widget's immediate rendered children in the order non-visual
/// consumers should traverse them. The ordinary case costs no sort and
/// exactly preserves draw order; axis-aware layout widgets ask for their
/// resolved screen positions only when somebody actually queries them.
pub fn ordered_children(&self, id: WidgetId, rsc: &dyn UiRsc) -> Vec<WidgetId> {
let Some(active) = self.active.get(&id) else {
return Vec::new();
};
let mut children = active.children.clone();
// A layout may draw a child to learn its size and then place that
// retained drawing. Both operations are recorded for redraw lifetime,
// but a semantic traversal visits the child once.
let mut seen = HashSet::default();
children.retain(|child| seen.insert(*child));
let order = rsc
.widgets()
.get_dyn(id)
.map(Widget::child_order)
.unwrap_or_default();
if let ChildOrder::Axis(axis) = order {
children.sort_by(|a, b| {
let at = self
.window_region(a, rsc)
.map(|r| r.top_left.axis(axis))
.unwrap_or(f32::INFINITY);
let bt = self
.window_region(b, rsc)
.map(|r| r.top_left.axis(axis))
.unwrap_or(f32::INFINITY);
at.total_cmp(&bt)
});
}
children
}
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
rsc.widgets_mut().needs_redraw.remove(&id);
if self.draw_started.contains(&id) {
@@ -1075,6 +1116,7 @@ impl UiRenderState {
child_move_slot: active.child_move_slot,
own_mask: active.own_mask,
primitives: active.primitives,
paints: active.paints,
},
rsc,
);
+14
View File
@@ -15,6 +15,16 @@ pub use tag::*;
pub use view::*;
pub use widgets::*;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ChildOrder {
/// The order in which the parent drew its children.
#[default]
Draw,
/// Ascending visual position on one screen axis. Equal positions keep
/// draw order; the resolved coordinates are sorted only when queried.
Axis(Axis),
}
pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter);
@@ -34,6 +44,10 @@ pub trait Widget: Any {
accesskit::Role::Unknown
}
fn child_order(&self) -> ChildOrder {
ChildOrder::Draw
}
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
false
+4 -4
View File
@@ -27,11 +27,11 @@ fn row_image(i: usize) -> image::DynamicImage {
fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
let tint = if i.is_multiple_of(2) {
Color::rgb(120, 130, 170)
Srgba8::rgb(120, 130, 170)
} else {
Color::rgb(70, 80, 140)
Srgba8::rgb(70, 80, 140)
};
let text_color = Color::BLACK;
let text_color = PaintId::BLACK;
if i.is_multiple_of(IMAGE_EVERY) {
let text = wtext(row_text(i))
.wrap(true)
@@ -77,7 +77,7 @@ impl DefaultAppState for State {
let root = list
.scrollable()
.masked()
.background(rect(Color::WHITE))
.background(rect(PaintId::WHITE))
.add_strong(rsc);
ui_state.set_root(root.any());
+1 -1
View File
@@ -15,7 +15,7 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
rect(Color::RED).set_root(rsc, &mut ui_state);
rect(PaintId::RED).set_root(rsc, &mut ui_state);
Self { ui_state }
}
}
+4 -4
View File
@@ -16,15 +16,15 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let rect = rect(Color::RED).add(rsc);
let rect = rect(PaintId::RED).add(rsc);
rect.task_on(CursorSense::click(), async move |mut ctx| {
tokio::time::sleep(Duration::from_secs(1)).await;
ctx.update(move |_, rsc| {
let rect = rect(rsc);
if rect.color == Color::RED {
rect.color = Color::BLUE;
if rect.is_paint(&PaintId::RED) {
rect.set_paint(PaintId::BLUE);
} else {
rect.color = Color::RED;
rect.set_paint(PaintId::RED);
}
});
})
+3 -3
View File
@@ -20,7 +20,7 @@ struct Test {
impl Test {
pub fn new(rsc: &mut Rsc) -> Self {
let root = rect(Color::RED).add(rsc);
let root = rect(PaintId::RED).add(rsc);
let cur = rsc.create_state(root, false);
Self { root, cur }
}
@@ -28,9 +28,9 @@ impl Test {
let cur = &mut rsc[self.cur];
*cur = !*cur;
if *cur {
rsc[self.root].color = Color::BLUE;
rsc[self.root].set_paint(PaintId::BLUE);
} else {
rsc[self.root].color = Color::RED;
rsc[self.root].set_paint(PaintId::RED);
}
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("Add task").add(&mut rsc);
let leaf: WeakWidget<Rect> = rect(PaintId::WHITE).label("Add task").add(&mut rsc);
let root = leaf.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
@@ -40,7 +40,7 @@ fn a_widget_with_no_label_never_reaches_the_tree() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let root = rsc.ui.widgets.add_strong(rect(UiColor::WHITE));
let root = rsc.ui.widgets.add_strong(rect(PaintId::WHITE));
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root.any(), &mut rsc);
@@ -58,7 +58,7 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("thing").add(&mut rsc);
let leaf: WeakWidget<Rect> = rect(PaintId::WHITE).label("thing").add(&mut rsc);
let leaf_strong = leaf.upgrade(&mut rsc).any();
let offset = rsc.ui.widgets.add_strong(Offset {
inner: leaf_strong,
+29 -17
View File
@@ -4,7 +4,7 @@ use android_view::{
jni::{JavaVM, objects::GlobalRef},
ndk::native_window::NativeWindow,
};
use iris_core::{FrameParts, UiData, UiRenderNode, UiRenderState};
use iris_core::{FrameParts, LinearRgba, UiData, UiRenderNode, UiRenderState};
use pollster::FutureExt;
use std::time::Instant;
use wgpu::{
@@ -12,7 +12,7 @@ use wgpu::{
*,
};
pub const CLEAR_COLOR: Color = Color::BLACK;
pub const CLEAR_COLOR: LinearRgba = LinearRgba::BLACK;
/// `NativeWindow` (from the surface android-view hands over in
/// `surfaceChanged`) has a window handle but not a display one -- there is
@@ -46,6 +46,7 @@ pub struct AndroidRenderer {
device: Device,
queue: Queue,
config: SurfaceConfiguration,
view_format: TextureFormat,
encoder: CommandEncoder,
pub ui: UiRenderNode,
pub adapter_name: String,
@@ -73,6 +74,7 @@ pub struct AndroidRenderer {
pub struct FrameDiagnostics {
pub masks_resized: bool,
pub moves_resized: bool,
pub paints_resized: bool,
pub atlas_pages_grown_prev: u64,
pub image_bind_group_creates_prev: u64,
}
@@ -189,30 +191,33 @@ impl AndroidRenderer {
);
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let formats = iris_core::srgb_surface_format(&surface_caps)?;
log::info!(
"iris renderer: surface={:?}, view={:?}, color_space=Srgb",
formats.surface,
formats.view,
);
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
format: formats.surface,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
color_space: SurfaceColorSpace::Srgb,
width,
height,
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2,
view_formats: vec![],
view_formats: (formats.view != formats.surface)
.then_some(formats.view)
.into_iter()
.collect(),
};
surface.configure(&device, &config);
let encoder = Self::create_encoder(&device);
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
let ui = match UiRenderNode::new(&device, &queue, formats.view, window_size) {
Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
};
@@ -222,6 +227,7 @@ impl AndroidRenderer {
device,
queue,
config,
view_format: formats.view,
encoder,
ui,
adapter_name,
@@ -289,8 +295,10 @@ impl AndroidRenderer {
format!(
"iris diagnostics. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver}\n\
surface: {surface:?}, view: {view:?}, color_space: Srgb\n\
content_scale: {content_scale}\n\
atlas format: Rgba8Unorm, views live: {views}\n\
paint format: linear vec4<f32>\n\
atlas/image format: Rgba8UnormSrgb, views live: {views}\n\
fonts: {families_found} families found, default={default_family:?} \
mono={default_mono_family:?}\n\
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
@@ -301,6 +309,8 @@ impl AndroidRenderer {
name = self.adapter_name,
backend = self.adapter_backend,
driver = self.adapter_driver,
surface = self.config.format,
view = self.view_format,
content_scale = self.content_scale,
views = self.ui.view_count(),
families_found = font.families_found,
@@ -328,6 +338,7 @@ impl AndroidRenderer {
FrameDiagnostics {
masks_resized: stats.masks_resized,
moves_resized: stats.moves_resized,
paints_resized: stats.paints_resized,
atlas_pages_grown_prev,
image_bind_group_creates_prev,
}
@@ -348,9 +359,10 @@ impl AndroidRenderer {
other => panic!("no surface texture to draw into: {other:?}"),
};
let acquire = acquire_start.elapsed();
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let view = output.texture.create_view(&TextureViewDescriptor {
format: Some(self.view_format),
..Default::default()
});
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{
@@ -359,7 +371,7 @@ impl AndroidRenderer {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR),
load: LoadOp::Clear(CLEAR_COLOR.to_wgpu()),
store: StoreOp::Store,
},
depth_slice: None,
+3
View File
@@ -383,10 +383,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
log::info!(
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
paints_resized={} \
atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}",
renderer.frame_count(),
frame_diagnostics.masks_resized,
frame_diagnostics.moves_resized,
frame_diagnostics.paints_resized,
frame_diagnostics.atlas_pages_grown_prev,
frame_diagnostics.image_bind_group_creates_prev,
renderer.wgpu_errors.snapshot().len(),
@@ -698,6 +700,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
self.rsc.ui.text.atlas.page_count(),
);
self.rsc.ui.textures.reupload();
self.rsc.ui.paints.reupload();
self.state.android_state_mut().renderer = Some(renderer);
self.render(ctx, Instant::now());
}
+7 -1
View File
@@ -99,7 +99,7 @@ where
/// whatever is behind the field (a list to pan) still sees every frame of
/// it, the same as a drag that never touched a selectable field at all.
fn on_press(
rsc: &mut impl UiRsc,
rsc: &mut impl HasEvents,
render: &UiRenderState,
state: &mut impl FocusHost,
id: WeakWidget<TextEdit>,
@@ -107,6 +107,12 @@ fn on_press(
size: Vec2,
sense: CursorSense,
) {
if sense == CursorSense::PressStart(CursorButton::Left) {
// An editable field becomes the command destination of the new
// interaction. Dismiss a retained display-text selection first so
// Copy cannot keep going to text the user has visibly left behind.
rsc.run_command(Command::Escape);
}
if state.is_focused(id) {
// Already focused, so there is no keyboard to withhold -- but a
// vertical drag still is not a selection. Android's own `EditText`
+28 -1
View File
@@ -344,7 +344,34 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state.window.request_redraw();
}
WindowEvent::KeyboardInput { event, .. } => {
if let Some(sel) = ui_state.focus
let requested = event.state.is_pressed().then(|| match &event.logical_key {
winit::keyboard::Key::Character(c) if ui_state.input.modifiers.control => {
match c.as_str().to_ascii_lowercase().as_str() {
"c" => Some(Command::Copy),
"a" => Some(Command::SelectAll),
_ => None,
}
}
winit::keyboard::Key::Named(winit::keyboard::NamedKey::Escape) => {
Some(Command::Escape)
}
_ => None,
});
let command = requested
.flatten()
.map_or(CommandResult::Unused, |command| rsc.run_command(command));
let command_used = match command {
CommandResult::Copy(text) => {
if let Err(err) = ui_state.clipboard.set_text(text) {
eprintln!("failed to copy text to clipboard: {err}")
}
true
}
CommandResult::Used => true,
CommandResult::Unused => false,
};
if !command_used
&& let Some(sel) = ui_state.focus
&& event.state.is_pressed()
{
let mut text = sel.edit(rsc);
+23 -16
View File
@@ -1,12 +1,12 @@
use crate::task::RequestRedraw;
use iris_core::{FrameParts, UiData, UiRenderNode, UiRenderState, util::Vec2};
use iris_core::{FrameParts, LinearRgba, UiData, UiRenderNode, UiRenderState, util::Vec2};
use pollster::FutureExt;
use std::sync::Arc;
use std::time::Instant;
use wgpu::*;
use winit::{dpi::PhysicalSize, window::Window};
pub const CLEAR_COLOR: Color = Color::BLACK;
pub const CLEAR_COLOR: LinearRgba = LinearRgba::BLACK;
impl RequestRedraw for Window {
fn request_redraw(&self) {
@@ -20,6 +20,7 @@ pub struct UiRenderer {
device: Device,
queue: Queue,
config: SurfaceConfiguration,
view_format: TextureFormat,
encoder: CommandEncoder,
pub ui: UiRenderNode,
}
@@ -45,9 +46,10 @@ impl UiRenderer {
other => panic!("no surface texture to draw into: {other:?}"),
};
let acquire = acquire_start.elapsed();
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let view = output.texture.create_view(&TextureViewDescriptor {
format: Some(self.view_format),
..Default::default()
});
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{
@@ -56,7 +58,7 @@ impl UiRenderer {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR),
load: LoadOp::Clear(CLEAR_COLOR.to_wgpu()),
store: StoreOp::Store,
},
depth_slice: None,
@@ -170,18 +172,19 @@ impl UiRenderer {
.expect("Could not get device!");
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let formats = iris_core::srgb_surface_format(&surface_caps)
.expect("Could not select an sRGB iris surface format");
log::info!(
"iris renderer: surface={:?}, view={:?}, color_space=Srgb",
formats.surface,
formats.view,
);
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
format: formats.surface,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
color_space: SurfaceColorSpace::Srgb,
width: size.width,
height: size.height,
// Vsync, because a toolkit aiming at battery life must not present
@@ -192,7 +195,10 @@ impl UiRenderer {
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2,
view_formats: vec![],
view_formats: (formats.view != formats.surface)
.then_some(formats.view)
.into_iter()
.collect(),
};
surface.configure(&device, &config);
@@ -210,7 +216,7 @@ impl UiRenderer {
// `default::content_scale` for why this backend stopped dividing
// into a separate logical space, and what disagreed while it did.
let physical_size = Vec2::new(size.width as f32, size.height as f32);
let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
let ui = UiRenderNode::new(&device, &queue, formats.view, physical_size)
.expect("Could not create iris render node!");
Self {
@@ -218,6 +224,7 @@ impl UiRenderer {
device,
queue,
config,
view_format: formats.view,
encoder,
ui,
window,
+11
View File
@@ -36,6 +36,17 @@ pub trait Eventable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
}
impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {}
pub trait Controllable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
fn controller<C: Controller<Rsc>>(self, controller: C) -> impl WidgetIdFn<Rsc, Self::Widget> {
move |rsc| {
let id = self.add(rsc);
rsc.register_controller(id, controller);
id
}
}
}
impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Controllable<Rsc, Tag> for WL {}
widget_trait! {
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
fn task_on<E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
+28 -27
View File
@@ -19,8 +19,9 @@ struct FixedRect(f32);
impl Widget for FixedRect {
fn draw(&mut self, painter: &mut Painter) {
let size = Size::from_axis(Axis::Y, Len::abs(self.0), Len::REST);
let paint = painter.paint(&PaintId::WHITE);
painter.primitive_within(
RectPrimitive::color(UiColor::WHITE),
RectPrimitive::color(paint),
size.to_uivec2(painter.density())
.align(RegionAlign::TOP_LEFT),
);
@@ -89,8 +90,8 @@ fn a_widget_retains_its_entry_layer_not_its_child_cursor() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let back = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let front = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let back = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let front = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED));
let stack = rsc.ui.widgets.add_strong(Stack {
children: vec![back.any(), front.any()],
size: StackSize::Default,
@@ -277,7 +278,7 @@ fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
ui: UiData::default(),
};
let first = rsc.ui.widgets.add_strong(FixedRect(40.0));
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let fill = rsc.ui.widgets.add_strong(Sized {
inner: fill.any(),
x: None,
@@ -309,7 +310,7 @@ fn scrolled_rects(
let mut span = Span::empty(Dir::DOWN);
let mut rects = Vec::with_capacity(n);
for _ in 0..n {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
rects.push(rect.weak());
let row = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
@@ -459,13 +460,13 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget)
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(UiColor::WHITE)
.color(PaintId::WHITE)
.add(rsc);
let bar = (field.pad(dp(12)).width(rest(1)),)
.span(Dir::RIGHT)
.background(rect(UiColor::new(40, 40, 46, 255)))
.background(rect(Srgba8::new(40, 40, 46, 255)))
.add(rsc);
let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc);
let list_stand_in = rect(PaintId::BLACK).height(rest(1)).add(rsc);
let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any();
(field, tree)
}
@@ -517,7 +518,7 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
@@ -567,7 +568,7 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
@@ -609,7 +610,7 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
inner: inner_root,
});
let masked_id = masked.id();
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Rect::new(PaintId::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
x: None,
@@ -650,7 +651,7 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
@@ -662,7 +663,7 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
y: Some(Len::dp(100.0)),
});
let capped_w = capped.weak();
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Rect::new(PaintId::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
x: None,
@@ -692,14 +693,14 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
let mut rsc = TestRsc {
ui: UiData::default(),
};
let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let top = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let spacer = rsc.ui.widgets.add_strong(Sized {
inner: top.any(),
x: None,
y: Some(Len::abs(100.0)),
});
let spacer_w = spacer.weak();
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let below = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let below_w = below.weak();
let mut span = Span::empty(Dir::DOWN);
span.push(spacer.any());
@@ -759,7 +760,7 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let child = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
@@ -800,12 +801,12 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
const RADIUS: f32 = 20.0;
fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) {
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let child_id = child.id();
let shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
.add_strong(Rect::new(PaintId::BLACK).radius(Len::abs(RADIUS)));
let shape_id = shape.id();
let root = rsc
.ui
@@ -909,13 +910,13 @@ fn nested_masks_multiply_their_coverage() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let child_id = child.id();
let inner_shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
.add_strong(Rect::new(PaintId::BLACK).radius(Len::abs(RADIUS)));
let inner_shape_id = inner_shape.id();
let inner = rsc.ui.widgets.add_strong(Masked {
shape: Some(inner_shape.any()),
@@ -924,7 +925,7 @@ fn nested_masks_multiply_their_coverage() {
let outer_shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
.add_strong(Rect::new(PaintId::BLACK).radius(Len::abs(RADIUS)));
let outer_shape_id = outer_shape.id();
let root = rsc
.ui
@@ -1004,7 +1005,7 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any();
let tall = rsc.ui.widgets.add_strong(Sized {
inner: fill,
x: None,
@@ -1039,7 +1040,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let header_fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)).any();
let header_fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED)).any();
let header_id = header_fill.id();
let header = rsc.ui.widgets.add_strong(Sized {
inner: header_fill,
@@ -1049,7 +1050,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
let mut inner = Span::empty(Dir::DOWN);
let mut rects = Vec::new();
for _ in 0..3 {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
rects.push(rect.weak());
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
@@ -1061,7 +1062,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
inner: sized.any(),
exact_region: false,
});
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::BLUE)).any();
let card = rsc.ui.widgets.add_strong(Stack {
children: vec![fill, padded.any()],
size: StackSize::Child(1),
@@ -1122,7 +1123,7 @@ fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let first = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let first = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let first_id = first.id();
let first = rsc.ui.widgets.add_strong(Sized {
inner: first.any(),
@@ -1146,7 +1147,7 @@ fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() {
render.resize((200.0, 200.0));
render.update(&root, &mut rsc);
let second = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let second = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let second_id = second.id();
let second = rsc.ui.widgets.add_strong(Sized {
inner: second.any(),
+1 -1
View File
@@ -1986,7 +1986,7 @@ mod drag_gesture_tests {
}
fn some_id(ui: &mut UiData) -> WidgetId {
ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id()
ui.widgets.add_strong(Rect::new(PaintId::WHITE)).id()
}
#[test]
+10 -10
View File
@@ -55,9 +55,9 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
};
// the case in IRIS_TODO.md's report.
let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let list = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let list_weak = list.weak();
let button = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let button = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED));
let button_weak = button.weak();
let scrolled = Rc::new(Cell::new(false));
@@ -125,7 +125,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
events: EventManager::default(),
};
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let draggable = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any();
let draggable_weak = draggable.weak();
let dropped = Rc::new(Cell::new(false));
@@ -184,9 +184,9 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
events: EventManager::default(),
};
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let a = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let a_weak = a.weak();
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let b = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED));
let b_weak = b.weak();
let b_hovered = Rc::new(Cell::new(false));
@@ -229,7 +229,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
events: EventManager::default(),
};
let scroll_strong = rect(UiColor::WHITE)
let scroll_strong = rect(PaintId::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
@@ -319,7 +319,7 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
ui: UiData::default(),
events: EventManager::default(),
};
let scroll_strong = rect(UiColor::WHITE)
let scroll_strong = rect(PaintId::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
@@ -374,7 +374,7 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
events: EventManager::default(),
};
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let capturer = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let capturer_weak = capturer.weak();
let bystander = rsc.ui.widgets.add_strong(Stack {
children: vec![capturer.any()],
@@ -474,7 +474,7 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
};
let seen = Rc::new(Cell::new(None));
let record = seen.clone();
let outer_strong = rect(UiColor::WHITE)
let outer_strong = rect(PaintId::WHITE)
.width(Len::abs(1000.0))
.height(Len::abs(1000.0))
.scrollable(Axis::X, Pin::Start)
@@ -533,7 +533,7 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
let record = slot.clone();
rect(UiColor::WHITE)
rect(PaintId::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.with_id(move |_rsc, id| {
+7 -3
View File
@@ -741,6 +741,10 @@ impl Scrollable for LazySpan {
}
impl Widget for LazySpan {
fn child_order(&self) -> ChildOrder {
ChildOrder::Axis(self.dir.axis)
}
/// A lazy span animates exactly one thing, its fling -- and it drives
/// its own rather than being handed deltas by a `ScrollArea` around
/// it, since which rows exist at all is a function of where it is
@@ -842,7 +846,7 @@ mod tests {
}
fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget<Sized>, StrongWidget) {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
@@ -1054,9 +1058,9 @@ mod tests {
rsc: &mut TestRsc,
height: f32,
) -> (WidgetId, WeakWidget<Sized>, StrongWidget) {
let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let bg = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let bg_id = bg.id();
let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let fg_rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let fg = rsc.ui.widgets.add_strong(Sized {
inner: fg_rect.any(),
x: None,
+1 -1
View File
@@ -98,7 +98,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any();
let id = fill.id();
let long = Some(Len::abs(1000.0));
let tall = rsc.ui.widgets.add_strong(Sized {
+4
View File
@@ -8,6 +8,10 @@ pub struct Span {
}
impl Widget for Span {
fn child_order(&self) -> ChildOrder {
ChildOrder::Axis(self.dir.axis)
}
fn draw(&mut self, painter: &mut Painter) {
let axis = self.dir.axis;
let gap = self.gap.apply_rest(painter.density()).abs;
+20 -9
View File
@@ -1,26 +1,36 @@
use crate::prelude::*;
#[derive(Clone, Copy)]
#[derive(Clone)]
pub struct Rect {
pub color: UiColor,
paint: PaintValue,
pub radius: Len,
pub thickness: f32,
pub inner_radius: f32,
}
impl Rect {
pub fn new(color: UiColor) -> Self {
pub fn new(paint: impl Paint) -> Self {
Self {
color,
paint: paint.into_value(),
radius: Len::ZERO,
inner_radius: 0.0,
thickness: 0.0,
}
}
pub fn color(mut self, color: UiColor) -> Self {
self.color = color;
pub fn paint(mut self, paint: impl Paint) -> Self {
self.paint = paint.into_value();
self
}
pub fn color(self, paint: impl Paint) -> Self {
self.paint(paint)
}
pub fn set_paint(&mut self, paint: impl Paint) {
self.paint = paint.into_value();
}
pub fn is_paint(&self, paint: &PaintId) -> bool {
self.paint.is(paint)
}
pub fn radius(mut self, radius: impl Into<Len>) -> Self {
self.radius = radius.into();
self
@@ -29,8 +39,9 @@ impl Rect {
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) {
let paint = painter.paint_value(&mut self.paint);
painter.primitive(RectPrimitive {
color: self.color,
paint,
radius: self.radius.fold_dp(painter.density()).abs,
thickness: self.thickness,
inner_radius: self.inner_radius,
@@ -43,6 +54,6 @@ impl Widget for Rect {
}
}
pub fn rect(color: UiColor) -> Rect {
Rect::new(color)
pub fn rect(paint: impl Paint) -> Rect {
Rect::new(paint)
}
+1 -1
View File
@@ -16,7 +16,7 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
self.attrs.line_height = self.attrs.font_size * LINE_HEIGHT_MULT;
self
}
pub fn color(mut self, color: UiColor) -> Self {
pub fn color(mut self, color: PaintId) -> Self {
self.attrs.color = color;
self
}
+48 -123
View File
@@ -1,7 +1,9 @@
use crate::prelude::*;
use iris_core::{TextData, UiColor};
use iris_core::{PaintId, TextData};
use parley::{Affinity, Layout, Selection};
use std::ops::{Deref, DerefMut};
use super::selection_layout;
#[cfg(not(target_os = "android"))]
use winit::{
event::KeyEvent,
@@ -22,10 +24,8 @@ pub enum Motion {
pub struct TextEdit {
view: TextView,
selection: Option<Selection>,
#[cfg_attr(target_os = "android", allow(dead_code))]
history: Vec<(String, Option<Selection>)>,
double_hit: Option<usize>,
pub(crate) press_origin: Option<Vec2>,
pub mode: EditMode,
}
@@ -40,20 +40,14 @@ impl TextEdit {
pub fn new(view: TextView, mode: EditMode) -> Self {
Self {
view,
selection: None,
history: Default::default(),
double_hit: None,
press_origin: None,
mode,
}
}
pub fn selected_text(&self) -> Option<String> {
let sel = self.selection?;
if sel.is_collapsed() {
return None;
}
Some(self.buf.text()[sel.text_range()].to_string())
self.view.selection.selected_text(self.view.buf.text())
}
/// The field's content. Byte-indexed, like everything else here since
@@ -67,46 +61,19 @@ impl TextEdit {
/// The selection as a byte range, collapsed to `caret..caret` when
/// there is no span. `None` when the field is not focused.
pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
Some(self.selection?.text_range())
self.view.selection.range()
}
/// The caret's byte offset -- the focus end of the selection, which is
/// where typing lands regardless of which end of a span it is.
pub fn caret(&self) -> Option<usize> {
Some(self.selection?.focus().index())
self.view.selection.caret()
}
}
impl Widget for TextEdit {
fn draw(&mut self, painter: &mut Painter) {
let base = painter.layer;
painter.child_layer();
let used = self.view.draw(painter);
painter.layer = base;
let region = self.region();
let Some(selection) = self.selection else {
painter.set_size(used);
return;
};
let layout = self.view.buf.layout();
for (rect, _) in selection.geometry(layout) {
let size = vec2(rect.width() as f32, rect.height() as f32);
let top_left = vec2(rect.x0 as f32, rect.y0 as f32);
painter.primitive_within(
RectPrimitive::color(Color::SKY),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
}
let caret = selection.focus().geometry(layout, CARET_WIDTH);
let size = vec2(caret.width() as f32, caret.height() as f32);
let top_left = vec2(caret.x0 as f32, caret.y0 as f32);
painter.primitive_within(
RectPrimitive::color(Color::WHITE),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
let used = self.view.draw_selectable(painter, true);
painter.set_size(used);
}
@@ -122,28 +89,26 @@ impl Widget for TextEdit {
}
}
const CARET_WIDTH: f32 = 1.0;
pub struct TextEditCtx<'a> {
pub text: &'a mut TextEdit,
pub data: &'a mut TextData,
}
impl<'a> TextEditCtx<'a> {
fn layout(&mut self) -> &Layout<UiColor> {
let attrs = self.text.view.attrs.clone();
let width = self.text.view.wrap_width();
let density = self.data.density;
self.text.view.buf.shape(self.data, &attrs, width, density);
self.text.view.buf.layout()
fn selection_ctx(&mut self) -> TextSelectionCtx<'_> {
TextSelectionCtx {
view: &mut self.text.view,
data: self.data,
}
}
fn layout(&mut self) -> &Layout<iris_core::PaintId> {
selection_layout(&mut self.text.view, self.data)
}
#[cfg_attr(target_os = "android", allow(dead_code))]
fn refresh(&mut self) {
if let Some(sel) = self.text.selection {
let layout = self.layout();
self.text.selection = Some(sel.refresh(layout));
}
self.selection_ctx().refresh();
}
pub fn take(&mut self) -> String {
@@ -156,18 +121,18 @@ impl<'a> TextEditCtx<'a> {
let text = self.string(text);
self.text.view.buf.set_text(text);
self.text.view.buf.changed = true;
self.text.selection = None;
self.text.view.selection.deselect();
}
pub fn set_with_spans(&mut self, text: &str, spans: Vec<SpanStyle>) {
let text = self.string(text);
self.text.view.buf.set_text(text);
self.text.view.buf.set_spans(spans);
self.text.selection = None;
self.text.view.selection.deselect();
}
pub fn motion(&mut self, motion: Motion, select: bool) {
let Some(sel) = self.text.selection else {
let Some(sel) = self.text.view.selection.range else {
return;
};
let layout = self.layout();
@@ -184,7 +149,7 @@ impl<'a> TextEditCtx<'a> {
} else {
apply_motion(sel, layout, motion, select)
};
self.text.selection = Some(sel);
self.text.view.selection.range = Some(sel);
}
pub fn replace(&mut self, len: usize, text: &str) {
@@ -213,7 +178,7 @@ impl<'a> TextEditCtx<'a> {
return;
}
self.clear_span();
let at = match self.text.selection {
let at = match self.text.view.selection.range {
Some(sel) => sel.focus().index(),
// No caret means nowhere to put the text, so this drops the
// keystroke -- which is invisible, and was the whole of the
@@ -238,7 +203,7 @@ impl<'a> TextEditCtx<'a> {
}
pub fn clear_span(&mut self) -> bool {
let Some(sel) = self.text.selection else {
let Some(sel) = self.text.view.selection.range else {
return false;
};
if sel.is_collapsed() {
@@ -252,13 +217,7 @@ impl<'a> TextEditCtx<'a> {
}
fn set_caret(&mut self, index: usize) {
let index = index.min(self.text.view.buf.text().len());
let layout = self.layout();
self.text.selection = Some(Selection::from_byte_index(
layout,
index,
Affinity::default(),
));
self.selection_ctx().set_caret(index);
}
pub fn newline(&mut self) {
@@ -271,7 +230,7 @@ impl<'a> TextEditCtx<'a> {
if self.clear_span() {
return;
}
let Some(sel) = self.text.selection else {
let Some(sel) = self.text.view.selection.range else {
return;
};
let end = sel.focus().index();
@@ -291,7 +250,7 @@ impl<'a> TextEditCtx<'a> {
if self.clear_span() {
return;
}
let Some(sel) = self.text.selection else {
let Some(sel) = self.text.view.selection.range else {
return;
};
let start = sel.focus().index();
@@ -350,67 +309,33 @@ impl<'a> TextEditCtx<'a> {
/// actually *on* something" checks its own ranges, which is what
/// makes a tap in the padding hit no link.
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
let pos = pos - self.text.region().top_left().to_abs(size);
let layout = self.layout();
Selection::from_point(layout, pos.x, pos.y).focus().index()
self.selection_ctx().byte_at(pos, size)
}
pub fn select_all(&mut self) {
let len = self.text.view.buf.text().len();
if len == 0 {
return;
}
let layout = self.layout();
let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default());
let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default());
self.text.selection = Some(Selection::new(anchor, focus));
self.selection_ctx().select_all();
}
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
let pos = pos - self.text.region().top_left().to_abs(size);
let prev_sel = self.text.selection;
let prev_hit = self.text.double_hit;
let outcome = {
let layout = self.layout();
if drag {
prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit))
} else {
let hit = Selection::from_point(layout, pos.x, pos.y);
let index = hit.focus().index();
Some(if recent && prev_hit == Some(index) {
(Some(Selection::line_from_point(layout, pos.x, pos.y)), None)
} else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) {
(
Some(Selection::word_from_point(layout, pos.x, pos.y)),
Some(index),
)
} else {
(Some(hit), None)
})
}
};
if let Some((selection, double_hit)) = outcome {
self.text.selection = selection;
self.text.double_hit = double_hit;
}
self.selection_ctx().select(pos, size, drag, recent);
}
pub fn deselect(&mut self) {
self.text.selection = None;
self.text.double_hit = None;
self.selection_ctx().deselect();
}
#[cfg(not(target_os = "android"))]
pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult {
let old = (self.text.view.buf.text().to_string(), self.text.selection);
let old = (
self.text.view.buf.text().to_string(),
self.text.view.selection.range,
);
let mut undo = false;
let res = self.apply_event_inner(event, modifiers, &mut undo);
if undo {
if let Some((old, selection)) = self.text.history.pop() {
self.set(&old);
self.text.selection = selection;
self.text.view.selection.range = selection;
self.refresh();
}
} else if self.text.view.buf.text() != old.0 {
@@ -495,7 +420,7 @@ impl<'a> TextEditCtx<'a> {
fn apply_motion(
sel: Selection,
layout: &Layout<UiColor>,
layout: &Layout<PaintId>,
motion: Motion,
extend: bool,
) -> Selection {
@@ -512,15 +437,15 @@ fn apply_motion(
}
trait RangeCursors {
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
fn start_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor;
fn end_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor;
}
impl RangeCursors for std::ops::Range<usize> {
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor {
fn start_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor {
parley::Cursor::from_byte_index(layout, self.start, Affinity::default())
}
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor {
fn end_cursor(&self, layout: &Layout<PaintId>) -> parley::Cursor {
parley::Cursor::from_byte_index(layout, self.end, Affinity::default())
}
}
@@ -605,7 +530,7 @@ mod tests {
ctx(&mut t, &mut d).set_caret(1);
ctx(&mut t, &mut d).insert("b");
assert_eq!(content(&t), "abc");
assert_eq!(t.selection.unwrap().focus().index(), 2);
assert_eq!(t.caret(), Some(2));
}
#[test]
@@ -655,14 +580,14 @@ mod tests {
ctx(&mut t, &mut d).select_all();
assert!(ctx(&mut t, &mut d).clear_span());
assert_eq!(content(&t), "");
assert_eq!(t.selection.unwrap().focus().index(), 0);
assert_eq!(t.caret(), Some(0));
}
#[test]
fn tapping_an_empty_field_places_a_caret_so_typing_lands() {
let (mut t, mut d) = edit("", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false);
assert!(t.selection.is_some(), "a tap must leave a caret behind");
assert!(t.caret().is_some(), "a tap must leave a caret behind");
ctx(&mut t, &mut d).insert("hi");
assert_eq!(content(&t), "hi");
}
@@ -671,14 +596,14 @@ mod tests {
fn tapping_past_the_end_of_the_text_clamps_to_the_end() {
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false);
assert_eq!(t.selection.unwrap().focus().index(), 3);
assert_eq!(t.caret(), Some(3));
}
#[test]
fn dragging_without_a_previous_selection_selects_nothing() {
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false);
assert!(t.selection.is_none());
assert!(t.selection_range().is_none());
}
#[test]
@@ -765,7 +690,7 @@ mod tests {
let (mut t, mut d) = edit("abc", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(0);
ctx(&mut t, &mut d).motion(Motion::Right, false);
assert_eq!(t.selection.unwrap().focus().index(), 1);
assert_eq!(t.caret(), Some(1));
ctx(&mut t, &mut d).motion(Motion::Right, true);
assert_eq!(t.selected_text().as_deref(), Some("b"));
}
@@ -775,11 +700,11 @@ mod tests {
let (mut t, mut d) = edit("abcdef", EditMode::SingleLine);
ctx(&mut t, &mut d).select_all();
ctx(&mut t, &mut d).motion(Motion::Left, false);
assert_eq!(t.selection.unwrap().focus().index(), 0);
assert_eq!(t.caret(), Some(0));
ctx(&mut t, &mut d).select_all();
ctx(&mut t, &mut d).motion(Motion::Right, false);
assert_eq!(t.selection.unwrap().focus().index(), 6);
assert_eq!(t.caret(), Some(6));
}
#[test]
+146 -2
View File
@@ -1,9 +1,11 @@
mod build;
mod edit;
mod selection;
pub use build::*;
pub use edit::*;
use iris_core::util::MutDetect;
pub use selection::*;
use crate::prelude::*;
use std::ops::{Deref, DerefMut};
@@ -19,6 +21,7 @@ pub struct TextView {
tex: Option<RenderedText>,
width: Option<f32>,
pub hint: Option<StrongWidget>,
selection: TextSelection,
}
impl TextView {
@@ -37,6 +40,7 @@ impl TextView {
tex: None,
width: None,
hint,
selection: TextSelection::default(),
}
}
@@ -94,6 +98,39 @@ impl TextView {
Size::abs(tex.size)
}
pub(super) fn draw_selectable(&mut self, painter: &mut Painter, caret: bool) -> Size {
let base = painter.layer;
painter.child_layer();
let used = self.draw(painter);
painter.layer = base;
let region = self.region();
let Some(selection) = self.selection.range else {
return used;
};
let layout = self.buf.layout();
for (rect, _) in selection.geometry(layout) {
let size = vec2(rect.width() as f32, rect.height() as f32);
let top_left = vec2(rect.x0 as f32, rect.y0 as f32);
let paint = painter.paint(&PaintId::SKY);
painter.primitive_within(
RectPrimitive::color(paint),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
}
if caret {
let caret = selection.focus().geometry(layout, CARET_WIDTH);
let size = vec2(caret.width() as f32, caret.height() as f32);
let top_left = vec2(caret.x0 as f32, caret.y0 as f32);
let paint = painter.paint(&PaintId::WHITE);
painter.primitive_within(
RectPrimitive::color(paint),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
}
used
}
pub fn content(&self) -> String {
self.buf.text().to_string()
}
@@ -111,14 +148,36 @@ impl Text {
if self.content.changed {
self.content.changed = false;
self.view.buf.set_text(self.content.as_str());
self.view.selection.deselect();
}
}
pub fn selected_text(&self) -> Option<String> {
self.view.selection.selected_text(self.view.buf.text())
}
pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
self.view.selection.range()
}
pub fn set_with_spans(&mut self, content: impl Into<String>, spans: Vec<SpanStyle>) {
let content = content.into();
*self.content = content.clone();
self.content.changed = false;
self.view.buf.set_text(content);
self.view.buf.set_spans(spans);
self.view.selection.deselect();
}
}
impl Widget for Text {
fn draw(&mut self, painter: &mut Painter) {
self.update_buf();
let size = self.view.draw(painter);
let size = if self.view.selection.range.is_some() {
self.view.draw_selectable(painter, false)
} else {
self.view.draw(painter)
};
painter.set_size(size);
}
@@ -127,6 +186,8 @@ impl Widget for Text {
}
}
pub(super) const CARET_WIDTH: f32 = 1.0;
impl Deref for Text {
type Target = TextAttrs;
@@ -160,6 +221,89 @@ mod tests {
use crate::layout_tests::TestRsc;
use crate::prelude::*;
fn rendered_text(content: &str) -> (TestRsc, UiRenderState, WeakWidget<Text>, StrongWidget) {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let text = wtext(content).add_strong(&mut rsc);
let id = text.weak();
let root = text.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
(rsc, render, id, root)
}
#[test]
fn display_text_and_edit_text_use_the_same_selection_engine() {
let (mut rsc, _render, text, _root) = rendered_text("hello there");
text.selection(&mut rsc).select_all();
let view = TextView::new(TextBuffer::new("hello there"), TextAttrs::default(), None);
let mut edit = TextEdit::new(view, EditMode::MultiLine);
let mut data = TextData::default();
TextEditCtx {
text: &mut edit,
data: &mut data,
}
.select_all();
assert_eq!(
rsc.ui.widgets[text].selection_range(),
edit.selection_range()
);
assert_eq!(rsc.ui.widgets[text].selected_text(), edit.selected_text());
}
#[test]
fn changing_display_text_clears_its_now_stale_selection() {
let (mut rsc, mut render, text, root) = rendered_text("before");
text.selection(&mut rsc).select_all();
assert_eq!(
rsc.ui.widgets[text].selected_text().as_deref(),
Some("before")
);
*rsc.ui.widgets[text].content = "after".to_string();
render.update(&root, &mut rsc);
assert_eq!(rsc.ui.widgets[text].selected_text(), None);
assert_eq!(rsc.ui.widgets[text].selection_range(), None);
}
#[test]
fn display_text_draws_the_shared_highlight_without_an_editing_caret() {
let (mut rsc, mut render, text, root) = rendered_text("selected");
let plain = render.active[&text.id()].primitives.len();
text.selection(&mut rsc).select_all();
render.update(&root, &mut rsc);
let selected = render.active[&text.id()].primitives.len();
let view = TextView::new(TextBuffer::new("selected"), TextAttrs::default(), None);
let edit = rsc
.ui
.widgets
.add_strong(TextEdit::new(view, EditMode::MultiLine));
let edit_id = edit.weak();
let edit_root = edit.any();
let mut edit_render = UiRenderState::new();
edit_render.resize((800.0, 600.0));
edit_render.update(&edit_root, &mut rsc);
edit_id.edit(&mut rsc).select_all();
edit_render.update(&edit_root, &mut rsc);
let editable = edit_render.active[&edit_id.id()].primitives.len();
assert!(
selected > plain,
"the selection added no highlight primitive"
);
assert_eq!(
editable,
selected + 1,
"editable text should add exactly its caret to the shared highlight"
);
}
#[test]
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
let mut rsc = TestRsc {
@@ -167,7 +311,7 @@ mod tests {
};
let root = wtext("hello there")
.size(18)
.color(UiColor::WHITE)
.color(PaintId::WHITE)
.add_strong(&mut rsc)
.any();
let mut render = UiRenderState::new();
+756
View File
@@ -0,0 +1,756 @@
use crate::prelude::*;
use iris_core::{PaintId, TextData};
use parley::{Affinity, Layout, Selection as ParleySelection};
use std::time::Instant;
/// The selection state shared by display text and editable text. Editing,
/// focus and IME state deliberately live in `TextEdit`; this owns only the
/// state whose meaning comes from a shaped text layout.
#[derive(Default)]
pub(super) struct TextSelection {
pub(super) range: Option<ParleySelection>,
double_hit: Option<usize>,
}
impl TextSelection {
pub(super) fn selected_text(&self, text: &str) -> Option<String> {
let selection = self.range?;
if selection.is_collapsed() {
return None;
}
Some(text[selection.text_range()].to_string())
}
pub(super) fn range(&self) -> Option<std::ops::Range<usize>> {
Some(self.range?.text_range())
}
pub(super) fn caret(&self) -> Option<usize> {
Some(self.range?.focus().index())
}
pub(super) fn deselect(&mut self) {
self.range = None;
self.double_hit = None;
}
}
/// Selection operations that need both a text widget's shaped buffer and
/// iris's text resources. `TextEditCtx` delegates to this same context rather
/// than maintaining an editable-only copy of the geometry and hit testing.
pub struct TextSelectionCtx<'a> {
pub(super) view: &'a mut TextView,
pub(super) data: &'a mut TextData,
}
impl TextSelectionCtx<'_> {
pub(super) fn layout(&mut self) -> &Layout<PaintId> {
selection_layout(self.view, self.data)
}
pub(crate) fn refresh(&mut self) {
if let Some(selection) = self.view.selection.range {
let layout = self.layout();
self.view.selection.range = Some(selection.refresh(layout));
}
}
/// The byte offset in the text nearest `pos`. Positions and `size` use
/// the same widget-local coordinates as a `CursorSense` event.
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
let pos = pos - self.view.region().top_left().to_abs(size);
let layout = self.layout();
ParleySelection::from_point(layout, pos.x, pos.y)
.focus()
.index()
}
pub fn select_all(&mut self) {
let len = self.view.buf.text().len();
if len == 0 {
return;
}
let layout = self.layout();
let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default());
let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default());
self.view.selection.range = Some(ParleySelection::new(anchor, focus));
}
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
let pos = pos - self.view.region().top_left().to_abs(size);
let previous = self.view.selection.range;
let previous_hit = self.view.selection.double_hit;
let outcome = {
let layout = self.layout();
if drag {
previous.map(|selection| {
(
Some(selection.extend_to_point(layout, pos.x, pos.y)),
previous_hit,
)
})
} else {
let hit = ParleySelection::from_point(layout, pos.x, pos.y);
let index = hit.focus().index();
Some(if recent && previous_hit == Some(index) {
(
Some(ParleySelection::line_from_point(layout, pos.x, pos.y)),
None,
)
} else if recent
&& previous.map(|selection| selection.focus().index()) == Some(index)
{
(
Some(ParleySelection::word_from_point(layout, pos.x, pos.y)),
Some(index),
)
} else {
(Some(hit), None)
})
}
};
if let Some((range, double_hit)) = outcome {
self.view.selection.range = range;
self.view.selection.double_hit = double_hit;
}
}
pub fn deselect(&mut self) {
self.view.selection.deselect();
}
pub(crate) fn set_caret(&mut self, index: usize) {
let index = index.min(self.view.buf.text().len());
let layout = self.layout();
self.view.selection.range = Some(ParleySelection::from_byte_index(
layout,
index,
Affinity::default(),
));
}
fn select_between(&mut self, anchor: usize, focus: usize) {
let len = self.view.buf.text().len();
let layout = self.layout();
let anchor = parley::Cursor::from_byte_index(layout, anchor.min(len), Affinity::default());
let focus = parley::Cursor::from_byte_index(layout, focus.min(len), Affinity::default());
self.view.selection.range = Some(ParleySelection::new(anchor, focus));
}
}
pub(super) fn selection_layout<'a>(
view: &'a mut TextView,
data: &mut TextData,
) -> &'a Layout<PaintId> {
let attrs = view.attrs.clone();
let width = view.wrap_width();
let density = data.density;
view.buf.shape(data, &attrs, width, density);
view.buf.layout()
}
/// Gives an ordinary `Text` handle access to the same selection operations as
/// `TextEditCtx`. Gesture policy is intentionally not part of this trait; a
/// selection controller and an editor's focus handler do different
/// things with the same mechanics.
pub trait TextSelectable {
fn selection<'a>(&self, ui: &'a mut impl UiRsc) -> TextSelectionCtx<'a>;
}
impl<I: IdLike<Widget = Text>> TextSelectable for I {
fn selection<'a>(&self, ui: &'a mut impl UiRsc) -> TextSelectionCtx<'a> {
let ui = ui.ui_mut();
TextSelectionCtx {
view: &mut ui.widgets.get_mut(self).unwrap().view,
data: &mut ui.text,
}
}
}
/// Selection across the ordinary `Text` descendants of the widget this
/// controller is attached to. The controller owns the cross-widget gesture
/// and command state; each text leaf owns only its local Parley selection.
pub struct SelectionController {
anchor: Option<(WidgetId, usize)>,
order: Vec<WidgetId>,
selected: Vec<WidgetId>,
gesture: DragGesture,
scroll: Option<WeakWidget<LazySpan>>,
separator: String,
last_input: Option<(Instant, CursorSense, SelectionInput)>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SelectionInput {
Tapped,
Handled,
}
impl Default for SelectionController {
fn default() -> Self {
Self::new()
}
}
impl SelectionController {
pub fn new() -> Self {
Self {
anchor: None,
order: Vec::new(),
selected: Vec::new(),
gesture: DragGesture::new(),
scroll: None,
separator: String::new(),
last_input: None,
}
}
pub fn with_scroll(mut self, scroll: WeakWidget<LazySpan>) -> Self {
self.scroll = Some(scroll);
self
}
pub fn separator(mut self, separator: impl Into<String>) -> Self {
self.separator = separator.into();
self
}
fn text_order(host: WidgetId, rsc: &impl UiRsc, render: &UiRenderState) -> Vec<WidgetId> {
fn visit(id: WidgetId, rsc: &impl UiRsc, render: &UiRenderState, out: &mut Vec<WidgetId>) {
if rsc
.widgets()
.get_dyn(id)
.is_some_and(|widget| widget.as_any().is::<Text>())
{
out.push(id);
return;
}
// An editor owns its own focus, commands and selection gesture.
if rsc
.widgets()
.get_dyn(id)
.is_some_and(|widget| widget.as_any().is::<TextEdit>())
{
return;
}
for child in render.ordered_children(id, rsc) {
visit(child, rsc, render, out);
}
}
let mut out = Vec::new();
visit(host, rsc, render, &mut out);
out
}
fn with_text<T>(
rsc: &mut impl UiRsc,
id: WidgetId,
f: impl FnOnce(&mut TextSelectionCtx<'_>) -> T,
) -> Option<T> {
let ui = rsc.ui_mut();
let text = ui
.widgets
.get_dyn_mut(id)?
.as_any_mut()
.downcast_mut::<Text>()?;
text.update_buf();
let mut ctx = TextSelectionCtx {
view: &mut text.view,
data: &mut ui.text,
};
Some(f(&mut ctx))
}
fn locate(
&self,
rsc: &impl UiRsc,
render: &UiRenderState,
pos: Vec2,
) -> Option<(WidgetId, Vec2, Vec2)> {
self.order.iter().find_map(|&id| {
let active = render.active.get(&id)?;
let region = render.window_region(&id, rsc)?;
(region.contains(pos) && render.mask_admits(active.mask, pos, rsc)).then(|| {
(
id,
pos - region.top_left,
region.bot_right - region.top_left,
)
})
})
}
fn deselect(&mut self, rsc: &mut impl UiRsc) {
let mut ids = std::mem::take(&mut self.selected);
if let Some((anchor, _)) = self.anchor.take()
&& !ids.contains(&anchor)
{
ids.push(anchor);
}
for id in ids {
Self::with_text(rsc, id, |text| text.deselect());
}
}
fn begin(&mut self, rsc: &mut impl UiRsc, id: WidgetId, pos: Vec2, size: Vec2) {
self.deselect(rsc);
let byte = Self::with_text(rsc, id, |text| {
text.select(pos, size, false, false);
text.byte_at(pos, size)
});
self.anchor = byte.map(|byte| (id, byte));
}
fn extend(&mut self, rsc: &mut impl UiRsc, id: WidgetId, pos: Vec2, size: Vec2) {
let Some((anchor, anchor_byte)) = self.anchor else {
return;
};
let Some(anchor_at) = self.order.iter().position(|&candidate| candidate == anchor) else {
self.deselect(rsc);
return;
};
let Some(focus_at) = self.order.iter().position(|&candidate| candidate == id) else {
return;
};
let Some(focus_byte) = Self::with_text(rsc, id, |text| text.byte_at(pos, size)) else {
return;
};
let (lo, hi) = if anchor_at <= focus_at {
(anchor_at, focus_at)
} else {
(focus_at, anchor_at)
};
let old = std::mem::take(&mut self.selected);
for old_id in old {
if !self.order[lo..=hi].contains(&old_id) {
Self::with_text(rsc, old_id, |text| text.deselect());
}
}
for &text_id in &self.order[lo..=hi] {
let forward = anchor_at <= focus_at;
Self::with_text(rsc, text_id, |text| {
let len = text.view.buf.text().len();
let (start, end) = if text_id == anchor && text_id == id {
(anchor_byte, focus_byte)
} else if text_id == anchor {
(anchor_byte, if forward { len } else { 0 })
} else if text_id == id {
(if forward { 0 } else { len }, focus_byte)
} else {
(0, len)
};
text.select_between(start, end);
});
}
self.selected = self.order[lo..=hi].to_vec();
}
pub fn drag<Rsc: HasEvents>(
&mut self,
id: ControllerId,
rsc: &mut Rsc,
input: &CursorData<'_>,
) -> SelectionInput {
// A leaf listener and the controller host may both cover one point on
// the same layer. They are two routes for one physical sample, not two
// gestures; the second route must observe the first route's decision.
if let Some((last, sense, outcome)) = self.last_input
&& last == input.cursor.time
&& sense == input.sense
{
return outcome;
}
self.order = Self::text_order(id.host(), rsc, input.render);
let hit = self.locate(rsc, input.render, input.cursor.pos);
let mut press = PressState::default();
if self.gesture.starts_press(input.sense) {
press.scrolling = self.scroll.is_some_and(|scroll| scroll(rsc).is_scrolling());
if let Some(scroll) = self.scroll {
scroll(rsc).cancel_fling();
}
}
press.already_selected = self.has_selection(rsc);
let outcome = self.gesture.handle(
input.pointer,
id.host(),
input.sense,
input.cursor.pos,
input.cursor.time,
press,
);
let input_result = match outcome {
GestureOutcome::Pan(delta) => {
if let Some(scroll) = self.scroll {
scroll(rsc).scroll(delta);
}
SelectionInput::Handled
}
GestureOutcome::SelectStart => {
if let Some((text, pos, size)) = hit {
self.begin(rsc, text, pos, size);
rsc.set_command_target(Some(id));
}
SelectionInput::Handled
}
GestureOutcome::SelectExtend => {
if let Some((text, pos, size)) = hit {
self.extend(rsc, text, pos, size);
}
SelectionInput::Handled
}
GestureOutcome::Released(Some(velocity)) => {
if let Some(scroll) = self.scroll
&& scroll(rsc).fling(velocity)
{
rsc.ui_mut().animate(scroll.id());
}
SelectionInput::Handled
}
GestureOutcome::Tapped => {
if self.anchor.is_some() || !self.selected.is_empty() {
self.deselect(rsc);
rsc.set_command_target(None);
SelectionInput::Handled
} else {
SelectionInput::Tapped
}
}
GestureOutcome::Cancelled
| GestureOutcome::Undecided
| GestureOutcome::Released(None) => SelectionInput::Handled,
};
self.last_input = Some((input.cursor.time, input.sense, input_result));
input_result
}
pub fn has_selection(&self, rsc: &impl UiRsc) -> bool {
self.selected.iter().any(|&id| {
rsc.widgets()
.get_dyn(id)
.and_then(|widget| widget.as_any().downcast_ref::<Text>())
.is_some_and(|text| text.selected_text().is_some())
})
}
pub fn selected_text(&self, rsc: &impl UiRsc) -> Option<String> {
let parts: Vec<String> = self
.selected
.iter()
.filter_map(|&id| {
rsc.widgets()
.get_dyn(id)
.and_then(|widget| widget.as_any().downcast_ref::<Text>())
.and_then(Text::selected_text)
})
.collect();
(!parts.is_empty()).then(|| parts.join(&self.separator))
}
}
impl<Rsc: HasEvents> Controller<Rsc> for SelectionController {
fn command(&mut self, command: Command, rsc: &mut Rsc) -> CommandResult {
match command {
Command::Copy => self
.selected_text(rsc)
.map(CommandResult::Copy)
.unwrap_or(CommandResult::Unused),
Command::SelectAll => {
let order = self.order.clone();
self.deselect(rsc);
for &id in &order {
Self::with_text(rsc, id, |text| text.select_all());
}
self.selected = order;
CommandResult::Used
}
Command::Escape => {
self.deselect(rsc);
CommandResult::Used
}
}
}
}
#[cfg(test)]
mod controller_tests {
use super::*;
struct TestRsc {
ui: UiData,
events: EventManager<TestRsc>,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
}
}
impl HasState for TestRsc {
type State = ();
}
impl HasEvents for TestRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
fn two_texts(
dir: Dir,
) -> (
TestRsc,
UiRenderState,
WeakWidget<Span>,
WeakWidget<Text>,
WeakWidget<Text>,
StrongWidget,
) {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let first = wtext("first").add(&mut rsc);
let second = wtext("second").add(&mut rsc);
let host = (first, second)
.span(dir)
.controller(SelectionController::new().separator("|"))
.add(&mut rsc);
let root = host.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((400.0, 200.0));
render.update(&root, &mut rsc);
(rsc, render, host, first, second, root)
}
#[test]
fn a_span_orders_selection_on_its_visual_axis() {
let (mut rsc, render, host, _first, _second, _root) = two_texts(Dir::LEFT);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.order = SelectionController::text_order(host.id(), rsc, &render);
});
rsc.set_command_target(Some(id));
assert_eq!(rsc.run_command(Command::SelectAll), CommandResult::Used);
assert_eq!(
rsc.run_command(Command::Copy),
CommandResult::Copy("second|first".to_string())
);
}
#[test]
fn a_widget_without_an_order_override_keeps_draw_order() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let first = wtext("back").add(&mut rsc);
let second = wtext("front").add(&mut rsc);
let host = (first, second)
.stack()
.controller(SelectionController::new().separator("|"))
.add(&mut rsc);
let root = host.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((400.0, 200.0));
render.update(&root, &mut rsc);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.order = SelectionController::text_order(host.id(), rsc, &render);
});
rsc.set_command_target(Some(id));
assert_eq!(rsc.run_command(Command::SelectAll), CommandResult::Used);
assert_eq!(
rsc.run_command(Command::Copy),
CommandResult::Copy("back|front".to_string())
);
}
#[test]
fn nearest_controller_prefers_the_inner_scope() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let leaf = wtext("leaf").add(&mut rsc);
let inner = (leaf,)
.span(Dir::DOWN)
.controller(SelectionController::new())
.add(&mut rsc);
let outer = (inner,)
.span(Dir::DOWN)
.controller(SelectionController::new())
.add(&mut rsc);
let root = outer.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((400.0, 200.0));
render.update(&root, &mut rsc);
let found = rsc
.events()
.controllers
.nearest_id::<SelectionController>(leaf.id(), &render)
.unwrap();
assert_eq!(found.host(), inner.id());
}
#[test]
fn command_target_outlives_pointer_release_and_copies_the_controller_selection() {
let (mut rsc, render, host, first, second, _root) = two_texts(Dir::DOWN);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.order = SelectionController::text_order(host.id(), rsc, &render);
let first_size = render.window_region(&first, rsc).unwrap().size();
let second_size = render.window_region(&second, rsc).unwrap().size();
selection.begin(rsc, first.id(), Vec2::ZERO, first_size);
selection.extend(rsc, second.id(), second_size, second_size);
});
rsc.set_command_target(Some(id));
assert_eq!(
rsc.run_command(Command::Copy),
CommandResult::Copy("first|second".to_string())
);
}
#[test]
fn tapping_after_selection_deselects_and_releases_the_command_target() {
let (mut rsc, render, host, _first, _second, _root) = two_texts(Dir::DOWN);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.order = SelectionController::text_order(host.id(), rsc, &render);
let order = selection.order.clone();
for &text in &order {
SelectionController::with_text(rsc, text, |text| text.select_all());
}
selection.selected = order;
});
rsc.set_command_target(Some(id));
let pointer = PointerRequests::default();
let now = Instant::now();
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
let press = CursorData {
pos: Vec2::ZERO,
size: Vec2::ZERO,
scroll_delta: Vec2::ZERO,
hover: Default::default(),
cursor: CursorState {
pos: Vec2::ZERO,
time: now,
..Default::default()
},
drag_axis: None,
captured: false,
sense: CursorSense::PressStart(CursorButton::Left),
render: &render,
pointer: &pointer,
};
selection.drag(id, rsc, &press);
let release = CursorData {
pos: Vec2::ZERO,
size: Vec2::ZERO,
scroll_delta: Vec2::ZERO,
hover: Default::default(),
cursor: CursorState {
pos: Vec2::ZERO,
time: now + std::time::Duration::from_millis(20),
..Default::default()
},
drag_axis: None,
captured: false,
sense: CursorSense::PressEnd(CursorButton::Left),
render: &render,
pointer: &pointer,
};
assert_eq!(selection.drag(id, rsc, &release), SelectionInput::Handled);
});
assert_eq!(rsc.events().controllers.command_target(), None);
assert_eq!(rsc.run_command(Command::Copy), CommandResult::Unused);
}
#[test]
fn removing_a_controller_host_clears_its_command_target() {
let (mut rsc, mut render, host, _first, _second, root) = two_texts(Dir::DOWN);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.set_command_target(Some(id));
drop(root);
render.update(None, &mut rsc);
rsc.free();
assert_eq!(rsc.events().controllers.command_target(), None);
assert_eq!(rsc.run_command(Command::Copy), CommandResult::Unused);
}
#[test]
fn removing_a_host_during_a_callback_does_not_restore_its_controller() {
let (mut rsc, _render, host, _first, _second, _root) = two_texts(Dir::DOWN);
let id = rsc
.events()
.controllers
.id::<SelectionController>(host.id())
.unwrap();
rsc.set_command_target(Some(id));
rsc.with_controller::<SelectionController, _>(id, |_selection, rsc| {
rsc.events_mut().controllers.remove(host.id());
});
assert!(
rsc.events()
.controllers
.id::<SelectionController>(host.id())
.is_none()
);
assert_eq!(rsc.events().controllers.command_target(), None);
}
}
+49 -41
View File
@@ -9,22 +9,23 @@ pub fn build<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Clie
where
Rsc::State: FocusHost,
{
let rrect = rect(Color::WHITE).radius(20);
let rrect = rect(PaintId::WHITE).radius(20);
let pad_test = (
rrect.color(Color::BLUE),
rrect.clone().color(PaintId::BLUE),
(
rrect
.color(Color::RED)
.clone()
.color(PaintId::RED)
.sized((100, 100))
.center()
.width(rest(2)),
(
rrect.color(Color::ORANGE),
rrect.color(Color::LIME).pad(10.0),
rrect.clone().color(PaintId::ORANGE),
rrect.clone().color(PaintId::LIME).pad(10.0),
)
.span(Dir::RIGHT)
.width(rest(2)),
rrect.color(Color::YELLOW),
rrect.clone().color(PaintId::YELLOW),
)
.span(Dir::RIGHT)
.pad(10)
@@ -34,19 +35,19 @@ where
.add(rsc);
let span_test = (
rrect.color(Color::GREEN).width(100),
rrect.color(Color::ORANGE),
rrect.color(Color::CYAN),
rrect.color(Color::BLUE).width(rel(0.5)),
rrect.color(Color::MAGENTA).width(100),
rrect.color(Color::RED).width(100),
rrect.clone().color(PaintId::GREEN).width(100),
rrect.clone().color(PaintId::ORANGE),
rrect.clone().color(PaintId::CYAN),
rrect.clone().color(PaintId::BLUE).width(rel(0.5)),
rrect.clone().color(PaintId::MAGENTA).width(100),
rrect.color(PaintId::RED).width(100),
)
.span(Dir::LEFT)
.add(rsc);
let span_add = Span::empty(Dir::RIGHT).add(rsc);
let add_button = rect(Color::LIME)
let add_button = rect(PaintId::LIME)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
let child = image(include_bytes!("../assets/sungals.png"))
@@ -57,7 +58,7 @@ where
.sized((150, 150))
.align(Align::BOT_RIGHT);
let del_button = rect(Color::RED)
let del_button = rect(PaintId::RED)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
span_add(rsc).pop();
@@ -79,9 +80,9 @@ where
btext("'").family(Family::Monospace).align(Align::TOP),
btext("'").family(Family::Monospace),
btext(":gamer mode").family(Family::Monospace),
rect(Color::CYAN).sized((10, 10)).center(),
rect(Color::RED).sized((100, 100)).center(),
rect(Color::PURPLE).sized((50, 50)).align(Align::TOP),
rect(PaintId::CYAN).sized((10, 10)).center(),
rect(PaintId::RED).sized((100, 100)).center(),
rect(PaintId::PURPLE).sized((50, 50)).align(Align::TOP),
)
.span(Dir::RIGHT)
.center(),
@@ -94,13 +95,13 @@ where
let msg_area = texts
.scrollable(Axis::Y, Pin::Start)
.masked()
.background(rect(Color::SKY));
.background(rect(PaintId::SKY));
let add_text = wtext("add")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.size(30)
.attr::<Selectable>(())
.on(Submit, move |ctx, rsc| {
.on(Submit, move |ctx, rsc: &mut Rsc| {
let w = ctx.widget;
let content = w.edit(rsc).take();
let text = wtext(content)
@@ -109,9 +110,11 @@ where
.text_align(Align::LEFT)
.wrap(true)
.attr::<Selectable>(());
let msg_box = text
.background(rect(Color::WHITE.darker(0.5)))
.add_strong(rsc);
let fill = rsc
.ui_mut()
.paints
.add(Srgba8::WHITE.to_linear().darker(0.5));
let msg_box = text.background(rect(fill)).add_strong(rsc);
texts(rsc).push(msg_box);
})
.add(rsc);
@@ -119,10 +122,14 @@ where
let text_edit_scroll = (
msg_area.height(rest(1)),
(
Rect::new(Color::WHITE.darker(0.9)),
Rect::new(
rsc.ui_mut()
.paints
.add(Srgba8::WHITE.to_linear().darker(0.9)),
),
(
add_text.width(rest(1)),
Rect::new(Color::GREEN)
Rect::new(PaintId::GREEN)
.on(CursorSense::click(), move |ctx, rsc: &mut Rsc| {
rsc.run_event::<Submit>(add_text, (), ctx.state);
})
@@ -142,7 +149,9 @@ where
let main = WidgetPtr::new().add(rsc);
let vals = Rc::new(RefCell::new((0, Vec::new())));
let mut switch_button = |color, to: WeakWidget, label| {
let mut switch_button = |solid: Srgba8, to: WeakWidget, label| {
let value = solid.to_linear();
let paint = rsc.ui_mut().paints.add(value);
let to = to.upgrade(rsc);
let vec = &mut vals.borrow_mut().1;
let i = vec.len();
@@ -153,38 +162,37 @@ where
vec.push(Some(to));
}
let vals = vals.clone();
let rect = rect(color)
.on(CursorSense::click(), move |ctx, rsc| {
let pressed = paint.clone();
let hovered = paint.clone();
let normal = paint.clone();
let rect = rect(paint)
.on(CursorSense::click(), move |_ctx, rsc: &mut Rsc| {
let (prev, vec) = &mut *vals.borrow_mut();
if let Some(h) = vec[i].take() {
vec[*prev] = main(rsc).replace(h);
*prev = i;
}
ctx.widget(rsc).color = color.darker(0.3);
rsc.ui_mut().paints.set(&pressed, value.darker(0.3));
})
.on(
CursorSense::HoverStart | CursorSense::unclick(),
move |ctx, rsc| {
ctx.widget(rsc).color = color.brighter(0.2);
move |_ctx, rsc: &mut Rsc| {
rsc.ui_mut().paints.set(&hovered, value.brighter(0.2));
},
)
.on(CursorSense::HoverEnd, move |ctx, rsc| {
ctx.widget(rsc).color = color;
.on(CursorSense::HoverEnd, move |_ctx, rsc: &mut Rsc| {
rsc.ui_mut().paints.set(&normal, value);
})
.label(label);
(rect, wtext(label).size(30).text_align(Align::CENTER)).stack()
};
let tabs = (
switch_button(Color::RED, pad_test, "pad"),
switch_button(Color::GREEN, span_test, "span"),
switch_button(Color::BLUE, span_add_test, "image span"),
switch_button(Color::MAGENTA, text_test, "text layout"),
switch_button(
Color::YELLOW.mul_rgb(0.5),
text_edit_scroll,
"text edit scroll",
),
switch_button(Srgba8::RED, pad_test, "pad"),
switch_button(Srgba8::GREEN, span_test, "span"),
switch_button(Srgba8::BLUE, span_add_test, "image span"),
switch_button(Srgba8::MAGENTA, text_test, "text layout"),
switch_button(Srgba8::YELLOW, text_edit_scroll, "text edit scroll"),
)
.span(Dir::RIGHT);
+178
View File
@@ -0,0 +1,178 @@
#![recursion_limit = "256"]
use iris::{harness::Harness, prelude::*};
use pollster::FutureExt;
use std::sync::OnceLock;
use wgpu::TextureFormat;
const SIZE: Vec2 = Vec2::new(2.0, 1.0);
const SOLID: Srgba8 = Srgba8::rgb(17, 127, 231);
const CHANGED_SOLID: Srgba8 = Srgba8::rgb(243, 139, 168);
const IMAGE: Srgba8 = Srgba8::rgb(205, 214, 244);
/// Covers the whole colour path rather than a conversion helper: an sRGB
/// literal enters the linear paint buffer, an sRGB image is sampled as
/// linear, the Iris shader returns both, and the sRGB attachment encodes the
/// stored bytes. Replacing only the paint-table entry also proves that a
/// theme change reaches an already-retained primitive.
#[test]
fn solid_paints_and_images_round_trip_through_an_srgb_target() {
let gpu = Gpu::open();
let mut harness = Harness::new(SIZE, 1.0);
let solid = harness.rsc.ui.paints.add(SOLID);
let bitmap = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
1,
1,
image::Rgba([IMAGE.r, IMAGE.g, IMAGE.b, IMAGE.a]),
));
let bitmap = image::<iris::harness::HarnessRsc>(bitmap)(&mut harness.rsc);
let root = (rect(solid.clone()).sized((1, 1)), bitmap)
.span(Dir::RIGHT)
.add_strong(&mut harness.rsc)
.any();
harness.state.set_root(root);
harness.frame(0);
let mut renderer =
UiRenderNode::new(&gpu.device, &gpu.queue, TextureFormat::Rgba8UnormSrgb, SIZE)
.expect("the Iris pipeline should accept an sRGB render target");
let first = render(&gpu, &mut renderer, &mut harness);
assert_pixel(first[0], SOLID, "linear paint buffer -> sRGB attachment");
assert_pixel(first[1], IMAGE, "sRGB texture -> shader -> sRGB attachment");
harness.rsc.ui.paints.set(&solid, CHANGED_SOLID);
let changed = render(&gpu, &mut renderer, &mut harness);
assert_pixel(
changed[0],
CHANGED_SOLID,
"updated paint table -> retained primitive",
);
assert_pixel(changed[1], IMAGE, "unchanged image after paint update");
}
fn render(gpu: &Gpu, renderer: &mut UiRenderNode, harness: &mut Harness) -> [[u8; 4]; 2] {
renderer.update(
&gpu.device,
&gpu.queue,
&mut harness.rsc.ui,
&mut harness.render,
);
let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
label: Some("Iris colour-space target"),
size: wgpu::Extent3d {
width: 2,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let readback = gpu.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Iris colour-space readback"),
size: 256,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = gpu.device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Iris colour-space pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(LinearRgba::BLACK.to_wgpu()),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
renderer.draw(&mut pass);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
wgpu::Extent3d {
width: 2,
height: 1,
depth_or_array_layers: 1,
},
);
gpu.queue.submit([encoder.finish()]);
let slice = readback.slice(..);
slice.map_async(wgpu::MapMode::Read, |result| {
result.expect("mapping the colour-space readback")
});
gpu.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("waiting for the colour-space readback");
let mapped = slice
.get_mapped_range()
.expect("reading the mapped colour-space buffer");
let pixels = [
mapped[0..4].try_into().unwrap(),
mapped[4..8].try_into().unwrap(),
];
drop(mapped);
readback.unmap();
pixels
}
fn assert_pixel(got: [u8; 4], want: Srgba8, path: &str) {
let want = [want.r, want.g, want.b, want.a];
assert!(
got.into_iter()
.zip(want)
.all(|(got, want)| got.abs_diff(want) <= 1),
"{path}: stored {got:?}, expected {want:?} (within one code value)",
);
}
fn vulkan_instance() -> &'static wgpu::Instance {
static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
INSTANCE.get_or_init(wgpu::Instance::default)
}
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
fn open() -> Self {
let adapter = vulkan_instance()
.request_adapter(&wgpu::RequestAdapterOptions::default())
.block_on()
.expect("no wgpu adapter, so Iris's colour-space pipeline went unchecked");
let info = adapter.get_info();
eprintln!(
"color_space: {} ({:?}, {})",
info.name, info.backend, info.driver
);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
required_limits: iris_core::device_limits(),
..Default::default()
})
.block_on()
.expect("could not get a device from the adapter");
Self { device, queue }
}
}