From 6060257ffe7545d867ab08a4a513ec7aa6764996 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 24 Sep 2026 11:27:33 +0100 Subject: [PATCH] feat: float transient windows with their parent --- compositor/src/handlers/xdg_shell.rs | 16 ++++++- compositor/src/state.rs | 71 ++++++++++++++++++++++++++++ crates/ferese-core/src/workspace.rs | 59 +++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 09c9e95..7ede5c6 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -34,7 +34,21 @@ impl XdgShellHandler for Ferese { } fn new_toplevel(&mut self, surface: ToplevelSurface) { - self.add_tiled_window(Window::new_wayland_window(surface)); + let parent = surface.parent().and_then(|parent| { + self.window_ids.iter().find_map(|(window, id)| { + window + .toplevel() + .is_some_and(|toplevel| toplevel.wl_surface() == &parent) + .then_some(*id) + }) + }); + let window = Window::new_wayland_window(surface); + + if let Some(parent) = parent { + self.add_transient_window(window, parent); + } else { + self.add_tiled_window(window); + } } fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 1aa3e8f..79cbc05 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -195,6 +195,33 @@ impl Ferese { self.relayout(); } + pub fn add_transient_window(&mut self, window: Window, parent: WindowId) { + let Some(workspace) = self.workspaces.workspace_for_window(parent) else { + self.add_tiled_window(window); + return; + }; + let Some(bounds) = self.output_bounds() else { + self.add_tiled_window(window); + return; + }; + let parent_rect = self.logical_window_rect(parent, bounds).unwrap_or(bounds); + let rect = centered_transient_rect(parent_rect); + let id = WindowId(self.next_window_id); + self.next_window_id += 1; + + if let Err(error) = self + .workspaces + .insert_floating_window(id, workspace, rect, false) + { + tracing::error!(%error, ?id, ?parent, "failed to insert transient window"); + return; + } + + self.window_ids.insert(window.clone(), id); + self.space.map_element(window, (0, 0), false); + self.relayout(); + } + pub fn remove_tiled_window(&mut self, window: &Window) { let Some(id) = self.window_ids.remove(window) else { return; @@ -538,6 +565,25 @@ impl Ferese { geometry.size.h as f64, )) } + + fn logical_window_rect(&self, window: WindowId, bounds: Rect) -> Option { + let workspace = self.workspaces.workspace_for_window(window)?; + let workspace = self.workspaces.workspace(workspace)?; + + if workspace.fullscreen == Some(window) { + return Some(bounds); + } + + match self.workspaces.placement(window)? { + WindowPlacement::Tiled => workspace + .layout + .geometry_with_gaps(bounds, GapConfig::default()) + .ok()? + .get(&window) + .copied(), + WindowPlacement::Floating { rect } => Some(rect), + } + } } fn centered_floating_rect(bounds: Rect) -> Rect { @@ -552,6 +598,18 @@ fn centered_floating_rect(bounds: Rect) -> Rect { ) } +fn centered_transient_rect(parent: Rect) -> Rect { + let width = (parent.width * 0.75).clamp(1.0, 640.0); + let height = (parent.height * 0.75).clamp(1.0, 480.0); + + Rect::new( + parent.x + (parent.width - width) / 2.0, + parent.y + (parent.height - height) / 2.0, + width, + height, + ) +} + #[derive(Default)] pub struct ClientState { pub compositor_state: CompositorClientState, @@ -561,3 +619,16 @@ impl ClientData for ClientState { fn initialized(&self, _client_id: ClientId) {} fn disconnected(&self, _client_id: ClientId, _reason: DisconnectReason) {} } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transient_geometry_is_centered_and_bounded_by_its_parent() { + let parent = Rect::new(100.0, 50.0, 1_000.0, 800.0); + let transient = centered_transient_rect(parent); + + assert_eq!(transient, Rect::new(280.0, 210.0, 640.0, 480.0)); + } +} diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 1908097..eea585f 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -232,6 +232,37 @@ impl WorkspaceSet { Ok(()) } + pub fn insert_floating_window( + &mut self, + window: WindowId, + workspace_id: WorkspaceId, + rect: Rect, + focus: bool, + ) -> Result<(), WorkspaceError> { + if self.window_workspaces.contains_key(&window) { + return Err(WorkspaceError::DuplicateWindow(window)); + } + + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(WorkspaceError::UnknownWorkspace(workspace_id))?; + workspace.floating.push(window); + if focus && workspace.fullscreen.is_none() { + workspace.last_focused = Some(window); + } + self.window_workspaces.insert(window, workspace_id); + self.placements.insert( + window, + WindowPlacement::Floating { + rect: normalized_floating_rect(rect), + }, + ); + + debug_assert!(self.validate().is_ok()); + Ok(()) + } + pub fn remove_window(&mut self, window: WindowId) -> Result<(), WorkspaceError> { let workspace_id = self .window_workspaces @@ -675,6 +706,34 @@ mod tests { assert!(workspaces.validate().is_ok()); } + #[test] + fn floating_window_can_follow_its_parent_workspace_without_focus() { + let mut workspaces = WorkspaceSet::default(); + workspaces + .insert_window(WindowId(1), Axis::Horizontal, 0.5) + .unwrap(); + let parent_workspace = workspaces.active_id(); + workspaces.switch_to_numeric(2).unwrap(); + workspaces + .insert_floating_window( + WindowId(2), + parent_workspace, + Rect::new(20.0, 30.0, 400.0, 300.0), + false, + ) + .unwrap(); + + assert_eq!( + workspaces.workspace_for_window(WindowId(2)), + Some(parent_workspace) + ); + assert_eq!( + workspaces.workspace(parent_workspace).unwrap().last_focused, + Some(WindowId(1)) + ); + assert!(workspaces.validate().is_ok()); + } + #[test] fn fullscreen_preserves_underlying_placement() { let mut workspaces = WorkspaceSet::default();