Compare commits

...
Author SHA1 Message Date
iris 7cefc72f97 Compare regions, not four loose numbers
`PixelRegion` derives `PartialEq`, `Clone` and `Copy`, so `assert_corners!`
compares one against another instead of flattening both to a tuple whose
order there was nothing to check. A failure now prints two regions.
2026-09-13 21:42:21 -04:00
iris 0f9f379cec Say what these do without naming what they lean on
A doc comment that names something outside the method goes stale when
that thing changes, and nobody editing it looks here.
2026-09-13 21:37:44 -04:00
iris 7eb2b85425 One proxy, and a macro instead of a shared test module
`Proxy` is the task queue as well as the way an application sends its own
events, so `ProxyQueue` is gone. `schedule_redraw` becomes
`request_redraw_if_needed`, which says what the comment beside it was
saying.

`assert_corners!` replaces the region helper, so `tests/common` goes with
it, and the scroll test now states both corners rather than one number.
2026-09-13 21:29:38 -04:00
iris 3a74a04a5b Post task updates to the loop instead of waking it
`WakeTaskQueue` becomes `TaskQueue`, which carries the update itself.
Delivery and waking are then one act: the winit host sends it through the
`EventLoopProxy` as a `DefaultEvent::Update`, so there is no channel
beside the loop and nothing has to claim a redraw is needed in order to be
looked at. `Window::request_redraw` is gone from this path; `event`
applies the update and then asks the tree whether anything became dirty,
which is the same question `window_event` already ended with -- now
`schedule_redraw`, called from both.

The loop's message type is `DefaultEvent<State>`, so `Proxy` becomes a
wrapper that takes the application's own `Event` and requires it to be
`Send`, since it now crosses to the task thread by that route.

The harness supplies a channel-backed queue, which is what lets a test
hold updates until it asks for them.

