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/src/default/mod.rs b/src/default/mod.rs index ea00eb7..0577309 100644 --- a/src/default/mod.rs +++ b/src/default/mod.rs @@ -64,6 +64,12 @@ impl DefaultUiState { } } +impl WakeTaskQueue for Window { + fn wake(&self) { + self.request_redraw(); + } +} + pub trait HasDefaultUiState: Sized + 'static { fn default_state(&self) -> &DefaultUiState; fn default_state_mut(&mut self) -> &mut DefaultUiState; @@ -105,8 +111,8 @@ pub struct DefaultRsc { } impl DefaultRsc { - fn init(window: Arc) -> (Self, TaskMsgReceiver) { - let (tasks, recv) = Tasks::init(window); + pub fn init(wake: Arc) -> (Self, TaskMsgReceiver) { + let (tasks, recv) = Tasks::init(wake); ( Self { ui: Default::default(), diff --git a/src/default/task.rs b/src/default/task.rs index 36b55c1..80588b9 100644 --- a/src/default/task.rs +++ b/src/default/task.rs @@ -13,7 +13,13 @@ use tokio::{ unbounded_channel as async_channel, }, }; -use winit::window::Window; + +/// Wakes the host so it applies queued task updates. A task reaches the +/// application only through [`TaskCtx::update`], so this is all the queue +/// needs of a platform. +pub trait WakeTaskQueue: Send + Sync + 'static { + fn wake(&self); +} pub type TaskMsgSender = SyncSender>>; pub type TaskMsgReceiver = SyncReceiver>>; @@ -23,29 +29,32 @@ impl TaskUpdate pub struct Tasks { start: AsyncSender, - window: Arc, + wake: Arc, msg_send: SyncSender>>, } pub struct TaskCtx { send: TaskMsgSender, + wake: Arc, } impl TaskCtx { pub fn update(&mut self, f: impl TaskUpdate + 'static) { - let _ = self.send.send(Box::new(f)); + if self.send.send(Box::new(f)).is_ok() { + self.wake.wake(); + } } } impl TaskCtx { - fn new(send: TaskMsgSender) -> Self { - Self { send } + fn new(send: TaskMsgSender, wake: Arc) -> Self { + Self { send, wake } } } type BoxTask = Pin + Send>>; impl Tasks { - pub fn init(window: Arc) -> (Self, TaskMsgReceiver) { + pub fn init(wake: Arc) -> (Self, TaskMsgReceiver) { let (start, start_recv) = async_channel(); let (msgs, msgs_recv) = sync_channel(); std::thread::spawn(|| { @@ -56,7 +65,7 @@ impl Tasks { Self { start, msg_send: msgs, - window, + wake, }, msgs_recv, ) @@ -67,10 +76,9 @@ impl Tasks { F::CallOnceFuture: Send, { let send = self.msg_send.clone(); - let window = self.window.clone(); + let wake = self.wake.clone(); let _ = self.start.send(Box::pin(async move { - task(TaskCtx::new(send)).await; - window.request_redraw(); + task(TaskCtx::new(send, wake)).await; })); } } @@ -80,3 +88,48 @@ async fn listen(mut recv: AsyncReceiver) { tokio::spawn(task); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::{sync::mpsc::sync_channel, time::Duration}; + + struct TestRsc; + + impl HasState for TestRsc { + type State = usize; + } + + /// Signals rather than counts, so the test waits for a wake instead of + /// racing the task thread to sample it. + struct WakeSignal(std::sync::mpsc::SyncSender<()>); + + impl WakeTaskQueue for WakeSignal { + fn wake(&self) { + let _ = self.0.send(()); + } + } + + #[test] + fn every_update_wakes_the_host() { + let (woken, wakes) = sync_channel(8); + let (mut tasks, updates) = Tasks::::init(Arc::new(WakeSignal(woken))); + + tasks.spawn(async move |mut ctx| { + ctx.update(|state: &mut usize, _| *state += 1); + ctx.update(|state: &mut usize, _| *state += 2); + }); + + let second = Duration::from_secs(1); + let (mut state, mut rsc) = (0, TestRsc); + for _ in 0..2 { + wakes.recv_timeout(second).expect("no wake for an update"); + updates.recv_timeout(second).unwrap()(&mut state, &mut rsc); + } + assert_eq!(state, 3); + assert!( + wakes.recv_timeout(Duration::from_millis(100)).is_err(), + "woken with nothing to apply" + ); + } +} diff --git a/src/harness.rs b/src/harness.rs new file mode 100644 index 0000000..1a268ff --- /dev/null +++ b/src/harness.rs @@ -0,0 +1,154 @@ +//! 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, time::Duration}; + +/// The harness drains the update queue itself, so there is no loop to wake. +struct NoWake; + +impl WakeTaskQueue for NoWake { + fn wake(&self) {} +} + +#[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: TaskMsgReceiver>, + cursor: CursorState, +} + +impl Harness { + /// `size` is the output in physical pixels. + pub fn new(size: impl Into) -> Self { + let (rsc, updates) = DefaultRsc::init(Arc::new(NoWake)); + 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 + /// [`move_to`](Self::move_to) last put the cursor -- nowhere, until it has + /// been called. + 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 last [`frame`](Self::frame)'s layout, 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/harness.rs b/tests/harness.rs new file mode 100644 index 0000000..52262c8 --- /dev/null +++ b/tests/harness.rs @@ -0,0 +1,140 @@ +//! Layout, hit testing and task updates, driven without a window. + +use std::{cell::RefCell, rc::Rc, time::Duration}; + +use iris::harness::Harness; +use iris::prelude::*; + +/// `PixelRegion` neither compares nor prints. +fn corners(h: &Harness, id: &impl IdLike) -> (f32, f32, f32, f32) { + let region = h.region(id).expect("widget drew nothing"); + ( + region.top_left.x, + region.top_left.y, + region.bot_right.x, + region.bot_right.y, + ) +} + +/// 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_eq!(corners(&h, &left), (0.0, 0.0, 100.0, 200.0)); + assert_eq!(corners(&h, &right), (100.0, 0.0, 400.0, 200.0)); +} + +#[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_eq!(corners(&h, &left), (0.0, 0.0, 100.0, 100.0)); + assert_eq!(corners(&h, &right), (100.0, 0.0, 800.0, 100.0)); +} + +#[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)); +} + +#[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); +} + +#[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_eq!(corners(&h, &top).1, -200.0); + + // The handler scales a wheel line by 50. + h.scroll((0, 1)); + h.frame(); + assert_eq!(corners(&h, &top).1, -150.0); + + h.scroll((0, 10)); + h.frame(); + assert_eq!(corners(&h, &top).1, 0.0); +}