Reviewed-on: iris/iris#17 Reviewed-by: iris <2+iris@noreply.localhost> Co-authored-by: iris-ai <4+iris-ai@noreply.localhost>
321 lines
9.7 KiB
Rust
321 lines
9.7 KiB
Rust
//! 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;
|
|
|
|
/// One replayed pointer sample, cut down to what a window delivers: where the
|
|
/// pointer is, and whether the button changed.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum TouchAction {
|
|
Down,
|
|
Move,
|
|
Up,
|
|
}
|
|
|
|
impl TouchAction {
|
|
fn parse(word: &str) -> Option<Self> {
|
|
match word {
|
|
"down" => Some(Self::Down),
|
|
"move" => Some(Self::Move),
|
|
"up" => Some(Self::Up),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct TouchSample {
|
|
pub t_ms: u64,
|
|
pub action: TouchAction,
|
|
pub pos: Vec2,
|
|
}
|
|
|
|
/// A recorded gesture, in the output's own pixels: `<ms> down|move|up <x> <y>`
|
|
/// a line, `#` to end of line ignored.
|
|
///
|
|
/// One parser for both ways of replaying a recording -- into a harness, and
|
|
/// into a real window -- so the two cannot read the same file differently.
|
|
pub struct TouchScript {
|
|
pub samples: Vec<TouchSample>,
|
|
}
|
|
|
|
impl TouchScript {
|
|
pub fn parse(text: &str) -> Result<Self, String> {
|
|
let mut samples: Vec<TouchSample> = Vec::new();
|
|
for (i, line) in text.lines().enumerate() {
|
|
let line = line.split('#').next().unwrap_or("").trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
|
|
let mut words = line.split_whitespace();
|
|
let (Some(t), Some(action), Some(x), Some(y), None) = (
|
|
words.next(),
|
|
words.next(),
|
|
words.next(),
|
|
words.next(),
|
|
words.next(),
|
|
) else {
|
|
return Err(at("expected `t_ms action x y`"));
|
|
};
|
|
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
|
|
let action =
|
|
TouchAction::parse(action).ok_or_else(|| at("action is not down/move/up"))?;
|
|
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
|
|
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
|
|
if let Some(last) = samples.last()
|
|
&& t_ms < last.t_ms
|
|
{
|
|
return Err(at("samples must be in time order"));
|
|
}
|
|
samples.push(TouchSample {
|
|
t_ms,
|
|
action,
|
|
pos: Vec2::new(x, y),
|
|
});
|
|
}
|
|
Ok(Self { samples })
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
/// Drives a recorded gesture through the harness.
|
|
pub fn replay(&mut self, script: &TouchScript) {
|
|
for sample in &script.samples {
|
|
match sample.action {
|
|
TouchAction::Down => {
|
|
self.move_to(sample.pos);
|
|
self.press(CursorButton::Left);
|
|
}
|
|
TouchAction::Move => self.move_to(sample.pos),
|
|
TouchAction::Up => {
|
|
self.move_to(sample.pos);
|
|
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();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn parse(text: &str) -> Result<Vec<(u64, TouchAction, f32, f32)>, String> {
|
|
Ok(TouchScript::parse(text)?
|
|
.samples
|
|
.iter()
|
|
.map(|s| (s.t_ms, s.action, s.pos.x, s.pos.y))
|
|
.collect())
|
|
}
|
|
|
|
#[test]
|
|
fn a_recording_is_time_action_and_a_point() {
|
|
assert_eq!(
|
|
parse("0 down 10 20\n16 move 10.5 24\n32 up 10.5 24").unwrap(),
|
|
[
|
|
(0, TouchAction::Down, 10.0, 20.0),
|
|
(16, TouchAction::Move, 10.5, 24.0),
|
|
(32, TouchAction::Up, 10.5, 24.0),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blank_lines_and_comments_are_not_samples() {
|
|
assert_eq!(
|
|
parse("# a flick\n\n 0 down 1 2 # the finger lands\n\n").unwrap(),
|
|
[(0, TouchAction::Down, 1.0, 2.0)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_recording_that_goes_backwards_is_rejected() {
|
|
// Replay waits out the gap between samples, so time running backwards
|
|
// would silently become no wait at all.
|
|
let err = parse("16 down 1 2\n0 up 1 2").unwrap_err();
|
|
assert!(err.contains("time order"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_line_that_is_not_a_sample_says_which_line() {
|
|
let err = parse("0 down 1 2\n16 wiggle 1 2").unwrap_err();
|
|
assert!(err.contains("line 2"), "{err}");
|
|
assert!(err.contains("down/move/up"), "{err}");
|
|
}
|
|
}
|