Tests split by subject -- layout, pointer, scroll, tasks -- with the
region helper in `tests/common`.
2026-09-13 21:04:29 -04:00
iris e97aba30e0 Merge upstream/main (#14) into split/15-harness 2026-09-13 20:44:01 -04:00
iris d5efdd2b97 Run a ui without a window, and test one
`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 moves from "the task ended" to "an update was sent", which is when
there is something for the host to apply: a task that keeps running after
sending one no longer holds its update until it finishes, and a task that
sends none no longer asks for a frame it does not need.

`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` does each of those; none of them could be written
before, since the only entry point to layout was a window.

It does not draw. A claim about pixels still needs a real surface.
2026-09-13 20:14:11 -04:00
14 changed files with 410 additions and 97 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 struct PixelRegion {
pub top_left: Vec2, pub top_left: Vec2,
pub bot_right: Vec2, pub bot_right: Vec2,
+4
View File
@@ -34,6 +34,10 @@ impl UiRenderState {
self.resized = true; 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) { 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 // safety mechanism for memory leaks; might wanna return a result instead so user can
// decide whether to panic or not // decide whether to panic or not
+1 -5
View File
@@ -10,11 +10,7 @@ struct State {
} }
impl DefaultAppState for State { impl DefaultAppState for State {
fn new( fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
rect(Color::RED).set_root(rsc, &mut ui_state); rect(Color::RED).set_root(rsc, &mut ui_state);
Self { ui_state } Self { ui_state }
} }
+1 -5
View File
@@ -15,11 +15,7 @@ pub struct Client {
} }
impl DefaultAppState for Client { impl DefaultAppState for Client {
fn new( fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let rrect = rect(Color::WHITE).radius(20); let rrect = rect(Color::WHITE).radius(20);
let pad_test = ( let pad_test = (
rrect.color(Color::BLUE), rrect.color(Color::BLUE),
+1 -5
View File
@@ -11,11 +11,7 @@ struct State {
} }
impl DefaultAppState for State { impl DefaultAppState for State {
fn new( fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let rect = rect(Color::RED).add(rsc); let rect = rect(Color::RED).add(rsc);
rect.task_on(CursorSense::click(), async move |mut ctx| { rect.task_on(CursorSense::click(), async move |mut ctx| {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
+1 -5
View File
@@ -36,11 +36,7 @@ impl Test {
} }
impl DefaultAppState for State { impl DefaultAppState for State {
fn new( fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let test = Test::new(rsc); let test = Test::new(rsc);
test.on(CursorSense::click(), move |_, rsc| { test.on(CursorSense::click(), move |_, rsc| {
+60 -42
View File
@@ -25,7 +25,35 @@ pub use sense::*;
pub use state::*; pub use state::*;
pub use task::*; 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 struct DefaultUiState {
pub root: Option<StrongWidget>, pub root: Option<StrongWidget>,
@@ -66,9 +94,8 @@ pub trait HasDefaultUiState: Sized + 'static {
} }
pub trait DefaultAppState: HasDefaultUiState { pub trait DefaultAppState: HasDefaultUiState {
type Event = (); type Event: Send = ();
fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, proxy: Proxy<Self::Event>) fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, proxy: Proxy<Self>) -> Self;
-> Self;
#[allow(unused_variables)] #[allow(unused_variables)]
fn event( fn event(
&mut self, &mut self,
@@ -101,18 +128,14 @@ pub struct DefaultRsc<State: 'static> {
} }
impl<State> DefaultRsc<State> { impl<State> DefaultRsc<State> {
fn init(window: Arc<Window>) -> (Self, TaskMsgReceiver<Self>) { pub fn init(queue: Arc<dyn TaskQueue<Self>>) -> Self {
let (tasks, recv) = Tasks::init(window); Self {
( ui: Default::default(),
Self { events: Default::default(),
ui: Default::default(), tasks: Tasks::init(queue),
events: Default::default(), state: Default::default(),
tasks, _state: Default::default(),
state: Default::default(), }
_state: Default::default(),
},
recv,
)
} }
pub fn create_state<T: 'static>(&mut self, id: impl IdLike, data: T) -> WeakState<T> { 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>, rsc: DefaultRsc<State>,
render: UiRenderState, render: UiRenderState,
state: State, state: State,
task_recv: TaskMsgReceiver<DefaultRsc<State>>,
} }
impl<State: DefaultAppState> AppState for DefaultApp<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 { fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
let window = event_loop let window = event_loop
.create_window(State::window_attributes()) .create_window(State::window_attributes())
.unwrap(); .unwrap();
let default_state = DefaultUiState::new(window); let default_state = DefaultUiState::new(window);
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone()); let mut rsc = DefaultRsc::init(Arc::new(Proxy(proxy.clone())));
let state = State::new(default_state, &mut rsc, proxy); let state = State::new(default_state, &mut rsc, Proxy(proxy));
let render = UiRenderState::new(); let render = UiRenderState::new();
Self { Self { rsc, state, render }
rsc,
state,
render,
task_recv,
}
} }
fn event(&mut self, event: Self::Event, _: &ActiveEventLoop) { 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) { fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop) {
let Self { let Self { rsc, render, state } = self;
rsc,
render,
state,
task_recv,
} = self;
for update in task_recv.try_iter() {
update(state, rsc);
}
let ui_state = state.default_state_mut(); let ui_state = state.default_state_mut();
let input_changed = ui_state.input.event(&event); let input_changed = ui_state.input.event(&event);
@@ -293,11 +305,8 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
_ => (), _ => (),
} }
state.window_event(event, rsc, render); state.window_event(event, rsc, render);
let ui_state = self.state.default_state_mut(); self.request_redraw_if_needed();
if render.needs_redraw(&ui_state.root, rsc.widgets()) { self.state.default_state_mut().input.end_frame();
ui_state.renderer.window().request_redraw();
}
ui_state.input.end_frame();
} }
fn exit(&mut self) { fn exit(&mut self) {
@@ -305,6 +314,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> { pub trait RscIdx<Rsc> {
type Output; type Output;
fn get(self, rsc: &Rsc) -> &Self::Output; fn get(self, rsc: &Rsc) -> &Self::Output;
+16 -34
View File
@@ -1,11 +1,5 @@
use iris_core::HasState; use iris_core::HasState;
use std::{ use std::{pin::Pin, sync::Arc};
pin::Pin,
sync::{
Arc,
mpsc::{Receiver as SyncReceiver, Sender as SyncSender, channel as sync_channel},
},
};
use tokio::{ use tokio::{
runtime::Runtime, runtime::Runtime,
sync::mpsc::{ sync::mpsc::{
@@ -13,64 +7,52 @@ use tokio::{
unbounded_channel as async_channel, 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 {} 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 {} 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> { pub struct Tasks<Rsc: HasState> {
start: AsyncSender<BoxTask>, start: AsyncSender<BoxTask>,
window: Arc<Window>, queue: Arc<dyn TaskQueue<Rsc>>,
msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>,
} }
pub struct TaskCtx<Rsc: HasState> { pub struct TaskCtx<Rsc: HasState> {
send: TaskMsgSender<Rsc>, queue: Arc<dyn TaskQueue<Rsc>>,
} }
impl<Rsc: HasState> TaskCtx<Rsc> { impl<Rsc: HasState> TaskCtx<Rsc> {
pub fn update(&mut self, f: impl TaskUpdate<Rsc> + 'static) { pub fn update(&mut self, f: impl TaskUpdate<Rsc> + 'static) {
let _ = self.send.send(Box::new(f)); self.queue.send(Box::new(f));
}
}
impl<Rsc: HasState + 'static> TaskCtx<Rsc> {
fn new(send: TaskMsgSender<Rsc>) -> Self {
Self { send }
} }
} }
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>; type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
impl<Rsc: HasState> Tasks<Rsc> { 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 (start, start_recv) = async_channel();
let (msgs, msgs_recv) = sync_channel();
std::thread::spawn(|| { std::thread::spawn(|| {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
rt.block_on(listen(start_recv)) rt.block_on(listen(start_recv))
}); });
( Self { start, queue }
Self {
start,
msg_send: msgs,
window,
},
msgs_recv,
)
} }
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F) pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
where where
F::CallOnceFuture: Send, F::CallOnceFuture: Send,
{ {
let send = self.msg_send.clone(); let queue = self.queue.clone();
let window = self.window.clone();
let _ = self.start.send(Box::pin(async move { let _ = self.start.send(Box::pin(async move {
task(TaskCtx::new(send)).await; task(TaskCtx { queue }).await;
window.request_redraw();
})); }));
} }
} }
+181
View File
@@ -0,0 +1,181 @@
//! 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();
let size = self.render.output_size();
self.render
.run_sensors(&mut self.rsc, &mut self.state, cursor, size);
self.cursor.end_frame();
}
}
+1
View File
@@ -7,6 +7,7 @@
pub mod default; pub mod default;
pub mod event; pub mod event;
pub mod harness;
pub mod widget; pub mod widget;
pub use iris_core as core; 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));
}
+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);
}