Compare commits

...
Author SHA1 Message Date
iris 7dc7614ae6 Swap two hover buffers, and drive the tests from the harness
The set of hovered widgets is two vectors that trade places, so an input
allocates nothing once they have grown, instead of building a fresh one
each time.

`run_sensors` no longer takes a `window_size`: `UiRenderState` already
holds the output size, and passing it back in was one more thing that
could disagree.

`tests/pointer_routing.rs` drops its own `Rsc`, event manager and layer
scaffolding for `iris::harness`, which is what it was standing in for.
Presses now arrive as a move and then a press, and a scroll after the
hover that precedes it, because that is what the harness delivers and
what a window does.
2026-09-13 21:58:39 -04:00
iris e865467a3f Merge upstream/main (#15) into split/12-pointer-routing 2026-09-13 21:55:53 -04:00
iris-aiandiris c8ac669f95 Run a ui without a window, and test one (#15)
Small, and disjoint from #12 — this touches `task.rs`, `harness.rs` and `render_state.rs`, none of which #12 goes near.

`Tasks` held an `Arc<Window>` only to call `request_redraw` when a task finished, which made the task queue, and so `DefaultRsc`, impossible to build without a window. It now takes an `Arc<dyn WakeTaskQueue>`, and `Window` implements it.

Waking also moves from *the task ended* to *an update was sent*, which is when there is actually something for the host to apply. A task that keeps running after sending one no longer holds it until it finishes, and a task that sends none no longer asks for a frame nothing needs.

`iris::harness` is what that buys. `UiRenderState` already does layout, hit testing and primitive building with no surface, so a test can build a tree, run frames, move a pointer and read back where widgets landed. `tests/harness.rs` covers span layout, resize relayout, press routing, hover start and end, wheel scrolling with its clamp, and a task update reaching the tree. None of them could be written before, since the only way into layout was a window.

It does not draw. A claim about pixels still needs a real surface — I checked this one against the rig rather than asserting it: `examples/task` under headless sway, centre pixel `ff0000` before the click and `0000ff` after, so the windowed path still applies task updates under the new wake.

The only core change is `UiRenderState::output_size()`, so that a host reading back the size it set does not have to keep a second copy.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: iris/iris#15
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 21:53:54 -04:00
16 changed files with 524 additions and 347 deletions

No files matched your search

+1 -1
View File
@@ -421,7 +421,7 @@ impl Display for UiRegion {
}
}
#[derive(Debug)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PixelRegion {
pub top_left: Vec2,
pub bot_right: Vec2,
+4
View File
@@ -34,6 +34,10 @@ impl UiRenderState {
self.resized = true;
}
pub fn output_size(&self) -> Vec2 {
self.output_size
}
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
// safety mechanism for memory leaks; might wanna return a result instead so user can
// decide whether to panic or not
+1 -5
View File
@@ -10,11 +10,7 @@ struct State {
}
impl DefaultAppState for State {
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
rect(Color::RED).set_root(rsc, &mut ui_state);
Self { ui_state }
}
+1 -5
View File
@@ -15,11 +15,7 @@ pub struct Client {
}
impl DefaultAppState for Client {
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
let rrect = rect(Color::WHITE).radius(20);
let pad_test = (
rrect.color(Color::BLUE),
+1 -5
View File
@@ -11,11 +11,7 @@ struct State {
}
impl DefaultAppState for State {
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
let rect = rect(Color::RED).add(rsc);
rect.task_on(CursorSense::click(), async move |mut ctx| {
tokio::time::sleep(Duration::from_secs(1)).await;
+1 -5
View File
@@ -36,11 +36,7 @@ impl Test {
}
impl DefaultAppState for State {
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
let test = Test::new(rsc);
test.on(CursorSense::click(), move |_, rsc| {
+61 -44
View File
@@ -25,7 +25,35 @@ pub use sense::*;
pub use state::*;
pub use task::*;
pub type Proxy<Event> = EventLoopProxy<Event>;
/// Sends an application's own events to its event loop. It wraps the proxy
/// rather than being one because task updates travel the same way: what an
/// application sends is its `Event`, not the loop's whole message type.
pub struct Proxy<State: DefaultAppState>(EventLoopProxy<DefaultEvent<State>>);
impl<State: DefaultAppState> Clone for Proxy<State> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<State: DefaultAppState> Proxy<State> {
pub fn send_event(&self, event: State::Event) {
let _ = self.0.send_event(DefaultEvent::User(event));
}
}
/// What the event loop carries: the application's own events, and the
/// updates tasks send back to the ui thread.
pub enum DefaultEvent<State: DefaultAppState> {
User(State::Event),
Update(Box<dyn TaskUpdate<DefaultRsc<State>>>),
}
impl<State: DefaultAppState> TaskQueue<DefaultRsc<State>> for Proxy<State> {
fn send(&self, update: Box<dyn TaskUpdate<DefaultRsc<State>>>) {
let _ = self.0.send_event(DefaultEvent::Update(update));
}
}
pub struct DefaultUiState {
pub root: Option<StrongWidget>,
@@ -66,9 +94,8 @@ pub trait HasDefaultUiState: Sized + 'static {
}
pub trait DefaultAppState: HasDefaultUiState {
type Event = ();
fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, proxy: Proxy<Self::Event>)
-> Self;
type Event: Send = ();
fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, proxy: Proxy<Self>) -> Self;
#[allow(unused_variables)]
fn event(
&mut self,
@@ -101,18 +128,14 @@ pub struct DefaultRsc<State: 'static> {
}
impl<State> DefaultRsc<State> {
fn init(window: Arc<Window>) -> (Self, TaskMsgReceiver<Self>) {
let (tasks, recv) = Tasks::init(window);
(
Self {
ui: Default::default(),
events: Default::default(),
tasks,
state: Default::default(),
_state: Default::default(),
},
recv,
)
pub fn init(queue: Arc<dyn TaskQueue<Self>>) -> Self {
Self {
ui: Default::default(),
events: Default::default(),
tasks: Tasks::init(queue),
state: Default::default(),
_state: Default::default(),
}
}
pub fn create_state<T: 'static>(&mut self, id: impl IdLike, data: T) -> WeakState<T> {
@@ -177,43 +200,32 @@ pub struct DefaultApp<State: DefaultAppState> {
rsc: DefaultRsc<State>,
render: UiRenderState,
state: State,
task_recv: TaskMsgReceiver<DefaultRsc<State>>,
}
impl<State: DefaultAppState> AppState for DefaultApp<State> {
type Event = State::Event;
type Event = DefaultEvent<State>;
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
let window = event_loop
.create_window(State::window_attributes())
.unwrap();
let default_state = DefaultUiState::new(window);
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
let state = State::new(default_state, &mut rsc, proxy);
let mut rsc = DefaultRsc::init(Arc::new(Proxy(proxy.clone())));
let state = State::new(default_state, &mut rsc, Proxy(proxy));
let render = UiRenderState::new();
Self {
rsc,
state,
render,
task_recv,
}
Self { rsc, state, render }
}
fn event(&mut self, event: Self::Event, _: &ActiveEventLoop) {
self.state.event(event, &mut self.rsc, &mut self.render);
match event {
DefaultEvent::User(event) => self.state.event(event, &mut self.rsc, &mut self.render),
DefaultEvent::Update(update) => update(&mut self.state, &mut self.rsc),
}
self.request_redraw_if_needed();
}
fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop) {
let Self {
rsc,
render,
state,
task_recv,
} = self;
for update in task_recv.try_iter() {
update(state, rsc);
}
let Self { rsc, render, state } = self;
let ui_state = state.default_state_mut();
let input_changed = ui_state.input.event(&event);
@@ -223,8 +235,7 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state.focus = None;
}
if input_changed {
let window_size = ui_state.window_size();
render.run_sensors(rsc, state, cursor_state, window_size);
render.run_sensors(rsc, state, cursor_state);
}
let ui_state = state.default_state_mut();
if old != ui_state.focus
@@ -293,11 +304,8 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
_ => (),
}
state.window_event(event, rsc, render);
let ui_state = self.state.default_state_mut();
if render.needs_redraw(&ui_state.root, rsc.widgets()) {
ui_state.renderer.window().request_redraw();
}
ui_state.input.end_frame();
self.request_redraw_if_needed();
self.state.default_state_mut().input.end_frame();
}
fn exit(&mut self) {
@@ -305,6 +313,15 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
}
}
impl<State: DefaultAppState> DefaultApp<State> {
fn request_redraw_if_needed(&mut self) {
let ui_state = self.state.default_state_mut();
if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) {
ui_state.renderer.window().request_redraw();
}
}
}
pub trait RscIdx<Rsc> {
type Output;
fn get(self, rsc: &Rsc) -> &Self::Output;
+22 -14
View File
@@ -27,10 +27,7 @@ pub struct CursorSenses(Vec<CursorSense>);
impl Event for CursorSenses {
type Data<'a> = CursorData<'a>;
/// Who the cursor was inside on the last input, which is what says whose
/// hover has ended -- including a widget a higher layer has since covered,
/// which this walk never reaches.
type Global = Vec<WidgetId>;
type Global = Hovered;
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
let mut data = data.clone();
@@ -48,6 +45,18 @@ impl Event for CursorSenses {
}
}
/// Who the cursor was inside, before and after an input. The difference is
/// whose hover has ended -- including a widget a higher layer has covered,
/// which the walk stops before reaching.
///
/// Two buffers that swap rather than one rebuilt, so an input allocates
/// nothing once they have grown.
#[derive(Default)]
pub struct Hovered {
was: Vec<WidgetId>,
now: Vec<WidgetId>,
}
impl CursorSense {
pub fn click() -> Self {
Self::PressStart(CursorButton::Left)
@@ -163,7 +172,6 @@ pub trait SensorUi {
rsc: &mut Rsc,
state: &mut Rsc::State,
cursor: CursorState,
window_size: Vec2,
);
}
@@ -173,17 +181,16 @@ impl SensorUi for UiRenderState {
rsc: &mut Rsc,
state: &mut Rsc::State,
cursor: CursorState,
window_size: Vec2,
) {
// in order to remove this take, need to store active list in UiRenderState somehow
// this would probably be done through a generic parameter that adds yet another rsc /
// state like thing, but local to render state, and is passed to UiRsc events so you can
// update it there?
let active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
let was = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().global);
let mut hovered = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().global);
hovered.now.clear();
let position_only = cursor.position_only();
let mut now: Vec<WidgetId> = Vec::new();
let region_of = |id| Some(self.active.get(&id)?.region.to_px(window_size));
let region_of = |id| self.window_region(&id);
for layer in self.layers.indices().rev() {
let mut consumed = false;
@@ -194,8 +201,8 @@ impl SensorUi for UiRenderState {
if !cursor.exists || !region.contains(cursor.pos) {
continue;
}
now.push(id);
let hover = match was.contains(&id) {
hovered.now.push(id);
let hover = match hovered.was.contains(&id) {
true => ActivationState::On,
false => ActivationState::Start,
};
@@ -216,17 +223,18 @@ impl SensorUi for UiRenderState {
// Whatever the cursor was inside and is not now, whether it left or a
// layer above took the input before the walk reached it. A widget that
// stopped being drawn has no region to report and is simply dropped.
for id in was {
if !now.contains(&id)
for &id in &hovered.was {
if !hovered.now.contains(&id)
&& let Some(region) = region_of(id)
{
deliver(self, rsc, state, id, ActivationState::End, &cursor, region);
}
}
std::mem::swap(&mut hovered.was, &mut hovered.now);
let senses = rsc.events_mut().get_type::<CursorSense>();
senses.active = active;
senses.global = now;
senses.global = hovered;
}
}
+16 -34
View File
@@ -1,11 +1,5 @@
use iris_core::HasState;
use std::{
pin::Pin,
sync::{
Arc,
mpsc::{Receiver as SyncReceiver, Sender as SyncSender, channel as sync_channel},
},
};
use std::{pin::Pin, sync::Arc};
use tokio::{
runtime::Runtime,
sync::mpsc::{
@@ -13,64 +7,52 @@ use tokio::{
unbounded_channel as async_channel,
},
};
use winit::window::Window;
pub type TaskMsgSender<Rsc> = SyncSender<Box<dyn TaskUpdate<Rsc>>>;
pub type TaskMsgReceiver<Rsc> = SyncReceiver<Box<dyn TaskUpdate<Rsc>>>;
pub trait TaskUpdate<Rsc: HasState>: FnOnce(&mut Rsc::State, &mut Rsc) + Send {}
impl<F: FnOnce(&mut Rsc::State, &mut Rsc) + Send, Rsc: HasState> TaskUpdate<Rsc> for F {}
/// Hands an update from a task to the thread that owns the ui. Delivery and
/// waking are one act: a host posts the update as a message its loop already
/// carries, so nothing has to wake the loop separately, or claim a redraw to
/// be looked at.
pub trait TaskQueue<Rsc: HasState>: Send + Sync + 'static {
fn send(&self, update: Box<dyn TaskUpdate<Rsc>>);
}
pub struct Tasks<Rsc: HasState> {
start: AsyncSender<BoxTask>,
window: Arc<Window>,
msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>,
queue: Arc<dyn TaskQueue<Rsc>>,
}
pub struct TaskCtx<Rsc: HasState> {
send: TaskMsgSender<Rsc>,
queue: Arc<dyn TaskQueue<Rsc>>,
}
impl<Rsc: HasState> TaskCtx<Rsc> {
pub fn update(&mut self, f: impl TaskUpdate<Rsc> + 'static) {
let _ = self.send.send(Box::new(f));
}
}
impl<Rsc: HasState + 'static> TaskCtx<Rsc> {
fn new(send: TaskMsgSender<Rsc>) -> Self {
Self { send }
self.queue.send(Box::new(f));
}
}
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
impl<Rsc: HasState> Tasks<Rsc> {
pub fn init(window: Arc<Window>) -> (Self, TaskMsgReceiver<Rsc>) {
pub fn init(queue: Arc<dyn TaskQueue<Rsc>>) -> Self {
let (start, start_recv) = async_channel();
let (msgs, msgs_recv) = sync_channel();
std::thread::spawn(|| {
let rt = Runtime::new().unwrap();
rt.block_on(listen(start_recv))
});
(
Self {
start,
msg_send: msgs,
window,
},
msgs_recv,
)
Self { start, queue }
}
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
where
F::CallOnceFuture: Send,
{
let send = self.msg_send.clone();
let window = self.window.clone();
let queue = self.queue.clone();
let _ = self.start.send(Box::pin(async move {
task(TaskCtx::new(send)).await;
window.request_redraw();
task(TaskCtx { queue }).await;
}));
}
}
+180
View File
@@ -0,0 +1,180 @@
//! A ui with no window: build a tree, run frames, move a pointer, and read
//! back where widgets landed.
//!
//! It does not draw. A claim about pixels still needs a real surface.
use crate::prelude::*;
use std::{
sync::{
Arc,
mpsc::{Receiver, SyncSender, sync_channel},
},
time::Duration,
};
/// There is no loop here to post to, so updates queue until the test asks
/// for them.
struct Queue(SyncSender<Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>>);
impl TaskQueue<DefaultRsc<HarnessState>> for Queue {
fn send(&self, update: Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>) {
let _ = self.0.send(update);
}
}
/// `assert_eq!` for where a frame put a widget, written as its two corners.
#[macro_export]
macro_rules! assert_corners {
($harness:expr, $id:expr, ($x0:expr, $y0:expr), ($x1:expr, $y1:expr)) => {
assert_eq!(
$harness.region(&$id).expect("widget drew nothing"),
$crate::core::PixelRegion {
top_left: $crate::core::util::Vec2::new($x0 as f32, $y0 as f32),
bot_right: $crate::core::util::Vec2::new($x1 as f32, $y1 as f32),
}
);
};
}
pub use crate::assert_corners;
#[derive(Default)]
pub struct HarnessState {
pub root: Option<StrongWidget>,
}
impl HasRoot for HarnessState {
fn set_root(&mut self, root: StrongWidget) {
self.root = Some(root);
}
}
pub struct Harness {
pub rsc: DefaultRsc<HarnessState>,
pub render: UiRenderState,
pub state: HarnessState,
updates: Receiver<Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>>,
cursor: CursorState,
}
impl Harness {
/// `size` is the output in physical pixels.
pub fn new(size: impl Into<Vec2>) -> Self {
// A `TaskQueue` must be `Sync`, which `mpsc::Sender` is not; the
// bound that comes with `SyncSender` is far past anything a test
// leaves unread.
let (send, updates) = sync_channel(1024);
let rsc = DefaultRsc::init(Arc::new(Queue(send)));
let mut render = UiRenderState::new();
render.resize(size);
Self {
rsc,
render,
state: HarnessState::default(),
updates,
cursor: CursorState::default(),
}
}
pub fn size(&self) -> Vec2 {
self.render.output_size()
}
pub fn resize(&mut self, size: impl Into<Vec2>) {
self.render.resize(size);
}
/// Sets the root and lays it out, so a pointer event has something to hit.
pub fn set_root<T>(&mut self, widget: impl WidgetLike<DefaultRsc<HarnessState>, T>) {
widget.set_root(&mut self.rsc, &mut self.state);
self.frame();
}
pub fn needs_redraw(&self) -> bool {
self.render
.needs_redraw(&self.state.root, self.rsc.widgets())
}
pub fn apply_updates(&mut self) -> usize {
let mut applied = 0;
while let Ok(update) = self.updates.try_recv() {
update(&mut self.state, &mut self.rsc);
applied += 1;
}
applied
}
/// Waits for a task's first update, then applies everything waiting.
/// False if none arrived in time.
#[must_use]
pub fn await_update(&mut self, timeout: Duration) -> bool {
let Ok(update) = self.updates.recv_timeout(timeout) else {
return false;
};
update(&mut self.state, &mut self.rsc);
self.apply_updates();
true
}
/// Lays the tree out and builds its primitives.
pub fn frame(&mut self) {
self.apply_updates();
self.render.update(&self.state.root, &mut self.rsc);
}
/// Where the last frame put a widget, or `None` if it drew nothing.
pub fn region(&self, id: &impl IdLike) -> Option<PixelRegion> {
self.render.window_region(id)
}
pub fn move_to(&mut self, pos: impl Into<Vec2>) {
self.cursor.pos = pos.into();
self.cursor.exists = true;
self.sense();
}
pub fn leave(&mut self) {
self.cursor.exists = false;
self.sense();
}
pub fn press(&mut self, button: CursorButton) {
self.button(button).update(true);
self.sense();
}
pub fn release(&mut self, button: CursorButton) {
self.button(button).update(false);
self.sense();
}
/// A wheel carries no position, so this goes wherever the cursor was last
/// moved to -- nowhere, until it has been moved.
pub fn scroll(&mut self, delta: impl Into<Vec2>) {
self.cursor.scroll_delta = delta.into();
self.sense();
}
pub fn click(&mut self, pos: impl Into<Vec2>) {
self.move_to(pos);
self.press(CursorButton::Left);
self.release(CursorButton::Left);
}
fn button(&mut self, button: CursorButton) -> &mut ActivationState {
let buttons = &mut self.cursor.buttons;
match button {
CursorButton::Left => &mut buttons.left,
CursorButton::Middle => &mut buttons.middle,
CursorButton::Right => &mut buttons.right,
}
}
/// Dispatches against the layout of the last frame, which is what a
/// window delivers input against too.
fn sense(&mut self) {
let cursor = self.cursor.clone();
self.render
.run_sensors(&mut self.rsc, &mut self.state, cursor);
self.cursor.end_frame();
}
}
+1
View File
@@ -7,6 +7,7 @@
pub mod default;
pub mod event;
pub mod harness;
pub mod widget;
pub use iris_core as core;
+34
View File
@@ -0,0 +1,34 @@
//! Where a frame puts things, with no window to put them in.
use iris::harness::{Harness, assert_corners};
use iris::prelude::*;
/// A fixed 100 wide, and the rest of the 400 to its neighbour.
fn two_rects(h: &mut Harness) -> (WidgetId, WidgetId) {
let left = rect(Color::RED).width(100).add(&mut h.rsc);
let right = rect(Color::BLUE).add(&mut h.rsc);
h.set_root((left, right).span(Dir::RIGHT));
(left.id(), right.id())
}
#[test]
fn a_span_gives_each_child_the_width_it_asked_for() {
let mut h = Harness::new((400, 200));
let (left, right) = two_rects(&mut h);
assert_corners!(h, left, (0, 0), (100, 200));
assert_corners!(h, right, (100, 0), (400, 200));
}
#[test]
fn resizing_relays_out_against_the_new_output() {
let mut h = Harness::new((400, 200));
let (left, right) = two_rects(&mut h);
h.resize((800, 100));
assert!(h.needs_redraw());
h.frame();
assert_corners!(h, left, (0, 0), (100, 100));
assert_corners!(h, right, (100, 0), (800, 100));
}
+60
View File
@@ -0,0 +1,60 @@
//! Which widget an input reaches.
use std::{cell::RefCell, rc::Rc};
use iris::harness::Harness;
use iris::prelude::*;
#[test]
fn a_press_reaches_only_the_widget_under_the_cursor() {
let mut h = Harness::new((400, 200));
let clicks = Rc::new(RefCell::new(Vec::new()));
let (on_left, on_right) = (clicks.clone(), clicks.clone());
let left = rect(Color::RED)
.width(100)
.on(CursorSense::click(), move |_, _| {
on_left.borrow_mut().push("left")
})
.add(&mut h.rsc);
let right = rect(Color::BLUE)
.on(CursorSense::click(), move |_, _| {
on_right.borrow_mut().push("right")
})
.add(&mut h.rsc);
h.set_root((left, right).span(Dir::RIGHT));
h.click((50, 100));
assert_eq!(*clicks.borrow(), ["left"]);
h.click((300, 100));
assert_eq!(*clicks.borrow(), ["left", "right"]);
}
#[test]
fn hover_ends_when_the_cursor_leaves_the_window() {
let mut h = Harness::new((400, 200));
let hovered = Rc::new(RefCell::new(0));
let ended = Rc::new(RefCell::new(0));
let (h_count, e_count) = (hovered.clone(), ended.clone());
let widget = rect(Color::RED)
.on(CursorSense::HoverStart, move |_, _| {
*h_count.borrow_mut() += 1
})
.on(CursorSense::HoverEnd, move |_, _| {
*e_count.borrow_mut() += 1
})
.add(&mut h.rsc);
h.set_root(widget);
h.move_to((200, 100));
assert_eq!((*hovered.borrow(), *ended.borrow()), (1, 0));
// A second sample inside the same widget is not a second hover.
h.move_to((210, 100));
assert_eq!((*hovered.borrow(), *ended.borrow()), (1, 0));
h.leave();
assert_eq!((*hovered.borrow(), *ended.borrow()), (1, 1));
}
+92 -234
View File
@@ -1,45 +1,10 @@
//! Input across layers: what stops at a layer, what passes through it, and
//! where hovering stops. These drive `run_sensors` directly, which needs no
//! GPU and no window.
//! where hovering stops.
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
struct SenseRsc {
ui: UiData,
events: EventManager<SenseRsc>,
}
impl UiRsc for SenseRsc {
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 SenseRsc {
type State = ();
}
impl HasEvents for SenseRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
use iris::harness::Harness;
use iris::prelude::*;
const WINDOW: f32 = 100.0;
@@ -53,88 +18,36 @@ impl Fired {
}
}
struct Ui {
rsc: SenseRsc,
render: UiRenderState,
state: (),
/// A widget filling whatever it is given, recording the senses it is sent.
fn listener(h: &mut Harness, senses: impl Into<CursorSenses>) -> (WeakWidget<Rect>, Fired) {
let fired = Fired::default();
let record = fired.clone();
let id = rect(Color::WHITE)
.on(senses.into(), move |ctx, _| {
record.0.borrow_mut().push(ctx.data.sense)
})
.add(&mut h.rsc);
(id, fired)
}
impl Ui {
fn new() -> Self {
Self {
rsc: SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
},
render: UiRenderState::new(),
state: (),
}
}
fn listen<W: Widget + ?core::marker::Sized + 'static>(
&mut self,
widget: &StrongWidget<W>,
senses: impl Into<CursorSenses>,
) -> Fired {
let fired = Fired::default();
let sink = fired.clone();
self.rsc
.register_event(widget.weak(), senses.into(), move |ctx, _rsc| {
sink.0.borrow_mut().push(ctx.data.sense)
});
fired
}
/// Stacks the widgets bottom first, each on its own layer, and lays them
/// out in a square window.
fn stack(&mut self, children: Vec<StrongWidget>) {
let root = self
.rsc
.ui
.widgets
.add_strong(Stack {
children,
size: StackSize::default(),
})
.any();
self.render.resize((WINDOW, WINDOW));
self.render.update(&root, &mut self.rsc);
}
fn cursor(&mut self, at: (f32, f32)) -> CursorState {
CursorState {
pos: at.into(),
exists: true,
buttons: Default::default(),
scroll_delta: Vec2::ZERO,
}
}
fn run(&mut self, cursor: CursorState) {
self.render.run_sensors(
&mut self.rsc,
&mut self.state,
cursor,
(WINDOW, WINDOW).into(),
);
}
/// A widget with no senses of its own, to leave a gap beside one that has.
fn blank(h: &mut Harness) -> WeakWidget<Rect> {
rect(Color::WHITE).add(&mut h.rsc)
}
fn full(ui: &mut Ui) -> StrongWidget<Rect> {
rect(UiColor::WHITE).add_strong(&mut ui.rsc)
fn harness() -> Harness {
Harness::new((WINDOW, WINDOW))
}
#[test]
fn hover_stops_at_the_topmost_widget() {
let ui = &mut Ui::new();
let (bottom, middle, top) = (full(ui), full(ui), full(ui));
let bottom_hover = ui.listen(&bottom, CursorSense::HoverStart);
let middle_hover = ui.listen(&middle, CursorSense::HoverStart);
let top_hover = ui.listen(&top, CursorSense::HoverStart);
ui.stack(vec![bottom.any(), middle.any(), top.any()]);
let mut h = harness();
let (bottom, bottom_hover) = listener(&mut h, CursorSense::HoverStart);
let (middle, middle_hover) = listener(&mut h, CursorSense::HoverStart);
let (top, top_hover) = listener(&mut h, CursorSense::HoverStart);
h.set_root((bottom, middle, top).stack());
let cursor = ui.cursor((50.0, 50.0));
ui.run(cursor);
h.move_to((50, 50));
assert_eq!(top_hover.take(), [CursorSense::HoverStart]);
assert_eq!(
@@ -147,16 +60,14 @@ fn hover_stops_at_the_topmost_widget() {
#[test]
fn a_scroll_passes_through_every_widget_that_does_not_want_it() {
let ui = &mut Ui::new();
let (list, button, overlay) = (full(ui), full(ui), full(ui));
let scrolled = ui.listen(&list, CursorSense::Scroll);
let clicked = ui.listen(&button, CursorSense::click());
let overlay_clicked = ui.listen(&overlay, CursorSense::click());
ui.stack(vec![list.any(), button.any(), overlay.any()]);
let mut h = harness();
let (list, scrolled) = listener(&mut h, CursorSense::Scroll);
let (button, clicked) = listener(&mut h, CursorSense::click());
let (overlay, overlay_clicked) = listener(&mut h, CursorSense::click());
h.set_root((list, button, overlay).stack());
let mut cursor = ui.cursor((50.0, 50.0));
cursor.scroll_delta = (0.0, 10.0).into();
ui.run(cursor);
h.move_to((50, 50));
h.scroll((0, 10));
assert_eq!(
scrolled.take(),
@@ -168,16 +79,32 @@ fn a_scroll_passes_through_every_widget_that_does_not_want_it() {
}
#[test]
fn only_the_topmost_listener_takes_a_press() {
let ui = &mut Ui::new();
let (below, above) = (full(ui), full(ui));
let below_clicked = ui.listen(&below, CursorSense::click());
let above_clicked = ui.listen(&above, CursorSense::click());
ui.stack(vec![below.any(), above.any()]);
fn hovering_a_button_above_does_not_stop_a_later_scroll() {
let mut h = harness();
let (list, scrolled) = listener(&mut h, CursorSense::Scroll);
let (button, _clicked) = listener(&mut h, CursorSense::click());
h.set_root((list, button).stack());
let mut cursor = ui.cursor((50.0, 50.0));
cursor.buttons.left = ActivationState::Start;
ui.run(cursor);
// The hover arrives in its own frame, as a window delivers it.
h.move_to((50, 50));
assert_eq!(scrolled.take(), []);
h.scroll((0, 10));
assert_eq!(
scrolled.take(),
[CursorSense::Scroll],
"a hover already resting on the button must not consume the wheel"
);
}
#[test]
fn only_the_topmost_listener_takes_a_press() {
let mut h = harness();
let (below, below_clicked) = listener(&mut h, CursorSense::click());
let (above, above_clicked) = listener(&mut h, CursorSense::click());
h.set_root((below, above).stack());
h.click((50, 50));
assert_eq!(above_clicked.take(), [CursorSense::click()]);
assert_eq!(below_clicked.take(), [], "one press goes to one widget");
@@ -185,29 +112,18 @@ fn only_the_topmost_listener_takes_a_press() {
#[test]
fn a_press_beside_the_button_reaches_the_layer_below() {
let ui = &mut Ui::new();
let list = full(ui);
let mut h = harness();
let (list, list_clicked) = listener(&mut h, CursorSense::click());
// The row above the list covers it, but only its left half is the button.
let button = full(ui);
let gap = full(ui);
let list_clicked = ui.listen(&list, CursorSense::click());
let button_clicked = ui.listen(&button, CursorSense::click());
let row = ui.rsc.ui.widgets.add_strong(Span {
children: vec![button.any(), gap.any()],
dir: Dir::RIGHT,
gap: 0.0,
});
ui.stack(vec![list.any(), row.any()]);
let (button, button_clicked) = listener(&mut h, CursorSense::click());
let row = (button, blank(&mut h)).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root((list, row).stack());
let mut on_button = ui.cursor((20.0, 50.0));
on_button.buttons.left = ActivationState::Start;
ui.run(on_button);
h.click((20, 50));
assert_eq!(button_clicked.take(), [CursorSense::click()]);
assert_eq!(list_clicked.take(), []);
let mut beside_it = ui.cursor((80.0, 50.0));
beside_it.buttons.left = ActivationState::Start;
ui.run(beside_it);
h.click((80, 50));
assert_eq!(button_clicked.take(), [], "the cursor is not on the button");
assert_eq!(
list_clicked.take(),
@@ -218,44 +134,32 @@ fn a_press_beside_the_button_reaches_the_layer_below() {
#[test]
fn leaving_a_widget_still_ends_its_hover() {
let ui = &mut Ui::new();
let widget = full(ui);
let hover = ui.listen(&widget, CursorSense::HoverStart | CursorSense::HoverEnd);
ui.stack(vec![widget.any()]);
let mut h = harness();
let (widget, hover) = listener(&mut h, CursorSense::HoverStart | CursorSense::HoverEnd);
h.set_root(widget);
let cursor = ui.cursor((50.0, 50.0));
ui.run(cursor);
h.move_to((50, 50));
assert_eq!(hover.take(), [CursorSense::HoverStart]);
let mut gone = ui.cursor((50.0, 50.0));
gone.exists = false;
ui.run(gone);
h.leave();
assert_eq!(hover.take(), [CursorSense::HoverEnd]);
}
#[test]
fn leaving_a_widget_does_not_block_the_layer_below() {
let ui = &mut Ui::new();
let below = full(ui);
let mut h = harness();
let (below, below_hover) = listener(&mut h, CursorSense::HoverStart);
// Only the left half of the layer above is a widget, so the cursor can
// leave it without leaving the one underneath.
let (above, gap) = (full(ui), full(ui));
let below_hover = ui.listen(&below, CursorSense::HoverStart);
let above_hover = ui.listen(&above, CursorSense::HoverStart | CursorSense::HoverEnd);
let row = ui.rsc.ui.widgets.add_strong(Span {
children: vec![above.any(), gap.any()],
dir: Dir::RIGHT,
gap: 0.0,
});
ui.stack(vec![below.any(), row.any()]);
let (above, above_hover) = listener(&mut h, CursorSense::HoverStart | CursorSense::HoverEnd);
let row = (above, blank(&mut h)).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root((below, row).stack());
let on_above = ui.cursor((20.0, 50.0));
ui.run(on_above);
h.move_to((20, 50));
assert_eq!(above_hover.take(), [CursorSense::HoverStart]);
assert_eq!(below_hover.take(), [], "the layer above is over it");
let beside_it = ui.cursor((80.0, 50.0));
ui.run(beside_it);
h.move_to((80, 50));
assert_eq!(above_hover.take(), [CursorSense::HoverEnd]);
assert_eq!(
below_hover.take(),
@@ -264,53 +168,18 @@ fn leaving_a_widget_does_not_block_the_layer_below() {
);
}
#[test]
fn hovering_a_button_above_does_not_stop_a_later_scroll() {
let ui = &mut Ui::new();
let (list, button) = (full(ui), full(ui));
let scrolled = ui.listen(&list, CursorSense::Scroll);
let clicked = ui.listen(&button, CursorSense::click());
ui.stack(vec![list.any(), button.any()]);
// The hover arrives in its own frame, as a window delivers it.
let hover = ui.cursor((50.0, 50.0));
ui.run(hover);
assert_eq!(scrolled.take(), []);
let mut wheel = ui.cursor((50.0, 50.0));
wheel.scroll_delta = (0.0, 10.0).into();
ui.run(wheel);
assert_eq!(
scrolled.take(),
[CursorSense::Scroll],
"a hover already resting on the button must not consume the wheel"
);
assert_eq!(clicked.take(), []);
}
#[test]
fn covering_a_widget_ends_its_hover() {
let ui = &mut Ui::new();
let below = full(ui);
// Only the left half of the layer above is a widget, so the cursor can
// start beside it and then move onto it.
let (above, gap) = (full(ui), full(ui));
let below_hover = ui.listen(&below, CursorSense::HoverStart | CursorSense::HoverEnd);
let above_hover = ui.listen(&above, CursorSense::HoverStart);
let row = ui.rsc.ui.widgets.add_strong(Span {
children: vec![above.any(), gap.any()],
dir: Dir::RIGHT,
gap: 0.0,
});
ui.stack(vec![below.any(), row.any()]);
let mut h = harness();
let (below, below_hover) = listener(&mut h, CursorSense::HoverStart | CursorSense::HoverEnd);
let (above, above_hover) = listener(&mut h, CursorSense::HoverStart);
let row = (above, blank(&mut h)).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root((below, row).stack());
let beside_it = ui.cursor((80.0, 50.0));
ui.run(beside_it);
h.move_to((80, 50));
assert_eq!(below_hover.take(), [CursorSense::HoverStart]);
let onto_above = ui.cursor((20.0, 50.0));
ui.run(onto_above);
h.move_to((20, 50));
assert_eq!(above_hover.take(), [CursorSense::HoverStart]);
assert_eq!(
below_hover.take(),
@@ -318,8 +187,7 @@ fn covering_a_widget_ends_its_hover() {
"a widget covered by one that took the input is no longer hovered"
);
let off_again = ui.cursor((80.0, 50.0));
ui.run(off_again);
h.move_to((80, 50));
assert_eq!(
below_hover.take(),
[CursorSense::HoverStart],
@@ -329,36 +197,26 @@ fn covering_a_widget_ends_its_hover() {
#[test]
fn hover_starts_and_ends_once_each() {
let ui = &mut Ui::new();
let mut h = harness();
// Only the left half is the widget, so the cursor can leave it without
// leaving the window.
let (widget, gap) = (full(ui), full(ui));
let hover = ui.listen(&widget, CursorSense::HoverStart | CursorSense::HoverEnd);
let row = ui.rsc.ui.widgets.add_strong(Span {
children: vec![widget.any(), gap.any()],
dir: Dir::RIGHT,
gap: 0.0,
});
ui.stack(vec![row.any()]);
let (widget, hover) = listener(&mut h, CursorSense::HoverStart | CursorSense::HoverEnd);
let row = (widget, blank(&mut h)).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root(row);
let inside = ui.cursor((20.0, 50.0));
ui.run(inside);
h.move_to((20, 50));
assert_eq!(hover.take(), [CursorSense::HoverStart]);
let further_in = ui.cursor((30.0, 50.0));
ui.run(further_in);
h.move_to((30, 50));
assert_eq!(hover.take(), [], "staying inside is not a second start");
let outside = ui.cursor((80.0, 50.0));
ui.run(outside);
h.move_to((80, 50));
assert_eq!(hover.take(), [CursorSense::HoverEnd]);
let further_out = ui.cursor((90.0, 50.0));
ui.run(further_out);
h.move_to((90, 50));
assert_eq!(hover.take(), [], "an ended hover does not end again");
let back_inside = ui.cursor((20.0, 50.0));
ui.run(back_inside);
h.move_to((20, 50));
assert_eq!(
hover.take(),
[CursorSense::HoverStart],
+26
View File
@@ -0,0 +1,26 @@
//! Scrolling moves content and stops at its ends.
use iris::harness::{Harness, assert_corners};
use iris::prelude::*;
#[test]
fn a_wheel_scrolls_the_content_and_stops_at_its_end() {
let mut h = Harness::new((400, 200));
// Twice the window's height, so there is 200 to scroll.
let top = rect(Color::RED).height(200).add(&mut h.rsc);
let bottom = rect(Color::BLUE).height(200).add(&mut h.rsc);
h.set_root((top, bottom).span(Dir::DOWN).scrollable());
h.move_to((200, 100));
// `Scroll` starts snapped to the end.
assert_corners!(h, top, (0, -200), (400, 0));
// The handler scales a wheel line by 50.
h.scroll((0, 1));
h.frame();
assert_corners!(h, top, (0, -150), (400, 50));
h.scroll((0, 10));
h.frame();
assert_corners!(h, top, (0, 0), (400, 200));
}
+23
View File
@@ -0,0 +1,23 @@
//! What a background task can change, and how it gets back to the ui.
use std::time::Duration;
use iris::harness::Harness;
use iris::prelude::*;
#[test]
fn a_task_update_reaches_the_tree() {
let mut h = Harness::new((400, 200));
let widget = rect(Color::RED).add(&mut h.rsc);
h.set_root(widget.task_on(CursorSense::click(), async move |mut ctx| {
ctx.update(move |_, rsc| widget(rsc).color = Color::BLUE);
}));
h.click((200, 100));
assert!(
h.await_update(Duration::from_secs(5)),
"the task sent no update"
);
assert_eq!(h.rsc[widget].color, Color::BLUE);
}