From 9d13f15beef6bb353120952a316c77272c62abbf Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 13 Sep 2026 23:10:23 -0400 Subject: [PATCH] Bring the headless rig into the repository A rendering claim about iris was verified by hand from another checkout, because the compositor script and the input replay lived in ai-app's submodule and not here. `scripts/run-headless.sh` starts a headless sway on its own socket, runs an example against it and screenshots the result. `rig-input`'s `replay-touch` drives a recorded gesture in through Wayland's virtual pointer, since a headless compositor has no input device to move. `iris::harness` gains the `.touch` parser, so a recording means the same thing replayed into a harness as into a window rather than being read twice by two parsers. The ai-app copy's `--phone` became `--mode`, since which phone is not iris's business; its `IRIS_SCALE` has nothing to hand a density to here, so it waits for one. --- Cargo.lock | 41 ++++++---- Cargo.toml | 4 +- rig-input/Cargo.toml | 16 ++++ rig-input/src/main.rs | 141 +++++++++++++++++++++++++++++++++++ scripts/headless.conf | 14 ++++ scripts/run-headless.sh | 161 ++++++++++++++++++++++++++++++++++++++++ src/harness.rs | 140 ++++++++++++++++++++++++++++++++++ tests/pointer.rs | 27 ++++++- 8 files changed, 526 insertions(+), 18 deletions(-) create mode 100644 rig-input/Cargo.toml create mode 100644 rig-input/src/main.rs create mode 100644 scripts/headless.conf create mode 100755 scripts/run-headless.sh diff --git a/Cargo.lock b/Cargo.lock index d91f2dd..c143053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -567,9 +567,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -2158,9 +2158,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -2353,6 +2353,15 @@ version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +[[package]] +name = "rig-input" +version = "0.1.0" +dependencies = [ + "iris", + "wayland-client", + "wayland-protocols-wlr", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -2909,9 +2918,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.12" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -2923,9 +2932,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.10.0", "rustix 1.1.3", @@ -2957,9 +2966,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -2982,9 +2991,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -2995,9 +3004,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -3006,9 +3015,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", diff --git a/Cargo.toml b/Cargo.toml index a1777e2..f9b643a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] } [workspace] -members = ["core", "macro"] +members = ["core", "macro", "rig-input"] [workspace.package] version = "0.1.0" @@ -40,3 +40,5 @@ arboard = "3.6.1" iris-core = { path = "core" } iris-macro = { path = "macro" } tokio = "1.49.0" +wayland-client = "0.31.15" +wayland-protocols-wlr = { version = "0.3.12", features = ["client"] } diff --git a/rig-input/Cargo.toml b/rig-input/Cargo.toml new file mode 100644 index 0000000..b1db853 --- /dev/null +++ b/rig-input/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rig-input" +version.workspace = true +edition.workspace = true + +# Replays `.touch` recordings through Wayland's virtual-pointer protocol; +# headless sway has no input devices for coordinate-driving tools to move. +[[bin]] +name = "replay-touch" +path = "src/main.rs" + +[dependencies] +# Share the harness parser so both ways of replaying read a file the same. +iris = { path = ".." } +wayland-client = { workspace = true } +wayland-protocols-wlr = { workspace = true } diff --git a/rig-input/src/main.rs b/rig-input/src/main.rs new file mode 100644 index 0000000..ec74f3c --- /dev/null +++ b/rig-input/src/main.rs @@ -0,0 +1,141 @@ +use iris::harness::{TouchAction, TouchScript}; +use std::time::Duration; +use wayland_client::protocol::wl_pointer::ButtonState; +use wayland_client::protocol::{wl_registry, wl_seat}; +use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop}; +use wayland_protocols_wlr::virtual_pointer::v1::client::{ + zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1, + zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1, +}; + +const BTN_LEFT: u32 = 0x110; + +const SETTLE: Duration = Duration::from_millis(200); + +#[derive(Default)] +struct Globals { + seat: Option, + manager: Option, +} + +impl Dispatch for Globals { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + "zwlr_virtual_pointer_manager_v1" => { + state.manager = Some(registry.bind(name, version.min(2), qh, ())); + } + _ => {} + } + } +} + +delegate_noop!(Globals: ignore wl_seat::WlSeat); +delegate_noop!(Globals: ZwlrVirtualPointerManagerV1); +delegate_noop!(Globals: ZwlrVirtualPointerV1); + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let [width, height, path] = args.as_slice() else { + eprintln!("usage: replay-touch WIDTH HEIGHT FILE"); + std::process::exit(2); + }; + let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT")); + let text = std::fs::read_to_string(path) + .unwrap_or_else(|e| fail(&format!("could not read {path}: {e}"))); + let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e)); + + let conn = Connection::connect_to_env().unwrap_or_else(|e| { + fail(&format!( + "no wayland display ({e}); is WAYLAND_DISPLAY set?" + )) + }); + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + let display = conn.display(); + display.get_registry(&qh, ()); + let mut globals = Globals::default(); + queue + .roundtrip(&mut globals) + .unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}"))); + + let manager = globals.manager.as_ref().unwrap_or_else(|| { + fail( + "this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \ + be synthesised; sway and every wlroots compositor do", + ) + }); + let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ()); + + // Put the pointer where the gesture starts and let the compositor + // settle before anything is pressed. Without this the press is + // dropped: sway has just learned about this pointer, and a button + // sent in the same breath as the motion that first puts it over a + // window arrives before there is a focused surface to send it to -- + // winit sees `CursorEntered`, the moves and the *release*, never the + // press, so the gesture reads as a hover and nothing scrolls. Found + // by printing winit's own events; the settle is what fixed it. + if let Some(first) = script.samples.first() { + pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height); + pointer.frame(); + conn.flush() + .unwrap_or_else(|e| fail(&format!("flush: {e}"))); + std::thread::sleep(SETTLE); + } + + let mut previous = 0; + for sample in &script.samples { + std::thread::sleep(Duration::from_millis(sample.t_ms - previous)); + previous = sample.t_ms; + let t = sample.t_ms as u32; + pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height); + pointer.frame(); + // The button goes in a frame of its own, *after* the motion has + // been committed. Sent in the same frame as the motion that + // first puts the pointer over the window, sway drops it: the + // client sees `CursorEntered` and the moves but never a + // `MouseInput { state: Pressed }`, so the whole gesture reads as + // a hover and nothing scrolls. Found exactly that way, by + // printing winit's events. + let state = match sample.action { + TouchAction::Down => Some(ButtonState::Pressed), + TouchAction::Up => Some(ButtonState::Released), + TouchAction::Move => None, + }; + if let Some(state) = state { + pointer.button(t, BTN_LEFT, state); + pointer.frame(); + } + conn.flush() + .unwrap_or_else(|e| fail(&format!("flush: {e}"))); + } + pointer.destroy(); + conn.flush().ok(); +} + +fn parse(text: &str, what: &str) -> u32 { + text.parse() + .unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}"))) +} + +fn fail(message: &str) -> ! { + eprintln!("replay-touch: {message}"); + std::process::exit(1); +} diff --git a/scripts/headless.conf b/scripts/headless.conf new file mode 100644 index 0000000..71abd5a --- /dev/null +++ b/scripts/headless.conf @@ -0,0 +1,14 @@ +# The compositor `scripts/run-headless.sh` starts, because this machine has no +# display. Nothing here is meant to be looked at directly; `grim` is. +# +# No Xwayland: winit talks Wayland natively, and starting an X server is a +# second thing to go wrong for no gain. (`emu`'s config forces it because the +# Android emulator's renderer speaks GLX.) +xwayland disable + +# A desktop-shaped output, since this is the desktop half of the port. Larger +# than the window an example opens, so nothing is scaled or clipped. +output HEADLESS-1 mode 1920x1200@60Hz + +default_border none +focus_follows_mouse no diff --git a/scripts/run-headless.sh b/scripts/run-headless.sh new file mode 100755 index 0000000..429ce84 --- /dev/null +++ b/scripts/run-headless.sh @@ -0,0 +1,161 @@ +#!/bin/sh +# Run an iris example on a machine with no display. +# +# ./scripts/run-headless.sh tabs +# ./scripts/run-headless.sh tabs --shot /tmp/tabs.png --seconds 4 +# ./scripts/run-headless.sh tabs --replay taps.touch --shot /tmp/tabs.png +# ./scripts/run-headless.sh app --dir ../elsewhere --mode 1080x2424@120Hz +# +# `--dir DIR` names the workspace to build in, defaulting to this one, so a +# project that depends on iris can be run through the same rig. `--bin` runs a +# crate binary rather than an example, and takes its own argv from +# `$RUN_HEADLESS_ARGS`, word-split on purpose. +# +# `--mode` sets the output, for running something at a size other than a +# desktop's -- a phone's, say. Set every run rather than only when it changes: +# the compositor is reused between runs, so a default-shaped run after a +# custom one would otherwise inherit the other's output and quietly screenshot +# the wrong size. +# +# `--replay FILE` drives a `.touch` recording into the window through +# `replay-touch`, which reads it with the same parser `iris::harness` uses. A +# recording is ` down|move|up ` in the output's own pixels. With +# `--shot` it also writes `-before.png` from just before the gesture, +# since "it moved" is a claim about two pictures. +# +# There is a real GPU here and no display, so what is missing is only a +# compositor to give winit a surface: a headless sway, and `grim` to see the +# result. It has its own socket and runtime directory rather than joining +# whatever else is running, because sway tiles -- adding a window to someone +# else's compositor resizes theirs. +set -eu + +scripts=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$scripts/.." && pwd) +workdir="$root" +cd "$root" +run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless" +seconds=3 +shot="" +replay="" +example="" +kind=example +mode=1920x1200@60Hz + + +while [ $# -gt 0 ]; do + case "$1" in + --shot) shot=$2; shift 2 ;; + --seconds) seconds=$2; shift 2 ;; + --bin) kind=bin; shift ;; + --mode) mode=$2; shift 2 ;; + --replay) replay=$2; shift 2 ;; + --dir) workdir=$(cd "$2" && pwd); shift 2 ;; + --) shift; break ;; + *) example=$1; shift ;; + esac +done +[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--dir DIR] [--mode WxH@Hz] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } +[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; } + +mkdir -p "$run" +export SWAYSOCK="$run/sway.sock" + +# Named rather than left to sway's pid-based default, so a second run reuses +# this compositor instead of starting another beside it. +if ! swaymsg -t get_version >/dev/null 2>&1; then + rm -f "$SWAYSOCK" + WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \ + setsid sway -c "$scripts/headless.conf" >"$run/sway.log" 2>&1 & + i=0 + while [ $i -lt 20 ]; do + swaymsg -t get_version >/dev/null 2>&1 && break + i=$((i + 1)); sleep 0.5 + done + swaymsg -t get_version >/dev/null 2>&1 || { + echo "run-headless: compositor did not start; see $run/sway.log" >&2 + exit 1 + } +fi + +rm -f "$run/display" +swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null +i=0 +while [ $i -lt 20 ]; do + [ -s "$run/display" ] && break + i=$((i + 1)); sleep 0.5 +done +[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; } +WAYLAND_DISPLAY=$(cat "$run/display") +export WAYLAND_DISPLAY + +echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2 + +swaymsg output HEADLESS-1 mode "$mode" >/dev/null +# The extent `replay-touch` positions against, so a script's coordinates +# are the output's own pixels. +out_w=${mode%x*} +out_h=${mode#*x}; out_h=${out_h%@*} + +# Built before the app starts, so a compile error is not reported as a +# window that failed to move. +[ -z "$replay" ] || (cd "$root" && cargo build --bin replay-touch -p rig-input) >&2 + +cd "$workdir" +if [ "$kind" = bin ]; then + cargo build --bin "$example" "$@" >&2 + bin="$workdir/target/debug/$example" +else + cargo build --example "$example" "$@" >&2 + bin="$workdir/target/debug/examples/$example" +fi + +# shellcheck disable=SC2086 -- deliberately word-split: this is the +# binary's own argv, not a single path. +"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 & +pid=$! +trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM + +# Wait for the window to be mapped rather than for a number of seconds. A +# fixed sleep took an all-black screenshot the first time this ran, when sway +# had started in the same invocation and had not composited its output yet -- +# which is indistinguishable from an app that draws nothing. +i=0 +while [ $i -lt 40 ]; do + kill -0 "$pid" 2>/dev/null || break + swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break + i=$((i + 1)); sleep 0.25 +done + +i=0 +while [ $i -lt "$((seconds * 2))" ]; do + kill -0 "$pid" 2>/dev/null || break + i=$((i + 1)); sleep 0.5 +done + +if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then + if [ -n "$shot" ]; then + grim "${shot%.png}-before.png" + echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2 + fi + "$root/target/debug/replay-touch" "$out_w" "$out_h" "$replay" + # A fling outlives the finger: the gesture's own last sample is not + # when the list stops. Long enough for Android's spline to settle + # (`FlingCalculator::duration` tops out around a second and a half). + sleep 2 +fi + +if kill -0 "$pid" 2>/dev/null; then + [ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2 + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + status=0 +else + wait "$pid" 2>/dev/null || status=$? + echo "run-headless: $example exited early (status ${status:-0})" >&2 + status=${status:-1} +fi + +echo "--- $example output ---" >&2 +cat "$run/$example.log" >&2 +exit "$status" diff --git a/src/harness.rs b/src/harness.rs index 8c947ba..63cebec 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -37,6 +37,81 @@ macro_rules! assert_corners { } 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 { + 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: ` down|move|up ` +/// 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, +} + +impl TouchScript { + pub fn parse(text: &str) -> Result { + let mut samples: Vec = 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, @@ -160,6 +235,23 @@ impl Harness { 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 { @@ -178,3 +270,51 @@ impl Harness { self.cursor.end_frame(); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(text: &str) -> Result, 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}"); + } +} diff --git a/tests/pointer.rs b/tests/pointer.rs index bcc17d4..1d1e7be 100644 --- a/tests/pointer.rs +++ b/tests/pointer.rs @@ -2,7 +2,7 @@ use std::{cell::RefCell, rc::Rc}; -use iris::harness::Harness; +use iris::harness::{Harness, TouchScript}; use iris::prelude::*; #[test] @@ -58,3 +58,28 @@ fn hover_ends_when_the_cursor_leaves_the_window() { h.leave(); assert_eq!((*hovered.borrow(), *ended.borrow()), (1, 1)); } + +#[test] +fn a_recorded_gesture_presses_where_it_says() { + 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)); + + let script = TouchScript::parse("0 down 300 100\n80 up 300 100").unwrap(); + h.replay(&script); + + assert_eq!(*clicks.borrow(), ["right"]); +}