Layer 2 of docs/RUST.md's "Three test layers":
./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
opens `transcript-fixture`'s screen -- the same fixture and the same
fold the headless tests and the Android bench use -- in a window at the
phone's own 1080x2424 and `content_scale` 2.55, and screenshots it. 15
seconds, warm. `--replay FILE` drives one of the `.touch` recordings
into it and writes `<shot>-before.png` too, so "the list moved" is two
pictures: the flick carries it back about seven turns of the fixture.
Two things this needed.
**The desktop backend now lays out in physical pixels with a density,
exactly as Android does** (`default::content_scale`, overridable with
`IRIS_SCALE`, which is how `--phone` hands it the phone's). It used to
divide winit's coordinates into a separate "logical" space, which left
`UiRenderState::resize` (physical, from `WindowEvent::Resized`) and the
window uniform (logical) disagreeing on any display whose scale factor
is not 1.0, and rasterised glyphs at one resolution to show them at
another. At 1.0 -- every display here -- the numbers are unchanged, and
the `tabs` screenshot is identical.
**`rig-input`'s `replay-touch`** puts a gesture on screen. This
machine's compositor has no pointer to move: sway runs on the headless
backend with no input devices, so `swaymsg seat - cursor press` reports
success and `swaymsg -t get_seats` shows `capabilities: 0`. wlroots 0.19
dropped `WLR_HEADLESS_INPUTS` and ydotool's uinput device would be
ignored by a compositor not reading libinput, so the virtual-pointer
protocol is what is left. It parses the *same* `TouchScript` the
harness does, so one recording drives both layers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
165 lines
6.3 KiB
Rust
165 lines
6.3 KiB
Rust
//! Replays a `.touch` file into the compositor as a left-button drag --
|
|
//! see this crate's `Cargo.toml` for why it exists rather than
|
|
//! `swaymsg seat - cursor`.
|
|
//!
|
|
//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE
|
|
//!
|
|
//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual
|
|
//! pointer protocol positions absolutely against an extent rather than
|
|
//! in pixels; passing the output size makes a script's coordinates mean
|
|
//! the same pixels they mean in the headless tests.
|
|
//!
|
|
//! Replayed in real time (the sleeps between samples are the gaps in the
|
|
//! file), because winit has no timestamp on a pointer event and dates
|
|
//! each one when it arrives -- so a 20ms flick has to actually take
|
|
//! 20ms here, unlike layer 1 where the sample carries its own time.
|
|
|
|
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,
|
|
};
|
|
|
|
/// `linux/input-event-codes.h`. The protocol takes the kernel's own
|
|
/// button code, not a wayland enum.
|
|
const BTN_LEFT: u32 = 0x110;
|
|
|
|
/// How long the pointer sits at the gesture's first position before the
|
|
/// script starts -- see the comment at the pre-step in `main`.
|
|
const SETTLE: Duration = Duration::from_millis(200);
|
|
|
|
#[derive(Default)]
|
|
struct Globals {
|
|
seat: Option<wl_seat::WlSeat>,
|
|
manager: Option<ZwlrVirtualPointerManagerV1>,
|
|
}
|
|
|
|
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
|
fn event(
|
|
state: &mut Self,
|
|
registry: &wl_registry::WlRegistry,
|
|
event: wl_registry::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
qh: &QueueHandle<Self>,
|
|
) {
|
|
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<String> = 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);
|
|
// One frame per sample, so the compositor delivers them as
|
|
// separate pointer frames rather than coalescing the whole
|
|
// gesture -- the shape the file recorded is the point.
|
|
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 | TouchAction::Cancel => 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);
|
|
}
|