From 5d779fe1d7ffbb0dd4a9dbc4c2c25f9225f14bb5 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 05:35:11 -0700 Subject: [PATCH] chore: initial work on udev support for monitors --- Cargo.lock | 1 + crates/hearthspace-compositor/Cargo.toml | 1 + .../src/compositor/mod.rs | 6 +- .../src/compositor/output.rs | 278 +++++++++++++++++- .../src/compositor/udev.rs | 24 ++ .../src/compositor/udev/device.rs | 106 ++++--- 6 files changed, 352 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb16755..3582fc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2272,6 +2272,7 @@ dependencies = [ "fontique", "gtk4", "hearthspace-common", + "hearthspace-ipc", "hearthspace-shell", "image", "masonry", diff --git a/crates/hearthspace-compositor/Cargo.toml b/crates/hearthspace-compositor/Cargo.toml index 925ea10..4ddb23d 100644 --- a/crates/hearthspace-compositor/Cargo.toml +++ b/crates/hearthspace-compositor/Cargo.toml @@ -32,6 +32,7 @@ atspi = { version = "0.30.0", features = ["zbus"] } calloop = { version = "0.14.4", features = ["signals"] } gtk = { package = "gtk4", version = "0.11", optional = true } hearthspace-common = { path = "../hearthspace-common" } +hearthspace-ipc = { path = "../hearthspace-ipc" } hearthspace-shell = { path = "../hearthspace-shell" } # Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our # linebender/xilem fork branch backing PR linebender/xilem#1830, which adds diff --git a/crates/hearthspace-compositor/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs index 2889bb3..cd939f7 100644 --- a/crates/hearthspace-compositor/src/compositor/mod.rs +++ b/crates/hearthspace-compositor/src/compositor/mod.rs @@ -27,7 +27,9 @@ use idle::{IdleTransition, WindowIdleDaemon}; pub(in crate::compositor) use input::handle_input_event; #[cfg(feature = "udev")] pub(in crate::compositor) use output::{OutputDescriptor, create_output_with_properties}; -pub(in crate::compositor) use output::{OutputRecord, OutputSet, create_output}; +pub(in crate::compositor) use output::{ + OutputRecord, OutputSet, create_output, load_output_layout, +}; #[cfg(feature = "udev")] pub(in crate::compositor) use runtime::create_calloop_data; pub(in crate::compositor) use runtime::{Backend, CalloopData, HeadlessBackend, run_event_loop}; @@ -451,7 +453,7 @@ pub(in crate::compositor) fn initialize_app( raw_pointer_location: (0.0, 0.0).into(), active_touch_slot: None, scroll_zooms_without_super: options.scroll_zooms_without_super, - outputs: OutputSet::new(primary_output), + outputs: OutputSet::new(primary_output, load_output_layout()), app_catalog, needs_redraw: true, dmabuf_state: dmabuf.state, diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index a525533..cd7d365 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -7,9 +7,15 @@ 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, +} + pub(in crate::compositor) struct OutputSet { primary: OutputRecord, secondary: Vec, + layout: OutputLayout, } pub(in crate::compositor) struct OutputRecord { @@ -40,22 +46,41 @@ pub(in crate::compositor) struct OutputDescriptor { } impl OutputSet { - pub(in crate::compositor) fn new(primary: OutputRecord) -> Self { - Self { + pub(in crate::compositor) fn new(primary: OutputRecord, layout: OutputLayout) -> Self { + let mut outputs = Self { primary, secondary: Vec::new(), + layout, + }; + outputs.apply_layout_to_existing_outputs(); + outputs + } + + #[cfg(feature = "udev")] + pub(in crate::compositor) fn set_layout(&mut self, layout: OutputLayout) -> bool { + if self.layout == layout { + return false; } + + self.layout = layout; + self.apply_layout_to_existing_outputs(); + true } #[cfg(feature = "udev")] fn sync_secondary_outputs(&mut self, dh: &DisplayHandle, descriptors: Vec) { - let mut next_x = self.primary.size.to_logical(self.primary.scale).w; let mut existing = std::mem::take(&mut self.secondary); let descriptors = secondary_descriptors_for_layout(descriptors, &self.primary.name); + let plan = output_locations_for_layout( + OutputGeometry::from(&self.primary), + descriptors.iter().map(OutputGeometry::from).collect(), + &self.layout, + ); + self.primary.set_location(plan.primary); let mut next_secondary = Vec::with_capacity(descriptors.len()); for descriptor in descriptors { - let location = (next_x, 0).into(); + let location = plan.secondary_location(&descriptor.name); let output = if let Some(index) = existing .iter() .position(|output| output.name == descriptor.name) @@ -68,7 +93,6 @@ impl OutputSet { tracing::info!(name = %output.name, ?location, "advertising newly connected Wayland output"); output }; - next_x += output.size.to_logical(output.scale).w; next_secondary.push(output); } @@ -80,6 +104,18 @@ impl OutputSet { self.secondary = next_secondary; } + fn apply_layout_to_existing_outputs(&mut self) { + let plan = output_locations_for_layout( + OutputGeometry::from(&self.primary), + self.secondary.iter().map(OutputGeometry::from).collect(), + &self.layout, + ); + self.primary.set_location(plan.primary); + for output in &mut self.secondary { + output.set_location(plan.secondary_location(&output.name)); + } + } + fn logical_size(&self) -> Size { let primary_size = self.primary.logical_size(); let mut width = primary_size.w; @@ -128,6 +164,169 @@ impl OutputRecord { self.location, ); } + + fn set_location(&mut self, location: Point) { + if self.location == location { + return; + } + + self.location = location; + update_output_mode_with_refresh_at( + &self.output, + self.size, + self.scale, + self.refresh, + self.location, + ); + } +} + +impl OutputLayout { + fn from_monitors(monitors: Vec) -> Self { + Self { monitors } + } + + fn location_for(&self, output_name: &str, role_id: &str) -> Option> { + self.monitors + .iter() + .find(|monitor| monitor.id == output_name || monitor.name == output_name) + .or_else(|| self.monitors.iter().find(|monitor| monitor.id == role_id)) + .map(|monitor| Point::from((monitor.x, monitor.y))) + } +} + +pub(in crate::compositor) fn load_output_layout() -> OutputLayout { + match hearthspace_ipc::client_get_settings() { + Ok(settings) => OutputLayout::from_monitors(settings.monitors), + Err(error) => { + debug!(%error, "using fallback output layout because settingsd is unavailable"); + OutputLayout::default() + } + } +} + +#[derive(Clone)] +struct OutputGeometry { + name: String, + size: Size, + scale: i32, +} + +impl OutputGeometry { + fn logical_size(&self) -> Size { + self.size.to_logical(self.scale) + } +} + +impl From<&OutputRecord> for OutputGeometry { + fn from(output: &OutputRecord) -> Self { + Self { + name: output.name.clone(), + size: output.size, + scale: output.scale, + } + } +} + +impl From<&OutputDescriptor> for OutputGeometry { + fn from(descriptor: &OutputDescriptor) -> Self { + Self { + name: descriptor.name.clone(), + size: descriptor.size, + scale: descriptor.scale, + } + } +} + +struct OutputLayoutPlan { + primary: Point, + secondary: Vec<(String, Point)>, +} + +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 output_locations_for_layout( + primary: OutputGeometry, + mut secondary: Vec, + layout: &OutputLayout, +) -> OutputLayoutPlan { + secondary.sort_by(|a, b| a.name.cmp(&b.name)); + + let primary_location = layout + .location_for(&primary.name, "primary") + .unwrap_or((0, 0).into()); + let mut placed = vec![( + primary.name.clone(), + primary.logical_size(), + primary_location, + )]; + 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((output.name, size, location)); + } else { + unplaced.push(output); + } + } + + let fallback_y = primary_location.y; + let mut next_x = placed + .iter() + .map(|(_, size, location)| location.x + 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((output.name, size, location)); + } + + let min_x = placed + .iter() + .map(|(_, _, location)| location.x) + .min() + .unwrap_or(0); + let min_y = placed + .iter() + .map(|(_, _, location)| location.y) + .min() + .unwrap_or(0); + + let mut primary_plan = Point::from((0, 0)); + let mut secondary_plan = Vec::new(); + for (name, _, location) in placed { + let normalized = Point::from((location.x - min_x, location.y - min_y)); + if name == primary.name { + primary_plan = normalized; + } else { + secondary_plan.push((name, normalized)); + } + } + + OutputLayoutPlan { + primary: primary_plan, + secondary: secondary_plan, + } +} + +fn secondary_role_id(index: usize) -> String { + if index == 0 { + "secondary".to_string() + } else { + format!("secondary-{}", index + 1) + } } impl App { @@ -139,6 +338,11 @@ impl App { self.outputs.logical_size() } + #[cfg(feature = "udev")] + pub(in crate::compositor) fn set_output_layout(&mut self, layout: OutputLayout) -> bool { + self.outputs.set_layout(layout) + } + #[cfg(feature = "udev")] pub(in crate::compositor) fn output_render_view(&self, name: &str) -> Option { if self.outputs.primary.name == name { @@ -313,10 +517,13 @@ fn update_output_mode_with_refresh_at( mod tests { use smithay::{ output::{PhysicalProperties, Subpixel}, - utils::{Physical, Size}, + utils::{Physical, Point, Size}, }; - use super::{OutputDescriptor, secondary_descriptors_for_layout}; + use super::{ + OutputDescriptor, OutputGeometry, OutputLayout, output_locations_for_layout, + secondary_descriptors_for_layout, + }; fn descriptor(name: &str) -> OutputDescriptor { OutputDescriptor { @@ -333,6 +540,26 @@ mod tests { } } + fn geometry(name: &str) -> OutputGeometry { + OutputGeometry::from(&descriptor(name)) + } + + 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, + x: *x, + y: *y, + }) + .collect(), + ) + } + #[test] fn secondary_layout_skips_selected_primary_and_sorts_by_connector_name() { let descriptors = vec![ @@ -365,4 +592,41 @@ mod tests { 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_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/udev.rs b/crates/hearthspace-compositor/src/compositor/udev.rs index ec28836..d6e47d1 100644 --- a/crates/hearthspace-compositor/src/compositor/udev.rs +++ b/crates/hearthspace-compositor/src/compositor/udev.rs @@ -9,6 +9,7 @@ use device::{ }; use input::log_input_event; +use calloop::timer::{TimeoutAction, Timer}; use smithay::{ backend::{ allocator::Buffer, @@ -32,6 +33,8 @@ use crate::{ config::{HEADLESS_OUTPUT_HEIGHT, HEADLESS_OUTPUT_WIDTH}, }; +const SETTINGS_LAYOUT_REFRESH_INTERVAL: Duration = Duration::from_millis(500); + pub(super) struct UdevBackendState { session: LibSeatSession, seat_name: String, @@ -150,6 +153,7 @@ pub fn run_udev(options: RunOptions) -> Result<(), Box> { .unwrap_or_default(); app.sync_connector_outputs(&dh, output_descriptors); app.enable_software_cursor(); + insert_settings_layout_refresh(&handle)?; handle.insert_source(session_notifier, |event, _, data| match event { SessionEvent::PauseSession => { @@ -177,6 +181,7 @@ pub fn run_udev(options: RunOptions) -> Result<(), Box> { }; let dh = data.display.handle(); data.state.sync_connector_outputs(&dh, output_descriptors); + data.state.configure_shell_bars(); data.state.reconcile_pointer_after_output_geometry_change(); data.full_redraw = data.full_redraw.max(1); data.state.request_redraw(); @@ -199,6 +204,7 @@ pub fn run_udev(options: RunOptions) -> Result<(), Box> { }; let dh = data.display.handle(); data.state.sync_connector_outputs(&dh, output_descriptors); + data.state.configure_shell_bars(); data.state.reconcile_pointer_after_output_geometry_change(); data.full_redraw = data.full_redraw.max(1); data.state.request_redraw(); @@ -275,6 +281,23 @@ pub fn run_udev(options: RunOptions) -> Result<(), Box> { super::run_event_loop(event_loop, &mut data) } +fn insert_settings_layout_refresh( + handle: &LoopHandle<'static, super::CalloopData>, +) -> Result<(), Box> { + let timer = Timer::from_duration(SETTINGS_LAYOUT_REFRESH_INTERVAL); + handle.insert_source(timer, |_, _, data| { + if data.state.set_output_layout(super::load_output_layout()) { + data.state.configure_shell_bars(); + data.state.reconcile_pointer_after_output_geometry_change(); + data.full_redraw = data.full_redraw.max(1); + data.state.request_redraw(); + debug!("applied updated monitor layout settings"); + } + TimeoutAction::ToDuration(SETTINGS_LAYOUT_REFRESH_INTERVAL) + })?; + Ok(()) +} + fn udev_backend_mut(data: &mut super::CalloopData) -> Option<&mut UdevBackendState> { match &mut data.backend { super::Backend::Udev(backend) => Some(backend), @@ -296,6 +319,7 @@ fn apply_connector_sync(data: &mut super::CalloopData, connector_sync: Connector } data.state .sync_connector_outputs(&dh, connector_sync.descriptors); + data.state.configure_shell_bars(); data.state.reconcile_pointer_after_output_geometry_change(); data.full_redraw = data.full_redraw.max(1); } diff --git a/crates/hearthspace-compositor/src/compositor/udev/device.rs b/crates/hearthspace-compositor/src/compositor/udev/device.rs index 3419a5a..995562e 100644 --- a/crates/hearthspace-compositor/src/compositor/udev/device.rs +++ b/crates/hearthspace-compositor/src/compositor/udev/device.rs @@ -136,63 +136,59 @@ impl UdevDevice { mode_count = info.modes().len(), "DRM connector queried" ); - if connected { - if let Some(mode) = info.modes().first() { - debug!(connector = ?connector_handle, ?mode, "selected preferred/first DRM mode"); - let current_crtc = info - .current_encoder() - .and_then(|encoder| self.scanout_node.drm_device.get_encoder(encoder).ok()) - .and_then(|encoder| encoder.crtc()); - let crtc_candidates = info - .encoders() - .iter() - .filter_map(|encoder| { - self.scanout_node.drm_device.get_encoder(*encoder).ok().map( - |encoder_info| { - resources.filter_crtcs(encoder_info.possible_crtcs()) - }, + if connected && let Some(mode) = info.modes().first() { + debug!(connector = ?connector_handle, ?mode, "selected preferred/first DRM mode"); + let current_crtc = info + .current_encoder() + .and_then(|encoder| self.scanout_node.drm_device.get_encoder(encoder).ok()) + .and_then(|encoder| encoder.crtc()); + let crtc_candidates = info + .encoders() + .iter() + .filter_map(|encoder| { + self.scanout_node.drm_device.get_encoder(*encoder).ok().map( + |encoder_info| resources.filter_crtcs(encoder_info.possible_crtcs()), + ) + }) + .flatten() + .collect::>(); + let crtc = current_crtc + .filter(|crtc| !used_crtcs.contains(crtc)) + .or_else(|| { + crtc_candidates + .iter() + .copied() + .find(|crtc| !used_crtcs.contains(crtc)) + }); + debug!( + connector = ?connector_handle, + current_crtc = ?current_crtc, + crtcs = ?crtc_candidates, + selected_crtc = ?crtc, + "DRM CRTC candidates" + ); + if let Some(crtc) = crtc { + used_crtcs.push(crtc); + let physical_size_mm = info + .size() + .map(|(width, height)| { + ( + i32::try_from(width).unwrap_or(0), + i32::try_from(height).unwrap_or(0), ) }) - .flatten() - .collect::>(); - let crtc = current_crtc - .filter(|crtc| !used_crtcs.contains(crtc)) - .or_else(|| { - crtc_candidates - .iter() - .copied() - .find(|crtc| !used_crtcs.contains(crtc)) - }); - debug!( - connector = ?connector_handle, - current_crtc = ?current_crtc, - crtcs = ?crtc_candidates, - selected_crtc = ?crtc, - "DRM CRTC candidates" - ); - if let Some(crtc) = crtc { - used_crtcs.push(crtc); - let physical_size_mm = info - .size() - .map(|(width, height)| { - ( - i32::try_from(width).unwrap_or(0), - i32::try_from(height).unwrap_or(0), - ) - }) - .unwrap_or((0, 0)); - targets.push(KmsOutputTarget { - connector: *connector_handle, - crtc, - mode: *mode, - connector_name: format!( - "{}-{}", - info.interface().as_str(), - info.interface_id() - ), - physical_size_mm, - }); - } + .unwrap_or((0, 0)); + targets.push(KmsOutputTarget { + connector: *connector_handle, + crtc, + mode: *mode, + connector_name: format!( + "{}-{}", + info.interface().as_str(), + info.interface_id() + ), + physical_size_mm, + }); } } } -- 2.54.0