From a331355c8d771e8cc0722df4b38cadb353e911c4 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 24 Sep 2026 11:33:43 +0100 Subject: [PATCH] feat: honor tiled window size constraints --- compositor/src/state.rs | 74 ++++-- crates/ferese-layout/src/lib.rs | 458 +++++++++++++++++++++++++++++--- 2 files changed, 480 insertions(+), 52 deletions(-) diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 00d7914..7e0a67f 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -7,7 +7,7 @@ use std::{ }; use ferese_core::{WindowPlacement, WorkspaceSet}; -use ferese_layout::{Axis, Direction, GapConfig, Rect, WindowId}; +use ferese_layout::{Axis, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, WindowId}; use smithay::{ desktop::{PopupManager, Space, Window, WindowSurfaceType}, @@ -23,15 +23,15 @@ use smithay::{ }, utils::{Logical, Point, Size}, wayland::{ - compositor::{CompositorClientState, CompositorState}, + compositor::{CompositorClientState, CompositorState, with_states}, fractional_scale::FractionalScaleManagerState, output::OutputManagerState, pointer_constraints::PointerConstraintsState, presentation::PresentationState, relative_pointer::RelativePointerManagerState, selection::{data_device::DataDeviceState, primary_selection::PrimarySelectionState}, - shell::xdg::XdgShellState, shell::xdg::decoration::XdgDecorationState, + shell::xdg::{SurfaceCachedState, XdgShellState}, shm::ShmState, socket::ListeningSocketSource, viewporter::ViewporterState, @@ -242,18 +242,24 @@ impl Ferese { let Some(bounds) = self.output_bounds() else { return; }; - let tiled_geometry = match self - .workspaces - .active() - .layout - .geometry_with_gaps(bounds, GapConfig::default()) - { - Ok(geometry) => geometry, + let layout = match self.tiled_layout(bounds) { + Ok(layout) => layout, Err(error) => { tracing::error!(%error, "failed to compute tiled geometry"); return; } }; + let tiled_geometry = layout.geometry; + + for warning in layout.warnings { + tracing::warn!( + window = ?warning.window, + kind = ?warning.kind, + requested = warning.requested, + assigned = warning.assigned, + "window size constraint could not be satisfied exactly" + ); + } let active = self.workspaces.active_id(); let fullscreen = self.workspaces.active().fullscreen; let hidden = self @@ -419,12 +425,9 @@ impl Ferese { }; let floating_rect = match self.workspaces.placement(window) { Some(WindowPlacement::Tiled) => self - .workspaces - .active() - .layout - .geometry_with_gaps(bounds, GapConfig::default()) + .tiled_layout(bounds) .ok() - .and_then(|geometry| geometry.get(&window).copied()) + .and_then(|layout| layout.geometry.get(&window).copied()) .unwrap_or_else(|| centered_floating_rect(bounds)), Some(WindowPlacement::Floating { rect }) => rect, None => return, @@ -580,13 +583,52 @@ impl Ferese { match self.workspaces.placement(window)? { WindowPlacement::Tiled => workspace .layout - .geometry_with_gaps(bounds, GapConfig::default()) + .geometry_with_constraints( + bounds, + GapConfig::default(), + &self.window_constraints(), + self.focused_window, + ) .ok()? + .geometry .get(&window) .copied(), WindowPlacement::Floating { rect } => Some(rect), } } + + fn tiled_layout(&self, bounds: Rect) -> Result { + self.workspaces.active().layout.geometry_with_constraints( + bounds, + GapConfig::default(), + &self.window_constraints(), + self.focused_window, + ) + } + + fn window_constraints(&self) -> HashMap { + self.window_ids + .iter() + .filter_map(|(window, id)| { + let toplevel = window.toplevel()?; + let (minimum, maximum) = with_states(toplevel.wl_surface(), |states| { + let mut cached = states.cached_state.get::(); + let state = cached.current(); + (state.min_size, state.max_size) + }); + + Some(( + *id, + SizeConstraints { + min_width: minimum.w.max(1) as f64, + min_height: minimum.h.max(1) as f64, + max_width: (maximum.w > 0).then_some(maximum.w as f64), + max_height: (maximum.h > 0).then_some(maximum.h as f64), + }, + )) + }) + .collect() + } } fn centered_floating_rect(bounds: Rect) -> Rect { diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index 8e39549..350147a 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -70,6 +70,47 @@ pub struct GapConfig { pub smart: bool, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SizeConstraints { + pub min_width: f64, + pub min_height: f64, + pub max_width: Option, + pub max_height: Option, +} + +impl Default for SizeConstraints { + fn default() -> Self { + Self { + min_width: 1.0, + min_height: 1.0, + max_width: None, + max_height: None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConstraintKind { + MinimumWidth, + MinimumHeight, + MaximumWidth, + MaximumHeight, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ConstraintWarning { + pub window: WindowId, + pub kind: ConstraintKind, + pub requested: f64, + pub assigned: f64, +} + +#[derive(Debug, Default, PartialEq)] +pub struct LayoutResult { + pub geometry: HashMap, + pub warnings: Vec, +} + impl Default for GapConfig { fn default() -> Self { Self { @@ -314,46 +355,69 @@ impl LayoutTree { gaps: GapConfig, ) -> Result, LayoutError> { let mut geometry = self.geometry(bounds)?; - let outer = if gaps.smart && geometry.len() == 1 { - 0.0 - } else { - valid_gap(gaps.outer) - }; - let half_inner = valid_gap(gaps.inner) / 2.0; - let right = bounds.x + bounds.width; - let bottom = bounds.y + bounds.height; - - for rect in geometry.values_mut() { - let left_inset = if nearly_equal(rect.x, bounds.x) { - outer - } else { - half_inner - }; - let top_inset = if nearly_equal(rect.y, bounds.y) { - outer - } else { - half_inner - }; - let right_inset = if nearly_equal(rect.x + rect.width, right) { - outer - } else { - half_inner - }; - let bottom_inset = if nearly_equal(rect.y + rect.height, bottom) { - outer - } else { - half_inner - }; - - rect.x += left_inset; - rect.y += top_inset; - rect.width = (rect.width - left_inset - right_inset).max(1.0); - rect.height = (rect.height - top_inset - bottom_inset).max(1.0); - } + apply_gaps(&mut geometry, bounds, gaps); Ok(geometry) } + pub fn geometry_with_constraints( + &self, + bounds: Rect, + gaps: GapConfig, + constraints: &HashMap, + focused: Option, + ) -> Result { + let mut result = LayoutResult::default(); + + if let Some(root) = self.root { + self.layout_node_constrained(root, bounds, constraints, focused, &mut result.geometry)?; + } + + apply_gaps(&mut result.geometry, bounds, gaps); + + for (window, rect) in &mut result.geometry { + let constraint = constraints.get(window).copied().unwrap_or_default(); + let constraint = normalized_constraints(constraint); + + record_minimum_warnings(*window, *rect, constraint, &mut result.warnings); + + if let Some(maximum) = constraint.max_width + && rect.width > maximum + { + result.warnings.push(ConstraintWarning { + window: *window, + kind: ConstraintKind::MaximumWidth, + requested: maximum, + assigned: rect.width, + }); + rect.width = maximum; + } + if let Some(maximum) = constraint.max_height + && rect.height > maximum + { + result.warnings.push(ConstraintWarning { + window: *window, + kind: ConstraintKind::MaximumHeight, + requested: maximum, + assigned: rect.height, + }); + rect.height = maximum; + } + } + + result.warnings.sort_by_key(|warning| { + let kind = match warning.kind { + ConstraintKind::MinimumWidth => 0, + ConstraintKind::MinimumHeight => 1, + ConstraintKind::MaximumWidth => 2, + ConstraintKind::MaximumHeight => 3, + }; + (warning.window.0, kind) + }); + + Ok(result) + } + pub fn automatic_axis( &self, focused: Option, @@ -665,6 +729,128 @@ impl LayoutTree { Ok(()) } + fn layout_node_constrained( + &self, + node: NodeId, + bounds: Rect, + constraints: &HashMap, + focused: Option, + geometry: &mut HashMap, + ) -> Result<(), LayoutError> { + match self.nodes.get(&node) { + Some(Node::Window(window)) => { + geometry.insert(*window, bounds); + } + Some(Node::Split { + axis, + ratio, + first, + second, + }) => { + let first_minimum = self.minimum_size(*first, constraints)?; + let second_minimum = self.minimum_size(*second, constraints)?; + let available = match axis { + Axis::Horizontal => bounds.width, + Axis::Vertical => bounds.height, + } + .max(0.0); + let first_required = match axis { + Axis::Horizontal => first_minimum.0, + Axis::Vertical => first_minimum.1, + }; + let second_required = match axis { + Axis::Horizontal => second_minimum.0, + Axis::Vertical => second_minimum.1, + }; + let desired = available * ratio; + let first_extent = if first_required + second_required <= available { + desired.clamp(first_required, available - second_required) + } else if focused.is_some_and(|window| self.contains_window(*first, window)) { + first_required.min(available) + } else { + (available - second_required).max(0.0) + }; + let adjusted_ratio = if available > 0.0 { + first_extent / available + } else { + 0.5 + }; + let (first_bounds, second_bounds) = split_rect(bounds, *axis, adjusted_ratio); + + self.layout_node_constrained(*first, first_bounds, constraints, focused, geometry)?; + self.layout_node_constrained( + *second, + second_bounds, + constraints, + focused, + geometry, + )?; + } + Some(Node::Stack { children, active }) => { + let child = children + .get(*active) + .ok_or(LayoutError::InvalidTree("stack active index is invalid"))?; + self.layout_node_constrained(*child, bounds, constraints, focused, geometry)?; + } + None => return Err(LayoutError::InvalidTree("node is missing")), + } + + Ok(()) + } + + fn minimum_size( + &self, + node: NodeId, + constraints: &HashMap, + ) -> Result<(f64, f64), LayoutError> { + match self.nodes.get(&node) { + Some(Node::Window(window)) => { + let constraint = constraints.get(window).copied().unwrap_or_default(); + let constraint = normalized_constraints(constraint); + Ok((constraint.min_width, constraint.min_height)) + } + Some(Node::Split { + axis, + first, + second, + .. + }) => { + let first = self.minimum_size(*first, constraints)?; + let second = self.minimum_size(*second, constraints)?; + + Ok(match axis { + Axis::Horizontal => (first.0 + second.0, first.1.max(second.1)), + Axis::Vertical => (first.0.max(second.0), first.1 + second.1), + }) + } + Some(Node::Stack { children, .. }) => { + let mut minimum = (1.0_f64, 1.0_f64); + + for child in children { + let child = self.minimum_size(*child, constraints)?; + minimum.0 = minimum.0.max(child.0); + minimum.1 = minimum.1.max(child.1); + } + + Ok(minimum) + } + None => Err(LayoutError::InvalidTree("node is missing")), + } + } + + fn contains_window(&self, node: NodeId, window: WindowId) -> bool { + match self.nodes.get(&node) { + Some(Node::Window(candidate)) => *candidate == window, + Some(Node::Split { first, second, .. }) => { + self.contains_window(*first, window) || self.contains_window(*second, window) + } + Some(Node::Stack { children, .. }) => children + .iter() + .any(|child| self.contains_window(*child, window)), + None => false, + } + } + fn validate_node( &self, node: NodeId, @@ -736,6 +922,93 @@ fn valid_resize_amount(amount: f64) -> f64 { } } +fn normalized_constraints(constraints: SizeConstraints) -> SizeConstraints { + let min_width = finite_positive(constraints.min_width).unwrap_or(1.0); + let min_height = finite_positive(constraints.min_height).unwrap_or(1.0); + let max_width = constraints + .max_width + .and_then(finite_positive) + .map(|maximum| maximum.max(min_width)); + let max_height = constraints + .max_height + .and_then(finite_positive) + .map(|maximum| maximum.max(min_height)); + + SizeConstraints { + min_width, + min_height, + max_width, + max_height, + } +} + +fn finite_positive(value: f64) -> Option { + (value.is_finite() && value > 0.0).then_some(value) +} + +fn record_minimum_warnings( + window: WindowId, + rect: Rect, + constraints: SizeConstraints, + warnings: &mut Vec, +) { + if rect.width < constraints.min_width { + warnings.push(ConstraintWarning { + window, + kind: ConstraintKind::MinimumWidth, + requested: constraints.min_width, + assigned: rect.width, + }); + } + if rect.height < constraints.min_height { + warnings.push(ConstraintWarning { + window, + kind: ConstraintKind::MinimumHeight, + requested: constraints.min_height, + assigned: rect.height, + }); + } +} + +fn apply_gaps(geometry: &mut HashMap, bounds: Rect, gaps: GapConfig) { + let outer = if gaps.smart && geometry.len() == 1 { + 0.0 + } else { + valid_gap(gaps.outer) + }; + let half_inner = valid_gap(gaps.inner) / 2.0; + let right = bounds.x + bounds.width; + let bottom = bounds.y + bounds.height; + + for rect in geometry.values_mut() { + let left_inset = if nearly_equal(rect.x, bounds.x) { + outer + } else { + half_inner + }; + let top_inset = if nearly_equal(rect.y, bounds.y) { + outer + } else { + half_inner + }; + let right_inset = if nearly_equal(rect.x + rect.width, right) { + outer + } else { + half_inner + }; + let bottom_inset = if nearly_equal(rect.y + rect.height, bottom) { + outer + } else { + half_inner + }; + + rect.x += left_inset; + rect.y += top_inset; + rect.width = (rect.width - left_inset - right_inset).max(1.0); + rect.height = (rect.height - top_inset - bottom_inset).max(1.0); + } +} + fn nearly_equal(left: f64, right: f64) -> bool { (left - right).abs() < f64::EPSILON * left.abs().max(right.abs()).max(1.0) } @@ -1155,6 +1428,119 @@ mod tests { } } + #[test] + fn constraints_adjust_split_boundaries_when_space_is_available() { + let mut tree = LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.2) + .unwrap(); + tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.2) + .unwrap(); + let constraints = HashMap::from([ + ( + WindowId(1), + SizeConstraints { + min_width: 40.0, + ..SizeConstraints::default() + }, + ), + ( + WindowId(2), + SizeConstraints { + min_width: 30.0, + ..SizeConstraints::default() + }, + ), + ]); + let result = tree + .geometry_with_constraints( + Rect::new(0.0, 0.0, 100.0, 80.0), + GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }, + &constraints, + Some(WindowId(1)), + ) + .unwrap(); + + assert_eq!(result.geometry[&WindowId(1)].width, 40.0); + assert_eq!(result.geometry[&WindowId(2)].width, 60.0); + assert!(result.warnings.is_empty()); + } + + #[test] + fn focused_window_wins_an_impossible_minimum_constraint_conflict() { + let mut tree = LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.5) + .unwrap(); + tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5) + .unwrap(); + let constraints = HashMap::from([ + ( + WindowId(1), + SizeConstraints { + min_width: 80.0, + ..SizeConstraints::default() + }, + ), + ( + WindowId(2), + SizeConstraints { + min_width: 80.0, + ..SizeConstraints::default() + }, + ), + ]); + let result = tree + .geometry_with_constraints( + Rect::new(0.0, 0.0, 100.0, 80.0), + GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }, + &constraints, + Some(WindowId(1)), + ) + .unwrap(); + + assert_eq!(result.geometry[&WindowId(1)].width, 80.0); + assert_eq!(result.geometry[&WindowId(2)].width, 20.0); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].window, WindowId(2)); + assert_eq!(result.warnings[0].kind, ConstraintKind::MinimumWidth); + } + + #[test] + fn maximum_constraints_clip_without_overlapping_neighbors() { + let mut tree = LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.5) + .unwrap(); + let constraints = HashMap::from([( + WindowId(1), + SizeConstraints { + max_width: Some(60.0), + max_height: Some(40.0), + ..SizeConstraints::default() + }, + )]); + let result = tree + .geometry_with_constraints( + Rect::new(0.0, 0.0, 100.0, 80.0), + GapConfig::default(), + &constraints, + Some(WindowId(1)), + ) + .unwrap(); + + assert_eq!( + result.geometry[&WindowId(1)], + Rect::new(0.0, 0.0, 60.0, 40.0) + ); + assert_eq!(result.warnings.len(), 2); + } + fn choose_window(windows: &[WindowId], random: u64) -> Option { (!windows.is_empty()).then(|| windows[random as usize % windows.len()]) }