From c8ac669f95149d0c121c43ef15465e9aec97c494 Mon Sep 17 00:00:00 2001 From: AIris <4+iris-ai@noreply.localhost> Date: Sun, 13 Sep 2026 21:53:54 -0400 Subject: [PATCH] Run a ui without a window, and test one (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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`, 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: https://git.arirex.me/iris/iris/pulls/15 Reviewed-by: iris <2+iris@noreply.localhost> Co-authored-by: AIris <4+iris-ai@noreply.localhost> --- core/src/orientation/pos.rs | 2 +- core/src/ui/render_state.rs | 4 + examples/minimal.rs | 6 +- examples/tabs/main.rs | 6 +- examples/task.rs | 6 +- examples/view.rs | 6 +- src/default/mod.rs | 102 +++++++++++--------- src/default/task.rs | 50 ++++------ src/harness.rs | 181 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/layout.rs | 34 +++++++ tests/pointer.rs | 60 ++++++++++++ tests/scroll.rs | 26 ++++++ tests/tasks.rs | 23 +++++ 14 files changed, 410 insertions(+), 97 deletions(-) create mode 100644 src/harness.rs create mode 100644 tests/layout.rs create mode 100644 tests/pointer.rs create mode 100644 tests/scroll.rs create mode 100644 tests/tasks.rs diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs index ae0cbc5..466022e 100644 --- a/core/src/orientation/pos.rs +++ b/core/src/orientation/pos.rs @@ -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, diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 4bd7a90..476dc78 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -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>, rsc: &mut dyn UiRsc) { // safety mechanism for memory leaks; might wanna return a result instead so user can // decide whether to panic or not diff --git a/examples/minimal.rs b/examples/minimal.rs index e98ae5b..ca10f51 100644 --- a/examples/minimal.rs +++ b/examples/minimal.rs @@ -10,11 +10,7 @@ struct State { } impl DefaultAppState for State { - fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, - _: Proxy, - ) -> Self { + fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc, _: Proxy) -> Self { rect(Color::RED).set_root(rsc, &mut ui_state); Self { ui_state } } diff --git a/examples/tabs/main.rs b/examples/tabs/main.rs index ba314c6..6bd93c4 100644 --- a/examples/tabs/main.rs +++ b/examples/tabs/main.rs @@ -15,11 +15,7 @@ pub struct Client { } impl DefaultAppState for Client { - fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, - _: Proxy, - ) -> Self { + fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc, _: Proxy) -> Self { let rrect = rect(Color::WHITE).radius(20); let pad_test = ( rrect.color(Color::BLUE), diff --git a/examples/task.rs b/examples/task.rs index 63e4d43..12ba8dc 100644 --- a/examples/task.rs +++ b/examples/task.rs @@ -11,11 +11,7 @@ struct State { } impl DefaultAppState for State { - fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, - _: Proxy, - ) -> Self { + fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc, _: Proxy) -> 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; diff --git a/examples/view.rs b/examples/view.rs index b8e06cf..2fb4995 100644 --- a/examples/view.rs +++ b/examples/view.rs @@ -36,11 +36,7 @@ impl Test { } impl DefaultAppState for State { - fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, - _: Proxy, - ) -> Self { + fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc, _: Proxy) -> Self { let test = Test::new(rsc); test.on(CursorSense::click(), move |_, rsc| { diff --git a/src/default/mod.rs b/src/default/mod.rs index af2c520..0c61a86 100644 --- a/src/default/mod.rs +++ b/src/default/mod.rs @@ -25,7 +25,35 @@ pub use sense::*; pub use state::*; pub use task::*; -pub type Proxy = EventLoopProxy; +/// 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(EventLoopProxy>); + +impl Clone for Proxy { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Proxy { + 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 { + User(State::Event), + Update(Box>>), +} + +impl TaskQueue> for Proxy { + fn send(&self, update: Box>>) { + let _ = self.0.send_event(DefaultEvent::Update(update)); + } +} pub struct DefaultUiState { pub root: Option, @@ -66,9 +94,8 @@ pub trait HasDefaultUiState: Sized + 'static { } pub trait DefaultAppState: HasDefaultUiState { - type Event = (); - fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc, proxy: Proxy) - -> Self; + type Event: Send = (); + fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc, proxy: Proxy) -> Self; #[allow(unused_variables)] fn event( &mut self, @@ -101,18 +128,14 @@ pub struct DefaultRsc { } impl DefaultRsc { - fn init(window: Arc) -> (Self, TaskMsgReceiver) { - 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>) -> Self { + Self { + ui: Default::default(), + events: Default::default(), + tasks: Tasks::init(queue), + state: Default::default(), + _state: Default::default(), + } } pub fn create_state(&mut self, id: impl IdLike, data: T) -> WeakState { @@ -177,43 +200,32 @@ pub struct DefaultApp { rsc: DefaultRsc, render: UiRenderState, state: State, - task_recv: TaskMsgReceiver>, } impl AppState for DefaultApp { - type Event = State::Event; + type Event = DefaultEvent; fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy) -> 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); @@ -293,11 +305,8 @@ impl AppState for DefaultApp { _ => (), } 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 +314,15 @@ impl AppState for DefaultApp { } } +impl DefaultApp { + 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 { type Output; fn get(self, rsc: &Rsc) -> &Self::Output; diff --git a/src/default/task.rs b/src/default/task.rs index 36b55c1..46a946e 100644 --- a/src/default/task.rs +++ b/src/default/task.rs @@ -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 = SyncSender>>; -pub type TaskMsgReceiver = SyncReceiver>>; pub trait TaskUpdate: FnOnce(&mut Rsc::State, &mut Rsc) + Send {} impl TaskUpdate 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: Send + Sync + 'static { + fn send(&self, update: Box>); +} + pub struct Tasks { start: AsyncSender, - window: Arc, - msg_send: SyncSender>>, + queue: Arc>, } pub struct TaskCtx { - send: TaskMsgSender, + queue: Arc>, } impl TaskCtx { pub fn update(&mut self, f: impl TaskUpdate + 'static) { - let _ = self.send.send(Box::new(f)); - } -} -impl TaskCtx { - fn new(send: TaskMsgSender) -> Self { - Self { send } + self.queue.send(Box::new(f)); } } type BoxTask = Pin + Send>>; impl Tasks { - pub fn init(window: Arc) -> (Self, TaskMsgReceiver) { + pub fn init(queue: Arc>) -> 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) + '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; })); } } diff --git a/src/harness.rs b/src/harness.rs new file mode 100644 index 0000000..f575df6 --- /dev/null +++ b/src/harness.rs @@ -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>>>); + +impl TaskQueue> for Queue { + fn send(&self, update: Box>>) { + 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, +} + +impl HasRoot for HarnessState { + fn set_root(&mut self, root: StrongWidget) { + self.root = Some(root); + } +} + +pub struct Harness { + pub rsc: DefaultRsc, + pub render: UiRenderState, + pub state: HarnessState, + updates: Receiver>>>, + cursor: CursorState, +} + +impl Harness { + /// `size` is the output in physical pixels. + pub fn new(size: impl Into) -> 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) { + self.render.resize(size); + } + + /// Sets the root and lays it out, so a pointer event has something to hit. + pub fn set_root(&mut self, widget: impl WidgetLike, 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 { + self.render.window_region(id) + } + + pub fn move_to(&mut self, pos: impl Into) { + 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) { + self.cursor.scroll_delta = delta.into(); + self.sense(); + } + + pub fn click(&mut self, pos: impl Into) { + 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(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 05ef101..1e50b70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod default; pub mod event; +pub mod harness; pub mod widget; pub use iris_core as core; diff --git a/tests/layout.rs b/tests/layout.rs new file mode 100644 index 0000000..940a5aa --- /dev/null +++ b/tests/layout.rs @@ -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)); +} diff --git a/tests/pointer.rs b/tests/pointer.rs new file mode 100644 index 0000000..bcc17d4 --- /dev/null +++ b/tests/pointer.rs @@ -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)); +} diff --git a/tests/scroll.rs b/tests/scroll.rs new file mode 100644 index 0000000..24a2d73 --- /dev/null +++ b/tests/scroll.rs @@ -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)); +} diff --git a/tests/tasks.rs b/tests/tasks.rs new file mode 100644 index 0000000..b036f3e --- /dev/null +++ b/tests/tasks.rs @@ -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); +}