From e9d5357b1898e032b1a79b48108031c27bbf7018 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 26 Jun 2026 23:34:49 -0700 Subject: [PATCH 01/22] feat: add WayDriver control input commands --- src/compositor/input.rs | 240 +++++++++++++++++++++++++++- src/compositor/shell_integration.rs | 69 +++++++- src/config.rs | 1 + src/lib.rs | 8 + src/main.rs | 3 + src/shell/command.rs | 97 ++++++++++- todos/WAYDRIVER.md | 22 +++ 7 files changed, 431 insertions(+), 9 deletions(-) diff --git a/src/compositor/input.rs b/src/compositor/input.rs index ab242f8..149224a 100644 --- a/src/compositor/input.rs +++ b/src/compositor/input.rs @@ -1,8 +1,8 @@ use smithay::{ backend::{ input::{ - AbsolutePositionEvent, Axis, ButtonState, Event, InputEvent, KeyboardKeyEvent, - PointerAxisEvent, PointerButtonEvent, + AbsolutePositionEvent, Axis, ButtonState, Event, InputEvent, KeyState, + KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, }, winit::WinitInput, }, @@ -11,7 +11,7 @@ use smithay::{ pointer::{AxisFrame, ButtonEvent, MotionEvent}, }, reexports::winit::window::CursorIcon, - utils::SERIAL_COUNTER, + utils::{Logical, Point, SERIAL_COUNTER}, }; use crate::config::{SCROLL_ZOOM_SENSITIVITY, WHEEL_SCROLL_PIXEL_EQUIVALENT}; @@ -282,6 +282,210 @@ fn is_super_keysym(keysym: u32) -> bool { } impl App { + pub(super) fn synthesize_key(&mut self, evdev_keycode: u32, state: KeyState) { + self.record_focused_client_activity(ActivityReason::ClientInput); + let keyboard = self.keyboard.clone(); + keyboard.input::<(), _>( + self, + evdev_keycode.saturating_add(8).into(), + state, + SERIAL_COUNTER.next_serial(), + event_time_msec(), + |_, _, _| FilterResult::Forward, + ); + } + + pub(super) fn synthesize_pointer_motion_abs(&mut self, location: Point) { + self.apply_pointer_motion(location, event_time_msec()); + } + + pub(super) fn synthesize_pointer_motion_rel(&mut self, delta: Point) { + self.apply_pointer_motion(self.pointer_location + delta, event_time_msec()); + } + + pub(super) fn synthesize_pointer_button(&mut self, button: u32, state: ButtonState) { + let time = event_time_msec(); + let is_left_button = button == 0x110; + + if is_left_button + && state == ButtonState::Released + && (self.drag.is_some() || self.resize.is_some()) + { + self.drag = None; + self.finish_resize(); + let pointer = self.pointer.clone(); + pointer.button( + self, + &ButtonEvent { + serial: SERIAL_COUNTER.next_serial(), + time, + button, + state, + }, + ); + pointer.frame(self); + return; + } + + if is_left_button && state == ButtonState::Pressed { + match self.hit_test(self.pointer_location) { + Some(HitTarget::CloseButton { window_index }) => { + self.windows[window_index].surface.send_close(); + self.drag = None; + return; + } + Some(HitTarget::TitleBar { window_index }) => { + let window_index = self.raise_window(window_index); + let surface = self.windows[window_index].surface.wl_surface().clone(); + self.set_keyboard_focus_to_window(window_index, surface); + self.drag = Some(DragState { + window_id: self.windows[window_index].id, + pointer_start: self.pointer_location, + window_start: self.windows[window_index].position, + }); + self.request_redraw(); + return; + } + Some(HitTarget::ResizeBorder { + window_index, + edges, + }) => { + let window_index = self.raise_window(window_index); + let surface = self.windows[window_index].surface.wl_surface().clone(); + self.set_keyboard_focus_to_window(window_index, surface); + self.start_resize(window_index, edges); + return; + } + Some(HitTarget::Client { window_index, .. }) => { + let window_index = self.raise_window(window_index); + let surface = self.windows[window_index].surface.wl_surface().clone(); + self.set_keyboard_focus_to_window(window_index, surface); + } + None => { + self.clear_keyboard_focus(); + } + } + } else if is_left_button + && state == ButtonState::Released + && matches!( + self.hit_test(self.pointer_location), + Some(HitTarget::CloseButton { .. }) + ) + { + return; + } + + let focus = match self.hit_test(self.pointer_location) { + Some(HitTarget::Client { + window_index, + surface, + surface_location, + }) => { + self.record_client_activity_for_window_index( + window_index, + ActivityReason::ClientInput, + ); + Some((surface, surface_location)) + } + _ => None, + }; + + match focus.clone() { + Some((surface, _)) => { + if state == ButtonState::Pressed + && let Some(window_index) = self.window_index_for_surface(&surface) + { + self.set_keyboard_focus_to_window(window_index, surface); + } + } + _ if is_left_button && state == ButtonState::Pressed => return, + _ => {} + } + + let pointer = self.pointer.clone(); + pointer.button( + self, + &ButtonEvent { + serial: SERIAL_COUNTER.next_serial(), + time, + button, + state, + }, + ); + pointer.frame(self); + } + + pub(super) fn synthesize_axis(&mut self, horizontal: f64, vertical: f64) { + if let Some(HitTarget::Client { window_index, .. }) = self.hit_test(self.pointer_location) { + self.record_client_activity_for_window_index(window_index, ActivityReason::ClientInput); + } + + let time = event_time_msec(); + let mut frame = AxisFrame::new(time); + frame = add_synthetic_axis_to_frame(frame, Axis::Horizontal, horizontal); + frame = add_synthetic_axis_to_frame(frame, Axis::Vertical, vertical); + + let pointer = self.pointer.clone(); + pointer.axis(self, frame); + pointer.frame(self); + } + + fn apply_pointer_motion(&mut self, location: Point, time: u32) { + self.pointer_location = clamp_point_to_output(location, self.output_size.to_logical(1)); + + if let Some(drag) = self.drag.as_ref() { + let delta = self.pointer_location - drag.pointer_start; + let new_position = super::CanvasPoint { + x: drag.window_start.x + (delta.x / self.viewport_scale).round() as i32, + y: drag.window_start.y + (delta.y / self.viewport_scale).round() as i32, + }; + let window_id = drag.window_id; + if let Some(window) = self.window_mut_by_id(window_id) { + window.position = new_position; + self.request_redraw(); + } + return; + } + + if let Some(resize) = self.resize.as_ref() { + let edges = resize.edges; + self.cursor_icon = resize_cursor_icon(edges); + self.update_resize(self.pointer_location); + return; + } + + let hit = self.hit_test(self.pointer_location); + self.cursor_icon = match &hit { + Some(HitTarget::ResizeBorder { edges, .. }) => resize_cursor_icon(*edges), + _ => CursorIcon::Default, + }; + let focus = match hit { + Some(HitTarget::Client { + window_index, + surface, + surface_location, + }) => { + self.record_client_activity_for_window_index( + window_index, + ActivityReason::ClientInput, + ); + Some((surface, surface_location)) + } + _ => None, + }; + let pointer = self.pointer.clone(); + pointer.motion( + self, + focus, + &MotionEvent { + location: self.pointer_location, + serial: SERIAL_COUNTER.next_serial(), + time, + }, + ); + pointer.frame(self); + } + fn super_modifier_active(&self) -> bool { self.keyboard.modifier_state().logo || self.keyboard.with_pressed_keysyms(|pressed| { @@ -308,3 +512,33 @@ impl App { true } } + +fn add_synthetic_axis_to_frame(mut frame: AxisFrame, axis: Axis, amount: f64) -> AxisFrame { + if amount == 0.0 { + frame = frame.stop(axis); + } else { + frame = frame.value(axis, amount); + } + frame +} + +fn clamp_point_to_output( + location: Point, + output_size: smithay::utils::Size, +) -> Point { + Point::from(( + location + .x + .clamp(0.0, f64::from(output_size.w.saturating_sub(1))), + location + .y + .clamp(0.0, f64::from(output_size.h.saturating_sub(1))), + )) +} + +fn event_time_msec() -> u32 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u32) + .unwrap_or_default() +} diff --git a/src/compositor/shell_integration.rs b/src/compositor/shell_integration.rs index f48bb97..a5977ea 100644 --- a/src/compositor/shell_integration.rs +++ b/src/compositor/shell_integration.rs @@ -1,6 +1,6 @@ use std::{ env, fs, - io::{self, ErrorKind, Read}, + io::{self, ErrorKind, Read, Write}, os::unix::{ fs::symlink, net::{UnixListener as CommandListener, UnixStream}, @@ -10,6 +10,10 @@ use std::{ }; use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, generic::Generic}; +use smithay::{ + backend::input::{ButtonState, KeyState}, + utils::Point, +}; use crate::{ config::*, @@ -238,7 +242,9 @@ fn read_command_connection( } Ok(read) => { buffer.extend_from_slice(&chunk[..read]); - drain_complete_commands(buffer, state); + if !drain_complete_commands(buffer, state, stream) { + return PostAction::Remove; + } if buffer.len() > MAX_COMMAND_BUFFER_BYTES { eprintln!( "Shell command exceeded {MAX_COMMAND_BUFFER_BYTES} bytes without a newline; dropping connection" @@ -256,10 +262,13 @@ fn read_command_connection( } } -fn drain_complete_commands(buffer: &mut Vec, state: &mut App) { +fn drain_complete_commands(buffer: &mut Vec, state: &mut App, stream: &UnixStream) -> bool { for command in take_complete_commands(buffer) { - state.run_control_action(command); + if !write_control_reply(stream, state.run_control_action(command)) { + return false; + } } + true } /// Split every complete newline-terminated command off the front of `buffer`, @@ -287,8 +296,34 @@ fn parse_command_line(bytes: &[u8]) -> Option { ShellCommand::parse(text.trim()) } +enum ControlReply { + Ok, + Err(String), +} + +impl ControlReply { + fn bytes(&self) -> Vec { + match self { + Self::Ok => b"ok\n".to_vec(), + Self::Err(message) => format!("err {message}\n").into_bytes(), + } + } +} + +fn write_control_reply(stream: &UnixStream, reply: ControlReply) -> bool { + let mut writer = stream; + match writer.write_all(&reply.bytes()) { + Ok(()) => true, + Err(error) if matches!(error.kind(), ErrorKind::BrokenPipe | ErrorKind::WouldBlock) => true, + Err(error) => { + eprintln!("Failed to write shell command reply: {error}"); + false + } + } +} + impl App { - fn run_control_action(&mut self, action: ShellCommand) { + fn run_control_action(&mut self, action: ShellCommand) -> ControlReply { self.advance_viewport_animation(); match action { @@ -304,8 +339,32 @@ impl App { ShellCommand::LogAccessibilityTree => { crate::accessibility::log_accessibility_tree(self.accessibility_window_snapshot()) } + ShellCommand::KeyDown(keycode) => self.synthesize_key(keycode, KeyState::Pressed), + ShellCommand::KeyUp(keycode) => self.synthesize_key(keycode, KeyState::Released), + ShellCommand::PointerMotionAbs { x, y } => { + self.synthesize_pointer_motion_abs(Point::from((x, y))) + } + ShellCommand::PointerMotionRel { dx, dy } => { + self.synthesize_pointer_motion_rel(Point::from((dx, dy))) + } + ShellCommand::PointerButtonDown(button) => { + self.synthesize_pointer_button(button, ButtonState::Pressed) + } + ShellCommand::PointerButtonUp(button) => { + self.synthesize_pointer_button(button, ButtonState::Released) + } + ShellCommand::Axis { + horizontal, + vertical, + } => self.synthesize_axis(horizontal, vertical), + ShellCommand::Screenshot => { + return ControlReply::Err( + "screenshot requires the future headless framebuffer readback path".to_string(), + ); + } } self.request_redraw(); + ControlReply::Ok } fn prepare_spawn_position(&mut self) { diff --git a/src/config.rs b/src/config.rs index 5f5f5a0..25e0609 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,7 @@ use std::time::Duration; pub const WAYLAND_DISPLAY_NAME: &str = "wayland-99"; pub const GTK_TEST_APP_FLAG: &str = "--gtk-test-app"; pub const SHELL_FLAG: &str = "--shell"; +pub const HEADLESS_FLAG: &str = "--headless"; pub const GTK_TEST_APP_ID: &str = "dev.hearthspace.A11yTest"; pub const GTK_TEST_APP_TITLE: &str = "Hearthspace Research Demo"; pub const SHELL_BAR_APP_ID: &str = "dev.hearthspace.shell-bar"; diff --git a/src/lib.rs b/src/lib.rs index b17dcc5..817ae58 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod test_apps; #[derive(Debug, Clone, Copy, Default)] pub struct RunOptions { pub scroll_zooms_without_super: bool, + pub headless: bool, } pub fn run() -> Result<(), Box> { @@ -16,5 +17,12 @@ pub fn run() -> Result<(), Box> { } pub fn run_with_options(options: RunOptions) -> Result<(), Box> { + if options.headless { + return Err( + "--headless is recognized but the Smithay offscreen backend is not implemented yet" + .into(), + ); + } + compositor::run_winit(options) } diff --git a/src/main.rs b/src/main.rs index 14479fc..ac17058 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,9 @@ fn main() -> Result<(), Box> { scroll_zooms_without_super: args .iter() .any(|arg| arg == hearthspace::config::SCROLL_ZOOMS_FLAG), + headless: args + .iter() + .any(|arg| arg == hearthspace::config::HEADLESS_FLAG), }) } } diff --git a/src/shell/command.rs b/src/shell/command.rs index f1e9174..69f5d0c 100644 --- a/src/shell/command.rs +++ b/src/shell/command.rs @@ -30,7 +30,7 @@ impl SpawnTarget { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum ShellCommand { Spawn(SpawnTarget), LaunchApp(String), @@ -41,6 +41,14 @@ pub enum ShellCommand { ZoomIn, ZoomOut, LogAccessibilityTree, + KeyDown(u32), + KeyUp(u32), + PointerMotionAbs { x: f64, y: f64 }, + PointerMotionRel { dx: f64, dy: f64 }, + PointerButtonDown(u32), + PointerButtonUp(u32), + Axis { horizontal: f64, vertical: f64 }, + Screenshot, } impl ShellCommand { @@ -67,6 +75,14 @@ impl ShellCommand { Self::ZoomIn => "ZOOM+", Self::ZoomOut => "ZOOM-", Self::LogAccessibilityTree => "LOG", + Self::KeyDown(_) => "KEYDOWN", + Self::KeyUp(_) => "KEYUP", + Self::PointerMotionAbs { .. } => "PTRABS", + Self::PointerMotionRel { .. } => "PTRREL", + Self::PointerButtonDown(_) => "BTNDOWN", + Self::PointerButtonUp(_) => "BTNUP", + Self::Axis { .. } => "AXIS", + Self::Screenshot => "SHOT", } } @@ -82,6 +98,17 @@ impl ShellCommand { Self::ZoomIn => "zoom-in".to_string(), Self::ZoomOut => "zoom-out".to_string(), Self::LogAccessibilityTree => "log-a11y-tree".to_string(), + Self::KeyDown(keycode) => format!("key-down {keycode}"), + Self::KeyUp(keycode) => format!("key-up {keycode}"), + Self::PointerMotionAbs { x, y } => format!("pointer-motion-abs {x} {y}"), + Self::PointerMotionRel { dx, dy } => format!("pointer-motion-rel {dx} {dy}"), + Self::PointerButtonDown(button) => format!("pointer-button-down {button}"), + Self::PointerButtonUp(button) => format!("pointer-button-up {button}"), + Self::Axis { + horizontal, + vertical, + } => format!("axis {horizontal} {vertical}"), + Self::Screenshot => "screenshot".to_string(), } } @@ -103,11 +130,44 @@ impl ShellCommand { "zoom-in" => Self::ZoomIn, "zoom-out" => Self::ZoomOut, "log-a11y-tree" => Self::LogAccessibilityTree, + "key-down" => Self::KeyDown(parse_u32(parts.next()?)?), + "key-up" => Self::KeyUp(parse_u32(parts.next()?)?), + "pointer-motion-abs" => Self::PointerMotionAbs { + x: parse_f64(parts.next()?)?, + y: parse_f64(parts.next()?)?, + }, + "pointer-motion-rel" => Self::PointerMotionRel { + dx: parse_f64(parts.next()?)?, + dy: parse_f64(parts.next()?)?, + }, + "pointer-button-down" => Self::PointerButtonDown(parse_u32(parts.next()?)?), + "pointer-button-up" => Self::PointerButtonUp(parse_u32(parts.next()?)?), + "axis" => Self::Axis { + horizontal: parse_f64(parts.next()?)?, + vertical: parse_f64(parts.next()?)?, + }, + "screenshot" => Self::Screenshot, _ => return None, }) } } +fn parse_u32(input: &str) -> Option { + if let Some(hex) = input + .strip_prefix("0x") + .or_else(|| input.strip_prefix("0X")) + { + u32::from_str_radix(hex, 16).ok() + } else { + input.parse().ok() + } +} + +fn parse_f64(input: &str) -> Option { + let value = input.parse().ok()?; + f64::is_finite(value).then_some(value) +} + #[cfg(test)] mod tests { use super::*; @@ -128,4 +188,39 @@ mod tests { )) ); } + + #[test] + fn waydriver_input_commands_parse() { + assert_eq!( + ShellCommand::parse("key-down 30"), + Some(ShellCommand::KeyDown(30)) + ); + assert_eq!( + ShellCommand::parse("key-up 0xff"), + Some(ShellCommand::KeyUp(255)) + ); + assert_eq!( + ShellCommand::parse("pointer-motion-abs 10.5 20"), + Some(ShellCommand::PointerMotionAbs { x: 10.5, y: 20.0 }) + ); + assert_eq!( + ShellCommand::parse("pointer-motion-rel -3 4.25"), + Some(ShellCommand::PointerMotionRel { dx: -3.0, dy: 4.25 }) + ); + assert_eq!( + ShellCommand::parse("pointer-button-down 272"), + Some(ShellCommand::PointerButtonDown(272)) + ); + assert_eq!( + ShellCommand::parse("axis 0 -120"), + Some(ShellCommand::Axis { + horizontal: 0.0, + vertical: -120.0, + }) + ); + assert_eq!( + ShellCommand::parse("screenshot"), + Some(ShellCommand::Screenshot) + ); + } } diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 4ebc21b..21d6604 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -119,6 +119,28 @@ Three pieces of work, in rough effort order: ## Incremental plan +## Implementation notes from autonomous spike + +- The repository paths for the referenced planning docs are `todos/BACKENDS.md` + and `todos/TESTING.md`, not `docs/BACKENDS.md` / `docs/TESTING.md`. +- `--headless` is now recognized by the CLI, but deliberately returns an error + instead of silently starting the winit backend. The real Smithay offscreen + renderer is still the blocking Phase 1 item. +- The command socket now has a minimal line-based response path: parsed commands + write `ok\n`; unsupported commands that parse but cannot complete write + `err \n`. This is intentionally smaller than the preferred future + length-prefixed binary framing, but gives WayDriver backend code a synchronous + reply mechanism for input commands. +- Implemented input command names are `key-down`, `key-up`, + `pointer-motion-abs`, `pointer-motion-rel`, `pointer-button-down`, + `pointer-button-up`, and `axis`. For now `key-down/up` accepts Linux evdev key + codes (the values from `input-event-codes.h`) rather than XKB keysyms; the + compositor adds Smithay's expected XKB offset internally. A future WayDriver + backend can either send evdev codes or add a keysym-to-evdev mapping layer. +- `screenshot` is parsed and replies with an `err` explaining that framebuffer + readback needs the future headless backend. No placeholder image bytes are + returned. + ### Phase 0 — design + spike ⬜ - [ ] Validate Smithay headless offscreen rendering (GLES + synthetic `Output`) -- 2.51.2 From 9b838aad0ee3a22a26da567ebcf817bd53d984ad Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 26 Jun 2026 23:39:59 -0700 Subject: [PATCH 02/22] feat: add control socket screenshots --- Cargo.lock | 1 + Cargo.toml | 1 + src/compositor/mod.rs | 59 ++++++++++++++++++++++++++++- src/compositor/shell_integration.rs | 49 +++++++++++++++--------- todos/WAYDRIVER.md | 6 +-- 5 files changed, 94 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ae7561..47bc9f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1779,6 +1779,7 @@ dependencies = [ "image", "masonry", "naga", + "png", "proptest", "rstest", "smithay", diff --git a/Cargo.toml b/Cargo.toml index 06a8501..19fc301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ fontique = { version = "0.8.0", features = ["fontconfig-dlopen"] } # directly so the title-text raster path is testable. Pulled in transitively # already, so this only fixes the version we link against. image = { version = "0.25", default-features = false } +png = "0.18.1" smithay = { version = "0.7.0", default-features = false, features = ["backend_winit", "desktop", "renderer_gl", "wayland_frontend"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } wayland-protocols = { version = "0.32.8", features = ["server"] } diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs index 49d3ed2..ce5ae4d 100644 --- a/src/compositor/mod.rs +++ b/src/compositor/mod.rs @@ -27,10 +27,11 @@ use windows::{ use smithay::reexports::winit::window::CursorIcon; use smithay::{ backend::{ + allocator::Fourcc, allocator::dmabuf::Dmabuf, egl::EGLDevice, renderer::{ - ImportDma, damage::OutputDamageTracker, element::Id, gles::GlesRenderer, + ExportMem, ImportDma, damage::OutputDamageTracker, element::Id, gles::GlesRenderer, utils::on_commit_buffer_handler, }, winit::{self, WinitEvent}, @@ -53,7 +54,7 @@ use smithay::{ }, wayland_server::{Display, protocol::wl_seat}, }, - utils::{Logical, Physical, Point, Serial, Size, Transform}, + utils::{Buffer as BufferCoord, Logical, Physical, Point, Rectangle, Serial, Size, Transform}, wayland::{ buffer::BufferHandler, compositor::{ @@ -866,6 +867,60 @@ impl CalloopData { } Ok(()) } + + fn screenshot_png(&mut self) -> Result, Box> { + self.process_pending_dmabuf_imports(); + + let CalloopData { state, backend, .. } = self; + let Backend::Winit(backend) = backend; + let size = state.output_size; + let mut screenshot_damage = OutputDamageTracker::new(size, 1.0, Transform::Flipped180); + let (renderer, mut framebuffer) = backend.bind()?; + state.render_frame(renderer, &mut framebuffer, &mut screenshot_damage, 0)?; + + let region = Rectangle::from_size(Size::::from((size.w, size.h))); + let mapping = renderer.copy_framebuffer(&framebuffer, region, Fourcc::Abgr8888)?; + let pixels = renderer.map_texture(&mapping)?.to_vec(); + encode_png_rgba(size, &pixels) + } +} + +fn encode_png_rgba( + size: Size, + bottom_up_rgba: &[u8], +) -> Result, Box> { + let width = usize::try_from(size.w)?; + let height = usize::try_from(size.h)?; + let stride = width.checked_mul(4).ok_or("screenshot stride overflow")?; + let expected_len = stride + .checked_mul(height) + .ok_or("screenshot buffer length overflow")?; + if bottom_up_rgba.len() != expected_len { + return Err(format!( + "screenshot readback returned {} bytes, expected {expected_len}", + bottom_up_rgba.len() + ) + .into()); + } + + let mut top_down_rgba = Vec::with_capacity(expected_len); + for row in bottom_up_rgba.chunks_exact(stride).rev() { + top_down_rgba.extend_from_slice(row); + } + + let mut png_bytes = Vec::new(); + { + let mut encoder = png::Encoder::new( + &mut png_bytes, + u32::try_from(width)?, + u32::try_from(height)?, + ); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header()?; + writer.write_image_data(&top_down_rgba)?; + } + Ok(png_bytes) } fn create_output(dh: &DisplayHandle, size: Size) -> Output { diff --git a/src/compositor/shell_integration.rs b/src/compositor/shell_integration.rs index a5977ea..3a03450 100644 --- a/src/compositor/shell_integration.rs +++ b/src/compositor/shell_integration.rs @@ -213,11 +213,7 @@ fn register_command_connection<'l>(handle: &LoopHandle<'l, CalloopData>, stream: let mut buffer: Vec = Vec::new(); let source = Generic::new(stream, Interest::READ, Mode::Level); if let Err(error) = handle.insert_source(source, move |_, stream, data| { - Ok(read_command_connection( - stream, - &mut buffer, - &mut data.state, - )) + Ok(read_command_connection(stream, &mut buffer, data)) }) { eprintln!("Failed to register shell command connection: {error}"); } @@ -226,7 +222,7 @@ fn register_command_connection<'l>(handle: &LoopHandle<'l, CalloopData>, stream: fn read_command_connection( stream: &UnixStream, buffer: &mut Vec, - state: &mut App, + data: &mut CalloopData, ) -> PostAction { // `Read` is implemented for `&UnixStream`, so read through a shared ref. let mut reader = stream; @@ -236,13 +232,13 @@ fn read_command_connection( Ok(0) => { // Client closed: run any trailing line that lacked a newline. if !buffer.is_empty() { - run_command_line(buffer, state); + run_command_line(buffer, data); } return PostAction::Remove; } Ok(read) => { buffer.extend_from_slice(&chunk[..read]); - if !drain_complete_commands(buffer, state, stream) { + if !drain_complete_commands(buffer, data, stream) { return PostAction::Remove; } if buffer.len() > MAX_COMMAND_BUFFER_BYTES { @@ -262,9 +258,13 @@ fn read_command_connection( } } -fn drain_complete_commands(buffer: &mut Vec, state: &mut App, stream: &UnixStream) -> bool { +fn drain_complete_commands( + buffer: &mut Vec, + data: &mut CalloopData, + stream: &UnixStream, +) -> bool { for command in take_complete_commands(buffer) { - if !write_control_reply(stream, state.run_control_action(command)) { + if !write_control_reply(stream, data.run_control_action(command)) { return false; } } @@ -285,9 +285,9 @@ fn take_complete_commands(buffer: &mut Vec) -> Vec { commands } -fn run_command_line(bytes: &[u8], state: &mut App) { +fn run_command_line(bytes: &[u8], data: &mut CalloopData) { if let Some(command) = parse_command_line(bytes) { - state.run_control_action(command); + data.run_control_action(command); } } @@ -298,6 +298,7 @@ fn parse_command_line(bytes: &[u8]) -> Option { enum ControlReply { Ok, + Payload(Vec), Err(String), } @@ -305,6 +306,11 @@ impl ControlReply { fn bytes(&self) -> Vec { match self { Self::Ok => b"ok\n".to_vec(), + Self::Payload(payload) => { + let mut bytes = format!("ok {}\n", payload.len()).into_bytes(); + bytes.extend_from_slice(payload); + bytes + } Self::Err(message) => format!("err {message}\n").into_bytes(), } } @@ -322,6 +328,19 @@ fn write_control_reply(stream: &UnixStream, reply: ControlReply) -> bool { } } +impl CalloopData { + fn run_control_action(&mut self, action: ShellCommand) -> ControlReply { + if action == ShellCommand::Screenshot { + return match self.screenshot_png() { + Ok(bytes) => ControlReply::Payload(bytes), + Err(error) => ControlReply::Err(format!("screenshot failed: {error}")), + }; + } + + self.state.run_control_action(action) + } +} + impl App { fn run_control_action(&mut self, action: ShellCommand) -> ControlReply { self.advance_viewport_animation(); @@ -357,11 +376,7 @@ impl App { horizontal, vertical, } => self.synthesize_axis(horizontal, vertical), - ShellCommand::Screenshot => { - return ControlReply::Err( - "screenshot requires the future headless framebuffer readback path".to_string(), - ); - } + ShellCommand::Screenshot => unreachable!("screenshot is handled by CalloopData"), } self.request_redraw(); ControlReply::Ok diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 21d6604..c1c5655 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -137,9 +137,9 @@ Three pieces of work, in rough effort order: codes (the values from `input-event-codes.h`) rather than XKB keysyms; the compositor adds Smithay's expected XKB offset internally. A future WayDriver backend can either send evdev codes or add a keysym-to-evdev mapping layer. -- `screenshot` is parsed and replies with an `err` explaining that framebuffer - readback needs the future headless backend. No placeholder image bytes are - returned. +- `screenshot` now reads back the current GLES framebuffer on the existing winit + backend and replies as `ok \n`. This is not headless + yet, but it proves the direct renderer readback path WayDriver needs. ### Phase 0 — design + spike ⬜ -- 2.51.2 From 5a9cf4cb38d94cdbda8864a31d0f6a81907b5a89 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 26 Jun 2026 23:49:59 -0700 Subject: [PATCH 03/22] feat: add headless compositor backend --- src/compositor/mod.rs | 276 ++++++++++++++++++++++++++++++++++++------ src/config.rs | 2 + src/lib.rs | 5 +- todos/WAYDRIVER.md | 11 +- 4 files changed, 250 insertions(+), 44 deletions(-) diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs index ce5ae4d..b6720d8 100644 --- a/src/compositor/mod.rs +++ b/src/compositor/mod.rs @@ -29,9 +29,12 @@ use smithay::{ backend::{ allocator::Fourcc, allocator::dmabuf::Dmabuf, - egl::EGLDevice, + egl::{EGLContext, EGLDevice, EGLDisplay, native::EGLSurfacelessDisplay}, renderer::{ - ExportMem, ImportDma, damage::OutputDamageTracker, element::Id, gles::GlesRenderer, + Bind, ExportMem, ImportDma, Offscreen, + damage::OutputDamageTracker, + element::Id, + gles::{GlesRenderbuffer, GlesRenderer}, utils::on_commit_buffer_handler, }, winit::{self, WinitEvent}, @@ -717,7 +720,7 @@ pub fn run_winit(options: RunOptions) -> Result<(), Box> let mut data = CalloopData { state, display, - backend: Backend::Winit(backend), + backend: Backend::Winit(Box::new(backend)), damage_tracker: OutputDamageTracker::new(output_size, 1.0, Transform::Flipped180), start_time: std::time::Instant::now(), running: true, @@ -758,8 +761,184 @@ pub fn run_winit(options: RunOptions) -> Result<(), Box> Ok(()) } +pub fn run_headless(options: RunOptions) -> Result<(), Box> { + let mut display: Display = Display::new()?; + let dh = display.handle(); + + let compositor_state = CompositorState::new::(&dh); + let xdg_decoration_state = XdgDecorationState::new::(&dh); + let output_manager_state = OutputManagerState::new_with_xdg_output::(&dh); + let shm_state = ShmState::new::(&dh, vec![]); + let mut seat_state = SeatState::new(); + let mut seat = seat_state.new_wl_seat(&dh, "hearthspace"); + let keyboard = seat.add_keyboard( + Default::default(), + KEYBOARD_REPEAT_DELAY_MS, + KEYBOARD_REPEAT_RATE, + )?; + let pointer = seat.add_pointer(); + + let egl_display = unsafe { EGLDisplay::new(EGLSurfacelessDisplay)? }; + let context = EGLContext::new(&egl_display)?; + let mut renderer = unsafe { GlesRenderer::new(context)? }; + let output_size = Size::::from((HEADLESS_OUTPUT_WIDTH, HEADLESS_OUTPUT_HEIGHT)); + let buffer_size = Size::::from((output_size.w, output_size.h)); + let buffer = renderer.create_buffer(Fourcc::Abgr8888, buffer_size)?; + let output = create_output(&dh, output_size); + + let dmabuf_formats = renderer.dmabuf_formats().into_iter().collect::>(); + let render_node_dev = EGLDevice::device_for_display(renderer.egl_context().display()) + .ok() + .and_then(|device| device.render_device_path().ok()) + .and_then(|path| std::fs::metadata(path).ok()) + .map(|metadata| metadata.rdev()); + let mut dmabuf_state = DmabufState::new(); + let dmabuf_global = match render_node_dev { + Some(dev) => { + let feedback = + DmabufFeedbackBuilder::new(dev, dmabuf_formats.iter().copied()).build()?; + dmabuf_state.create_global_with_default_feedback::(&dh, &feedback) + } + None => dmabuf_state.create_global::(&dh, dmabuf_formats.iter().copied()), + }; + + let mut event_loop: EventLoop = EventLoop::try_new()?; + let handle = event_loop.handle(); + + let state = App { + compositor_state, + xdg_shell_state: XdgShellState::new_with_capabilities::( + &dh, + std::iter::empty::(), + ), + _xdg_decoration_state: xdg_decoration_state, + _output_manager_state: output_manager_state, + shm_state, + seat_state, + data_device_state: DataDeviceState::new::(&dh), + _seat: seat, + pointer, + keyboard, + viewport_offset: CanvasPoint { x: 0, y: 0 }, + viewport_scale: 1.0, + viewport_animation: None, + windows: Vec::new(), + next_window_id: 1, + idle_daemon: WindowIdleDaemon::new(WINDOW_IDLE_THRESHOLDS), + focused_normal_window_id: None, + drag: None, + resize: None, + cursor_icon: CursorIcon::Default, + next_spawn_position: CanvasPoint { x: 80, y: 96 }, + spawn_offset: 0, + pointer_location: (0.0, 0.0).into(), + scroll_zooms_without_super: options.scroll_zooms_without_super, + output_size, + output, + app_catalog: AppCatalog::load(), + needs_redraw: true, + dmabuf_state, + _dmabuf_global: dmabuf_global, + pending_dmabuf_imports: Vec::new(), + loop_handle: handle.clone(), + popups: PopupManager::default(), + background_dot_ids: Vec::new(), + }; + + let command_socket_path = command_socket_path(); + remove_stale_socket(&command_socket_path)?; + let command_listener = CommandListener::bind(&command_socket_path)?; + command_listener.set_nonblocking(true)?; + + let socket_source = ListeningSocketSource::with_name(WAYLAND_DISPLAY_NAME)?; + handle.insert_source(socket_source, |stream, _, data| { + if let Err(error) = data + .display + .handle() + .insert_client(stream, Arc::new(ClientState::default())) + { + eprintln!("Failed to insert Wayland client: {error}"); + } + data.state.request_redraw(); + })?; + + let display_fd = display.backend().poll_fd().try_clone_to_owned()?; + handle.insert_source( + Generic::new(display_fd, Interest::READ, CalloopMode::Level), + |_, _, data| { + let CalloopData { state, display, .. } = data; + display.dispatch_clients(state)?; + Ok(PostAction::Continue) + }, + )?; + + let command_loop_handle = handle.clone(); + handle.insert_source( + Generic::new(command_listener, Interest::READ, CalloopMode::Level), + move |_, listener, _| { + accept_command_connections(listener, &command_loop_handle)?; + Ok(PostAction::Continue) + }, + )?; + + spawn_shell(&command_socket_path); + + println!("Headless Hearthspace running on WAYLAND_DISPLAY={WAYLAND_DISPLAY_NAME}"); + if state.scroll_zooms_without_super { + println!("Scroll zoom testing mode enabled: vertical scroll zooms without Super"); + } + + let mut data = CalloopData { + state, + display, + backend: Backend::Headless(Box::new(HeadlessBackend { renderer, buffer })), + damage_tracker: OutputDamageTracker::new(output_size, 1.0, Transform::Flipped180), + start_time: std::time::Instant::now(), + running: true, + full_redraw: 1, + applied_cursor: CursorIcon::Default, + }; + + data.render()?; + + while data.running { + let timeout = data + .state + .viewport_animation + .is_some() + .then_some(ANIMATION_FRAME_INTERVAL); + event_loop.dispatch(timeout, &mut data)?; + + if !data.running { + break; + } + + data.process_pending_dmabuf_imports(); + data.state.handle_idle_transitions(); + data.state.advance_viewport_animation(); + data.apply_cursor_icon(); + + if data.state.needs_redraw { + data.render()?; + data.state.needs_redraw = false; + } + + data.display.flush_clients()?; + data.state.popups.cleanup(); + data.state.output.cleanup(); + } + + Ok(()) +} + enum Backend { - Winit(smithay::backend::winit::WinitGraphicsBackend), + Winit(Box>), + Headless(Box), +} + +struct HeadlessBackend { + renderer: GlesRenderer, + buffer: GlesRenderbuffer, } struct CalloopData { @@ -787,8 +966,9 @@ impl CalloopData { return; } self.applied_cursor = self.state.cursor_icon; - let Backend::Winit(backend) = &self.backend; - backend.window().set_cursor(self.applied_cursor); + if let Backend::Winit(backend) = &self.backend { + backend.window().set_cursor(self.applied_cursor); + } } fn process_pending_dmabuf_imports(&mut self) { @@ -796,9 +976,12 @@ impl CalloopData { return; } let CalloopData { state, backend, .. } = self; - let Backend::Winit(backend) = backend; for (dmabuf, notifier) in state.pending_dmabuf_imports.drain(..) { - match backend.renderer().import_dmabuf(&dmabuf, None) { + let import = match backend { + Backend::Winit(backend) => backend.renderer().import_dmabuf(&dmabuf, None), + Backend::Headless(backend) => backend.renderer.import_dmabuf(&dmabuf, None), + }; + match import { Ok(_texture) => { let _ = notifier.successful::(); } @@ -823,22 +1006,34 @@ impl CalloopData { full_redraw, .. } = self; - let Backend::Winit(backend) = backend; - - // `buffer_age` is an `eglQuerySurface` that only succeeds while the - // window surface is the current EGL draw surface. After a dmabuf import - // (or on the first frame) that is not guaranteed, so those frames are - // forced to a full redraw (age 0) instead of querying a stale surface. - let age = if *full_redraw > 0 { - *full_redraw = full_redraw.saturating_sub(1); - 0 - } else { - backend.buffer_age().unwrap_or(0) - }; - let damage = { - let (renderer, mut framebuffer) = backend.bind()?; - state.render_frame(renderer, &mut framebuffer, damage_tracker, age)? - }; + match backend { + Backend::Winit(backend) => { + // `buffer_age` is an `eglQuerySurface` that only succeeds while + // the window surface is the current EGL draw surface. After a + // dmabuf import (or on the first frame) that is not guaranteed, + // so those frames are forced to a full redraw (age 0) instead of + // querying a stale surface. + let age = if *full_redraw > 0 { + *full_redraw = full_redraw.saturating_sub(1); + 0 + } else { + backend.buffer_age().unwrap_or(0) + }; + let damage = { + let (renderer, mut framebuffer) = backend.bind()?; + state.render_frame(renderer, &mut framebuffer, damage_tracker, age)? + }; + + if let Some(damage) = damage.as_ref() { + backend.submit(Some(damage))?; + } + } + Backend::Headless(backend) => { + *full_redraw = 0; + let mut framebuffer = backend.renderer.bind(&mut backend.buffer)?; + state.render_frame(&mut backend.renderer, &mut framebuffer, damage_tracker, 0)?; + } + } for window in &state.windows { send_frames_surface_tree( @@ -860,11 +1055,6 @@ impl CalloopData { display.flush_clients()?; - // No damage means the scene is identical to the on-screen buffer, so - // there is nothing to swap. - if let Some(damage) = damage { - backend.submit(Some(&damage))?; - } Ok(()) } @@ -872,15 +1062,31 @@ impl CalloopData { self.process_pending_dmabuf_imports(); let CalloopData { state, backend, .. } = self; - let Backend::Winit(backend) = backend; let size = state.output_size; let mut screenshot_damage = OutputDamageTracker::new(size, 1.0, Transform::Flipped180); - let (renderer, mut framebuffer) = backend.bind()?; - state.render_frame(renderer, &mut framebuffer, &mut screenshot_damage, 0)?; - let region = Rectangle::from_size(Size::::from((size.w, size.h))); - let mapping = renderer.copy_framebuffer(&framebuffer, region, Fourcc::Abgr8888)?; - let pixels = renderer.map_texture(&mapping)?.to_vec(); + let pixels = match backend { + Backend::Winit(backend) => { + let (renderer, mut framebuffer) = backend.bind()?; + state.render_frame(renderer, &mut framebuffer, &mut screenshot_damage, 0)?; + let mapping = renderer.copy_framebuffer(&framebuffer, region, Fourcc::Abgr8888)?; + renderer.map_texture(&mapping)?.to_vec() + } + Backend::Headless(backend) => { + let mut framebuffer = backend.renderer.bind(&mut backend.buffer)?; + state.render_frame( + &mut backend.renderer, + &mut framebuffer, + &mut screenshot_damage, + 0, + )?; + let mapping = + backend + .renderer + .copy_framebuffer(&framebuffer, region, Fourcc::Abgr8888)?; + backend.renderer.map_texture(&mapping)?.to_vec() + } + }; encode_png_rgba(size, &pixels) } } diff --git a/src/config.rs b/src/config.rs index 25e0609..ad6b6cd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ pub const SHELL_COMMAND_SOCKET_NAME: &str = "hearthspace-shell.sock"; pub const SHELL_COMMAND_SOCKET_ENV: &str = "HEARTHSPACE_COMMAND_SOCKET"; pub const GTK_CLIENT_CONFIG_DIR_NAME: &str = "hearthspace-gtk-client-config"; pub const SCROLL_ZOOMS_FLAG: &str = "--scroll-zooms"; +pub const HEADLESS_OUTPUT_WIDTH: i32 = 1280; +pub const HEADLESS_OUTPUT_HEIGHT: i32 = 720; pub const KEYBOARD_REPEAT_DELAY_MS: i32 = 600; pub const KEYBOARD_REPEAT_RATE: i32 = 25; diff --git a/src/lib.rs b/src/lib.rs index 817ae58..583bd15 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,10 +18,7 @@ pub fn run() -> Result<(), Box> { pub fn run_with_options(options: RunOptions) -> Result<(), Box> { if options.headless { - return Err( - "--headless is recognized but the Smithay offscreen backend is not implemented yet" - .into(), - ); + return compositor::run_headless(options); } compositor::run_winit(options) diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index c1c5655..595e71a 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -123,9 +123,9 @@ Three pieces of work, in rough effort order: - The repository paths for the referenced planning docs are `todos/BACKENDS.md` and `todos/TESTING.md`, not `docs/BACKENDS.md` / `docs/TESTING.md`. -- `--headless` is now recognized by the CLI, but deliberately returns an error - instead of silently starting the winit backend. The real Smithay offscreen - renderer is still the blocking Phase 1 item. +- `--headless` now starts a real Smithay surfaceless EGL/GLES backend backed by + an offscreen renderbuffer. The current virtual output is fixed at 1280x720; + resolution/scale CLI args are still pending. - The command socket now has a minimal line-based response path: parsed commands write `ok\n`; unsupported commands that parse but cannot complete write `err \n`. This is intentionally smaller than the preferred future @@ -138,8 +138,9 @@ Three pieces of work, in rough effort order: compositor adds Smithay's expected XKB offset internally. A future WayDriver backend can either send evdev codes or add a keysym-to-evdev mapping layer. - `screenshot` now reads back the current GLES framebuffer on the existing winit - backend and replies as `ok \n`. This is not headless - yet, but it proves the direct renderer readback path WayDriver needs. + backend and the new headless backend, then replies as + `ok \n`. A smoke test against `hearthspace --headless` + returned a valid PNG from the control socket. ### Phase 0 — design + spike ⬜ -- 2.51.2 From 71bceedb3ab6f139d76176621a22e16fe7721ccc Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 26 Jun 2026 23:53:23 -0700 Subject: [PATCH 04/22] feat: configure headless output size --- src/compositor/mod.rs | 5 ++- src/config.rs | 1 + src/lib.rs | 1 + src/main.rs | 71 +++++++++++++++++++++++++++++++++++++++++++ todos/WAYDRIVER.md | 5 +-- 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs index b6720d8..d7afe6b 100644 --- a/src/compositor/mod.rs +++ b/src/compositor/mod.rs @@ -781,7 +781,10 @@ pub fn run_headless(options: RunOptions) -> Result<(), Box::from((HEADLESS_OUTPUT_WIDTH, HEADLESS_OUTPUT_HEIGHT)); + let (width, height) = options + .headless_output_size + .unwrap_or((HEADLESS_OUTPUT_WIDTH, HEADLESS_OUTPUT_HEIGHT)); + let output_size = Size::::from((width, height)); let buffer_size = Size::::from((output_size.w, output_size.h)); let buffer = renderer.create_buffer(Fourcc::Abgr8888, buffer_size)?; let output = create_output(&dh, output_size); diff --git a/src/config.rs b/src/config.rs index ad6b6cd..e50c246 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ pub const SHELL_COMMAND_SOCKET_NAME: &str = "hearthspace-shell.sock"; pub const SHELL_COMMAND_SOCKET_ENV: &str = "HEARTHSPACE_COMMAND_SOCKET"; pub const GTK_CLIENT_CONFIG_DIR_NAME: &str = "hearthspace-gtk-client-config"; pub const SCROLL_ZOOMS_FLAG: &str = "--scroll-zooms"; +pub const HEADLESS_SIZE_FLAG: &str = "--headless-size"; pub const HEADLESS_OUTPUT_WIDTH: i32 = 1280; pub const HEADLESS_OUTPUT_HEIGHT: i32 = 720; diff --git a/src/lib.rs b/src/lib.rs index 583bd15..2c4a30f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod test_apps; pub struct RunOptions { pub scroll_zooms_without_super: bool, pub headless: bool, + pub headless_output_size: Option<(i32, i32)>, } pub fn run() -> Result<(), Box> { diff --git a/src/main.rs b/src/main.rs index ac17058..be55420 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ fn main() -> Result<(), Box> { { hearthspace::shell::xilem_shell::run() } else { + let headless_output_size = parse_headless_output_size(&args)?; hearthspace::run_with_options(hearthspace::RunOptions { scroll_zooms_without_super: args .iter() @@ -28,6 +29,7 @@ fn main() -> Result<(), Box> { headless: args .iter() .any(|arg| arg == hearthspace::config::HEADLESS_FLAG), + headless_output_size, }) } } @@ -41,3 +43,72 @@ fn run_gtk_test_app() -> Result<(), Box> { fn run_gtk_test_app() -> Result<(), Box> { Err("GTK test app support is not enabled; rebuild with `--features test-apps`".into()) } + +fn parse_headless_output_size(args: &[String]) -> Result, String> { + for (index, arg) in args.iter().enumerate() { + if arg == hearthspace::config::HEADLESS_SIZE_FLAG { + let Some(value) = args.get(index + 1) else { + return Err(format!( + "{} requires WIDTHxHEIGHT", + hearthspace::config::HEADLESS_SIZE_FLAG + )); + }; + return parse_size(value).map(Some); + } + + if let Some(value) = + arg.strip_prefix(&format!("{}=", hearthspace::config::HEADLESS_SIZE_FLAG)) + { + return parse_size(value).map(Some); + } + } + + Ok(None) +} + +fn parse_size(value: &str) -> Result<(i32, i32), String> { + let Some((width, height)) = value.split_once('x').or_else(|| value.split_once('X')) else { + return Err(format!("invalid size {value:?}; expected WIDTHxHEIGHT")); + }; + let width = parse_positive_dimension(width, "width")?; + let height = parse_positive_dimension(height, "height")?; + Ok((width, height)) +} + +fn parse_positive_dimension(value: &str, name: &str) -> Result { + let parsed = value + .parse::() + .map_err(|_| format!("invalid {name} {value:?}"))?; + if parsed <= 0 { + return Err(format!("{name} must be positive")); + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_headless_size_forms() { + assert_eq!( + parse_headless_output_size(&["hearthspace".into(), "--headless-size=800x600".into()]), + Ok(Some((800, 600))) + ); + assert_eq!( + parse_headless_output_size(&[ + "hearthspace".into(), + "--headless-size".into(), + "1024X768".into(), + ]), + Ok(Some((1024, 768))) + ); + } + + #[test] + fn rejects_invalid_headless_size() { + assert!(parse_size("800").is_err()); + assert!(parse_size("0x600").is_err()); + assert!(parse_size("800xnope").is_err()); + } +} diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 595e71a..47d190d 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -124,8 +124,9 @@ Three pieces of work, in rough effort order: - The repository paths for the referenced planning docs are `todos/BACKENDS.md` and `todos/TESTING.md`, not `docs/BACKENDS.md` / `docs/TESTING.md`. - `--headless` now starts a real Smithay surfaceless EGL/GLES backend backed by - an offscreen renderbuffer. The current virtual output is fixed at 1280x720; - resolution/scale CLI args are still pending. + an offscreen renderbuffer. The virtual output defaults to 1280x720 and can be + overridden with `--headless-size WIDTHxHEIGHT`; scale CLI args are still + pending. - The command socket now has a minimal line-based response path: parsed commands write `ok\n`; unsupported commands that parse but cannot complete write `err \n`. This is intentionally smaller than the preferred future -- 2.51.2 From 31ef92985e38a9d94aef3895465c7523a11be946 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 00:15:05 -0700 Subject: [PATCH 05/22] docs: document headless test controls --- CONTRIBUTING.md | 47 +++++++++++++++++++++++++++++ src/compositor/shell_integration.rs | 6 ++++ src/shell/command.rs | 5 +++ todos/WAYDRIVER.md | 2 ++ 4 files changed, 60 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8fef77..865c18c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,53 @@ cargo test --features test-apps Without `test-apps`, `--gtk-test-app` and the shell `A11yTest` action report that the binary must be rebuilt with `--features test-apps`. +### Runtime And Test Flags + +Common compositor flags: + +```sh +cargo run -- --scroll-zooms +cargo run -- --headless +cargo run -- --headless --headless-size 1280x720 +``` + +`--scroll-zooms` makes vertical scroll events zoom the canvas without holding +Super. This is mainly for nested compositor and VM testing where host gestures or +modifier routing are unreliable. + +`--headless` starts Hearthspace with a surfaceless EGL/GLES renderer and an +offscreen virtual output instead of a host winit window. It still opens the +deterministic Wayland socket `wayland-99` in `XDG_RUNTIME_DIR` and starts the +shell as a normal Wayland client. + +`--headless-size WIDTHxHEIGHT` overrides the headless virtual output size. The +default is `1280x720`; both `--headless-size 800x600` and +`--headless-size=800x600` are accepted. + +The shell/control socket is `hearthspace-shell.sock` in `XDG_RUNTIME_DIR`. Shell +clients receive its full path through `HEARTHSPACE_COMMAND_SOCKET`, but tests can +connect to it directly. The protocol is line-oriented for requests. Successful +commands reply `ok\n`; screenshots reply `ok \n`; parsed +commands that fail reply `err \n`. + +Useful control commands for headless smoke tests: + +```text +key-down +key-up +pointer-motion-abs +pointer-motion-rel +pointer-button-down +pointer-button-up +axis +screenshot +quit +``` + +Keyboard commands currently take Linux evdev key codes, not XKB keysyms. Pointer +button commands use Linux input button codes, for example `272` (`0x110`) for the +left mouse button. + ### Xilem Fork (git dependency) The shell UI is built with [Xilem](https://github.com/linebender/xilem). Stock diff --git a/src/compositor/shell_integration.rs b/src/compositor/shell_integration.rs index 3a03450..fd0d267 100644 --- a/src/compositor/shell_integration.rs +++ b/src/compositor/shell_integration.rs @@ -330,6 +330,11 @@ fn write_control_reply(stream: &UnixStream, reply: ControlReply) -> bool { impl CalloopData { fn run_control_action(&mut self, action: ShellCommand) -> ControlReply { + if action == ShellCommand::Quit { + self.running = false; + return ControlReply::Ok; + } + if action == ShellCommand::Screenshot { return match self.screenshot_png() { Ok(bytes) => ControlReply::Payload(bytes), @@ -377,6 +382,7 @@ impl App { vertical, } => self.synthesize_axis(horizontal, vertical), ShellCommand::Screenshot => unreachable!("screenshot is handled by CalloopData"), + ShellCommand::Quit => unreachable!("quit is handled by CalloopData"), } self.request_redraw(); ControlReply::Ok diff --git a/src/shell/command.rs b/src/shell/command.rs index 69f5d0c..6b2ec39 100644 --- a/src/shell/command.rs +++ b/src/shell/command.rs @@ -49,6 +49,7 @@ pub enum ShellCommand { PointerButtonUp(u32), Axis { horizontal: f64, vertical: f64 }, Screenshot, + Quit, } impl ShellCommand { @@ -83,6 +84,7 @@ impl ShellCommand { Self::PointerButtonUp(_) => "BTNUP", Self::Axis { .. } => "AXIS", Self::Screenshot => "SHOT", + Self::Quit => "QUIT", } } @@ -109,6 +111,7 @@ impl ShellCommand { vertical, } => format!("axis {horizontal} {vertical}"), Self::Screenshot => "screenshot".to_string(), + Self::Quit => "quit".to_string(), } } @@ -147,6 +150,7 @@ impl ShellCommand { vertical: parse_f64(parts.next()?)?, }, "screenshot" => Self::Screenshot, + "quit" => Self::Quit, _ => return None, }) } @@ -222,5 +226,6 @@ mod tests { ShellCommand::parse("screenshot"), Some(ShellCommand::Screenshot) ); + assert_eq!(ShellCommand::parse("quit"), Some(ShellCommand::Quit)); } } diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 47d190d..0e288a3 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -142,6 +142,8 @@ Three pieces of work, in rough effort order: backend and the new headless backend, then replies as `ok \n`. A smoke test against `hearthspace --headless` returned a valid PNG from the control socket. +- `quit` stops the compositor cleanly over the control socket so harnesses do + not need to rely on process termination for normal teardown. ### Phase 0 — design + spike ⬜ -- 2.51.2 From 82247c1d29254a36efe9709944c939d0758daf5f Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 00:17:29 -0700 Subject: [PATCH 06/22] feat: allow headless runs without shell --- CONTRIBUTING.md | 4 ++++ src/compositor/mod.rs | 8 ++++++-- src/config.rs | 1 + src/lib.rs | 14 +++++++++++++- src/main.rs | 3 +++ todos/WAYDRIVER.md | 2 ++ 6 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 865c18c..3b1500f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,7 @@ Common compositor flags: cargo run -- --scroll-zooms cargo run -- --headless cargo run -- --headless --headless-size 1280x720 +cargo run -- --headless --no-shell ``` `--scroll-zooms` makes vertical scroll events zoom the canvas without holding @@ -81,6 +82,9 @@ shell as a normal Wayland client. default is `1280x720`; both `--headless-size 800x600` and `--headless-size=800x600` are accepted. +`--no-shell` skips spawning the Xilem shell client. This is useful for headless +harnesses that want to launch only the client under test. + The shell/control socket is `hearthspace-shell.sock` in `XDG_RUNTIME_DIR`. Shell clients receive its full path through `HEARTHSPACE_COMMAND_SOCKET`, but tests can connect to it directly. The protocol is line-oriented for requests. Successful diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs index d7afe6b..108e903 100644 --- a/src/compositor/mod.rs +++ b/src/compositor/mod.rs @@ -710,7 +710,9 @@ pub fn run_winit(options: RunOptions) -> Result<(), Box> WinitEvent::Focus(_) => {} })?; - spawn_shell(&command_socket_path); + if options.start_shell { + spawn_shell(&command_socket_path); + } println!("Hearthspace running on WAYLAND_DISPLAY={WAYLAND_DISPLAY_NAME}"); if state.scroll_zooms_without_super { @@ -884,7 +886,9 @@ pub fn run_headless(options: RunOptions) -> Result<(), Box, + pub start_shell: bool, +} + +impl Default for RunOptions { + fn default() -> Self { + Self { + scroll_zooms_without_super: false, + headless: false, + headless_output_size: None, + start_shell: true, + } + } } pub fn run() -> Result<(), Box> { diff --git a/src/main.rs b/src/main.rs index be55420..83d2364 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,9 @@ fn main() -> Result<(), Box> { .iter() .any(|arg| arg == hearthspace::config::HEADLESS_FLAG), headless_output_size, + start_shell: !args + .iter() + .any(|arg| arg == hearthspace::config::NO_SHELL_FLAG), }) } } diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 0e288a3..a0996c0 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -144,6 +144,8 @@ Three pieces of work, in rough effort order: returned a valid PNG from the control socket. - `quit` stops the compositor cleanly over the control socket so harnesses do not need to rely on process termination for normal teardown. +- `--no-shell` skips spawning the shell client, which keeps headless WayDriver + runs focused on the app under test. ### Phase 0 — design + spike ⬜ -- 2.51.2 From be84e6b3ffceb7aea64295b71cea687b1895baf6 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 00:20:33 -0700 Subject: [PATCH 07/22] feat: configure headless output scale --- CONTRIBUTING.md | 5 +++++ src/compositor/mod.rs | 15 ++++++++------- src/config.rs | 1 + src/lib.rs | 2 ++ src/main.rs | 44 +++++++++++++++++++++++++++++++++++++++++++ todos/WAYDRIVER.md | 4 ++-- 6 files changed, 62 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b1500f..8d1c6a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,7 @@ Common compositor flags: cargo run -- --scroll-zooms cargo run -- --headless cargo run -- --headless --headless-size 1280x720 +cargo run -- --headless --headless-scale 2 cargo run -- --headless --no-shell ``` @@ -82,6 +83,10 @@ shell as a normal Wayland client. default is `1280x720`; both `--headless-size 800x600` and `--headless-size=800x600` are accepted. +`--headless-scale INTEGER` overrides the Wayland output scale advertised by the +headless backend. The default is `1`; both `--headless-scale 2` and +`--headless-scale=2` are accepted. + `--no-shell` skips spawning the Xilem shell client. This is useful for headless harnesses that want to launch only the client under test. diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs index 108e903..115bad2 100644 --- a/src/compositor/mod.rs +++ b/src/compositor/mod.rs @@ -582,7 +582,7 @@ pub fn run_winit(options: RunOptions) -> Result<(), Box> let (mut backend, winit) = winit::init::()?; let output_size = backend.window_size(); - let output = create_output(&dh, output_size); + let output = create_output(&dh, output_size, 1); // Advertise linux-dmabuf so GPU-accelerated clients (e.g. GTK4's GL // renderer) can hand us hardware buffers instead of failing EGL setup. The @@ -697,7 +697,7 @@ pub fn run_winit(options: RunOptions) -> Result<(), Box> WinitEvent::Resized { size, .. } => { if size != data.state.output_size { data.state.output_size = size; - update_output_mode(&data.state.output, size); + update_output_mode(&data.state.output, size, 1); data.damage_tracker = OutputDamageTracker::new(size, 1.0, Transform::Flipped180); data.full_redraw = 1; data.state.configure_shell_bars(); @@ -787,9 +787,10 @@ pub fn run_headless(options: RunOptions) -> Result<(), Box::from((width, height)); + let output_scale = options.headless_output_scale.unwrap_or(1); let buffer_size = Size::::from((output_size.w, output_size.h)); let buffer = renderer.create_buffer(Fourcc::Abgr8888, buffer_size)?; - let output = create_output(&dh, output_size); + let output = create_output(&dh, output_size, output_scale); let dmabuf_formats = renderer.dmabuf_formats().into_iter().collect::>(); let render_node_dev = EGLDevice::device_for_display(renderer.egl_context().display()) @@ -1136,7 +1137,7 @@ fn encode_png_rgba( Ok(png_bytes) } -fn create_output(dh: &DisplayHandle, size: Size) -> Output { +fn create_output(dh: &DisplayHandle, size: Size, scale: i32) -> Output { let output = Output::new( "hearthspace-0".into(), PhysicalProperties { @@ -1147,11 +1148,11 @@ fn create_output(dh: &DisplayHandle, size: Size) -> Output { }, ); output.create_global::(dh); - update_output_mode(&output, size); + update_output_mode(&output, size, scale); output } -fn update_output_mode(output: &Output, size: Size) { +fn update_output_mode(output: &Output, size: Size, scale: i32) { let mode = Mode { size, refresh: 60_000, @@ -1160,7 +1161,7 @@ fn update_output_mode(output: &Output, size: Size) { output.change_current_state( Some(mode), Some(Transform::Normal), - Some(Scale::Integer(1)), + Some(Scale::Integer(scale)), Some((0, 0).into()), ); } diff --git a/src/config.rs b/src/config.rs index 31ab555..509b49a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ pub const SHELL_COMMAND_SOCKET_ENV: &str = "HEARTHSPACE_COMMAND_SOCKET"; pub const GTK_CLIENT_CONFIG_DIR_NAME: &str = "hearthspace-gtk-client-config"; pub const SCROLL_ZOOMS_FLAG: &str = "--scroll-zooms"; pub const HEADLESS_SIZE_FLAG: &str = "--headless-size"; +pub const HEADLESS_SCALE_FLAG: &str = "--headless-scale"; pub const NO_SHELL_FLAG: &str = "--no-shell"; pub const HEADLESS_OUTPUT_WIDTH: i32 = 1280; pub const HEADLESS_OUTPUT_HEIGHT: i32 = 720; diff --git a/src/lib.rs b/src/lib.rs index cd0a791..36baf3e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub struct RunOptions { pub scroll_zooms_without_super: bool, pub headless: bool, pub headless_output_size: Option<(i32, i32)>, + pub headless_output_scale: Option, pub start_shell: bool, } @@ -20,6 +21,7 @@ impl Default for RunOptions { scroll_zooms_without_super: false, headless: false, headless_output_size: None, + headless_output_scale: None, start_shell: true, } } diff --git a/src/main.rs b/src/main.rs index 83d2364..541e72d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ fn main() -> Result<(), Box> { hearthspace::shell::xilem_shell::run() } else { let headless_output_size = parse_headless_output_size(&args)?; + let headless_output_scale = parse_headless_output_scale(&args)?; hearthspace::run_with_options(hearthspace::RunOptions { scroll_zooms_without_super: args .iter() @@ -30,6 +31,7 @@ fn main() -> Result<(), Box> { .iter() .any(|arg| arg == hearthspace::config::HEADLESS_FLAG), headless_output_size, + headless_output_scale, start_shell: !args .iter() .any(|arg| arg == hearthspace::config::NO_SHELL_FLAG), @@ -37,6 +39,28 @@ fn main() -> Result<(), Box> { } } +fn parse_headless_output_scale(args: &[String]) -> Result, String> { + for (index, arg) in args.iter().enumerate() { + if arg == hearthspace::config::HEADLESS_SCALE_FLAG { + let Some(value) = args.get(index + 1) else { + return Err(format!( + "{} requires a positive integer", + hearthspace::config::HEADLESS_SCALE_FLAG + )); + }; + return parse_positive_dimension(value, "scale").map(Some); + } + + if let Some(value) = + arg.strip_prefix(&format!("{}=", hearthspace::config::HEADLESS_SCALE_FLAG)) + { + return parse_positive_dimension(value, "scale").map(Some); + } + } + + Ok(None) +} + #[cfg(feature = "test-apps")] fn run_gtk_test_app() -> Result<(), Box> { hearthspace::test_apps::gtk::run() @@ -108,10 +132,30 @@ mod tests { ); } + #[test] + fn parses_headless_scale_forms() { + assert_eq!( + parse_headless_output_scale(&["hearthspace".into(), "--headless-scale=2".into()]), + Ok(Some(2)) + ); + assert_eq!( + parse_headless_output_scale(&[ + "hearthspace".into(), + "--headless-scale".into(), + "3".into(), + ]), + Ok(Some(3)) + ); + } + #[test] fn rejects_invalid_headless_size() { assert!(parse_size("800").is_err()); assert!(parse_size("0x600").is_err()); assert!(parse_size("800xnope").is_err()); + assert!( + parse_headless_output_scale(&["hearthspace".into(), "--headless-scale=0".into()]) + .is_err() + ); } } diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index a0996c0..a274a3a 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -125,8 +125,8 @@ Three pieces of work, in rough effort order: and `todos/TESTING.md`, not `docs/BACKENDS.md` / `docs/TESTING.md`. - `--headless` now starts a real Smithay surfaceless EGL/GLES backend backed by an offscreen renderbuffer. The virtual output defaults to 1280x720 and can be - overridden with `--headless-size WIDTHxHEIGHT`; scale CLI args are still - pending. + overridden with `--headless-size WIDTHxHEIGHT`. The advertised Wayland output + scale defaults to 1 and can be overridden with `--headless-scale INTEGER`. - The command socket now has a minimal line-based response path: parsed commands write `ok\n`; unsupported commands that parse but cannot complete write `err \n`. This is intentionally smaller than the preferred future -- 2.51.2 From e510b8b9d2ff9c76206347df0d618c9d41311aa5 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 00:32:44 -0700 Subject: [PATCH 08/22] docs: update WayDriver implementation status --- todos/WAYDRIVER.md | 89 ++++++++++++++++++++++++---------------------- 1 file changed, 46 insertions(+), 43 deletions(-) diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index a274a3a..1412645 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -91,31 +91,31 @@ WayDriver is `tokio`/`zbus`/`async-trait`. The two runtimes never mix because they live in **separate processes** communicating over the control socket. Hearthspace handles commands in its existing calloop source. -## Gaps on the Hearthspace side - -Three pieces of work, in rough effort order: - -1. **Headless backend (the real work).** Today Hearthspace has only the nested - **winit** backend; DRM is still planned ([BACKENDS.md](./BACKENDS.md)). - WayDriver needs a no-display compositor that renders offscreen to a fixed - virtual output and opens a Wayland socket at a known name + runtime dir. - Smithay supports this (headless EGL/GLES + a synthetic `Output`). It slots in - as a third `Backend` variant alongside the winit/udev seam that - [BACKENDS.md](./BACKENDS.md) Step 2 already created — `render_frame` is - already renderer-generic. - -2. **Control-protocol extensions for input + screenshot.** The current command - socket is fire-and-forget (`shell/command.rs` `ShellCommand`). We need: - - new commands: `key-down/up `, `pointer-motion-abs/rel`, - `pointer-button-down/up`, `axis`, `screenshot`; - - a **reply channel** — the socket has no response path today, and - `screenshot` must return bytes. This is the main protocol change. - - Input commands synthesize events directly into the Smithay seat - (`compositor/input.rs`); screenshot does a framebuffer readback → PNG. - -3. **Video (optional, defer).** Screenshots are a direct readback. Continuous - WebM is the one place PipeWire would still earn its keep. Stub - `start_recording` initially and revisit. +## Hearthspace-side status + +The compositor-side pieces needed for a first WayDriver backend are now mostly +in place: + +1. **Headless backend: implemented.** `hearthspace --headless` starts a + surfaceless EGL/GLES renderer backed by an offscreen renderbuffer, advertises + a synthetic Smithay `Output`, opens the deterministic `wayland-99` socket, and + runs the same calloop-driven compositor state as the nested winit backend. + `--headless-size WIDTHxHEIGHT` configures the virtual output size, + `--headless-scale INTEGER` configures the advertised Wayland scale, and + `--no-shell` skips the Xilem shell client for app-focused harnesses. + +2. **Control-protocol extensions: implemented for screenshots and input.** The + command socket now replies to parsed commands, supports synthetic keyboard, + pointer, button, and axis events, and returns PNG bytes for `screenshot` via a + direct GLES framebuffer readback. `quit` provides graceful harness teardown. + The current keyboard command accepts Linux evdev key codes rather than XKB + keysyms; a WayDriver adapter can map keysyms before sending, or we can add a + compositor-side mapping later. + +3. **Remaining Hearthspace-side gaps.** The response protocol is intentionally a + small line-based frame (`ok`, `err`, `ok ` + payload), not the + previously preferred length-prefixed binary envelope. Continuous video is not + implemented; screenshots are the supported capture path for now. ## Incremental plan @@ -147,28 +147,29 @@ Three pieces of work, in rough effort order: - `--no-shell` skips spawning the shell client, which keeps headless WayDriver runs focused on the app under test. -### Phase 0 — design + spike ⬜ +### Phase 0 — design + spike ✅ -- [ ] Validate Smithay headless offscreen rendering (GLES + synthetic `Output`) - in a throwaway branch; confirm a client can connect and we can read back a +- [x] Validate Smithay headless offscreen rendering (GLES + synthetic `Output`) + in the compositor; confirm a client can connect and we can read back a frame to PNG. -- [ ] Decide the control-socket reply protocol (length-prefixed binary vs. a - small request/response framing) — see Open Questions. +- [x] Decide the control-socket reply protocol (length-prefixed binary vs. a + small request/response framing) — see Decisions. -### Phase 1 — headless backend ⬜ +### Phase 1 — headless backend ✅ -- [ ] Add a `Backend::Headless` variant; gate behind a `--headless` flag (and/or +- [x] Add a `Backend::Headless` variant; gate behind a `--headless` flag (and/or a cargo feature) with a deterministic `WAYLAND_DISPLAY` + runtime dir. -- [ ] Fixed virtual output size from a CLI arg (mirror WayDriver's +- [x] Fixed virtual output size from a CLI arg (mirror WayDriver's `resolution` / `scale`). -- [ ] **Done when:** `hearthspace --headless` runs with no monitor, a client can +- [x] **Done when:** `hearthspace --headless` runs with no monitor, a client can connect, and the process is idle when nothing animates. -### Phase 2 — input + screenshot IPC ⬜ +### Phase 2 — input + screenshot IPC ✅/⬜ -- [ ] Extend the control protocol with input + `screenshot` commands and a reply +- [x] Extend the control protocol with input + `screenshot` commands and a reply channel. -- [ ] Synthesize input into the Smithay seat; implement framebuffer→PNG readback. +- [x] Synthesize input into the Smithay seat; implement framebuffer→PNG readback. +- [x] Add a control-socket `quit` command for graceful harness teardown. - [ ] **Done when:** a script can drive a headless client end-to-end (move pointer, click, type, screenshot) over the socket. @@ -187,7 +188,7 @@ Three pieces of work, in rough effort order: - [ ] Video recording (PipeWire) if needed for CI artifacts. - [ ] Confirm the Xilem shell's Masonry/AccessKit AT-SPI tree is XPath-locatable at runtime. -## Decisions to make +## Decisions - **Where do the backend crates live?** Since the goal is testing Hearthspace itself, keep `waydriver-{compositor,input,capture}-hearthspace` **in this @@ -195,9 +196,10 @@ Three pieces of work, in rough effort order: the upstream `waydriver` library crate. Avoids coupling our test harness to WayDriver's release cadence. Upstreaming later stays possible (additive siblings). -- **Control-socket reply protocol.** The current one-way text protocol needs a - response path for `screenshot`. Prefer a minimal length-prefixed framing over - re-using line-based text, so binary PNG payloads don't need escaping. +- **Control-socket reply protocol.** Implemented as a minimal request/reply + protocol over the existing Unix stream: `ok\n`, `err \n`, or + `ok \n` for screenshots. This keeps the current shell + command model simple while giving tests a binary-safe screenshot path. - **Headless gating.** CLI flag vs. cargo feature — a runtime `--headless` flag keeps a single binary (simpler for the backend to spawn) and avoids a build matrix. @@ -205,8 +207,9 @@ Three pieces of work, in rough effort order: ## Open questions - Does Smithay's headless GLES path on our ARM64 VM read back framebuffers - without a real GBM device, or do we need a software/llvmpipe EGL? (Phase 0 - spike answers this.) + without a real GBM device, or do we need a software/llvmpipe EGL? **Answered:** + surfaceless EGL works on the VM, and headless screenshot smoke tests return + valid PNGs. - Tokio dev-dependency: the backend crates pull `tokio`/`zbus`/`async-trait` into `[dev-dependencies]` only — confirm that doesn't leak into the main build. - How much CI cost does an E2E suite add, and should it be a separate, opt-in -- 2.51.2 From 98e84a31863dd16bf745b91e4910143fc004f5d6 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 00:41:58 -0700 Subject: [PATCH 09/22] test: add headless control smoke test --- tests/headless_control.rs | 155 ++++++++++++++++++++++++++++++++++++++ todos/WAYDRIVER.md | 6 ++ 2 files changed, 161 insertions(+) create mode 100644 tests/headless_control.rs diff --git a/tests/headless_control.rs b/tests/headless_control.rs new file mode 100644 index 0000000..08b2244 --- /dev/null +++ b/tests/headless_control.rs @@ -0,0 +1,155 @@ +use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + path::PathBuf, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const WIDTH: u32 = 320; +const HEIGHT: u32 = 240; + +struct HeadlessCompositor { + child: Child, +} + +impl HeadlessCompositor { + fn spawn() -> Self { + let child = Command::new(env!("CARGO_BIN_EXE_hearthspace")) + .args(["--headless", "--no-shell", "--headless-size", "320x240"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn headless Hearthspace"); + + Self { child } + } + + fn wait_for_socket(&mut self) -> UnixStream { + let deadline = Instant::now() + Duration::from_secs(10); + let path = command_socket_path(); + let mut last_error = None; + + while Instant::now() < deadline { + if let Some(status) = self.child.try_wait().expect("poll compositor") { + panic!("headless Hearthspace exited before accepting commands: {status}"); + } + + match UnixStream::connect(&path) { + Ok(stream) => return stream, + Err(error) => last_error = Some(error), + } + + thread::sleep(Duration::from_millis(50)); + } + + panic!( + "timed out connecting to {}: {:?}", + path.display(), + last_error + ); + } +} + +impl Drop for HeadlessCompositor { + fn drop(&mut self) { + if matches!(self.child.try_wait(), Ok(None)) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +#[test] +#[ignore = "requires surfaceless EGL and the headless compositor socket"] +fn headless_control_socket_drives_input_screenshot_and_quit() { + let mut compositor = HeadlessCompositor::spawn(); + let first_stream = compositor.wait_for_socket(); + drop(first_stream); + + for command in [ + "pointer-motion-abs 10 10", + "pointer-motion-rel 5 5", + "pointer-button-down 272", + "pointer-button-up 272", + "axis 0 -120", + "key-down 30", + "key-up 30", + ] { + assert_eq!(send_text_command(command), "ok\n", "command {command:?}"); + } + + let screenshot = take_screenshot(); + assert!(screenshot.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(png_dimensions(&screenshot), (WIDTH, HEIGHT)); + + assert_eq!(send_text_command("quit"), "ok\n"); + wait_for_exit(&mut compositor.child); +} + +fn command_socket_path() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("hearthspace-shell.sock") +} + +fn send_text_command(command: &str) -> String { + let mut stream = UnixStream::connect(command_socket_path()).expect("connect command socket"); + stream + .write_all(format!("{command}\n").as_bytes()) + .expect("write command"); + read_line(&mut stream) +} + +fn take_screenshot() -> Vec { + let mut stream = UnixStream::connect(command_socket_path()).expect("connect command socket"); + stream.write_all(b"screenshot\n").expect("write screenshot"); + + let header = read_line(&mut stream); + let mut parts = header.split_whitespace(); + assert_eq!(parts.next(), Some("ok")); + let len = parts + .next() + .expect("screenshot byte length") + .parse::() + .expect("valid screenshot byte length"); + assert_eq!(parts.next(), None); + + let mut payload = vec![0; len]; + stream.read_exact(&mut payload).expect("read PNG payload"); + payload +} + +fn read_line(stream: &mut UnixStream) -> String { + let mut bytes = Vec::new(); + loop { + let mut byte = [0]; + stream.read_exact(&mut byte).expect("read reply byte"); + bytes.push(byte[0]); + if byte[0] == b'\n' { + break; + } + } + String::from_utf8(bytes).expect("utf-8 reply") +} + +fn png_dimensions(png: &[u8]) -> (u32, u32) { + assert!(png.len() >= 24); + let width = u32::from_be_bytes(png[16..20].try_into().expect("PNG width bytes")); + let height = u32::from_be_bytes(png[20..24].try_into().expect("PNG height bytes")); + (width, height) +} + +fn wait_for_exit(child: &mut Child) { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if child.try_wait().expect("poll compositor").is_some() { + return; + } + thread::sleep(Duration::from_millis(50)); + } + panic!("headless Hearthspace did not exit after quit command"); +} diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 1412645..86183b0 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -146,6 +146,10 @@ in place: not need to rely on process termination for normal teardown. - `--no-shell` skips spawning the shell client, which keeps headless WayDriver runs focused on the app under test. +- `tests/headless_control.rs` is an ignored integration smoke test for the + compositor-side protocol. Run it with + `cargo test --test headless_control -- --ignored` on machines with surfaceless + EGL support. ### Phase 0 — design + spike ✅ @@ -170,6 +174,8 @@ in place: channel. - [x] Synthesize input into the Smithay seat; implement framebuffer→PNG readback. - [x] Add a control-socket `quit` command for graceful harness teardown. +- [x] Add an ignored integration smoke test that drives input commands, + captures a screenshot, and quits over the socket. - [ ] **Done when:** a script can drive a headless client end-to-end (move pointer, click, type, screenshot) over the socket. -- 2.51.2 From 04bfc456f26569cf286c4e67d2b4fda199857cf7 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 00:46:45 -0700 Subject: [PATCH 10/22] test: drive GTK client in headless smoke test --- tests/headless_control.rs | 65 ++++++++++++++++++++++++++++++++++++++- todos/WAYDRIVER.md | 12 +++++--- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/tests/headless_control.rs b/tests/headless_control.rs index 08b2244..98dd154 100644 --- a/tests/headless_control.rs +++ b/tests/headless_control.rs @@ -3,12 +3,19 @@ use std::{ os::unix::net::UnixStream, path::PathBuf, process::{Child, Command, Stdio}, + sync::Mutex, thread, time::{Duration, Instant}, }; const WIDTH: u32 = 320; const HEIGHT: u32 = 240; +#[cfg(feature = "test-apps")] +const CLIENT_WIDTH: u32 = 800; +#[cfg(feature = "test-apps")] +const CLIENT_HEIGHT: u32 = 600; + +static HEADLESS_TEST_LOCK: Mutex<()> = Mutex::new(()); struct HeadlessCompositor { child: Child, @@ -16,8 +23,12 @@ struct HeadlessCompositor { impl HeadlessCompositor { fn spawn() -> Self { + Self::spawn_with_size("320x240") + } + + fn spawn_with_size(size: &str) -> Self { let child = Command::new(env!("CARGO_BIN_EXE_hearthspace")) - .args(["--headless", "--no-shell", "--headless-size", "320x240"]) + .args(["--headless", "--no-shell", "--headless-size", size]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -65,6 +76,7 @@ impl Drop for HeadlessCompositor { #[test] #[ignore = "requires surfaceless EGL and the headless compositor socket"] fn headless_control_socket_drives_input_screenshot_and_quit() { + let _guard = HEADLESS_TEST_LOCK.lock().expect("headless test lock"); let mut compositor = HeadlessCompositor::spawn(); let first_stream = compositor.wait_for_socket(); drop(first_stream); @@ -89,6 +101,44 @@ fn headless_control_socket_drives_input_screenshot_and_quit() { wait_for_exit(&mut compositor.child); } +#[cfg(feature = "test-apps")] +#[test] +#[ignore = "requires surfaceless EGL, GTK, and the headless compositor socket"] +fn headless_control_socket_spawns_and_drives_real_gtk_client() { + let _guard = HEADLESS_TEST_LOCK.lock().expect("headless test lock"); + let mut compositor = HeadlessCompositor::spawn_with_size("800x600"); + let first_stream = compositor.wait_for_socket(); + drop(first_stream); + + let empty = take_screenshot(); + assert_eq!(png_dimensions(&empty), (CLIENT_WIDTH, CLIENT_HEIGHT)); + + assert_eq!(send_text_command("spawn a11y-test"), "ok\n"); + let with_client = wait_for_screenshot_change(&empty); + assert_eq!(png_dimensions(&with_client), (CLIENT_WIDTH, CLIENT_HEIGHT)); + + for command in [ + "pointer-motion-abs 420 260", + "pointer-button-down 272", + "pointer-button-up 272", + "key-down 30", + "key-up 30", + "screenshot", + ] { + if command == "screenshot" { + assert_eq!( + png_dimensions(&take_screenshot()), + (CLIENT_WIDTH, CLIENT_HEIGHT) + ); + } else { + assert_eq!(send_text_command(command), "ok\n", "command {command:?}"); + } + } + + assert_eq!(send_text_command("quit"), "ok\n"); + wait_for_exit(&mut compositor.child); +} + fn command_socket_path() -> PathBuf { std::env::var_os("XDG_RUNTIME_DIR") .map(PathBuf::from) @@ -123,6 +173,19 @@ fn take_screenshot() -> Vec { payload } +#[cfg(feature = "test-apps")] +fn wait_for_screenshot_change(previous: &[u8]) -> Vec { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let screenshot = take_screenshot(); + if screenshot != previous { + return screenshot; + } + thread::sleep(Duration::from_millis(100)); + } + panic!("screenshot did not change after spawning GTK test client"); +} + fn read_line(stream: &mut UnixStream) -> String { let mut bytes = Vec::new(); loop { diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 86183b0..ea7da23 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -147,9 +147,11 @@ in place: - `--no-shell` skips spawning the shell client, which keeps headless WayDriver runs focused on the app under test. - `tests/headless_control.rs` is an ignored integration smoke test for the - compositor-side protocol. Run it with - `cargo test --test headless_control -- --ignored` on machines with surfaceless - EGL support. + compositor-side protocol. It also has a `test-apps` feature-gated variant that + spawns and drives the in-repo GTK a11y client. Run it with + `cargo test --test headless_control -- --ignored` or + `cargo test --features test-apps --test headless_control -- --ignored` on + machines with surfaceless EGL support. ### Phase 0 — design + spike ✅ @@ -176,7 +178,9 @@ in place: - [x] Add a control-socket `quit` command for graceful harness teardown. - [x] Add an ignored integration smoke test that drives input commands, captures a screenshot, and quits over the socket. -- [ ] **Done when:** a script can drive a headless client end-to-end (move +- [x] Add an ignored, `test-apps`-gated smoke test that spawns the GTK a11y + client and drives pointer/key/screenshot commands against it. +- [x] **Done when:** a script can drive a headless client end-to-end (move pointer, click, type, screenshot) over the socket. ### Phase 3 — WayDriver backend crates ⬜ -- 2.51.2 From 43228efa5d0a911b1ccf00186d8b7eac3ad45729 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 00:54:11 -0700 Subject: [PATCH 11/22] test: probe GTK client AT-SPI exposure --- src/accessibility.rs | 35 +++++++++++++++++++++++++++++++++++ tests/headless_control.rs | 22 ++++++++++++++++++++++ todos/WAYDRIVER.md | 9 +++++++-- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/accessibility.rs b/src/accessibility.rs index 2306b09..96c0ffc 100644 --- a/src/accessibility.rs +++ b/src/accessibility.rs @@ -28,6 +28,41 @@ pub fn log_accessibility_tree(windows: Vec) { }); } +pub async fn accessibility_tree_contains_term( + term: &str, +) -> Result> { + let term = term.trim(); + if term.is_empty() { + return Ok(false); + } + + let connection = AccessibilityConnection::new().await?; + let root = connection.root_accessible_on_registry().await?; + let mut stack = root.get_children().await?; + let mut visited = 0; + + while let Some(object) = stack.pop() { + if visited >= MAX_A11Y_NODES { + break; + } + visited += 1; + + let Ok(proxy) = connection.object_as_accessible(&object).await else { + continue; + }; + let summary = summarize_accessible(&proxy, object).await; + if !is_desktop_shell_root(&summary) && accessible_matches_term(&summary, term) { + return Ok(true); + } + + if let Ok(children) = proxy.get_children().await { + stack.extend(children); + } + } + + Ok(false) +} + async fn log_accessibility_tree_async( windows: Vec, ) -> Result<(), Box> { diff --git a/tests/headless_control.rs b/tests/headless_control.rs index 98dd154..9f0c0d9 100644 --- a/tests/headless_control.rs +++ b/tests/headless_control.rs @@ -116,6 +116,14 @@ fn headless_control_socket_spawns_and_drives_real_gtk_client() { assert_eq!(send_text_command("spawn a11y-test"), "ok\n"); let with_client = wait_for_screenshot_change(&empty); assert_eq!(png_dimensions(&with_client), (CLIENT_WIDTH, CLIENT_HEIGHT)); + let saw_accessible = wait_for_accessible_term("Research Workspace"); + if std::env::var_os("HEARTHSPACE_REQUIRE_ATSPI").is_some() { + assert!(saw_accessible, "AT-SPI tree did not expose GTK test app"); + } else if !saw_accessible { + eprintln!( + "GTK test app did not appear on AT-SPI bus; set HEARTHSPACE_REQUIRE_ATSPI=1 to make this fatal" + ); + } for command in [ "pointer-motion-abs 420 260", @@ -186,6 +194,20 @@ fn wait_for_screenshot_change(previous: &[u8]) -> Vec { panic!("screenshot did not change after spawning GTK test client"); } +#[cfg(feature = "test-apps")] +fn wait_for_accessible_term(term: &str) -> bool { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if async_io::block_on(hearthspace::accessibility::accessibility_tree_contains_term(term)) + .expect("query AT-SPI tree") + { + return true; + } + thread::sleep(Duration::from_millis(100)); + } + false +} + fn read_line(stream: &mut UnixStream) -> String { let mut bytes = Vec::new(); loop { diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index ea7da23..fbcc8fa 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -148,7 +148,11 @@ in place: runs focused on the app under test. - `tests/headless_control.rs` is an ignored integration smoke test for the compositor-side protocol. It also has a `test-apps` feature-gated variant that - spawns and drives the in-repo GTK a11y client. Run it with + spawns the in-repo GTK a11y client and drives input/screenshot commands + against it. It probes for the client on the AT-SPI bus and treats that as an + opt-in assertion when `HEARTHSPACE_REQUIRE_ATSPI=1` is set, because the GTK + app currently renders under the headless compositor in this VM without + registering as an AT-SPI application root on the host bus. Run it with `cargo test --test headless_control -- --ignored` or `cargo test --features test-apps --test headless_control -- --ignored` on machines with surfaceless EGL support. @@ -179,7 +183,8 @@ in place: - [x] Add an ignored integration smoke test that drives input commands, captures a screenshot, and quits over the socket. - [x] Add an ignored, `test-apps`-gated smoke test that spawns the GTK a11y - client and drives pointer/key/screenshot commands against it. + client and drives pointer/key/screenshot commands against it. It includes + an opt-in AT-SPI assertion via `HEARTHSPACE_REQUIRE_ATSPI=1`. - [x] **Done when:** a script can drive a headless client end-to-end (move pointer, click, type, screenshot) over the socket. -- 2.51.2 From 373cf7311d7789222114e197cf2076aa1e2d74bd Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 01:14:16 -0700 Subject: [PATCH 12/22] feat: add WayDriver Hearthspace backend --- .gitignore | 2 +- CONTRIBUTING.md | 33 +- Cargo.lock | 838 ++++++++++++++++++++---- Cargo.toml | 4 + crates/waydriver-hearthspace/Cargo.toml | 14 + crates/waydriver-hearthspace/src/lib.rs | 522 +++++++++++++++ tests/waydriver_hearthspace.rs | 118 ++++ todos/WAYDRIVER.md | 23 +- 8 files changed, 1423 insertions(+), 131 deletions(-) create mode 100644 crates/waydriver-hearthspace/Cargo.toml create mode 100644 crates/waydriver-hearthspace/src/lib.rs create mode 100644 tests/waydriver_hearthspace.rs diff --git a/.gitignore b/.gitignore index 75836d1..04039d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/target/ +target/ opencode.json AGENTS.md .DS_Store \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d1c6a1..2ddaf7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,10 +34,27 @@ As such, while my CPU, GPU, and RAM are all quite spec'd up, the VM is not a per I develop Hearthspace on Ubuntu 26.04 LTS, and the following packages are required to build and run the compositor and shell: ```sh -sudo apt-get install -y build-essential cargo rustc rustfmt pkg-config clang libclang-dev libwayland-dev wayland-protocols wayland-utils libinput-dev libxkbcommon-dev libxkbcommon-x11-dev libudev-dev libseat-dev libgbm-dev libegl1-mesa-dev libgles2-mesa-dev libdrm-dev libsystemd-dev foot +sudo apt-get install -y build-essential cargo rustc rustfmt pkg-config clang libclang-dev libwayland-dev wayland-protocols wayland-utils libinput-dev libxkbcommon-dev libxkbcommon-x11-dev libudev-dev libseat-dev libgbm-dev libegl1-mesa-dev libgles2-mesa-dev libdrm-dev libsystemd-dev ``` -`foot` is installed as a small Wayland-native terminal for server-side decoration testing. +#### E2E Testing Dependencies + +For E2E testing, the WayDriver adapter depends on the published `waydriver` crate, which +links GStreamer even when the Hearthspace backend overrides screenshot capture. +Install the development packages before building tests that include +`waydriver-hearthspace`: + +```sh +sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev +``` + +### Optional Dependencies + +For testing, `foot` is installed as a small Wayland-native terminal for server-side decoration testing. + +``` +sudo apt-get install -y foot +``` ### Optional Test Apps @@ -114,6 +131,18 @@ Keyboard commands currently take Linux evdev key codes, not XKB keysyms. Pointer button commands use Linux input button codes, for example `272` (`0x110`) for the left mouse button. +The WayDriver backend adapter lives in `crates/waydriver-hearthspace` and uses +the published `waydriver` crate. Its ignored smoke tests can be run with: + +```sh +cargo test --test waydriver_hearthspace -- --ignored +cargo test --features test-apps --test waydriver_hearthspace -- --ignored +``` + +The full WayDriver `Session` XPath test is compiled with `test-apps` but only +exercises AT-SPI when `HEARTHSPACE_REQUIRE_ATSPI=1` is set, because the VM can +render the GTK test client without exposing it as an AT-SPI application root. + ### Xilem Fork (git dependency) The shell UI is built with [Xilem](https://github.com/linebender/xilem). Stock diff --git a/Cargo.lock b/Cargo.lock index 47bc9f5..aea04e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,8 +111,8 @@ dependencies = [ "accesskit_consumer 0.35.0", "hashbrown 0.16.1", "static_assertions", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -157,6 +157,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -206,6 +224,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + [[package]] name = "anymap3" version = "1.1.0" @@ -233,6 +257,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -251,6 +292,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -403,6 +453,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" +[[package]] +name = "atomic_refcell" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4" + [[package]] name = "atspi" version = "0.29.0" @@ -410,6 +466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" dependencies = [ "atspi-common 0.13.0", + "atspi-connection 0.13.0", "atspi-proxies 0.13.0", ] @@ -420,7 +477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bf601cccedfffec598ec2db1f9d6745885458bccc0e8916d7023f017c94b3d0" dependencies = [ "atspi-common 0.14.0", - "atspi-connection", + "atspi-connection 0.14.0", "atspi-proxies 0.14.0", ] @@ -456,6 +513,18 @@ dependencies = [ "zvariant", ] +[[package]] +name = "atspi-connection" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b28a59b7c709ce0f1196405e6eacee35424dad07b4b005c0f9e417d8c2116f" +dependencies = [ + "atspi-common 0.13.0", + "atspi-proxies 0.13.0", + "futures-lite", + "zbus", +] + [[package]] name = "atspi-connection" version = "0.14.0" @@ -496,6 +565,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey 0.1.1", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "base64" version = "0.22.1" @@ -517,6 +629,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -529,6 +647,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "bitvec" version = "1.1.1" @@ -578,6 +705,12 @@ dependencies = [ "piper", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -900,6 +1033,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1003,6 +1155,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "endi" version = "1.1.1" @@ -1030,6 +1188,26 @@ dependencies = [ "syn", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1082,12 +1260,33 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1202,8 +1401,8 @@ dependencies = [ "read-fonts", "roxmltree 0.21.1", "smallvec", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", "yeslogic-fontconfig-sys", ] @@ -1307,6 +1506,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -1406,7 +1611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1575,7 +1780,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.61.3", + "windows", ] [[package]] @@ -1652,6 +1857,99 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gstreamer" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28ca0c594cac4e86f5444aaa767c7bb810340c0710667a6467d3ead248e35e84" +dependencies = [ + "cfg-if", + "futures-channel", + "futures-core", + "futures-util", + "glib", + "gstreamer-sys", + "itertools", + "kstring", + "libc", + "muldiv", + "num-integer", + "num-rational", + "option-operations", + "pastey 0.2.3", + "pin-project-lite", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gstreamer-app" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97f8ae9238c2352398dcc084de28df3f7099af216ac6c160b52318d23f25c010" +dependencies = [ + "futures-core", + "futures-sink", + "glib", + "gstreamer", + "gstreamer-app-sys", + "gstreamer-base", + "libc", +] + +[[package]] +name = "gstreamer-app-sys" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a74a8211e5d7df2f45b612c284ddf56b92bdf4e879e8ed72e7c46dd0842e158" +dependencies = [ + "glib-sys", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-base" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c279df2918be97fb9570e589a32ade33598f643b0c4f0c92c17f06be6940574e" +dependencies = [ + "atomic_refcell", + "cfg-if", + "glib", + "gstreamer", + "gstreamer-base-sys", + "libc", +] + +[[package]] +name = "gstreamer-base-sys" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6569606feeb89cfcf95a6476a64a0f0aec83fadcef0e91c24e576f7851ceac3a" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-sys" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533fa8d28fc830eafccbcfcfddb390563ea5d3a351af2c3aab99e197e5f5b1ba" +dependencies = [ + "cfg-if", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "gtk4" version = "0.11.3" @@ -1784,7 +2082,11 @@ dependencies = [ "rstest", "smithay", "tempfile", + "tokio", + "tokio-util", "tracing-subscriber", + "waydriver", + "waydriver-hearthspace", "wayland-protocols", "wayland-server", "xilem", @@ -1949,9 +2251,20 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2016,6 +2329,12 @@ dependencies = [ "wgpu", ] +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "2.14.0" @@ -2026,6 +2345,26 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "jni" version = "0.22.4" @@ -2040,7 +2379,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2132,6 +2471,15 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2150,6 +2498,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.186" @@ -2174,6 +2528,16 @@ dependencies = [ "libdeflate-sys", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.8.9" @@ -2181,7 +2545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2256,6 +2620,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2357,6 +2730,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.2" @@ -2427,6 +2810,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muldiv" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" + [[package]] name = "naga" version = "28.0.0" @@ -2483,6 +2872,36 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2492,12 +2911,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2786,6 +3246,15 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-operations" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754" +dependencies = [ + "pastey 0.2.3", +] + [[package]] name = "orbclient" version = "0.3.55" @@ -2888,7 +3357,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2931,6 +3400,18 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "peniko" version = "0.6.1" @@ -2950,6 +3431,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "peresil" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57" + [[package]] name = "phf" version = "0.13.1" @@ -3199,6 +3686,15 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3292,12 +3788,82 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.37.0" @@ -3672,6 +4238,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3890,6 +4465,27 @@ dependencies = [ "siphasher", ] +[[package]] +name = "sxd-document" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d82f37be9faf1b10a82c4bd492b74f698e40082f0f40de38ab275f31d42078" +dependencies = [ + "peresil", + "typed-arena", +] + +[[package]] +name = "sxd-xpath" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e39da5d30887b5690e29de4c5ebb8ddff64ebd9933f98a01daaa4fd11b36ea" +dependencies = [ + "peresil", + "quick-error 1.2.3", + "sxd-document", +] + [[package]] name = "syn" version = "2.0.118" @@ -4008,6 +4604,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.51" @@ -4121,9 +4731,12 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -4151,6 +4764,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4307,6 +4933,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typed-arena" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d" + [[package]] name = "typenum" version = "1.20.1" @@ -4452,6 +5084,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4644,6 +5287,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "waydriver" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854c98e78696424ad6d61d8e3979e47c2ffd6e357c793c607cddde8e78c90eb4" +dependencies = [ + "async-trait", + "atspi 0.29.0", + "gstreamer", + "gstreamer-app", + "image", + "libc", + "sxd-document", + "sxd-xpath", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "zbus", +] + +[[package]] +name = "waydriver-hearthspace" +version = "0.1.0" +dependencies = [ + "async-trait", + "tempfile", + "tokio", + "tokio-util", + "uuid", + "waydriver", +] + [[package]] name = "wayland-backend" version = "0.3.15" @@ -4964,8 +5640,8 @@ dependencies = [ "wasm-bindgen", "web-sys", "wgpu-types", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -4990,38 +5666,16 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -5030,20 +5684,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -5054,20 +5695,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -5076,9 +5706,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -5103,45 +5733,20 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-core", + "windows-link", ] [[package]] @@ -5150,16 +5755,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -5168,7 +5764,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5195,7 +5791,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5214,22 +5810,13 @@ dependencies = [ "windows_x86_64_msvc", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5489,6 +6076,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yeslogic-fontconfig-sys" version = "6.0.1" @@ -5702,6 +6295,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index 19fc301..9b62aca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,10 @@ wayland-server = "0.31.9" proptest = "1" rstest = "0.26" tempfile = "3" +tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] } +tokio-util = "0.7" +waydriver = "0.3.9" +waydriver-hearthspace = { path = "crates/waydriver-hearthspace" } # Workaround for an upstream incompatibility pulled in via Xilem/Masonry: # `vello_shaders` 0.8's build script depends on `naga` 28 with the `wgsl-in` diff --git a/crates/waydriver-hearthspace/Cargo.toml b/crates/waydriver-hearthspace/Cargo.toml new file mode 100644 index 0000000..b34ab4f --- /dev/null +++ b/crates/waydriver-hearthspace/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "waydriver-hearthspace" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[dependencies] +async-trait = "0.1" +tempfile = "3" +tokio = { version = "1", features = ["io-util", "net", "process", "time"] } +tokio-util = "0.7" +uuid = { version = "1", features = ["v4"] } +waydriver = "0.3.9" diff --git a/crates/waydriver-hearthspace/src/lib.rs b/crates/waydriver-hearthspace/src/lib.rs new file mode 100644 index 0000000..c796a5a --- /dev/null +++ b/crates/waydriver-hearthspace/src/lib.rs @@ -0,0 +1,522 @@ +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; + +use async_trait::async_trait; +use tempfile::TempDir; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixStream, + process::{Child, Command}, +}; +use tokio_util::sync::CancellationToken; +use waydriver::{ + CaptureBackend, CompositorRuntime, Error, InputBackend, PipeWireStream, PointerAxis, + PointerButton, Result, StreamToken, backend::cancellable_tail, +}; + +const WAYLAND_DISPLAY: &str = "wayland-99"; +const COMMAND_SOCKET: &str = "hearthspace-shell.sock"; +const DEFAULT_RESOLUTION: &str = "1280x720"; +const INPUT_TAIL_DELAY: Duration = Duration::from_millis(30); +const PRESS_RELEASE_DELAY: Duration = Duration::from_millis(20); + +#[derive(Debug)] +pub struct HearthspaceState { + id: String, + runtime_dir: PathBuf, +} + +impl HearthspaceState { + pub fn id(&self) -> &str { + &self.id + } + + pub fn runtime_dir(&self) -> &Path { + &self.runtime_dir + } + + pub fn command_socket_path(&self) -> PathBuf { + self.runtime_dir.join(COMMAND_SOCKET) + } +} + +pub struct HearthspaceCompositor { + id: String, + binary: PathBuf, + runtime_dir: TempDir, + child: Option, + state: Option>, +} + +impl HearthspaceCompositor { + pub fn new(binary: impl Into) -> Result { + let id = uuid::Uuid::new_v4().to_string()[..8].to_string(); + let runtime_dir = tempfile::Builder::new() + .prefix(&format!("wd-hearthspace-{id}-")) + .tempdir() + .map_err(|e| Error::process_with("create runtime dir", e))?; + + Ok(Self { + id, + binary: binary.into(), + runtime_dir, + child: None, + state: None, + }) + } + + pub fn state(&self) -> Result> { + self.state + .clone() + .ok_or_else(|| Error::process("Hearthspace compositor has not been started")) + } + + fn command_socket_path(&self) -> PathBuf { + self.runtime_dir.path().join(COMMAND_SOCKET) + } +} + +#[async_trait] +impl CompositorRuntime for HearthspaceCompositor { + async fn start(&mut self, resolution: Option<&str>, scale: Option) -> Result<()> { + if self.child.is_some() { + return Ok(()); + } + + let resolution = resolution.unwrap_or(DEFAULT_RESOLUTION); + validate_resolution(resolution)?; + let scale = scale_to_integer(scale)?; + + let mut command = Command::new(&self.binary); + command + .args(["--headless", "--no-shell", "--headless-size", resolution]) + .env("XDG_RUNTIME_DIR", self.runtime_dir.path()) + .env("WAYLAND_DISPLAY", WAYLAND_DISPLAY) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + + if let Some(scale) = scale { + command.args(["--headless-scale", &scale.to_string()]); + } + + let child = command + .spawn() + .map_err(|e| Error::process_with("spawn hearthspace", e))?; + self.child = Some(child); + + let command_socket_path = self.command_socket_path(); + wait_for_socket(self.child.as_mut(), &command_socket_path).await?; + self.state = Some(Arc::new(HearthspaceState { + id: self.id.clone(), + runtime_dir: self.runtime_dir.path().to_path_buf(), + })); + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + let Some(mut child) = self.child.take() else { + return Ok(()); + }; + + if child + .try_wait() + .map_err(|e| Error::process_with("poll hearthspace", e))? + .is_none() + { + let _ = send_text_command(&self.command_socket_path(), "quit").await; + if tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .is_err() + { + child + .kill() + .await + .map_err(|e| Error::process_with("kill hearthspace", e))?; + let _ = child.wait().await; + } + } + + self.state = None; + Ok(()) + } + + fn id(&self) -> &str { + &self.id + } + + fn wayland_display(&self) -> &str { + WAYLAND_DISPLAY + } + + fn runtime_dir(&self) -> &Path { + self.runtime_dir.path() + } +} + +pub struct HearthspaceInput { + state: Arc, +} + +impl HearthspaceInput { + pub fn new(state: Arc) -> Self { + Self { state } + } + + async fn send(&self, command: impl AsRef) -> Result<()> { + send_text_command(&self.state.command_socket_path(), command.as_ref()).await?; + Ok(()) + } +} + +#[async_trait] +impl InputBackend for HearthspaceInput { + async fn press_keysym(&self, keysym: u32, cancel: &CancellationToken) -> Result<()> { + self.key_down(keysym, cancel).await?; + tokio::time::sleep(PRESS_RELEASE_DELAY).await; + self.key_up(keysym, cancel).await?; + cancellable_tail(INPUT_TAIL_DELAY, cancel).await; + Ok(()) + } + + async fn key_down(&self, keysym: u32, _cancel: &CancellationToken) -> Result<()> { + let keycode = keysym_to_evdev(keysym)?; + self.send(format!("key-down {keycode}")).await + } + + async fn key_up(&self, keysym: u32, _cancel: &CancellationToken) -> Result<()> { + let keycode = keysym_to_evdev(keysym)?; + self.send(format!("key-up {keycode}")).await + } + + async fn pointer_motion_relative( + &self, + dx: f64, + dy: f64, + _cancel: &CancellationToken, + ) -> Result<()> { + self.send(format!("pointer-motion-rel {dx} {dy}")).await + } + + async fn pointer_motion_absolute( + &self, + x: f64, + y: f64, + _cancel: &CancellationToken, + ) -> Result<()> { + self.send(format!("pointer-motion-abs {x} {y}")).await + } + + async fn pointer_button_down( + &self, + button: PointerButton, + _cancel: &CancellationToken, + ) -> Result<()> { + self.send(format!("pointer-button-down {}", button.evdev_code())) + .await + } + + async fn pointer_button_up( + &self, + button: PointerButton, + cancel: &CancellationToken, + ) -> Result<()> { + self.send(format!("pointer-button-up {}", button.evdev_code())) + .await?; + cancellable_tail(INPUT_TAIL_DELAY, cancel).await; + Ok(()) + } + + async fn pointer_axis_discrete( + &self, + axis: PointerAxis, + steps: i32, + cancel: &CancellationToken, + ) -> Result<()> { + let (horizontal, vertical) = match axis { + PointerAxis::Vertical => (0, steps * 120), + PointerAxis::Horizontal => (steps * 120, 0), + }; + self.send(format!("axis {horizontal} {vertical}")).await?; + cancellable_tail(INPUT_TAIL_DELAY, cancel).await; + Ok(()) + } +} + +pub struct HearthspaceCapture { + state: Arc, +} + +impl HearthspaceCapture { + pub fn new(state: Arc) -> Self { + Self { state } + } +} + +#[async_trait] +impl CaptureBackend for HearthspaceCapture { + async fn start_stream(&self) -> Result { + Ok(PipeWireStream { + node_id: 0, + token: StreamToken::new(()), + }) + } + + async fn stop_stream(&self, _stream: PipeWireStream) -> Result<()> { + Ok(()) + } + + fn pipewire_socket(&self) -> PathBuf { + self.state.runtime_dir().join("pipewire-0") + } + + async fn grab_screenshot(&self, _stream: &PipeWireStream) -> Result> { + take_screenshot(&self.state.command_socket_path()).await + } +} + +async fn wait_for_socket(child: Option<&mut Child>, path: &Path) -> Result<()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let mut child = child; + let mut last_error = None; + + while tokio::time::Instant::now() < deadline { + if let Some(child) = child.as_deref_mut() { + if let Some(status) = child + .try_wait() + .map_err(|e| Error::process_with("poll hearthspace", e))? + { + return Err(Error::process(format!( + "hearthspace exited before accepting commands: {status}" + ))); + } + } + + match UnixStream::connect(path).await { + Ok(_) => return Ok(()), + Err(error) => last_error = Some(error), + } + + tokio::time::sleep(Duration::from_millis(50)).await; + } + + Err(Error::Timeout(format!( + "timed out connecting to {}: {:?}", + path.display(), + last_error + ))) +} + +async fn send_text_command(path: &Path, command: &str) -> Result { + let mut stream = UnixStream::connect(path) + .await + .map_err(|e| Error::process_with("connect command socket", e))?; + stream + .write_all(format!("{command}\n").as_bytes()) + .await + .map_err(|e| Error::process_with("write command", e))?; + + let line = read_line(&mut stream).await?; + if line == "ok\n" || line.starts_with("ok ") { + Ok(line) + } else if let Some(message) = line.strip_prefix("err ") { + Err(Error::process(message.trim_end().to_string())) + } else { + Err(Error::process(format!("unexpected reply {line:?}"))) + } +} + +async fn take_screenshot(path: &Path) -> Result> { + let mut stream = UnixStream::connect(path) + .await + .map_err(|e| Error::screenshot_with("connect command socket", e))?; + stream + .write_all(b"screenshot\n") + .await + .map_err(|e| Error::screenshot_with("write command", e))?; + + let header = read_line(&mut stream).await?; + let mut parts = header.split_whitespace(); + match parts.next() { + Some("ok") => {} + Some("err") => { + let message = parts.collect::>().join(" "); + return Err(Error::screenshot(message)); + } + _ => return Err(Error::screenshot(format!("unexpected reply {header:?}"))), + } + let len = parts + .next() + .ok_or_else(|| Error::screenshot("missing screenshot byte length"))? + .parse::() + .map_err(|e| Error::screenshot_with("parse screenshot byte length", e))?; + if parts.next().is_some() { + return Err(Error::screenshot(format!("unexpected reply {header:?}"))); + } + + let mut payload = vec![0; len]; + stream + .read_exact(&mut payload) + .await + .map_err(|e| Error::screenshot_with("read screenshot payload", e))?; + Ok(payload) +} + +async fn read_line(stream: &mut UnixStream) -> Result { + let mut bytes = Vec::new(); + loop { + let mut byte = [0]; + stream + .read_exact(&mut byte) + .await + .map_err(|e| Error::process_with("read reply", e))?; + bytes.push(byte[0]); + if byte[0] == b'\n' { + break; + } + } + String::from_utf8(bytes).map_err(|e| Error::process_with("decode reply", e)) +} + +fn validate_resolution(resolution: &str) -> Result<()> { + let Some((width, height)) = resolution + .split_once('x') + .or_else(|| resolution.split_once('X')) + else { + return Err(Error::process(format!( + "invalid resolution {resolution:?}; expected WIDTHxHEIGHT" + ))); + }; + for (label, value) in [("width", width), ("height", height)] { + let value = value + .parse::() + .map_err(|e| Error::process_with(format!("parse {label}"), e))?; + if value <= 0 { + return Err(Error::process(format!("{label} must be positive"))); + } + } + Ok(()) +} + +fn scale_to_integer(scale: Option) -> Result> { + let Some(scale) = scale else { + return Ok(None); + }; + if !scale.is_finite() || scale <= 0.0 { + return Err(Error::process("scale must be a positive finite number")); + } + let rounded = scale.round(); + if (scale - rounded).abs() > f64::EPSILON { + return Err(Error::process(format!( + "Hearthspace headless scale must be an integer, got {scale}" + ))); + } + let scale = + i32::try_from(rounded as i64).map_err(|e| Error::process_with("convert scale", e))?; + Ok((scale != 1).then_some(scale)) +} + +fn keysym_to_evdev(keysym: u32) -> Result { + if let Ok(byte) = u8::try_from(keysym) { + if let Some(keycode) = ascii_keysym_to_evdev(byte) { + return Ok(keycode); + } + } + + let keycode = match keysym { + 0xff08 => 14, // BackSpace + 0xff09 => 15, // Tab + 0xff0d => 28, // Return + 0xff1b => 1, // Escape + 0xffe1 => 42, // Shift_L + 0xffe2 => 54, // Shift_R + 0xffe3 => 29, // Control_L + 0xffe4 => 97, // Control_R + 0xffe9 => 56, // Alt_L + 0xffea => 100, // Alt_R + _ => { + return Err(Error::process(format!( + "unsupported keysym 0x{keysym:x}; add a keysym-to-evdev mapping" + ))); + } + }; + Ok(keycode) +} + +fn ascii_keysym_to_evdev(keysym: u8) -> Option { + Some(match keysym.to_ascii_lowercase() { + b'a' => 30, + b'b' => 48, + b'c' => 46, + b'd' => 32, + b'e' => 18, + b'f' => 33, + b'g' => 34, + b'h' => 35, + b'i' => 23, + b'j' => 36, + b'k' => 37, + b'l' => 38, + b'm' => 50, + b'n' => 49, + b'o' => 24, + b'p' => 25, + b'q' => 16, + b'r' => 19, + b's' => 31, + b't' => 20, + b'u' => 22, + b'v' => 47, + b'w' => 17, + b'x' => 45, + b'y' => 21, + b'z' => 44, + b'1' => 2, + b'2' => 3, + b'3' => 4, + b'4' => 5, + b'5' => 6, + b'6' => 7, + b'7' => 8, + b'8' => 9, + b'9' => 10, + b'0' => 11, + b' ' => 57, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scale_must_be_integer() { + assert_eq!(scale_to_integer(None).unwrap(), None); + assert_eq!(scale_to_integer(Some(1.0)).unwrap(), None); + assert_eq!(scale_to_integer(Some(2.0)).unwrap(), Some(2)); + assert!(scale_to_integer(Some(1.5)).is_err()); + } + + #[test] + fn maps_common_keysyms_to_evdev() { + assert_eq!(keysym_to_evdev(b'a' as u32).unwrap(), 30); + assert_eq!(keysym_to_evdev(b'A' as u32).unwrap(), 30); + assert_eq!(keysym_to_evdev(0xff0d).unwrap(), 28); + assert!(keysym_to_evdev(0x2603).is_err()); + } + + #[test] + fn validates_resolution_shape() { + assert!(validate_resolution("800x600").is_ok()); + assert!(validate_resolution("800X600").is_ok()); + assert!(validate_resolution("800").is_err()); + assert!(validate_resolution("0x600").is_err()); + } +} diff --git a/tests/waydriver_hearthspace.rs b/tests/waydriver_hearthspace.rs new file mode 100644 index 0000000..e4013d4 --- /dev/null +++ b/tests/waydriver_hearthspace.rs @@ -0,0 +1,118 @@ +use std::{path::PathBuf, sync::Arc}; + +use tokio_util::sync::CancellationToken; +use waydriver::{CaptureBackend, CompositorRuntime, InputBackend, PointerAxis, PointerButton}; +#[cfg(feature = "test-apps")] +use waydriver::{Session, SessionConfig}; +use waydriver_hearthspace::{HearthspaceCapture, HearthspaceCompositor, HearthspaceInput}; + +static WAYDRIVER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[tokio::test] +#[ignore = "requires surfaceless EGL and the headless compositor socket"] +async fn waydriver_backends_drive_input_capture_and_teardown() { + let _guard = WAYDRIVER_TEST_LOCK.lock().await; + let mut compositor = HearthspaceCompositor::new(hearthspace_binary()).unwrap(); + compositor.start(Some("320x240"), Some(1.0)).await.unwrap(); + + let state = compositor.state().unwrap(); + let input = HearthspaceInput::new(Arc::clone(&state)); + let capture = HearthspaceCapture::new(state); + let cancel = CancellationToken::new(); + + input + .pointer_motion_absolute(10.0, 10.0, &cancel) + .await + .unwrap(); + input + .pointer_motion_relative(5.0, 5.0, &cancel) + .await + .unwrap(); + input + .pointer_button(PointerButton::Left, &cancel) + .await + .unwrap(); + input + .pointer_axis_discrete(PointerAxis::Vertical, -1, &cancel) + .await + .unwrap(); + input.press_keysym(b'a' as u32, &cancel).await.unwrap(); + + let stream = capture.start_stream().await.unwrap(); + let screenshot = capture.grab_screenshot(&stream).await.unwrap(); + assert!(screenshot.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(png_dimensions(&screenshot), (320, 240)); + capture.stop_stream(stream).await.unwrap(); + + compositor.stop().await.unwrap(); +} + +#[cfg(feature = "test-apps")] +#[tokio::test] +#[ignore = "requires AT-SPI exposure for the GTK test client under headless Hearthspace"] +async fn waydriver_session_locates_real_client_by_xpath() { + let _guard = WAYDRIVER_TEST_LOCK.lock().await; + if std::env::var_os("HEARTHSPACE_REQUIRE_ATSPI").is_none() { + eprintln!( + "skipping full WayDriver Session XPath test; set HEARTHSPACE_REQUIRE_ATSPI=1 to enable" + ); + return; + } + + let mut compositor = HearthspaceCompositor::new(hearthspace_binary()).unwrap(); + compositor.start(Some("800x600"), Some(1.0)).await.unwrap(); + + let state = compositor.state().unwrap(); + let input = Box::new(HearthspaceInput::new(Arc::clone(&state))); + let capture = Box::new(HearthspaceCapture::new(state)); + let compositor = Box::new(compositor); + let session = Arc::new( + Session::start( + compositor, + input, + capture, + SessionConfig { + command: hearthspace_binary().to_string_lossy().into_owned(), + args: vec!["--gtk-test-app".to_string()], + cwd: None, + app_name: "Hearthspace Research Demo".to_string(), + video_output: None, + video_bitrate: None, + video_fps: None, + prewarm_visual: false, + visual_region_tuning: Default::default(), + visual_text_tuning: Default::default(), + visual_click_tuning: Default::default(), + gsettings_isolated: true, + xdg_isolated: true, + extra_env: vec![("GDK_BACKEND".to_string(), "wayland".to_string())], + capture_external_effects: false, + }, + ) + .await + .unwrap(), + ); + + { + let header = session.locate("//*[@name='Research Workspace']"); + header.click().await.unwrap(); + } + let screenshot = session.take_screenshot().await.unwrap(); + assert_eq!(png_dimensions(&screenshot), (800, 600)); + let session = match Arc::try_unwrap(session) { + Ok(session) => session, + Err(_) => panic!("session still has live references"), + }; + session.kill().await.unwrap(); +} + +fn hearthspace_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_hearthspace")) +} + +fn png_dimensions(png: &[u8]) -> (u32, u32) { + assert!(png.len() >= 24); + let width = u32::from_be_bytes(png[16..20].try_into().expect("PNG width bytes")); + let height = u32::from_be_bytes(png[20..24].try_into().expect("PNG height bytes")); + (width, height) +} diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index fbcc8fa..baca260 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -188,13 +188,18 @@ in place: - [x] **Done when:** a script can drive a headless client end-to-end (move pointer, click, type, screenshot) over the socket. -### Phase 3 — WayDriver backend crates ⬜ +### Phase 3 — WayDriver backend crates ◩ -- [ ] Implement `HearthspaceCompositor` (`CompositorRuntime`), +- [x] Implement `HearthspaceCompositor` (`CompositorRuntime`), `HearthspaceInput` (`InputBackend`), `HearthspaceCapture` (`CaptureBackend`, overriding `grab_screenshot`/`take_screenshot` to bypass PipeWire). -- [ ] Wire them into a `Session` and stand up the first AT-SPI-driven E2E test - against a real client (e.g. the in-repo a11y test app). +- [x] Add an ignored WayDriver-trait smoke test that starts headless Hearthspace, + drives input, captures a PNG screenshot, and tears down through the + adapter. +- [x] Wire them into a `Session` and stand up the first AT-SPI-driven E2E test + against the in-repo a11y test app. The test is currently opt-in with + `HEARTHSPACE_REQUIRE_ATSPI=1` because the GTK client renders in this VM + but does not register as an AT-SPI application root on the host bus. - [ ] **Done when:** a `cargo test` E2E spins up headless Hearthspace, launches a client, locates a widget by XPath, clicks it, and asserts on the result. @@ -205,12 +210,10 @@ in place: ## Decisions -- **Where do the backend crates live?** Since the goal is testing Hearthspace - itself, keep `waydriver-{compositor,input,capture}-hearthspace` **in this - repo** (e.g. under `tests/` support crates or a workspace member), depending on - the upstream `waydriver` library crate. Avoids coupling our test harness to - WayDriver's release cadence. Upstreaming later stays possible (additive - siblings). +- **Where does the backend crate live?** Since the goal is testing Hearthspace + itself, keep the adapter in this repo as `crates/waydriver-hearthspace`, using + the published `waydriver` crate from crates.io for the shared traits. Upstreaming + later stays possible. - **Control-socket reply protocol.** Implemented as a minimal request/reply protocol over the existing Unix stream: `ok\n`, `err \n`, or `ok \n` for screenshots. This keeps the current shell -- 2.51.2 From b3974b97d35f203c470db60baf1914f77e25ce15 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 01:21:23 -0700 Subject: [PATCH 13/22] test: enable WayDriver XPath smoke test --- CONTRIBUTING.md | 6 +++--- tests/waydriver_hearthspace.rs | 23 +++++++++++++++-------- todos/WAYDRIVER.md | 8 +++----- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ddaf7b..40cdadb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,9 +139,9 @@ cargo test --test waydriver_hearthspace -- --ignored cargo test --features test-apps --test waydriver_hearthspace -- --ignored ``` -The full WayDriver `Session` XPath test is compiled with `test-apps` but only -exercises AT-SPI when `HEARTHSPACE_REQUIRE_ATSPI=1` is set, because the VM can -render the GTK test client without exposing it as an AT-SPI application root. +The feature-gated WayDriver `Session` test launches the GTK test app through +WayDriver, locates its `Research Workspace` heading by XPath on the AT-SPI tree, +clicks it, and captures a screenshot. ### Xilem Fork (git dependency) diff --git a/tests/waydriver_hearthspace.rs b/tests/waydriver_hearthspace.rs index e4013d4..58fb71a 100644 --- a/tests/waydriver_hearthspace.rs +++ b/tests/waydriver_hearthspace.rs @@ -6,11 +6,15 @@ use waydriver::{CaptureBackend, CompositorRuntime, InputBackend, PointerAxis, Po use waydriver::{Session, SessionConfig}; use waydriver_hearthspace::{HearthspaceCapture, HearthspaceCompositor, HearthspaceInput}; +#[cfg(feature = "test-apps")] +const GTK_TEST_APP_ACCESSIBLE_NAME: &str = "hearthspace-gtk-test-app"; + static WAYDRIVER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); #[tokio::test] #[ignore = "requires surfaceless EGL and the headless compositor socket"] async fn waydriver_backends_drive_input_capture_and_teardown() { + init_tracing(); let _guard = WAYDRIVER_TEST_LOCK.lock().await; let mut compositor = HearthspaceCompositor::new(hearthspace_binary()).unwrap(); compositor.start(Some("320x240"), Some(1.0)).await.unwrap(); @@ -49,15 +53,10 @@ async fn waydriver_backends_drive_input_capture_and_teardown() { #[cfg(feature = "test-apps")] #[tokio::test] -#[ignore = "requires AT-SPI exposure for the GTK test client under headless Hearthspace"] +#[ignore = "requires surfaceless EGL, GTK, and AT-SPI exposure"] async fn waydriver_session_locates_real_client_by_xpath() { + init_tracing(); let _guard = WAYDRIVER_TEST_LOCK.lock().await; - if std::env::var_os("HEARTHSPACE_REQUIRE_ATSPI").is_none() { - eprintln!( - "skipping full WayDriver Session XPath test; set HEARTHSPACE_REQUIRE_ATSPI=1 to enable" - ); - return; - } let mut compositor = HearthspaceCompositor::new(hearthspace_binary()).unwrap(); compositor.start(Some("800x600"), Some(1.0)).await.unwrap(); @@ -75,7 +74,9 @@ async fn waydriver_session_locates_real_client_by_xpath() { command: hearthspace_binary().to_string_lossy().into_owned(), args: vec!["--gtk-test-app".to_string()], cwd: None, - app_name: "Hearthspace Research Demo".to_string(), + // GTK exposes the AT-SPI application root using argv[0], not + // the window title or application id. + app_name: GTK_TEST_APP_ACCESSIBLE_NAME.to_string(), video_output: None, video_bitrate: None, video_fps: None, @@ -110,6 +111,12 @@ fn hearthspace_binary() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_hearthspace")) } +fn init_tracing() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); +} + fn png_dimensions(png: &[u8]) -> (u32, u32) { assert!(png.len() >= 24); let width = u32::from_be_bytes(png[16..20].try_into().expect("PNG width bytes")); diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index baca260..2de72a7 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -188,7 +188,7 @@ in place: - [x] **Done when:** a script can drive a headless client end-to-end (move pointer, click, type, screenshot) over the socket. -### Phase 3 — WayDriver backend crates ◩ +### Phase 3 — WayDriver backend crates ✅ - [x] Implement `HearthspaceCompositor` (`CompositorRuntime`), `HearthspaceInput` (`InputBackend`), `HearthspaceCapture` (`CaptureBackend`, @@ -197,10 +197,8 @@ in place: drives input, captures a PNG screenshot, and tears down through the adapter. - [x] Wire them into a `Session` and stand up the first AT-SPI-driven E2E test - against the in-repo a11y test app. The test is currently opt-in with - `HEARTHSPACE_REQUIRE_ATSPI=1` because the GTK client renders in this VM - but does not register as an AT-SPI application root on the host bus. -- [ ] **Done when:** a `cargo test` E2E spins up headless Hearthspace, launches a + against the in-repo a11y test app. +- [x] **Done when:** a `cargo test` E2E spins up headless Hearthspace, launches a client, locates a widget by XPath, clicks it, and asserts on the result. ### Phase 4 — optional follow-ups ⬜ -- 2.51.2 From 54fbd9d0ca28298665707a8b1402e2d074847be4 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 01:36:48 -0700 Subject: [PATCH 14/22] test: confirm shell AT-SPI XPath lookup --- CONTRIBUTING.md | 8 +++ Cargo.lock | 1 + Cargo.toml | 2 +- crates/waydriver-hearthspace/src/lib.rs | 13 +++- tests/waydriver_hearthspace.rs | 94 ++++++++++++++++++++++--- todos/WAYDRIVER.md | 6 +- 6 files changed, 110 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40cdadb..cbee74c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,6 +143,14 @@ The feature-gated WayDriver `Session` test launches the GTK test app through WayDriver, locates its `Research Workspace` heading by XPath on the AT-SPI tree, clicks it, and captures a screenshot. +The non-feature WayDriver ignored suite also verifies the Xilem shell's +Masonry/AccessKit tree. That test enables `org.a11y.Status.ScreenReaderEnabled` +for its duration because AccessKit's Unix bridge registers with AT-SPI only while +screen-reader status is active. + +Run the WayDriver ignored suites sequentially rather than as separate concurrent +`cargo test` processes, since `ScreenReaderEnabled` is a session-bus global. + ### Xilem Fork (git dependency) The shell UI is built with [Xilem](https://github.com/linebender/xilem). Stock diff --git a/Cargo.lock b/Cargo.lock index aea04e7..6a04549 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,6 +479,7 @@ dependencies = [ "atspi-common 0.14.0", "atspi-connection 0.14.0", "atspi-proxies 0.14.0", + "zbus", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9b62aca..1393db2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ test-apps = ["dep:gtk"] [dependencies] async-io = "2.3.3" -atspi = "0.30.0" +atspi = { version = "0.30.0", features = ["zbus"] } gtk = { package = "gtk4", version = "0.11", optional = true } # Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our # linebender/xilem fork branch backing PR linebender/xilem#1830, which adds diff --git a/crates/waydriver-hearthspace/src/lib.rs b/crates/waydriver-hearthspace/src/lib.rs index c796a5a..49c12cc 100644 --- a/crates/waydriver-hearthspace/src/lib.rs +++ b/crates/waydriver-hearthspace/src/lib.rs @@ -47,6 +47,7 @@ impl HearthspaceState { pub struct HearthspaceCompositor { id: String, binary: PathBuf, + start_shell: bool, runtime_dir: TempDir, child: Option, state: Option>, @@ -63,12 +64,18 @@ impl HearthspaceCompositor { Ok(Self { id, binary: binary.into(), + start_shell: false, runtime_dir, child: None, state: None, }) } + pub fn with_shell(mut self) -> Self { + self.start_shell = true; + self + } + pub fn state(&self) -> Result> { self.state .clone() @@ -93,7 +100,7 @@ impl CompositorRuntime for HearthspaceCompositor { let mut command = Command::new(&self.binary); command - .args(["--headless", "--no-shell", "--headless-size", resolution]) + .args(["--headless", "--headless-size", resolution]) .env("XDG_RUNTIME_DIR", self.runtime_dir.path()) .env("WAYLAND_DISPLAY", WAYLAND_DISPLAY) .stdin(Stdio::null()) @@ -101,6 +108,10 @@ impl CompositorRuntime for HearthspaceCompositor { .stderr(Stdio::null()) .kill_on_drop(true); + if !self.start_shell { + command.arg("--no-shell"); + } + if let Some(scale) = scale { command.args(["--headless-scale", &scale.to_string()]); } diff --git a/tests/waydriver_hearthspace.rs b/tests/waydriver_hearthspace.rs index 58fb71a..967cfef 100644 --- a/tests/waydriver_hearthspace.rs +++ b/tests/waydriver_hearthspace.rs @@ -2,12 +2,12 @@ use std::{path::PathBuf, sync::Arc}; use tokio_util::sync::CancellationToken; use waydriver::{CaptureBackend, CompositorRuntime, InputBackend, PointerAxis, PointerButton}; -#[cfg(feature = "test-apps")] use waydriver::{Session, SessionConfig}; use waydriver_hearthspace::{HearthspaceCapture, HearthspaceCompositor, HearthspaceInput}; #[cfg(feature = "test-apps")] const GTK_TEST_APP_ACCESSIBLE_NAME: &str = "hearthspace-gtk-test-app"; +const SHELL_ACCESSIBLE_NAME: &str = "hearthspace"; static WAYDRIVER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -51,6 +51,50 @@ async fn waydriver_backends_drive_input_capture_and_teardown() { compositor.stop().await.unwrap(); } +#[tokio::test] +#[ignore = "requires surfaceless EGL and shell AT-SPI exposure"] +async fn waydriver_session_locates_xilem_shell_by_xpath() { + init_tracing(); + let _guard = WAYDRIVER_TEST_LOCK.lock().await; + let mut compositor = HearthspaceCompositor::new(hearthspace_binary()) + .unwrap() + .with_shell(); + compositor.start(Some("800x600"), Some(1.0)).await.unwrap(); + let previous_screen_reader_enabled = set_screen_reader_enabled(true).await.unwrap(); + + let state = compositor.state().unwrap(); + let input = Box::new(HearthspaceInput::new(Arc::clone(&state))); + let capture = Box::new(HearthspaceCapture::new(state)); + let compositor = Box::new(compositor); + let session = Arc::new( + Session::start( + compositor, + input, + capture, + session_config("true", vec![], SHELL_ACCESSIBLE_NAME), + ) + .await + .unwrap(), + ); + + { + let pan_left = session.locate("//*[@name='LEFT']").first(); + assert_eq!(pan_left.name().await.unwrap().as_deref(), Some("LEFT")); + let bounds = pan_left.bounds().await.unwrap(); + assert!(bounds.width > 0); + assert!(bounds.height > 0); + } + + let session = match Arc::try_unwrap(session) { + Ok(session) => session, + Err(_) => panic!("session still has live references"), + }; + session.kill().await.unwrap(); + set_screen_reader_enabled(previous_screen_reader_enabled) + .await + .unwrap(); +} + #[cfg(feature = "test-apps")] #[tokio::test] #[ignore = "requires surfaceless EGL, GTK, and AT-SPI exposure"] @@ -77,17 +121,8 @@ async fn waydriver_session_locates_real_client_by_xpath() { // GTK exposes the AT-SPI application root using argv[0], not // the window title or application id. app_name: GTK_TEST_APP_ACCESSIBLE_NAME.to_string(), - video_output: None, - video_bitrate: None, - video_fps: None, - prewarm_visual: false, - visual_region_tuning: Default::default(), - visual_text_tuning: Default::default(), - visual_click_tuning: Default::default(), - gsettings_isolated: true, - xdg_isolated: true, extra_env: vec![("GDK_BACKEND".to_string(), "wayland".to_string())], - capture_external_effects: false, + ..session_config("", vec![], "") }, ) .await @@ -111,12 +146,49 @@ fn hearthspace_binary() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_hearthspace")) } +fn session_config( + command: impl Into, + args: Vec, + app_name: impl Into, +) -> SessionConfig { + SessionConfig { + command: command.into(), + args, + cwd: None, + app_name: app_name.into(), + video_output: None, + video_bitrate: None, + video_fps: None, + prewarm_visual: false, + visual_region_tuning: Default::default(), + visual_text_tuning: Default::default(), + visual_click_tuning: Default::default(), + gsettings_isolated: true, + xdg_isolated: true, + extra_env: vec![], + capture_external_effects: false, + } +} + fn init_tracing() { let _ = tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .try_init(); } +async fn set_screen_reader_enabled(enabled: bool) -> Result> { + let connection = atspi::zbus::Connection::session().await?; + let status = atspi::proxy::bus::StatusProxy::new(&connection).await?; + let previous = status.screen_reader_enabled().await?; + if previous == enabled { + // AccessKit's Unix bridge reacts to property-change signals, so force a + // transition even if the host already had the requested value. + status.set_screen_reader_enabled(!enabled).await?; + } + status.set_screen_reader_enabled(enabled).await?; + Ok(previous) +} + fn png_dimensions(png: &[u8]) -> (u32, u32) { assert!(png.len() >= 24); let width = u32::from_be_bytes(png[16..20].try_into().expect("PNG width bytes")); diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 2de72a7..6a6002d 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -204,7 +204,11 @@ in place: ### Phase 4 — optional follow-ups ⬜ - [ ] Video recording (PipeWire) if needed for CI artifacts. -- [ ] Confirm the Xilem shell's Masonry/AccessKit AT-SPI tree is XPath-locatable at runtime. +- [x] Confirm the Xilem shell's Masonry/AccessKit AT-SPI tree is XPath-locatable at runtime. + `tests/waydriver_hearthspace.rs` starts headless Hearthspace with the shell, + toggles `org.a11y.Status.ScreenReaderEnabled` so AccessKit registers on + AT-SPI, targets the shell app root (`hearthspace`), and locates the `LEFT` + shell button by XPath. ## Decisions -- 2.51.2 From a58a44d4ab91be1c036de135117cee443e875e4d Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 01:47:57 -0700 Subject: [PATCH 15/22] test: restore AT-SPI status after shell lookup --- CONTRIBUTING.md | 3 +- tests/waydriver_hearthspace.rs | 63 +++++++++++++++++++--------------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbee74c..4793169 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -146,7 +146,8 @@ clicks it, and captures a screenshot. The non-feature WayDriver ignored suite also verifies the Xilem shell's Masonry/AccessKit tree. That test enables `org.a11y.Status.ScreenReaderEnabled` for its duration because AccessKit's Unix bridge registers with AT-SPI only while -screen-reader status is active. +screen-reader status is active; AT-SPI does not provide a per-application version +of that switch on the host session bus. Run the WayDriver ignored suites sequentially rather than as separate concurrent `cargo test` processes, since `ScreenReaderEnabled` is a session-bus global. diff --git a/tests/waydriver_hearthspace.rs b/tests/waydriver_hearthspace.rs index 967cfef..0f83dd9 100644 --- a/tests/waydriver_hearthspace.rs +++ b/tests/waydriver_hearthspace.rs @@ -53,16 +53,21 @@ async fn waydriver_backends_drive_input_capture_and_teardown() { #[tokio::test] #[ignore = "requires surfaceless EGL and shell AT-SPI exposure"] -async fn waydriver_session_locates_xilem_shell_by_xpath() { +async fn waydriver_session_locates_xilem_shell_by_xpath() -> Result<(), Box> +{ init_tracing(); let _guard = WAYDRIVER_TEST_LOCK.lock().await; - let mut compositor = HearthspaceCompositor::new(hearthspace_binary()) - .unwrap() - .with_shell(); - compositor.start(Some("800x600"), Some(1.0)).await.unwrap(); - let previous_screen_reader_enabled = set_screen_reader_enabled(true).await.unwrap(); + let previous_screen_reader_enabled = set_screen_reader_enabled(true).await?; + let result = run_xilem_shell_xpath_check().await; + set_screen_reader_enabled(previous_screen_reader_enabled).await?; + result +} - let state = compositor.state().unwrap(); +async fn run_xilem_shell_xpath_check() -> Result<(), Box> { + let mut compositor = HearthspaceCompositor::new(hearthspace_binary())?.with_shell(); + compositor.start(Some("800x600"), Some(1.0)).await?; + + let state = compositor.state()?; let input = Box::new(HearthspaceInput::new(Arc::clone(&state))); let capture = Box::new(HearthspaceCapture::new(state)); let compositor = Box::new(compositor); @@ -73,26 +78,33 @@ async fn waydriver_session_locates_xilem_shell_by_xpath() { capture, session_config("true", vec![], SHELL_ACCESSIBLE_NAME), ) - .await - .unwrap(), + .await?, ); - { + let check_result = async { let pan_left = session.locate("//*[@name='LEFT']").first(); - assert_eq!(pan_left.name().await.unwrap().as_deref(), Some("LEFT")); - let bounds = pan_left.bounds().await.unwrap(); - assert!(bounds.width > 0); - assert!(bounds.height > 0); + let name = pan_left.name().await?; + if name.as_deref() != Some("LEFT") { + return Err(std::io::Error::other(format!( + "expected LEFT shell control, got {name:?}" + )) + .into()); + } + let bounds = pan_left.bounds().await?; + if bounds.width <= 0 || bounds.height <= 0 { + return Err(std::io::Error::other(format!( + "LEFT shell control has invalid bounds {bounds:?}" + )) + .into()); + } + Ok(()) } + .await; - let session = match Arc::try_unwrap(session) { - Ok(session) => session, - Err(_) => panic!("session still has live references"), - }; - session.kill().await.unwrap(); - set_screen_reader_enabled(previous_screen_reader_enabled) - .await - .unwrap(); + let session = Arc::try_unwrap(session) + .map_err(|_| std::io::Error::other("session still has live references"))?; + session.kill().await?; + check_result } #[cfg(feature = "test-apps")] @@ -180,12 +192,9 @@ async fn set_screen_reader_enabled(enabled: bool) -> Result Date: Sat, 27 Jun 2026 01:54:01 -0700 Subject: [PATCH 16/22] test: isolate shell AT-SPI bus --- CONTRIBUTING.md | 11 ++--- tests/waydriver_hearthspace.rs | 84 +++++++++++++++++++++++++++++----- todos/WAYDRIVER.md | 6 +-- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4793169..e876d49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,13 +144,10 @@ WayDriver, locates its `Research Workspace` heading by XPath on the AT-SPI tree, clicks it, and captures a screenshot. The non-feature WayDriver ignored suite also verifies the Xilem shell's -Masonry/AccessKit tree. That test enables `org.a11y.Status.ScreenReaderEnabled` -for its duration because AccessKit's Unix bridge registers with AT-SPI only while -screen-reader status is active; AT-SPI does not provide a per-application version -of that switch on the host session bus. - -Run the WayDriver ignored suites sequentially rather than as separate concurrent -`cargo test` processes, since `ScreenReaderEnabled` is a session-bus global. +Masonry/AccessKit tree. That test starts a private `dbus-daemon --session`, points +Hearthspace and WayDriver at it, and enables `org.a11y.Status.ScreenReaderEnabled` +inside that throwaway bus because AccessKit's Unix bridge registers with AT-SPI +only while screen-reader status is active. ### Xilem Fork (git dependency) diff --git a/tests/waydriver_hearthspace.rs b/tests/waydriver_hearthspace.rs index 0f83dd9..3abd5c7 100644 --- a/tests/waydriver_hearthspace.rs +++ b/tests/waydriver_hearthspace.rs @@ -1,4 +1,10 @@ -use std::{path::PathBuf, sync::Arc}; +use std::{ + ffi::OsString, + io::BufRead, + path::PathBuf, + process::{Child, Command, Stdio}, + sync::Arc, +}; use tokio_util::sync::CancellationToken; use waydriver::{CaptureBackend, CompositorRuntime, InputBackend, PointerAxis, PointerButton}; @@ -57,10 +63,9 @@ async fn waydriver_session_locates_xilem_shell_by_xpath() -> Result<(), Box Result<(), Box> { @@ -188,14 +193,71 @@ fn init_tracing() { .try_init(); } -async fn set_screen_reader_enabled(enabled: bool) -> Result> { +struct PrivateSessionBus { + previous_address: Option, + child: Child, +} + +impl PrivateSessionBus { + fn start() -> Result> { + let previous_address = std::env::var_os("DBUS_SESSION_BUS_ADDRESS"); + let mut child = Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| std::io::Error::other("dbus-daemon stdout was not piped"))?; + let mut reader = std::io::BufReader::new(stdout); + let mut address = String::new(); + reader.read_line(&mut address)?; + let address = address.trim(); + if address.is_empty() { + return Err(std::io::Error::other("dbus-daemon did not print an address").into()); + } + + // SAFETY: these ignored WayDriver tests are serialized by + // WAYDRIVER_TEST_LOCK before this guard is created. The env var is + // restored in Drop before the lock is released, so no other test in this + // process observes the private bus address. + unsafe { + std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address); + } + + Ok(Self { + previous_address, + child, + }) + } +} + +impl Drop for PrivateSessionBus { + fn drop(&mut self) { + match &self.previous_address { + Some(address) => { + // SAFETY: see PrivateSessionBus::start; the same test lock is + // still held while this guard is dropped. + unsafe { std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address) }; + } + None => { + // SAFETY: see PrivateSessionBus::start; the same test lock is + // still held while this guard is dropped. + unsafe { std::env::remove_var("DBUS_SESSION_BUS_ADDRESS") }; + } + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +async fn set_screen_reader_enabled(enabled: bool) -> Result<(), Box> { let connection = atspi::zbus::Connection::session().await?; let status = atspi::proxy::bus::StatusProxy::new(&connection).await?; - let previous = status.screen_reader_enabled().await?; - if previous != enabled { - status.set_screen_reader_enabled(enabled).await?; - } - Ok(previous) + status.set_screen_reader_enabled(enabled).await?; + Ok(()) } fn png_dimensions(png: &[u8]) -> (u32, u32) { diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md index 6a6002d..c0a435e 100644 --- a/todos/WAYDRIVER.md +++ b/todos/WAYDRIVER.md @@ -206,9 +206,9 @@ in place: - [ ] Video recording (PipeWire) if needed for CI artifacts. - [x] Confirm the Xilem shell's Masonry/AccessKit AT-SPI tree is XPath-locatable at runtime. `tests/waydriver_hearthspace.rs` starts headless Hearthspace with the shell, - toggles `org.a11y.Status.ScreenReaderEnabled` so AccessKit registers on - AT-SPI, targets the shell app root (`hearthspace`), and locates the `LEFT` - shell button by XPath. + runs it under a private session bus, toggles `org.a11y.Status.ScreenReaderEnabled` + on that throwaway bus so AccessKit registers on AT-SPI, targets the shell + app root (`hearthspace`), and locates the `LEFT` shell button by XPath. ## Decisions -- 2.51.2 From d04fdb0ef7ace2e056a98b512f80d8272972ecee Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 01:59:11 -0700 Subject: [PATCH 17/22] docs: document E2E testing architecture --- CONTRIBUTING.md | 3 + docs/E2E_TESTING.md | 230 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 docs/E2E_TESTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e876d49..497002a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,6 +149,9 @@ Hearthspace and WayDriver at it, and enables `org.a11y.Status.ScreenReaderEnable inside that throwaway bus because AccessKit's Unix bridge registers with AT-SPI only while screen-reader status is active. +See [docs/E2E_TESTING.md](./docs/E2E_TESTING.md) for the evergreen technical +architecture of the headless and WayDriver E2E harness. + ### Xilem Fork (git dependency) The shell UI is built with [Xilem](https://github.com/linebender/xilem). Stock diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md new file mode 100644 index 0000000..b3fdc15 --- /dev/null +++ b/docs/E2E_TESTING.md @@ -0,0 +1,230 @@ +# E2E Testing + +Hearthspace's end-to-end tests run the real compositor, real Wayland clients, +real input routing, real framebuffer capture, and real AT-SPI accessibility +trees. The current harness is built around +[WayDriver](https://crates.io/crates/waydriver), with a Hearthspace-specific +backend adapter in this repository. + +This document is the evergreen architecture reference for the E2E harness. Keep +it updated when changing headless runtime flags, the command socket protocol, +WayDriver adapter behavior, accessibility exposure, or E2E test layout. + +## Goals + +- Start Hearthspace without a physical display. +- Launch real Wayland clients inside the compositor. +- Drive keyboard, pointer, button, and scroll input through Smithay's seat. +- Capture screenshots from the compositor framebuffer. +- Locate and interact with UI through AT-SPI/XPath where possible. +- Keep test runtime state isolated from the developer's desktop session. + +## Components + +### Headless Compositor + +`hearthspace --headless` starts the same Smithay compositor state used by the +nested winit backend, but renders to a surfaceless EGL/GLES offscreen target. +It advertises a synthetic Smithay `Output` and opens the deterministic Wayland +socket `wayland-99` inside `XDG_RUNTIME_DIR`. + +Runtime flags: + +```sh +hearthspace --headless +hearthspace --headless --headless-size 1280x720 +hearthspace --headless --headless-scale 2 +hearthspace --headless --no-shell +``` + +`--headless-size WIDTHxHEIGHT` configures the physical framebuffer size. +`--headless-scale INTEGER` configures the advertised Wayland scale. `--no-shell` +skips the Xilem shell client, which is useful for app-focused tests. + +The implementation lives in `src/compositor/mod.rs`; CLI parsing lives in +`src/main.rs`; shared defaults and names live in `src/config.rs`. + +### Control Socket + +The compositor exposes a Unix stream control socket named +`hearthspace-shell.sock` in `XDG_RUNTIME_DIR`. Shell clients receive this path in +`HEARTHSPACE_COMMAND_SOCKET`, and tests can connect to it directly. + +Requests are newline-terminated UTF-8 commands. Replies are: + +```text +ok\n +err \n +ok \n +``` + +Supported test-driving commands: + +```text +key-down +key-up +pointer-motion-abs +pointer-motion-rel +pointer-button-down +pointer-button-up +axis +screenshot +quit +``` + +Keyboard commands currently accept Linux evdev key codes. The compositor adds +Smithay's expected XKB offset internally. Pointer button commands use Linux input +button codes, for example `272` (`0x110`) for the left mouse button. + +Screenshots are direct GLES framebuffer readbacks encoded as PNG and returned as +`ok \n`. Continuous video is not implemented; screenshot +capture is the supported E2E capture path. + +The parser and command model live in `src/shell/command.rs`; socket handling and +command execution live in `src/compositor/shell_integration.rs`; synthetic input +lives in `src/compositor/input.rs`. + +### WayDriver Adapter + +The crate `crates/waydriver-hearthspace` implements the published WayDriver +traits: + +- `HearthspaceCompositor`: implements `CompositorRuntime` by spawning the + `hearthspace` binary with `--headless`, an isolated `XDG_RUNTIME_DIR`, and the + requested resolution/scale. +- `HearthspaceInput`: implements `InputBackend` by translating WayDriver calls + into control-socket input commands. +- `HearthspaceCapture`: implements `CaptureBackend` by overriding screenshot + capture to call the control socket instead of PipeWire/GStreamer. + +The adapter defaults to `--no-shell` so app-focused sessions only contain the +client under test. Use `HearthspaceCompositor::with_shell()` for tests that need +the Xilem shell chrome. + +WayDriver accepts X11 keysyms at the trait boundary. The adapter currently maps +common ASCII keys and a small set of control keysyms to evdev key codes before +sending control-socket commands. Add mappings in the adapter when a test needs +more keys, or add compositor-side keysym handling if that becomes preferable. + +## Accessibility Strategy + +WayDriver locates elements through AT-SPI and XPath. It does not locate through +the compositor protocol. This is intentional: E2E tests double as accessibility +regression tests for both client applications and shell chrome. + +### Client Apps + +Client applications launched by `Session::start` use the WayDriver-provided +Wayland display/runtime directory and the current D-Bus session bus. The +feature-gated smoke test launches the in-repo GTK test app (`--gtk-test-app`), +waits for its AT-SPI application root (`hearthspace-gtk-test-app`), locates the +`Research Workspace` heading by XPath, clicks it, and captures a screenshot. + +GTK exposes the test app's AT-SPI application root using `argv[0]`, not the +window title or application id. The current root name is +`hearthspace-gtk-test-app`. + +### Xilem Shell + +The shell is a Xilem/Masonry Wayland client. Masonry emits an AccessKit tree, +and AccessKit's Unix bridge exposes that tree on AT-SPI. On Unix, AccessKit only +registers with AT-SPI while `org.a11y.Status.ScreenReaderEnabled` is active. + +The shell XPath test avoids touching the developer's host accessibility state by +starting a private `dbus-daemon --session`, temporarily setting +`DBUS_SESSION_BUS_ADDRESS` for the serialized test scope, enabling +`ScreenReaderEnabled` inside that private bus, and then launching headless +Hearthspace with the shell enabled. The private bus is killed and the environment +is restored when the test ends. + +Under AccessKit, the shell's AT-SPI application root is the executable name +`hearthspace`. The smoke test locates the `LEFT` shell control by XPath and +asserts that it has a non-empty bounding box. + +## Test Files + +`tests/headless_control.rs` is a lower-level integration smoke test for the +control socket. It starts `hearthspace --headless`, sends input commands, +captures a screenshot, and quits. With `--features test-apps`, it also asks the +compositor to spawn the GTK test app and drives input/screenshot commands +against it. This test intentionally exercises the compositor-side protocol +without using WayDriver. + +`tests/waydriver_hearthspace.rs` exercises the WayDriver adapter and full +WayDriver `Session` path: + +- `waydriver_backends_drive_input_capture_and_teardown` starts headless + Hearthspace, drives WayDriver input calls, captures a PNG through + `HearthspaceCapture`, and tears down. +- `waydriver_session_locates_xilem_shell_by_xpath` starts headless Hearthspace + with the Xilem shell under a private D-Bus/AT-SPI session and locates the shell + `LEFT` control by XPath. +- `waydriver_session_locates_real_client_by_xpath` is gated by `test-apps`; it + launches the GTK test app through WayDriver, locates `Research Workspace` by + XPath, clicks it, and captures a screenshot. + +The ignored E2E tests are serialized inside each test binary with a static Tokio +mutex because they share deterministic socket names and, for the shell test, +temporarily modify process environment variables. + +## Running Tests + +Normal tests and lint: + +```sh +cargo test +cargo clippy --all-targets +``` + +Headless control socket smoke tests: + +```sh +cargo test --test headless_control -- --ignored +cargo test --features test-apps --test headless_control -- --ignored +``` + +WayDriver smoke tests: + +```sh +cargo test --test waydriver_hearthspace -- --ignored +cargo test --features test-apps --test waydriver_hearthspace -- --ignored +``` + +When debugging AT-SPI discovery, enable WayDriver logs: + +```sh +RUST_LOG=waydriver=debug cargo test --features test-apps --test waydriver_hearthspace -- --ignored --nocapture +``` + +## System Dependencies + +The published `waydriver` crate links GStreamer even though the Hearthspace +adapter overrides screenshot capture. Install the development packages before +building tests that include `waydriver-hearthspace`: + +```sh +sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev +``` + +The GTK test app requires GTK 4 development headers and the `test-apps` cargo +feature: + +```sh +sudo apt-get install -y libgtk-4-dev +cargo test --features test-apps +``` + +The shell AT-SPI test requires `dbus-daemon` and a working `org.a11y.Bus` D-Bus +service, normally provided by `dbus` and `at-spi2-core` packages on desktop Linux +systems. + +## Current Limits + +- Continuous video capture is not implemented for Hearthspace's adapter. Use PNG + screenshots for assertions and artifacts. +- The control socket protocol is intentionally small and line-oriented. It is not + a general binary RPC protocol. +- Keyboard input maps only the keysyms currently needed by tests. Add mappings as + tests require them. +- Ignored E2E tests require surfaceless EGL support. They are not part of the + default `cargo test` run. -- 2.51.2 From 6affc91d06828991a991b3c9adc694b4a2471cba Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 02:23:24 -0700 Subject: [PATCH 18/22] refactor: split compositor modules --- src/compositor/handlers.rs | 406 +++++++++++++++++++++++++ src/compositor/mod.rs | 416 +------------------------- src/compositor/windows.rs | 451 +--------------------------- src/compositor/windows/geometry.rs | 465 +++++++++++++++++++++++++++++ 4 files changed, 896 insertions(+), 842 deletions(-) create mode 100644 src/compositor/handlers.rs create mode 100644 src/compositor/windows/geometry.rs diff --git a/src/compositor/handlers.rs b/src/compositor/handlers.rs new file mode 100644 index 0000000..9271fb1 --- /dev/null +++ b/src/compositor/handlers.rs @@ -0,0 +1,406 @@ +use std::os::unix::io::OwnedFd; + +use smithay::{ + backend::allocator::dmabuf::Dmabuf, + backend::renderer::utils::on_commit_buffer_handler, + desktop::{ + PopupKeyboardGrab, PopupKind, PopupPointerGrab, PopupUngrabStrategy, + find_popup_root_surface, + }, + input::{ + Seat, SeatHandler, + pointer::{CursorImageStatus, Focus}, + }, + reexports::{calloop::Interest, wayland_server::protocol::wl_seat}, + utils::{Serial, Size}, + wayland::{ + buffer::BufferHandler, + compositor::{ + BufferAssignment, CompositorClientState, CompositorHandler, CompositorState, + SurfaceAttributes, add_blocker, add_pre_commit_hook, with_states, + }, + dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier, get_dmabuf}, + output::OutputHandler, + selection::{ + SelectionHandler, + data_device::{ + ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, + }, + }, + shell::xdg::{ + PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, + decoration::XdgDecorationHandler, + }, + shm::{ShmHandler, ShmState}, + }, +}; +use wayland_protocols::xdg::{ + decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode, + shell::server::xdg_toplevel, +}; +use wayland_server::{ + Client, Resource, + protocol::{wl_buffer, wl_surface::WlSurface}, +}; + +use super::{ + App, ClientState, DragState, ManagedWindow, ManagedWindowKind, WindowDecoration, + idle::ActivityReason, + windows::{ + ResizeEdges, decoration_for_new_window, position_for_new_window, window_kind_for_toplevel, + }, +}; + +impl BufferHandler for App { + fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {} +} + +impl XdgShellHandler for App { + fn xdg_shell_state(&mut self) -> &mut XdgShellState { + &mut self.xdg_shell_state + } + + fn new_toplevel(&mut self, surface: ToplevelSurface) { + let kind = window_kind_for_toplevel(&surface); + let id = self.next_window_id; + self.next_window_id += 1; + self.windows.push(ManagedWindow { + id, + surface: surface.clone(), + position: position_for_new_window(kind, self.next_spawn_position), + kind, + decoration: decoration_for_new_window(kind), + titlebar: None, + content_bbox_size: Size::default(), + }); + if kind == ManagedWindowKind::Normal { + self.idle_daemon.register_window(id); + } + self.output.enter(surface.wl_surface()); + self.request_redraw(); + + self.configure_toplevel(&surface, kind); + } + + fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) { + // Record the popup's geometry from the client's positioner. The initial + // configure is sent on the popup's first commit (see `commit`), not here: + // sending it at role-creation time races the client's first commit so the + // configured geometry never lands in the surface's current state, leaving + // the popup mispositioned the first time it opens. + surface.with_pending_state(|state| { + state.geometry = positioner.get_geometry(); + }); + if let Err(err) = self.popups.track_popup(PopupKind::Xdg(surface)) { + eprintln!("Failed to track popup: {err}"); + } + } + + fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) { + let Some(window_index) = self + .windows + .iter() + .position(|window| window.surface == surface) + else { + return; + }; + + if self.windows[window_index].kind != ManagedWindowKind::Normal { + return; + } + + let window_index = self.raise_window(window_index); + let surface = self.windows[window_index].surface.wl_surface().clone(); + self.set_keyboard_focus_to_window(window_index, surface); + self.drag = Some(DragState { + window_id: self.windows[window_index].id, + pointer_start: self.pointer_location, + window_start: self.windows[window_index].position, + }); + self.request_redraw(); + } + + fn resize_request( + &mut self, + surface: ToplevelSurface, + _seat: wl_seat::WlSeat, + _serial: Serial, + edges: xdg_toplevel::ResizeEdge, + ) { + let edges = ResizeEdges::from(edges); + if edges.is_empty() { + return; + } + let Some(window_index) = self + .windows + .iter() + .position(|window| window.surface == surface) + else { + return; + }; + + if self.windows[window_index].kind != ManagedWindowKind::Normal { + return; + } + + let window_index = self.raise_window(window_index); + let surface = self.windows[window_index].surface.wl_surface().clone(); + self.set_keyboard_focus_to_window(window_index, surface); + self.start_resize(window_index, edges); + } + + fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) { + // Set up a popup grab so the menu behaves modally: keyboard and pointer + // input route to the popup, and clicking elsewhere dismisses it. + let Some(seat) = Seat::::from_resource(&seat) else { + return; + }; + let kind = PopupKind::Xdg(surface); + let Ok(root) = find_popup_root_surface(&kind) else { + return; + }; + let mut grab = match self.popups.grab_popup(root, kind, &seat, serial) { + Ok(grab) => grab, + Err(_) => return, + }; + + if let Some(keyboard) = seat.get_keyboard() { + if keyboard.is_grabbed() + && !(keyboard.has_grab(serial) + || keyboard.has_grab(grab.previous_serial().unwrap_or(serial))) + { + grab.ungrab(PopupUngrabStrategy::All); + return; + } + keyboard.set_focus(self, grab.current_grab(), serial); + keyboard.set_grab(self, PopupKeyboardGrab::new(&grab), serial); + } + + if let Some(pointer) = seat.get_pointer() { + if pointer.is_grabbed() + && !(pointer.has_grab(serial) + || pointer.has_grab(grab.previous_serial().unwrap_or_else(|| grab.serial()))) + { + grab.ungrab(PopupUngrabStrategy::All); + return; + } + pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep); + } + } + + fn reposition_request( + &mut self, + surface: PopupSurface, + positioner: PositionerState, + token: u32, + ) { + surface.with_pending_state(|state| { + state.geometry = positioner.get_geometry(); + state.positioner = positioner; + }); + surface.send_repositioned(token); + } + + fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { + if let Some(window) = self.windows.iter().find(|window| window.surface == surface) { + self.idle_daemon.unregister_window(window.id); + if self.focused_normal_window_id == Some(window.id) { + self.focused_normal_window_id = None; + } + } + self.windows.retain(|window| window.surface != surface); + self.drag = None; + self.resize = None; + self.request_redraw(); + } + + fn app_id_changed(&mut self, surface: ToplevelSurface) { + let kind = window_kind_for_toplevel(&surface); + if let Some(window_index) = self + .windows + .iter() + .position(|window| window.surface == surface) + { + let mut window = self.windows.remove(window_index); + let old_kind = window.kind; + window.kind = kind; + window.position = position_for_new_window(kind, window.position); + if kind.is_shell_chrome() { + window.decoration = WindowDecoration::ClientSide; + } + if old_kind == ManagedWindowKind::Normal && kind != ManagedWindowKind::Normal { + self.idle_daemon.unregister_window(window.id); + if self.focused_normal_window_id == Some(window.id) { + self.focused_normal_window_id = None; + } + } else if old_kind != ManagedWindowKind::Normal && kind == ManagedWindowKind::Normal { + self.idle_daemon.register_window(window.id); + } + let insert_index = match kind { + ManagedWindowKind::Normal => self.normal_insert_index(), + ManagedWindowKind::ShellBar | ManagedWindowKind::Launcher => self.windows.len(), + }; + self.windows.insert(insert_index, window); + self.configure_toplevel(&surface, kind); + self.request_redraw(); + } + } + + fn title_changed(&mut self, _surface: ToplevelSurface) { + self.request_redraw(); + } +} + +impl XdgDecorationHandler for App { + fn new_decoration(&mut self, toplevel: ToplevelSurface) { + self.set_window_decoration(&toplevel, WindowDecoration::ClientSide); + self.request_redraw(); + } + + fn request_mode(&mut self, toplevel: ToplevelSurface, mode: DecorationMode) { + let decoration = match mode { + DecorationMode::ClientSide => WindowDecoration::ClientSide, + DecorationMode::ServerSide => WindowDecoration::ServerSide, + _ => WindowDecoration::ServerSide, + }; + self.set_window_decoration(&toplevel, decoration); + self.request_redraw(); + } + + fn unset_mode(&mut self, toplevel: ToplevelSurface) { + self.set_window_decoration(&toplevel, WindowDecoration::ClientSide); + self.request_redraw(); + } +} + +impl DmabufHandler for App { + fn dmabuf_state(&mut self) -> &mut DmabufState { + &mut self.dmabuf_state + } + + fn dmabuf_imported( + &mut self, + _global: &DmabufGlobal, + dmabuf: Dmabuf, + notifier: ImportNotifier, + ) { + // The renderer lives on the winit backend (not in `App`), so the import + // is deferred to the event loop where the renderer is reachable. + self.pending_dmabuf_imports.push((dmabuf, notifier)); + self.request_redraw(); + } +} + +impl SelectionHandler for App { + type SelectionUserData = (); +} + +impl DataDeviceHandler for App { + fn data_device_state(&self) -> &DataDeviceState { + &self.data_device_state + } +} + +impl ClientDndGrabHandler for App {} +impl ServerDndGrabHandler for App { + fn send(&mut self, _mime_type: String, _fd: OwnedFd, _seat: Seat) {} +} + +impl CompositorHandler for App { + fn compositor_state(&mut self) -> &mut CompositorState { + &mut self.compositor_state + } + + fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState { + &client.get_data::().unwrap().compositor_state + } + + fn new_surface(&mut self, surface: &WlSurface) { + // Defer applying a commit until the client's buffer is actually ready. + // + // GPU clients attach a dmabuf together with an implicit-sync fence that + // only signals once their rendering has finished. Compositing before the + // fence signals risks sampling a half-drawn buffer (tearing/corruption); + // on real hardware the right behaviour is to wait for the fence on the + // GPU timeline rather than spin on the CPU. + // + // We follow the standard Smithay/anvil approach, which mirrors how + // Mutter/KWin handle this: attach a blocker on commit that holds the + // transaction until a calloop source polling the fence fires, so the + // wait happens asynchronously instead of blocking the event loop. + add_pre_commit_hook::(surface, |state, _dh, surface| { + let maybe_dmabuf = with_states(surface, |states| { + let mut guard = states.cached_state.get::(); + match guard.pending().buffer.as_ref() { + Some(BufferAssignment::NewBuffer(buffer)) => get_dmabuf(buffer).ok().cloned(), + _ => None, + } + }); + let Some(dmabuf) = maybe_dmabuf else { + return; + }; + // `Err(AlreadyReady)` means the fence is already signalled, so the + // commit can proceed immediately with no blocker (the common case). + let Ok((blocker, source)) = dmabuf.generate_blocker(Interest::READ) else { + return; + }; + let Some(client) = surface.client() else { + return; + }; + let inserted = state.loop_handle.insert_source(source, move |_, _, data| { + let dh = data.display.handle(); + data.state + .client_compositor_state(&client) + .blocker_cleared(&mut data.state, &dh); + Ok(()) + }); + if inserted.is_ok() { + add_blocker(surface, blocker); + } + }); + } + + fn commit(&mut self, surface: &WlSurface) { + on_commit_buffer_handler::(surface); + self.popups.commit(surface); + // Send the popup's initial configure on its first commit. xdg requires + // the client to commit once (without a buffer) to request a configure; + // doing it here (rather than in `new_popup`) ensures the configured + // geometry is applied to the surface's current state. + if let Some(PopupKind::Xdg(popup)) = self.popups.find_popup(surface) + && !popup.is_initial_configure_sent() + && let Err(err) = popup.send_configure() + { + eprintln!("Failed to send initial popup configure: {err}"); + } + self.refresh_window_content_bbox(surface); + self.reanchor_resize(surface); + if let Some(window_id) = self.managed_normal_window_id_for_surface(surface) { + self.idle_daemon + .record_activity(window_id, ActivityReason::SurfaceCommit); + } + self.request_redraw(); + } +} + +impl ShmHandler for App { + fn shm_state(&self) -> &ShmState { + &self.shm_state + } +} + +impl OutputHandler for App {} + +impl SeatHandler for App { + type KeyboardFocus = WlSurface; + type PointerFocus = WlSurface; + type TouchFocus = WlSurface; + + fn seat_state(&mut self) -> &mut smithay::input::SeatState { + &mut self.seat_state + } + + fn focus_changed(&mut self, _seat: &Seat, _focused: Option<&WlSurface>) {} + + fn cursor_image(&mut self, _seat: &Seat, _image: CursorImageStatus) {} +} diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs index 115bad2..0eed6a8 100644 --- a/src/compositor/mod.rs +++ b/src/compositor/mod.rs @@ -1,10 +1,11 @@ use std::{ - os::unix::{fs::MetadataExt, io::OwnedFd, net::UnixListener as CommandListener}, + os::unix::{fs::MetadataExt, net::UnixListener as CommandListener}, sync::Arc, }; use crate::{RunOptions, config::*, geometry::CanvasPoint, shell::app_catalog::AppCatalog}; +mod handlers; mod idle; mod input; mod masonry_titlebar; @@ -13,16 +14,14 @@ mod shell_integration; mod viewport; mod windows; -use idle::{ActivityReason, IdleTransition, WindowIdleDaemon}; +use idle::{IdleTransition, WindowIdleDaemon}; use input::handle_input_event; use rendering::send_frames_surface_tree; use shell_integration::{ accept_command_connections, command_socket_path, remove_stale_socket, spawn_shell, }; use viewport::ViewportAnimation; -use windows::{ - ResizeEdges, decoration_for_new_window, position_for_new_window, window_kind_for_toplevel, -}; +use windows::ResizeEdges; use smithay::reexports::winit::window::CursorIcon; use smithay::{ @@ -35,60 +34,36 @@ use smithay::{ damage::OutputDamageTracker, element::Id, gles::{GlesRenderbuffer, GlesRenderer}, - utils::on_commit_buffer_handler, }, winit::{self, WinitEvent}, }, delegate_compositor, delegate_data_device, delegate_dmabuf, delegate_output, delegate_seat, delegate_shm, delegate_xdg_decoration, delegate_xdg_shell, - desktop::{ - PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, PopupUngrabStrategy, - find_popup_root_surface, - }, - input::{ - Seat, SeatHandler, SeatState, - keyboard::KeyboardHandle, - pointer::{Focus, PointerHandle}, - }, + desktop::PopupManager, + input::{Seat, SeatState, keyboard::KeyboardHandle, pointer::PointerHandle}, output::{Mode, Output, PhysicalProperties, Scale, Subpixel}, reexports::{ calloop::{ EventLoop, Interest, LoopHandle, Mode as CalloopMode, PostAction, generic::Generic, }, - wayland_server::{Display, protocol::wl_seat}, + wayland_server::Display, }, - utils::{Buffer as BufferCoord, Logical, Physical, Point, Rectangle, Serial, Size, Transform}, + utils::{Buffer as BufferCoord, Logical, Physical, Point, Rectangle, Size, Transform}, wayland::{ - buffer::BufferHandler, - compositor::{ - BufferAssignment, CompositorClientState, CompositorHandler, CompositorState, - SurfaceAttributes, add_blocker, add_pre_commit_hook, with_states, - }, - dmabuf::{ - DmabufFeedbackBuilder, DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier, - get_dmabuf, - }, - output::{OutputHandler, OutputManagerState}, - selection::{ - SelectionHandler, - data_device::{ - ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, - }, - }, - shell::xdg::{ - PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, - decoration::{XdgDecorationHandler, XdgDecorationState}, - }, - shm::{ShmHandler, ShmState}, + compositor::{CompositorClientState, CompositorState}, + dmabuf::{DmabufFeedbackBuilder, DmabufGlobal, DmabufState, ImportNotifier}, + output::OutputManagerState, + selection::data_device::DataDeviceState, + shell::xdg::{ToplevelSurface, XdgShellState, decoration::XdgDecorationState}, + shm::ShmState, socket::ListeningSocketSource, }, }; -use wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode; use wayland_protocols::xdg::shell::server::xdg_toplevel; use wayland_server::{ - Client, DisplayHandle, Resource, + DisplayHandle, backend::{ClientData, ClientId, DisconnectReason}, - protocol::{wl_buffer, wl_surface::WlSurface}, + protocol::wl_surface::WlSurface, }; struct ManagedWindow { @@ -204,365 +179,6 @@ struct App { background_dot_ids: Vec, } -impl BufferHandler for App { - fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {} -} - -impl XdgShellHandler for App { - fn xdg_shell_state(&mut self) -> &mut XdgShellState { - &mut self.xdg_shell_state - } - - fn new_toplevel(&mut self, surface: ToplevelSurface) { - let kind = window_kind_for_toplevel(&surface); - let id = self.next_window_id; - self.next_window_id += 1; - self.windows.push(ManagedWindow { - id, - surface: surface.clone(), - position: position_for_new_window(kind, self.next_spawn_position), - kind, - decoration: decoration_for_new_window(kind), - titlebar: None, - content_bbox_size: Size::default(), - }); - if kind == ManagedWindowKind::Normal { - self.idle_daemon.register_window(id); - } - self.output.enter(surface.wl_surface()); - self.request_redraw(); - - self.configure_toplevel(&surface, kind); - } - - fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) { - // Record the popup's geometry from the client's positioner. The initial - // configure is sent on the popup's first commit (see `commit`), not here: - // sending it at role-creation time races the client's first commit so the - // configured geometry never lands in the surface's current state, leaving - // the popup mispositioned the first time it opens. - surface.with_pending_state(|state| { - state.geometry = positioner.get_geometry(); - }); - if let Err(err) = self.popups.track_popup(PopupKind::Xdg(surface)) { - eprintln!("Failed to track popup: {err}"); - } - } - - fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) { - let Some(window_index) = self - .windows - .iter() - .position(|window| window.surface == surface) - else { - return; - }; - - if self.windows[window_index].kind != ManagedWindowKind::Normal { - return; - } - - let window_index = self.raise_window(window_index); - let surface = self.windows[window_index].surface.wl_surface().clone(); - self.set_keyboard_focus_to_window(window_index, surface); - self.drag = Some(DragState { - window_id: self.windows[window_index].id, - pointer_start: self.pointer_location, - window_start: self.windows[window_index].position, - }); - self.request_redraw(); - } - - fn resize_request( - &mut self, - surface: ToplevelSurface, - _seat: wl_seat::WlSeat, - _serial: Serial, - edges: xdg_toplevel::ResizeEdge, - ) { - let edges = ResizeEdges::from(edges); - if edges.is_empty() { - return; - } - let Some(window_index) = self - .windows - .iter() - .position(|window| window.surface == surface) - else { - return; - }; - - if self.windows[window_index].kind != ManagedWindowKind::Normal { - return; - } - - let window_index = self.raise_window(window_index); - let surface = self.windows[window_index].surface.wl_surface().clone(); - self.set_keyboard_focus_to_window(window_index, surface); - self.start_resize(window_index, edges); - } - - fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) { - // Set up a popup grab so the menu behaves modally: keyboard and pointer - // input route to the popup, and clicking elsewhere dismisses it. - let Some(seat) = Seat::::from_resource(&seat) else { - return; - }; - let kind = PopupKind::Xdg(surface); - let Ok(root) = find_popup_root_surface(&kind) else { - return; - }; - let mut grab = match self.popups.grab_popup(root, kind, &seat, serial) { - Ok(grab) => grab, - Err(_) => return, - }; - - if let Some(keyboard) = seat.get_keyboard() { - if keyboard.is_grabbed() - && !(keyboard.has_grab(serial) - || keyboard.has_grab(grab.previous_serial().unwrap_or(serial))) - { - grab.ungrab(PopupUngrabStrategy::All); - return; - } - keyboard.set_focus(self, grab.current_grab(), serial); - keyboard.set_grab(self, PopupKeyboardGrab::new(&grab), serial); - } - - if let Some(pointer) = seat.get_pointer() { - if pointer.is_grabbed() - && !(pointer.has_grab(serial) - || pointer.has_grab(grab.previous_serial().unwrap_or_else(|| grab.serial()))) - { - grab.ungrab(PopupUngrabStrategy::All); - return; - } - pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep); - } - } - - fn reposition_request( - &mut self, - surface: PopupSurface, - positioner: PositionerState, - token: u32, - ) { - surface.with_pending_state(|state| { - state.geometry = positioner.get_geometry(); - state.positioner = positioner; - }); - surface.send_repositioned(token); - } - - fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { - if let Some(window) = self.windows.iter().find(|window| window.surface == surface) { - self.idle_daemon.unregister_window(window.id); - if self.focused_normal_window_id == Some(window.id) { - self.focused_normal_window_id = None; - } - } - self.windows.retain(|window| window.surface != surface); - self.drag = None; - self.resize = None; - self.request_redraw(); - } - - fn app_id_changed(&mut self, surface: ToplevelSurface) { - let kind = window_kind_for_toplevel(&surface); - if let Some(window_index) = self - .windows - .iter() - .position(|window| window.surface == surface) - { - let mut window = self.windows.remove(window_index); - let old_kind = window.kind; - window.kind = kind; - window.position = position_for_new_window(kind, window.position); - if kind.is_shell_chrome() { - window.decoration = WindowDecoration::ClientSide; - } - if old_kind == ManagedWindowKind::Normal && kind != ManagedWindowKind::Normal { - self.idle_daemon.unregister_window(window.id); - if self.focused_normal_window_id == Some(window.id) { - self.focused_normal_window_id = None; - } - } else if old_kind != ManagedWindowKind::Normal && kind == ManagedWindowKind::Normal { - self.idle_daemon.register_window(window.id); - } - let insert_index = match kind { - ManagedWindowKind::Normal => self.normal_insert_index(), - ManagedWindowKind::ShellBar | ManagedWindowKind::Launcher => self.windows.len(), - }; - self.windows.insert(insert_index, window); - self.configure_toplevel(&surface, kind); - self.request_redraw(); - } - } - - fn title_changed(&mut self, _surface: ToplevelSurface) { - self.request_redraw(); - } -} - -impl XdgDecorationHandler for App { - fn new_decoration(&mut self, toplevel: ToplevelSurface) { - self.set_window_decoration(&toplevel, WindowDecoration::ClientSide); - self.request_redraw(); - } - - fn request_mode(&mut self, toplevel: ToplevelSurface, mode: DecorationMode) { - let decoration = match mode { - DecorationMode::ClientSide => WindowDecoration::ClientSide, - DecorationMode::ServerSide => WindowDecoration::ServerSide, - _ => WindowDecoration::ServerSide, - }; - self.set_window_decoration(&toplevel, decoration); - self.request_redraw(); - } - - fn unset_mode(&mut self, toplevel: ToplevelSurface) { - self.set_window_decoration(&toplevel, WindowDecoration::ClientSide); - self.request_redraw(); - } -} - -impl DmabufHandler for App { - fn dmabuf_state(&mut self) -> &mut DmabufState { - &mut self.dmabuf_state - } - - fn dmabuf_imported( - &mut self, - _global: &DmabufGlobal, - dmabuf: Dmabuf, - notifier: ImportNotifier, - ) { - // The renderer lives on the winit backend (not in `App`), so the import - // is deferred to the event loop where the renderer is reachable. - self.pending_dmabuf_imports.push((dmabuf, notifier)); - self.request_redraw(); - } -} - -impl SelectionHandler for App { - type SelectionUserData = (); -} - -impl DataDeviceHandler for App { - fn data_device_state(&self) -> &DataDeviceState { - &self.data_device_state - } -} - -impl ClientDndGrabHandler for App {} -impl ServerDndGrabHandler for App { - fn send(&mut self, _mime_type: String, _fd: OwnedFd, _seat: Seat) {} -} - -impl CompositorHandler for App { - fn compositor_state(&mut self) -> &mut CompositorState { - &mut self.compositor_state - } - - fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState { - &client.get_data::().unwrap().compositor_state - } - - fn new_surface(&mut self, surface: &WlSurface) { - // Defer applying a commit until the client's buffer is actually ready. - // - // GPU clients attach a dmabuf together with an implicit-sync fence that - // only signals once their rendering has finished. Compositing before the - // fence signals risks sampling a half-drawn buffer (tearing/corruption); - // on real hardware the right behaviour is to wait for the fence on the - // GPU timeline rather than spin on the CPU. - // - // We follow the standard Smithay/anvil approach, which mirrors how - // Mutter/KWin handle this: attach a blocker on commit that holds the - // transaction until a calloop source polling the fence fires, so the - // wait happens asynchronously instead of blocking the event loop. - add_pre_commit_hook::(surface, |state, _dh, surface| { - let maybe_dmabuf = with_states(surface, |states| { - let mut guard = states.cached_state.get::(); - match guard.pending().buffer.as_ref() { - Some(BufferAssignment::NewBuffer(buffer)) => get_dmabuf(buffer).ok().cloned(), - _ => None, - } - }); - let Some(dmabuf) = maybe_dmabuf else { - return; - }; - // `Err(AlreadyReady)` means the fence is already signalled, so the - // commit can proceed immediately with no blocker (the common case). - let Ok((blocker, source)) = dmabuf.generate_blocker(Interest::READ) else { - return; - }; - let Some(client) = surface.client() else { - return; - }; - let inserted = state.loop_handle.insert_source(source, move |_, _, data| { - let dh = data.display.handle(); - data.state - .client_compositor_state(&client) - .blocker_cleared(&mut data.state, &dh); - Ok(()) - }); - if inserted.is_ok() { - add_blocker(surface, blocker); - } - }); - } - - fn commit(&mut self, surface: &WlSurface) { - on_commit_buffer_handler::(surface); - self.popups.commit(surface); - // Send the popup's initial configure on its first commit. xdg requires - // the client to commit once (without a buffer) to request a configure; - // doing it here (rather than in `new_popup`) ensures the configured - // geometry is applied to the surface's current state. - if let Some(PopupKind::Xdg(popup)) = self.popups.find_popup(surface) - && !popup.is_initial_configure_sent() - && let Err(err) = popup.send_configure() - { - eprintln!("Failed to send initial popup configure: {err}"); - } - self.refresh_window_content_bbox(surface); - self.reanchor_resize(surface); - if let Some(window_id) = self.managed_normal_window_id_for_surface(surface) { - self.idle_daemon - .record_activity(window_id, ActivityReason::SurfaceCommit); - } - self.request_redraw(); - } -} - -impl ShmHandler for App { - fn shm_state(&self) -> &ShmState { - &self.shm_state - } -} - -impl OutputHandler for App {} - -impl SeatHandler for App { - type KeyboardFocus = WlSurface; - type PointerFocus = WlSurface; - type TouchFocus = WlSurface; - - fn seat_state(&mut self) -> &mut SeatState { - &mut self.seat_state - } - - fn focus_changed(&mut self, _seat: &Seat, _focused: Option<&WlSurface>) {} - - fn cursor_image( - &mut self, - _seat: &Seat, - _image: smithay::input::pointer::CursorImageStatus, - ) { - } -} - pub fn run_winit(options: RunOptions) -> Result<(), Box> { let mut display: Display = Display::new()?; let dh = display.handle(); diff --git a/src/compositor/windows.rs b/src/compositor/windows.rs index ba9ff7d..3ef8f03 100644 --- a/src/compositor/windows.rs +++ b/src/compositor/windows.rs @@ -3,7 +3,6 @@ use smithay::{ PopupManager, WindowSurfaceType, utils::{bbox_from_surface_tree, under_from_surface_tree}, }, - reexports::winit::window::CursorIcon, utils::{Logical, Physical, Point, Rectangle, SERIAL_COUNTER, Size}, wayland::{ compositor::{TraversalAction, with_states, with_surface_tree_downward}, @@ -22,70 +21,20 @@ use crate::{ geometry::{CanvasPoint, rect_contains}, }; +mod geometry; + +pub(super) use geometry::{ResizeEdges, resize_cursor_icon}; + +use geometry::{ + close_button_canvas_rect_for, content_canvas_origin_for, resize_anchored_position, + resize_edges_at, resize_target_content_size, title_bar_canvas_rect_for, window_canvas_rect_for, +}; + use super::{ App, HitTarget, ManagedWindow, ManagedWindowKind, ResizeState, WindowDecoration, idle::ActivityReason, rendering::{toplevel_geometry_loc, toplevel_geometry_size}, }; -/// Which edges of a window are being dragged during an interactive resize. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(super) struct ResizeEdges { - pub left: bool, - pub right: bool, - pub top: bool, - pub bottom: bool, -} - -impl ResizeEdges { - pub(super) fn is_empty(self) -> bool { - !(self.left || self.right || self.top || self.bottom) - } -} - -impl From for ResizeEdges { - fn from(edges: xdg_toplevel::ResizeEdge) -> Self { - use xdg_toplevel::ResizeEdge; - match edges { - ResizeEdge::Top => Self { - top: true, - ..Self::default() - }, - ResizeEdge::Bottom => Self { - bottom: true, - ..Self::default() - }, - ResizeEdge::Left => Self { - left: true, - ..Self::default() - }, - ResizeEdge::Right => Self { - right: true, - ..Self::default() - }, - ResizeEdge::TopLeft => Self { - top: true, - left: true, - ..Self::default() - }, - ResizeEdge::TopRight => Self { - top: true, - right: true, - ..Self::default() - }, - ResizeEdge::BottomLeft => Self { - bottom: true, - left: true, - ..Self::default() - }, - ResizeEdge::BottomRight => Self { - bottom: true, - right: true, - ..Self::default() - }, - _ => Self::default(), - } - } -} fn configure_server_side_decoration(toplevel: &ToplevelSurface) { if window_kind_for_toplevel(toplevel).is_shell_chrome() { @@ -653,201 +602,10 @@ fn normal_insert_index_for_kinds(kinds: impl Iterator) .unwrap_or(0) } -/// Canvas-space rectangle of a window's server-side title bar, given the -/// window's canvas position and the width of its content surface tree. -fn title_bar_canvas_rect_for(position: CanvasPoint, content_width: i32) -> Rectangle { - Rectangle::new( - (position.x, position.y).into(), - (content_width.max(MIN_WINDOW_WIDTH), TITLE_BAR_HEIGHT).into(), - ) -} - -/// Canvas-space rectangle of the close button, positioned at the right edge of -/// the given title bar and vertically centered within it. -fn close_button_canvas_rect_for(title_bar: Rectangle) -> Rectangle { - Rectangle::new( - ( - title_bar.loc.x + title_bar.size.w - CLOSE_BUTTON_MARGIN - CLOSE_BUTTON_SIZE, - title_bar.loc.y + (title_bar.size.h - CLOSE_BUTTON_SIZE) / 2, - ) - .into(), - (CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE).into(), - ) -} - -/// Canvas-space origin of a window's client content, offset below the title bar -/// only when the compositor is drawing server-side chrome for it. -fn content_canvas_origin_for(position: CanvasPoint, has_chrome: bool) -> Point { - let title_bar_height = if has_chrome { TITLE_BAR_HEIGHT } else { 0 }; - Point::::from((position.x, position.y + title_bar_height)) -} - -/// Full canvas-space bounds of a window given its position, client content size -/// and whether the compositor draws server-side chrome (a title bar above the -/// content). The width tracks the title bar, which is clamped to a minimum. -fn window_canvas_rect_for( - position: CanvasPoint, - content_size: Size, - has_chrome: bool, -) -> Rectangle { - let title_bar_height = if has_chrome { TITLE_BAR_HEIGHT } else { 0 }; - let width = if has_chrome { - content_size.w.max(MIN_WINDOW_WIDTH) - } else { - content_size.w - }; - Rectangle::new( - (position.x, position.y).into(), - (width, content_size.h + title_bar_height).into(), - ) -} - -/// Determine which resize edges, if any, the pointer is over. The resize handle -/// is a band centered on each window edge: it reaches `outset` pixels outside -/// the edge and `inset` pixels inside it, so the visible edge is grabbable while -/// the deep interior is not. Title bar and close-button hit-testing run first, -/// so they keep priority over the top resize band. -fn resize_edges_at( - window_rect: Rectangle, - point: Point, - outset: i32, - inset: i32, -) -> Option { - let outer = Rectangle::new( - (window_rect.loc.x - outset, window_rect.loc.y - outset).into(), - ( - window_rect.size.w + outset * 2, - window_rect.size.h + outset * 2, - ) - .into(), - ); - if !rect_contains(outer, point) { - return None; - } - let inset = f64::from(inset); - let edges = ResizeEdges { - left: point.x < f64::from(window_rect.loc.x) + inset, - right: point.x >= f64::from(window_rect.loc.x + window_rect.size.w) - inset, - top: point.y < f64::from(window_rect.loc.y) + inset, - bottom: point.y >= f64::from(window_rect.loc.y + window_rect.size.h) - inset, - }; - (!edges.is_empty()).then_some(edges) -} - -/// Desired client content size for a resize drag, derived from the size at the -/// start of the resize and the canvas-space pointer delta. Edges that are not -/// being dragged leave their dimension unchanged; the result is clamped to the -/// minimum window dimensions. -fn resize_target_content_size( - edges: ResizeEdges, - initial: Size, - delta: Point, -) -> Size { - let mut width = initial.w; - let mut height = initial.h; - if edges.left { - width = initial.w - delta.x; - } - if edges.right { - width = initial.w + delta.x; - } - if edges.top { - height = initial.h - delta.y; - } - if edges.bottom { - height = initial.h + delta.y; - } - (width.max(MIN_WINDOW_WIDTH), height.max(MIN_WINDOW_HEIGHT)).into() -} - -/// Window position that keeps the edge opposite a left/top resize anchored in -/// place as the client's content size changes from `initial` to `actual`. -fn resize_anchored_position( - edges: ResizeEdges, - anchor: CanvasPoint, - initial: Size, - actual: Size, -) -> CanvasPoint { - let mut position = anchor; - if edges.left { - position.x = anchor.x + initial.w - actual.w; - } - if edges.top { - position.y = anchor.y + initial.h - actual.h; - } - position -} - -/// The cursor that communicates which resize a window edge or corner performs. -/// Edges map to the bidirectional CSS-style resize cursors; corners map to the -/// matching diagonal cursor. -pub(super) fn resize_cursor_icon(edges: ResizeEdges) -> CursorIcon { - match (edges.top || edges.bottom, edges.left || edges.right) { - (true, true) => { - if (edges.top && edges.left) || (edges.bottom && edges.right) { - CursorIcon::NwseResize - } else { - CursorIcon::NeswResize - } - } - (true, false) => CursorIcon::NsResize, - (false, true) => CursorIcon::EwResize, - (false, false) => CursorIcon::Default, - } -} - #[cfg(test)] mod tests { use super::*; - fn point(x: i32, y: i32) -> CanvasPoint { - CanvasPoint { x, y } - } - - #[test] - fn title_bar_uses_content_width_when_wider_than_minimum() { - let rect = title_bar_canvas_rect_for(point(40, 50), MIN_WINDOW_WIDTH + 120); - assert_eq!(rect.loc.x, 40); - assert_eq!(rect.loc.y, 50); - assert_eq!(rect.size.w, MIN_WINDOW_WIDTH + 120); - assert_eq!(rect.size.h, TITLE_BAR_HEIGHT); - } - - #[test] - fn title_bar_clamps_to_minimum_window_width() { - let rect = title_bar_canvas_rect_for(point(0, 0), 10); - assert_eq!(rect.size.w, MIN_WINDOW_WIDTH); - } - - #[test] - fn close_button_sits_inside_the_title_bar_right_edge() { - let title_bar = title_bar_canvas_rect_for(point(100, 200), 400); - let close = close_button_canvas_rect_for(title_bar); - - assert_eq!(close.size.w, CLOSE_BUTTON_SIZE); - assert_eq!(close.size.h, CLOSE_BUTTON_SIZE); - // Right edge respects the margin. - assert_eq!( - close.loc.x + close.size.w, - title_bar.loc.x + title_bar.size.w - CLOSE_BUTTON_MARGIN - ); - // Fully contained within the title bar vertically. - assert!(close.loc.y >= title_bar.loc.y); - assert!(close.loc.y + close.size.h <= title_bar.loc.y + title_bar.size.h); - } - - #[test] - fn content_origin_drops_below_title_bar_only_with_chrome() { - assert_eq!( - content_canvas_origin_for(point(10, 20), true), - Point::::from((10, 20 + TITLE_BAR_HEIGHT)) - ); - assert_eq!( - content_canvas_origin_for(point(10, 20), false), - Point::::from((10, 20)) - ); - } - #[test] fn insert_index_is_after_the_last_normal_window() { use ManagedWindowKind::{Normal, ShellBar}; @@ -867,195 +625,4 @@ mod tests { 3 ); } - - fn size(w: i32, h: i32) -> Size { - (w, h).into() - } - - fn fpoint(x: f64, y: f64) -> Point { - (x, y).into() - } - - fn edges(left: bool, right: bool, top: bool, bottom: bool) -> ResizeEdges { - ResizeEdges { - left, - right, - top, - bottom, - } - } - - #[test] - fn resize_edges_from_protocol_corners_set_two_sides() { - use xdg_toplevel::ResizeEdge; - assert_eq!( - ResizeEdges::from(ResizeEdge::TopLeft), - edges(true, false, true, false) - ); - assert_eq!( - ResizeEdges::from(ResizeEdge::BottomRight), - edges(false, true, false, true) - ); - assert_eq!( - ResizeEdges::from(ResizeEdge::Right), - edges(false, true, false, false) - ); - assert!(ResizeEdges::from(ResizeEdge::None).is_empty()); - } - - #[test] - fn window_rect_adds_title_bar_height_only_with_chrome() { - let with_chrome = window_canvas_rect_for(point(10, 20), size(400, 300), true); - assert_eq!(with_chrome.loc, Point::::from((10, 20))); - assert_eq!(with_chrome.size.w, 400); - assert_eq!(with_chrome.size.h, 300 + TITLE_BAR_HEIGHT); - - let without_chrome = window_canvas_rect_for(point(10, 20), size(400, 300), false); - assert_eq!(without_chrome.size.h, 300); - } - - #[test] - fn window_rect_width_clamps_to_minimum_with_chrome() { - let rect = window_canvas_rect_for(point(0, 0), size(10, 200), true); - assert_eq!(rect.size.w, MIN_WINDOW_WIDTH); - } - - #[test] - fn resize_edges_at_detects_corner_outside_window() { - let rect = Rectangle::new((100, 100).into(), (200, 150).into()); - // Just outside the top-left corner. - assert_eq!( - resize_edges_at(rect, fpoint(96.0, 96.0), 8, 8), - Some(edges(true, false, true, false)) - ); - // Along the right edge only. - assert_eq!( - resize_edges_at(rect, fpoint(303.0, 175.0), 8, 8), - Some(edges(false, true, false, false)) - ); - } - - #[test] - fn resize_edges_at_ignores_interior_and_far_points() { - let rect = Rectangle::new((100, 100).into(), (200, 150).into()); - // Inside the window content. - assert_eq!(resize_edges_at(rect, fpoint(150.0, 150.0), 8, 8), None); - // Beyond the outset frame. - assert_eq!(resize_edges_at(rect, fpoint(50.0, 50.0), 8, 8), None); - } - - #[test] - fn resize_edges_at_handle_is_centered_on_the_edge() { - let rect = Rectangle::new((100, 100).into(), (200, 150).into()); - // The left edge is at x = 100; with an 8px inset the handle reaches to - // x = 108 inside the window, so a point a few pixels inside resizes. - assert_eq!( - resize_edges_at(rect, fpoint(104.0, 175.0), 8, 8), - Some(edges(true, false, false, false)) - ); - // With an 8px outset it also reaches to x = 92 outside the window. - assert_eq!( - resize_edges_at(rect, fpoint(95.0, 175.0), 8, 8), - Some(edges(true, false, false, false)) - ); - // Just past the inset (x = 109) is interior content, not a resize target. - assert_eq!(resize_edges_at(rect, fpoint(109.0, 175.0), 8, 8), None); - // Just inside the bottom edge resizes too (250 is the bottom; 246 is within 8). - assert_eq!( - resize_edges_at(rect, fpoint(200.0, 246.0), 8, 8), - Some(edges(false, false, false, true)) - ); - } - - #[test] - fn resize_target_grows_with_bottom_right_drag() { - let result = resize_target_content_size( - edges(false, true, false, true), - size(400, 300), - (60, 40).into(), - ); - assert_eq!(result, size(460, 340)); - } - - #[test] - fn resize_target_shrinks_with_left_drag_and_clamps() { - // Dragging the left edge right shrinks the width. - let result = resize_target_content_size( - edges(true, false, false, false), - size(400, 300), - (30, 0).into(), - ); - assert_eq!(result, size(370, 300)); - // Clamped to the minimum width regardless of how far the drag goes. - let clamped = resize_target_content_size( - edges(true, false, false, false), - size(400, 300), - (10_000, 0).into(), - ); - assert_eq!(clamped.w, MIN_WINDOW_WIDTH); - } - - #[test] - fn anchored_position_keeps_right_and_bottom_fixed_on_left_top_resize() { - // Initial right edge = 100 + 400 = 500, bottom = 100 + 300 = 400. - let position = resize_anchored_position( - edges(true, false, true, false), - point(100, 100), - size(400, 300), - size(450, 320), - ); - // New width 450 keeps right at 500 -> x = 50. New height 320 keeps bottom at 400 -> y = 80. - assert_eq!(position, point(50, 80)); - } - - #[test] - fn anchored_position_unchanged_for_right_bottom_resize() { - let position = resize_anchored_position( - edges(false, true, false, true), - point(100, 100), - size(400, 300), - size(450, 320), - ); - assert_eq!(position, point(100, 100)); - } - - #[test] - fn resize_cursor_matches_edges_and_corners() { - assert_eq!( - resize_cursor_icon(edges(false, false, true, false)), - CursorIcon::NsResize - ); - assert_eq!( - resize_cursor_icon(edges(false, false, false, true)), - CursorIcon::NsResize - ); - assert_eq!( - resize_cursor_icon(edges(true, false, false, false)), - CursorIcon::EwResize - ); - assert_eq!( - resize_cursor_icon(edges(true, false, true, false)), - CursorIcon::NwseResize, - "top-left corner" - ); - assert_eq!( - resize_cursor_icon(edges(false, true, false, true)), - CursorIcon::NwseResize, - "bottom-right corner" - ); - assert_eq!( - resize_cursor_icon(edges(false, true, true, false)), - CursorIcon::NeswResize, - "top-right corner" - ); - assert_eq!( - resize_cursor_icon(edges(true, false, false, true)), - CursorIcon::NeswResize, - "bottom-left corner" - ); - assert_eq!( - resize_cursor_icon(ResizeEdges::default()), - CursorIcon::Default - ); - } } diff --git a/src/compositor/windows/geometry.rs b/src/compositor/windows/geometry.rs new file mode 100644 index 0000000..78e9bed --- /dev/null +++ b/src/compositor/windows/geometry.rs @@ -0,0 +1,465 @@ +use smithay::{ + reexports::winit::window::CursorIcon, + utils::{Logical, Point, Rectangle, Size}, +}; +use wayland_protocols::xdg::shell::server::xdg_toplevel; + +use crate::{ + config::*, + geometry::{CanvasPoint, rect_contains}, +}; + +/// Which edges of a window are being dragged during an interactive resize. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(in crate::compositor) struct ResizeEdges { + pub(in crate::compositor) left: bool, + pub(in crate::compositor) right: bool, + pub(in crate::compositor) top: bool, + pub(in crate::compositor) bottom: bool, +} + +impl ResizeEdges { + pub(in crate::compositor) fn is_empty(self) -> bool { + !(self.left || self.right || self.top || self.bottom) + } +} + +impl From for ResizeEdges { + fn from(edges: xdg_toplevel::ResizeEdge) -> Self { + use xdg_toplevel::ResizeEdge; + match edges { + ResizeEdge::Top => Self { + top: true, + ..Self::default() + }, + ResizeEdge::Bottom => Self { + bottom: true, + ..Self::default() + }, + ResizeEdge::Left => Self { + left: true, + ..Self::default() + }, + ResizeEdge::Right => Self { + right: true, + ..Self::default() + }, + ResizeEdge::TopLeft => Self { + top: true, + left: true, + ..Self::default() + }, + ResizeEdge::TopRight => Self { + top: true, + right: true, + ..Self::default() + }, + ResizeEdge::BottomLeft => Self { + bottom: true, + left: true, + ..Self::default() + }, + ResizeEdge::BottomRight => Self { + bottom: true, + right: true, + ..Self::default() + }, + _ => Self::default(), + } + } +} + +/// Canvas-space rectangle of a window's server-side title bar, given the +/// window's canvas position and the width of its content surface tree. +pub(super) fn title_bar_canvas_rect_for( + position: CanvasPoint, + content_width: i32, +) -> Rectangle { + Rectangle::new( + (position.x, position.y).into(), + (content_width.max(MIN_WINDOW_WIDTH), TITLE_BAR_HEIGHT).into(), + ) +} + +/// Canvas-space rectangle of the close button, positioned at the right edge of +/// the given title bar and vertically centered within it. +pub(super) fn close_button_canvas_rect_for( + title_bar: Rectangle, +) -> Rectangle { + Rectangle::new( + ( + title_bar.loc.x + title_bar.size.w - CLOSE_BUTTON_MARGIN - CLOSE_BUTTON_SIZE, + title_bar.loc.y + (title_bar.size.h - CLOSE_BUTTON_SIZE) / 2, + ) + .into(), + (CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE).into(), + ) +} + +/// Canvas-space origin of a window's client content, offset below the title bar +/// only when the compositor is drawing server-side chrome for it. +pub(super) fn content_canvas_origin_for( + position: CanvasPoint, + has_chrome: bool, +) -> Point { + let title_bar_height = if has_chrome { TITLE_BAR_HEIGHT } else { 0 }; + Point::::from((position.x, position.y + title_bar_height)) +} + +/// Full canvas-space bounds of a window given its position, client content size +/// and whether the compositor draws server-side chrome (a title bar above the +/// content). The width tracks the title bar, which is clamped to a minimum. +pub(super) fn window_canvas_rect_for( + position: CanvasPoint, + content_size: Size, + has_chrome: bool, +) -> Rectangle { + let title_bar_height = if has_chrome { TITLE_BAR_HEIGHT } else { 0 }; + let width = if has_chrome { + content_size.w.max(MIN_WINDOW_WIDTH) + } else { + content_size.w + }; + Rectangle::new( + (position.x, position.y).into(), + (width, content_size.h + title_bar_height).into(), + ) +} + +/// Determine which resize edges, if any, the pointer is over. The resize handle +/// is a band centered on each window edge: it reaches `outset` pixels outside +/// the edge and `inset` pixels inside it, so the visible edge is grabbable while +/// the deep interior is not. Title bar and close-button hit-testing run first, +/// so they keep priority over the top resize band. +pub(super) fn resize_edges_at( + window_rect: Rectangle, + point: Point, + outset: i32, + inset: i32, +) -> Option { + let outer = Rectangle::new( + (window_rect.loc.x - outset, window_rect.loc.y - outset).into(), + ( + window_rect.size.w + outset * 2, + window_rect.size.h + outset * 2, + ) + .into(), + ); + if !rect_contains(outer, point) { + return None; + } + let inset = f64::from(inset); + let edges = ResizeEdges { + left: point.x < f64::from(window_rect.loc.x) + inset, + right: point.x >= f64::from(window_rect.loc.x + window_rect.size.w) - inset, + top: point.y < f64::from(window_rect.loc.y) + inset, + bottom: point.y >= f64::from(window_rect.loc.y + window_rect.size.h) - inset, + }; + (!edges.is_empty()).then_some(edges) +} + +/// Desired client content size for a resize drag, derived from the size at the +/// start of the resize and the canvas-space pointer delta. Edges that are not +/// being dragged leave their dimension unchanged; the result is clamped to the +/// minimum window dimensions. +pub(super) fn resize_target_content_size( + edges: ResizeEdges, + initial: Size, + delta: Point, +) -> Size { + let mut width = initial.w; + let mut height = initial.h; + if edges.left { + width = initial.w - delta.x; + } + if edges.right { + width = initial.w + delta.x; + } + if edges.top { + height = initial.h - delta.y; + } + if edges.bottom { + height = initial.h + delta.y; + } + (width.max(MIN_WINDOW_WIDTH), height.max(MIN_WINDOW_HEIGHT)).into() +} + +/// Window position that keeps the edge opposite a left/top resize anchored in +/// place as the client's content size changes from `initial` to `actual`. +pub(super) fn resize_anchored_position( + edges: ResizeEdges, + anchor: CanvasPoint, + initial: Size, + actual: Size, +) -> CanvasPoint { + let mut position = anchor; + if edges.left { + position.x = anchor.x + initial.w - actual.w; + } + if edges.top { + position.y = anchor.y + initial.h - actual.h; + } + position +} + +/// The cursor that communicates which resize a window edge or corner performs. +/// Edges map to the bidirectional CSS-style resize cursors; corners map to the +/// matching diagonal cursor. +pub(in crate::compositor) fn resize_cursor_icon(edges: ResizeEdges) -> CursorIcon { + match (edges.top || edges.bottom, edges.left || edges.right) { + (true, true) => { + if (edges.top && edges.left) || (edges.bottom && edges.right) { + CursorIcon::NwseResize + } else { + CursorIcon::NeswResize + } + } + (true, false) => CursorIcon::NsResize, + (false, true) => CursorIcon::EwResize, + (false, false) => CursorIcon::Default, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn point(x: i32, y: i32) -> CanvasPoint { + CanvasPoint { x, y } + } + + #[test] + fn title_bar_uses_content_width_when_wider_than_minimum() { + let rect = title_bar_canvas_rect_for(point(40, 50), MIN_WINDOW_WIDTH + 120); + assert_eq!(rect.loc.x, 40); + assert_eq!(rect.loc.y, 50); + assert_eq!(rect.size.w, MIN_WINDOW_WIDTH + 120); + assert_eq!(rect.size.h, TITLE_BAR_HEIGHT); + } + + #[test] + fn title_bar_clamps_to_minimum_window_width() { + let rect = title_bar_canvas_rect_for(point(0, 0), 10); + assert_eq!(rect.size.w, MIN_WINDOW_WIDTH); + } + + #[test] + fn close_button_sits_inside_the_title_bar_right_edge() { + let title_bar = title_bar_canvas_rect_for(point(100, 200), 400); + let close = close_button_canvas_rect_for(title_bar); + + assert_eq!(close.size.w, CLOSE_BUTTON_SIZE); + assert_eq!(close.size.h, CLOSE_BUTTON_SIZE); + // Right edge respects the margin. + assert_eq!( + close.loc.x + close.size.w, + title_bar.loc.x + title_bar.size.w - CLOSE_BUTTON_MARGIN + ); + // Fully contained within the title bar vertically. + assert!(close.loc.y >= title_bar.loc.y); + assert!(close.loc.y + close.size.h <= title_bar.loc.y + title_bar.size.h); + } + + #[test] + fn content_origin_drops_below_title_bar_only_with_chrome() { + assert_eq!( + content_canvas_origin_for(point(10, 20), true), + Point::::from((10, 20 + TITLE_BAR_HEIGHT)) + ); + assert_eq!( + content_canvas_origin_for(point(10, 20), false), + Point::::from((10, 20)) + ); + } + + fn size(w: i32, h: i32) -> Size { + (w, h).into() + } + + fn fpoint(x: f64, y: f64) -> Point { + (x, y).into() + } + + fn edges(left: bool, right: bool, top: bool, bottom: bool) -> ResizeEdges { + ResizeEdges { + left, + right, + top, + bottom, + } + } + + #[test] + fn resize_edges_from_protocol_corners_set_two_sides() { + use xdg_toplevel::ResizeEdge; + assert_eq!( + ResizeEdges::from(ResizeEdge::TopLeft), + edges(true, false, true, false) + ); + assert_eq!( + ResizeEdges::from(ResizeEdge::BottomRight), + edges(false, true, false, true) + ); + assert_eq!( + ResizeEdges::from(ResizeEdge::Right), + edges(false, true, false, false) + ); + assert!(ResizeEdges::from(ResizeEdge::None).is_empty()); + } + + #[test] + fn window_rect_adds_title_bar_height_only_with_chrome() { + let with_chrome = window_canvas_rect_for(point(10, 20), size(400, 300), true); + assert_eq!(with_chrome.loc, Point::::from((10, 20))); + assert_eq!(with_chrome.size.w, 400); + assert_eq!(with_chrome.size.h, 300 + TITLE_BAR_HEIGHT); + + let without_chrome = window_canvas_rect_for(point(10, 20), size(400, 300), false); + assert_eq!(without_chrome.size.h, 300); + } + + #[test] + fn window_rect_width_clamps_to_minimum_with_chrome() { + let rect = window_canvas_rect_for(point(0, 0), size(10, 200), true); + assert_eq!(rect.size.w, MIN_WINDOW_WIDTH); + } + + #[test] + fn resize_edges_at_detects_corner_outside_window() { + let rect = Rectangle::new((100, 100).into(), (200, 150).into()); + // Just outside the top-left corner. + assert_eq!( + resize_edges_at(rect, fpoint(96.0, 96.0), 8, 8), + Some(edges(true, false, true, false)) + ); + // Along the right edge only. + assert_eq!( + resize_edges_at(rect, fpoint(303.0, 175.0), 8, 8), + Some(edges(false, true, false, false)) + ); + } + + #[test] + fn resize_edges_at_ignores_interior_and_far_points() { + let rect = Rectangle::new((100, 100).into(), (200, 150).into()); + // Inside the window content. + assert_eq!(resize_edges_at(rect, fpoint(150.0, 150.0), 8, 8), None); + // Beyond the outset frame. + assert_eq!(resize_edges_at(rect, fpoint(50.0, 50.0), 8, 8), None); + } + + #[test] + fn resize_edges_at_handle_is_centered_on_the_edge() { + let rect = Rectangle::new((100, 100).into(), (200, 150).into()); + // The left edge is at x = 100; with an 8px inset the handle reaches to + // x = 108 inside the window, so a point a few pixels inside resizes. + assert_eq!( + resize_edges_at(rect, fpoint(104.0, 175.0), 8, 8), + Some(edges(true, false, false, false)) + ); + // With an 8px outset it also reaches to x = 92 outside the window. + assert_eq!( + resize_edges_at(rect, fpoint(95.0, 175.0), 8, 8), + Some(edges(true, false, false, false)) + ); + // Just past the inset (x = 109) is interior content, not a resize target. + assert_eq!(resize_edges_at(rect, fpoint(109.0, 175.0), 8, 8), None); + // Just inside the bottom edge resizes too (250 is the bottom; 246 is within 8). + assert_eq!( + resize_edges_at(rect, fpoint(200.0, 246.0), 8, 8), + Some(edges(false, false, false, true)) + ); + } + + #[test] + fn resize_target_grows_with_bottom_right_drag() { + let result = resize_target_content_size( + edges(false, true, false, true), + size(400, 300), + (60, 40).into(), + ); + assert_eq!(result, size(460, 340)); + } + + #[test] + fn resize_target_shrinks_with_left_drag_and_clamps() { + // Dragging the left edge right shrinks the width. + let result = resize_target_content_size( + edges(true, false, false, false), + size(400, 300), + (30, 0).into(), + ); + assert_eq!(result, size(370, 300)); + // Clamped to the minimum width regardless of how far the drag goes. + let clamped = resize_target_content_size( + edges(true, false, false, false), + size(400, 300), + (10_000, 0).into(), + ); + assert_eq!(clamped.w, MIN_WINDOW_WIDTH); + } + + #[test] + fn anchored_position_keeps_right_and_bottom_fixed_on_left_top_resize() { + // Initial right edge = 100 + 400 = 500, bottom = 100 + 300 = 400. + let position = resize_anchored_position( + edges(true, false, true, false), + point(100, 100), + size(400, 300), + size(450, 320), + ); + // New width 450 keeps right at 500 -> x = 50. New height 320 keeps bottom at 400 -> y = 80. + assert_eq!(position, point(50, 80)); + } + + #[test] + fn anchored_position_unchanged_for_right_bottom_resize() { + let position = resize_anchored_position( + edges(false, true, false, true), + point(100, 100), + size(400, 300), + size(450, 320), + ); + assert_eq!(position, point(100, 100)); + } + + #[test] + fn resize_cursor_matches_edges_and_corners() { + assert_eq!( + resize_cursor_icon(edges(false, false, true, false)), + CursorIcon::NsResize + ); + assert_eq!( + resize_cursor_icon(edges(false, false, false, true)), + CursorIcon::NsResize + ); + assert_eq!( + resize_cursor_icon(edges(true, false, false, false)), + CursorIcon::EwResize + ); + assert_eq!( + resize_cursor_icon(edges(true, false, true, false)), + CursorIcon::NwseResize, + "top-left corner" + ); + assert_eq!( + resize_cursor_icon(edges(false, true, false, true)), + CursorIcon::NwseResize, + "bottom-right corner" + ); + assert_eq!( + resize_cursor_icon(edges(false, true, true, false)), + CursorIcon::NeswResize, + "top-right corner" + ); + assert_eq!( + resize_cursor_icon(edges(true, false, false, true)), + CursorIcon::NeswResize, + "bottom-left corner" + ); + assert_eq!( + resize_cursor_icon(ResizeEdges::default()), + CursorIcon::Default + ); + } +} -- 2.51.2 From 69af3dca0fe2e88c535278f965d13c09c7887c2a Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 02:29:35 -0700 Subject: [PATCH 19/22] docs: remove interim WAYDRIVER todo --- todos/WAYDRIVER.md | 236 --------------------------------------------- 1 file changed, 236 deletions(-) delete mode 100644 todos/WAYDRIVER.md diff --git a/todos/WAYDRIVER.md b/todos/WAYDRIVER.md deleted file mode 100644 index c0a435e..0000000 --- a/todos/WAYDRIVER.md +++ /dev/null @@ -1,236 +0,0 @@ -# Headless E2E Testing via WayDriver - -Design notes for driving **Hearthspace itself** under -[WayDriver](https://github.com/BohdanTkachenko/waydriver) — a headless GUI test -harness for Wayland — by implementing a custom Hearthspace backend. - -The goal is a deterministic, no-display integration harness: boot a headless -Hearthspace, launch a real Wayland client inside it, inject real input, capture -screenshots, and assert against the AT-SPI tree. This complements the unit/ -property tests tracked in [TESTING.md](./TESTING.md) with full end-to-end -coverage of the compositor, input routing, decorations, and accessibility -passthrough. - -## Background: how WayDriver is structured - -WayDriver is backend-agnostic. Its entire compositor-facing surface is three -traits (`crates/waydriver/src/backend.rs`): - -| Trait | Responsibility | -| --- | --- | -| `CompositorRuntime` | Spawn/stop a headless compositor; expose `wayland_display()` + `runtime_dir()`. | -| `InputBackend` | Inject keysyms, pointer motion (abs/rel), buttons, and discrete axis. | -| `CaptureBackend` | Screenshots + video. Default path is PipeWire→GStreamer, but `take_screenshot`/`grab_screenshot` are **explicitly overridable** (the docs cite "a future wlr-screencopy backend"). | - -Element *location* is done entirely over **AT-SPI on the host session bus** and -is independent of the backend — `session.locate("//Button[@name='ok']")`. The -backend crates do **not** link into the compositor; today `MutterCompositor` -just spawns `mutter --wayland` as a child process and puppets it over D-Bus. Our -backend would spawn the `hearthspace` binary as a child the same way. - -WayDriver is Apache-2.0 (license-compatible). - -## Why Hearthspace is a *better* fit than Mutter - -The Mutter backend is heavy because Mutter is a black box puppeted from the -outside. Hearthspace owns its compositor, so most of that plumbing evaporates: - -| Concern | Mutter backend | Hearthspace backend | -| --- | --- | --- | -| Input | `org.gnome.Mutter.RemoteDesktop` D-Bus session, linked to a ScreenCast session just to make absolute pointer motion legal | We **own the Smithay seat** — synthesize events directly into `compositor/input.rs`, no portal/D-Bus dance | -| Capture | private `dbus-daemon` + `pipewire` + `wireplumber` + ScreenCast negotiation | We **own the GLES renderer** — read the framebuffer back to PNG directly | -| AT-SPI | already works (host bus) | **already works** — Hearthspace already integrates AT-SPI (`accessibility.rs`) and ships an a11y test app | -| Control IPC | invented per backend | **already exists** — the shell command socket (`compositor/shell_integration.rs`) | - -## The AT-SPI question: a pro for both clients and shell chrome - -Testing through AT-SPI is a genuine **pro**, not a con, for the common case: - -- For **client windows** running inside Hearthspace, locating widgets via the - AT-SPI tree means our E2E tests double as an **accessibility regression - test** — they verify Hearthspace correctly bridges a client's a11y tree to - the host bus, which is something the compositor already cares about - (`accessibility.rs` walks exactly this tree). This is real-world a11y coverage - for free. - -Hearthspace's *own* shell chrome is drawn by **Xilem**, whose widget layer -**Masonry** integrates **AccessKit** (`accesskit` is now in `Cargo.lock` via -`masonry_winit`). On Linux AccessKit is bridged to AT-SPI, so the shell's -widgets are expected to expose an AT-SPI tree and be reachable by XPath -locators — unlike the previous shell implementation, which shipped no -AccessKit. The shell -(`shell/xilem_shell.rs`) is a standalone Wayland client, so its a11y tree is -bridged the same way as any other client. - -Implications for what we can assert on: - -- **Client apps under Hearthspace** — full AT-SPI locator coverage. ✅ -- **Hearthspace's shell chrome** — expected AT-SPI coverage via Masonry/AccessKit - (to be confirmed at runtime); it can also be driven via the existing command - socket and asserted via **screenshots**. - -## Architecture - -```mermaid -flowchart LR - subgraph tokio["waydriver process (tokio)"] - S[Session] --> C[HearthspaceCompositor\nCompositorRuntime] - S --> I[HearthspaceInput\nInputBackend] - S --> P[HearthspaceCapture\nCaptureBackend] - S -. AT-SPI host bus .-> A[(AT-SPI registry)] - end - C -- spawn --> H[hearthspace --headless] - I -- control socket --> H - P -- control socket --> H - H -- composites --> APP[GTK / Wayland app under test] - APP -- registers --> A -``` - -**Runtime mismatch is a non-issue.** Hearthspace is `async-io`/`calloop`; -WayDriver is `tokio`/`zbus`/`async-trait`. The two runtimes never mix because -they live in **separate processes** communicating over the control socket. -Hearthspace handles commands in its existing calloop source. - -## Hearthspace-side status - -The compositor-side pieces needed for a first WayDriver backend are now mostly -in place: - -1. **Headless backend: implemented.** `hearthspace --headless` starts a - surfaceless EGL/GLES renderer backed by an offscreen renderbuffer, advertises - a synthetic Smithay `Output`, opens the deterministic `wayland-99` socket, and - runs the same calloop-driven compositor state as the nested winit backend. - `--headless-size WIDTHxHEIGHT` configures the virtual output size, - `--headless-scale INTEGER` configures the advertised Wayland scale, and - `--no-shell` skips the Xilem shell client for app-focused harnesses. - -2. **Control-protocol extensions: implemented for screenshots and input.** The - command socket now replies to parsed commands, supports synthetic keyboard, - pointer, button, and axis events, and returns PNG bytes for `screenshot` via a - direct GLES framebuffer readback. `quit` provides graceful harness teardown. - The current keyboard command accepts Linux evdev key codes rather than XKB - keysyms; a WayDriver adapter can map keysyms before sending, or we can add a - compositor-side mapping later. - -3. **Remaining Hearthspace-side gaps.** The response protocol is intentionally a - small line-based frame (`ok`, `err`, `ok ` + payload), not the - previously preferred length-prefixed binary envelope. Continuous video is not - implemented; screenshots are the supported capture path for now. - -## Incremental plan - -## Implementation notes from autonomous spike - -- The repository paths for the referenced planning docs are `todos/BACKENDS.md` - and `todos/TESTING.md`, not `docs/BACKENDS.md` / `docs/TESTING.md`. -- `--headless` now starts a real Smithay surfaceless EGL/GLES backend backed by - an offscreen renderbuffer. The virtual output defaults to 1280x720 and can be - overridden with `--headless-size WIDTHxHEIGHT`. The advertised Wayland output - scale defaults to 1 and can be overridden with `--headless-scale INTEGER`. -- The command socket now has a minimal line-based response path: parsed commands - write `ok\n`; unsupported commands that parse but cannot complete write - `err \n`. This is intentionally smaller than the preferred future - length-prefixed binary framing, but gives WayDriver backend code a synchronous - reply mechanism for input commands. -- Implemented input command names are `key-down`, `key-up`, - `pointer-motion-abs`, `pointer-motion-rel`, `pointer-button-down`, - `pointer-button-up`, and `axis`. For now `key-down/up` accepts Linux evdev key - codes (the values from `input-event-codes.h`) rather than XKB keysyms; the - compositor adds Smithay's expected XKB offset internally. A future WayDriver - backend can either send evdev codes or add a keysym-to-evdev mapping layer. -- `screenshot` now reads back the current GLES framebuffer on the existing winit - backend and the new headless backend, then replies as - `ok \n`. A smoke test against `hearthspace --headless` - returned a valid PNG from the control socket. -- `quit` stops the compositor cleanly over the control socket so harnesses do - not need to rely on process termination for normal teardown. -- `--no-shell` skips spawning the shell client, which keeps headless WayDriver - runs focused on the app under test. -- `tests/headless_control.rs` is an ignored integration smoke test for the - compositor-side protocol. It also has a `test-apps` feature-gated variant that - spawns the in-repo GTK a11y client and drives input/screenshot commands - against it. It probes for the client on the AT-SPI bus and treats that as an - opt-in assertion when `HEARTHSPACE_REQUIRE_ATSPI=1` is set, because the GTK - app currently renders under the headless compositor in this VM without - registering as an AT-SPI application root on the host bus. Run it with - `cargo test --test headless_control -- --ignored` or - `cargo test --features test-apps --test headless_control -- --ignored` on - machines with surfaceless EGL support. - -### Phase 0 — design + spike ✅ - -- [x] Validate Smithay headless offscreen rendering (GLES + synthetic `Output`) - in the compositor; confirm a client can connect and we can read back a - frame to PNG. -- [x] Decide the control-socket reply protocol (length-prefixed binary vs. a - small request/response framing) — see Decisions. - -### Phase 1 — headless backend ✅ - -- [x] Add a `Backend::Headless` variant; gate behind a `--headless` flag (and/or - a cargo feature) with a deterministic `WAYLAND_DISPLAY` + runtime dir. -- [x] Fixed virtual output size from a CLI arg (mirror WayDriver's - `resolution` / `scale`). -- [x] **Done when:** `hearthspace --headless` runs with no monitor, a client can - connect, and the process is idle when nothing animates. - -### Phase 2 — input + screenshot IPC ✅/⬜ - -- [x] Extend the control protocol with input + `screenshot` commands and a reply - channel. -- [x] Synthesize input into the Smithay seat; implement framebuffer→PNG readback. -- [x] Add a control-socket `quit` command for graceful harness teardown. -- [x] Add an ignored integration smoke test that drives input commands, - captures a screenshot, and quits over the socket. -- [x] Add an ignored, `test-apps`-gated smoke test that spawns the GTK a11y - client and drives pointer/key/screenshot commands against it. It includes - an opt-in AT-SPI assertion via `HEARTHSPACE_REQUIRE_ATSPI=1`. -- [x] **Done when:** a script can drive a headless client end-to-end (move - pointer, click, type, screenshot) over the socket. - -### Phase 3 — WayDriver backend crates ✅ - -- [x] Implement `HearthspaceCompositor` (`CompositorRuntime`), - `HearthspaceInput` (`InputBackend`), `HearthspaceCapture` (`CaptureBackend`, - overriding `grab_screenshot`/`take_screenshot` to bypass PipeWire). -- [x] Add an ignored WayDriver-trait smoke test that starts headless Hearthspace, - drives input, captures a PNG screenshot, and tears down through the - adapter. -- [x] Wire them into a `Session` and stand up the first AT-SPI-driven E2E test - against the in-repo a11y test app. -- [x] **Done when:** a `cargo test` E2E spins up headless Hearthspace, launches a - client, locates a widget by XPath, clicks it, and asserts on the result. - -### Phase 4 — optional follow-ups ⬜ - -- [ ] Video recording (PipeWire) if needed for CI artifacts. -- [x] Confirm the Xilem shell's Masonry/AccessKit AT-SPI tree is XPath-locatable at runtime. - `tests/waydriver_hearthspace.rs` starts headless Hearthspace with the shell, - runs it under a private session bus, toggles `org.a11y.Status.ScreenReaderEnabled` - on that throwaway bus so AccessKit registers on AT-SPI, targets the shell - app root (`hearthspace`), and locates the `LEFT` shell button by XPath. - -## Decisions - -- **Where does the backend crate live?** Since the goal is testing Hearthspace - itself, keep the adapter in this repo as `crates/waydriver-hearthspace`, using - the published `waydriver` crate from crates.io for the shared traits. Upstreaming - later stays possible. -- **Control-socket reply protocol.** Implemented as a minimal request/reply - protocol over the existing Unix stream: `ok\n`, `err \n`, or - `ok \n` for screenshots. This keeps the current shell - command model simple while giving tests a binary-safe screenshot path. -- **Headless gating.** CLI flag vs. cargo feature — a runtime `--headless` flag - keeps a single binary (simpler for the backend to spawn) and avoids a build - matrix. - -## Open questions - -- Does Smithay's headless GLES path on our ARM64 VM read back framebuffers - without a real GBM device, or do we need a software/llvmpipe EGL? **Answered:** - surfaceless EGL works on the VM, and headless screenshot smoke tests return - valid PNGs. -- Tokio dev-dependency: the backend crates pull `tokio`/`zbus`/`async-trait` - into `[dev-dependencies]` only — confirm that doesn't leak into the main build. -- How much CI cost does an E2E suite add, and should it be a separate, opt-in - job (like WayDriver's own `--ignored` e2e split)? -- 2.51.2 From 440fc3d950d47f8e0e77c626eca48a546e0940e3 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 02:32:51 -0700 Subject: [PATCH 20/22] chore: add E2E tests --- .github/workflows/e2e.yml | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..16538ad --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,62 @@ +name: E2E Tests + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + e2e: + name: Headless E2E + runs-on: ubuntu-latest + timeout-minutes: 45 + + env: + CARGO_TERM_COLOR: always + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install native dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + at-spi2-core \ + build-essential \ + clang \ + dbus \ + libclang-dev \ + libdrm-dev \ + libegl1-mesa-dev \ + libgbm-dev \ + libgles2-mesa-dev \ + libgstreamer-plugins-base1.0-dev \ + libgstreamer1.0-dev \ + libgtk-4-dev \ + libinput-dev \ + libseat-dev \ + libsystemd-dev \ + libudev-dev \ + libwayland-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + pkg-config \ + wayland-protocols \ + wayland-utils + + - name: Set up Rust + uses: ./.github/actions/setup-rust + + - name: Run headless control E2E tests + run: cargo test --test headless_control -- --ignored + + - name: Run headless control E2E tests with test apps + run: cargo test --features test-apps --test headless_control -- --ignored + + - name: Run WayDriver E2E tests + run: cargo test --test waydriver_hearthspace -- --ignored + + - name: Run WayDriver E2E tests with test apps + run: cargo test --features test-apps --test waydriver_hearthspace -- --ignored -- 2.51.2 From 84bedf89faa8d240bf29e1397c5eecc0569ad0d6 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 02:51:52 -0700 Subject: [PATCH 21/22] chore: make e2e tests opt-in feature-wise --- .github/workflows/e2e.yml | 8 +------- CONTRIBUTING.md | 7 ++++--- Cargo.toml | 10 ++++++++-- docs/E2E_TESTING.md | 10 ++++++---- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 16538ad..f6cfaa2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -50,13 +50,7 @@ jobs: uses: ./.github/actions/setup-rust - name: Run headless control E2E tests - run: cargo test --test headless_control -- --ignored - - - name: Run headless control E2E tests with test apps run: cargo test --features test-apps --test headless_control -- --ignored - name: Run WayDriver E2E tests - run: cargo test --test waydriver_hearthspace -- --ignored - - - name: Run WayDriver E2E tests with test apps - run: cargo test --features test-apps --test waydriver_hearthspace -- --ignored + run: cargo test --features e2e,test-apps --test waydriver_hearthspace -- --ignored diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 497002a..7449de5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,8 @@ sudo apt-get install -y build-essential cargo rustc rustfmt pkg-config clang lib For E2E testing, the WayDriver adapter depends on the published `waydriver` crate, which links GStreamer even when the Hearthspace backend overrides screenshot capture. Install the development packages before building tests that include -`waydriver-hearthspace`: +`waydriver-hearthspace`. Those crates are gated behind the Cargo feature `e2e`, +so normal builds and CI do not require GStreamer: ```sh sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev @@ -135,8 +136,8 @@ The WayDriver backend adapter lives in `crates/waydriver-hearthspace` and uses the published `waydriver` crate. Its ignored smoke tests can be run with: ```sh -cargo test --test waydriver_hearthspace -- --ignored -cargo test --features test-apps --test waydriver_hearthspace -- --ignored +cargo test --features e2e --test waydriver_hearthspace -- --ignored +cargo test --features e2e,test-apps --test waydriver_hearthspace -- --ignored ``` The feature-gated WayDriver `Session` test launches the GTK test app through diff --git a/Cargo.toml b/Cargo.toml index 1393db2..de680b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ rust-version = "1.93.1" license = "Apache-2.0" [features] +e2e = ["dep:waydriver", "dep:waydriver-hearthspace"] test-apps = ["dep:gtk"] [dependencies] @@ -37,6 +38,8 @@ smithay = { version = "0.7.0", default-features = false, features = ["backend_wi tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } wayland-protocols = { version = "0.32.8", features = ["server"] } wayland-server = "0.31.9" +waydriver = { version = "0.3.9", optional = true } +waydriver-hearthspace = { path = "crates/waydriver-hearthspace", optional = true } [dev-dependencies] proptest = "1" @@ -44,8 +47,11 @@ rstest = "0.26" tempfile = "3" tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] } tokio-util = "0.7" -waydriver = "0.3.9" -waydriver-hearthspace = { path = "crates/waydriver-hearthspace" } + +[[test]] +name = "waydriver_hearthspace" +path = "tests/waydriver_hearthspace.rs" +required-features = ["e2e"] # Workaround for an upstream incompatibility pulled in via Xilem/Masonry: # `vello_shaders` 0.8's build script depends on `naga` 28 with the `wgsl-in` diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md index b3fdc15..46eee73 100644 --- a/docs/E2E_TESTING.md +++ b/docs/E2E_TESTING.md @@ -186,21 +186,23 @@ cargo test --features test-apps --test headless_control -- --ignored WayDriver smoke tests: ```sh -cargo test --test waydriver_hearthspace -- --ignored -cargo test --features test-apps --test waydriver_hearthspace -- --ignored +cargo test --features e2e --test waydriver_hearthspace -- --ignored +cargo test --features e2e,test-apps --test waydriver_hearthspace -- --ignored ``` When debugging AT-SPI discovery, enable WayDriver logs: ```sh -RUST_LOG=waydriver=debug cargo test --features test-apps --test waydriver_hearthspace -- --ignored --nocapture +RUST_LOG=waydriver=debug cargo test --features e2e,test-apps --test waydriver_hearthspace -- --ignored --nocapture ``` ## System Dependencies The published `waydriver` crate links GStreamer even though the Hearthspace adapter overrides screenshot capture. Install the development packages before -building tests that include `waydriver-hearthspace`: +building tests that include `waydriver-hearthspace`. Those crates are gated +behind the Cargo feature `e2e`, so normal builds and CI do not require +GStreamer: ```sh sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev -- 2.51.2 From ecd4ab0126867a9c2c97dd49d4428e1bb0d2aeb8 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 27 Jun 2026 02:55:48 -0700 Subject: [PATCH 22/22] chore: move tests from ignored to e2e flag checking --- .github/workflows/e2e.yml | 4 ++-- CONTRIBUTING.md | 17 +++++++++-------- Cargo.toml | 5 +++++ docs/E2E_TESTING.md | 21 +++++++++++---------- tests/headless_control.rs | 2 -- tests/waydriver_hearthspace.rs | 3 --- 6 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f6cfaa2..1df6e18 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -50,7 +50,7 @@ jobs: uses: ./.github/actions/setup-rust - name: Run headless control E2E tests - run: cargo test --features test-apps --test headless_control -- --ignored + run: cargo test --features e2e,test-apps --test headless_control - name: Run WayDriver E2E tests - run: cargo test --features e2e,test-apps --test waydriver_hearthspace -- --ignored + run: cargo test --features e2e,test-apps --test waydriver_hearthspace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7449de5..b46586f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,22 +133,23 @@ button commands use Linux input button codes, for example `272` (`0x110`) for th left mouse button. The WayDriver backend adapter lives in `crates/waydriver-hearthspace` and uses -the published `waydriver` crate. Its ignored smoke tests can be run with: +the published `waydriver` crate. Its E2E smoke tests can be run with: ```sh -cargo test --features e2e --test waydriver_hearthspace -- --ignored -cargo test --features e2e,test-apps --test waydriver_hearthspace -- --ignored +cargo test --features e2e --test waydriver_hearthspace +cargo test --features e2e,test-apps --test waydriver_hearthspace ``` The feature-gated WayDriver `Session` test launches the GTK test app through WayDriver, locates its `Research Workspace` heading by XPath on the AT-SPI tree, clicks it, and captures a screenshot. -The non-feature WayDriver ignored suite also verifies the Xilem shell's -Masonry/AccessKit tree. That test starts a private `dbus-daemon --session`, points -Hearthspace and WayDriver at it, and enables `org.a11y.Status.ScreenReaderEnabled` -inside that throwaway bus because AccessKit's Unix bridge registers with AT-SPI -only while screen-reader status is active. +The non-`test-apps` WayDriver suite also verifies the Xilem shell's +Masonry/AccessKit tree. That test starts a private `dbus-daemon --session`, +points Hearthspace and WayDriver at it, and enables +`org.a11y.Status.ScreenReaderEnabled` inside that throwaway bus because +AccessKit's Unix bridge registers with AT-SPI only while screen-reader status is +active. See [docs/E2E_TESTING.md](./docs/E2E_TESTING.md) for the evergreen technical architecture of the headless and WayDriver E2E harness. diff --git a/Cargo.toml b/Cargo.toml index de680b4..cfee0b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,11 @@ tempfile = "3" tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] } tokio-util = "0.7" +[[test]] +name = "headless_control" +path = "tests/headless_control.rs" +required-features = ["e2e"] + [[test]] name = "waydriver_hearthspace" path = "tests/waydriver_hearthspace.rs" diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md index 46eee73..4e4970c 100644 --- a/docs/E2E_TESTING.md +++ b/docs/E2E_TESTING.md @@ -163,9 +163,10 @@ WayDriver `Session` path: launches the GTK test app through WayDriver, locates `Research Workspace` by XPath, clicks it, and captures a screenshot. -The ignored E2E tests are serialized inside each test binary with a static Tokio -mutex because they share deterministic socket names and, for the shell test, -temporarily modify process environment variables. +The E2E test targets require the Cargo feature `e2e`, which keeps them out of +normal `cargo test --all-targets` and CI runs. Tests are serialized inside each +test binary with a static lock because they share deterministic socket names +and, for the shell test, temporarily modify process environment variables. ## Running Tests @@ -179,21 +180,21 @@ cargo clippy --all-targets Headless control socket smoke tests: ```sh -cargo test --test headless_control -- --ignored -cargo test --features test-apps --test headless_control -- --ignored +cargo test --features e2e --test headless_control +cargo test --features e2e,test-apps --test headless_control ``` WayDriver smoke tests: ```sh -cargo test --features e2e --test waydriver_hearthspace -- --ignored -cargo test --features e2e,test-apps --test waydriver_hearthspace -- --ignored +cargo test --features e2e --test waydriver_hearthspace +cargo test --features e2e,test-apps --test waydriver_hearthspace ``` When debugging AT-SPI discovery, enable WayDriver logs: ```sh -RUST_LOG=waydriver=debug cargo test --features e2e,test-apps --test waydriver_hearthspace -- --ignored --nocapture +RUST_LOG=waydriver=debug cargo test --features e2e,test-apps --test waydriver_hearthspace -- --nocapture ``` ## System Dependencies @@ -228,5 +229,5 @@ systems. a general binary RPC protocol. - Keyboard input maps only the keysyms currently needed by tests. Add mappings as tests require them. -- Ignored E2E tests require surfaceless EGL support. They are not part of the - default `cargo test` run. +- E2E tests require surfaceless EGL support and the Cargo feature `e2e`. They + are not part of the default `cargo test` run. diff --git a/tests/headless_control.rs b/tests/headless_control.rs index 9f0c0d9..c84deab 100644 --- a/tests/headless_control.rs +++ b/tests/headless_control.rs @@ -74,7 +74,6 @@ impl Drop for HeadlessCompositor { } #[test] -#[ignore = "requires surfaceless EGL and the headless compositor socket"] fn headless_control_socket_drives_input_screenshot_and_quit() { let _guard = HEADLESS_TEST_LOCK.lock().expect("headless test lock"); let mut compositor = HeadlessCompositor::spawn(); @@ -103,7 +102,6 @@ fn headless_control_socket_drives_input_screenshot_and_quit() { #[cfg(feature = "test-apps")] #[test] -#[ignore = "requires surfaceless EGL, GTK, and the headless compositor socket"] fn headless_control_socket_spawns_and_drives_real_gtk_client() { let _guard = HEADLESS_TEST_LOCK.lock().expect("headless test lock"); let mut compositor = HeadlessCompositor::spawn_with_size("800x600"); diff --git a/tests/waydriver_hearthspace.rs b/tests/waydriver_hearthspace.rs index 3abd5c7..626fa7a 100644 --- a/tests/waydriver_hearthspace.rs +++ b/tests/waydriver_hearthspace.rs @@ -18,7 +18,6 @@ const SHELL_ACCESSIBLE_NAME: &str = "hearthspace"; static WAYDRIVER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); #[tokio::test] -#[ignore = "requires surfaceless EGL and the headless compositor socket"] async fn waydriver_backends_drive_input_capture_and_teardown() { init_tracing(); let _guard = WAYDRIVER_TEST_LOCK.lock().await; @@ -58,7 +57,6 @@ async fn waydriver_backends_drive_input_capture_and_teardown() { } #[tokio::test] -#[ignore = "requires surfaceless EGL and shell AT-SPI exposure"] async fn waydriver_session_locates_xilem_shell_by_xpath() -> Result<(), Box> { init_tracing(); @@ -114,7 +112,6 @@ async fn run_xilem_shell_xpath_check() -> Result<(), Box> #[cfg(feature = "test-apps")] #[tokio::test] -#[ignore = "requires surfaceless EGL, GTK, and AT-SPI exposure"] async fn waydriver_session_locates_real_client_by_xpath() { init_tracing(); let _guard = WAYDRIVER_TEST_LOCK.lock().await; -- 2.51.2