diff --git a/crates/hearthspace-compositor/src/compositor/client.rs b/crates/hearthspace-compositor/src/compositor/client.rs new file mode 100644 index 0000000..5bc5d7a --- /dev/null +++ b/crates/hearthspace-compositor/src/compositor/client.rs @@ -0,0 +1,18 @@ +use smithay::wayland::compositor::CompositorClientState; +use tracing::debug; +use wayland_server::backend::{ClientData, ClientId, DisconnectReason}; + +#[derive(Default)] +pub(super) struct ClientState { + pub(super) compositor_state: CompositorClientState, +} + +impl ClientData for ClientState { + fn initialized(&self, client_id: ClientId) { + debug!(?client_id, "Wayland client initialized"); + } + + fn disconnected(&self, client_id: ClientId, reason: DisconnectReason) { + debug!(?client_id, ?reason, "Wayland client disconnected"); + } +} diff --git a/crates/hearthspace-compositor/src/compositor/input.rs b/crates/hearthspace-compositor/src/compositor/input.rs index b30284e..2e24085 100644 --- a/crates/hearthspace-compositor/src/compositor/input.rs +++ b/crates/hearthspace-compositor/src/compositor/input.rs @@ -2,23 +2,24 @@ use smithay::{ backend::input::{ - AbsolutePositionEvent, Axis, ButtonState, Event, InputBackend, InputEvent, KeyState, + AbsolutePositionEvent, Axis, ButtonState, Event, InputBackend, InputEvent, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, TouchEvent, }, input::{ - keyboard::{FilterResult, keysyms}, - pointer::{AxisFrame, ButtonEvent, MotionEvent}, + keyboard::FilterResult, + pointer::{AxisFrame, ButtonEvent}, }, - utils::{Logical, Point, Rectangle, SERIAL_COUNTER}, + utils::SERIAL_COUNTER, }; use tracing::{debug, trace}; -use crate::config::{SCROLL_ZOOM_SENSITIVITY, WHEEL_SCROLL_PIXEL_EQUIVALENT}; +use crate::config::WHEEL_SCROLL_PIXEL_EQUIVALENT; -use super::{ - App, DragState, HitTarget, cursor::CursorIcon, idle::ActivityReason, - windows::resize_cursor_icon, -}; +use super::{App, DragState, HitTarget, idle::ActivityReason}; + +mod pointer; + +use pointer::relative_pointer_location; pub(in crate::compositor) fn handle_input_event( state: &mut App, @@ -308,434 +309,3 @@ fn scroll_amount_for_axis( .map(|amount| amount / 120.0 * WHEEL_SCROLL_PIXEL_EQUIVALENT) }) } - -fn is_super_keysym(keysym: u32) -> bool { - matches!( - keysym, - keysyms::KEY_Super_L - | keysyms::KEY_Super_R - | keysyms::KEY_Meta_L - | keysyms::KEY_Meta_R - | keysyms::KEY_Hyper_L - | keysyms::KEY_Hyper_R - ) -} - -impl App { - pub(super) fn synthesize_key(&mut self, evdev_keycode: u32, state: KeyState) { - trace!(evdev_keycode, ?state, "synthesizing key input"); - 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) { - trace!(?location, "synthesizing absolute pointer motion"); - self.apply_pointer_motion(location, event_time_msec()); - } - - pub(super) fn synthesize_pointer_motion_rel(&mut self, delta: Point) { - trace!(?delta, "synthesizing relative pointer motion"); - self.apply_pointer_motion( - relative_pointer_location(self.raw_pointer_location, delta), - event_time_msec(), - ); - } - - pub(super) fn synthesize_pointer_button(&mut self, button: u32, state: ButtonState) { - trace!(button, ?state, "synthesizing pointer button"); - 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()) - { - debug!("ending synthetic pointer drag/resize from button release"); - 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 }) => { - debug!( - window_id = self.windows[window_index].id, - "close button clicked from synthetic input" - ); - 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.raw_pointer_location, - window_start: self.windows[window_index].position, - }); - debug!( - window_id = self.windows[window_index].id, - "started titlebar drag from synthetic input" - ); - 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); - debug!( - window_id = self.windows[window_index].id, - ?edges, - "started resize from synthetic pointer press" - ); - 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 => { - debug!("clearing keyboard focus from synthetic pointer press on canvas"); - 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) { - trace!(horizontal, vertical, "synthesizing pointer axis"); - 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) { - let previous_location = self.pointer_location; - self.raw_pointer_location = location; - self.pointer_location = clamp_point_to_outputs(location, &self.output_logical_rects()); - if self.software_cursor_visible && self.pointer_location != previous_location { - self.request_redraw(); - } - - if let Some(drag) = self.drag.as_ref() { - let delta = self.raw_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; - trace!(window_id, ?new_position, "updated window drag 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.raw_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); - } - - pub(super) fn reconcile_pointer_after_output_geometry_change(&mut self) { - let output_rects = self.output_logical_rects(); - let previous_location = self.pointer_location; - self.pointer_location = if self.pointer_location == Point::from((0.0, 0.0)) { - primary_output_center(&output_rects).unwrap_or(self.pointer_location) - } else { - clamp_point_to_outputs(self.pointer_location, &output_rects) - }; - self.raw_pointer_location = self.pointer_location; - if self.software_cursor_visible && self.pointer_location != previous_location { - self.request_redraw(); - } - - 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 { - surface, - surface_location, - .. - }) => 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: event_time_msec(), - }, - ); - pointer.frame(self); - debug!( - ?previous_location, - location = ?self.pointer_location, - output_rects = ?output_rects, - "reconciled pointer after output geometry change" - ); - } - - fn super_modifier_active(&self) -> bool { - self.keyboard.modifier_state().logo - || self.keyboard.with_pressed_keysyms(|pressed| { - pressed - .iter() - .any(|key| is_super_keysym(key.modified_sym().raw())) - }) - } - - fn scroll_zoom_active(&self) -> bool { - self.scroll_zooms_without_super || self.super_modifier_active() - } - - fn zoom_from_scroll(&mut self, event: &impl PointerAxisEvent) -> bool { - let Some(scroll_amount) = scroll_amount_for_axis(event, Axis::Vertical) else { - return false; - }; - if scroll_amount == 0.0 { - return true; - } - - self.advance_viewport_animation(); - debug!(scroll_amount, "zooming viewport from scroll input"); - self.animate_zoom_around_viewport_center((-scroll_amount * SCROLL_ZOOM_SENSITIVITY).exp()); - 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 primary_output_center(output_rects: &[Rectangle]) -> Option> { - let rect = output_rects.first()?; - Some(Point::from(( - f64::from(rect.loc.x) + f64::from(rect.size.w.max(1)) / 2.0, - f64::from(rect.loc.y) + f64::from(rect.size.h.max(1)) / 2.0, - ))) -} - -fn clamp_point_to_outputs( - location: Point, - output_rects: &[Rectangle], -) -> Point { - output_rects - .iter() - .map(|rect| clamp_point_to_rect(location, *rect)) - .min_by(|a, b| { - squared_distance(location, *a) - .partial_cmp(&squared_distance(location, *b)) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .unwrap_or(location) -} - -fn clamp_point_to_rect( - location: Point, - rect: Rectangle, -) -> Point { - Point::from(( - location.x.clamp( - f64::from(rect.loc.x), - f64::from(rect.loc.x + rect.size.w.saturating_sub(1)), - ), - location.y.clamp( - f64::from(rect.loc.y), - f64::from(rect.loc.y + rect.size.h.saturating_sub(1)), - ), - )) -} - -fn squared_distance(a: Point, b: Point) -> f64 { - let dx = a.x - b.x; - let dy = a.y - b.y; - dx * dx + dy * dy -} - -fn relative_pointer_location( - raw_location: Point, - delta: Point, -) -> Point { - raw_location + delta -} - -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() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn relative_motion_continues_from_unclamped_location_after_visible_pointer_hits_edge() { - let output_rects = [Rectangle::from_size((100, 100).into())]; - let raw_location = Point::from((120.0, 50.0)); - let visible_location = clamp_point_to_outputs(raw_location, &output_rects); - - assert_eq!(visible_location, Point::from((99.0, 50.0))); - assert_eq!( - relative_pointer_location(raw_location, Point::from((20.0, 0.0))), - Point::from((140.0, 50.0)) - ); - } - - #[test] - fn pointer_clamps_to_real_outputs_not_dead_space_in_bounding_box() { - let output_rects = [ - Rectangle::new((0, 0).into(), (2560, 1440).into()), - Rectangle::new((2560, 0).into(), (1366, 768).into()), - ]; - - assert_eq!( - clamp_point_to_outputs(Point::from((3200.0, 1200.0)), &output_rects), - Point::from((3200.0, 767.0)) - ); - } - - #[test] - fn primary_output_center_uses_first_active_output() { - let output_rects = [ - Rectangle::new((100, 20).into(), (800, 600).into()), - Rectangle::new((900, 20).into(), (640, 480).into()), - ]; - - assert_eq!( - primary_output_center(&output_rects), - Some(Point::from((500.0, 320.0))) - ); - } -} diff --git a/crates/hearthspace-compositor/src/compositor/input/pointer.rs b/crates/hearthspace-compositor/src/compositor/input/pointer.rs new file mode 100644 index 0000000..749d8c8 --- /dev/null +++ b/crates/hearthspace-compositor/src/compositor/input/pointer.rs @@ -0,0 +1,461 @@ +use smithay::{ + backend::input::{Axis, ButtonState, InputBackend, KeyState, PointerAxisEvent}, + input::{ + keyboard::{FilterResult, keysyms}, + pointer::{AxisFrame, ButtonEvent, MotionEvent}, + }, + utils::{Logical, Point, Rectangle, SERIAL_COUNTER}, +}; +use tracing::{debug, trace}; + +use crate::config::SCROLL_ZOOM_SENSITIVITY; + +use super::scroll_amount_for_axis; +use crate::compositor::{ + App, DragState, HitTarget, cursor::CursorIcon, idle::ActivityReason, + windows::resize_cursor_icon, +}; + +fn is_super_keysym(keysym: u32) -> bool { + matches!( + keysym, + keysyms::KEY_Super_L + | keysyms::KEY_Super_R + | keysyms::KEY_Meta_L + | keysyms::KEY_Meta_R + | keysyms::KEY_Hyper_L + | keysyms::KEY_Hyper_R + ) +} + +impl App { + pub(in crate::compositor) fn synthesize_key(&mut self, evdev_keycode: u32, state: KeyState) { + trace!(evdev_keycode, ?state, "synthesizing key input"); + 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(in crate::compositor) fn synthesize_pointer_motion_abs( + &mut self, + location: Point, + ) { + trace!(?location, "synthesizing absolute pointer motion"); + self.apply_pointer_motion(location, event_time_msec()); + } + + pub(in crate::compositor) fn synthesize_pointer_motion_rel( + &mut self, + delta: Point, + ) { + trace!(?delta, "synthesizing relative pointer motion"); + self.apply_pointer_motion( + relative_pointer_location(self.raw_pointer_location, delta), + event_time_msec(), + ); + } + + pub(in crate::compositor) fn synthesize_pointer_button( + &mut self, + button: u32, + state: ButtonState, + ) { + trace!(button, ?state, "synthesizing pointer button"); + 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()) + { + debug!("ending synthetic pointer drag/resize from button release"); + 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 }) => { + debug!( + window_id = self.windows[window_index].id, + "close button clicked from synthetic input" + ); + 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.raw_pointer_location, + window_start: self.windows[window_index].position, + }); + debug!( + window_id = self.windows[window_index].id, + "started titlebar drag from synthetic input" + ); + 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); + debug!( + window_id = self.windows[window_index].id, + ?edges, + "started resize from synthetic pointer press" + ); + 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 => { + debug!("clearing keyboard focus from synthetic pointer press on canvas"); + 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(in crate::compositor) fn synthesize_axis(&mut self, horizontal: f64, vertical: f64) { + trace!(horizontal, vertical, "synthesizing pointer axis"); + 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); + } + + pub(super) fn apply_pointer_motion(&mut self, location: Point, time: u32) { + let previous_location = self.pointer_location; + self.raw_pointer_location = location; + self.pointer_location = clamp_point_to_outputs(location, &self.output_logical_rects()); + if self.software_cursor_visible && self.pointer_location != previous_location { + self.request_redraw(); + } + + if let Some(drag) = self.drag.as_ref() { + let delta = self.raw_pointer_location - drag.pointer_start; + let new_position = crate::geometry::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; + trace!(window_id, ?new_position, "updated window drag 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.raw_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); + } + + pub(in crate::compositor) fn reconcile_pointer_after_output_geometry_change(&mut self) { + let output_rects = self.output_logical_rects(); + let previous_location = self.pointer_location; + self.pointer_location = if self.pointer_location == Point::from((0.0, 0.0)) { + primary_output_center(&output_rects).unwrap_or(self.pointer_location) + } else { + clamp_point_to_outputs(self.pointer_location, &output_rects) + }; + self.raw_pointer_location = self.pointer_location; + if self.software_cursor_visible && self.pointer_location != previous_location { + self.request_redraw(); + } + + 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 { + surface, + surface_location, + .. + }) => 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: event_time_msec(), + }, + ); + pointer.frame(self); + debug!( + ?previous_location, + location = ?self.pointer_location, + output_rects = ?output_rects, + "reconciled pointer after output geometry change" + ); + } + + fn super_modifier_active(&self) -> bool { + self.keyboard.modifier_state().logo + || self.keyboard.with_pressed_keysyms(|pressed| { + pressed + .iter() + .any(|key| is_super_keysym(key.modified_sym().raw())) + }) + } + + pub(super) fn scroll_zoom_active(&self) -> bool { + self.scroll_zooms_without_super || self.super_modifier_active() + } + + pub(super) fn zoom_from_scroll( + &mut self, + event: &impl PointerAxisEvent, + ) -> bool { + let Some(scroll_amount) = scroll_amount_for_axis(event, Axis::Vertical) else { + return false; + }; + if scroll_amount == 0.0 { + return true; + } + + self.advance_viewport_animation(); + debug!(scroll_amount, "zooming viewport from scroll input"); + self.animate_zoom_around_viewport_center((-scroll_amount * SCROLL_ZOOM_SENSITIVITY).exp()); + 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 primary_output_center(output_rects: &[Rectangle]) -> Option> { + let rect = output_rects.first()?; + Some(Point::from(( + f64::from(rect.loc.x) + f64::from(rect.size.w.max(1)) / 2.0, + f64::from(rect.loc.y) + f64::from(rect.size.h.max(1)) / 2.0, + ))) +} + +fn clamp_point_to_outputs( + location: Point, + output_rects: &[Rectangle], +) -> Point { + output_rects + .iter() + .map(|rect| clamp_point_to_rect(location, *rect)) + .min_by(|a, b| { + squared_distance(location, *a) + .partial_cmp(&squared_distance(location, *b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or(location) +} + +fn clamp_point_to_rect( + location: Point, + rect: Rectangle, +) -> Point { + Point::from(( + location.x.clamp( + f64::from(rect.loc.x), + f64::from(rect.loc.x + rect.size.w.saturating_sub(1)), + ), + location.y.clamp( + f64::from(rect.loc.y), + f64::from(rect.loc.y + rect.size.h.saturating_sub(1)), + ), + )) +} + +fn squared_distance(a: Point, b: Point) -> f64 { + let dx = a.x - b.x; + let dy = a.y - b.y; + dx * dx + dy * dy +} + +pub(super) fn relative_pointer_location( + raw_location: Point, + delta: Point, +) -> Point { + raw_location + delta +} + +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() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_motion_continues_from_unclamped_location_after_visible_pointer_hits_edge() { + let output_rects = [Rectangle::from_size((100, 100).into())]; + let raw_location = Point::from((120.0, 50.0)); + let visible_location = clamp_point_to_outputs(raw_location, &output_rects); + + assert_eq!(visible_location, Point::from((99.0, 50.0))); + assert_eq!( + relative_pointer_location(raw_location, Point::from((20.0, 0.0))), + Point::from((140.0, 50.0)) + ); + } + + #[test] + fn pointer_clamps_to_real_outputs_not_dead_space_in_bounding_box() { + let output_rects = [ + Rectangle::new((0, 0).into(), (2560, 1440).into()), + Rectangle::new((2560, 0).into(), (1366, 768).into()), + ]; + + assert_eq!( + clamp_point_to_outputs(Point::from((3200.0, 1200.0)), &output_rects), + Point::from((3200.0, 767.0)) + ); + } + + #[test] + fn primary_output_center_uses_first_active_output() { + let output_rects = [ + Rectangle::new((100, 20).into(), (800, 600).into()), + Rectangle::new((900, 20).into(), (640, 480).into()), + ]; + + assert_eq!( + primary_output_center(&output_rects), + Some(Point::from((500.0, 320.0))) + ); + } +} diff --git a/crates/hearthspace-compositor/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs index 193af03..2d0c86c 100644 --- a/crates/hearthspace-compositor/src/compositor/mod.rs +++ b/crates/hearthspace-compositor/src/compositor/mod.rs @@ -8,6 +8,7 @@ use std::{ use crate::{RunOptions, config::*, geometry::CanvasPoint}; use hearthspace_shell::app_catalog::AppCatalog; +mod client; mod cursor; mod handlers; mod idle; @@ -41,6 +42,7 @@ use windows::ResizeEdges; pub use udev::run_udev; use calloop::signals::{Signal, Signals}; +use client::ClientState; use cursor::{CursorIcon, SoftwareCursor}; #[cfg(any(feature = "winit", feature = "udev"))] use smithay::backend::input::TouchSlot; @@ -67,7 +69,7 @@ use smithay::{ }, utils::{Buffer as BufferCoord, Logical, Physical, Point, Size}, wayland::{ - compositor::{CompositorClientState, CompositorState}, + compositor::CompositorState, dmabuf::{DmabufFeedbackBuilder, DmabufGlobal, DmabufState, ImportNotifier}, output::OutputManagerState, selection::data_device::DataDeviceState, @@ -78,11 +80,7 @@ use smithay::{ }; use tracing::{debug, error, info}; use wayland_protocols::xdg::shell::server::xdg_toplevel; -use wayland_server::{ - DisplayHandle, - backend::{ClientData, ClientId, DisconnectReason}, - protocol::wl_surface::WlSurface, -}; +use wayland_server::{DisplayHandle, protocol::wl_surface::WlSurface}; struct ManagedWindow { id: u64, @@ -585,21 +583,6 @@ fn log_idle_transition(transition: IdleTransition) { ); } -#[derive(Default)] -struct ClientState { - compositor_state: CompositorClientState, -} - -impl ClientData for ClientState { - fn initialized(&self, client_id: ClientId) { - debug!(?client_id, "Wayland client initialized"); - } - - fn disconnected(&self, client_id: ClientId, reason: DisconnectReason) { - debug!(?client_id, ?reason, "Wayland client disconnected"); - } -} - delegate_xdg_shell!(App); delegate_compositor!(App); delegate_shm!(App); diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index d35fd1a..9e26f79 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -7,10 +7,12 @@ use wayland_server::{DisplayHandle, backend::GlobalId, protocol::wl_surface::WlS use super::App; -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(in crate::compositor) struct OutputLayout { - monitors: Vec, -} +mod layout; + +pub(in crate::compositor) use layout::OutputLayout; +#[cfg(feature = "udev")] +use layout::secondary_descriptors_for_layout; +use layout::{OutputGeometry, minimum_output_width, output_locations_for_layout}; pub(in crate::compositor) struct OutputSet { primary: OutputRecord, @@ -144,22 +146,6 @@ impl OutputSet { } } -/// Native layout policy: selected primary at (0, 0), all other connectors placed -/// horizontally to the right in stable connector-name order. -#[cfg(feature = "udev")] -fn secondary_descriptors_for_layout( - descriptors: Vec, - primary_name: &str, -) -> Vec { - let mut secondary = descriptors - .into_iter() - .skip(1) - .filter(|descriptor| descriptor.name != primary_name) - .collect::>(); - secondary.sort_by(|a, b| a.name.cmp(&b.name)); - secondary -} - impl OutputRecord { fn logical_size(&self) -> Size { Size::from((self.size.w, self.size.h)) @@ -202,58 +188,6 @@ impl OutputRecord { } } -impl OutputLayout { - pub(in crate::compositor) fn from_monitors( - monitors: Vec, - ) -> Self { - Self { monitors } - } - - fn location_for(&self, output_name: &str, role_id: &str) -> Option> { - let use_role_fallback = !default_placeholder_monitors(&self.monitors); - self.monitor_for(output_name, role_id, use_role_fallback) - .map(|monitor| Point::from((monitor.x, monitor.y))) - } - - fn scale_for(&self, output_name: &str, role_id: &str) -> Option { - self.monitor_for(output_name, role_id, true) - .map(|monitor| monitor.scale.max(1)) - } - - fn monitor_for( - &self, - output_name: &str, - role_id: &str, - use_role_fallback: bool, - ) -> Option<&hearthspace_ipc::MonitorConfig> { - self.monitors - .iter() - .find(|monitor| monitor.id == output_name || monitor.name == output_name) - .or_else(|| { - use_role_fallback - .then(|| self.monitors.iter().find(|monitor| monitor.id == role_id)) - .flatten() - }) - } -} - -fn default_placeholder_monitors(monitors: &[hearthspace_ipc::MonitorConfig]) -> bool { - monitors == hearthspace_ipc::Settings::default().monitors -} - -#[derive(Clone)] -struct OutputGeometry { - name: String, - size: Size, - scale: i32, -} - -impl OutputGeometry { - fn logical_size(&self) -> Size { - Size::from((self.size.w, self.size.h)) - } -} - impl From<&OutputRecord> for OutputGeometry { fn from(output: &OutputRecord) -> Self { Self { @@ -274,196 +208,6 @@ impl From<&OutputDescriptor> for OutputGeometry { } } -struct OutputLayoutPlan { - primary: Point, - primary_scale: i32, - secondary: Vec<(String, Point, i32)>, -} - -#[derive(Debug, Clone)] -struct OutputPlacement { - name: String, - size: Size, - location: Point, - scale: i32, -} - -impl OutputLayoutPlan { - fn secondary_location(&self, name: &str) -> Point { - self.secondary - .iter() - .find(|(secondary_name, _, _)| secondary_name == name) - .map(|(_, location, _)| *location) - .unwrap_or((0, 0).into()) - } - - fn secondary_scale(&self, name: &str) -> Option { - self.secondary - .iter() - .find(|(secondary_name, _, _)| secondary_name == name) - .map(|(_, _, scale)| *scale) - } -} - -fn output_locations_for_layout( - mut primary: OutputGeometry, - mut secondary: Vec, - layout: &OutputLayout, -) -> OutputLayoutPlan { - secondary.sort_by(|a, b| a.name.cmp(&b.name)); - if let Some(scale) = layout.scale_for(&primary.name, "primary") { - primary.scale = scale; - } - for (index, output) in secondary.iter_mut().enumerate() { - let role_id = secondary_role_id(index); - if let Some(scale) = layout.scale_for(&output.name, &role_id) { - output.scale = scale; - } - } - let fallback_primary = primary.clone(); - let fallback_secondary = secondary.clone(); - - let primary_location = layout - .location_for(&primary.name, "primary") - .unwrap_or((0, 0).into()); - let mut placed = vec![OutputPlacement { - name: primary.name.clone(), - size: primary.logical_size(), - location: primary_location, - scale: primary.scale, - }]; - let mut unplaced = Vec::new(); - - for (index, output) in secondary.into_iter().enumerate() { - let role_id = secondary_role_id(index); - if let Some(location) = layout.location_for(&output.name, &role_id) { - let size = output.logical_size(); - placed.push(OutputPlacement { - name: output.name, - size, - location, - scale: output.scale, - }); - } else { - unplaced.push(output); - } - } - - let fallback_y = primary_location.y; - let mut next_x = placed - .iter() - .map(|placement| placement.location.x + placement.size.w) - .max() - .unwrap_or(0); - for output in unplaced { - let size = output.logical_size(); - let location = Point::from((next_x, fallback_y)); - next_x += size.w; - placed.push(OutputPlacement { - name: output.name, - size, - location, - scale: output.scale, - }); - } - - if placements_overlap(&placed) { - debug!(placements = ?placement_debug(&placed), "repairing overlapping active monitor layout"); - placed = fallback_output_placements(fallback_primary, fallback_secondary); - } - - let min_x = placed - .iter() - .map(|placement| placement.location.x) - .min() - .unwrap_or(0); - let min_y = placed - .iter() - .map(|placement| placement.location.y) - .min() - .unwrap_or(0); - - let mut primary_plan = Point::from((0, 0)); - let mut primary_scale = primary.scale; - let mut secondary_plan = Vec::new(); - for placement in placed { - let normalized = Point::from((placement.location.x - min_x, placement.location.y - min_y)); - if placement.name == primary.name { - primary_plan = normalized; - primary_scale = placement.scale; - } else { - secondary_plan.push((placement.name, normalized, placement.scale)); - } - } - - OutputLayoutPlan { - primary: primary_plan, - primary_scale, - secondary: secondary_plan, - } -} - -fn fallback_output_placements( - primary: OutputGeometry, - secondary: Vec, -) -> Vec { - let mut next_x = 0; - std::iter::once(primary) - .chain(secondary) - .map(|output| { - let size = output.logical_size(); - let location = Point::from((next_x, 0)); - next_x += size.w; - OutputPlacement { - name: output.name, - size, - location, - scale: output.scale, - } - }) - .collect() -} - -fn placements_overlap(placements: &[OutputPlacement]) -> bool { - placements.iter().enumerate().any(|(index, placement)| { - let rect = Rectangle::new(placement.location, placement.size); - placements - .iter() - .skip(index + 1) - .any(|other| rectangles_overlap(rect, Rectangle::new(other.location, other.size))) - }) -} - -fn rectangles_overlap(a: Rectangle, b: Rectangle) -> bool { - a.loc.x < b.loc.x + b.size.w - && a.loc.x + a.size.w > b.loc.x - && a.loc.y < b.loc.y + b.size.h - && a.loc.y + a.size.h > b.loc.y -} - -fn placement_debug(placements: &[OutputPlacement]) -> Vec<(String, i32, i32, i32, i32)> { - placements - .iter() - .map(|placement| { - ( - placement.name.clone(), - placement.location.x, - placement.location.y, - placement.size.w, - placement.size.h, - ) - }) - .collect() -} - -fn secondary_role_id(index: usize) -> String { - if index == 0 { - "secondary".to_string() - } else { - format!("secondary-{}", index + 1) - } -} - impl App { pub(super) fn output_size(&self) -> Size { self.outputs.primary.size @@ -664,213 +408,3 @@ fn update_output_mode_with_refresh_at( Some(location), ); } - -fn minimum_output_width(rects: impl IntoIterator>) -> Option { - rects.into_iter().map(|rect| rect.size.w.max(1)).min() -} - -#[cfg(all(test, feature = "udev"))] -mod tests { - use smithay::{ - output::{PhysicalProperties, Subpixel}, - utils::{Physical, Point, Rectangle, Size}, - }; - - use super::{ - OutputDescriptor, OutputGeometry, OutputLayout, minimum_output_width, - output_locations_for_layout, secondary_descriptors_for_layout, - }; - - fn descriptor(name: &str) -> OutputDescriptor { - OutputDescriptor { - name: name.into(), - properties: PhysicalProperties { - size: (0, 0).into(), - subpixel: Subpixel::Unknown, - make: "test".into(), - model: name.into(), - }, - size: Size::::from((100, 100)), - scale: 1, - refresh: 60_000, - } - } - - fn geometry(name: &str) -> OutputGeometry { - OutputGeometry::from(&descriptor(name)) - } - - fn geometry_with_size(name: &str, width: i32, height: i32) -> OutputGeometry { - OutputGeometry { - name: name.into(), - size: Size::::from((width, height)), - scale: 1, - } - } - - fn layout(monitors: &[(&str, i32, i32)]) -> OutputLayout { - OutputLayout::from_monitors( - monitors - .iter() - .map(|(id, x, y)| hearthspace_ipc::MonitorConfig { - id: (*id).into(), - name: (*id).into(), - width: 100, - height: 100, - scale: 1, - x: *x, - y: *y, - }) - .collect(), - ) - } - - #[test] - fn secondary_layout_skips_selected_primary_and_sorts_by_connector_name() { - let descriptors = vec![ - descriptor("eDP-1"), - descriptor("DP-2"), - descriptor("HDMI-A-1"), - descriptor("DP-1"), - ]; - - let names = secondary_descriptors_for_layout(descriptors, "eDP-1") - .into_iter() - .map(|descriptor| descriptor.name) - .collect::>(); - - assert_eq!(names, ["DP-1", "DP-2", "HDMI-A-1"]); - } - - #[test] - fn secondary_layout_avoids_duplicate_primary_global_name() { - let descriptors = vec![ - descriptor("DP-1"), - descriptor("eDP-1"), - descriptor("HDMI-A-1"), - ]; - - let names = secondary_descriptors_for_layout(descriptors, "eDP-1") - .into_iter() - .map(|descriptor| descriptor.name) - .collect::>(); - - assert_eq!(names, ["HDMI-A-1"]); - } - - #[test] - fn output_layout_uses_connector_positions_and_normalizes_to_zero_origin() { - let plan = output_locations_for_layout( - geometry("eDP-1"), - vec![geometry("HDMI-A-1")], - &layout(&[("eDP-1", 1920, 0), ("HDMI-A-1", 0, -200)]), - ); - - assert_eq!(plan.primary, Point::from((1920, 200))); - assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 0))); - } - - #[test] - fn output_layout_maps_placeholder_roles_by_output_order() { - let plan = output_locations_for_layout( - geometry("eDP-1"), - vec![geometry("HDMI-A-1")], - &layout(&[("primary", 0, 0), ("secondary", -100, 100)]), - ); - - assert_eq!(plan.primary, Point::from((100, 0))); - assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 100))); - } - - #[test] - fn output_layout_ignores_default_placeholder_positions_for_mixed_resolutions() { - let plan = output_locations_for_layout( - geometry_with_size("eDP-1", 2560, 1440), - vec![geometry_with_size("HDMI-A-1", 1366, 768)], - &OutputLayout::from_monitors(hearthspace_ipc::Settings::default().monitors), - ); - - assert_eq!(plan.primary, Point::from((0, 0))); - assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); - } - - #[test] - fn output_layout_keeps_scaled_outputs_at_physical_layout_size() { - let plan = output_locations_for_layout( - geometry_with_size("eDP-1", 2560, 1440), - vec![geometry_with_size("HDMI-A-1", 1920, 1080)], - &OutputLayout::from_monitors(vec![ - hearthspace_ipc::MonitorConfig { - id: "eDP-1".into(), - name: "eDP-1".into(), - width: 2560, - height: 1440, - scale: 2, - x: 0, - y: 0, - }, - hearthspace_ipc::MonitorConfig { - id: "HDMI-A-1".into(), - name: "HDMI-A-1".into(), - width: 1920, - height: 1080, - scale: 1, - x: 1280, - y: 0, - }, - ]), - ); - - assert_eq!(plan.primary, Point::from((0, 0))); - assert_eq!(plan.primary_scale, 2); - assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); - assert_eq!(plan.secondary_scale("HDMI-A-1"), Some(1)); - } - - #[test] - fn output_layout_repairs_overlapping_saved_active_monitors() { - let plan = output_locations_for_layout( - geometry_with_size("eDP-1", 2560, 1440), - vec![geometry_with_size("HDMI-A-1", 1366, 768)], - &layout(&[("primary", -1280, -736), ("secondary", -1920, -540)]), - ); - - assert_eq!(plan.primary, Point::from((0, 0))); - assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); - } - - #[test] - fn output_layout_size_includes_primary_location() { - let plan = output_locations_for_layout( - geometry_with_size("eDP-1", 100, 100), - vec![geometry_with_size("HDMI-A-1", 100, 100)], - &layout(&[("eDP-1", 100, 0), ("HDMI-A-1", 0, 0)]), - ); - - assert_eq!(plan.primary, Point::from((100, 0))); - assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 0))); - } - - #[test] - fn minimum_output_width_uses_smallest_monitor() { - let width = minimum_output_width([ - Rectangle::new((0, 0).into(), (2560, 1440).into()), - Rectangle::new((2560, 0).into(), (1366, 768).into()), - ]); - - assert_eq!(width, Some(1366)); - } - - #[test] - fn output_layout_places_unconfigured_outputs_to_the_right() { - let plan = output_locations_for_layout( - geometry("eDP-1"), - vec![geometry("HDMI-A-1"), geometry("DP-1")], - &layout(&[("primary", 0, 0)]), - ); - - assert_eq!(plan.primary, Point::from((0, 0))); - assert_eq!(plan.secondary_location("DP-1"), Point::from((100, 0))); - assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((200, 0))); - } -} diff --git a/crates/hearthspace-compositor/src/compositor/output/layout.rs b/crates/hearthspace-compositor/src/compositor/output/layout.rs new file mode 100644 index 0000000..cbece89 --- /dev/null +++ b/crates/hearthspace-compositor/src/compositor/output/layout.rs @@ -0,0 +1,482 @@ +use smithay::utils::{Logical, Physical, Point, Rectangle, Size}; +use tracing::debug; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(in crate::compositor) struct OutputLayout { + monitors: Vec, +} + +impl OutputLayout { + pub(in crate::compositor) fn from_monitors( + monitors: Vec, + ) -> Self { + Self { monitors } + } + + pub(super) fn location_for( + &self, + output_name: &str, + role_id: &str, + ) -> Option> { + let use_role_fallback = !default_placeholder_monitors(&self.monitors); + self.monitor_for(output_name, role_id, use_role_fallback) + .map(|monitor| Point::from((monitor.x, monitor.y))) + } + + pub(super) fn scale_for(&self, output_name: &str, role_id: &str) -> Option { + self.monitor_for(output_name, role_id, true) + .map(|monitor| monitor.scale.max(1)) + } + + fn monitor_for( + &self, + output_name: &str, + role_id: &str, + use_role_fallback: bool, + ) -> Option<&hearthspace_ipc::MonitorConfig> { + self.monitors + .iter() + .find(|monitor| monitor.id == output_name || monitor.name == output_name) + .or_else(|| { + use_role_fallback + .then(|| self.monitors.iter().find(|monitor| monitor.id == role_id)) + .flatten() + }) + } +} + +fn default_placeholder_monitors(monitors: &[hearthspace_ipc::MonitorConfig]) -> bool { + monitors == hearthspace_ipc::Settings::default().monitors +} + +#[derive(Clone)] +pub(super) struct OutputGeometry { + pub(super) name: String, + pub(super) size: Size, + pub(super) scale: i32, +} + +impl OutputGeometry { + fn logical_size(&self) -> Size { + Size::from((self.size.w, self.size.h)) + } +} + +pub(super) struct OutputLayoutPlan { + pub(super) primary: Point, + pub(super) primary_scale: i32, + secondary: Vec<(String, Point, i32)>, +} + +#[derive(Debug, Clone)] +struct OutputPlacement { + name: String, + size: Size, + location: Point, + scale: i32, +} + +impl OutputLayoutPlan { + pub(super) fn secondary_location(&self, name: &str) -> Point { + self.secondary + .iter() + .find(|(secondary_name, _, _)| secondary_name == name) + .map(|(_, location, _)| *location) + .unwrap_or((0, 0).into()) + } + + pub(super) fn secondary_scale(&self, name: &str) -> Option { + self.secondary + .iter() + .find(|(secondary_name, _, _)| secondary_name == name) + .map(|(_, _, scale)| *scale) + } +} + +pub(super) fn output_locations_for_layout( + mut primary: OutputGeometry, + mut secondary: Vec, + layout: &OutputLayout, +) -> OutputLayoutPlan { + secondary.sort_by(|a, b| a.name.cmp(&b.name)); + if let Some(scale) = layout.scale_for(&primary.name, "primary") { + primary.scale = scale; + } + for (index, output) in secondary.iter_mut().enumerate() { + let role_id = secondary_role_id(index); + if let Some(scale) = layout.scale_for(&output.name, &role_id) { + output.scale = scale; + } + } + let fallback_primary = primary.clone(); + let fallback_secondary = secondary.clone(); + + let primary_location = layout + .location_for(&primary.name, "primary") + .unwrap_or((0, 0).into()); + let mut placed = vec![OutputPlacement { + name: primary.name.clone(), + size: primary.logical_size(), + location: primary_location, + scale: primary.scale, + }]; + let mut unplaced = Vec::new(); + + for (index, output) in secondary.into_iter().enumerate() { + let role_id = secondary_role_id(index); + if let Some(location) = layout.location_for(&output.name, &role_id) { + let size = output.logical_size(); + placed.push(OutputPlacement { + name: output.name, + size, + location, + scale: output.scale, + }); + } else { + unplaced.push(output); + } + } + + let fallback_y = primary_location.y; + let mut next_x = placed + .iter() + .map(|placement| placement.location.x + placement.size.w) + .max() + .unwrap_or(0); + for output in unplaced { + let size = output.logical_size(); + let location = Point::from((next_x, fallback_y)); + next_x += size.w; + placed.push(OutputPlacement { + name: output.name, + size, + location, + scale: output.scale, + }); + } + + if placements_overlap(&placed) { + debug!(placements = ?placement_debug(&placed), "repairing overlapping active monitor layout"); + placed = fallback_output_placements(fallback_primary, fallback_secondary); + } + + let min_x = placed + .iter() + .map(|placement| placement.location.x) + .min() + .unwrap_or(0); + let min_y = placed + .iter() + .map(|placement| placement.location.y) + .min() + .unwrap_or(0); + + let mut primary_plan = Point::from((0, 0)); + let mut primary_scale = primary.scale; + let mut secondary_plan = Vec::new(); + for placement in placed { + let normalized = Point::from((placement.location.x - min_x, placement.location.y - min_y)); + if placement.name == primary.name { + primary_plan = normalized; + primary_scale = placement.scale; + } else { + secondary_plan.push((placement.name, normalized, placement.scale)); + } + } + + OutputLayoutPlan { + primary: primary_plan, + primary_scale, + secondary: secondary_plan, + } +} + +fn fallback_output_placements( + primary: OutputGeometry, + secondary: Vec, +) -> Vec { + let mut next_x = 0; + std::iter::once(primary) + .chain(secondary) + .map(|output| { + let size = output.logical_size(); + let location = Point::from((next_x, 0)); + next_x += size.w; + OutputPlacement { + name: output.name, + size, + location, + scale: output.scale, + } + }) + .collect() +} + +fn placements_overlap(placements: &[OutputPlacement]) -> bool { + placements.iter().enumerate().any(|(index, placement)| { + let rect = Rectangle::new(placement.location, placement.size); + placements + .iter() + .skip(index + 1) + .any(|other| rectangles_overlap(rect, Rectangle::new(other.location, other.size))) + }) +} + +fn rectangles_overlap(a: Rectangle, b: Rectangle) -> bool { + a.loc.x < b.loc.x + b.size.w + && a.loc.x + a.size.w > b.loc.x + && a.loc.y < b.loc.y + b.size.h + && a.loc.y + a.size.h > b.loc.y +} + +fn placement_debug(placements: &[OutputPlacement]) -> Vec<(String, i32, i32, i32, i32)> { + placements + .iter() + .map(|placement| { + ( + placement.name.clone(), + placement.location.x, + placement.location.y, + placement.size.w, + placement.size.h, + ) + }) + .collect() +} + +fn secondary_role_id(index: usize) -> String { + if index == 0 { + "secondary".to_string() + } else { + format!("secondary-{}", index + 1) + } +} + +/// Native layout policy: selected primary at (0, 0), all other connectors placed +/// horizontally to the right in stable connector-name order. +#[cfg(feature = "udev")] +pub(super) fn secondary_descriptors_for_layout( + descriptors: Vec, + primary_name: &str, +) -> Vec { + let mut secondary = descriptors + .into_iter() + .skip(1) + .filter(|descriptor| descriptor.name != primary_name) + .collect::>(); + secondary.sort_by(|a, b| a.name.cmp(&b.name)); + secondary +} + +pub(super) fn minimum_output_width( + rects: impl IntoIterator>, +) -> Option { + rects.into_iter().map(|rect| rect.size.w.max(1)).min() +} + +#[cfg(all(test, feature = "udev"))] +mod tests { + use smithay::{ + output::{PhysicalProperties, Subpixel}, + utils::{Physical, Point, Rectangle, Size}, + }; + + use super::{ + OutputGeometry, OutputLayout, minimum_output_width, output_locations_for_layout, + secondary_descriptors_for_layout, + }; + use crate::compositor::OutputDescriptor; + + fn descriptor(name: &str) -> OutputDescriptor { + OutputDescriptor { + name: name.into(), + properties: PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: name.into(), + }, + size: Size::::from((100, 100)), + scale: 1, + refresh: 60_000, + } + } + + fn geometry(name: &str) -> OutputGeometry { + OutputGeometry::from(&descriptor(name)) + } + + fn geometry_with_size(name: &str, width: i32, height: i32) -> OutputGeometry { + OutputGeometry { + name: name.into(), + size: Size::::from((width, height)), + scale: 1, + } + } + + fn layout(monitors: &[(&str, i32, i32)]) -> OutputLayout { + OutputLayout::from_monitors( + monitors + .iter() + .map(|(id, x, y)| hearthspace_ipc::MonitorConfig { + id: (*id).into(), + name: (*id).into(), + width: 100, + height: 100, + scale: 1, + x: *x, + y: *y, + }) + .collect(), + ) + } + + #[test] + fn secondary_layout_skips_selected_primary_and_sorts_by_connector_name() { + let descriptors = vec![ + descriptor("eDP-1"), + descriptor("DP-2"), + descriptor("HDMI-A-1"), + descriptor("DP-1"), + ]; + + let names = secondary_descriptors_for_layout(descriptors, "eDP-1") + .into_iter() + .map(|descriptor| descriptor.name) + .collect::>(); + + assert_eq!(names, ["DP-1", "DP-2", "HDMI-A-1"]); + } + + #[test] + fn secondary_layout_avoids_duplicate_primary_global_name() { + let descriptors = vec![ + descriptor("DP-1"), + descriptor("eDP-1"), + descriptor("HDMI-A-1"), + ]; + + let names = secondary_descriptors_for_layout(descriptors, "eDP-1") + .into_iter() + .map(|descriptor| descriptor.name) + .collect::>(); + + assert_eq!(names, ["HDMI-A-1"]); + } + + #[test] + fn output_layout_uses_connector_positions_and_normalizes_to_zero_origin() { + let plan = output_locations_for_layout( + geometry("eDP-1"), + vec![geometry("HDMI-A-1")], + &layout(&[("eDP-1", 1920, 0), ("HDMI-A-1", 0, -200)]), + ); + + assert_eq!(plan.primary, Point::from((1920, 200))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 0))); + } + + #[test] + fn output_layout_maps_placeholder_roles_by_output_order() { + let plan = output_locations_for_layout( + geometry("eDP-1"), + vec![geometry("HDMI-A-1")], + &layout(&[("primary", 0, 0), ("secondary", -100, 100)]), + ); + + assert_eq!(plan.primary, Point::from((100, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 100))); + } + + #[test] + fn output_layout_ignores_default_placeholder_positions_for_mixed_resolutions() { + let plan = output_locations_for_layout( + geometry_with_size("eDP-1", 2560, 1440), + vec![geometry_with_size("HDMI-A-1", 1366, 768)], + &OutputLayout::from_monitors(hearthspace_ipc::Settings::default().monitors), + ); + + assert_eq!(plan.primary, Point::from((0, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); + } + + #[test] + fn output_layout_keeps_scaled_outputs_at_physical_layout_size() { + let plan = output_locations_for_layout( + geometry_with_size("eDP-1", 2560, 1440), + vec![geometry_with_size("HDMI-A-1", 1920, 1080)], + &OutputLayout::from_monitors(vec![ + hearthspace_ipc::MonitorConfig { + id: "eDP-1".into(), + name: "eDP-1".into(), + width: 2560, + height: 1440, + scale: 2, + x: 0, + y: 0, + }, + hearthspace_ipc::MonitorConfig { + id: "HDMI-A-1".into(), + name: "HDMI-A-1".into(), + width: 1920, + height: 1080, + scale: 1, + x: 1280, + y: 0, + }, + ]), + ); + + assert_eq!(plan.primary, Point::from((0, 0))); + assert_eq!(plan.primary_scale, 2); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); + assert_eq!(plan.secondary_scale("HDMI-A-1"), Some(1)); + } + + #[test] + fn output_layout_repairs_overlapping_saved_active_monitors() { + let plan = output_locations_for_layout( + geometry_with_size("eDP-1", 2560, 1440), + vec![geometry_with_size("HDMI-A-1", 1366, 768)], + &layout(&[("primary", -1280, -736), ("secondary", -1920, -540)]), + ); + + assert_eq!(plan.primary, Point::from((0, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); + } + + #[test] + fn output_layout_size_includes_primary_location() { + let plan = output_locations_for_layout( + geometry_with_size("eDP-1", 100, 100), + vec![geometry_with_size("HDMI-A-1", 100, 100)], + &layout(&[("eDP-1", 100, 0), ("HDMI-A-1", 0, 0)]), + ); + + assert_eq!(plan.primary, Point::from((100, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 0))); + } + + #[test] + fn minimum_output_width_uses_smallest_monitor() { + let width = minimum_output_width([ + Rectangle::new((0, 0).into(), (2560, 1440).into()), + Rectangle::new((2560, 0).into(), (1366, 768).into()), + ]); + + assert_eq!(width, Some(1366)); + } + + #[test] + fn output_layout_places_unconfigured_outputs_to_the_right() { + let plan = output_locations_for_layout( + geometry("eDP-1"), + vec![geometry("HDMI-A-1"), geometry("DP-1")], + &layout(&[("primary", 0, 0)]), + ); + + assert_eq!(plan.primary, Point::from((0, 0))); + assert_eq!(plan.secondary_location("DP-1"), Point::from((100, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((200, 0))); + } +} diff --git a/crates/hearthspace-compositor/src/compositor/rendering.rs b/crates/hearthspace-compositor/src/compositor/rendering.rs index 0e9229e..6baac51 100644 --- a/crates/hearthspace-compositor/src/compositor/rendering.rs +++ b/crates/hearthspace-compositor/src/compositor/rendering.rs @@ -1,51 +1,33 @@ use smithay::{ - backend::{ - allocator::Fourcc, - renderer::{ - Color32F, ContextId, ImportMem, Renderer, RendererSuper, - damage::OutputDamageTracker, - element::{ - Id, Kind, - memory::MemoryRenderBufferRenderElement, - render_elements, - surface::{WaylandSurfaceRenderElement, render_elements_from_surface_tree}, - texture::TextureRenderElement, - utils::{Relocate, RelocateRenderElement, RescaleRenderElement}, - }, - gles::GlesRenderer, + backend::renderer::{ + Color32F, RendererSuper, + damage::OutputDamageTracker, + element::{ + Kind, + memory::MemoryRenderBufferRenderElement, + render_elements, + surface::{WaylandSurfaceRenderElement, render_elements_from_surface_tree}, + texture::TextureRenderElement, + utils::{Relocate, RelocateRenderElement, RescaleRenderElement}, }, + gles::GlesRenderer, }, desktop::PopupManager, - utils::{Logical, Physical, Point, Rectangle, Size, Transform}, + utils::{Logical, Physical, Point, Rectangle, Size}, wayland::{ compositor::{SurfaceAttributes, TraversalAction, with_states, with_surface_tree_downward}, shell::xdg::SurfaceCachedState, }, }; -use std::{fs, io::Cursor}; -use tracing::warn; use wayland_server::protocol::wl_surface; use super::{App, ManagedWindowKind, masonry_titlebar, windows::toplevel_title}; -use crate::geometry::CanvasPoint; -type GlesTexture = ::TextureId; - -const MAX_BACKGROUND_TILE_ELEMENTS: usize = 512; -const DEFAULT_BACKGROUND_IMAGE_PNG: &[u8] = - include_bytes!("../../../../assets/backgrounds/default.png"); +mod background; -pub(super) struct BackgroundImageTile { - context_id: ContextId, - texture: GlesTexture, - size: Size, -} +pub(in crate::compositor) use background::BackgroundImageTile; -struct BackgroundImageData { - pixels: Vec, - format: Fourcc, - size: Size, -} +type GlesTexture = ::TextureId; render_elements! { pub(super) HearthspaceRenderElement<=GlesRenderer>; @@ -254,143 +236,6 @@ impl App { elements } - /// Build repeated background image render elements for the current viewport. - /// - /// The tile is laid out in *canvas* space, so it pans and zooms with the - /// viewport by default while keeping the render-element count proportional - /// to visible image tiles, not to details inside the image. This is also the - /// shape future user-defined repeating backgrounds can reuse. - fn background_image_elements( - &mut self, - renderer: &mut GlesRenderer, - output_rect: Rectangle, - ) -> Vec> { - if self.viewport_scale <= 0.0 || !self.viewport_scale.is_finite() { - return Vec::new(); - } - let moves_with_canvas = self.background_moves_with_canvas; - - let (context_id, texture, tile_size) = match self.background_image_tile(renderer) { - Ok(tile) => (tile.context_id.clone(), tile.texture.clone(), tile.size), - Err(_) => return Vec::new(), - }; - - let (min_x, max_x, min_y, max_y) = background_image_visible_bounds( - output_rect, - moves_with_canvas, - self.viewport_offset, - self.viewport_scale, - ); - - let Some((first_x, last_x, x_count)) = - background_tile_axis_bounds(min_x, max_x, f64::from(tile_size.w)) - else { - return Vec::new(); - }; - let Some((first_y, last_y, y_count)) = - background_tile_axis_bounds(min_y, max_y, f64::from(tile_size.h)) - else { - return Vec::new(); - }; - let tile_count = x_count.saturating_mul(y_count); - if tile_count > MAX_BACKGROUND_TILE_ELEMENTS { - return Vec::new(); - } - - while self.background_tile_ids.len() < tile_count { - self.background_tile_ids.push(Id::new()); - } - - let mut elements = Vec::with_capacity(tile_count); - let src = Rectangle::from_size(tile_size.to_f64()); - for gy in first_y..=last_y { - for gx in first_x..=last_x { - let tile_origin = Point::from(( - gx as f64 * f64::from(tile_size.w), - gy as f64 * f64::from(tile_size.h), - )); - let next_tile_origin = Point::from(( - (gx + 1) as f64 * f64::from(tile_size.w), - (gy + 1) as f64 * f64::from(tile_size.h), - )); - let (screen, next_screen) = if moves_with_canvas { - ( - self.canvas_to_screen(tile_origin), - self.canvas_to_screen(next_tile_origin), - ) - } else { - (tile_origin, next_tile_origin) - }; - let left = screen.x.round() as i32; - let top = screen.y.round() as i32; - let right = next_screen.x.round() as i32; - let bottom = next_screen.y.round() as i32; - let location = Point::::from((f64::from(left), f64::from(top))); - let size = Size::from(((right - left).max(1), (bottom - top).max(1))); - let slot = elements.len(); - elements.push(TextureRenderElement::from_static_texture( - self.background_tile_ids[slot].clone(), - context_id.clone(), - location, - texture.clone(), - 1, - Transform::Normal, - None, - Some(src), - Some(size), - None, - Kind::Unspecified, - )); - } - } - elements - } - - fn background_image_tile( - &mut self, - renderer: &mut GlesRenderer, - ) -> Result<&BackgroundImageTile, ::Error> { - let context_id = renderer.context_id(); - let needs_rebuild = self - .background_image_tile - .as_ref() - .map(|tile| tile.context_id != context_id) - .unwrap_or(true); - - if needs_rebuild { - let data = self.background_image_data(); - let texture = renderer.import_memory( - &data.pixels, - data.format, - (data.size.w, data.size.h).into(), - false, - )?; - self.background_image_tile = Some(BackgroundImageTile { - context_id, - texture, - size: data.size, - }); - } - - Ok(self.background_image_tile.as_ref().unwrap()) - } - - fn background_image_data(&self) -> BackgroundImageData { - if let Some(path) = &self.background_image_path { - match fs::read(path) { - Ok(contents) => match decode_background_png(&contents) { - Ok(image) => return image, - Err(error) => { - warn!(path, %error, "failed to decode background image; using default") - } - }, - Err(error) => warn!(path, %error, "failed to read background image; using default"), - } - } - - default_background_image_data() - } - fn window_render_elements_at( &self, renderer: &mut GlesRenderer, @@ -505,75 +350,6 @@ fn point_in_logical_rect(point: Point, rect: Rectangle Option<(i64, i64, usize)> { - if !min.is_finite() || !max.is_finite() || tile_size <= 0.0 || !tile_size.is_finite() { - return None; - } - - let first = (min / tile_size).floor() as i64; - let last = (max / tile_size).ceil() as i64 - 1; - let count = usize::try_from(last.checked_sub(first)?.checked_add(1)?).ok()?; - Some((first, last, count)) -} - -fn background_image_visible_bounds( - output_rect: Rectangle, - moves_with_canvas: bool, - viewport_offset: CanvasPoint, - viewport_scale: f64, -) -> (f64, f64, f64, f64) { - let top_left = Point::from((f64::from(output_rect.loc.x), f64::from(output_rect.loc.y))); - let bottom_right = Point::from(( - f64::from(output_rect.loc.x) + f64::from(output_rect.size.w), - f64::from(output_rect.loc.y) + f64::from(output_rect.size.h), - )); - - let (top_left, bottom_right) = if moves_with_canvas { - ( - crate::geometry::screen_to_canvas(top_left, viewport_offset, viewport_scale), - crate::geometry::screen_to_canvas(bottom_right, viewport_offset, viewport_scale), - ) - } else { - (top_left, bottom_right) - }; - - ( - top_left.x.min(bottom_right.x), - top_left.x.max(bottom_right.x), - top_left.y.min(bottom_right.y), - top_left.y.max(bottom_right.y), - ) -} - -fn default_background_image_data() -> BackgroundImageData { - decode_background_png(DEFAULT_BACKGROUND_IMAGE_PNG) - .expect("packaged default background image must be a valid 8-bit RGBA PNG") -} - -fn decode_background_png(contents: &[u8]) -> Result { - let mut decoder = png::Decoder::new(Cursor::new(contents)); - decoder.set_transformations(png::Transformations::ALPHA | png::Transformations::STRIP_16); - let mut reader = decoder.read_info().map_err(|error| error.to_string())?; - let mut pixels = vec![0; reader.output_buffer_size().unwrap_or_default()]; - let output = reader - .next_frame(&mut pixels) - .map_err(|error| error.to_string())?; - pixels.truncate(output.buffer_size()); - - if output.color_type != png::ColorType::Rgba || output.bit_depth != png::BitDepth::Eight { - return Err(format!( - "expected 8-bit RGBA PNG, got {:?} {:?}", - output.bit_depth, output.color_type - )); - } - - Ok(BackgroundImageData { - pixels, - format: Fourcc::Abgr8888, - size: Size::::from((output.width as i32, output.height as i32)), - }) -} - /// The offset of a toplevel's window geometry within its surface, used to place /// popups relative to the parent surface origin. Defaults to `(0, 0)` when the /// client has not set an explicit window geometry. @@ -628,13 +404,9 @@ pub(super) fn send_frames_surface_tree(surface: &wl_surface::WlSurface, time: u3 #[cfg(test)] mod tests { - use smithay::utils::{Logical, Point, Rectangle, Size}; - - use crate::geometry::CanvasPoint; + use smithay::utils::{Logical, Point, Rectangle}; - use super::{ - background_tile_axis_bounds, default_background_image_data, point_in_logical_rect, - }; + use super::point_in_logical_rect; #[test] fn point_in_logical_rect_excludes_other_outputs() { @@ -645,80 +417,4 @@ mod tests { assert!(!point_in_logical_rect(Point::from((1279.0, 12.0)), rect)); assert!(!point_in_logical_rect(Point::from((2304.0, 12.0)), rect)); } - - #[test] - fn background_tile_axis_bounds_include_partially_visible_tiles() { - assert_eq!( - background_tile_axis_bounds(0.0, 240.0, 120.0), - Some((0, 1, 2)) - ); - assert_eq!( - background_tile_axis_bounds(-240.0, 0.0, 120.0), - Some((-2, -1, 2)) - ); - assert_eq!( - background_tile_axis_bounds(10.0, 240.1, 120.0), - Some((0, 2, 3)) - ); - } - - #[test] - fn fixed_background_bounds_ignore_viewport_pan_and_zoom() { - let output = Rectangle::::new((0, 0).into(), (800, 600).into()); - - assert_eq!( - super::background_image_visible_bounds(output, false, CanvasPoint { x: 0, y: 0 }, 1.0,), - super::background_image_visible_bounds( - output, - false, - CanvasPoint { - x: 10_000, - y: -5_000 - }, - 0.25, - ) - ); - } - - #[test] - fn canvas_background_bounds_follow_viewport_pan_and_zoom() { - let output = Rectangle::::new((0, 0).into(), (800, 600).into()); - - assert_ne!( - super::background_image_visible_bounds(output, true, CanvasPoint { x: 0, y: 0 }, 1.0,), - super::background_image_visible_bounds( - output, - true, - CanvasPoint { - x: 10_000, - y: -5_000 - }, - 0.25, - ) - ); - } - - #[test] - fn default_background_image_loads_from_png_asset() { - let image = default_background_image_data(); - assert_eq!(image.size, Size::::from((1920, 1080))); - - assert_eq!( - pixel_rgba(&image.pixels, image.size, 0, 0), - [199, 199, 204, 255] - ); - assert_eq!( - pixel_rgba(&image.pixels, image.size, 1919, 0), - [199, 199, 204, 255] - ); - assert_eq!( - pixel_rgba(&image.pixels, image.size, 60, 60), - [255, 255, 255, 255] - ); - } - - fn pixel_rgba(pixels: &[u8], size: Size, x: i32, y: i32) -> [u8; 4] { - let offset = ((y * size.w + x) * 4) as usize; - pixels[offset..offset + 4].try_into().unwrap() - } } diff --git a/crates/hearthspace-compositor/src/compositor/rendering/background.rs b/crates/hearthspace-compositor/src/compositor/rendering/background.rs new file mode 100644 index 0000000..3a65826 --- /dev/null +++ b/crates/hearthspace-compositor/src/compositor/rendering/background.rs @@ -0,0 +1,328 @@ +use std::{fs, io::Cursor}; + +use smithay::{ + backend::{ + allocator::Fourcc, + renderer::{ + ContextId, ImportMem, Renderer, RendererSuper, + element::{Id, Kind, texture::TextureRenderElement}, + gles::GlesRenderer, + }, + }, + utils::{Logical, Physical, Point, Rectangle, Size, Transform}, +}; +use tracing::warn; + +use crate::geometry::CanvasPoint; + +use super::GlesTexture; +use crate::compositor::App; + +const MAX_BACKGROUND_TILE_ELEMENTS: usize = 512; +const DEFAULT_BACKGROUND_IMAGE_PNG: &[u8] = + include_bytes!("../../../../../assets/backgrounds/default.png"); + +pub(in crate::compositor) struct BackgroundImageTile { + context_id: ContextId, + texture: GlesTexture, + size: Size, +} + +struct BackgroundImageData { + pixels: Vec, + format: Fourcc, + size: Size, +} + +impl App { + /// Build repeated background image render elements for the current viewport. + /// + /// The tile is laid out in *canvas* space, so it pans and zooms with the + /// viewport by default while keeping the render-element count proportional + /// to visible image tiles, not to details inside the image. This is also the + /// shape future user-defined repeating backgrounds can reuse. + pub(super) fn background_image_elements( + &mut self, + renderer: &mut GlesRenderer, + output_rect: Rectangle, + ) -> Vec> { + if self.viewport_scale <= 0.0 || !self.viewport_scale.is_finite() { + return Vec::new(); + } + let moves_with_canvas = self.background_moves_with_canvas; + + let (context_id, texture, tile_size) = match self.background_image_tile(renderer) { + Ok(tile) => (tile.context_id.clone(), tile.texture.clone(), tile.size), + Err(_) => return Vec::new(), + }; + + let (min_x, max_x, min_y, max_y) = background_image_visible_bounds( + output_rect, + moves_with_canvas, + self.viewport_offset, + self.viewport_scale, + ); + + let Some((first_x, last_x, x_count)) = + background_tile_axis_bounds(min_x, max_x, f64::from(tile_size.w)) + else { + return Vec::new(); + }; + let Some((first_y, last_y, y_count)) = + background_tile_axis_bounds(min_y, max_y, f64::from(tile_size.h)) + else { + return Vec::new(); + }; + let tile_count = x_count.saturating_mul(y_count); + if tile_count > MAX_BACKGROUND_TILE_ELEMENTS { + return Vec::new(); + } + + while self.background_tile_ids.len() < tile_count { + self.background_tile_ids.push(Id::new()); + } + + let mut elements = Vec::with_capacity(tile_count); + let src = Rectangle::from_size(tile_size.to_f64()); + for gy in first_y..=last_y { + for gx in first_x..=last_x { + let tile_origin = Point::from(( + gx as f64 * f64::from(tile_size.w), + gy as f64 * f64::from(tile_size.h), + )); + let next_tile_origin = Point::from(( + (gx + 1) as f64 * f64::from(tile_size.w), + (gy + 1) as f64 * f64::from(tile_size.h), + )); + let (screen, next_screen) = if moves_with_canvas { + ( + self.canvas_to_screen(tile_origin), + self.canvas_to_screen(next_tile_origin), + ) + } else { + (tile_origin, next_tile_origin) + }; + let left = screen.x.round() as i32; + let top = screen.y.round() as i32; + let right = next_screen.x.round() as i32; + let bottom = next_screen.y.round() as i32; + let location = Point::::from((f64::from(left), f64::from(top))); + let size = Size::from(((right - left).max(1), (bottom - top).max(1))); + let slot = elements.len(); + elements.push(TextureRenderElement::from_static_texture( + self.background_tile_ids[slot].clone(), + context_id.clone(), + location, + texture.clone(), + 1, + Transform::Normal, + None, + Some(src), + Some(size), + None, + Kind::Unspecified, + )); + } + } + elements + } + + fn background_image_tile( + &mut self, + renderer: &mut GlesRenderer, + ) -> Result<&BackgroundImageTile, ::Error> { + let context_id = renderer.context_id(); + let needs_rebuild = self + .background_image_tile + .as_ref() + .map(|tile| tile.context_id != context_id) + .unwrap_or(true); + + if needs_rebuild { + let data = self.background_image_data(); + let texture = renderer.import_memory( + &data.pixels, + data.format, + (data.size.w, data.size.h).into(), + false, + )?; + self.background_image_tile = Some(BackgroundImageTile { + context_id, + texture, + size: data.size, + }); + } + + Ok(self.background_image_tile.as_ref().unwrap()) + } + + fn background_image_data(&self) -> BackgroundImageData { + if let Some(path) = &self.background_image_path { + match fs::read(path) { + Ok(contents) => match decode_background_png(&contents) { + Ok(image) => return image, + Err(error) => { + warn!(path, %error, "failed to decode background image; using default") + } + }, + Err(error) => warn!(path, %error, "failed to read background image; using default"), + } + } + + default_background_image_data() + } +} + +fn background_tile_axis_bounds(min: f64, max: f64, tile_size: f64) -> Option<(i64, i64, usize)> { + if !min.is_finite() || !max.is_finite() || tile_size <= 0.0 || !tile_size.is_finite() { + return None; + } + + let first = (min / tile_size).floor() as i64; + let last = (max / tile_size).ceil() as i64 - 1; + let count = usize::try_from(last.checked_sub(first)?.checked_add(1)?).ok()?; + Some((first, last, count)) +} + +fn background_image_visible_bounds( + output_rect: Rectangle, + moves_with_canvas: bool, + viewport_offset: CanvasPoint, + viewport_scale: f64, +) -> (f64, f64, f64, f64) { + let top_left = Point::from((f64::from(output_rect.loc.x), f64::from(output_rect.loc.y))); + let bottom_right = Point::from(( + f64::from(output_rect.loc.x) + f64::from(output_rect.size.w), + f64::from(output_rect.loc.y) + f64::from(output_rect.size.h), + )); + + let (top_left, bottom_right) = if moves_with_canvas { + ( + crate::geometry::screen_to_canvas(top_left, viewport_offset, viewport_scale), + crate::geometry::screen_to_canvas(bottom_right, viewport_offset, viewport_scale), + ) + } else { + (top_left, bottom_right) + }; + + ( + top_left.x.min(bottom_right.x), + top_left.x.max(bottom_right.x), + top_left.y.min(bottom_right.y), + top_left.y.max(bottom_right.y), + ) +} + +fn default_background_image_data() -> BackgroundImageData { + decode_background_png(DEFAULT_BACKGROUND_IMAGE_PNG) + .expect("packaged default background image must be a valid 8-bit RGBA PNG") +} + +fn decode_background_png(contents: &[u8]) -> Result { + let mut decoder = png::Decoder::new(Cursor::new(contents)); + decoder.set_transformations(png::Transformations::ALPHA | png::Transformations::STRIP_16); + let mut reader = decoder.read_info().map_err(|error| error.to_string())?; + let mut pixels = vec![0; reader.output_buffer_size().unwrap_or_default()]; + let output = reader + .next_frame(&mut pixels) + .map_err(|error| error.to_string())?; + pixels.truncate(output.buffer_size()); + + if output.color_type != png::ColorType::Rgba || output.bit_depth != png::BitDepth::Eight { + return Err(format!( + "expected 8-bit RGBA PNG, got {:?} {:?}", + output.bit_depth, output.color_type + )); + } + + Ok(BackgroundImageData { + pixels, + format: Fourcc::Abgr8888, + size: Size::::from((output.width as i32, output.height as i32)), + }) +} + +#[cfg(test)] +mod tests { + use smithay::utils::{Logical, Rectangle, Size}; + + use crate::geometry::CanvasPoint; + + use super::{background_tile_axis_bounds, default_background_image_data}; + + #[test] + fn background_tile_axis_bounds_include_partially_visible_tiles() { + assert_eq!( + background_tile_axis_bounds(0.0, 240.0, 120.0), + Some((0, 1, 2)) + ); + assert_eq!( + background_tile_axis_bounds(-240.0, 0.0, 120.0), + Some((-2, -1, 2)) + ); + assert_eq!( + background_tile_axis_bounds(10.0, 240.1, 120.0), + Some((0, 2, 3)) + ); + } + + #[test] + fn fixed_background_bounds_ignore_viewport_pan_and_zoom() { + let output = Rectangle::::new((0, 0).into(), (800, 600).into()); + + assert_eq!( + super::background_image_visible_bounds(output, false, CanvasPoint { x: 0, y: 0 }, 1.0,), + super::background_image_visible_bounds( + output, + false, + CanvasPoint { + x: 10_000, + y: -5_000 + }, + 0.25, + ) + ); + } + + #[test] + fn canvas_background_bounds_follow_viewport_pan_and_zoom() { + let output = Rectangle::::new((0, 0).into(), (800, 600).into()); + + assert_ne!( + super::background_image_visible_bounds(output, true, CanvasPoint { x: 0, y: 0 }, 1.0,), + super::background_image_visible_bounds( + output, + true, + CanvasPoint { + x: 10_000, + y: -5_000 + }, + 0.25, + ) + ); + } + + #[test] + fn default_background_image_loads_from_png_asset() { + let image = default_background_image_data(); + assert_eq!(image.size, Size::::from((1920, 1080))); + + assert_eq!( + pixel_rgba(&image.pixels, image.size, 0, 0), + [199, 199, 204, 255] + ); + assert_eq!( + pixel_rgba(&image.pixels, image.size, 1919, 0), + [199, 199, 204, 255] + ); + assert_eq!( + pixel_rgba(&image.pixels, image.size, 60, 60), + [255, 255, 255, 255] + ); + } + + fn pixel_rgba(pixels: &[u8], size: Size, x: i32, y: i32) -> [u8; 4] { + let offset = ((y * size.w + x) * 4) as usize; + pixels[offset..offset + 4].try_into().unwrap() + } +} diff --git a/crates/hearthspace-compositor/src/compositor/shell_integration.rs b/crates/hearthspace-compositor/src/compositor/shell_integration.rs index d25ebfc..bcb5fd2 100644 --- a/crates/hearthspace-compositor/src/compositor/shell_integration.rs +++ b/crates/hearthspace-compositor/src/compositor/shell_integration.rs @@ -1,20 +1,16 @@ use std::{ env, fs, - io::{self, ErrorKind, Read, Write}, - os::unix::{ - fs::symlink, - net::{UnixListener as CommandListener, UnixStream}, - }, + io::{self, ErrorKind}, + os::unix::fs::symlink, path::PathBuf, process::Command, }; -use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, generic::Generic}; use smithay::{ backend::input::{ButtonState, KeyState}, utils::Point, }; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, error, info, warn}; use crate::{config::*, geometry::CanvasPoint}; use hearthspace_shell::{ @@ -22,15 +18,13 @@ use hearthspace_shell::{ app_catalog::{DesktopApp, spawn_argv_with_env}, }; -use super::{App, CalloopData}; +use super::App; -pub(super) fn command_socket_path() -> PathBuf { - if let Some(socket_path) = env::var_os(SHELL_COMMAND_SOCKET_ENV) { - return PathBuf::from(socket_path); - } +mod command_socket; - runtime_path(SHELL_COMMAND_SOCKET_NAME) -} +pub(in crate::compositor) use command_socket::{ + ControlReply, accept_command_connections, command_socket_path, remove_stale_socket, +}; fn runtime_path(name: &str) -> PathBuf { env::var_os("XDG_RUNTIME_DIR") @@ -126,14 +120,6 @@ fn sanitized_path_component(input: &str) -> String { .collect() } -pub(super) fn remove_stale_socket(path: &PathBuf) -> std::io::Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - /// Spawn the Xilem-rendered shell (app launcher + compositor controls) as its /// own Wayland client. The shell tags its surface with [`SHELL_BAR_APP_ID`] so /// the compositor renders it chrome-less in the bar position. @@ -190,178 +176,6 @@ fn apply_gtk_client_environment(command: &mut Command) { } } -/// Largest amount of unparsed command data we will buffer for a single -/// connection before giving up. Commands are short single lines, so this only -/// guards against a misbehaving client streaming data without a newline. -const MAX_COMMAND_BUFFER_BYTES: usize = 4096; - -/// Accept every pending command connection and register each one as its own -/// non-blocking calloop source. Reading is incremental, so a client that -/// connects but never finishes sending cannot block the event loop. -pub(super) fn accept_command_connections<'l>( - listener: &CommandListener, - handle: &LoopHandle<'l, CalloopData>, -) -> io::Result<()> { - loop { - match listener.accept() { - Ok((stream, _)) => { - debug!("accepted shell command connection"); - stream.set_nonblocking(true)?; - register_command_connection(handle, stream); - } - Err(error) if error.kind() == ErrorKind::WouldBlock => return Ok(()), - Err(error) => return Err(error), - } - } -} - -fn register_command_connection<'l>(handle: &LoopHandle<'l, CalloopData>, stream: UnixStream) { - 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, data)) - }) { - error!(%error, "failed to register shell command connection"); - } -} - -fn read_command_connection( - stream: &UnixStream, - buffer: &mut Vec, - data: &mut CalloopData, -) -> PostAction { - // `Read` is implemented for `&UnixStream`, so read through a shared ref. - let mut reader = stream; - let mut chunk = [0u8; 1024]; - loop { - match reader.read(&mut chunk) { - Ok(0) => { - // Client closed: run any trailing line that lacked a newline. - if !buffer.is_empty() { - run_command_line(buffer, data); - } - return PostAction::Remove; - } - Ok(read) => { - buffer.extend_from_slice(&chunk[..read]); - if !drain_complete_commands(buffer, data, stream) { - return PostAction::Remove; - } - if buffer.len() > MAX_COMMAND_BUFFER_BYTES { - warn!( - limit = MAX_COMMAND_BUFFER_BYTES, - "shell command exceeded maximum buffered length; dropping connection" - ); - return PostAction::Remove; - } - } - Err(error) if error.kind() == ErrorKind::Interrupted => continue, - Err(error) if error.kind() == ErrorKind::WouldBlock => return PostAction::Continue, - Err(error) => { - error!(%error, "failed to read shell command"); - return PostAction::Remove; - } - } - } -} - -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, data.run_control_action(command)) { - return false; - } - } - true -} - -/// Split every complete newline-terminated command off the front of `buffer`, -/// returning the parsed commands in order. Unterminated trailing bytes are left -/// in `buffer` for the next read. Lines that fail to parse are silently skipped. -fn take_complete_commands(buffer: &mut Vec) -> Vec { - let mut commands = Vec::new(); - while let Some(newline) = buffer.iter().position(|&byte| byte == b'\n') { - let line: Vec = buffer.drain(..=newline).collect(); - if let Some(command) = parse_command_line(&line) { - commands.push(command); - } - } - commands -} - -fn run_command_line(bytes: &[u8], data: &mut CalloopData) { - if let Some(command) = parse_command_line(bytes) { - trace!(command = %command.wire_name(), "running trailing shell command line"); - data.run_control_action(command); - } -} - -fn parse_command_line(bytes: &[u8]) -> Option { - let text = std::str::from_utf8(bytes).ok()?; - let trimmed = text.trim(); - let command = ShellCommand::parse(trimmed); - if command.is_none() && !trimmed.is_empty() { - warn!(line = trimmed, "ignored invalid shell command"); - } - command -} - -enum ControlReply { - Ok, - Payload(Vec), - Err(String), -} - -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(), - } - } -} - -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) => { - error!(%error, "failed to write shell command reply"); - false - } - } -} - -impl CalloopData { - fn run_control_action(&mut self, action: ShellCommand) -> ControlReply { - let command = action.wire_name(); - debug!(%command, "running compositor control action"); - if action == ShellCommand::Quit { - info!("quit control action requested; stopping compositor event loop"); - self.running = false; - return ControlReply::Ok; - } - - 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 { let command = action.wire_name(); @@ -540,46 +354,6 @@ impl App { #[cfg(test)] mod tests { use super::*; - use hearthspace_shell::SpawnTarget; - - #[test] - fn drains_only_complete_newline_terminated_commands() { - let mut buffer = b"zoom-in\npan-left\n".to_vec(); - let commands = take_complete_commands(&mut buffer); - assert_eq!(commands, vec![ShellCommand::ZoomIn, ShellCommand::PanLeft]); - assert!(buffer.is_empty()); - } - - #[test] - fn keeps_an_unterminated_trailing_command_buffered() { - let mut buffer = b"zoom-in\npan-le".to_vec(); - let commands = take_complete_commands(&mut buffer); - assert_eq!(commands, vec![ShellCommand::ZoomIn]); - assert_eq!(buffer, b"pan-le"); - - // The remainder completes once the newline arrives. - buffer.extend_from_slice(b"ft\n"); - let commands = take_complete_commands(&mut buffer); - assert_eq!(commands, vec![ShellCommand::PanLeft]); - assert!(buffer.is_empty()); - } - - #[test] - fn skips_unparseable_lines_while_draining() { - let mut buffer = b"not-a-command\nspawn foot\n".to_vec(); - let commands = take_complete_commands(&mut buffer); - assert_eq!(commands, vec![ShellCommand::Spawn(SpawnTarget::Foot)]); - } - - #[test] - fn parse_command_line_trims_and_rejects_invalid_utf8() { - assert_eq!( - parse_command_line(b" zoom-out \n"), - Some(ShellCommand::ZoomOut) - ); - assert_eq!(parse_command_line(&[0xff, 0xfe]), None); - assert_eq!(parse_command_line(b"\n"), None); - } #[test] fn sanitized_path_component_replaces_unsafe_characters() { diff --git a/crates/hearthspace-compositor/src/compositor/shell_integration/command_socket.rs b/crates/hearthspace-compositor/src/compositor/shell_integration/command_socket.rs new file mode 100644 index 0000000..314369c --- /dev/null +++ b/crates/hearthspace-compositor/src/compositor/shell_integration/command_socket.rs @@ -0,0 +1,248 @@ +use std::{ + env, fs, + io::{self, ErrorKind, Read, Write}, + os::unix::net::{UnixListener as CommandListener, UnixStream}, + path::PathBuf, +}; + +use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, generic::Generic}; +use tracing::{debug, error, info, trace, warn}; + +use crate::config::{SHELL_COMMAND_SOCKET_ENV, SHELL_COMMAND_SOCKET_NAME}; +use hearthspace_shell::ShellCommand; + +use super::runtime_path; +use crate::compositor::CalloopData; + +pub(in crate::compositor) fn command_socket_path() -> PathBuf { + if let Some(socket_path) = env::var_os(SHELL_COMMAND_SOCKET_ENV) { + return PathBuf::from(socket_path); + } + + runtime_path(SHELL_COMMAND_SOCKET_NAME) +} + +pub(in crate::compositor) fn remove_stale_socket(path: &PathBuf) -> std::io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +/// Largest amount of unparsed command data we will buffer for a single +/// connection before giving up. Commands are short single lines, so this only +/// guards against a misbehaving client streaming data without a newline. +const MAX_COMMAND_BUFFER_BYTES: usize = 4096; + +/// Accept every pending command connection and register each one as its own +/// non-blocking calloop source. Reading is incremental, so a client that +/// connects but never finishes sending cannot block the event loop. +pub(in crate::compositor) fn accept_command_connections<'l>( + listener: &CommandListener, + handle: &LoopHandle<'l, CalloopData>, +) -> io::Result<()> { + loop { + match listener.accept() { + Ok((stream, _)) => { + debug!("accepted shell command connection"); + stream.set_nonblocking(true)?; + register_command_connection(handle, stream); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => return Ok(()), + Err(error) => return Err(error), + } + } +} + +fn register_command_connection<'l>(handle: &LoopHandle<'l, CalloopData>, stream: UnixStream) { + 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, data)) + }) { + error!(%error, "failed to register shell command connection"); + } +} + +fn read_command_connection( + stream: &UnixStream, + buffer: &mut Vec, + data: &mut CalloopData, +) -> PostAction { + // `Read` is implemented for `&UnixStream`, so read through a shared ref. + let mut reader = stream; + let mut chunk = [0u8; 1024]; + loop { + match reader.read(&mut chunk) { + Ok(0) => { + // Client closed: run any trailing line that lacked a newline. + if !buffer.is_empty() { + run_command_line(buffer, data); + } + return PostAction::Remove; + } + Ok(read) => { + buffer.extend_from_slice(&chunk[..read]); + if !drain_complete_commands(buffer, data, stream) { + return PostAction::Remove; + } + if buffer.len() > MAX_COMMAND_BUFFER_BYTES { + warn!( + limit = MAX_COMMAND_BUFFER_BYTES, + "shell command exceeded maximum buffered length; dropping connection" + ); + return PostAction::Remove; + } + } + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) if error.kind() == ErrorKind::WouldBlock => return PostAction::Continue, + Err(error) => { + error!(%error, "failed to read shell command"); + return PostAction::Remove; + } + } + } +} + +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, data.run_control_action(command)) { + return false; + } + } + true +} + +/// Split every complete newline-terminated command off the front of `buffer`, +/// returning the parsed commands in order. Unterminated trailing bytes are left +/// in `buffer` for the next read. Lines that fail to parse are silently skipped. +fn take_complete_commands(buffer: &mut Vec) -> Vec { + let mut commands = Vec::new(); + while let Some(newline) = buffer.iter().position(|&byte| byte == b'\n') { + let line: Vec = buffer.drain(..=newline).collect(); + if let Some(command) = parse_command_line(&line) { + commands.push(command); + } + } + commands +} + +fn run_command_line(bytes: &[u8], data: &mut CalloopData) { + if let Some(command) = parse_command_line(bytes) { + trace!(command = %command.wire_name(), "running trailing shell command line"); + data.run_control_action(command); + } +} + +fn parse_command_line(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + let trimmed = text.trim(); + let command = ShellCommand::parse(trimmed); + if command.is_none() && !trimmed.is_empty() { + warn!(line = trimmed, "ignored invalid shell command"); + } + command +} + +pub(in crate::compositor) enum ControlReply { + Ok, + Payload(Vec), + Err(String), +} + +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(), + } + } +} + +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) => { + error!(%error, "failed to write shell command reply"); + false + } + } +} + +impl CalloopData { + fn run_control_action(&mut self, action: ShellCommand) -> ControlReply { + let command = action.wire_name(); + debug!(%command, "running compositor control action"); + if action == ShellCommand::Quit { + info!("quit control action requested; stopping compositor event loop"); + self.running = false; + return ControlReply::Ok; + } + + 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hearthspace_shell::SpawnTarget; + + #[test] + fn drains_only_complete_newline_terminated_commands() { + let mut buffer = b"zoom-in\npan-left\n".to_vec(); + let commands = take_complete_commands(&mut buffer); + assert_eq!(commands, vec![ShellCommand::ZoomIn, ShellCommand::PanLeft]); + assert!(buffer.is_empty()); + } + + #[test] + fn keeps_an_unterminated_trailing_command_buffered() { + let mut buffer = b"zoom-in\npan-le".to_vec(); + let commands = take_complete_commands(&mut buffer); + assert_eq!(commands, vec![ShellCommand::ZoomIn]); + assert_eq!(buffer, b"pan-le"); + + // The remainder completes once the newline arrives. + buffer.extend_from_slice(b"ft\n"); + let commands = take_complete_commands(&mut buffer); + assert_eq!(commands, vec![ShellCommand::PanLeft]); + assert!(buffer.is_empty()); + } + + #[test] + fn skips_unparseable_lines_while_draining() { + let mut buffer = b"not-a-command\nspawn foot\n".to_vec(); + let commands = take_complete_commands(&mut buffer); + assert_eq!(commands, vec![ShellCommand::Spawn(SpawnTarget::Foot)]); + } + + #[test] + fn parse_command_line_trims_and_rejects_invalid_utf8() { + assert_eq!( + parse_command_line(b" zoom-out \n"), + Some(ShellCommand::ZoomOut) + ); + assert_eq!(parse_command_line(&[0xff, 0xfe]), None); + assert_eq!(parse_command_line(b"\n"), None); + } +} diff --git a/crates/hearthspace-compositor/src/compositor/udev.rs b/crates/hearthspace-compositor/src/compositor/udev.rs index 2ba97ee..ba237a2 100644 --- a/crates/hearthspace-compositor/src/compositor/udev.rs +++ b/crates/hearthspace-compositor/src/compositor/udev.rs @@ -1,11 +1,12 @@ -use std::{io, path::PathBuf, time::Duration}; +use std::time::Duration; mod device; +mod hotplug; mod input; use device::{ - KmsOutputTarget, UdevDevice, UdevDeviceInfo, create_udev_device, current_device_list, - initial_device_list, log_device_list, + KmsOutputTarget, UdevDevice, UdevDeviceInfo, create_udev_device, initial_device_list, + log_device_list, }; use input::log_input_event; @@ -491,210 +492,6 @@ impl UdevBackendState { Ok(()) } - fn pause_session(&mut self) { - self.session_active = false; - self.drm_commits_paused = true; - self.kms_devices_active = false; - self.repaint_pending = false; - self.frame_pending = false; - self.frame_dirty = true; - if let Some(token) = self.input_source.take() { - self.loop_handle.remove(token); - info!("native libinput source removed for session pause"); - } - if let Some(device) = &mut self.primary_device { - device.scanout_node.drm_device.pause(); - device.output_surfaces.clear(); - device.active = false; - } - info!("native session paused; DRM commits disabled and Wayland clients remain connected"); - } - - fn activate_session(&mut self) -> ConnectorSync { - self.session_active = true; - self.connector_rescan_pending = true; - - if self.primary_device.is_none() - && let Err(error) = self.open_primary_device() - { - error!(%error, "failed to open primary DRM device after session activation"); - } - - if self.input_source.is_none() { - match insert_libinput_source(&self.loop_handle, self.session.clone(), &self.seat_name) { - Ok(token) => { - self.input_source = Some(token); - info!("native libinput source recreated after session activation"); - } - Err(error) => { - error!(%error, "failed to recreate native libinput source after session activation"); - } - } - } - - if let Err(error) = self.rescan_devices() { - error!(%error, "failed to re-scan DRM devices after session activation"); - } - - let Some(device) = self.primary_device.as_mut() else { - self.kms_devices_active = false; - self.drm_commits_paused = true; - self.repaint_pending = false; - self.frame_pending = false; - self.frame_dirty = true; - warn!("native session activated but no primary DRM device is available"); - return ConnectorSync::default(); - }; - - device.active = true; - if let Err(error) = device.scanout_node.drm_device.activate(true) { - error!(%error, "failed to reactivate primary DRM device after session activation"); - } - - device.output_targets = device.connected_output_targets(); - let primary = device - .output_targets - .first() - .map(KmsOutputTarget::output_descriptor); - if let Err(error) = device.rebuild_output_surfaces(device.output_targets.clone()) { - error!(%error, "failed to rebuild KMS output surfaces after session activation"); - } - - self.kms_devices_active = device.has_output_surfaces(); - self.drm_commits_paused = !self.kms_devices_active; - self.connector_rescan_pending = false; - self.repaint_pending = self.kms_devices_active; - self.frame_pending = false; - self.frame_dirty = true; - - info!( - repaint_pending = self.repaint_pending, - "native session activated; DRM devices reactivated" - ); - - ConnectorSync { - descriptors: device.output_descriptors(), - primary, - } - } - - fn add_or_update_device(&mut self, device_id: u64, path: PathBuf) { - match self - .devices - .iter_mut() - .find(|device| device.device_id == device_id) - { - Some(device) => device.path = path, - None => self.devices.push(UdevDeviceInfo { device_id, path }), - } - self.connector_rescan_pending = true; - } - - fn output_descriptors(&self) -> Vec { - self.primary_device - .as_ref() - .map(UdevDevice::output_descriptors) - .unwrap_or_default() - } - - fn handle_device_changed(&mut self, device_id: u64) -> ConnectorSync { - self.connector_rescan_pending = true; - if !self.session_active || self.drm_commits_paused { - debug!( - device_id, - "deferring DRM device change while native session is paused" - ); - return ConnectorSync::default(); - } - - if let Err(error) = self.rescan_devices() { - error!(%error, "failed to re-scan DRM devices after device change"); - } - - let Some(device) = self.primary_device.as_mut() else { - return ConnectorSync::default(); - }; - if device.scanout_node.drm_fd.dev_id().ok() != Some(device_id) { - debug!( - device_id, - "changed DRM device is not the selected primary device" - ); - return ConnectorSync { - descriptors: device.output_descriptors(), - primary: None, - }; - } - - let previous_target = device.output_target.clone(); - let next_targets = device.connected_output_targets(); - let next_target = next_targets.first().cloned(); - if previous_target == next_target && device.output_targets == next_targets { - debug!("primary DRM connector state re-scanned; selected output is unchanged"); - self.connector_rescan_pending = false; - device.output_targets = next_targets; - return ConnectorSync { - descriptors: device.output_descriptors(), - primary: None, - }; - } - - info!(previous_target = ?previous_target, next_target = ?next_target, "DRM connector selection changed; rebuilding KMS output surfaces"); - device.output_targets = next_targets; - let primary = next_target.as_ref().map(KmsOutputTarget::output_descriptor); - if let Err(error) = device.rebuild_output_surfaces(device.output_targets.clone()) { - error!(%error, "failed to rebuild KMS output surfaces"); - } - - self.kms_devices_active = self.session_active && device.has_output_surfaces(); - self.drm_commits_paused = !self.session_active || !device.has_output_surfaces(); - self.connector_rescan_pending = false; - self.frame_pending = false; - self.frame_dirty = self.kms_devices_active; - self.repaint_pending = self.kms_devices_active; - - ConnectorSync { - descriptors: device.output_descriptors(), - primary, - } - } - - fn remove_device(&mut self, device_id: u64) { - let Some(index) = self - .devices - .iter() - .position(|device| device.device_id == device_id) - else { - return; - }; - let removed = self.devices.remove(index); - if self - .primary_device - .as_ref() - .is_some_and(|device| device.path == removed.path) - { - self.primary_device = None; - self.kms_devices_active = false; - self.repaint_pending = false; - warn!("primary DRM device removed; KMS state marked inactive"); - } - self.connector_rescan_pending = true; - } - - fn rescan_devices(&mut self) -> io::Result<()> { - self.devices = current_device_list(&self.seat_name)?; - log_device_list(&self.seat_name, &self.devices); - Ok(()) - } - - fn open_primary_device(&mut self) -> Result<(), Box> { - let Some(path) = primary_gpu(&self.seat_name)? else { - return Ok(()); - }; - let (device, _notifier) = create_udev_device(&mut self.session, path, self.session_active)?; - self.primary_device = Some(device); - Ok(()) - } - fn log_summary(&self) { info!( session_active = self.session_active, diff --git a/crates/hearthspace-compositor/src/compositor/udev/device.rs b/crates/hearthspace-compositor/src/compositor/udev/device.rs index dd9a215..0ee2644 100644 --- a/crates/hearthspace-compositor/src/compositor/udev/device.rs +++ b/crates/hearthspace-compositor/src/compositor/udev/device.rs @@ -1,4 +1,4 @@ -use std::{cmp, io, path::PathBuf}; +use std::{io, path::PathBuf}; use smithay::{ backend::{ @@ -12,21 +12,16 @@ use smithay::{ }, output::{PhysicalProperties, Subpixel}, reexports::{ - drm::{ - Device as BasicDrmDevice, DriverCapability, - buffer::Buffer as DrmBuffer, - control::{ - AtomicCommitFlags, Device as ControlDevice, Mode, PlaneType, atomic::AtomicModeReq, - connector, crtc, dumbbuffer, property, - }, - }, + drm::control::{Device as ControlDevice, Mode, connector, crtc}, rustix::fs::{OFlags, stat}, }, - utils::{DeviceFd, Logical, Physical, Point, Rectangle, Size, Transform}, + utils::{DeviceFd, Physical, Size, Transform}, }; use tracing::{debug, error, info, warn}; -use super::super::cursor::{CursorIcon, CursorImage, cursor_image_for_icon}; +mod cursor; + +use cursor::{HardwareCursor, create_hardware_cursor}; pub(super) struct UdevDevice { pub(super) path: PathBuf, @@ -60,12 +55,6 @@ pub(super) struct KmsOutputSurface { pub(super) frame_dirty: bool, } -struct HardwareCursor { - buffer: dumbbuffer::DumbBuffer, - hotspot: (i32, i32), - icon: CursorIcon, -} - #[derive(Debug, Clone, PartialEq)] pub(super) struct KmsOutputTarget { pub(super) connector: connector::Handle, @@ -249,60 +238,6 @@ impl UdevDevice { Ok(()) } - pub(super) fn update_hardware_cursor(&mut self, state: &super::super::App) { - let Some(cursor) = self.hardware_cursor.as_mut() else { - return; - }; - if cursor.icon != state.cursor_icon { - update_hardware_cursor_image(&self.scanout_node, cursor, state.cursor_icon); - if let Some(crtc) = self.hardware_cursor_crtc { - set_legacy_cursor(&self.scanout_node, crtc, cursor); - } - self.hardware_cursor_position = None; - } - - if !self.hardware_cursor_initialized { - clear_all_drm_cursor_planes(&self.scanout_node); - for output in &self.output_surfaces { - clear_legacy_cursor(&self.scanout_node, output.target.crtc); - } - self.hardware_cursor_initialized = true; - } - - let pointer = state.pointer_location; - let target = self.output_surfaces.iter().find_map(|output| { - let view = state.output_render_view(output.target.connector_name())?; - let rect = Rectangle::new(view.location, view.size); - point_in_logical_rect(pointer, rect).then(|| { - let x = (pointer.x - f64::from(view.location.x)).round() as i32 - cursor.hotspot.0; - let y = (pointer.y - f64::from(view.location.y)).round() as i32 - cursor.hotspot.1; - (output.target.crtc, (x, y)) - }) - }); - - let Some((crtc, position)) = target else { - if let Some(active_crtc) = self.hardware_cursor_crtc.take() { - clear_legacy_cursor(&self.scanout_node, active_crtc); - } - self.hardware_cursor_position = None; - return; - }; - - if self.hardware_cursor_crtc != Some(crtc) { - if let Some(active_crtc) = self.hardware_cursor_crtc { - clear_legacy_cursor(&self.scanout_node, active_crtc); - } - set_legacy_cursor(&self.scanout_node, crtc, cursor); - self.hardware_cursor_crtc = Some(crtc); - self.hardware_cursor_position = None; - } - - if self.hardware_cursor_position != Some(position) { - move_legacy_cursor(&self.scanout_node, crtc, position); - self.hardware_cursor_position = Some(position); - } - } - pub(super) fn has_output_surfaces(&self) -> bool { !self.output_surfaces.is_empty() } @@ -365,214 +300,6 @@ fn create_output_surface( }) } -fn set_legacy_cursor(scanout_node: &ScanoutNode, crtc: crtc::Handle, cursor: &HardwareCursor) { - #[allow(deprecated)] - match scanout_node.drm_fd.set_cursor(crtc, Some(&cursor.buffer)) { - Ok(()) => debug!(?crtc, "set legacy DRM cursor"), - Err(error) => debug!(?crtc, %error, "legacy DRM cursor set failed"), - } -} - -fn move_legacy_cursor(scanout_node: &ScanoutNode, crtc: crtc::Handle, position: (i32, i32)) { - #[allow(deprecated)] - match scanout_node.drm_fd.move_cursor(crtc, position) { - Ok(()) => debug!(?crtc, ?position, "moved legacy DRM cursor"), - Err(error) => debug!(?crtc, ?position, %error, "legacy DRM cursor move failed"), - } -} - -fn clear_legacy_cursor(scanout_node: &ScanoutNode, crtc: crtc::Handle) { - #[allow(deprecated)] - match scanout_node - .drm_fd - .set_cursor(crtc, Option::<&dumbbuffer::DumbBuffer>::None) - { - Ok(()) => debug!(?crtc, "cleared legacy DRM cursor"), - Err(error) => debug!(?crtc, %error, "legacy DRM cursor clear failed"), - } -} - -fn clear_all_drm_cursor_planes(scanout_node: &ScanoutNode) { - let Ok(planes) = scanout_node.drm_device.plane_handles() else { - debug!("failed to query DRM planes for cursor clear"); - return; - }; - - let mut request = AtomicModeReq::new(); - let mut cursor_planes = Vec::new(); - for plane in planes { - let Ok(properties) = scanout_node.drm_device.get_properties(plane) else { - debug!( - ?plane, - "failed to query DRM plane properties for cursor clear" - ); - continue; - }; - let Ok(property_info) = properties.as_hashmap(&scanout_node.drm_device) else { - debug!( - ?plane, - "failed to map DRM plane properties for cursor clear" - ); - continue; - }; - let Some(type_property) = property_info.get("type") else { - continue; - }; - let Some((_, type_value)) = properties - .iter() - .find(|(property, _)| **property == type_property.handle()) - else { - continue; - }; - if *type_value != PlaneType::Cursor as u64 { - continue; - } - let Some(crtc_id) = property_info.get("CRTC_ID") else { - continue; - }; - let Some(fb_id) = property_info.get("FB_ID") else { - continue; - }; - request.add_property(plane, crtc_id.handle(), property::Value::CRTC(None)); - request.add_property(plane, fb_id.handle(), property::Value::Framebuffer(None)); - cursor_planes.push(plane); - } - - if cursor_planes.is_empty() { - return; - } - - match scanout_node - .drm_device - .atomic_commit(AtomicCommitFlags::empty(), request) - { - Ok(()) => debug!(?cursor_planes, "cleared all DRM cursor planes"), - Err(error) => debug!(?cursor_planes, %error, "all DRM cursor plane clear failed"), - } -} - -fn create_hardware_cursor(scanout_node: &ScanoutNode) -> Option { - let icon = CursorIcon::Default; - let image = cursor_image_for_icon(icon); - let width = cursor_capability(scanout_node, DriverCapability::CursorWidth) - .unwrap_or(image.width) - .max(image.width); - let height = cursor_capability(scanout_node, DriverCapability::CursorHeight) - .unwrap_or(image.height) - .max(image.height); - let mut buffer = - match scanout_node - .drm_device - .create_dumb_buffer((width, height), Fourcc::Argb8888, 32) - { - Ok(buffer) => buffer, - Err(error) => { - warn!(%error, width, height, "failed to create DRM hardware cursor buffer"); - return None; - } - }; - - let pitch = usize::try_from(buffer.pitch()).ok()?; - let mut mapping = match scanout_node.drm_device.map_dumb_buffer(&mut buffer) { - Ok(mapping) => mapping, - Err(error) => { - warn!(%error, "failed to map DRM hardware cursor buffer"); - return None; - } - }; - mapping.fill(0); - copy_cursor_rgba_to_argb8888(&image, mapping.as_mut(), pitch, width, height); - drop(mapping); - - let hotspot = ( - cmp::min(image.hotspot.0.max(0), width.saturating_sub(1) as i32), - cmp::min(image.hotspot.1.max(0), height.saturating_sub(1) as i32), - ); - info!( - width, - height, - ?hotspot, - "created DRM hardware cursor buffer" - ); - Some(HardwareCursor { - buffer, - hotspot, - icon, - }) -} - -fn update_hardware_cursor_image( - scanout_node: &ScanoutNode, - cursor: &mut HardwareCursor, - icon: CursorIcon, -) { - let image = cursor_image_for_icon(icon); - let (width, height) = cursor.buffer.size(); - let pitch = match usize::try_from(cursor.buffer.pitch()) { - Ok(pitch) => pitch, - Err(error) => { - warn!(%error, "failed to read DRM hardware cursor pitch"); - return; - } - }; - let mut mapping = match scanout_node.drm_device.map_dumb_buffer(&mut cursor.buffer) { - Ok(mapping) => mapping, - Err(error) => { - warn!(%error, ?icon, "failed to map DRM hardware cursor buffer for icon update"); - return; - } - }; - mapping.fill(0); - copy_cursor_rgba_to_argb8888(&image, mapping.as_mut(), pitch, width, height); - drop(mapping); - - cursor.hotspot = ( - cmp::min(image.hotspot.0.max(0), width.saturating_sub(1) as i32), - cmp::min(image.hotspot.1.max(0), height.saturating_sub(1) as i32), - ); - cursor.icon = icon; - debug!(?icon, hotspot = ?cursor.hotspot, "updated DRM hardware cursor image"); -} - -fn cursor_capability(scanout_node: &ScanoutNode, capability: DriverCapability) -> Option { - scanout_node - .drm_device - .get_driver_capability(capability) - .ok() - .and_then(|value| u32::try_from(value).ok()) - .filter(|value| *value > 0) -} - -fn copy_cursor_rgba_to_argb8888( - image: &CursorImage, - target: &mut [u8], - pitch: usize, - cursor_width: u32, - cursor_height: u32, -) { - let copy_width = cmp::min(image.width, cursor_width) as usize; - let copy_height = cmp::min(image.height, cursor_height) as usize; - let image_width = image.width as usize; - for y in 0..copy_height { - for x in 0..copy_width { - let source = (y * image_width + x) * 4; - let destination = y * pitch + x * 4; - let [red, green, blue, alpha] = image.pixels_rgba[source..source + 4] else { - continue; - }; - // DRM_FORMAT_ARGB8888 is stored as BGRA bytes on little-endian hosts. - target[destination..destination + 4].copy_from_slice(&[blue, green, red, alpha]); - } - } -} - -fn point_in_logical_rect(point: Point, rect: Rectangle) -> bool { - point.x >= f64::from(rect.loc.x) - && point.y >= f64::from(rect.loc.y) - && point.x < f64::from(rect.loc.x + rect.size.w) - && point.y < f64::from(rect.loc.y + rect.size.h) -} - pub(super) fn create_udev_device( session: &mut LibSeatSession, path: PathBuf, diff --git a/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs b/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs new file mode 100644 index 0000000..45c2890 --- /dev/null +++ b/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs @@ -0,0 +1,291 @@ +use std::cmp; + +use smithay::{ + backend::allocator::Fourcc, + reexports::drm::{ + Device as BasicDrmDevice, DriverCapability, + buffer::Buffer as DrmBuffer, + control::{ + AtomicCommitFlags, Device as ControlDevice, PlaneType, atomic::AtomicModeReq, crtc, + dumbbuffer, property, + }, + }, + utils::{Logical, Point, Rectangle}, +}; +use tracing::{debug, info, warn}; + +use super::{ScanoutNode, UdevDevice}; +use crate::compositor::{ + App, + cursor::{CursorIcon, CursorImage, cursor_image_for_icon}, +}; + +pub(super) struct HardwareCursor { + buffer: dumbbuffer::DumbBuffer, + hotspot: (i32, i32), + icon: CursorIcon, +} + +impl UdevDevice { + pub(in crate::compositor::udev) fn update_hardware_cursor(&mut self, state: &App) { + let Some(cursor) = self.hardware_cursor.as_mut() else { + return; + }; + if cursor.icon != state.cursor_icon { + update_hardware_cursor_image(&self.scanout_node, cursor, state.cursor_icon); + if let Some(crtc) = self.hardware_cursor_crtc { + set_legacy_cursor(&self.scanout_node, crtc, cursor); + } + self.hardware_cursor_position = None; + } + + if !self.hardware_cursor_initialized { + clear_all_drm_cursor_planes(&self.scanout_node); + for output in &self.output_surfaces { + clear_legacy_cursor(&self.scanout_node, output.target.crtc); + } + self.hardware_cursor_initialized = true; + } + + let pointer = state.pointer_location; + let target = self.output_surfaces.iter().find_map(|output| { + let view = state.output_render_view(output.target.connector_name())?; + let rect = Rectangle::new(view.location, view.size); + point_in_logical_rect(pointer, rect).then(|| { + let x = (pointer.x - f64::from(view.location.x)).round() as i32 - cursor.hotspot.0; + let y = (pointer.y - f64::from(view.location.y)).round() as i32 - cursor.hotspot.1; + (output.target.crtc, (x, y)) + }) + }); + + let Some((crtc, position)) = target else { + if let Some(active_crtc) = self.hardware_cursor_crtc.take() { + clear_legacy_cursor(&self.scanout_node, active_crtc); + } + self.hardware_cursor_position = None; + return; + }; + + if self.hardware_cursor_crtc != Some(crtc) { + if let Some(active_crtc) = self.hardware_cursor_crtc { + clear_legacy_cursor(&self.scanout_node, active_crtc); + } + set_legacy_cursor(&self.scanout_node, crtc, cursor); + self.hardware_cursor_crtc = Some(crtc); + self.hardware_cursor_position = None; + } + + if self.hardware_cursor_position != Some(position) { + move_legacy_cursor(&self.scanout_node, crtc, position); + self.hardware_cursor_position = Some(position); + } + } +} + +fn set_legacy_cursor(scanout_node: &ScanoutNode, crtc: crtc::Handle, cursor: &HardwareCursor) { + #[allow(deprecated)] + match scanout_node.drm_fd.set_cursor(crtc, Some(&cursor.buffer)) { + Ok(()) => debug!(?crtc, "set legacy DRM cursor"), + Err(error) => debug!(?crtc, %error, "legacy DRM cursor set failed"), + } +} + +fn move_legacy_cursor(scanout_node: &ScanoutNode, crtc: crtc::Handle, position: (i32, i32)) { + #[allow(deprecated)] + match scanout_node.drm_fd.move_cursor(crtc, position) { + Ok(()) => debug!(?crtc, ?position, "moved legacy DRM cursor"), + Err(error) => debug!(?crtc, ?position, %error, "legacy DRM cursor move failed"), + } +} + +fn clear_legacy_cursor(scanout_node: &ScanoutNode, crtc: crtc::Handle) { + #[allow(deprecated)] + match scanout_node + .drm_fd + .set_cursor(crtc, Option::<&dumbbuffer::DumbBuffer>::None) + { + Ok(()) => debug!(?crtc, "cleared legacy DRM cursor"), + Err(error) => debug!(?crtc, %error, "legacy DRM cursor clear failed"), + } +} + +fn clear_all_drm_cursor_planes(scanout_node: &ScanoutNode) { + let Ok(planes) = scanout_node.drm_device.plane_handles() else { + debug!("failed to query DRM planes for cursor clear"); + return; + }; + + let mut request = AtomicModeReq::new(); + let mut cursor_planes = Vec::new(); + for plane in planes { + let Ok(properties) = scanout_node.drm_device.get_properties(plane) else { + debug!( + ?plane, + "failed to query DRM plane properties for cursor clear" + ); + continue; + }; + let Ok(property_info) = properties.as_hashmap(&scanout_node.drm_device) else { + debug!( + ?plane, + "failed to map DRM plane properties for cursor clear" + ); + continue; + }; + let Some(type_property) = property_info.get("type") else { + continue; + }; + let Some((_, type_value)) = properties + .iter() + .find(|(property, _)| **property == type_property.handle()) + else { + continue; + }; + if *type_value != PlaneType::Cursor as u64 { + continue; + } + let Some(crtc_id) = property_info.get("CRTC_ID") else { + continue; + }; + let Some(fb_id) = property_info.get("FB_ID") else { + continue; + }; + request.add_property(plane, crtc_id.handle(), property::Value::CRTC(None)); + request.add_property(plane, fb_id.handle(), property::Value::Framebuffer(None)); + cursor_planes.push(plane); + } + + if cursor_planes.is_empty() { + return; + } + + match scanout_node + .drm_device + .atomic_commit(AtomicCommitFlags::empty(), request) + { + Ok(()) => debug!(?cursor_planes, "cleared all DRM cursor planes"), + Err(error) => debug!(?cursor_planes, %error, "all DRM cursor plane clear failed"), + } +} + +pub(super) fn create_hardware_cursor(scanout_node: &ScanoutNode) -> Option { + let icon = CursorIcon::Default; + let image = cursor_image_for_icon(icon); + let width = cursor_capability(scanout_node, DriverCapability::CursorWidth) + .unwrap_or(image.width) + .max(image.width); + let height = cursor_capability(scanout_node, DriverCapability::CursorHeight) + .unwrap_or(image.height) + .max(image.height); + let mut buffer = + match scanout_node + .drm_device + .create_dumb_buffer((width, height), Fourcc::Argb8888, 32) + { + Ok(buffer) => buffer, + Err(error) => { + warn!(%error, width, height, "failed to create DRM hardware cursor buffer"); + return None; + } + }; + + let pitch = usize::try_from(buffer.pitch()).ok()?; + let mut mapping = match scanout_node.drm_device.map_dumb_buffer(&mut buffer) { + Ok(mapping) => mapping, + Err(error) => { + warn!(%error, "failed to map DRM hardware cursor buffer"); + return None; + } + }; + mapping.fill(0); + copy_cursor_rgba_to_argb8888(&image, mapping.as_mut(), pitch, width, height); + drop(mapping); + + let hotspot = ( + cmp::min(image.hotspot.0.max(0), width.saturating_sub(1) as i32), + cmp::min(image.hotspot.1.max(0), height.saturating_sub(1) as i32), + ); + info!( + width, + height, + ?hotspot, + "created DRM hardware cursor buffer" + ); + Some(HardwareCursor { + buffer, + hotspot, + icon, + }) +} + +fn update_hardware_cursor_image( + scanout_node: &ScanoutNode, + cursor: &mut HardwareCursor, + icon: CursorIcon, +) { + let image = cursor_image_for_icon(icon); + let (width, height) = cursor.buffer.size(); + let pitch = match usize::try_from(cursor.buffer.pitch()) { + Ok(pitch) => pitch, + Err(error) => { + warn!(%error, "failed to read DRM hardware cursor pitch"); + return; + } + }; + let mut mapping = match scanout_node.drm_device.map_dumb_buffer(&mut cursor.buffer) { + Ok(mapping) => mapping, + Err(error) => { + warn!(%error, ?icon, "failed to map DRM hardware cursor buffer for icon update"); + return; + } + }; + mapping.fill(0); + copy_cursor_rgba_to_argb8888(&image, mapping.as_mut(), pitch, width, height); + drop(mapping); + + cursor.hotspot = ( + cmp::min(image.hotspot.0.max(0), width.saturating_sub(1) as i32), + cmp::min(image.hotspot.1.max(0), height.saturating_sub(1) as i32), + ); + cursor.icon = icon; + debug!(?icon, hotspot = ?cursor.hotspot, "updated DRM hardware cursor image"); +} + +fn cursor_capability(scanout_node: &ScanoutNode, capability: DriverCapability) -> Option { + scanout_node + .drm_device + .get_driver_capability(capability) + .ok() + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0) +} + +fn copy_cursor_rgba_to_argb8888( + image: &CursorImage, + target: &mut [u8], + pitch: usize, + cursor_width: u32, + cursor_height: u32, +) { + let copy_width = cmp::min(image.width, cursor_width) as usize; + let copy_height = cmp::min(image.height, cursor_height) as usize; + let image_width = image.width as usize; + for y in 0..copy_height { + for x in 0..copy_width { + let source = (y * image_width + x) * 4; + let destination = y * pitch + x * 4; + let [red, green, blue, alpha] = image.pixels_rgba[source..source + 4] else { + continue; + }; + // DRM_FORMAT_ARGB8888 is stored as BGRA bytes on little-endian hosts. + target[destination..destination + 4].copy_from_slice(&[blue, green, red, alpha]); + } + } +} + +fn point_in_logical_rect(point: Point, rect: Rectangle) -> bool { + point.x >= f64::from(rect.loc.x) + && point.y >= f64::from(rect.loc.y) + && point.x < f64::from(rect.loc.x + rect.size.w) + && point.y < f64::from(rect.loc.y + rect.size.h) +} diff --git a/crates/hearthspace-compositor/src/compositor/udev/hotplug.rs b/crates/hearthspace-compositor/src/compositor/udev/hotplug.rs new file mode 100644 index 0000000..4ab9d1c --- /dev/null +++ b/crates/hearthspace-compositor/src/compositor/udev/hotplug.rs @@ -0,0 +1,219 @@ +use std::{io, path::PathBuf}; + +use smithay::backend::udev::primary_gpu; +use tracing::{debug, error, info, warn}; + +use super::{ + ConnectorSync, UdevBackendState, + device::{ + KmsOutputTarget, UdevDevice, UdevDeviceInfo, create_udev_device, current_device_list, + log_device_list, + }, + insert_libinput_source, +}; + +impl UdevBackendState { + pub(super) fn pause_session(&mut self) { + self.session_active = false; + self.drm_commits_paused = true; + self.kms_devices_active = false; + self.repaint_pending = false; + self.frame_pending = false; + self.frame_dirty = true; + if let Some(token) = self.input_source.take() { + self.loop_handle.remove(token); + info!("native libinput source removed for session pause"); + } + if let Some(device) = &mut self.primary_device { + device.scanout_node.drm_device.pause(); + device.output_surfaces.clear(); + device.active = false; + } + info!("native session paused; DRM commits disabled and Wayland clients remain connected"); + } + + pub(super) fn activate_session(&mut self) -> ConnectorSync { + self.session_active = true; + self.connector_rescan_pending = true; + + if self.primary_device.is_none() + && let Err(error) = self.open_primary_device() + { + error!(%error, "failed to open primary DRM device after session activation"); + } + + if self.input_source.is_none() { + match insert_libinput_source(&self.loop_handle, self.session.clone(), &self.seat_name) { + Ok(token) => { + self.input_source = Some(token); + info!("native libinput source recreated after session activation"); + } + Err(error) => { + error!(%error, "failed to recreate native libinput source after session activation"); + } + } + } + + if let Err(error) = self.rescan_devices() { + error!(%error, "failed to re-scan DRM devices after session activation"); + } + + let Some(device) = self.primary_device.as_mut() else { + self.kms_devices_active = false; + self.drm_commits_paused = true; + self.repaint_pending = false; + self.frame_pending = false; + self.frame_dirty = true; + warn!("native session activated but no primary DRM device is available"); + return ConnectorSync::default(); + }; + + device.active = true; + if let Err(error) = device.scanout_node.drm_device.activate(true) { + error!(%error, "failed to reactivate primary DRM device after session activation"); + } + + device.output_targets = device.connected_output_targets(); + let primary = device + .output_targets + .first() + .map(KmsOutputTarget::output_descriptor); + if let Err(error) = device.rebuild_output_surfaces(device.output_targets.clone()) { + error!(%error, "failed to rebuild KMS output surfaces after session activation"); + } + + self.kms_devices_active = device.has_output_surfaces(); + self.drm_commits_paused = !self.kms_devices_active; + self.connector_rescan_pending = false; + self.repaint_pending = self.kms_devices_active; + self.frame_pending = false; + self.frame_dirty = true; + + info!( + repaint_pending = self.repaint_pending, + "native session activated; DRM devices reactivated" + ); + + ConnectorSync { + descriptors: device.output_descriptors(), + primary, + } + } + + pub(super) fn add_or_update_device(&mut self, device_id: u64, path: PathBuf) { + match self + .devices + .iter_mut() + .find(|device| device.device_id == device_id) + { + Some(device) => device.path = path, + None => self.devices.push(UdevDeviceInfo { device_id, path }), + } + self.connector_rescan_pending = true; + } + + pub(super) fn output_descriptors(&self) -> Vec { + self.primary_device + .as_ref() + .map(UdevDevice::output_descriptors) + .unwrap_or_default() + } + + pub(super) fn handle_device_changed(&mut self, device_id: u64) -> ConnectorSync { + self.connector_rescan_pending = true; + if !self.session_active || self.drm_commits_paused { + debug!( + device_id, + "deferring DRM device change while native session is paused" + ); + return ConnectorSync::default(); + } + + if let Err(error) = self.rescan_devices() { + error!(%error, "failed to re-scan DRM devices after device change"); + } + + let Some(device) = self.primary_device.as_mut() else { + return ConnectorSync::default(); + }; + if device.scanout_node.drm_fd.dev_id().ok() != Some(device_id) { + debug!( + device_id, + "changed DRM device is not the selected primary device" + ); + return ConnectorSync { + descriptors: device.output_descriptors(), + primary: None, + }; + } + + let previous_target = device.output_target.clone(); + let next_targets = device.connected_output_targets(); + let next_target = next_targets.first().cloned(); + if previous_target == next_target && device.output_targets == next_targets { + debug!("primary DRM connector state re-scanned; selected output is unchanged"); + self.connector_rescan_pending = false; + device.output_targets = next_targets; + return ConnectorSync { + descriptors: device.output_descriptors(), + primary: None, + }; + } + + info!(previous_target = ?previous_target, next_target = ?next_target, "DRM connector selection changed; rebuilding KMS output surfaces"); + device.output_targets = next_targets; + let primary = next_target.as_ref().map(KmsOutputTarget::output_descriptor); + if let Err(error) = device.rebuild_output_surfaces(device.output_targets.clone()) { + error!(%error, "failed to rebuild KMS output surfaces"); + } + + self.kms_devices_active = self.session_active && device.has_output_surfaces(); + self.drm_commits_paused = !self.session_active || !device.has_output_surfaces(); + self.connector_rescan_pending = false; + self.frame_pending = false; + self.frame_dirty = self.kms_devices_active; + self.repaint_pending = self.kms_devices_active; + + ConnectorSync { + descriptors: device.output_descriptors(), + primary, + } + } + + pub(super) fn remove_device(&mut self, device_id: u64) { + let Some(index) = self + .devices + .iter() + .position(|device| device.device_id == device_id) + else { + return; + }; + let removed = self.devices.remove(index); + if self + .primary_device + .as_ref() + .is_some_and(|device| device.path == removed.path) + { + self.primary_device = None; + self.kms_devices_active = false; + self.repaint_pending = false; + warn!("primary DRM device removed; KMS state marked inactive"); + } + self.connector_rescan_pending = true; + } + + fn rescan_devices(&mut self) -> io::Result<()> { + self.devices = current_device_list(&self.seat_name)?; + log_device_list(&self.seat_name, &self.devices); + Ok(()) + } + + fn open_primary_device(&mut self) -> Result<(), Box> { + let Some(path) = primary_gpu(&self.seat_name)? else { + return Ok(()); + }; + let (device, _notifier) = create_udev_device(&mut self.session, path, self.session_active)?; + self.primary_device = Some(device); + Ok(()) + } +} diff --git a/crates/hearthspace-compositor/src/compositor/windows.rs b/crates/hearthspace-compositor/src/compositor/windows.rs index 8401aa1..a99ec2e 100644 --- a/crates/hearthspace-compositor/src/compositor/windows.rs +++ b/crates/hearthspace-compositor/src/compositor/windows.rs @@ -1,8 +1,5 @@ use smithay::{ - desktop::{ - PopupManager, WindowSurfaceType, - utils::{bbox_from_surface_tree, under_from_surface_tree}, - }, + desktop::utils::bbox_from_surface_tree, utils::{Logical, Physical, Point, Rectangle, SERIAL_COUNTER, Size}, wayland::{ compositor::{TraversalAction, with_states, with_surface_tree_downward}, @@ -16,13 +13,10 @@ use wayland_protocols::xdg::{ }; use wayland_server::protocol::wl_surface::{self, WlSurface}; -use crate::{ - accessibility::ManagedWindowAccessibilityInfo, - config::*, - geometry::{CanvasPoint, rect_contains}, -}; +use crate::{accessibility::ManagedWindowAccessibilityInfo, config::*, geometry::CanvasPoint}; mod geometry; +mod hit_testing; mod ordering; pub(super) use geometry::{ResizeEdges, resize_cursor_icon}; @@ -30,11 +24,11 @@ use ordering::normal_insert_index_for_kinds; 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, + resize_target_content_size, title_bar_canvas_rect_for, window_canvas_rect_for, }; use super::{ - App, HitTarget, ManagedWindow, ManagedWindowKind, ResizeState, WindowDecoration, + App, ManagedWindow, ManagedWindowKind, ResizeState, WindowDecoration, idle::ActivityReason, rendering::{toplevel_geometry_loc, toplevel_geometry_size}, }; @@ -198,120 +192,6 @@ impl App { .collect() } - pub(super) fn hit_test(&self, location: Point) -> Option { - // Shell chrome (the bar and the launcher palette) is drawn in screen - // space above the canvas, so it is hit-tested first using each surface's - // own screen-space origin. - for (window_index, window) in self.windows.iter().enumerate().rev() { - match window.kind { - ManagedWindowKind::ShellBar => { - for origin in self.shell_bar_logical_origins() { - if let Some(target) = - self.hit_test_shell_surface(window_index, location, origin) - { - return Some(target); - } - } - } - ManagedWindowKind::Launcher => { - for origin in self.launcher_logical_origins() { - if let Some(target) = - self.hit_test_shell_surface(window_index, location, origin) - { - return Some(target); - } - } - } - ManagedWindowKind::Normal => continue, - } - } - - if location.y < f64::from(CONTROL_BAR_HEIGHT) { - return None; - } - - let canvas_location = self.screen_to_canvas(location); - - for (window_index, window) in self.windows.iter().enumerate().rev() { - if window.kind != ManagedWindowKind::Normal { - continue; - } - - let content_origin = self.content_canvas_origin(window_index); - - // Popups (menus) sit above this window's content and chrome, so - // they are hit-tested first. Their location comes from the popup's - // configured offset relative to the parent surface origin. - let geometry_loc = toplevel_geometry_loc(window.surface.wl_surface()); - for (popup, popup_offset) in - PopupManager::popups_for_surface(window.surface.wl_surface()) - { - let popup_origin = - content_origin + geometry_loc + popup_offset - popup.geometry().loc; - let hit = under_from_surface_tree( - popup.wl_surface(), - canvas_location, - popup_origin, - WindowSurfaceType::ALL, - ); - if let Some((surface, surface_location)) = hit { - let relative_surface_location = canvas_location - surface_location.to_f64(); - let pointer_focus_origin = location - relative_surface_location; - return Some(HitTarget::Client { - window_index, - surface, - surface_location: pointer_focus_origin, - }); - } - } - - if self.has_compositor_chrome(window_index) { - if rect_contains(self.close_button_canvas_rect(window_index), canvas_location) { - return Some(HitTarget::CloseButton { window_index }); - } - - if rect_contains(self.title_bar_canvas_rect(window_index), canvas_location) { - return Some(HitTarget::TitleBar { window_index }); - } - } - - // The interactive resize handle is a band centered on each window - // edge, checked before the client surface tree so the visible edge - // is grabbable for resizing. This applies to both server- and - // client-side-decorated windows; for the latter `window_canvas_rect` - // tracks the visible geometry so the band sits on the real edge. - let window_rect = self.window_canvas_rect(window_index); - if let Some(edges) = resize_edges_at( - window_rect, - canvas_location, - RESIZE_HANDLE_OUTSET, - RESIZE_HANDLE_INSET, - ) { - return Some(HitTarget::ResizeBorder { - window_index, - edges, - }); - } - - if let Some((surface, surface_location)) = under_from_surface_tree( - window.surface.wl_surface(), - canvas_location, - content_origin, - WindowSurfaceType::ALL, - ) { - let relative_surface_location = canvas_location - surface_location.to_f64(); - let pointer_focus_origin = location - relative_surface_location; - return Some(HitTarget::Client { - window_index, - surface, - surface_location: pointer_focus_origin, - }); - } - } - - None - } - pub(super) fn raise_window(&mut self, window_index: usize) -> usize { if self.windows[window_index].kind != ManagedWindowKind::Normal { return window_index; @@ -610,28 +490,6 @@ impl App { .to_physical(1), } } - - fn hit_test_shell_surface( - &self, - window_index: usize, - location: Point, - origin: Point, - ) -> Option { - let window = &self.windows[window_index]; - let (surface, surface_location) = under_from_surface_tree( - window.surface.wl_surface(), - location, - origin, - WindowSurfaceType::ALL, - )?; - let relative_surface_location = location - surface_location.to_f64(); - let pointer_focus_origin = location - relative_surface_location; - Some(HitTarget::Client { - window_index, - surface, - surface_location: pointer_focus_origin, - }) - } } fn launcher_logical_origins_for_outputs( diff --git a/crates/hearthspace-compositor/src/compositor/windows/hit_testing.rs b/crates/hearthspace-compositor/src/compositor/windows/hit_testing.rs new file mode 100644 index 0000000..420b25d --- /dev/null +++ b/crates/hearthspace-compositor/src/compositor/windows/hit_testing.rs @@ -0,0 +1,153 @@ +use smithay::{ + desktop::{PopupManager, WindowSurfaceType, utils::under_from_surface_tree}, + utils::{Logical, Point}, +}; + +use crate::{ + config::{CONTROL_BAR_HEIGHT, RESIZE_HANDLE_INSET, RESIZE_HANDLE_OUTSET}, + geometry::rect_contains, +}; + +use super::geometry::resize_edges_at; +use crate::compositor::{App, HitTarget, ManagedWindowKind, rendering::toplevel_geometry_loc}; + +impl App { + pub(in crate::compositor) fn hit_test( + &self, + location: Point, + ) -> Option { + // Shell chrome (the bar and the launcher palette) is drawn in screen + // space above the canvas, so it is hit-tested first using each surface's + // own screen-space origin. + for (window_index, window) in self.windows.iter().enumerate().rev() { + match window.kind { + ManagedWindowKind::ShellBar => { + for origin in self.shell_bar_logical_origins() { + if let Some(target) = + self.hit_test_shell_surface(window_index, location, origin) + { + return Some(target); + } + } + } + ManagedWindowKind::Launcher => { + for origin in self.launcher_logical_origins() { + if let Some(target) = + self.hit_test_shell_surface(window_index, location, origin) + { + return Some(target); + } + } + } + ManagedWindowKind::Normal => continue, + } + } + + if location.y < f64::from(CONTROL_BAR_HEIGHT) { + return None; + } + + let canvas_location = self.screen_to_canvas(location); + + for (window_index, window) in self.windows.iter().enumerate().rev() { + if window.kind != ManagedWindowKind::Normal { + continue; + } + + let content_origin = self.content_canvas_origin(window_index); + + // Popups (menus) sit above this window's content and chrome, so + // they are hit-tested first. Their location comes from the popup's + // configured offset relative to the parent surface origin. + let geometry_loc = toplevel_geometry_loc(window.surface.wl_surface()); + for (popup, popup_offset) in + PopupManager::popups_for_surface(window.surface.wl_surface()) + { + let popup_origin = + content_origin + geometry_loc + popup_offset - popup.geometry().loc; + let hit = under_from_surface_tree( + popup.wl_surface(), + canvas_location, + popup_origin, + WindowSurfaceType::ALL, + ); + if let Some((surface, surface_location)) = hit { + let relative_surface_location = canvas_location - surface_location.to_f64(); + let pointer_focus_origin = location - relative_surface_location; + return Some(HitTarget::Client { + window_index, + surface, + surface_location: pointer_focus_origin, + }); + } + } + + if self.has_compositor_chrome(window_index) { + if rect_contains(self.close_button_canvas_rect(window_index), canvas_location) { + return Some(HitTarget::CloseButton { window_index }); + } + + if rect_contains(self.title_bar_canvas_rect(window_index), canvas_location) { + return Some(HitTarget::TitleBar { window_index }); + } + } + + // The interactive resize handle is a band centered on each window + // edge, checked before the client surface tree so the visible edge + // is grabbable for resizing. This applies to both server- and + // client-side-decorated windows; for the latter `window_canvas_rect` + // tracks the visible geometry so the band sits on the real edge. + let window_rect = self.window_canvas_rect(window_index); + if let Some(edges) = resize_edges_at( + window_rect, + canvas_location, + RESIZE_HANDLE_OUTSET, + RESIZE_HANDLE_INSET, + ) { + return Some(HitTarget::ResizeBorder { + window_index, + edges, + }); + } + + if let Some((surface, surface_location)) = under_from_surface_tree( + window.surface.wl_surface(), + canvas_location, + content_origin, + WindowSurfaceType::ALL, + ) { + let relative_surface_location = canvas_location - surface_location.to_f64(); + let pointer_focus_origin = location - relative_surface_location; + return Some(HitTarget::Client { + window_index, + surface, + surface_location: pointer_focus_origin, + }); + } + } + + None + } + + fn hit_test_shell_surface( + &self, + window_index: usize, + location: Point, + origin: Point, + ) -> Option { + let window = &self.windows[window_index]; + let (surface, surface_location) = under_from_surface_tree( + window.surface.wl_surface(), + location, + origin, + WindowSurfaceType::ALL, + )?; + let relative_surface_location = location - surface_location.to_f64(); + let pointer_focus_origin = location - relative_surface_location; + Some(HitTarget::Client { + window_index, + surface, + surface_location: pointer_focus_origin, + }) + } +} diff --git a/crates/hearthspace-compositor/tests/headless_control.rs b/crates/hearthspace-compositor/tests/headless_control.rs index 4122631..6a9115c 100644 --- a/crates/hearthspace-compositor/tests/headless_control.rs +++ b/crates/hearthspace-compositor/tests/headless_control.rs @@ -1,7 +1,7 @@ use std::{ io::{Read, Write}, os::unix::net::UnixStream, - path::PathBuf, + path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::Mutex, thread, @@ -19,6 +19,8 @@ static HEADLESS_TEST_LOCK: Mutex<()> = Mutex::new(()); struct HeadlessCompositor { child: Child, + runtime_dir: tempfile::TempDir, + command_socket_path: PathBuf, } impl HeadlessCompositor { @@ -27,20 +29,29 @@ impl HeadlessCompositor { } fn spawn_with_size(size: &str) -> Self { + let runtime_dir = tempfile::tempdir().expect("create headless runtime dir"); + let command_socket_path = runtime_dir.path().join("command.sock"); + let wayland_display = format!("hearthspace-test-{}", std::process::id()); let child = Command::new(env!("CARGO_BIN_EXE_hearthspace")) .args(["--headless", "--no-shell", "--headless-size", size]) + .env("XDG_RUNTIME_DIR", runtime_dir.path()) + .env("HEARTHSPACE_COMMAND_SOCKET", &command_socket_path) + .env("HEARTHSPACE_WAYLAND_DISPLAY", wayland_display) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn headless Hearthspace"); - Self { child } + Self { + child, + runtime_dir, + command_socket_path, + } } 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 { @@ -48,7 +59,7 @@ impl HeadlessCompositor { panic!("headless Hearthspace exited before accepting commands: {status}"); } - match UnixStream::connect(&path) { + match UnixStream::connect(&self.command_socket_path) { Ok(stream) => return stream, Err(error) => last_error = Some(error), } @@ -58,10 +69,18 @@ impl HeadlessCompositor { panic!( "timed out connecting to {}: {:?}", - path.display(), + self.command_socket_path.display(), last_error ); } + + fn send_text_command(&self, command: &str) -> String { + send_text_command(&self.command_socket_path, command) + } + + fn take_screenshot(&self) -> Vec { + take_screenshot(&self.command_socket_path) + } } impl Drop for HeadlessCompositor { @@ -70,6 +89,7 @@ impl Drop for HeadlessCompositor { let _ = self.child.kill(); let _ = self.child.wait(); } + let _ = self.runtime_dir.path(); } } @@ -89,14 +109,18 @@ fn headless_control_socket_drives_input_screenshot_and_quit() { "key-down 30", "key-up 30", ] { - assert_eq!(send_text_command(command), "ok\n", "command {command:?}"); + assert_eq!( + compositor.send_text_command(command), + "ok\n", + "command {command:?}" + ); } - let screenshot = take_screenshot(); + let screenshot = compositor.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"); + assert_eq!(compositor.send_text_command("quit"), "ok\n"); wait_for_exit(&mut compositor.child); } @@ -108,11 +132,11 @@ fn headless_control_socket_spawns_and_drives_real_gtk_client() { let first_stream = compositor.wait_for_socket(); drop(first_stream); - let empty = take_screenshot(); + let empty = compositor.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!(compositor.send_text_command("spawn a11y-test"), "ok\n"); + let with_client = wait_for_screenshot_change(&compositor, &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() { @@ -133,35 +157,32 @@ fn headless_control_socket_spawns_and_drives_real_gtk_client() { ] { if command == "screenshot" { assert_eq!( - png_dimensions(&take_screenshot()), + png_dimensions(&compositor.take_screenshot()), (CLIENT_WIDTH, CLIENT_HEIGHT) ); } else { - assert_eq!(send_text_command(command), "ok\n", "command {command:?}"); + assert_eq!( + compositor.send_text_command(command), + "ok\n", + "command {command:?}" + ); } } - assert_eq!(send_text_command("quit"), "ok\n"); + assert_eq!(compositor.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"); +fn send_text_command(command_socket_path: &Path, 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"); +fn take_screenshot(command_socket_path: &Path) -> 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); @@ -180,10 +201,10 @@ fn take_screenshot() -> Vec { } #[cfg(feature = "test-apps")] -fn wait_for_screenshot_change(previous: &[u8]) -> Vec { +fn wait_for_screenshot_change(compositor: &HeadlessCompositor, previous: &[u8]) -> Vec { let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { - let screenshot = take_screenshot(); + let screenshot = compositor.take_screenshot(); if screenshot != previous { return screenshot; } diff --git a/crates/hearthspace-session/src/cli.rs b/crates/hearthspace-session/src/cli.rs new file mode 100644 index 0000000..ea63a78 --- /dev/null +++ b/crates/hearthspace-session/src/cli.rs @@ -0,0 +1,66 @@ +use clap::{ArgAction, Parser}; + +#[derive(Debug, Parser)] +#[command(about = "Start a Hearthspace desktop session")] +pub(crate) struct Args { + /// Run child processes through `cargo run -p ...`. + #[arg(long, conflicts_with = "installed")] + pub(crate) dev: bool, + + /// Run installed child binaries directly. + #[arg(long, conflicts_with = "dev")] + pub(crate) installed: bool, + + /// Wayland display socket name. + #[arg(long)] + pub(crate) wayland_display: Option, + + /// Start development watchers for Hearthspace apps. + #[arg(long, conflicts_with = "no_watch_apps")] + pub(crate) watch_apps: bool, + + /// Do not start development watchers. + #[arg(long, conflicts_with = "watch_apps")] + pub(crate) no_watch_apps: bool, + + /// Do not restart shell if it exits. + #[arg(long)] + pub(crate) no_shell_restart: bool, + + /// Do not restart settingsd if it exits. + #[arg(long)] + pub(crate) no_settingsd_restart: bool, + + /// Set log level for session and children. + #[arg(long, default_value = "info")] + pub(crate) log_level: String, + + /// Print the generated session environment. + #[arg(long)] + pub(crate) print_env: bool, + + /// Print what would be started without starting processes. + #[arg(long, action = ArgAction::SetTrue)] + pub(crate) dry_run: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RunMode { + DevCargo, + InstalledBinaries, +} + +pub(crate) fn resolve_mode(args: &Args) -> RunMode { + if args.dev { + return RunMode::DevCargo; + } + if args.installed { + return RunMode::InstalledBinaries; + } + + if cfg!(debug_assertions) { + RunMode::DevCargo + } else { + RunMode::InstalledBinaries + } +} diff --git a/crates/hearthspace-session/src/compositor.rs b/crates/hearthspace-session/src/compositor.rs new file mode 100644 index 0000000..7e7e171 --- /dev/null +++ b/crates/hearthspace-session/src/compositor.rs @@ -0,0 +1,56 @@ +use std::{env, path::PathBuf}; + +use crate::process::ProcessSpec; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CompositorBackend { + NativeTty, + NestedWinit, +} + +pub(crate) fn select_compositor_backend() -> CompositorBackend { + if env::var("XDG_SESSION_TYPE").is_ok_and(|session_type| session_type == "tty") { + return CompositorBackend::NativeTty; + } + + if host_wayland_display_path().is_some() || env::var_os("DISPLAY").is_some() { + CompositorBackend::NestedWinit + } else { + CompositorBackend::NativeTty + } +} + +pub(crate) fn configure_compositor_environment(spec: &mut ProcessSpec, backend: CompositorBackend) { + spec.env_removals.extend([ + "WAYLAND_DISPLAY".to_string(), + "WAYLAND_SOCKET".to_string(), + "DISPLAY".to_string(), + ]); + + if backend == CompositorBackend::NestedWinit { + if let Some(path) = host_wayland_display_path() { + spec.envs + .push((hearthspace_common::WAYLAND_DISPLAY_ENV.to_string(), path)); + } + if let Some(display) = env::var_os("DISPLAY") { + spec.envs.push(( + "DISPLAY".to_string(), + display.to_string_lossy().into_owned(), + )); + } + } +} + +fn host_wayland_display_path() -> Option { + let display = env::var_os("WAYLAND_DISPLAY")?; + let display_path = PathBuf::from(&display); + let socket_path = if display_path.is_absolute() { + display_path + } else { + PathBuf::from(env::var_os("XDG_RUNTIME_DIR")?).join(display_path) + }; + + socket_path + .exists() + .then(|| socket_path.to_string_lossy().into_owned()) +} diff --git a/crates/hearthspace-session/src/desktop_entries.rs b/crates/hearthspace-session/src/desktop_entries.rs new file mode 100644 index 0000000..7b7841a --- /dev/null +++ b/crates/hearthspace-session/src/desktop_entries.rs @@ -0,0 +1,36 @@ +use std::{fs, path::Path}; + +use tracing::info; + +use crate::session_env::{SessionPaths, repo_root}; + +pub(crate) fn generate_dev_desktop_entries( + paths: &SessionPaths, +) -> Result<(), Box> { + let exec = repo_root().join("scripts/dev-run-app"); + let desktop_entry = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=Hearthspace Settings\n\ + Comment=Configure Hearthspace\n\ + Exec=\"{}\" hearthspace-settings\n\ + Icon=hearthspace\n\ + Categories=Settings;System;\n\ + Keywords=hearthspace;settings;config;preferences;\n\ + OnlyShowIn=Hearthspace;\n\ + X-Hearthspace-Dev=true\n", + escape_exec_path(&exec) + ); + let path = paths + .applications_dir + .join("dev.hearthspace.Settings.desktop"); + fs::write(&path, desktop_entry)?; + info!(path = %path.display(), "generated development desktop entry"); + Ok(()) +} + +fn escape_exec_path(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} diff --git a/crates/hearthspace-session/src/dev_cargo.rs b/crates/hearthspace-session/src/dev_cargo.rs new file mode 100644 index 0000000..145dccc --- /dev/null +++ b/crates/hearthspace-session/src/dev_cargo.rs @@ -0,0 +1,48 @@ +pub(crate) fn dev_cargo_args(binary: &str, binary_args: &[&str]) -> Vec { + let mut args = vec!["run".to_string()]; + if cargo_package_exists(binary) { + args.extend(["-p".to_string(), binary.to_string()]); + } else { + args.extend(["--bin".to_string(), binary.to_string()]); + } + if binary == "hearthspace-compositor" { + args.extend(["--features".to_string(), "udev".to_string()]); + } + args.push("--".to_string()); + args.extend(binary_args.iter().map(|arg| (*arg).to_string())); + args +} + +fn cargo_package_exists(package: &str) -> bool { + matches!( + package, + "hearthspace-compositor" + | "hearthspace-settings" + | "hearthspace-settingsd" + | "hearthspace-shell" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dev_compositor_child_enables_udev_feature() { + assert_eq!( + dev_cargo_args( + "hearthspace-compositor", + &[hearthspace_common::NO_SHELL_FLAG] + ), + vec![ + "run", + "-p", + "hearthspace-compositor", + "--features", + "udev", + "--", + hearthspace_common::NO_SHELL_FLAG, + ] + ); + } +} diff --git a/crates/hearthspace-session/src/main.rs b/crates/hearthspace-session/src/main.rs index 8b9639e..b4788c0 100644 --- a/crates/hearthspace-session/src/main.rs +++ b/crates/hearthspace-session/src/main.rs @@ -1,130 +1,21 @@ -use std::{ - env, fs, - io::{self, Read, Write}, - net::Shutdown, - os::unix::fs::PermissionsExt, - os::unix::net::UnixStream, - path::{Path, PathBuf}, - process::{self, Child, Command, ExitStatus, Stdio}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - thread, - time::{Duration, Instant}, -}; - -use clap::{ArgAction, Parser}; -use nix::{sys::signal, unistd::Pid}; -use tracing::{info, warn}; - -#[derive(Debug, Parser)] -#[command(about = "Start a Hearthspace desktop session")] -struct Args { - /// Run child processes through `cargo run -p ...`. - #[arg(long, conflicts_with = "installed")] - dev: bool, - - /// Run installed child binaries directly. - #[arg(long, conflicts_with = "dev")] - installed: bool, - - /// Wayland display socket name. - #[arg(long)] - wayland_display: Option, - - /// Start development watchers for Hearthspace apps. - #[arg(long, conflicts_with = "no_watch_apps")] - watch_apps: bool, - - /// Do not start development watchers. - #[arg(long, conflicts_with = "watch_apps")] - no_watch_apps: bool, - - /// Do not restart shell if it exits. - #[arg(long)] - no_shell_restart: bool, - - /// Do not restart settingsd if it exits. - #[arg(long)] - no_settingsd_restart: bool, - - /// Set log level for session and children. - #[arg(long, default_value = "info")] - log_level: String, - - /// Print the generated session environment. - #[arg(long)] - print_env: bool, - - /// Print what would be started without starting processes. - #[arg(long, action = ArgAction::SetTrue)] - dry_run: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RunMode { - DevCargo, - InstalledBinaries, -} - -#[derive(Debug)] -struct SessionPaths { - session_dir: PathBuf, - applications_dir: PathBuf, - settings_socket: PathBuf, - session_socket: PathBuf, - shell_command_socket: PathBuf, -} - -#[derive(Debug)] -struct SessionEnv { - wayland_display: String, - vars: Vec<(String, String)>, -} - -#[derive(Debug, Clone)] -struct ProcessSpec { - name: &'static str, - program: String, - args: Vec, - envs: Vec<(String, String)>, - env_removals: Vec, - current_dir: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CompositorBackend { - NativeTty, - NestedWinit, -} - -struct ManagedProcess { - spec: ProcessSpec, - child: Child, - output_activity: Arc>, - restarts: usize, - max_restarts: usize, -} - -#[derive(Debug, Clone, Copy)] -enum ChildOutputStream { - Stdout, - Stderr, -} - -const STARTUP_OUTPUT_IDLE_TIMEOUT: Duration = Duration::from_secs(120); - -struct Supervisor { - mode: RunMode, - paths: SessionPaths, - session_env: SessionEnv, - shutdown_requested: Arc, - settingsd: Option, - compositor: Option, - shell: Option, - watcher: Option, -} +use clap::Parser; +use tracing::info; + +mod cli; +mod compositor; +mod desktop_entries; +mod dev_cargo; +mod process; +mod session_env; +mod settingsd; +mod signals; +mod supervisor; + +use cli::{Args, RunMode, resolve_mode}; +use desktop_entries::generate_dev_desktop_entries; +use session_env::{create_session_env, create_session_paths}; +use signals::install_signal_handler; +use supervisor::Supervisor; fn main() -> Result<(), Box> { let args = Args::parse(); @@ -177,784 +68,3 @@ fn initialize_tracing(log_level: &str) { Err(_) => tracing_subscriber::fmt().init(), } } - -fn resolve_mode(args: &Args) -> RunMode { - if args.dev { - return RunMode::DevCargo; - } - if args.installed { - return RunMode::InstalledBinaries; - } - - if cfg!(debug_assertions) { - RunMode::DevCargo - } else { - RunMode::InstalledBinaries - } -} - -fn create_session_paths() -> Result> { - let host_runtime_dir = env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set")?; - let session_dir = PathBuf::from(host_runtime_dir) - .join("hearthspace") - .join(format!("session-{}", process::id())); - let applications_dir = session_dir.join("share/applications"); - - fs::create_dir_all(&applications_dir)?; - fs::set_permissions(&session_dir, fs::Permissions::from_mode(0o700))?; - - Ok(SessionPaths { - settings_socket: session_dir.join("settings.sock"), - session_socket: session_dir.join("session.sock"), - shell_command_socket: session_dir.join("shell-command.sock"), - session_dir, - applications_dir, - }) -} - -fn create_session_env( - args: &Args, - mode: RunMode, - paths: &SessionPaths, -) -> Result> { - let wayland_display = args - .wayland_display - .clone() - .unwrap_or_else(|| format!("hearthspace-dev-{}", process::id())); - let session_dir = path_to_string(&paths.session_dir)?; - let settings_socket = path_to_string(&paths.settings_socket)?; - let session_socket = path_to_string(&paths.session_socket)?; - let shell_command_socket = path_to_string(&paths.shell_command_socket)?; - let mut vars = vec![ - ("XDG_RUNTIME_DIR".to_string(), session_dir.clone()), - ("WAYLAND_DISPLAY".to_string(), wayland_display.clone()), - ( - "XDG_CURRENT_DESKTOP".to_string(), - hearthspace_common::CURRENT_DESKTOP.to_string(), - ), - ( - "DESKTOP_SESSION".to_string(), - hearthspace_common::DESKTOP_SESSION.to_string(), - ), - ( - hearthspace_common::SESSION_DIR_ENV.to_string(), - session_dir.clone(), - ), - ( - hearthspace_common::SETTINGS_SOCKET_ENV.to_string(), - settings_socket, - ), - ( - hearthspace_common::SESSION_SOCKET_ENV.to_string(), - session_socket, - ), - ( - hearthspace_common::SHELL_COMMAND_SOCKET_ENV.to_string(), - shell_command_socket, - ), - ("RUST_LOG".to_string(), args.log_level.clone()), - ( - "XDG_DATA_DIRS".to_string(), - xdg_data_dirs(&path_to_string(&paths.session_dir.join("share"))?)?, - ), - ]; - - if mode == RunMode::DevCargo { - vars.push((hearthspace_common::DEV_ENV.to_string(), "1".to_string())); - vars.push(( - hearthspace_common::REPO_ROOT_ENV.to_string(), - path_to_string(&repo_root())?, - )); - } - - Ok(SessionEnv { - wayland_display, - vars, - }) -} - -fn path_to_string(path: &Path) -> Result> { - Ok(path - .to_str() - .ok_or_else(|| format!("path is not valid UTF-8: {}", path.display()))? - .to_string()) -} - -fn xdg_data_dirs(session_share_dir: &str) -> Result> { - let mut dirs = vec![PathBuf::from(session_share_dir)]; - if let Some(existing) = env::var_os("XDG_DATA_DIRS") { - dirs.extend(env::split_paths(&existing)); - } else { - dirs.push(PathBuf::from("/usr/local/share")); - dirs.push(PathBuf::from("/usr/share")); - } - Ok(env::join_paths(dirs)?.to_string_lossy().into_owned()) -} - -fn repo_root() -> PathBuf { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - root.canonicalize().unwrap_or(root) -} - -fn generate_dev_desktop_entries(paths: &SessionPaths) -> Result<(), Box> { - let exec = repo_root().join("scripts/dev-run-app"); - let desktop_entry = format!( - "[Desktop Entry]\n\ - Type=Application\n\ - Name=Hearthspace Settings\n\ - Comment=Configure Hearthspace\n\ - Exec=\"{}\" hearthspace-settings\n\ - Icon=hearthspace\n\ - Categories=Settings;System;\n\ - Keywords=hearthspace;settings;config;preferences;\n\ - OnlyShowIn=Hearthspace;\n\ - X-Hearthspace-Dev=true\n", - escape_exec_path(&exec) - ); - let path = paths - .applications_dir - .join("dev.hearthspace.Settings.desktop"); - fs::write(&path, desktop_entry)?; - info!(path = %path.display(), "generated development desktop entry"); - Ok(()) -} - -fn escape_exec_path(path: &Path) -> String { - path.to_string_lossy() - .replace('\\', "\\\\") - .replace('"', "\\\"") -} - -fn install_signal_handler() -> Result, Box> { - let shutdown_requested = Arc::new(AtomicBool::new(false)); - let signal_shutdown = Arc::clone(&shutdown_requested); - ctrlc::set_handler(move || { - signal_shutdown.store(true, Ordering::SeqCst); - })?; - Ok(shutdown_requested) -} - -impl Supervisor { - fn new( - mode: RunMode, - paths: SessionPaths, - session_env: SessionEnv, - shutdown_requested: Arc, - ) -> Self { - Self { - mode, - paths, - session_env, - shutdown_requested, - settingsd: None, - compositor: None, - shell: None, - watcher: None, - } - } - - fn start_settingsd(&mut self, max_restarts: usize) -> Result<(), Box> { - let spec = self.binary_spec("hearthspace-settingsd", &[], true); - self.settingsd = Some(ManagedProcess::spawn(spec, max_restarts)?); - Ok(()) - } - - fn start_compositor(&mut self) -> Result<(), Box> { - let backend = select_compositor_backend(); - let backend_flag = match backend { - CompositorBackend::NativeTty => "--tty", - CompositorBackend::NestedWinit => "--winit", - }; - let mut spec = self.binary_spec( - "hearthspace-compositor", - &[hearthspace_common::NO_SHELL_FLAG, backend_flag], - false, - ); - configure_compositor_environment(&mut spec, backend); - self.compositor = Some(ManagedProcess::spawn(spec, 0)?); - Ok(()) - } - - fn start_shell(&mut self, max_restarts: usize) -> Result<(), Box> { - let spec = self.binary_spec("hearthspace-shell", &[], true); - self.shell = Some(ManagedProcess::spawn(spec, max_restarts)?); - Ok(()) - } - - fn start_app_watcher_if_available( - &mut self, - args: &Args, - ) -> Result<(), Box> { - if self.mode != RunMode::DevCargo || args.no_watch_apps { - return Ok(()); - } - - let Some(spec) = self.watcher_spec() else { - if args.watch_apps { - warn!("app watcher requested, but neither cargo-watch nor watchexec was found"); - } else { - warn!( - "cargo-watch/watchexec not found; Hearthspace Settings will build on first launch" - ); - } - return Ok(()); - }; - - self.watcher = Some(ManagedProcess::spawn(spec, 0)?); - Ok(()) - } - - fn wait_for_settingsd_ready(&mut self) -> Result<(), Box> { - info!(socket = %self.paths.settings_socket.display(), "waiting for settingsd readiness"); - loop { - if let Some(status) = poll_process(&mut self.settingsd)? { - return Err(format!("settingsd exited before readiness: {status}").into()); - } - if settingsd_ping(&self.paths.settings_socket).is_ok() { - info!("settingsd ready"); - return Ok(()); - } - if process_output_idle_duration(&self.settingsd) - .is_some_and(|idle| idle >= STARTUP_OUTPUT_IDLE_TIMEOUT) - { - return Err(format!( - "timed out waiting for settingsd readiness after {}s without child output", - STARTUP_OUTPUT_IDLE_TIMEOUT.as_secs() - ) - .into()); - } - thread::sleep(Duration::from_millis(50)); - } - } - - fn wait_for_compositor_ready(&mut self) -> Result<(), Box> { - let socket = self - .paths - .session_dir - .join(&self.session_env.wayland_display); - info!(socket = %socket.display(), "waiting for compositor readiness"); - loop { - if let Some(status) = poll_process(&mut self.compositor)? { - return Err(format!("compositor exited before readiness: {status}").into()); - } - if socket.exists() { - info!("compositor ready"); - return Ok(()); - } - if process_output_idle_duration(&self.compositor) - .is_some_and(|idle| idle >= STARTUP_OUTPUT_IDLE_TIMEOUT) - { - return Err(format!( - "timed out waiting for compositor readiness after {}s without child output", - STARTUP_OUTPUT_IDLE_TIMEOUT.as_secs() - ) - .into()); - } - thread::sleep(Duration::from_millis(50)); - } - } - - fn monitor_until_exit(&mut self) -> Result<(), Box> { - info!("monitoring Hearthspace session processes"); - loop { - if self.shutdown_requested.load(Ordering::SeqCst) { - info!("shutdown requested"); - return Ok(()); - } - - if let Some(status) = poll_process(&mut self.compositor)? { - info!(%status, "compositor exited; ending session"); - return Ok(()); - } - - if let Some(status) = poll_process(&mut self.settingsd)? { - if self.restart_process("settingsd", status)? { - self.wait_for_settingsd_ready()?; - } else { - return Err(format!("settingsd exited: {status}").into()); - } - } - - if let Some(status) = poll_process(&mut self.shell)? - && !self.restart_process("shell", status)? - { - return Err(format!("shell exited: {status}").into()); - } - - if let Some(status) = poll_process(&mut self.watcher)? { - warn!(%status, "app watcher exited; continuing session"); - self.watcher = None; - } - - thread::sleep(Duration::from_millis(100)); - } - } - - fn shutdown(&mut self) { - info!("shutting down Hearthspace session processes"); - terminate_process(&mut self.shell); - terminate_process(&mut self.compositor); - terminate_process(&mut self.settingsd); - terminate_process(&mut self.watcher); - - wait_then_kill_process(&mut self.shell); - wait_then_kill_process(&mut self.compositor); - wait_then_kill_process(&mut self.settingsd); - wait_then_kill_process(&mut self.watcher); - - match fs::remove_dir_all(&self.paths.session_dir) { - Ok(()) => { - info!(path = %self.paths.session_dir.display(), "removed session runtime directory") - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - warn!(path = %self.paths.session_dir.display(), %error, "failed to remove session runtime directory") - } - } - } - - fn restart_process( - &mut self, - name: &'static str, - status: ExitStatus, - ) -> Result> { - let process = match name { - "settingsd" => &mut self.settingsd, - "shell" => &mut self.shell, - _ => return Ok(false), - }; - let Some(process) = process else { - return Ok(false); - }; - if process.restarts >= process.max_restarts { - return Ok(false); - } - warn!(name, %status, restart = process.restarts + 1, max_restarts = process.max_restarts, "restarting process"); - process.restart()?; - Ok(true) - } - - fn binary_spec( - &self, - binary: &'static str, - binary_args: &[&str], - include_client_wayland: bool, - ) -> ProcessSpec { - let mut envs = self.child_env(include_client_wayland); - if !include_client_wayland { - envs.push(( - hearthspace_common::HEARTHSPACE_WAYLAND_DISPLAY_ENV.to_string(), - self.session_env.wayland_display.clone(), - )); - } - - let current_dir = (self.mode == RunMode::DevCargo).then(repo_root); - match self.mode { - RunMode::DevCargo => { - let args = dev_cargo_args(binary, binary_args); - ProcessSpec { - name: binary, - program: "cargo".to_string(), - args, - envs, - env_removals: Vec::new(), - current_dir, - } - } - RunMode::InstalledBinaries => ProcessSpec { - name: binary, - program: binary.to_string(), - args: binary_args.iter().map(|arg| (*arg).to_string()).collect(), - envs, - env_removals: Vec::new(), - current_dir, - }, - } - } - - fn child_env(&self, include_client_wayland: bool) -> Vec<(String, String)> { - self.session_env - .vars - .iter() - .filter(|(key, _)| { - include_client_wayland || key != hearthspace_common::WAYLAND_DISPLAY_ENV - }) - .cloned() - .collect() - } - - fn watcher_spec(&self) -> Option { - let envs = self.child_env(true); - let current_dir = Some(repo_root()); - if which::which("cargo-watch").is_ok() { - return Some(ProcessSpec { - name: "settings-watch", - program: "cargo".to_string(), - args: vec![ - "watch".to_string(), - "-w".to_string(), - "crates/hearthspace-settings".to_string(), - "-w".to_string(), - "crates/hearthspace-ipc".to_string(), - "-w".to_string(), - "crates/hearthspace-common".to_string(), - "-x".to_string(), - "build -p hearthspace-settings".to_string(), - ], - envs, - env_removals: Vec::new(), - current_dir, - }); - } - if which::which("watchexec").is_ok() { - return Some(ProcessSpec { - name: "settings-watch", - program: "watchexec".to_string(), - args: vec![ - "-w".to_string(), - "crates/hearthspace-settings".to_string(), - "-w".to_string(), - "crates/hearthspace-ipc".to_string(), - "-w".to_string(), - "crates/hearthspace-common".to_string(), - "--".to_string(), - "cargo".to_string(), - "build".to_string(), - "-p".to_string(), - "hearthspace-settings".to_string(), - ], - envs, - env_removals: Vec::new(), - current_dir, - }); - } - None - } -} - -fn select_compositor_backend() -> CompositorBackend { - if env::var("XDG_SESSION_TYPE").is_ok_and(|session_type| session_type == "tty") { - return CompositorBackend::NativeTty; - } - - if host_wayland_display_path().is_some() || env::var_os("DISPLAY").is_some() { - CompositorBackend::NestedWinit - } else { - CompositorBackend::NativeTty - } -} - -fn configure_compositor_environment(spec: &mut ProcessSpec, backend: CompositorBackend) { - spec.env_removals.extend([ - "WAYLAND_DISPLAY".to_string(), - "WAYLAND_SOCKET".to_string(), - "DISPLAY".to_string(), - ]); - - if backend == CompositorBackend::NestedWinit { - if let Some(path) = host_wayland_display_path() { - spec.envs - .push((hearthspace_common::WAYLAND_DISPLAY_ENV.to_string(), path)); - } - if let Some(display) = env::var_os("DISPLAY") { - spec.envs.push(( - "DISPLAY".to_string(), - display.to_string_lossy().into_owned(), - )); - } - } -} - -fn host_wayland_display_path() -> Option { - let display = env::var_os("WAYLAND_DISPLAY")?; - let display_path = PathBuf::from(&display); - let socket_path = if display_path.is_absolute() { - display_path - } else { - PathBuf::from(env::var_os("XDG_RUNTIME_DIR")?).join(display_path) - }; - - socket_path - .exists() - .then(|| socket_path.to_string_lossy().into_owned()) -} - -fn dev_cargo_args(binary: &str, binary_args: &[&str]) -> Vec { - let mut args = vec!["run".to_string()]; - if cargo_package_exists(binary) { - args.extend(["-p".to_string(), binary.to_string()]); - } else { - args.extend(["--bin".to_string(), binary.to_string()]); - } - if binary == "hearthspace-compositor" { - args.extend(["--features".to_string(), "udev".to_string()]); - } - args.push("--".to_string()); - args.extend(binary_args.iter().map(|arg| (*arg).to_string())); - args -} - -fn cargo_package_exists(package: &str) -> bool { - matches!( - package, - "hearthspace-compositor" - | "hearthspace-settings" - | "hearthspace-settingsd" - | "hearthspace-shell" - ) -} - -impl ManagedProcess { - fn spawn(spec: ProcessSpec, max_restarts: usize) -> Result> { - let output_activity = Arc::new(std::sync::Mutex::new(Instant::now())); - let child = spawn_child(&spec, Arc::clone(&output_activity))?; - Ok(Self { - spec, - child, - output_activity, - restarts: 0, - max_restarts, - }) - } - - fn restart(&mut self) -> Result<(), Box> { - self.restarts += 1; - record_output_activity(&self.output_activity); - self.child = spawn_child(&self.spec, Arc::clone(&self.output_activity))?; - Ok(()) - } -} - -fn spawn_child( - spec: &ProcessSpec, - output_activity: Arc>, -) -> Result> { - info!(name = spec.name, program = %spec.program, args = ?spec.args, "starting process"); - let mut command = Command::new(&spec.program); - for key in &spec.env_removals { - command.env_remove(key); - } - command - .args(&spec.args) - .envs(spec.envs.iter().map(|(key, value)| (key, value))) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if let Some(current_dir) = &spec.current_dir { - command.current_dir(current_dir); - } - let mut child = command.spawn()?; - if let Some(stdout) = child.stdout.take() { - forward_child_output( - spec.name, - ChildOutputStream::Stdout, - Box::new(stdout), - Arc::clone(&output_activity), - ); - } - if let Some(stderr) = child.stderr.take() { - forward_child_output( - spec.name, - ChildOutputStream::Stderr, - Box::new(stderr), - output_activity, - ); - } - Ok(child) -} - -fn forward_child_output( - process_name: &'static str, - stream: ChildOutputStream, - mut reader: Box, - output_activity: Arc>, -) { - thread::spawn(move || { - let mut buffer = [0; 8192]; - loop { - match reader.read(&mut buffer) { - Ok(0) => return, - Ok(read) => { - record_output_activity(&output_activity); - if let Err(error) = write_child_output(stream, &buffer[..read]) { - warn!(process_name, ?stream, %error, "failed to forward child output"); - return; - } - } - Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, - Err(error) => { - warn!(process_name, ?stream, %error, "failed to read child output"); - return; - } - } - } - }); -} - -fn write_child_output(stream: ChildOutputStream, bytes: &[u8]) -> io::Result<()> { - match stream { - ChildOutputStream::Stdout => { - let mut stdout = io::stdout().lock(); - stdout.write_all(bytes)?; - stdout.flush() - } - ChildOutputStream::Stderr => { - let mut stderr = io::stderr().lock(); - stderr.write_all(bytes)?; - stderr.flush() - } - } -} - -fn record_output_activity(output_activity: &Arc>) { - match output_activity.lock() { - Ok(mut activity) => *activity = Instant::now(), - Err(error) => *error.into_inner() = Instant::now(), - } -} - -fn process_output_idle_duration(process: &Option) -> Option { - let process = process.as_ref()?; - let last_output = match process.output_activity.lock() { - Ok(activity) => *activity, - Err(error) => *error.into_inner(), - }; - Some(Instant::now().duration_since(last_output)) -} - -fn poll_process( - process: &mut Option, -) -> Result, Box> { - let Some(process) = process else { - return Ok(None); - }; - Ok(process.child.try_wait()?) -} - -fn settingsd_ping(socket_path: &Path) -> std::io::Result<()> { - if !socket_path.exists() { - return Err(std::io::ErrorKind::NotFound.into()); - } - let mut stream = UnixStream::connect(socket_path)?; - stream.write_all(b"get\n")?; - stream.shutdown(Shutdown::Write)?; - - let mut response = String::new(); - stream.read_to_string(&mut response)?; - if response.starts_with("ok ") { - Ok(()) - } else { - Err(std::io::Error::other(format!( - "unexpected settingsd response: {response:?}" - ))) - } -} - -fn terminate_process(process: &mut Option) { - let Some(process) = process else { - return; - }; - if matches!(process.child.try_wait(), Ok(Some(_))) { - return; - } - let Ok(raw_pid) = i32::try_from(process.child.id()) else { - warn!( - name = process.spec.name, - pid = process.child.id(), - "cannot signal process with oversized pid" - ); - return; - }; - match signal::kill(Pid::from_raw(raw_pid), signal::Signal::SIGTERM) { - Ok(()) => info!(name = process.spec.name, pid = raw_pid, "sent SIGTERM"), - Err(error) => { - warn!(name = process.spec.name, pid = raw_pid, %error, "failed to send SIGTERM") - } - } -} - -fn wait_then_kill_process(process: &mut Option) { - let Some(process) = process else { - return; - }; - let deadline = Instant::now() + Duration::from_secs(3); - while Instant::now() < deadline { - match process.child.try_wait() { - Ok(Some(status)) => { - info!(name = process.spec.name, %status, "process exited during shutdown"); - return; - } - Ok(None) => thread::sleep(Duration::from_millis(50)), - Err(error) => { - warn!(name = process.spec.name, %error, "failed to poll process during shutdown"); - return; - } - } - } - - match process.child.kill() { - Ok(()) => info!( - name = process.spec.name, - "killed process after shutdown timeout" - ), - Err(error) => { - warn!(name = process.spec.name, %error, "failed to kill process after shutdown timeout") - } - } - let _ = process.child.wait(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dev_compositor_child_enables_udev_feature() { - assert_eq!( - dev_cargo_args( - "hearthspace-compositor", - &[hearthspace_common::NO_SHELL_FLAG] - ), - vec![ - "run", - "-p", - "hearthspace-compositor", - "--features", - "udev", - "--", - hearthspace_common::NO_SHELL_FLAG, - ] - ); - } - - #[test] - fn session_env_uses_private_runtime_dir() { - let paths = SessionPaths { - session_dir: PathBuf::from("/tmp/hearthspace-test/session-1"), - applications_dir: PathBuf::from("/tmp/hearthspace-test/session-1/share/applications"), - settings_socket: PathBuf::from("/tmp/hearthspace-test/session-1/settings.sock"), - session_socket: PathBuf::from("/tmp/hearthspace-test/session-1/session.sock"), - shell_command_socket: PathBuf::from( - "/tmp/hearthspace-test/session-1/shell-command.sock", - ), - }; - let args = Args { - dev: false, - installed: true, - wayland_display: Some("hearthspace-test".to_string()), - watch_apps: false, - no_watch_apps: false, - no_shell_restart: false, - no_settingsd_restart: false, - log_level: "info".to_string(), - print_env: false, - dry_run: false, - }; - - let env = create_session_env(&args, RunMode::InstalledBinaries, &paths).unwrap(); - - assert!(env.vars.iter().any(|(key, value)| { - key == "XDG_RUNTIME_DIR" && value == "/tmp/hearthspace-test/session-1" - })); - } -} diff --git a/crates/hearthspace-session/src/process.rs b/crates/hearthspace-session/src/process.rs new file mode 100644 index 0000000..2020746 --- /dev/null +++ b/crates/hearthspace-session/src/process.rs @@ -0,0 +1,219 @@ +use std::{ + io::{self, Read, Write}, + path::PathBuf, + process::{Child, Command, ExitStatus, Stdio}, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant}, +}; + +use nix::{sys::signal, unistd::Pid}; +use tracing::{info, warn}; + +#[derive(Debug, Clone)] +pub(crate) struct ProcessSpec { + pub(crate) name: &'static str, + pub(crate) program: String, + pub(crate) args: Vec, + pub(crate) envs: Vec<(String, String)>, + pub(crate) env_removals: Vec, + pub(crate) current_dir: Option, +} + +pub(crate) struct ManagedProcess { + spec: ProcessSpec, + child: Child, + output_activity: Arc>, + pub(crate) restarts: usize, + pub(crate) max_restarts: usize, +} + +#[derive(Debug, Clone, Copy)] +enum ChildOutputStream { + Stdout, + Stderr, +} + +impl ManagedProcess { + pub(crate) fn spawn( + spec: ProcessSpec, + max_restarts: usize, + ) -> Result> { + let output_activity = Arc::new(Mutex::new(Instant::now())); + let child = spawn_child(&spec, Arc::clone(&output_activity))?; + Ok(Self { + spec, + child, + output_activity, + restarts: 0, + max_restarts, + }) + } + + pub(crate) fn restart(&mut self) -> Result<(), Box> { + self.restarts += 1; + record_output_activity(&self.output_activity); + self.child = spawn_child(&self.spec, Arc::clone(&self.output_activity))?; + Ok(()) + } +} + +fn spawn_child( + spec: &ProcessSpec, + output_activity: Arc>, +) -> Result> { + info!(name = spec.name, program = %spec.program, args = ?spec.args, "starting process"); + let mut command = Command::new(&spec.program); + for key in &spec.env_removals { + command.env_remove(key); + } + command + .args(&spec.args) + .envs(spec.envs.iter().map(|(key, value)| (key, value))) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(current_dir) = &spec.current_dir { + command.current_dir(current_dir); + } + let mut child = command.spawn()?; + if let Some(stdout) = child.stdout.take() { + forward_child_output( + spec.name, + ChildOutputStream::Stdout, + Box::new(stdout), + Arc::clone(&output_activity), + ); + } + if let Some(stderr) = child.stderr.take() { + forward_child_output( + spec.name, + ChildOutputStream::Stderr, + Box::new(stderr), + output_activity, + ); + } + Ok(child) +} + +fn forward_child_output( + process_name: &'static str, + stream: ChildOutputStream, + mut reader: Box, + output_activity: Arc>, +) { + thread::spawn(move || { + let mut buffer = [0; 8192]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return, + Ok(read) => { + record_output_activity(&output_activity); + if let Err(error) = write_child_output(stream, &buffer[..read]) { + warn!(process_name, ?stream, %error, "failed to forward child output"); + return; + } + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => { + warn!(process_name, ?stream, %error, "failed to read child output"); + return; + } + } + } + }); +} + +fn write_child_output(stream: ChildOutputStream, bytes: &[u8]) -> io::Result<()> { + match stream { + ChildOutputStream::Stdout => { + let mut stdout = io::stdout().lock(); + stdout.write_all(bytes)?; + stdout.flush() + } + ChildOutputStream::Stderr => { + let mut stderr = io::stderr().lock(); + stderr.write_all(bytes)?; + stderr.flush() + } + } +} + +fn record_output_activity(output_activity: &Arc>) { + match output_activity.lock() { + Ok(mut activity) => *activity = Instant::now(), + Err(error) => *error.into_inner() = Instant::now(), + } +} + +pub(crate) fn process_output_idle_duration(process: &Option) -> Option { + let process = process.as_ref()?; + let last_output = match process.output_activity.lock() { + Ok(activity) => *activity, + Err(error) => *error.into_inner(), + }; + Some(Instant::now().duration_since(last_output)) +} + +pub(crate) fn poll_process( + process: &mut Option, +) -> Result, Box> { + let Some(process) = process else { + return Ok(None); + }; + Ok(process.child.try_wait()?) +} + +pub(crate) fn terminate_process(process: &mut Option) { + let Some(process) = process else { + return; + }; + if matches!(process.child.try_wait(), Ok(Some(_))) { + return; + } + let Ok(raw_pid) = i32::try_from(process.child.id()) else { + warn!( + name = process.spec.name, + pid = process.child.id(), + "cannot signal process with oversized pid" + ); + return; + }; + match signal::kill(Pid::from_raw(raw_pid), signal::Signal::SIGTERM) { + Ok(()) => info!(name = process.spec.name, pid = raw_pid, "sent SIGTERM"), + Err(error) => { + warn!(name = process.spec.name, pid = raw_pid, %error, "failed to send SIGTERM") + } + } +} + +pub(crate) fn wait_then_kill_process(process: &mut Option) { + let Some(process) = process else { + return; + }; + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + match process.child.try_wait() { + Ok(Some(status)) => { + info!(name = process.spec.name, %status, "process exited during shutdown"); + return; + } + Ok(None) => thread::sleep(Duration::from_millis(50)), + Err(error) => { + warn!(name = process.spec.name, %error, "failed to poll process during shutdown"); + return; + } + } + } + + match process.child.kill() { + Ok(()) => info!( + name = process.spec.name, + "killed process after shutdown timeout" + ), + Err(error) => { + warn!(name = process.spec.name, %error, "failed to kill process after shutdown timeout") + } + } + let _ = process.child.wait(); +} diff --git a/crates/hearthspace-session/src/session_env.rs b/crates/hearthspace-session/src/session_env.rs new file mode 100644 index 0000000..998c733 --- /dev/null +++ b/crates/hearthspace-session/src/session_env.rs @@ -0,0 +1,162 @@ +use std::{ + env, fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process, +}; + +use crate::cli::{Args, RunMode}; + +#[derive(Debug)] +pub(crate) struct SessionPaths { + pub(crate) session_dir: PathBuf, + pub(crate) applications_dir: PathBuf, + pub(crate) settings_socket: PathBuf, + pub(crate) session_socket: PathBuf, + pub(crate) shell_command_socket: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct SessionEnv { + pub(crate) wayland_display: String, + pub(crate) vars: Vec<(String, String)>, +} + +pub(crate) fn create_session_paths() -> Result> { + let host_runtime_dir = env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set")?; + let session_dir = PathBuf::from(host_runtime_dir) + .join("hearthspace") + .join(format!("session-{}", process::id())); + let applications_dir = session_dir.join("share/applications"); + + fs::create_dir_all(&applications_dir)?; + fs::set_permissions(&session_dir, fs::Permissions::from_mode(0o700))?; + + Ok(SessionPaths { + settings_socket: session_dir.join("settings.sock"), + session_socket: session_dir.join("session.sock"), + shell_command_socket: session_dir.join("shell-command.sock"), + session_dir, + applications_dir, + }) +} + +pub(crate) fn create_session_env( + args: &Args, + mode: RunMode, + paths: &SessionPaths, +) -> Result> { + let wayland_display = args + .wayland_display + .clone() + .unwrap_or_else(|| format!("hearthspace-dev-{}", process::id())); + let session_dir = path_to_string(&paths.session_dir)?; + let settings_socket = path_to_string(&paths.settings_socket)?; + let session_socket = path_to_string(&paths.session_socket)?; + let shell_command_socket = path_to_string(&paths.shell_command_socket)?; + let mut vars = vec![ + ("XDG_RUNTIME_DIR".to_string(), session_dir.clone()), + ("WAYLAND_DISPLAY".to_string(), wayland_display.clone()), + ( + "XDG_CURRENT_DESKTOP".to_string(), + hearthspace_common::CURRENT_DESKTOP.to_string(), + ), + ( + "DESKTOP_SESSION".to_string(), + hearthspace_common::DESKTOP_SESSION.to_string(), + ), + ( + hearthspace_common::SESSION_DIR_ENV.to_string(), + session_dir.clone(), + ), + ( + hearthspace_common::SETTINGS_SOCKET_ENV.to_string(), + settings_socket, + ), + ( + hearthspace_common::SESSION_SOCKET_ENV.to_string(), + session_socket, + ), + ( + hearthspace_common::SHELL_COMMAND_SOCKET_ENV.to_string(), + shell_command_socket, + ), + ("RUST_LOG".to_string(), args.log_level.clone()), + ( + "XDG_DATA_DIRS".to_string(), + xdg_data_dirs(&path_to_string(&paths.session_dir.join("share"))?)?, + ), + ]; + + if mode == RunMode::DevCargo { + vars.push((hearthspace_common::DEV_ENV.to_string(), "1".to_string())); + vars.push(( + hearthspace_common::REPO_ROOT_ENV.to_string(), + path_to_string(&repo_root())?, + )); + } + + Ok(SessionEnv { + wayland_display, + vars, + }) +} + +fn path_to_string(path: &Path) -> Result> { + Ok(path + .to_str() + .ok_or_else(|| format!("path is not valid UTF-8: {}", path.display()))? + .to_string()) +} + +fn xdg_data_dirs(session_share_dir: &str) -> Result> { + let mut dirs = vec![PathBuf::from(session_share_dir)]; + if let Some(existing) = env::var_os("XDG_DATA_DIRS") { + dirs.extend(env::split_paths(&existing)); + } else { + dirs.push(PathBuf::from("/usr/local/share")); + dirs.push(PathBuf::from("/usr/share")); + } + Ok(env::join_paths(dirs)?.to_string_lossy().into_owned()) +} + +pub(crate) fn repo_root() -> PathBuf { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + root.canonicalize().unwrap_or(root) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_env_uses_private_runtime_dir() { + let paths = SessionPaths { + session_dir: PathBuf::from("/tmp/hearthspace-test/session-1"), + applications_dir: PathBuf::from("/tmp/hearthspace-test/session-1/share/applications"), + settings_socket: PathBuf::from("/tmp/hearthspace-test/session-1/settings.sock"), + session_socket: PathBuf::from("/tmp/hearthspace-test/session-1/session.sock"), + shell_command_socket: PathBuf::from( + "/tmp/hearthspace-test/session-1/shell-command.sock", + ), + }; + let args = Args { + dev: false, + installed: true, + wayland_display: Some("hearthspace-test".to_string()), + watch_apps: false, + no_watch_apps: false, + no_shell_restart: false, + no_settingsd_restart: false, + log_level: "info".to_string(), + print_env: false, + dry_run: false, + }; + + let env = create_session_env(&args, RunMode::InstalledBinaries, &paths).unwrap(); + + assert!(env.vars.iter().any(|(key, value)| { + key == "XDG_RUNTIME_DIR" && value == "/tmp/hearthspace-test/session-1" + })); + } +} diff --git a/crates/hearthspace-session/src/settingsd.rs b/crates/hearthspace-session/src/settingsd.rs new file mode 100644 index 0000000..a52a60b --- /dev/null +++ b/crates/hearthspace-session/src/settingsd.rs @@ -0,0 +1,25 @@ +use std::{ + io::{Read, Write}, + net::Shutdown, + os::unix::net::UnixStream, + path::Path, +}; + +pub(crate) fn settingsd_ping(socket_path: &Path) -> std::io::Result<()> { + if !socket_path.exists() { + return Err(std::io::ErrorKind::NotFound.into()); + } + let mut stream = UnixStream::connect(socket_path)?; + stream.write_all(b"get\n")?; + stream.shutdown(Shutdown::Write)?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + if response.starts_with("ok ") { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "unexpected settingsd response: {response:?}" + ))) + } +} diff --git a/crates/hearthspace-session/src/signals.rs b/crates/hearthspace-session/src/signals.rs new file mode 100644 index 0000000..1bc15e3 --- /dev/null +++ b/crates/hearthspace-session/src/signals.rs @@ -0,0 +1,13 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +pub(crate) fn install_signal_handler() -> Result, Box> { + let shutdown_requested = Arc::new(AtomicBool::new(false)); + let signal_shutdown = Arc::clone(&shutdown_requested); + ctrlc::set_handler(move || { + signal_shutdown.store(true, Ordering::SeqCst); + })?; + Ok(shutdown_requested) +} diff --git a/crates/hearthspace-session/src/supervisor.rs b/crates/hearthspace-session/src/supervisor.rs new file mode 100644 index 0000000..98af558 --- /dev/null +++ b/crates/hearthspace-session/src/supervisor.rs @@ -0,0 +1,341 @@ +use std::{ + fs, + process::ExitStatus, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::Duration, +}; + +use tracing::{info, warn}; + +use crate::{ + cli::{Args, RunMode}, + compositor::{CompositorBackend, configure_compositor_environment, select_compositor_backend}, + dev_cargo::dev_cargo_args, + process::{ + ManagedProcess, ProcessSpec, poll_process, process_output_idle_duration, terminate_process, + wait_then_kill_process, + }, + session_env::{SessionEnv, SessionPaths, repo_root}, + settingsd::settingsd_ping, +}; + +const STARTUP_OUTPUT_IDLE_TIMEOUT: Duration = Duration::from_secs(120); + +pub(crate) struct Supervisor { + mode: RunMode, + paths: SessionPaths, + session_env: SessionEnv, + shutdown_requested: Arc, + settingsd: Option, + compositor: Option, + shell: Option, + watcher: Option, +} + +impl Supervisor { + pub(crate) fn new( + mode: RunMode, + paths: SessionPaths, + session_env: SessionEnv, + shutdown_requested: Arc, + ) -> Self { + Self { + mode, + paths, + session_env, + shutdown_requested, + settingsd: None, + compositor: None, + shell: None, + watcher: None, + } + } + + pub(crate) fn start_settingsd( + &mut self, + max_restarts: usize, + ) -> Result<(), Box> { + let spec = self.binary_spec("hearthspace-settingsd", &[], true); + self.settingsd = Some(ManagedProcess::spawn(spec, max_restarts)?); + Ok(()) + } + + pub(crate) fn start_compositor(&mut self) -> Result<(), Box> { + let backend = select_compositor_backend(); + let backend_flag = match backend { + CompositorBackend::NativeTty => "--tty", + CompositorBackend::NestedWinit => "--winit", + }; + let mut spec = self.binary_spec( + "hearthspace-compositor", + &[hearthspace_common::NO_SHELL_FLAG, backend_flag], + false, + ); + configure_compositor_environment(&mut spec, backend); + self.compositor = Some(ManagedProcess::spawn(spec, 0)?); + Ok(()) + } + + pub(crate) fn start_shell( + &mut self, + max_restarts: usize, + ) -> Result<(), Box> { + let spec = self.binary_spec("hearthspace-shell", &[], true); + self.shell = Some(ManagedProcess::spawn(spec, max_restarts)?); + Ok(()) + } + + pub(crate) fn start_app_watcher_if_available( + &mut self, + args: &Args, + ) -> Result<(), Box> { + if self.mode != RunMode::DevCargo || args.no_watch_apps { + return Ok(()); + } + + let Some(spec) = self.watcher_spec() else { + if args.watch_apps { + warn!("app watcher requested, but neither cargo-watch nor watchexec was found"); + } else { + warn!( + "cargo-watch/watchexec not found; Hearthspace Settings will build on first launch" + ); + } + return Ok(()); + }; + + self.watcher = Some(ManagedProcess::spawn(spec, 0)?); + Ok(()) + } + + pub(crate) fn wait_for_settingsd_ready(&mut self) -> Result<(), Box> { + info!(socket = %self.paths.settings_socket.display(), "waiting for settingsd readiness"); + loop { + if let Some(status) = poll_process(&mut self.settingsd)? { + return Err(format!("settingsd exited before readiness: {status}").into()); + } + if settingsd_ping(&self.paths.settings_socket).is_ok() { + info!("settingsd ready"); + return Ok(()); + } + if process_output_idle_duration(&self.settingsd) + .is_some_and(|idle| idle >= STARTUP_OUTPUT_IDLE_TIMEOUT) + { + return Err(format!( + "timed out waiting for settingsd readiness after {}s without child output", + STARTUP_OUTPUT_IDLE_TIMEOUT.as_secs() + ) + .into()); + } + thread::sleep(Duration::from_millis(50)); + } + } + + pub(crate) fn wait_for_compositor_ready(&mut self) -> Result<(), Box> { + let socket = self + .paths + .session_dir + .join(&self.session_env.wayland_display); + info!(socket = %socket.display(), "waiting for compositor readiness"); + loop { + if let Some(status) = poll_process(&mut self.compositor)? { + return Err(format!("compositor exited before readiness: {status}").into()); + } + if socket.exists() { + info!("compositor ready"); + return Ok(()); + } + if process_output_idle_duration(&self.compositor) + .is_some_and(|idle| idle >= STARTUP_OUTPUT_IDLE_TIMEOUT) + { + return Err(format!( + "timed out waiting for compositor readiness after {}s without child output", + STARTUP_OUTPUT_IDLE_TIMEOUT.as_secs() + ) + .into()); + } + thread::sleep(Duration::from_millis(50)); + } + } + + pub(crate) fn monitor_until_exit(&mut self) -> Result<(), Box> { + info!("monitoring Hearthspace session processes"); + loop { + if self.shutdown_requested.load(Ordering::SeqCst) { + info!("shutdown requested"); + return Ok(()); + } + + if let Some(status) = poll_process(&mut self.compositor)? { + info!(%status, "compositor exited; ending session"); + return Ok(()); + } + + if let Some(status) = poll_process(&mut self.settingsd)? { + if self.restart_process("settingsd", status)? { + self.wait_for_settingsd_ready()?; + } else { + return Err(format!("settingsd exited: {status}").into()); + } + } + + if let Some(status) = poll_process(&mut self.shell)? + && !self.restart_process("shell", status)? + { + return Err(format!("shell exited: {status}").into()); + } + + if let Some(status) = poll_process(&mut self.watcher)? { + warn!(%status, "app watcher exited; continuing session"); + self.watcher = None; + } + + thread::sleep(Duration::from_millis(100)); + } + } + + pub(crate) fn shutdown(&mut self) { + info!("shutting down Hearthspace session processes"); + terminate_process(&mut self.shell); + terminate_process(&mut self.compositor); + terminate_process(&mut self.settingsd); + terminate_process(&mut self.watcher); + + wait_then_kill_process(&mut self.shell); + wait_then_kill_process(&mut self.compositor); + wait_then_kill_process(&mut self.settingsd); + wait_then_kill_process(&mut self.watcher); + + match fs::remove_dir_all(&self.paths.session_dir) { + Ok(()) => { + info!(path = %self.paths.session_dir.display(), "removed session runtime directory") + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + warn!(path = %self.paths.session_dir.display(), %error, "failed to remove session runtime directory") + } + } + } + + fn restart_process( + &mut self, + name: &'static str, + status: ExitStatus, + ) -> Result> { + let process = match name { + "settingsd" => &mut self.settingsd, + "shell" => &mut self.shell, + _ => return Ok(false), + }; + let Some(process) = process else { + return Ok(false); + }; + if process.restarts >= process.max_restarts { + return Ok(false); + } + warn!(name, %status, restart = process.restarts + 1, max_restarts = process.max_restarts, "restarting process"); + process.restart()?; + Ok(true) + } + + fn binary_spec( + &self, + binary: &'static str, + binary_args: &[&str], + include_client_wayland: bool, + ) -> ProcessSpec { + let mut envs = self.child_env(include_client_wayland); + if !include_client_wayland { + envs.push(( + hearthspace_common::HEARTHSPACE_WAYLAND_DISPLAY_ENV.to_string(), + self.session_env.wayland_display.clone(), + )); + } + + let current_dir = (self.mode == RunMode::DevCargo).then(repo_root); + match self.mode { + RunMode::DevCargo => { + let args = dev_cargo_args(binary, binary_args); + ProcessSpec { + name: binary, + program: "cargo".to_string(), + args, + envs, + env_removals: Vec::new(), + current_dir, + } + } + RunMode::InstalledBinaries => ProcessSpec { + name: binary, + program: binary.to_string(), + args: binary_args.iter().map(|arg| (*arg).to_string()).collect(), + envs, + env_removals: Vec::new(), + current_dir, + }, + } + } + + fn child_env(&self, include_client_wayland: bool) -> Vec<(String, String)> { + self.session_env + .vars + .iter() + .filter(|(key, _)| { + include_client_wayland || key != hearthspace_common::WAYLAND_DISPLAY_ENV + }) + .cloned() + .collect() + } + + fn watcher_spec(&self) -> Option { + let envs = self.child_env(true); + let current_dir = Some(repo_root()); + if which::which("cargo-watch").is_ok() { + return Some(ProcessSpec { + name: "settings-watch", + program: "cargo".to_string(), + args: vec![ + "watch".to_string(), + "-w".to_string(), + "crates/hearthspace-settings".to_string(), + "-w".to_string(), + "crates/hearthspace-ipc".to_string(), + "-w".to_string(), + "crates/hearthspace-common".to_string(), + "-x".to_string(), + "build -p hearthspace-settings".to_string(), + ], + envs, + env_removals: Vec::new(), + current_dir, + }); + } + if which::which("watchexec").is_ok() { + return Some(ProcessSpec { + name: "settings-watch", + program: "watchexec".to_string(), + args: vec![ + "-w".to_string(), + "crates/hearthspace-settings".to_string(), + "-w".to_string(), + "crates/hearthspace-ipc".to_string(), + "-w".to_string(), + "crates/hearthspace-common".to_string(), + "--".to_string(), + "cargo".to_string(), + "build".to_string(), + "-p".to_string(), + "hearthspace-settings".to_string(), + ], + envs, + env_removals: Vec::new(), + current_dir, + }); + } + None + } +} diff --git a/crates/hearthspace-settings/src/actions.rs b/crates/hearthspace-settings/src/actions.rs new file mode 100644 index 0000000..135a1c2 --- /dev/null +++ b/crates/hearthspace-settings/src/actions.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; + +use hearthspace_ipc::{MonitorConfig, client_set_background_image_path, client_set_monitor_scale}; + +use crate::{ + monitors::{recalculate_monitor_positions_for_scale, settings_with_live_outputs}, + settings_backend::{notify_compositor_settings_changed, save_monitor_positions}, + state::SettingsApp, +}; + +#[derive(Debug)] +pub(crate) enum MonitorEditorAction { + Select { + id: String, + monitors: Vec, + }, + Drag { + moved_id: String, + monitors: Vec, + commit: bool, + }, +} + +pub(crate) fn apply_monitor_editor_action(state: &mut SettingsApp, action: MonitorEditorAction) { + match action { + MonitorEditorAction::Select { id, monitors } => { + state.settings.monitors = monitors; + state.selected_monitor_id = Some(id.clone()); + state.status = format!("Selected monitor {id}"); + } + MonitorEditorAction::Drag { + moved_id, + monitors, + commit, + } => { + let moved_monitor = monitors + .iter() + .find(|monitor| monitor.id == moved_id) + .cloned(); + state.selected_monitor_id = Some(moved_id.clone()); + state.settings.monitors = monitors; + + if commit { + match save_monitor_positions(&state.settings.monitors) { + Ok(Some(settings)) => { + state.settings = settings_with_live_outputs(settings, &state.live_outputs); + keep_selected_monitor_available(state); + state.status = format!("Saved monitor layout for {moved_id}"); + } + Ok(None) => state.status = "Saved empty monitor layout".to_string(), + Err(error) => state.status = format!("Failed to save monitor layout: {error}"), + } + } else if let Some(monitor) = moved_monitor { + state.status = format!("Moving {} to {},{}", monitor.name, monitor.x, monitor.y); + } else { + state.status = format!("Unknown monitor: {moved_id}"); + } + } + } +} + +fn keep_selected_monitor_available(state: &mut SettingsApp) { + if state.selected_monitor_id.as_ref().is_some_and(|id| { + state + .settings + .monitors + .iter() + .any(|monitor| &monitor.id == id) + }) { + return; + } + state.selected_monitor_id = state + .settings + .monitors + .first() + .map(|monitor| monitor.id.clone()); +} + +pub(crate) fn save_monitor_scale(state: &mut SettingsApp, id: &str, scale: i32) { + let previous = state.settings.clone(); + let mut monitors = state.settings.monitors.clone(); + recalculate_monitor_positions_for_scale(&mut monitors, id, scale); + state.settings.monitors = monitors.clone(); + + match client_set_monitor_scale(id, scale) { + Ok(_) => match save_monitor_positions(&monitors) { + Ok(Some(settings)) => { + state.settings = settings_with_live_outputs(settings, &state.live_outputs); + state.selected_monitor_id = Some(id.to_string()); + keep_selected_monitor_available(state); + state.status = format!("Saved monitor scale for {id}: {scale}x"); + } + Ok(None) => { + state.settings.monitors = monitors; + state.selected_monitor_id = Some(id.to_string()); + state.status = format!("Saved monitor scale for {id}: {scale}x"); + } + Err(error) => { + let _ = previous + .monitors + .iter() + .find(|monitor| monitor.id == id) + .map(|monitor| client_set_monitor_scale(id, monitor.scale)); + state.settings = previous; + state.status = format!("Failed to save monitor positions after scale: {error}"); + } + }, + Err(error) => { + state.settings = previous; + state.status = format!("Failed to save monitor scale: {error}"); + } + } +} + +pub(crate) fn save_background_image_path(state: &mut SettingsApp, path: Option) { + let previous = state.settings.background_image_path.clone(); + let path = match path { + Some(path) => match path.into_os_string().into_string() { + Ok(path) => Some(path), + Err(path) => { + state.status = format!("Background path is not valid UTF-8: {path:?}"); + return; + } + }, + None => None, + }; + state.settings.background_image_path = path.clone(); + + match client_set_background_image_path(path.as_deref()) { + Ok(settings) => { + state.settings = settings_with_live_outputs(settings, &state.live_outputs); + state.status = match notify_compositor_settings_changed() { + Ok(()) => match &state.settings.background_image_path { + Some(path) => format!("Saved background image: {path}"), + None => "Restored default background image".to_string(), + }, + Err(error) => format!("Saved background image; compositor reload failed: {error}"), + }; + } + Err(error) => { + state.settings.background_image_path = previous; + state.status = format!("Failed to save background image: {error}"); + } + } +} diff --git a/crates/hearthspace-settings/src/main.rs b/crates/hearthspace-settings/src/main.rs index 1052ebe..104769c 100644 --- a/crates/hearthspace-settings/src/main.rs +++ b/crates/hearthspace-settings/src/main.rs @@ -1,82 +1,15 @@ -use std::{ - env, io, - io::{Read, Write}, - net::Shutdown, - os::unix::net::UnixStream, - path::PathBuf, -}; - -use wayland_client::{ - Connection, Dispatch, QueueHandle, WEnum, - globals::{GlobalListContents, registry_queue_init}, - protocol::{wl_output, wl_registry}, -}; -use xilem::core::{MessageCtx, MessageResult, Mut, View, ViewMarker, fork}; -use xilem::masonry::accesskit::{Node, Role}; -use xilem::masonry::core::{ - AccessCtx, AccessEvent, ChildrenIds, CursorIcon, EventCtx, LayoutCtx, MeasureCtx, PaintCtx, - PointerButton, PointerButtonEvent, PointerEvent, PointerUpdate, PropertiesMut, PropertiesRef, - QueryCtx, RegisterCtx, TextEvent, Widget, -}; -use xilem::masonry::imaging::Painter; -use xilem::masonry::kurbo::{Axis, Point, Rect, Size, Stroke}; -use xilem::masonry::layout::{LenReq, Length}; -use xilem::masonry::palette; -use xilem::peniko::Color; -use xilem::tokio::sync::mpsc::UnboundedSender; -use xilem::{Pod, ViewCtx}; - -use hearthspace_ipc::{ - MonitorConfig, Settings, client_get_settings, client_set_background_image_path, - client_set_background_moves_with_canvas, client_set_monitor_position, client_set_monitor_scale, - client_set_test, -}; -use rfd::FileDialog; -use xilem::{ - AnyWidgetView, EventLoop, WidgetView, WindowOptions, Xilem, - dpi::LogicalSize, - masonry::{layout::AsUnit, parley::fontique::GenericFamily}, - style::Style, - view::{checkbox, flex_col, flex_row, label, sized_box, text_button, worker}, -}; - -const EDITOR_PADDING: f64 = 24.0; -const MONITOR_SNAP: i32 = 10; -const MONITOR_LAYOUT_CANVAS_MULTIPLIER: f64 = 1.2; -const EMPTY_MONITOR_CANVAS_WIDTH: i32 = 1920; -const EMPTY_MONITOR_CANVAS_HEIGHT: i32 = 1080; -const MONITOR_SCALE_OPTIONS: [i32; 4] = [1, 2, 3, 4]; - -struct SettingsApp { - settings: Settings, - live_outputs: Vec, - selected_monitor_id: Option, - background_picker_request_sender: Option>, - background_picker_pending: bool, - status: String, - config_label: String, -} - -#[derive(Debug)] -enum BackgroundPickerRequest { - PickFile, -} - -#[derive(Debug)] -enum BackgroundPickerResponse { - Selected(PathBuf), - Cancelled, - Failed(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LiveOutput { - id: String, - name: String, - width: i32, - height: i32, - scale: i32, -} +mod actions; +mod monitor_layout_view; +mod monitor_layout_widget; +mod monitors; +mod settings_backend; +mod state; +mod ui; +mod wayland_outputs; + +use state::initial_state; +use ui::app_logic; +use xilem::{EventLoop, WindowOptions, Xilem, dpi::LogicalSize}; fn main() -> Result<(), Box> { let state = initial_state(); @@ -91,1594 +24,3 @@ fn main() -> Result<(), Box> { app.run_in(EventLoop::with_user_event())?; Ok(()) } - -fn initial_state() -> SettingsApp { - let (mut settings, mut status) = match client_get_settings() { - Ok(settings) => (settings, "Connected to hearthspace-settingsd".to_string()), - Err(error) => ( - Settings::default(), - format!("Settings backend unavailable: {error}"), - ), - }; - let live_outputs = match live_wayland_outputs() { - Ok(outputs) => outputs, - Err(error) => { - status.push_str(&format!("; live monitors unavailable: {error}")); - Vec::new() - } - }; - settings = settings_with_live_outputs(settings, &live_outputs); - let selected_monitor_id = settings.monitors.first().map(|monitor| monitor.id.clone()); - - let config_label = match hearthspace_common::config_path() { - Ok(path) => format!("Config: {}", path.display()), - Err(error) => format!("Config path unavailable: {error}"), - }; - - SettingsApp { - settings, - live_outputs, - selected_monitor_id, - background_picker_request_sender: None, - background_picker_pending: false, - status, - config_label, - } -} - -fn app_logic(state: &mut SettingsApp) -> impl WidgetView + use<> { - let test_checked = state.settings.test; - let background_moves_with_canvas = state.settings.background_moves_with_canvas; - let background_image_label = state - .settings - .background_image_path - .as_deref() - .unwrap_or("Default background") - .to_string(); - let select_background_button_label = if state.background_picker_pending { - "Selecting background image..." - } else { - "Select background image" - }; - let monitor_summary = format_monitor_summary(&state.settings.monitors); - - let content = sized_box( - flex_col(( - label("Hearthspace Settings") - .font(GenericFamily::SystemUi) - .text_size(22.0), - label("Drag monitors to arrange their relative positions."), - sized_box(monitor_layout_editor()).fixed_height(320.0.px()), - label(monitor_summary), - monitor_details_view(state), - checkbox("test", test_checked, |state: &mut SettingsApp, checked| { - let previous = state.settings.test; - state.settings.test = checked; - - match client_set_test(checked) { - Ok(settings) => { - let saved_test = settings.test; - state.settings = settings_with_live_outputs(settings, &state.live_outputs); - state.status = format!("Saved test = {saved_test}"); - } - Err(error) => { - state.settings.test = previous; - state.status = format!("Failed to save setting: {error}"); - } - } - }), - checkbox( - "Move background with canvas", - background_moves_with_canvas, - |state: &mut SettingsApp, checked| { - let previous = state.settings.background_moves_with_canvas; - state.settings.background_moves_with_canvas = checked; - - match client_set_background_moves_with_canvas(checked) { - Ok(settings) => { - state.settings = - settings_with_live_outputs(settings, &state.live_outputs); - state.status = match notify_compositor_settings_changed() { - Ok(()) => format!("Saved background canvas movement = {checked}"), - Err(error) => format!( - "Saved background canvas movement = {checked}; compositor reload failed: {error}" - ), - }; - } - Err(error) => { - state.settings.background_moves_with_canvas = previous; - state.status = format!("Failed to save setting: {error}"); - } - } - }, - ), - label(format!("Background image: {background_image_label}")), - flex_row(( - text_button(select_background_button_label, |state: &mut SettingsApp| { - if state.background_picker_pending { - state.status = "Background image picker is already open".to_string(); - return; - } - - let Some(sender) = &state.background_picker_request_sender else { - state.status = "Background image picker is not ready".to_string(); - return; - }; - - match sender.send(BackgroundPickerRequest::PickFile) { - Ok(()) => { - state.background_picker_pending = true; - state.status = "Opening background image picker".to_string(); - } - Err(error) => { - state.status = format!("Failed to open background image picker: {error}"); - } - } - }), - text_button("Use default background", |state: &mut SettingsApp| { - save_background_image_path(state, None); - }), - )), - label(state.config_label.clone()), - label(state.status.clone()), - )) - .gap(12.0.px()), - ) - .padding(20.0.px()); - - fork( - content, - worker( - |proxy, mut rx| async move { - while let Some(BackgroundPickerRequest::PickFile) = rx.recv().await { - let response = match xilem::tokio::task::spawn_blocking(|| { - FileDialog::new() - .add_filter("PNG image", &["png"]) - .pick_file() - }) - .await - { - Ok(Some(path)) => BackgroundPickerResponse::Selected(path), - Ok(None) => BackgroundPickerResponse::Cancelled, - Err(error) => BackgroundPickerResponse::Failed(error.to_string()), - }; - - drop(proxy.message(response)); - } - }, - |state: &mut SettingsApp, sender| { - state.background_picker_request_sender = Some(sender); - }, - |state: &mut SettingsApp, response| { - state.background_picker_pending = false; - match response { - BackgroundPickerResponse::Selected(path) => { - save_background_image_path(state, Some(path)); - } - BackgroundPickerResponse::Cancelled => { - state.status = "Background image selection cancelled".to_string(); - } - BackgroundPickerResponse::Failed(error) => { - state.status = format!("Background image picker failed: {error}"); - } - } - }, - ), - ) -} - -fn format_monitor_summary(monitors: &[MonitorConfig]) -> String { - monitors - .iter() - .map(|monitor| { - format!( - "{}: {}x{} @ {}x at {},{}", - monitor.name, monitor.width, monitor.height, monitor.scale, monitor.x, monitor.y - ) - }) - .collect::>() - .join(" | ") -} - -fn monitor_details_view(state: &SettingsApp) -> Box> { - let Some(monitor) = selected_monitor(state) else { - return label("Select a monitor to edit its settings.").boxed(); - }; - - let scale_buttons = MONITOR_SCALE_OPTIONS.map(|scale| { - let id = monitor.id.clone(); - text_button(format!("{}x", scale), move |state: &mut SettingsApp| { - save_monitor_scale(state, &id, scale); - }) - }); - - flex_col(( - label(format!("Selected: {}", monitor.name)), - label(format!( - "Physical: {}x{} | Desktop: {}x{} | Position: {},{}", - monitor.width, - monitor.height, - monitor_logical_width(monitor), - monitor_logical_height(monitor), - monitor.x, - monitor.y - )), - flex_row((label(format!("Scale: {}x", monitor.scale)), scale_buttons)), - )) - .gap(8.0.px()) - .boxed() -} - -fn selected_monitor(state: &SettingsApp) -> Option<&MonitorConfig> { - let selected_id = state.selected_monitor_id.as_ref()?; - state - .settings - .monitors - .iter() - .find(|monitor| &monitor.id == selected_id) -} - -fn monitor_layout_editor() -> MonitorLayoutEditor { - MonitorLayoutEditor -} - -struct MonitorLayoutEditor; - -impl ViewMarker for MonitorLayoutEditor {} - -impl View for MonitorLayoutEditor { - type Element = Pod; - type ViewState = (); - - fn build( - &self, - ctx: &mut ViewCtx, - state: &mut SettingsApp, - ) -> (Self::Element, Self::ViewState) { - ( - ctx.with_action_widget(|ctx| { - ctx.create_pod(MonitorLayoutWidget::new( - state.settings.monitors.clone(), - state.selected_monitor_id.clone(), - )) - }), - (), - ) - } - - fn rebuild( - &self, - _prev: &Self, - (): &mut Self::ViewState, - _ctx: &mut ViewCtx, - mut element: Mut<'_, Self::Element>, - state: &mut SettingsApp, - ) { - element.widget.set_monitors( - state.settings.monitors.clone(), - state.selected_monitor_id.clone(), - &mut element.ctx, - ); - } - - fn teardown( - &self, - (): &mut Self::ViewState, - ctx: &mut ViewCtx, - element: Mut<'_, Self::Element>, - ) { - ctx.teardown_action_source(element); - } - - fn message( - &self, - (): &mut Self::ViewState, - message: &mut MessageCtx, - _element: Mut<'_, Self::Element>, - state: &mut SettingsApp, - ) -> MessageResult<()> { - let Some(action) = message.take_message::() else { - return MessageResult::Stale; - }; - apply_monitor_editor_action(state, *action); - MessageResult::Action(()) - } -} - -fn apply_monitor_editor_action(state: &mut SettingsApp, action: MonitorEditorAction) { - match action { - MonitorEditorAction::Select { id, monitors } => { - state.settings.monitors = monitors; - state.selected_monitor_id = Some(id.clone()); - state.status = format!("Selected monitor {id}"); - } - MonitorEditorAction::Drag { - moved_id, - monitors, - commit, - } => { - let moved_monitor = monitors - .iter() - .find(|monitor| monitor.id == moved_id) - .cloned(); - state.selected_monitor_id = Some(moved_id.clone()); - state.settings.monitors = monitors; - - if commit { - match save_monitor_positions(&state.settings.monitors) { - Ok(Some(settings)) => { - state.settings = settings_with_live_outputs(settings, &state.live_outputs); - keep_selected_monitor_available(state); - state.status = format!("Saved monitor layout for {moved_id}"); - } - Ok(None) => state.status = "Saved empty monitor layout".to_string(), - Err(error) => state.status = format!("Failed to save monitor layout: {error}"), - } - } else if let Some(monitor) = moved_monitor { - state.status = format!("Moving {} to {},{}", monitor.name, monitor.x, monitor.y); - } else { - state.status = format!("Unknown monitor: {moved_id}"); - } - } - } -} - -fn keep_selected_monitor_available(state: &mut SettingsApp) { - if state.selected_monitor_id.as_ref().is_some_and(|id| { - state - .settings - .monitors - .iter() - .any(|monitor| &monitor.id == id) - }) { - return; - } - state.selected_monitor_id = state - .settings - .monitors - .first() - .map(|monitor| monitor.id.clone()); -} - -fn save_monitor_scale(state: &mut SettingsApp, id: &str, scale: i32) { - let previous = state.settings.clone(); - let mut monitors = state.settings.monitors.clone(); - recalculate_monitor_positions_for_scale(&mut monitors, id, scale); - state.settings.monitors = monitors.clone(); - - match client_set_monitor_scale(id, scale) { - Ok(_) => match save_monitor_positions(&monitors) { - Ok(Some(settings)) => { - state.settings = settings_with_live_outputs(settings, &state.live_outputs); - state.selected_monitor_id = Some(id.to_string()); - keep_selected_monitor_available(state); - state.status = format!("Saved monitor scale for {id}: {scale}x"); - } - Ok(None) => { - state.settings.monitors = monitors; - state.selected_monitor_id = Some(id.to_string()); - state.status = format!("Saved monitor scale for {id}: {scale}x"); - } - Err(error) => { - let _ = previous - .monitors - .iter() - .find(|monitor| monitor.id == id) - .map(|monitor| client_set_monitor_scale(id, monitor.scale)); - state.settings = previous; - state.status = format!("Failed to save monitor positions after scale: {error}"); - } - }, - Err(error) => { - state.settings = previous; - state.status = format!("Failed to save monitor scale: {error}"); - } - } -} - -fn save_background_image_path(state: &mut SettingsApp, path: Option) { - let previous = state.settings.background_image_path.clone(); - let path = match path { - Some(path) => match path.into_os_string().into_string() { - Ok(path) => Some(path), - Err(path) => { - state.status = format!("Background path is not valid UTF-8: {path:?}"); - return; - } - }, - None => None, - }; - state.settings.background_image_path = path.clone(); - - match client_set_background_image_path(path.as_deref()) { - Ok(settings) => { - state.settings = settings_with_live_outputs(settings, &state.live_outputs); - state.status = match notify_compositor_settings_changed() { - Ok(()) => match &state.settings.background_image_path { - Some(path) => format!("Saved background image: {path}"), - None => "Restored default background image".to_string(), - }, - Err(error) => format!("Saved background image; compositor reload failed: {error}"), - }; - } - Err(error) => { - state.settings.background_image_path = previous; - state.status = format!("Failed to save background image: {error}"); - } - } -} - -fn recalculate_monitor_positions_for_scale(monitors: &mut [MonitorConfig], id: &str, scale: i32) { - let Some(index) = monitors.iter().position(|monitor| monitor.id == id) else { - return; - }; - let old_monitor = monitors[index].clone(); - let old_width = monitor_logical_width(&old_monitor); - let old_height = monitor_logical_height(&old_monitor); - let old_right = old_monitor.x + old_width; - let old_bottom = old_monitor.y + old_height; - - monitors[index].scale = scale.max(1); - let new_width = monitor_logical_width(&monitors[index]); - let new_height = monitor_logical_height(&monitors[index]); - let dx = new_width - old_width; - let dy = new_height - old_height; - - for (monitor_index, monitor) in monitors.iter_mut().enumerate() { - if monitor_index == index { - continue; - } - if monitor.x >= old_right { - monitor.x += dx; - } - if monitor.y >= old_bottom { - monitor.y += dy; - } - } - - if monitors_overlap(monitors) { - place_monitors_edge_to_edge(monitors); - } -} - -fn settings_with_live_outputs(mut settings: Settings, live_outputs: &[LiveOutput]) -> Settings { - if live_outputs.is_empty() { - return settings; - } - - settings.monitors = monitors_for_live_outputs(&settings.monitors, live_outputs); - settings -} - -fn monitors_for_live_outputs( - saved_monitors: &[MonitorConfig], - live_outputs: &[LiveOutput], -) -> Vec { - let mut monitors = Vec::with_capacity(live_outputs.len()); - let mut next_x = 0; - let saved_monitors_are_placeholders = default_placeholder_monitors(saved_monitors); - for (index, output) in live_outputs.iter().enumerate() { - let role_id = monitor_role_id(index); - let saved = (!saved_monitors_are_placeholders) - .then(|| { - saved_monitors - .iter() - .find(|monitor| monitor.id == output.id || monitor.name == output.name) - .or_else(|| saved_monitors.iter().find(|monitor| monitor.id == role_id)) - }) - .flatten(); - let (id, scale, x, y) = if let Some(saved) = saved { - (saved.id.clone(), saved.scale.max(1), saved.x, saved.y) - } else if saved_monitors_are_placeholders && index < 2 { - (role_id, output.scale.max(1), next_x, 0) - } else { - (output.id.clone(), output.scale.max(1), next_x, 0) - }; - monitors.push(MonitorConfig { - id, - name: output.name.clone(), - width: output.width, - height: output.height, - scale, - x, - y, - }); - next_x += logical_dimension(output.width, scale); - } - if monitors_overlap(&monitors) { - place_monitors_edge_to_edge(&mut monitors); - } - monitors -} - -fn place_monitors_edge_to_edge(monitors: &mut [MonitorConfig]) { - let mut next_x = 0; - for monitor in monitors { - monitor.x = next_x; - monitor.y = 0; - next_x += monitor_logical_width(monitor); - } -} - -fn monitors_overlap(monitors: &[MonitorConfig]) -> bool { - monitors.iter().enumerate().any(|(index, monitor)| { - monitors - .iter() - .skip(index + 1) - .any(|other| monitor_rects_overlap(monitor, other)) - }) -} - -fn monitor_rects_overlap(a: &MonitorConfig, b: &MonitorConfig) -> bool { - a.x < b.x + monitor_logical_width(b) - && a.x + monitor_logical_width(a) > b.x - && a.y < b.y + monitor_logical_height(b) - && a.y + monitor_logical_height(a) > b.y -} - -fn monitor_logical_width(monitor: &MonitorConfig) -> i32 { - logical_dimension(monitor.width, monitor.scale) -} - -fn monitor_logical_height(monitor: &MonitorConfig) -> i32 { - logical_dimension(monitor.height, monitor.scale) -} - -fn logical_dimension(size: i32, _scale: i32) -> i32 { - size.max(1) -} - -fn default_placeholder_monitors(monitors: &[MonitorConfig]) -> bool { - monitors == Settings::default().monitors -} - -fn monitor_role_id(index: usize) -> String { - if index == 0 { - "primary".to_string() - } else if index == 1 { - "secondary".to_string() - } else { - format!("secondary-{index}") - } -} - -fn live_wayland_outputs() -> std::io::Result> { - let connection = Connection::connect_to_env().map_err(std::io::Error::other)?; - let (globals, mut event_queue) = - registry_queue_init::(&connection).map_err(std::io::Error::other)?; - let queue_handle = event_queue.handle(); - let mut probe = OutputProbe::default(); - for global in globals.contents().clone_list() { - if global.interface == "wl_output" { - globals.registry().bind::( - global.name, - global.version.min(4), - &queue_handle, - OutputGlobal { name: global.name }, - ); - } - } - event_queue - .roundtrip(&mut probe) - .map_err(std::io::Error::other)?; - Ok(probe.live_outputs()) -} - -#[derive(Default)] -struct OutputProbe { - outputs: Vec, -} - -struct OutputGlobal { - name: u32, -} - -#[derive(Debug)] -struct OutputState { - global_name: u32, - name: Option, - description: Option, - width: Option, - height: Option, - scale: Option, -} - -impl OutputProbe { - fn output_mut(&mut self, global_name: u32) -> &mut OutputState { - if let Some(index) = self - .outputs - .iter() - .position(|output| output.global_name == global_name) - { - return &mut self.outputs[index]; - } - self.outputs.push(OutputState { - global_name, - name: None, - description: None, - width: None, - height: None, - scale: None, - }); - self.outputs.last_mut().unwrap() - } - - fn live_outputs(self) -> Vec { - self.outputs - .into_iter() - .map(|output| { - let fallback = format!("output-{}", output.global_name); - let id = output.name.clone().unwrap_or_else(|| fallback.clone()); - LiveOutput { - id: id.clone(), - name: output.description.or(output.name).unwrap_or(id), - width: output.width.unwrap_or(EMPTY_MONITOR_CANVAS_WIDTH), - height: output.height.unwrap_or(EMPTY_MONITOR_CANVAS_HEIGHT), - scale: output.scale.unwrap_or(1).max(1), - } - }) - .collect() - } -} - -impl Dispatch for OutputProbe { - fn event( - _state: &mut Self, - _proxy: &wl_registry::WlRegistry, - _event: wl_registry::Event, - _data: &GlobalListContents, - _connection: &Connection, - _queue_handle: &QueueHandle, - ) { - } -} - -impl Dispatch for OutputProbe { - fn event( - state: &mut Self, - _proxy: &wl_output::WlOutput, - event: wl_output::Event, - data: &OutputGlobal, - _connection: &Connection, - _queue_handle: &QueueHandle, - ) { - let output = state.output_mut(data.name); - match event { - wl_output::Event::Name { name } => output.name = Some(name), - wl_output::Event::Description { description } => { - output.description = Some(description); - } - wl_output::Event::Mode { - flags, - width, - height, - .. - } if output_size_is_current(flags) => { - output.width = Some(width); - output.height = Some(height); - } - wl_output::Event::Scale { factor } => output.scale = Some(factor.max(1)), - _ => {} - } - } -} - -fn output_size_is_current(flags: WEnum) -> bool { - matches!(flags, WEnum::Value(flags) if flags.contains(wl_output::Mode::Current)) -} - -fn save_monitor_positions(monitors: &[MonitorConfig]) -> std::io::Result> { - let mut saved = None; - for monitor in monitors { - saved = Some(client_set_monitor_position( - &monitor.id, - monitor.x, - monitor.y, - )?); - } - Ok(saved) -} - -fn notify_compositor_settings_changed() -> io::Result<()> { - let command_socket = command_socket_path()?; - let mut stream = UnixStream::connect(&command_socket)?; - stream.write_all(b"reload-settings\n")?; - stream.shutdown(Shutdown::Write)?; - - let mut response = String::new(); - stream.read_to_string(&mut response)?; - if response == "ok\n" { - Ok(()) - } else { - Err(io::Error::other(format!( - "unexpected compositor response: {response:?}" - ))) - } -} - -fn command_socket_path() -> io::Result { - env::var_os(hearthspace_common::SHELL_COMMAND_SOCKET_ENV) - .map(PathBuf::from) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - format!( - "{} is not set", - hearthspace_common::SHELL_COMMAND_SOCKET_ENV - ), - ) - }) -} - -#[derive(Debug)] -enum MonitorEditorAction { - Select { - id: String, - monitors: Vec, - }, - Drag { - moved_id: String, - monitors: Vec, - commit: bool, - }, -} - -const DRAG_START_DISTANCE: f64 = 3.0; - -#[derive(Debug)] -struct MonitorLayoutWidget { - monitors: Vec, - selected_monitor_id: Option, - drag: Option, -} - -#[derive(Debug, Clone)] -struct MonitorDrag { - id: String, - start_pointer: Point, - start_x: i32, - start_y: i32, - origin_x: f64, - origin_y: f64, - scale: f64, - moved: bool, -} - -impl MonitorLayoutWidget { - fn new(monitors: Vec, selected_monitor_id: Option) -> Self { - Self { - monitors, - selected_monitor_id, - drag: None, - } - } - - fn set_monitors( - &mut self, - monitors: Vec, - selected_monitor_id: Option, - ctx: &mut xilem::masonry::core::MutateCtx<'_>, - ) { - if self.monitors != monitors || self.selected_monitor_id != selected_monitor_id { - self.monitors = monitors; - self.selected_monitor_id = selected_monitor_id; - ctx.request_render(); - } - } - - fn layout_metrics(&self, size: Size) -> LayoutMetrics { - if let Some(drag) = &self.drag { - return LayoutMetrics { - origin_x: drag.origin_x, - origin_y: drag.origin_y, - scale: drag.scale, - }; - } - - let canvas_size = monitor_canvas_size(&self.monitors); - let scale = layout_scale_for_canvas(&self.monitors, size); - let (origin_x, origin_y) = monitor_canvas_origin(&self.monitors, canvas_size); - - LayoutMetrics { - origin_x, - origin_y, - scale, - } - } - - fn monitor_rect(&self, monitor: &MonitorConfig, metrics: LayoutMetrics) -> Rect { - Rect::new( - EDITOR_PADDING + (f64::from(monitor.x) - metrics.origin_x) * metrics.scale, - EDITOR_PADDING + (f64::from(monitor.y) - metrics.origin_y) * metrics.scale, - EDITOR_PADDING - + (f64::from(monitor.x + monitor_logical_width(monitor)) - metrics.origin_x) - * metrics.scale, - EDITOR_PADDING - + (f64::from(monitor.y + monitor_logical_height(monitor)) - metrics.origin_y) - * metrics.scale, - ) - } - - fn hit_monitor(&self, point: Point, metrics: LayoutMetrics) -> Option<&MonitorConfig> { - self.monitors - .iter() - .rev() - .find(|monitor| self.monitor_rect(monitor, metrics).contains(point)) - } - - fn monitors_for_drag( - &self, - drag: &MonitorDrag, - point: Point, - normalize: bool, - ) -> Vec { - let x = drag.start_x + ((point.x - drag.start_pointer.x) / drag.scale).round() as i32; - let y = drag.start_y + ((point.y - drag.start_pointer.y) / drag.scale).round() as i32; - let (x, y) = - snap_monitor_to_neighbor(&self.monitors, &drag.id, snap_position(x), snap_position(y)); - let mut monitors = self.monitors.clone(); - set_monitor_position(&mut monitors, &drag.id, x, y); - if normalize { - normalize_monitors_near_origin(&mut monitors); - } - monitors - } -} - -impl Widget for MonitorLayoutWidget { - type Action = MonitorEditorAction; - - fn on_pointer_event( - &mut self, - ctx: &mut EventCtx<'_>, - _props: &mut PropertiesMut<'_>, - event: &PointerEvent, - ) { - match event { - PointerEvent::Down(PointerButtonEvent { - button: None | Some(PointerButton::Primary), - state, - .. - }) => { - let point = ctx.local_position(state.position); - let metrics = self.layout_metrics(ctx.content_box().size()); - if let Some(monitor) = self.hit_monitor(point, metrics) { - let selected_id = monitor.id.clone(); - let start_x = monitor.x; - let start_y = monitor.y; - ctx.capture_pointer(); - ctx.request_focus(); - ctx.set_handled(); - self.selected_monitor_id = Some(selected_id.clone()); - self.drag = Some(MonitorDrag { - id: selected_id.clone(), - start_pointer: point, - start_x, - start_y, - origin_x: metrics.origin_x, - origin_y: metrics.origin_y, - scale: metrics.scale, - moved: false, - }); - ctx.submit_action::(MonitorEditorAction::Select { - id: selected_id, - monitors: self.monitors.clone(), - }); - ctx.request_render(); - } - } - PointerEvent::Move(PointerUpdate { current, .. }) if ctx.is_active() => { - if let Some(drag) = &mut self.drag { - let point = ctx.local_position(current.position); - if !drag.moved && point.distance(drag.start_pointer) < DRAG_START_DISTANCE { - return; - } - drag.moved = true; - let drag = drag.clone(); - let moved_id = drag.id.clone(); - let monitors = self.monitors_for_drag(&drag, point, false); - self.monitors = monitors.clone(); - ctx.submit_action::(MonitorEditorAction::Drag { - moved_id, - monitors, - commit: false, - }); - ctx.request_render(); - ctx.set_handled(); - } - } - PointerEvent::Up(PointerButtonEvent { - button: None | Some(PointerButton::Primary), - state, - .. - }) => { - if let Some(drag) = self.drag.take() { - if !drag.moved { - self.selected_monitor_id = Some(drag.id.clone()); - ctx.submit_action::(MonitorEditorAction::Select { - id: drag.id, - monitors: self.monitors.clone(), - }); - ctx.request_render(); - ctx.set_handled(); - return; - } - let point = ctx.local_position(state.position); - let monitors = self.monitors_for_drag(&drag, point, true); - self.monitors = monitors.clone(); - ctx.submit_action::(MonitorEditorAction::Drag { - moved_id: drag.id, - monitors, - commit: true, - }); - ctx.request_render(); - ctx.set_handled(); - } - } - PointerEvent::Cancel(..) => self.drag = None, - _ => {} - } - } - - fn on_text_event( - &mut self, - _ctx: &mut EventCtx<'_>, - _props: &mut PropertiesMut<'_>, - _event: &TextEvent, - ) { - } - - fn on_access_event( - &mut self, - _ctx: &mut EventCtx<'_>, - _props: &mut PropertiesMut<'_>, - _event: &AccessEvent, - ) { - } - - fn register_children(&mut self, _ctx: &mut RegisterCtx<'_>) {} - - fn measure( - &mut self, - _ctx: &mut MeasureCtx<'_>, - _props: &PropertiesRef<'_>, - axis: Axis, - _len_req: LenReq, - _cross_length: Option, - ) -> Length { - match axis { - Axis::Horizontal => 520.0.px(), - Axis::Vertical => 300.0.px(), - } - } - - fn layout(&mut self, _ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, _size: Size) {} - - fn paint( - &mut self, - ctx: &mut PaintCtx<'_>, - _props: &PropertiesRef<'_>, - painter: &mut Painter<'_>, - ) { - let content = ctx.content_box(); - painter - .fill(content, Color::from_rgb8(0x13, 0x17, 0x22)) - .draw(); - - let metrics = self.layout_metrics(content.size()); - for monitor in &self.monitors { - let rect = self.monitor_rect(monitor, metrics); - let active = self.drag.as_ref().is_some_and(|drag| drag.id == monitor.id); - let selected = self - .selected_monitor_id - .as_ref() - .is_some_and(|id| id == &monitor.id); - let fill = if active { - Color::from_rgb8(0x74, 0x9C, 0xFF) - } else if selected { - Color::from_rgb8(0x50, 0x72, 0xD8) - } else { - Color::from_rgb8(0x38, 0x55, 0xA3) - }; - painter.fill(rect, fill).draw(); - painter - .stroke(rect, &Stroke::new(2.0), palette::css::WHITE) - .draw(); - } - } - - fn accessibility_role(&self) -> Role { - Role::Canvas - } - - fn accessibility( - &mut self, - _ctx: &mut AccessCtx<'_>, - _props: &PropertiesRef<'_>, - node: &mut Node, - ) { - node.set_label("Drag monitor rectangles to configure their arrangement"); - } - - fn children_ids(&self) -> ChildrenIds { - ChildrenIds::new() - } - - fn get_cursor(&self, ctx: &QueryCtx<'_>, pos: Point) -> CursorIcon { - let metrics = self.layout_metrics(ctx.content_box().size()); - if self.hit_monitor(ctx.to_local(pos), metrics).is_some() { - CursorIcon::Grab - } else { - CursorIcon::Default - } - } -} - -#[derive(Clone, Copy)] -struct LayoutMetrics { - origin_x: f64, - origin_y: f64, - scale: f64, -} - -fn monitor_canvas_size(monitors: &[MonitorConfig]) -> Size { - let (width, height) = monitor_canvas_span(monitors); - Size::new( - f64::from(width) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, - f64::from(height) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, - ) -} - -fn monitor_canvas_origin(monitors: &[MonitorConfig], canvas_size: Size) -> (f64, f64) { - let Some((min_x, min_y, max_x, max_y)) = monitor_arrangement_bounds(monitors) else { - return (canvas_size.width / -2.0, canvas_size.height / -2.0); - }; - let center_x = (f64::from(min_x) + f64::from(max_x)) / 2.0; - let center_y = (f64::from(min_y) + f64::from(max_y)) / 2.0; - ( - center_x - canvas_size.width / 2.0, - center_y - canvas_size.height / 2.0, - ) -} - -fn monitor_canvas_span(monitors: &[MonitorConfig]) -> (i32, i32) { - if monitors.is_empty() { - return (EMPTY_MONITOR_CANVAS_WIDTH, EMPTY_MONITOR_CANVAS_HEIGHT); - } - - ( - combined_monitor_width(monitors), - combined_monitor_height(monitors), - ) -} - -fn combined_monitor_width(monitors: &[MonitorConfig]) -> i32 { - monitors.iter().map(monitor_logical_width).sum() -} - -fn combined_monitor_height(monitors: &[MonitorConfig]) -> i32 { - monitors.iter().map(monitor_logical_height).sum() -} - -fn layout_scale_for_canvas(monitors: &[MonitorConfig], size: Size) -> f64 { - let canvas_size = monitor_canvas_size(monitors); - let available_width = (size.width - EDITOR_PADDING * 2.0).max(1.0); - let available_height = (size.height - EDITOR_PADDING * 2.0).max(1.0); - (available_width / canvas_size.width) - .min(available_height / canvas_size.height) - .clamp(0.03, 1.0) -} - -fn set_monitor_position(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { - if let Some(monitor) = monitors.iter_mut().find(|monitor| monitor.id == id) { - monitor.x = x; - monitor.y = y; - } -} - -fn normalize_monitors_near_origin(monitors: &mut [MonitorConfig]) { - let Some((min_x, min_y, max_x, max_y)) = monitor_arrangement_bounds(monitors) else { - return; - }; - let center_x = midpoint_i32(min_x, max_x); - let center_y = midpoint_i32(min_y, max_y); - for monitor in monitors { - monitor.x -= center_x; - monitor.y -= center_y; - } -} - -fn monitor_arrangement_bounds(monitors: &[MonitorConfig]) -> Option<(i32, i32, i32, i32)> { - let first = monitors.first()?; - let mut min_x = first.x; - let mut min_y = first.y; - let mut max_x = first.x + monitor_logical_width(first); - let mut max_y = first.y + monitor_logical_height(first); - for monitor in monitors.iter().skip(1) { - min_x = min_x.min(monitor.x); - min_y = min_y.min(monitor.y); - max_x = max_x.max(monitor.x + monitor_logical_width(monitor)); - max_y = max_y.max(monitor.y + monitor_logical_height(monitor)); - } - Some((min_x, min_y, max_x, max_y)) -} - -fn midpoint_i32(start: i32, end: i32) -> i32 { - ((i64::from(start) + i64::from(end)) / 2) as i32 -} - -#[cfg(test)] -fn offset_monitor(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { - set_monitor_position(monitors, id, x, y); -} - -fn snap_position(value: i32) -> i32 { - let half = MONITOR_SNAP / 2; - if value >= 0 { - ((value + half) / MONITOR_SNAP) * MONITOR_SNAP - } else { - ((value - half) / MONITOR_SNAP) * MONITOR_SNAP - } -} - -fn snap_monitor_to_neighbor(monitors: &[MonitorConfig], id: &str, x: i32, y: i32) -> (i32, i32) { - let Some(monitor) = monitors.iter().find(|monitor| monitor.id == id) else { - return (x, y); - }; - let candidates = monitors - .iter() - .filter(|other| other.id != id) - .flat_map(|other| bordered_positions_for(monitor, other, x, y)); - - candidates - .min_by_key(|(candidate_x, candidate_y)| { - let dx = i64::from(*candidate_x - x); - let dy = i64::from(*candidate_y - y); - dx * dx + dy * dy - }) - .unwrap_or((x, y)) -} - -fn bordered_positions_for( - monitor: &MonitorConfig, - other: &MonitorConfig, - x: i32, - y: i32, -) -> [(i32, i32); 4] { - let monitor_width = monitor_logical_width(monitor); - let monitor_height = monitor_logical_height(monitor); - let other_width = monitor_logical_width(other); - let other_height = monitor_logical_height(other); - let left_of_other = other.x - monitor_width; - let right_of_other = other.x + other_width; - let above_other = other.y - monitor_height; - let below_other = other.y + other_height; - - [ - ( - left_of_other, - clamp_overlap(y, monitor_height, other.y, other_height), - ), - ( - right_of_other, - clamp_overlap(y, monitor_height, other.y, other_height), - ), - ( - clamp_overlap(x, monitor_width, other.x, other_width), - above_other, - ), - ( - clamp_overlap(x, monitor_width, other.x, other_width), - below_other, - ), - ] -} - -fn clamp_overlap(position: i32, size: i32, other_position: i32, other_size: i32) -> i32 { - let size = size.max(1); - let other_size = other_size.max(1); - position.clamp(other_position - size + 1, other_position + other_size - 1) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn monitor_canvas_size_uses_combined_dimensions_with_multiplier() { - let monitors = vec![ - MonitorConfig { - id: "left".to_string(), - name: "Left".to_string(), - width: 1280, - height: 720, - scale: 1, - x: -1280, - y: 120, - }, - MonitorConfig { - id: "main".to_string(), - name: "Main".to_string(), - width: 1920, - height: 1080, - scale: 1, - x: 0, - y: 0, - }, - ]; - - assert_eq!(combined_monitor_width(&monitors), 3200); - assert_eq!(combined_monitor_height(&monitors), 1800); - assert_eq!( - monitor_canvas_size(&monitors), - Size::new( - 3200.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, - 1800.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, - ) - ); - } - - #[test] - fn monitor_canvas_size_does_not_cap_large_monitors() { - let monitors = vec![ - MonitorConfig { - id: "ultrawide".to_string(), - name: "Ultrawide".to_string(), - width: 5120, - height: 1440, - scale: 1, - x: 0, - y: 0, - }, - MonitorConfig { - id: "hidpi".to_string(), - name: "HiDPI".to_string(), - width: 3840, - height: 2160, - scale: 1, - x: 5120, - y: 0, - }, - ]; - - assert_eq!(combined_monitor_width(&monitors), 8960); - assert_eq!(combined_monitor_height(&monitors), 3600); - assert_eq!( - monitor_canvas_size(&monitors), - Size::new( - 8960.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, - 3600.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, - ) - ); - } - - #[test] - fn monitor_canvas_size_uses_default_only_when_no_monitors_exist() { - assert_eq!( - monitor_canvas_size(&[]), - Size::new( - f64::from(EMPTY_MONITOR_CANVAS_WIDTH) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, - f64::from(EMPTY_MONITOR_CANVAS_HEIGHT) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, - ) - ); - } - - #[test] - fn live_outputs_hide_saved_disconnected_monitors() { - let monitors = monitors_for_live_outputs( - &Settings::default().monitors, - &[LiveOutput { - id: "eDP-1".to_string(), - name: "Built-in Display".to_string(), - width: 2256, - height: 1504, - scale: 1, - }], - ); - - assert_eq!(monitors.len(), 1); - assert_eq!(monitors[0].id, "primary"); - assert_eq!(monitors[0].name, "Built-in Display"); - assert_eq!((monitors[0].width, monitors[0].height), (2256, 1504)); - } - - #[test] - fn live_outputs_do_not_reuse_placeholder_positions_for_mixed_resolutions() { - let monitors = monitors_for_live_outputs( - &Settings::default().monitors, - &[ - LiveOutput { - id: "eDP-1".to_string(), - name: "Built-in Display".to_string(), - width: 2560, - height: 1440, - scale: 1, - }, - LiveOutput { - id: "HDMI-A-1".to_string(), - name: "External Monitor".to_string(), - width: 1366, - height: 768, - scale: 1, - }, - ], - ); - - assert_eq!(monitors[0].id, "primary"); - assert_eq!((monitors[0].x, monitors[0].y), (0, 0)); - assert_eq!(monitors[1].id, "secondary"); - assert_eq!((monitors[1].x, monitors[1].y), (2560, 0)); - } - - #[test] - fn live_outputs_repair_overlapping_saved_active_monitors() { - let saved = vec![ - MonitorConfig { - id: "primary".to_string(), - name: "Primary".to_string(), - width: 1920, - height: 1080, - scale: 1, - x: -1280, - y: -736, - }, - MonitorConfig { - id: "secondary".to_string(), - name: "Secondary".to_string(), - width: 1920, - height: 1080, - scale: 1, - x: -1920, - y: -540, - }, - ]; - - let monitors = monitors_for_live_outputs( - &saved, - &[ - LiveOutput { - id: "eDP-1".to_string(), - name: "Built-in Display".to_string(), - width: 2560, - height: 1440, - scale: 1, - }, - LiveOutput { - id: "HDMI-A-1".to_string(), - name: "External Monitor".to_string(), - width: 1366, - height: 768, - scale: 1, - }, - ], - ); - - assert_eq!((monitors[0].x, monitors[0].y), (0, 0)); - assert_eq!((monitors[1].x, monitors[1].y), (2560, 0)); - assert!(!monitors_overlap(&monitors)); - } - - #[test] - fn live_outputs_preserve_saved_connector_positions() { - let saved = vec![MonitorConfig { - id: "HDMI-A-1".to_string(), - name: "HDMI-A-1".to_string(), - width: 1920, - height: 1080, - scale: 2, - x: -1920, - y: 200, - }]; - - let monitors = monitors_for_live_outputs( - &saved, - &[LiveOutput { - id: "HDMI-A-1".to_string(), - name: "External Monitor".to_string(), - width: 2560, - height: 1440, - scale: 1, - }], - ); - - assert_eq!(monitors.len(), 1); - assert_eq!(monitors[0].id, "HDMI-A-1"); - assert_eq!(monitors[0].name, "External Monitor"); - assert_eq!((monitors[0].x, monitors[0].y), (-1920, 200)); - assert_eq!((monitors[0].width, monitors[0].height), (2560, 1440)); - assert_eq!(monitors[0].scale, 2); - } - - #[test] - fn live_outputs_use_reported_scale_for_new_monitors() { - let monitors = monitors_for_live_outputs( - &[], - &[LiveOutput { - id: "eDP-1".to_string(), - name: "Built-in Display".to_string(), - width: 2560, - height: 1440, - scale: 2, - }], - ); - - assert_eq!(monitors[0].scale, 2); - assert_eq!(monitor_logical_width(&monitors[0]), 2560); - } - - #[test] - fn scaling_monitor_keeps_outputs_at_physical_desktop_size() { - let mut monitors = Settings::default().monitors; - - recalculate_monitor_positions_for_scale(&mut monitors, "primary", 2); - - let primary = monitors - .iter() - .find(|monitor| monitor.id == "primary") - .unwrap(); - let secondary = monitors - .iter() - .find(|monitor| monitor.id == "secondary") - .unwrap(); - assert_eq!(primary.scale, 2); - assert_eq!(monitor_logical_width(primary), 1920); - assert_eq!((secondary.x, secondary.y), (1920, 0)); - assert!(!monitors_overlap(&monitors)); - } - - #[test] - fn scaling_monitor_keeps_outputs_below_at_physical_desktop_size() { - let mut monitors = Settings::default().monitors; - offset_monitor(&mut monitors, "secondary", 0, 1080); - - recalculate_monitor_positions_for_scale(&mut monitors, "primary", 2); - - let secondary = monitors - .iter() - .find(|monitor| monitor.id == "secondary") - .unwrap(); - assert_eq!((secondary.x, secondary.y), (0, 1080)); - assert!(!monitors_overlap(&monitors)); - } - - #[test] - fn layout_scale_does_not_change_when_monitors_are_dragged() { - let mut monitors = Settings::default().monitors; - let size = Size::new(760.0, 320.0); - let scale = layout_scale_for_canvas(&monitors, size); - - offset_monitor(&mut monitors, "secondary", -3200, 900); - - assert_eq!(layout_scale_for_canvas(&monitors, size), scale); - } - - #[test] - fn layout_metrics_do_not_recenter_during_live_drag() { - let mut widget = MonitorLayoutWidget::new(Settings::default().monitors, None); - let size = Size::new(760.0, 320.0); - let metrics = widget.layout_metrics(size); - widget.drag = Some(MonitorDrag { - id: "secondary".to_string(), - start_pointer: Point::from((0.0, 0.0)), - start_x: 1920, - start_y: 0, - origin_x: metrics.origin_x, - origin_y: metrics.origin_y, - scale: metrics.scale, - moved: false, - }); - - offset_monitor(&mut widget.monitors, "secondary", 0, 1200); - - let live_metrics = widget.layout_metrics(size); - assert_eq!(live_metrics.origin_x, metrics.origin_x); - assert_eq!(live_metrics.origin_y, metrics.origin_y); - assert_eq!(live_metrics.scale, metrics.scale); - } - - #[test] - fn canvas_origin_centers_far_away_arrangements() { - let mut monitors = Settings::default().monitors; - offset_monitor(&mut monitors, "primary", 10_000, -8_000); - offset_monitor(&mut monitors, "secondary", 11_920, -8_000); - let canvas_size = monitor_canvas_size(&monitors); - - let origin = monitor_canvas_origin(&monitors, canvas_size); - - assert_eq!(origin, (9616.0, -8756.0)); - } - - #[test] - fn normalize_monitors_keeps_layout_shape_centered_near_origin() { - let mut monitors = Settings::default().monitors; - offset_monitor(&mut monitors, "primary", 10_000, -8_000); - offset_monitor(&mut monitors, "secondary", 11_920, -8_000); - - normalize_monitors_near_origin(&mut monitors); - - assert_eq!( - monitor_arrangement_bounds(&monitors), - Some((-1920, -540, 1920, 540)) - ); - assert!(monitor_borders_another(&monitors, "secondary", (0, -540))); - } - - #[test] - fn live_drag_moves_only_selected_monitor_until_release() { - let widget = MonitorLayoutWidget::new(Settings::default().monitors, None); - let drag = MonitorDrag { - id: "secondary".to_string(), - start_pointer: Point::from((0.0, 0.0)), - start_x: 1920, - start_y: 0, - origin_x: 0.0, - origin_y: 0.0, - scale: 1.0, - moved: true, - }; - - let live = widget.monitors_for_drag(&drag, Point::from((0.0, 1200.0)), false); - let released = widget.monitors_for_drag(&drag, Point::from((0.0, 1200.0)), true); - - let live_primary = live.iter().find(|monitor| monitor.id == "primary").unwrap(); - let live_secondary = live - .iter() - .find(|monitor| monitor.id == "secondary") - .unwrap(); - assert_eq!((live_primary.x, live_primary.y), (0, 0)); - assert_eq!((live_secondary.x, live_secondary.y), (1919, 1080)); - - assert_eq!( - monitor_arrangement_bounds(&released), - Some((-1919, -1080, 1920, 1080)) - ); - } - - #[test] - fn monitor_drag_snaps_gap_to_shared_border() { - let monitors = Settings::default().monitors; - - let snapped = snap_monitor_to_neighbor(&monitors, "secondary", 2600, 120); - - assert_eq!(snapped, (1920, 120)); - assert!(monitor_borders_another(&monitors, "secondary", snapped)); - } - - #[test] - fn monitor_drag_snaps_to_border_and_preserves_overlap() { - let monitors = Settings::default().monitors; - - let snapped = snap_monitor_to_neighbor(&monitors, "secondary", 600, -1500); - - assert_eq!(snapped, (600, -1080)); - assert!(monitor_borders_another(&monitors, "secondary", snapped)); - } - - #[test] - fn snap_position_rounds_to_nearest_grid_line() { - assert_eq!(snap_position(14), 10); - assert_eq!(snap_position(15), 20); - assert_eq!(snap_position(-14), -10); - assert_eq!(snap_position(-15), -20); - } - - fn monitor_borders_another(monitors: &[MonitorConfig], id: &str, position: (i32, i32)) -> bool { - let monitor = monitors.iter().find(|monitor| monitor.id == id).unwrap(); - monitors - .iter() - .any(|other| other.id != id && shares_border(position.0, position.1, monitor, other)) - } - - fn shares_border(x: i32, y: i32, monitor: &MonitorConfig, other: &MonitorConfig) -> bool { - let width = monitor_logical_width(monitor); - let height = monitor_logical_height(monitor); - let other_width = monitor_logical_width(other); - let other_height = monitor_logical_height(other); - let horizontal_overlap = y < other.y + other_height && y + height > other.y; - let vertical_overlap = x < other.x + other_width && x + width > other.x; - let touches_left_or_right = x + width == other.x || x == other.x + other_width; - let touches_top_or_bottom = y + height == other.y || y == other.y + other_height; - - (touches_left_or_right && horizontal_overlap) || (touches_top_or_bottom && vertical_overlap) - } -} diff --git a/crates/hearthspace-settings/src/monitor_layout_view.rs b/crates/hearthspace-settings/src/monitor_layout_view.rs new file mode 100644 index 0000000..b29dc05 --- /dev/null +++ b/crates/hearthspace-settings/src/monitor_layout_view.rs @@ -0,0 +1,75 @@ +use xilem::core::{MessageCtx, MessageResult, Mut, View, ViewMarker}; +use xilem::{Pod, ViewCtx}; + +use crate::{ + actions::{MonitorEditorAction, apply_monitor_editor_action}, + monitor_layout_widget::MonitorLayoutWidget, + state::SettingsApp, +}; + +pub(crate) fn monitor_layout_editor() -> MonitorLayoutEditor { + MonitorLayoutEditor +} + +pub(crate) struct MonitorLayoutEditor; + +impl ViewMarker for MonitorLayoutEditor {} + +impl View for MonitorLayoutEditor { + type Element = Pod; + type ViewState = (); + + fn build( + &self, + ctx: &mut ViewCtx, + state: &mut SettingsApp, + ) -> (Self::Element, Self::ViewState) { + ( + ctx.with_action_widget(|ctx| { + ctx.create_pod(MonitorLayoutWidget::new( + state.settings.monitors.clone(), + state.selected_monitor_id.clone(), + )) + }), + (), + ) + } + + fn rebuild( + &self, + _prev: &Self, + (): &mut Self::ViewState, + _ctx: &mut ViewCtx, + mut element: Mut<'_, Self::Element>, + state: &mut SettingsApp, + ) { + element.widget.set_monitors( + state.settings.monitors.clone(), + state.selected_monitor_id.clone(), + &mut element.ctx, + ); + } + + fn teardown( + &self, + (): &mut Self::ViewState, + ctx: &mut ViewCtx, + element: Mut<'_, Self::Element>, + ) { + ctx.teardown_action_source(element); + } + + fn message( + &self, + (): &mut Self::ViewState, + message: &mut MessageCtx, + _element: Mut<'_, Self::Element>, + state: &mut SettingsApp, + ) -> MessageResult<()> { + let Some(action) = message.take_message::() else { + return MessageResult::Stale; + }; + apply_monitor_editor_action(state, *action); + MessageResult::Action(()) + } +} diff --git a/crates/hearthspace-settings/src/monitor_layout_widget.rs b/crates/hearthspace-settings/src/monitor_layout_widget.rs new file mode 100644 index 0000000..233b2e0 --- /dev/null +++ b/crates/hearthspace-settings/src/monitor_layout_widget.rs @@ -0,0 +1,479 @@ +use hearthspace_ipc::MonitorConfig; +use xilem::masonry::accesskit::{Node, Role}; +use xilem::masonry::core::{ + AccessCtx, AccessEvent, ChildrenIds, CursorIcon, EventCtx, LayoutCtx, MeasureCtx, PaintCtx, + PointerButton, PointerButtonEvent, PointerEvent, PointerUpdate, PropertiesMut, PropertiesRef, + QueryCtx, RegisterCtx, TextEvent, Widget, +}; +use xilem::masonry::imaging::Painter; +use xilem::masonry::kurbo::{Axis, Point, Rect, Size, Stroke}; +use xilem::masonry::layout::{LenReq, Length}; +use xilem::masonry::palette; +use xilem::masonry::{core::MutateCtx, layout::AsUnit}; +use xilem::peniko::Color; + +use crate::{ + actions::MonitorEditorAction, + monitors::{ + EMPTY_MONITOR_CANVAS_HEIGHT, EMPTY_MONITOR_CANVAS_WIDTH, monitor_logical_height, + monitor_logical_width, + }, +}; + +const EDITOR_PADDING: f64 = 24.0; +const MONITOR_SNAP: i32 = 10; +const MONITOR_LAYOUT_CANVAS_MULTIPLIER: f64 = 1.2; +const DRAG_START_DISTANCE: f64 = 3.0; + +#[derive(Debug)] +pub(crate) struct MonitorLayoutWidget { + monitors: Vec, + selected_monitor_id: Option, + drag: Option, +} + +#[derive(Debug, Clone)] +struct MonitorDrag { + id: String, + start_pointer: Point, + start_x: i32, + start_y: i32, + origin_x: f64, + origin_y: f64, + scale: f64, + moved: bool, +} + +impl MonitorLayoutWidget { + pub(crate) fn new(monitors: Vec, selected_monitor_id: Option) -> Self { + Self { + monitors, + selected_monitor_id, + drag: None, + } + } + + pub(crate) fn set_monitors( + &mut self, + monitors: Vec, + selected_monitor_id: Option, + ctx: &mut MutateCtx<'_>, + ) { + if self.monitors != monitors || self.selected_monitor_id != selected_monitor_id { + self.monitors = monitors; + self.selected_monitor_id = selected_monitor_id; + ctx.request_render(); + } + } + + fn layout_metrics(&self, size: Size) -> LayoutMetrics { + if let Some(drag) = &self.drag { + return LayoutMetrics { + origin_x: drag.origin_x, + origin_y: drag.origin_y, + scale: drag.scale, + }; + } + + let canvas_size = monitor_canvas_size(&self.monitors); + let scale = layout_scale_for_canvas(&self.monitors, size); + let (origin_x, origin_y) = monitor_canvas_origin(&self.monitors, canvas_size); + + LayoutMetrics { + origin_x, + origin_y, + scale, + } + } + + fn monitor_rect(&self, monitor: &MonitorConfig, metrics: LayoutMetrics) -> Rect { + Rect::new( + EDITOR_PADDING + (f64::from(monitor.x) - metrics.origin_x) * metrics.scale, + EDITOR_PADDING + (f64::from(monitor.y) - metrics.origin_y) * metrics.scale, + EDITOR_PADDING + + (f64::from(monitor.x + monitor_logical_width(monitor)) - metrics.origin_x) + * metrics.scale, + EDITOR_PADDING + + (f64::from(monitor.y + monitor_logical_height(monitor)) - metrics.origin_y) + * metrics.scale, + ) + } + + fn hit_monitor(&self, point: Point, metrics: LayoutMetrics) -> Option<&MonitorConfig> { + self.monitors + .iter() + .rev() + .find(|monitor| self.monitor_rect(monitor, metrics).contains(point)) + } + + fn monitors_for_drag( + &self, + drag: &MonitorDrag, + point: Point, + normalize: bool, + ) -> Vec { + let x = drag.start_x + ((point.x - drag.start_pointer.x) / drag.scale).round() as i32; + let y = drag.start_y + ((point.y - drag.start_pointer.y) / drag.scale).round() as i32; + let (x, y) = + snap_monitor_to_neighbor(&self.monitors, &drag.id, snap_position(x), snap_position(y)); + let mut monitors = self.monitors.clone(); + set_monitor_position(&mut monitors, &drag.id, x, y); + if normalize { + normalize_monitors_near_origin(&mut monitors); + } + monitors + } +} + +impl Widget for MonitorLayoutWidget { + type Action = MonitorEditorAction; + + fn on_pointer_event( + &mut self, + ctx: &mut EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + event: &PointerEvent, + ) { + match event { + PointerEvent::Down(PointerButtonEvent { + button: None | Some(PointerButton::Primary), + state, + .. + }) => { + let point = ctx.local_position(state.position); + let metrics = self.layout_metrics(ctx.content_box().size()); + if let Some(monitor) = self.hit_monitor(point, metrics) { + let selected_id = monitor.id.clone(); + let start_x = monitor.x; + let start_y = monitor.y; + ctx.capture_pointer(); + ctx.request_focus(); + ctx.set_handled(); + self.selected_monitor_id = Some(selected_id.clone()); + self.drag = Some(MonitorDrag { + id: selected_id.clone(), + start_pointer: point, + start_x, + start_y, + origin_x: metrics.origin_x, + origin_y: metrics.origin_y, + scale: metrics.scale, + moved: false, + }); + ctx.submit_action::(MonitorEditorAction::Select { + id: selected_id, + monitors: self.monitors.clone(), + }); + ctx.request_render(); + } + } + PointerEvent::Move(PointerUpdate { current, .. }) if ctx.is_active() => { + if let Some(drag) = &mut self.drag { + let point = ctx.local_position(current.position); + if !drag.moved && point.distance(drag.start_pointer) < DRAG_START_DISTANCE { + return; + } + drag.moved = true; + let drag = drag.clone(); + let moved_id = drag.id.clone(); + let monitors = self.monitors_for_drag(&drag, point, false); + self.monitors = monitors.clone(); + ctx.submit_action::(MonitorEditorAction::Drag { + moved_id, + monitors, + commit: false, + }); + ctx.request_render(); + ctx.set_handled(); + } + } + PointerEvent::Up(PointerButtonEvent { + button: None | Some(PointerButton::Primary), + state, + .. + }) => { + if let Some(drag) = self.drag.take() { + if !drag.moved { + self.selected_monitor_id = Some(drag.id.clone()); + ctx.submit_action::(MonitorEditorAction::Select { + id: drag.id, + monitors: self.monitors.clone(), + }); + ctx.request_render(); + ctx.set_handled(); + return; + } + let point = ctx.local_position(state.position); + let monitors = self.monitors_for_drag(&drag, point, true); + self.monitors = monitors.clone(); + ctx.submit_action::(MonitorEditorAction::Drag { + moved_id: drag.id, + monitors, + commit: true, + }); + ctx.request_render(); + ctx.set_handled(); + } + } + PointerEvent::Cancel(..) => self.drag = None, + _ => {} + } + } + + fn on_text_event( + &mut self, + _ctx: &mut EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &TextEvent, + ) { + } + + fn on_access_event( + &mut self, + _ctx: &mut EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &AccessEvent, + ) { + } + + fn register_children(&mut self, _ctx: &mut RegisterCtx<'_>) {} + + fn measure( + &mut self, + _ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + axis: Axis, + _len_req: LenReq, + _cross_length: Option, + ) -> Length { + match axis { + Axis::Horizontal => 520.0.px(), + Axis::Vertical => 300.0.px(), + } + } + + fn layout(&mut self, _ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, _size: Size) {} + + fn paint( + &mut self, + ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + painter: &mut Painter<'_>, + ) { + let content = ctx.content_box(); + painter + .fill(content, Color::from_rgb8(0x13, 0x17, 0x22)) + .draw(); + + let metrics = self.layout_metrics(content.size()); + for monitor in &self.monitors { + let rect = self.monitor_rect(monitor, metrics); + let active = self.drag.as_ref().is_some_and(|drag| drag.id == monitor.id); + let selected = self + .selected_monitor_id + .as_ref() + .is_some_and(|id| id == &monitor.id); + let fill = if active { + Color::from_rgb8(0x74, 0x9C, 0xFF) + } else if selected { + Color::from_rgb8(0x50, 0x72, 0xD8) + } else { + Color::from_rgb8(0x38, 0x55, 0xA3) + }; + painter.fill(rect, fill).draw(); + painter + .stroke(rect, &Stroke::new(2.0), palette::css::WHITE) + .draw(); + } + } + + fn accessibility_role(&self) -> Role { + Role::Canvas + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + node: &mut Node, + ) { + node.set_label("Drag monitor rectangles to configure their arrangement"); + } + + fn children_ids(&self) -> ChildrenIds { + ChildrenIds::new() + } + + fn get_cursor(&self, ctx: &QueryCtx<'_>, pos: Point) -> CursorIcon { + let metrics = self.layout_metrics(ctx.content_box().size()); + if self.hit_monitor(ctx.to_local(pos), metrics).is_some() { + CursorIcon::Grab + } else { + CursorIcon::Default + } + } +} + +#[derive(Clone, Copy)] +struct LayoutMetrics { + origin_x: f64, + origin_y: f64, + scale: f64, +} + +fn monitor_canvas_size(monitors: &[MonitorConfig]) -> Size { + let (width, height) = monitor_canvas_span(monitors); + Size::new( + f64::from(width) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + f64::from(height) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + ) +} + +fn monitor_canvas_origin(monitors: &[MonitorConfig], canvas_size: Size) -> (f64, f64) { + let Some((min_x, min_y, max_x, max_y)) = monitor_arrangement_bounds(monitors) else { + return (canvas_size.width / -2.0, canvas_size.height / -2.0); + }; + let center_x = (f64::from(min_x) + f64::from(max_x)) / 2.0; + let center_y = (f64::from(min_y) + f64::from(max_y)) / 2.0; + ( + center_x - canvas_size.width / 2.0, + center_y - canvas_size.height / 2.0, + ) +} + +fn monitor_canvas_span(monitors: &[MonitorConfig]) -> (i32, i32) { + if monitors.is_empty() { + return (EMPTY_MONITOR_CANVAS_WIDTH, EMPTY_MONITOR_CANVAS_HEIGHT); + } + + ( + combined_monitor_width(monitors), + combined_monitor_height(monitors), + ) +} + +fn combined_monitor_width(monitors: &[MonitorConfig]) -> i32 { + monitors.iter().map(monitor_logical_width).sum() +} + +fn combined_monitor_height(monitors: &[MonitorConfig]) -> i32 { + monitors.iter().map(monitor_logical_height).sum() +} + +fn layout_scale_for_canvas(monitors: &[MonitorConfig], size: Size) -> f64 { + let canvas_size = monitor_canvas_size(monitors); + let available_width = (size.width - EDITOR_PADDING * 2.0).max(1.0); + let available_height = (size.height - EDITOR_PADDING * 2.0).max(1.0); + (available_width / canvas_size.width) + .min(available_height / canvas_size.height) + .clamp(0.03, 1.0) +} + +fn set_monitor_position(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { + if let Some(monitor) = monitors.iter_mut().find(|monitor| monitor.id == id) { + monitor.x = x; + monitor.y = y; + } +} + +fn normalize_monitors_near_origin(monitors: &mut [MonitorConfig]) { + let Some((min_x, min_y, max_x, max_y)) = monitor_arrangement_bounds(monitors) else { + return; + }; + let center_x = midpoint_i32(min_x, max_x); + let center_y = midpoint_i32(min_y, max_y); + for monitor in monitors { + monitor.x -= center_x; + monitor.y -= center_y; + } +} + +fn monitor_arrangement_bounds(monitors: &[MonitorConfig]) -> Option<(i32, i32, i32, i32)> { + let first = monitors.first()?; + let mut min_x = first.x; + let mut min_y = first.y; + let mut max_x = first.x + monitor_logical_width(first); + let mut max_y = first.y + monitor_logical_height(first); + for monitor in monitors.iter().skip(1) { + min_x = min_x.min(monitor.x); + min_y = min_y.min(monitor.y); + max_x = max_x.max(monitor.x + monitor_logical_width(monitor)); + max_y = max_y.max(monitor.y + monitor_logical_height(monitor)); + } + Some((min_x, min_y, max_x, max_y)) +} + +fn midpoint_i32(start: i32, end: i32) -> i32 { + ((i64::from(start) + i64::from(end)) / 2) as i32 +} + +fn snap_position(value: i32) -> i32 { + let half = MONITOR_SNAP / 2; + if value >= 0 { + ((value + half) / MONITOR_SNAP) * MONITOR_SNAP + } else { + ((value - half) / MONITOR_SNAP) * MONITOR_SNAP + } +} + +fn snap_monitor_to_neighbor(monitors: &[MonitorConfig], id: &str, x: i32, y: i32) -> (i32, i32) { + let Some(monitor) = monitors.iter().find(|monitor| monitor.id == id) else { + return (x, y); + }; + let candidates = monitors + .iter() + .filter(|other| other.id != id) + .flat_map(|other| bordered_positions_for(monitor, other, x, y)); + + candidates + .min_by_key(|(candidate_x, candidate_y)| { + let dx = i64::from(*candidate_x - x); + let dy = i64::from(*candidate_y - y); + dx * dx + dy * dy + }) + .unwrap_or((x, y)) +} + +fn bordered_positions_for( + monitor: &MonitorConfig, + other: &MonitorConfig, + x: i32, + y: i32, +) -> [(i32, i32); 4] { + let monitor_width = monitor_logical_width(monitor); + let monitor_height = monitor_logical_height(monitor); + let other_width = monitor_logical_width(other); + let other_height = monitor_logical_height(other); + let left_of_other = other.x - monitor_width; + let right_of_other = other.x + other_width; + let above_other = other.y - monitor_height; + let below_other = other.y + other_height; + + [ + ( + left_of_other, + clamp_overlap(y, monitor_height, other.y, other_height), + ), + ( + right_of_other, + clamp_overlap(y, monitor_height, other.y, other_height), + ), + ( + clamp_overlap(x, monitor_width, other.x, other_width), + above_other, + ), + ( + clamp_overlap(x, monitor_width, other.x, other_width), + below_other, + ), + ] +} + +fn clamp_overlap(position: i32, size: i32, other_position: i32, other_size: i32) -> i32 { + let size = size.max(1); + let other_size = other_size.max(1); + position.clamp(other_position - size + 1, other_position + other_size - 1) +} + +#[cfg(test)] +mod tests; diff --git a/crates/hearthspace-settings/src/monitor_layout_widget/tests.rs b/crates/hearthspace-settings/src/monitor_layout_widget/tests.rs new file mode 100644 index 0000000..3ee9e56 --- /dev/null +++ b/crates/hearthspace-settings/src/monitor_layout_widget/tests.rs @@ -0,0 +1,226 @@ +use super::*; +use hearthspace_ipc::Settings; + +fn offset_monitor(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { + set_monitor_position(monitors, id, x, y); +} + +#[test] +fn monitor_canvas_size_uses_combined_dimensions_with_multiplier() { + let monitors = vec![ + MonitorConfig { + id: "left".to_string(), + name: "Left".to_string(), + width: 1280, + height: 720, + scale: 1, + x: -1280, + y: 120, + }, + MonitorConfig { + id: "main".to_string(), + name: "Main".to_string(), + width: 1920, + height: 1080, + scale: 1, + x: 0, + y: 0, + }, + ]; + + assert_eq!(combined_monitor_width(&monitors), 3200); + assert_eq!(combined_monitor_height(&monitors), 1800); + assert_eq!( + monitor_canvas_size(&monitors), + Size::new( + 3200.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + 1800.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + ) + ); +} + +#[test] +fn monitor_canvas_size_does_not_cap_large_monitors() { + let monitors = vec![ + MonitorConfig { + id: "ultrawide".to_string(), + name: "Ultrawide".to_string(), + width: 5120, + height: 1440, + scale: 1, + x: 0, + y: 0, + }, + MonitorConfig { + id: "hidpi".to_string(), + name: "HiDPI".to_string(), + width: 3840, + height: 2160, + scale: 1, + x: 5120, + y: 0, + }, + ]; + + assert_eq!(combined_monitor_width(&monitors), 8960); + assert_eq!(combined_monitor_height(&monitors), 3600); + assert_eq!( + monitor_canvas_size(&monitors), + Size::new( + 8960.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + 3600.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + ) + ); +} + +#[test] +fn monitor_canvas_size_uses_default_only_when_no_monitors_exist() { + assert_eq!( + monitor_canvas_size(&[]), + Size::new( + f64::from(EMPTY_MONITOR_CANVAS_WIDTH) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + f64::from(EMPTY_MONITOR_CANVAS_HEIGHT) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + ) + ); +} + +#[test] +fn layout_scale_does_not_change_when_monitors_are_dragged() { + let mut monitors = Settings::default().monitors; + let size = Size::new(760.0, 320.0); + let scale = layout_scale_for_canvas(&monitors, size); + + offset_monitor(&mut monitors, "secondary", -3200, 900); + + assert_eq!(layout_scale_for_canvas(&monitors, size), scale); +} + +#[test] +fn layout_metrics_do_not_recenter_during_live_drag() { + let mut widget = MonitorLayoutWidget::new(Settings::default().monitors, None); + let size = Size::new(760.0, 320.0); + let metrics = widget.layout_metrics(size); + widget.drag = Some(MonitorDrag { + id: "secondary".to_string(), + start_pointer: Point::from((0.0, 0.0)), + start_x: 1920, + start_y: 0, + origin_x: metrics.origin_x, + origin_y: metrics.origin_y, + scale: metrics.scale, + moved: false, + }); + + offset_monitor(&mut widget.monitors, "secondary", 0, 1200); + + let live_metrics = widget.layout_metrics(size); + assert_eq!(live_metrics.origin_x, metrics.origin_x); + assert_eq!(live_metrics.origin_y, metrics.origin_y); + assert_eq!(live_metrics.scale, metrics.scale); +} + +#[test] +fn canvas_origin_centers_far_away_arrangements() { + let mut monitors = Settings::default().monitors; + offset_monitor(&mut monitors, "primary", 10_000, -8_000); + offset_monitor(&mut monitors, "secondary", 11_920, -8_000); + let canvas_size = monitor_canvas_size(&monitors); + + let origin = monitor_canvas_origin(&monitors, canvas_size); + + assert_eq!(origin, (9616.0, -8756.0)); +} + +#[test] +fn normalize_monitors_keeps_layout_shape_centered_near_origin() { + let mut monitors = Settings::default().monitors; + offset_monitor(&mut monitors, "primary", 10_000, -8_000); + offset_monitor(&mut monitors, "secondary", 11_920, -8_000); + + normalize_monitors_near_origin(&mut monitors); + + assert_eq!( + monitor_arrangement_bounds(&monitors), + Some((-1920, -540, 1920, 540)) + ); + assert!(monitor_borders_another(&monitors, "secondary", (0, -540))); +} + +#[test] +fn live_drag_moves_only_selected_monitor_until_release() { + let widget = MonitorLayoutWidget::new(Settings::default().monitors, None); + let drag = MonitorDrag { + id: "secondary".to_string(), + start_pointer: Point::from((0.0, 0.0)), + start_x: 1920, + start_y: 0, + origin_x: 0.0, + origin_y: 0.0, + scale: 1.0, + moved: true, + }; + + let live = widget.monitors_for_drag(&drag, Point::from((0.0, 1200.0)), false); + let released = widget.monitors_for_drag(&drag, Point::from((0.0, 1200.0)), true); + + let live_primary = live.iter().find(|monitor| monitor.id == "primary").unwrap(); + let live_secondary = live + .iter() + .find(|monitor| monitor.id == "secondary") + .unwrap(); + assert_eq!((live_primary.x, live_primary.y), (0, 0)); + assert_eq!((live_secondary.x, live_secondary.y), (1919, 1080)); + + assert_eq!( + monitor_arrangement_bounds(&released), + Some((-1919, -1080, 1920, 1080)) + ); +} + +#[test] +fn monitor_drag_snaps_gap_to_shared_border() { + let monitors = Settings::default().monitors; + + let snapped = snap_monitor_to_neighbor(&monitors, "secondary", 2600, 120); + + assert_eq!(snapped, (1920, 120)); + assert!(monitor_borders_another(&monitors, "secondary", snapped)); +} + +#[test] +fn monitor_drag_snaps_to_border_and_preserves_overlap() { + let monitors = Settings::default().monitors; + + let snapped = snap_monitor_to_neighbor(&monitors, "secondary", 600, -1500); + + assert_eq!(snapped, (600, -1080)); + assert!(monitor_borders_another(&monitors, "secondary", snapped)); +} + +#[test] +fn snap_position_rounds_to_nearest_grid_line() { + assert_eq!(snap_position(14), 10); + assert_eq!(snap_position(15), 20); + assert_eq!(snap_position(-14), -10); + assert_eq!(snap_position(-15), -20); +} + +fn monitor_borders_another(monitors: &[MonitorConfig], id: &str, position: (i32, i32)) -> bool { + let monitor = monitors.iter().find(|monitor| monitor.id == id).unwrap(); + monitors + .iter() + .any(|other| other.id != id && shares_border(position.0, position.1, monitor, other)) +} + +fn shares_border(x: i32, y: i32, monitor: &MonitorConfig, other: &MonitorConfig) -> bool { + let width = monitor_logical_width(monitor); + let height = monitor_logical_height(monitor); + let other_width = monitor_logical_width(other); + let other_height = monitor_logical_height(other); + let horizontal_overlap = y < other.y + other_height && y + height > other.y; + let vertical_overlap = x < other.x + other_width && x + width > other.x; + let touches_left_or_right = x + width == other.x || x == other.x + other_width; + let touches_top_or_bottom = y + height == other.y || y == other.y + other_height; + + (touches_left_or_right && horizontal_overlap) || (touches_top_or_bottom && vertical_overlap) +} diff --git a/crates/hearthspace-settings/src/monitors.rs b/crates/hearthspace-settings/src/monitors.rs new file mode 100644 index 0000000..b273723 --- /dev/null +++ b/crates/hearthspace-settings/src/monitors.rs @@ -0,0 +1,345 @@ +use hearthspace_ipc::{MonitorConfig, Settings}; + +pub(crate) const MONITOR_SCALE_OPTIONS: [i32; 4] = [1, 2, 3, 4]; +pub(crate) const EMPTY_MONITOR_CANVAS_WIDTH: i32 = 1920; +pub(crate) const EMPTY_MONITOR_CANVAS_HEIGHT: i32 = 1080; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LiveOutput { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) width: i32, + pub(crate) height: i32, + pub(crate) scale: i32, +} + +pub(crate) fn recalculate_monitor_positions_for_scale( + monitors: &mut [MonitorConfig], + id: &str, + scale: i32, +) { + let Some(index) = monitors.iter().position(|monitor| monitor.id == id) else { + return; + }; + let old_monitor = monitors[index].clone(); + let old_width = monitor_logical_width(&old_monitor); + let old_height = monitor_logical_height(&old_monitor); + let old_right = old_monitor.x + old_width; + let old_bottom = old_monitor.y + old_height; + + monitors[index].scale = scale.max(1); + let new_width = monitor_logical_width(&monitors[index]); + let new_height = monitor_logical_height(&monitors[index]); + let dx = new_width - old_width; + let dy = new_height - old_height; + + for (monitor_index, monitor) in monitors.iter_mut().enumerate() { + if monitor_index == index { + continue; + } + if monitor.x >= old_right { + monitor.x += dx; + } + if monitor.y >= old_bottom { + monitor.y += dy; + } + } + + if monitors_overlap(monitors) { + place_monitors_edge_to_edge(monitors); + } +} + +pub(crate) fn settings_with_live_outputs( + mut settings: Settings, + live_outputs: &[LiveOutput], +) -> Settings { + if live_outputs.is_empty() { + return settings; + } + + settings.monitors = monitors_for_live_outputs(&settings.monitors, live_outputs); + settings +} + +fn monitors_for_live_outputs( + saved_monitors: &[MonitorConfig], + live_outputs: &[LiveOutput], +) -> Vec { + let mut monitors = Vec::with_capacity(live_outputs.len()); + let mut next_x = 0; + let saved_monitors_are_placeholders = default_placeholder_monitors(saved_monitors); + for (index, output) in live_outputs.iter().enumerate() { + let role_id = monitor_role_id(index); + let saved = (!saved_monitors_are_placeholders) + .then(|| { + saved_monitors + .iter() + .find(|monitor| monitor.id == output.id || monitor.name == output.name) + .or_else(|| saved_monitors.iter().find(|monitor| monitor.id == role_id)) + }) + .flatten(); + let (id, scale, x, y) = if let Some(saved) = saved { + (saved.id.clone(), saved.scale.max(1), saved.x, saved.y) + } else if saved_monitors_are_placeholders && index < 2 { + (role_id, output.scale.max(1), next_x, 0) + } else { + (output.id.clone(), output.scale.max(1), next_x, 0) + }; + monitors.push(MonitorConfig { + id, + name: output.name.clone(), + width: output.width, + height: output.height, + scale, + x, + y, + }); + next_x += logical_dimension(output.width, scale); + } + if monitors_overlap(&monitors) { + place_monitors_edge_to_edge(&mut monitors); + } + monitors +} + +fn place_monitors_edge_to_edge(monitors: &mut [MonitorConfig]) { + let mut next_x = 0; + for monitor in monitors { + monitor.x = next_x; + monitor.y = 0; + next_x += monitor_logical_width(monitor); + } +} + +pub(crate) fn monitors_overlap(monitors: &[MonitorConfig]) -> bool { + monitors.iter().enumerate().any(|(index, monitor)| { + monitors + .iter() + .skip(index + 1) + .any(|other| monitor_rects_overlap(monitor, other)) + }) +} + +fn monitor_rects_overlap(a: &MonitorConfig, b: &MonitorConfig) -> bool { + a.x < b.x + monitor_logical_width(b) + && a.x + monitor_logical_width(a) > b.x + && a.y < b.y + monitor_logical_height(b) + && a.y + monitor_logical_height(a) > b.y +} + +pub(crate) fn monitor_logical_width(monitor: &MonitorConfig) -> i32 { + logical_dimension(monitor.width, monitor.scale) +} + +pub(crate) fn monitor_logical_height(monitor: &MonitorConfig) -> i32 { + logical_dimension(monitor.height, monitor.scale) +} + +fn logical_dimension(size: i32, _scale: i32) -> i32 { + size.max(1) +} + +fn default_placeholder_monitors(monitors: &[MonitorConfig]) -> bool { + monitors == Settings::default().monitors +} + +fn monitor_role_id(index: usize) -> String { + if index == 0 { + "primary".to_string() + } else if index == 1 { + "secondary".to_string() + } else { + format!("secondary-{index}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn offset_monitor(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { + if let Some(monitor) = monitors.iter_mut().find(|monitor| monitor.id == id) { + monitor.x = x; + monitor.y = y; + } + } + + #[test] + fn live_outputs_hide_saved_disconnected_monitors() { + let monitors = monitors_for_live_outputs( + &Settings::default().monitors, + &[LiveOutput { + id: "eDP-1".to_string(), + name: "Built-in Display".to_string(), + width: 2256, + height: 1504, + scale: 1, + }], + ); + + assert_eq!(monitors.len(), 1); + assert_eq!(monitors[0].id, "primary"); + assert_eq!(monitors[0].name, "Built-in Display"); + assert_eq!((monitors[0].width, monitors[0].height), (2256, 1504)); + } + + #[test] + fn live_outputs_do_not_reuse_placeholder_positions_for_mixed_resolutions() { + let monitors = monitors_for_live_outputs( + &Settings::default().monitors, + &[ + LiveOutput { + id: "eDP-1".to_string(), + name: "Built-in Display".to_string(), + width: 2560, + height: 1440, + scale: 1, + }, + LiveOutput { + id: "HDMI-A-1".to_string(), + name: "External Monitor".to_string(), + width: 1366, + height: 768, + scale: 1, + }, + ], + ); + + assert_eq!(monitors[0].id, "primary"); + assert_eq!((monitors[0].x, monitors[0].y), (0, 0)); + assert_eq!(monitors[1].id, "secondary"); + assert_eq!((monitors[1].x, monitors[1].y), (2560, 0)); + } + + #[test] + fn live_outputs_repair_overlapping_saved_active_monitors() { + let saved = vec![ + MonitorConfig { + id: "primary".to_string(), + name: "Primary".to_string(), + width: 1920, + height: 1080, + scale: 1, + x: -1280, + y: -736, + }, + MonitorConfig { + id: "secondary".to_string(), + name: "Secondary".to_string(), + width: 1920, + height: 1080, + scale: 1, + x: -1920, + y: -540, + }, + ]; + + let monitors = monitors_for_live_outputs( + &saved, + &[ + LiveOutput { + id: "eDP-1".to_string(), + name: "Built-in Display".to_string(), + width: 2560, + height: 1440, + scale: 1, + }, + LiveOutput { + id: "HDMI-A-1".to_string(), + name: "External Monitor".to_string(), + width: 1366, + height: 768, + scale: 1, + }, + ], + ); + + assert_eq!((monitors[0].x, monitors[0].y), (0, 0)); + assert_eq!((monitors[1].x, monitors[1].y), (2560, 0)); + assert!(!monitors_overlap(&monitors)); + } + + #[test] + fn live_outputs_preserve_saved_connector_positions() { + let saved = vec![MonitorConfig { + id: "HDMI-A-1".to_string(), + name: "HDMI-A-1".to_string(), + width: 1920, + height: 1080, + scale: 2, + x: -1920, + y: 200, + }]; + + let monitors = monitors_for_live_outputs( + &saved, + &[LiveOutput { + id: "HDMI-A-1".to_string(), + name: "External Monitor".to_string(), + width: 2560, + height: 1440, + scale: 1, + }], + ); + + assert_eq!(monitors.len(), 1); + assert_eq!(monitors[0].id, "HDMI-A-1"); + assert_eq!(monitors[0].name, "External Monitor"); + assert_eq!((monitors[0].x, monitors[0].y), (-1920, 200)); + assert_eq!((monitors[0].width, monitors[0].height), (2560, 1440)); + assert_eq!(monitors[0].scale, 2); + } + + #[test] + fn live_outputs_use_reported_scale_for_new_monitors() { + let monitors = monitors_for_live_outputs( + &[], + &[LiveOutput { + id: "eDP-1".to_string(), + name: "Built-in Display".to_string(), + width: 2560, + height: 1440, + scale: 2, + }], + ); + + assert_eq!(monitors[0].scale, 2); + assert_eq!(monitor_logical_width(&monitors[0]), 2560); + } + + #[test] + fn scaling_monitor_keeps_outputs_at_physical_desktop_size() { + let mut monitors = Settings::default().monitors; + + recalculate_monitor_positions_for_scale(&mut monitors, "primary", 2); + + let primary = monitors + .iter() + .find(|monitor| monitor.id == "primary") + .unwrap(); + let secondary = monitors + .iter() + .find(|monitor| monitor.id == "secondary") + .unwrap(); + assert_eq!(primary.scale, 2); + assert_eq!(monitor_logical_width(primary), 1920); + assert_eq!((secondary.x, secondary.y), (1920, 0)); + assert!(!monitors_overlap(&monitors)); + } + + #[test] + fn scaling_monitor_keeps_outputs_below_at_physical_desktop_size() { + let mut monitors = Settings::default().monitors; + offset_monitor(&mut monitors, "secondary", 0, 1080); + + recalculate_monitor_positions_for_scale(&mut monitors, "primary", 2); + + let secondary = monitors + .iter() + .find(|monitor| monitor.id == "secondary") + .unwrap(); + assert_eq!((secondary.x, secondary.y), (0, 1080)); + assert!(!monitors_overlap(&monitors)); + } +} diff --git a/crates/hearthspace-settings/src/settings_backend.rs b/crates/hearthspace-settings/src/settings_backend.rs new file mode 100644 index 0000000..948a2a7 --- /dev/null +++ b/crates/hearthspace-settings/src/settings_backend.rs @@ -0,0 +1,54 @@ +use std::{ + env, io, + io::{Read, Write}, + net::Shutdown, + os::unix::net::UnixStream, + path::PathBuf, +}; + +use hearthspace_ipc::{MonitorConfig, Settings, client_set_monitor_position}; + +pub(crate) fn save_monitor_positions( + monitors: &[MonitorConfig], +) -> std::io::Result> { + let mut saved = None; + for monitor in monitors { + saved = Some(client_set_monitor_position( + &monitor.id, + monitor.x, + monitor.y, + )?); + } + Ok(saved) +} + +pub(crate) fn notify_compositor_settings_changed() -> io::Result<()> { + let command_socket = command_socket_path()?; + let mut stream = UnixStream::connect(&command_socket)?; + stream.write_all(b"reload-settings\n")?; + stream.shutdown(Shutdown::Write)?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + if response == "ok\n" { + Ok(()) + } else { + Err(io::Error::other(format!( + "unexpected compositor response: {response:?}" + ))) + } +} + +fn command_socket_path() -> io::Result { + env::var_os(hearthspace_common::SHELL_COMMAND_SOCKET_ENV) + .map(PathBuf::from) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!( + "{} is not set", + hearthspace_common::SHELL_COMMAND_SOCKET_ENV + ), + ) + }) +} diff --git a/crates/hearthspace-settings/src/state.rs b/crates/hearthspace-settings/src/state.rs new file mode 100644 index 0000000..b60c10d --- /dev/null +++ b/crates/hearthspace-settings/src/state.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; + +use hearthspace_ipc::{Settings, client_get_settings}; +use xilem::tokio::sync::mpsc::UnboundedSender; + +use crate::{ + monitors::{LiveOutput, settings_with_live_outputs}, + wayland_outputs::live_wayland_outputs, +}; + +pub(crate) struct SettingsApp { + pub(crate) settings: Settings, + pub(crate) live_outputs: Vec, + pub(crate) selected_monitor_id: Option, + pub(crate) background_picker_request_sender: Option>, + pub(crate) background_picker_pending: bool, + pub(crate) status: String, + pub(crate) config_label: String, +} + +#[derive(Debug)] +pub(crate) enum BackgroundPickerRequest { + PickFile, +} + +#[derive(Debug)] +pub(crate) enum BackgroundPickerResponse { + Selected(PathBuf), + Cancelled, + Failed(String), +} + +pub(crate) fn initial_state() -> SettingsApp { + let (mut settings, mut status) = match client_get_settings() { + Ok(settings) => (settings, "Connected to hearthspace-settingsd".to_string()), + Err(error) => ( + Settings::default(), + format!("Settings backend unavailable: {error}"), + ), + }; + let live_outputs = match live_wayland_outputs() { + Ok(outputs) => outputs, + Err(error) => { + status.push_str(&format!("; live monitors unavailable: {error}")); + Vec::new() + } + }; + settings = settings_with_live_outputs(settings, &live_outputs); + let selected_monitor_id = settings.monitors.first().map(|monitor| monitor.id.clone()); + + let config_label = match hearthspace_common::config_path() { + Ok(path) => format!("Config: {}", path.display()), + Err(error) => format!("Config path unavailable: {error}"), + }; + + SettingsApp { + settings, + live_outputs, + selected_monitor_id, + background_picker_request_sender: None, + background_picker_pending: false, + status, + config_label, + } +} diff --git a/crates/hearthspace-settings/src/ui.rs b/crates/hearthspace-settings/src/ui.rs new file mode 100644 index 0000000..cd7d735 --- /dev/null +++ b/crates/hearthspace-settings/src/ui.rs @@ -0,0 +1,211 @@ +use hearthspace_ipc::{MonitorConfig, client_set_background_moves_with_canvas, client_set_test}; +use rfd::FileDialog; +use xilem::{ + AnyWidgetView, WidgetView, + core::fork, + masonry::{layout::AsUnit, parley::fontique::GenericFamily}, + style::Style, + view::{checkbox, flex_col, flex_row, label, sized_box, text_button, worker}, +}; + +use crate::{ + actions::{save_background_image_path, save_monitor_scale}, + monitor_layout_view::monitor_layout_editor, + monitors::{ + MONITOR_SCALE_OPTIONS, monitor_logical_height, monitor_logical_width, + settings_with_live_outputs, + }, + settings_backend::notify_compositor_settings_changed, + state::{BackgroundPickerRequest, BackgroundPickerResponse, SettingsApp}, +}; + +pub(crate) fn app_logic(state: &mut SettingsApp) -> impl WidgetView + use<> { + let test_checked = state.settings.test; + let background_moves_with_canvas = state.settings.background_moves_with_canvas; + let background_image_label = state + .settings + .background_image_path + .as_deref() + .unwrap_or("Default background") + .to_string(); + let select_background_button_label = if state.background_picker_pending { + "Selecting background image..." + } else { + "Select background image" + }; + let monitor_summary = format_monitor_summary(&state.settings.monitors); + + let content = sized_box( + flex_col(( + label("Hearthspace Settings") + .font(GenericFamily::SystemUi) + .text_size(22.0), + label("Drag monitors to arrange their relative positions."), + sized_box(monitor_layout_editor()).fixed_height(320.0.px()), + label(monitor_summary), + monitor_details_view(state), + checkbox("test", test_checked, |state: &mut SettingsApp, checked| { + let previous = state.settings.test; + state.settings.test = checked; + + match client_set_test(checked) { + Ok(settings) => { + let saved_test = settings.test; + state.settings = settings_with_live_outputs(settings, &state.live_outputs); + state.status = format!("Saved test = {saved_test}"); + } + Err(error) => { + state.settings.test = previous; + state.status = format!("Failed to save setting: {error}"); + } + } + }), + checkbox( + "Move background with canvas", + background_moves_with_canvas, + |state: &mut SettingsApp, checked| { + let previous = state.settings.background_moves_with_canvas; + state.settings.background_moves_with_canvas = checked; + + match client_set_background_moves_with_canvas(checked) { + Ok(settings) => { + state.settings = settings_with_live_outputs(settings, &state.live_outputs); + state.status = match notify_compositor_settings_changed() { + Ok(()) => format!("Saved background canvas movement = {checked}"), + Err(error) => format!( + "Saved background canvas movement = {checked}; compositor reload failed: {error}" + ), + }; + } + Err(error) => { + state.settings.background_moves_with_canvas = previous; + state.status = format!("Failed to save setting: {error}"); + } + } + }, + ), + label(format!("Background image: {background_image_label}")), + flex_row(( + text_button(select_background_button_label, |state: &mut SettingsApp| { + if state.background_picker_pending { + state.status = "Background image picker is already open".to_string(); + return; + } + + let Some(sender) = &state.background_picker_request_sender else { + state.status = "Background image picker is not ready".to_string(); + return; + }; + + match sender.send(BackgroundPickerRequest::PickFile) { + Ok(()) => { + state.background_picker_pending = true; + state.status = "Opening background image picker".to_string(); + } + Err(error) => { + state.status = format!("Failed to open background image picker: {error}"); + } + } + }), + text_button("Use default background", |state: &mut SettingsApp| { + save_background_image_path(state, None); + }), + )), + label(state.config_label.clone()), + label(state.status.clone()), + )) + .gap(12.0.px()), + ) + .padding(20.0.px()); + + fork( + content, + worker( + |proxy, mut rx| async move { + while let Some(BackgroundPickerRequest::PickFile) = rx.recv().await { + let response = match xilem::tokio::task::spawn_blocking(|| { + FileDialog::new() + .add_filter("PNG image", &["png"]) + .pick_file() + }) + .await + { + Ok(Some(path)) => BackgroundPickerResponse::Selected(path), + Ok(None) => BackgroundPickerResponse::Cancelled, + Err(error) => BackgroundPickerResponse::Failed(error.to_string()), + }; + + drop(proxy.message(response)); + } + }, + |state: &mut SettingsApp, sender| { + state.background_picker_request_sender = Some(sender); + }, + |state: &mut SettingsApp, response| { + state.background_picker_pending = false; + match response { + BackgroundPickerResponse::Selected(path) => { + save_background_image_path(state, Some(path)); + } + BackgroundPickerResponse::Cancelled => { + state.status = "Background image selection cancelled".to_string(); + } + BackgroundPickerResponse::Failed(error) => { + state.status = format!("Background image picker failed: {error}"); + } + } + }, + ), + ) +} + +fn format_monitor_summary(monitors: &[MonitorConfig]) -> String { + monitors + .iter() + .map(|monitor| { + format!( + "{}: {}x{} @ {}x at {},{}", + monitor.name, monitor.width, monitor.height, monitor.scale, monitor.x, monitor.y + ) + }) + .collect::>() + .join(" | ") +} + +fn monitor_details_view(state: &SettingsApp) -> Box> { + let Some(monitor) = selected_monitor(state) else { + return label("Select a monitor to edit its settings.").boxed(); + }; + + let scale_buttons = MONITOR_SCALE_OPTIONS.map(|scale| { + let id = monitor.id.clone(); + text_button(format!("{}x", scale), move |state: &mut SettingsApp| { + save_monitor_scale(state, &id, scale); + }) + }); + + flex_col(( + label(format!("Selected: {}", monitor.name)), + label(format!( + "Physical: {}x{} | Desktop: {}x{} | Position: {},{}", + monitor.width, + monitor.height, + monitor_logical_width(monitor), + monitor_logical_height(monitor), + monitor.x, + monitor.y + )), + flex_row((label(format!("Scale: {}x", monitor.scale)), scale_buttons)), + )) + .gap(8.0.px()) + .boxed() +} + +fn selected_monitor(state: &SettingsApp) -> Option<&MonitorConfig> { + let selected_id = state.selected_monitor_id.as_ref()?; + state + .settings + .monitors + .iter() + .find(|monitor| &monitor.id == selected_id) +} diff --git a/crates/hearthspace-settings/src/wayland_outputs.rs b/crates/hearthspace-settings/src/wayland_outputs.rs new file mode 100644 index 0000000..fe90143 --- /dev/null +++ b/crates/hearthspace-settings/src/wayland_outputs.rs @@ -0,0 +1,132 @@ +use wayland_client::{ + Connection, Dispatch, QueueHandle, WEnum, + globals::{GlobalListContents, registry_queue_init}, + protocol::{wl_output, wl_registry}, +}; + +use crate::monitors::{EMPTY_MONITOR_CANVAS_HEIGHT, EMPTY_MONITOR_CANVAS_WIDTH, LiveOutput}; + +pub(crate) fn live_wayland_outputs() -> std::io::Result> { + let connection = Connection::connect_to_env().map_err(std::io::Error::other)?; + let (globals, mut event_queue) = + registry_queue_init::(&connection).map_err(std::io::Error::other)?; + let queue_handle = event_queue.handle(); + let mut probe = OutputProbe::default(); + for global in globals.contents().clone_list() { + if global.interface == "wl_output" { + globals.registry().bind::( + global.name, + global.version.min(4), + &queue_handle, + OutputGlobal { name: global.name }, + ); + } + } + event_queue + .roundtrip(&mut probe) + .map_err(std::io::Error::other)?; + Ok(probe.live_outputs()) +} + +#[derive(Default)] +struct OutputProbe { + outputs: Vec, +} + +struct OutputGlobal { + name: u32, +} + +#[derive(Debug)] +struct OutputState { + global_name: u32, + name: Option, + description: Option, + width: Option, + height: Option, + scale: Option, +} + +impl OutputProbe { + fn output_mut(&mut self, global_name: u32) -> &mut OutputState { + if let Some(index) = self + .outputs + .iter() + .position(|output| output.global_name == global_name) + { + return &mut self.outputs[index]; + } + self.outputs.push(OutputState { + global_name, + name: None, + description: None, + width: None, + height: None, + scale: None, + }); + self.outputs.last_mut().unwrap() + } + + fn live_outputs(self) -> Vec { + self.outputs + .into_iter() + .map(|output| { + let fallback = format!("output-{}", output.global_name); + let id = output.name.clone().unwrap_or_else(|| fallback.clone()); + LiveOutput { + id: id.clone(), + name: output.description.or(output.name).unwrap_or(id), + width: output.width.unwrap_or(EMPTY_MONITOR_CANVAS_WIDTH), + height: output.height.unwrap_or(EMPTY_MONITOR_CANVAS_HEIGHT), + scale: output.scale.unwrap_or(1).max(1), + } + }) + .collect() + } +} + +impl Dispatch for OutputProbe { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + _queue_handle: &QueueHandle, + ) { + } +} + +impl Dispatch for OutputProbe { + fn event( + state: &mut Self, + _proxy: &wl_output::WlOutput, + event: wl_output::Event, + data: &OutputGlobal, + _connection: &Connection, + _queue_handle: &QueueHandle, + ) { + let output = state.output_mut(data.name); + match event { + wl_output::Event::Name { name } => output.name = Some(name), + wl_output::Event::Description { description } => { + output.description = Some(description); + } + wl_output::Event::Mode { + flags, + width, + height, + .. + } if output_size_is_current(flags) => { + output.width = Some(width); + output.height = Some(height); + } + wl_output::Event::Scale { factor } => output.scale = Some(factor.max(1)), + _ => {} + } + } +} + +fn output_size_is_current(flags: WEnum) -> bool { + matches!(flags, WEnum::Value(flags) if flags.contains(wl_output::Mode::Current)) +} diff --git a/crates/hearthspace-shell/src/app_catalog.rs b/crates/hearthspace-shell/src/app_catalog.rs index 97ba34f..991af8c 100644 --- a/crates/hearthspace-shell/src/app_catalog.rs +++ b/crates/hearthspace-shell/src/app_catalog.rs @@ -1,12 +1,15 @@ -use std::{ - collections::{HashMap, HashSet}, - env, fs, - path::{Path, PathBuf}, - process::Command, -}; +use std::{collections::HashSet, fs, path::PathBuf}; + use tracing::debug; -const SETTINGS_APP_BINARY: &str = "hearthspace-settings"; +mod desktop_entry; +mod exec; +mod paths; +mod search; +mod spawn; +mod terminal; + +pub use spawn::{spawn_argv, spawn_argv_with_env, spawn_argv_with_wayland_display}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DesktopApp { @@ -31,7 +34,7 @@ pub struct AppCatalog { impl AppCatalog { pub fn load() -> Self { - Self::load_from_data_dirs(xdg_data_dirs()) + Self::load_from_data_dirs(paths::xdg_data_dirs()) } fn load_from_data_dirs(data_dirs: Vec) -> Self { @@ -41,12 +44,12 @@ impl AppCatalog { for data_dir in data_dirs { let applications_dir = data_dir.join("applications"); - let Ok(paths) = desktop_files_under(&applications_dir) else { + let Ok(paths) = desktop_entry::desktop_files_under(&applications_dir) else { continue; }; for path in paths { - let Some(id) = desktop_entry_id(&applications_dir, &path) else { + let Some(id) = desktop_entry::desktop_entry_id(&applications_dir, &path) else { continue; }; if seen_ids.contains(&id) { @@ -82,552 +85,36 @@ impl AppCatalog { } pub fn search(&self, query: &str, limit: usize) -> Vec { - let query_tokens = tokenize_search(query); - if query_tokens.is_empty() { - return self.apps.iter().take(limit).cloned().collect(); - } - - let mut scored = self - .apps - .iter() - .filter_map(|app| search_score(app, &query_tokens).map(|score| (score, app))) - .collect::>(); - scored.sort_by(|(left_score, left), (right_score, right)| { - right_score - .cmp(left_score) - .then(left.name.cmp(&right.name)) - .then(left.id.cmp(&right.id)) - }); - - if scored.is_empty() { - return self.apps.iter().take(limit).cloned().collect(); - } - - scored - .into_iter() - .take(limit) - .map(|(_, app)| app.clone()) - .collect() + search::search_apps(&self.apps, query, limit) } pub fn terminal_command_for(&self, command: Vec) -> Result, String> { - if command.is_empty() { - return Err("cannot wrap an empty terminal command".to_string()); - } - - if executable_in_path("xdg-terminal-exec") { - let mut terminal_command = vec!["xdg-terminal-exec".to_string()]; - terminal_command.extend(command); - return Ok(terminal_command); - } - - let terminal = self - .preferred_terminal() - .ok_or_else(|| "no terminal emulator found".to_string())?; - let mut terminal_command = terminal.launch_argv()?; - match terminal.terminal_arg_exec.as_deref() { - Some("") => {} - Some(arg) => terminal_command.push(arg.to_string()), - None => terminal_command.push("-e".to_string()), - } - terminal_command.extend(command); - Ok(terminal_command) + terminal::terminal_command_for(self, command) } fn preferred_terminal(&self) -> Option<&DesktopApp> { - for id in terminal_preference_ids() { - if let Some(app) = self.app_by_id(&id).filter(|app| { - app.categories - .iter() - .any(|category| category == "TerminalEmulator") - }) { - return Some(app); - } - } - - self.apps.iter().find(|app| { - app.categories - .iter() - .any(|category| category == "TerminalEmulator") - }) + terminal::preferred_terminal(self) } } impl DesktopApp { - fn from_desktop_entry(id: String, path: PathBuf, contents: &str) -> Option { - let fields = parse_desktop_entry_fields(contents); - if fields.get("Type")? != "Application" { - return None; - } - if bool_field(&fields, "Hidden") || bool_field(&fields, "NoDisplay") { - return None; - } - if !only_show_in_allows(fields.get("OnlyShowIn")) { - return None; - } - if !not_show_in_allows(fields.get("NotShowIn")) { - return None; - } - if fields - .get("TryExec") - .is_some_and(|try_exec| !try_exec_resolves(try_exec)) - { - return None; - } - - Some(Self { - id, - name: fields.get("Name")?.clone(), - generic_name: fields.get("GenericName").cloned(), - comment: fields.get("Comment").cloned(), - keywords: fields - .get("Keywords") - .map(|value| parse_semicolon_list(value)) - .unwrap_or_default(), - exec: fields.get("Exec")?.clone(), - icon: fields.get("Icon").cloned(), - terminal: bool_field(&fields, "Terminal"), - path, - categories: fields - .get("Categories") - .map(|value| parse_semicolon_list(value)) - .unwrap_or_default(), - terminal_arg_exec: fields - .get("TerminalArgExec") - .or_else(|| fields.get("X-TerminalArgExec")) - .or_else(|| fields.get("X-ExecArg")) - .cloned(), - snap_instance_name: fields.get("X-SnapInstanceName").cloned(), - }) - } - pub fn launch_argv(&self) -> Result, String> { - let argv = parse_exec_argv(self)?; - if argv.is_empty() { - Err(format!("desktop entry {} produced no command", self.id)) - } else { - Ok(argv) - } - } -} - -fn xdg_data_dirs() -> Vec { - let mut dirs = Vec::new(); - if let Some(xdg_data_home) = env::var_os("XDG_DATA_HOME") { - dirs.push(PathBuf::from(xdg_data_home)); - } else if let Some(home) = env::var_os("HOME") { - dirs.push(PathBuf::from(home).join(".local/share")); - } - - let data_dirs = env::var_os("XDG_DATA_DIRS") - .map(|value| env::split_paths(&value).collect::>()) - .unwrap_or_else(|| { - vec![ - PathBuf::from("/usr/local/share"), - PathBuf::from("/usr/share"), - ] - }); - dirs.extend(data_dirs); - dirs.push(source_tree_data_dir()); - dirs -} - -fn source_tree_data_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data") -} - -fn desktop_files_under(dir: &Path) -> std::io::Result> { - let mut paths = Vec::new(); - collect_desktop_files(dir, &mut paths)?; - paths.sort(); - Ok(paths) -} - -fn collect_desktop_files(dir: &Path, paths: &mut Vec) -> std::io::Result<()> { - let Ok(entries) = fs::read_dir(dir) else { - return Ok(()); - }; - - for entry in entries { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - collect_desktop_files(&path, paths)?; - } else if path - .extension() - .is_some_and(|extension| extension == "desktop") - { - paths.push(path); - } - } - Ok(()) -} - -fn desktop_entry_id(base: &Path, path: &Path) -> Option { - let relative = path.strip_prefix(base).ok()?; - let id = relative - .components() - .map(|component| component.as_os_str().to_string_lossy()) - .collect::>() - .join("-"); - (!id.is_empty()).then_some(id) -} - -fn parse_desktop_entry_fields(contents: &str) -> HashMap { - let mut fields = HashMap::new(); - let mut in_desktop_entry = false; - - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if line.starts_with('[') && line.ends_with(']') { - in_desktop_entry = line == "[Desktop Entry]"; - continue; - } - if !in_desktop_entry { - continue; - } - let Some((key, value)) = line.split_once('=') else { - continue; - }; - if key.contains('[') { - continue; - } - fields.insert(key.to_string(), unescape_desktop_value(value)); + exec::launch_argv(self) } - - fields -} - -fn unescape_desktop_value(value: &str) -> String { - let mut output = String::new(); - let mut chars = value.chars(); - while let Some(ch) = chars.next() { - if ch != '\\' { - output.push(ch); - continue; - } - - match chars.next() { - Some('s') => output.push(' '), - Some('n') => output.push('\n'), - Some('t') => output.push('\t'), - Some('r') => output.push('\r'), - Some('\\') => output.push('\\'), - Some(other) => output.push(other), - None => output.push('\\'), - } - } - output -} - -fn bool_field(fields: &HashMap, key: &str) -> bool { - fields - .get(key) - .is_some_and(|value| value.eq_ignore_ascii_case("true")) -} - -fn parse_semicolon_list(value: &str) -> Vec { - value - .split(';') - .filter(|item| !item.is_empty()) - .map(str::to_string) - .collect() -} - -fn only_show_in_allows(value: Option<&String>) -> bool { - let Some(value) = value else { - return true; - }; - - parse_semicolon_list(value) - .iter() - .any(|desktop| desktop == hearthspace_common::CURRENT_DESKTOP) -} - -fn not_show_in_allows(value: Option<&String>) -> bool { - let Some(value) = value else { - return true; - }; - - !parse_semicolon_list(value) - .iter() - .any(|desktop| desktop == hearthspace_common::CURRENT_DESKTOP) -} - -fn try_exec_resolves(try_exec: &str) -> bool { - let path = Path::new(try_exec); - if path.is_absolute() || try_exec.contains('/') { - path.exists() - } else { - executable_in_path(try_exec) - } -} - -fn executable_in_path(name: &str) -> bool { - env::var_os("PATH").is_some_and(|paths| { - env::split_paths(&paths).any(|path| { - let candidate = path.join(name); - candidate.exists() && !candidate.is_dir() - }) - }) -} - -fn tokenize_search(query: &str) -> Vec { - query - .split_whitespace() - .map(str::to_lowercase) - .filter(|token| !token.is_empty()) - .collect() -} - -fn search_score(app: &DesktopApp, query_tokens: &[String]) -> Option { - let mut total = 0; - for token in query_tokens { - let score = token_score(app, token)?; - total += score; - } - Some(total) -} - -fn token_score(app: &DesktopApp, token: &str) -> Option { - let name = app.name.to_lowercase(); - if name == token { - return Some(1000); - } - if name.starts_with(token) { - return Some(900); - } - if name.split_whitespace().any(|word| word.starts_with(token)) { - return Some(800); - } - if app - .keywords - .iter() - .any(|keyword| keyword.to_lowercase().contains(token)) - { - return Some(700); - } - if name.contains(token) { - return Some(600); - } - if app - .generic_name - .as_ref() - .is_some_and(|value| value.to_lowercase().contains(token)) - || app - .comment - .as_ref() - .is_some_and(|value| value.to_lowercase().contains(token)) - { - return Some(500); - } - if app.id.to_lowercase().contains(token) - || app.exec.to_lowercase().contains(token) - || app - .categories - .iter() - .any(|category| category.to_lowercase().contains(token)) - { - return Some(400); - } - None -} - -fn parse_exec_argv(app: &DesktopApp) -> Result, String> { - let args = split_exec(&app.exec)?; - let mut expanded = Vec::new(); - for arg in args { - if let Some(arg) = expand_exec_field_codes(app, &arg)? { - expanded.push(arg); - } - } - Ok(expanded) -} - -fn split_exec(exec: &str) -> Result, String> { - let mut args = Vec::new(); - let mut current = String::new(); - let mut chars = exec.chars().peekable(); - let mut in_quotes = false; - - while let Some(ch) = chars.next() { - match ch { - '"' => in_quotes = !in_quotes, - '\\' => match chars.next() { - Some(next) => current.push(next), - None => current.push('\\'), - }, - ch if ch.is_whitespace() && !in_quotes => { - if !current.is_empty() { - args.push(std::mem::take(&mut current)); - } - } - _ => current.push(ch), - } - } - - if in_quotes { - return Err(format!("unterminated quote in Exec={exec}")); - } - if !current.is_empty() { - args.push(current); - } - Ok(args) -} - -fn expand_exec_field_codes(app: &DesktopApp, arg: &str) -> Result, String> { - let mut output = String::new(); - let mut chars = arg.chars(); - - while let Some(ch) = chars.next() { - if ch != '%' { - output.push(ch); - continue; - } - - let Some(code) = chars.next() else { - return Err(format!("dangling field code in {}", app.id)); - }; - match code { - 'f' | 'F' | 'u' | 'U' | 'i' => return Ok(None), - 'c' => output.push_str(&app.name), - 'k' => output.push_str(&app.path.to_string_lossy()), - '%' => output.push('%'), - other => return Err(format!("unsupported field code %{other} in {}", app.id)), - } - } - - Ok((!output.is_empty()).then_some(output)) -} - -fn terminal_preference_ids() -> Vec { - terminal_preference_files() - .into_iter() - .filter_map(|path| fs::read_to_string(path).ok()) - .flat_map(|contents| { - contents - .lines() - .filter_map(|line| { - let line = line.trim(); - if line.is_empty() - || line.starts_with('#') - || line.starts_with('/') - || line.starts_with('-') - || line.starts_with('+') - { - return None; - } - Some( - line.split_once(':') - .map_or(line, |(entry_id, _)| entry_id) - .to_string(), - ) - }) - .collect::>() - }) - .collect() -} - -fn terminal_preference_files() -> Vec { - let mut files = Vec::new(); - let config_dirs = xdg_config_dirs(); - for config_dir in config_dirs { - files.push(config_dir.join("hearthspace-xdg-terminals.list")); - files.push(config_dir.join("xdg-terminals.list")); - } - for data_dir in xdg_data_dirs().into_iter().skip(1) { - files.push(data_dir.join("xdg-terminal-exec/hearthspace-xdg-terminals.list")); - files.push(data_dir.join("xdg-terminal-exec/xdg-terminals.list")); - } - files -} - -fn xdg_config_dirs() -> Vec { - let mut dirs = Vec::new(); - if let Some(xdg_config_home) = env::var_os("XDG_CONFIG_HOME") { - dirs.push(PathBuf::from(xdg_config_home)); - } else if let Some(home) = env::var_os("HOME") { - dirs.push(PathBuf::from(home).join(".config")); - } - - let config_dirs = env::var_os("XDG_CONFIG_DIRS") - .map(|value| env::split_paths(&value).collect::>()) - .unwrap_or_else(|| vec![PathBuf::from("/etc/xdg")]); - dirs.extend(config_dirs); - dirs -} - -pub fn spawn_argv(argv: &[String]) -> Result<(), String> { - let wayland_display = hearthspace_common::wayland_display_name(); - spawn_argv_with_wayland_display(argv, &wayland_display) -} - -pub fn spawn_argv_with_wayland_display( - argv: &[String], - wayland_display: &str, -) -> Result<(), String> { - spawn_argv_with_env(argv, wayland_display, &[]) -} - -pub fn spawn_argv_with_env( - argv: &[String], - wayland_display: &str, - envs: &[(&str, &str)], -) -> Result<(), String> { - let Some((program, args)) = argv.split_first() else { - return Err("cannot spawn an empty command".to_string()); - }; - let mut command = Command::new(resolve_launch_program(program)); - command - .args(args) - .env("WAYLAND_DISPLAY", wayland_display) - .env_remove("DISPLAY") - .env_remove("DBUS_SESSION_BUS_ADDRESS"); - for key in session_environment_keys() { - if let Some(value) = env::var_os(key) { - command.env(key, value); - } - } - for (key, value) in envs { - command.env(key, value); - } - command - .spawn() - .map(|_| ()) - .map_err(|error| format!("failed to spawn {program}: {error}")) -} - -fn session_environment_keys() -> &'static [&'static str] { - &[ - "XDG_CURRENT_DESKTOP", - "DESKTOP_SESSION", - hearthspace_common::SHELL_COMMAND_SOCKET_ENV, - hearthspace_common::SETTINGS_SOCKET_ENV, - hearthspace_common::SESSION_SOCKET_ENV, - hearthspace_common::REPO_ROOT_ENV, - ] -} - -fn resolve_launch_program(program: &str) -> PathBuf { - let path = Path::new(program); - if program != SETTINGS_APP_BINARY || path.components().count() != 1 { - return path.to_path_buf(); - } - - env::current_exe() - .ok() - .and_then(|current_exe| current_exe.parent().map(|parent| parent.join(program))) - .filter(|candidate| candidate.exists()) - .unwrap_or_else(|| path.to_path_buf()) } #[cfg(test)] mod tests { use super::*; + use super::{ + desktop_entry::{desktop_entry_id, parse_semicolon_list, unescape_desktop_value}, + exec::split_exec, + paths::source_tree_data_dir, + search::token_score, + spawn::session_environment_keys, + }; use rstest::rstest; + use std::{fs, path::Path}; fn app_with(name: &str, contents: &str) -> DesktopApp { DesktopApp::from_desktop_entry( @@ -823,7 +310,6 @@ mod tests { fs::create_dir_all(&apps_dir).unwrap(); fs::write(apps_dir.join(name), contents).unwrap(); }; - write( first.path(), "editor.desktop", diff --git a/crates/hearthspace-shell/src/app_catalog/desktop_entry.rs b/crates/hearthspace-shell/src/app_catalog/desktop_entry.rs new file mode 100644 index 0000000..a8148a2 --- /dev/null +++ b/crates/hearthspace-shell/src/app_catalog/desktop_entry.rs @@ -0,0 +1,186 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use super::{DesktopApp, paths::executable_in_path}; + +impl DesktopApp { + pub(super) fn from_desktop_entry(id: String, path: PathBuf, contents: &str) -> Option { + let fields = parse_desktop_entry_fields(contents); + if fields.get("Type")? != "Application" { + return None; + } + if bool_field(&fields, "Hidden") || bool_field(&fields, "NoDisplay") { + return None; + } + if !only_show_in_allows(fields.get("OnlyShowIn")) { + return None; + } + if !not_show_in_allows(fields.get("NotShowIn")) { + return None; + } + if fields + .get("TryExec") + .is_some_and(|try_exec| !try_exec_resolves(try_exec)) + { + return None; + } + + Some(Self { + id, + name: fields.get("Name")?.clone(), + generic_name: fields.get("GenericName").cloned(), + comment: fields.get("Comment").cloned(), + keywords: fields + .get("Keywords") + .map(|value| parse_semicolon_list(value)) + .unwrap_or_default(), + exec: fields.get("Exec")?.clone(), + icon: fields.get("Icon").cloned(), + terminal: bool_field(&fields, "Terminal"), + path, + categories: fields + .get("Categories") + .map(|value| parse_semicolon_list(value)) + .unwrap_or_default(), + terminal_arg_exec: fields + .get("TerminalArgExec") + .or_else(|| fields.get("X-TerminalArgExec")) + .or_else(|| fields.get("X-ExecArg")) + .cloned(), + snap_instance_name: fields.get("X-SnapInstanceName").cloned(), + }) + } +} + +pub(super) fn desktop_files_under(dir: &Path) -> std::io::Result> { + let mut paths = Vec::new(); + collect_desktop_files(dir, &mut paths)?; + paths.sort(); + Ok(paths) +} + +fn collect_desktop_files(dir: &Path, paths: &mut Vec) -> std::io::Result<()> { + let Ok(entries) = fs::read_dir(dir) else { + return Ok(()); + }; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_desktop_files(&path, paths)?; + } else if path + .extension() + .is_some_and(|extension| extension == "desktop") + { + paths.push(path); + } + } + Ok(()) +} + +pub(super) fn desktop_entry_id(base: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(base).ok()?; + let id = relative + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("-"); + (!id.is_empty()).then_some(id) +} + +fn parse_desktop_entry_fields(contents: &str) -> HashMap { + let mut fields = HashMap::new(); + let mut in_desktop_entry = false; + + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + in_desktop_entry = line == "[Desktop Entry]"; + continue; + } + if !in_desktop_entry { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key.contains('[') { + continue; + } + fields.insert(key.to_string(), unescape_desktop_value(value)); + } + + fields +} + +pub(super) fn unescape_desktop_value(value: &str) -> String { + let mut output = String::new(); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + output.push(ch); + continue; + } + + match chars.next() { + Some('s') => output.push(' '), + Some('n') => output.push('\n'), + Some('t') => output.push('\t'), + Some('r') => output.push('\r'), + Some('\\') => output.push('\\'), + Some(other) => output.push(other), + None => output.push('\\'), + } + } + output +} + +fn bool_field(fields: &HashMap, key: &str) -> bool { + fields + .get(key) + .is_some_and(|value| value.eq_ignore_ascii_case("true")) +} + +pub(super) fn parse_semicolon_list(value: &str) -> Vec { + value + .split(';') + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + +fn only_show_in_allows(value: Option<&String>) -> bool { + let Some(value) = value else { + return true; + }; + + parse_semicolon_list(value) + .iter() + .any(|desktop| desktop == hearthspace_common::CURRENT_DESKTOP) +} + +fn not_show_in_allows(value: Option<&String>) -> bool { + let Some(value) = value else { + return true; + }; + + !parse_semicolon_list(value) + .iter() + .any(|desktop| desktop == hearthspace_common::CURRENT_DESKTOP) +} + +fn try_exec_resolves(try_exec: &str) -> bool { + let path = Path::new(try_exec); + if path.is_absolute() || try_exec.contains('/') { + path.exists() + } else { + executable_in_path(try_exec) + } +} diff --git a/crates/hearthspace-shell/src/app_catalog/exec.rs b/crates/hearthspace-shell/src/app_catalog/exec.rs new file mode 100644 index 0000000..14a5439 --- /dev/null +++ b/crates/hearthspace-shell/src/app_catalog/exec.rs @@ -0,0 +1,77 @@ +use super::DesktopApp; + +pub(super) fn launch_argv(app: &DesktopApp) -> Result, String> { + let argv = parse_exec_argv(app)?; + if argv.is_empty() { + Err(format!("desktop entry {} produced no command", app.id)) + } else { + Ok(argv) + } +} + +fn parse_exec_argv(app: &DesktopApp) -> Result, String> { + let args = split_exec(&app.exec)?; + let mut expanded = Vec::new(); + for arg in args { + if let Some(arg) = expand_exec_field_codes(app, &arg)? { + expanded.push(arg); + } + } + Ok(expanded) +} + +pub(super) fn split_exec(exec: &str) -> Result, String> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut chars = exec.chars().peekable(); + let mut in_quotes = false; + + while let Some(ch) = chars.next() { + match ch { + '"' => in_quotes = !in_quotes, + '\\' => match chars.next() { + Some(next) => current.push(next), + None => current.push('\\'), + }, + ch if ch.is_whitespace() && !in_quotes => { + if !current.is_empty() { + args.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } + } + + if in_quotes { + return Err(format!("unterminated quote in Exec={exec}")); + } + if !current.is_empty() { + args.push(current); + } + Ok(args) +} + +fn expand_exec_field_codes(app: &DesktopApp, arg: &str) -> Result, String> { + let mut output = String::new(); + let mut chars = arg.chars(); + + while let Some(ch) = chars.next() { + if ch != '%' { + output.push(ch); + continue; + } + + let Some(code) = chars.next() else { + return Err(format!("dangling field code in {}", app.id)); + }; + match code { + 'f' | 'F' | 'u' | 'U' | 'i' => return Ok(None), + 'c' => output.push_str(&app.name), + 'k' => output.push_str(&app.path.to_string_lossy()), + '%' => output.push('%'), + other => return Err(format!("unsupported field code %{other} in {}", app.id)), + } + } + + Ok((!output.is_empty()).then_some(output)) +} diff --git a/crates/hearthspace-shell/src/app_catalog/paths.rs b/crates/hearthspace-shell/src/app_catalog/paths.rs new file mode 100644 index 0000000..2afd2d7 --- /dev/null +++ b/crates/hearthspace-shell/src/app_catalog/paths.rs @@ -0,0 +1,50 @@ +use std::{env, path::PathBuf}; + +pub(super) fn xdg_data_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(xdg_data_home) = env::var_os("XDG_DATA_HOME") { + dirs.push(PathBuf::from(xdg_data_home)); + } else if let Some(home) = env::var_os("HOME") { + dirs.push(PathBuf::from(home).join(".local/share")); + } + + let data_dirs = env::var_os("XDG_DATA_DIRS") + .map(|value| env::split_paths(&value).collect::>()) + .unwrap_or_else(|| { + vec![ + PathBuf::from("/usr/local/share"), + PathBuf::from("/usr/share"), + ] + }); + dirs.extend(data_dirs); + dirs.push(source_tree_data_dir()); + dirs +} + +pub(super) fn source_tree_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data") +} + +pub(super) fn xdg_config_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(xdg_config_home) = env::var_os("XDG_CONFIG_HOME") { + dirs.push(PathBuf::from(xdg_config_home)); + } else if let Some(home) = env::var_os("HOME") { + dirs.push(PathBuf::from(home).join(".config")); + } + + let config_dirs = env::var_os("XDG_CONFIG_DIRS") + .map(|value| env::split_paths(&value).collect::>()) + .unwrap_or_else(|| vec![PathBuf::from("/etc/xdg")]); + dirs.extend(config_dirs); + dirs +} + +pub(super) fn executable_in_path(name: &str) -> bool { + env::var_os("PATH").is_some_and(|paths| { + env::split_paths(&paths).any(|path| { + let candidate = path.join(name); + candidate.exists() && !candidate.is_dir() + }) + }) +} diff --git a/crates/hearthspace-shell/src/app_catalog/search.rs b/crates/hearthspace-shell/src/app_catalog/search.rs new file mode 100644 index 0000000..e8ea75a --- /dev/null +++ b/crates/hearthspace-shell/src/app_catalog/search.rs @@ -0,0 +1,90 @@ +use super::DesktopApp; + +pub(super) fn search_apps(apps: &[DesktopApp], query: &str, limit: usize) -> Vec { + let query_tokens = tokenize_search(query); + if query_tokens.is_empty() { + return apps.iter().take(limit).cloned().collect(); + } + + let mut scored = apps + .iter() + .filter_map(|app| search_score(app, &query_tokens).map(|score| (score, app))) + .collect::>(); + scored.sort_by(|(left_score, left), (right_score, right)| { + right_score + .cmp(left_score) + .then(left.name.cmp(&right.name)) + .then(left.id.cmp(&right.id)) + }); + + if scored.is_empty() { + return apps.iter().take(limit).cloned().collect(); + } + + scored + .into_iter() + .take(limit) + .map(|(_, app)| app.clone()) + .collect() +} + +fn tokenize_search(query: &str) -> Vec { + query + .split_whitespace() + .map(str::to_lowercase) + .filter(|token| !token.is_empty()) + .collect() +} + +fn search_score(app: &DesktopApp, query_tokens: &[String]) -> Option { + let mut total = 0; + for token in query_tokens { + let score = token_score(app, token)?; + total += score; + } + Some(total) +} + +pub(super) fn token_score(app: &DesktopApp, token: &str) -> Option { + let name = app.name.to_lowercase(); + if name == token { + return Some(1000); + } + if name.starts_with(token) { + return Some(900); + } + if name.split_whitespace().any(|word| word.starts_with(token)) { + return Some(800); + } + if app + .keywords + .iter() + .any(|keyword| keyword.to_lowercase().contains(token)) + { + return Some(700); + } + if name.contains(token) { + return Some(600); + } + if app + .generic_name + .as_ref() + .is_some_and(|value| value.to_lowercase().contains(token)) + || app + .comment + .as_ref() + .is_some_and(|value| value.to_lowercase().contains(token)) + { + return Some(500); + } + if app.id.to_lowercase().contains(token) + || app.exec.to_lowercase().contains(token) + || app + .categories + .iter() + .any(|category| category.to_lowercase().contains(token)) + { + return Some(400); + } + None +} diff --git a/crates/hearthspace-shell/src/app_catalog/spawn.rs b/crates/hearthspace-shell/src/app_catalog/spawn.rs new file mode 100644 index 0000000..efaca5c --- /dev/null +++ b/crates/hearthspace-shell/src/app_catalog/spawn.rs @@ -0,0 +1,71 @@ +use std::{ + env, + path::{Path, PathBuf}, + process::Command, +}; + +const SETTINGS_APP_BINARY: &str = "hearthspace-settings"; + +pub fn spawn_argv(argv: &[String]) -> Result<(), String> { + let wayland_display = hearthspace_common::wayland_display_name(); + spawn_argv_with_wayland_display(argv, &wayland_display) +} + +pub fn spawn_argv_with_wayland_display( + argv: &[String], + wayland_display: &str, +) -> Result<(), String> { + spawn_argv_with_env(argv, wayland_display, &[]) +} + +pub fn spawn_argv_with_env( + argv: &[String], + wayland_display: &str, + envs: &[(&str, &str)], +) -> Result<(), String> { + let Some((program, args)) = argv.split_first() else { + return Err("cannot spawn an empty command".to_string()); + }; + let mut command = Command::new(resolve_launch_program(program)); + command + .args(args) + .env("WAYLAND_DISPLAY", wayland_display) + .env_remove("DISPLAY") + .env_remove("DBUS_SESSION_BUS_ADDRESS"); + for key in session_environment_keys() { + if let Some(value) = env::var_os(key) { + command.env(key, value); + } + } + for (key, value) in envs { + command.env(key, value); + } + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("failed to spawn {program}: {error}")) +} + +pub(super) fn session_environment_keys() -> &'static [&'static str] { + &[ + "XDG_CURRENT_DESKTOP", + "DESKTOP_SESSION", + hearthspace_common::SHELL_COMMAND_SOCKET_ENV, + hearthspace_common::SETTINGS_SOCKET_ENV, + hearthspace_common::SESSION_SOCKET_ENV, + hearthspace_common::REPO_ROOT_ENV, + ] +} + +fn resolve_launch_program(program: &str) -> PathBuf { + let path = Path::new(program); + if program != SETTINGS_APP_BINARY || path.components().count() != 1 { + return path.to_path_buf(); + } + + env::current_exe() + .ok() + .and_then(|current_exe| current_exe.parent().map(|parent| parent.join(program))) + .filter(|candidate| candidate.exists()) + .unwrap_or_else(|| path.to_path_buf()) +} diff --git a/crates/hearthspace-shell/src/app_catalog/terminal.rs b/crates/hearthspace-shell/src/app_catalog/terminal.rs new file mode 100644 index 0000000..20dfc9e --- /dev/null +++ b/crates/hearthspace-shell/src/app_catalog/terminal.rs @@ -0,0 +1,93 @@ +use std::{fs, path::PathBuf}; + +use super::{ + AppCatalog, DesktopApp, + paths::{executable_in_path, xdg_config_dirs, xdg_data_dirs}, +}; + +pub(super) fn terminal_command_for( + catalog: &AppCatalog, + command: Vec, +) -> Result, String> { + if command.is_empty() { + return Err("cannot wrap an empty terminal command".to_string()); + } + + if executable_in_path("xdg-terminal-exec") { + let mut terminal_command = vec!["xdg-terminal-exec".to_string()]; + terminal_command.extend(command); + return Ok(terminal_command); + } + + let terminal = catalog + .preferred_terminal() + .ok_or_else(|| "no terminal emulator found".to_string())?; + let mut terminal_command = terminal.launch_argv()?; + match terminal.terminal_arg_exec.as_deref() { + Some("") => {} + Some(arg) => terminal_command.push(arg.to_string()), + None => terminal_command.push("-e".to_string()), + } + terminal_command.extend(command); + Ok(terminal_command) +} + +pub(super) fn preferred_terminal(catalog: &AppCatalog) -> Option<&DesktopApp> { + for id in terminal_preference_ids() { + if let Some(app) = catalog.app_by_id(&id).filter(|app| { + app.categories + .iter() + .any(|category| category == "TerminalEmulator") + }) { + return Some(app); + } + } + + catalog.apps.iter().find(|app| { + app.categories + .iter() + .any(|category| category == "TerminalEmulator") + }) +} + +fn terminal_preference_ids() -> Vec { + terminal_preference_files() + .into_iter() + .filter_map(|path| fs::read_to_string(path).ok()) + .flat_map(|contents| { + contents + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() + || line.starts_with('#') + || line.starts_with('/') + || line.starts_with('-') + || line.starts_with('+') + { + return None; + } + Some( + line.split_once(':') + .map_or(line, |(entry_id, _)| entry_id) + .to_string(), + ) + }) + .collect::>() + }) + .collect() +} + +fn terminal_preference_files() -> Vec { + let mut files = Vec::new(); + let config_dirs = xdg_config_dirs(); + for config_dir in config_dirs { + files.push(config_dir.join("hearthspace-xdg-terminals.list")); + files.push(config_dir.join("xdg-terminals.list")); + } + for data_dir in xdg_data_dirs().into_iter().skip(1) { + files.push(data_dir.join("xdg-terminal-exec/hearthspace-xdg-terminals.list")); + files.push(data_dir.join("xdg-terminal-exec/xdg-terminals.list")); + } + files +}