diff --git a/README.md b/README.md index 77ea601..abcf2e0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,14 @@ default_column_width = "full" `default_column_width = 1.0` is equivalent. `Super+M` switches the active workspace between scrolling and tree modes. +Outer gaps remain visible with one window by default. To intentionally remove +them for a lone tiled window: + +```toml +[layout] +smart_gaps = true +``` + Scrolling keeps windows in stable workspace coordinates and animates the viewport only when focus must be revealed. Set `focus_strategy = "center_on_focus"` under `[scrolling]` to center focused diff --git a/compositor/src/config.rs b/compositor/src/config.rs index c8cfa1b..8b8b03c 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use ferese_animation::SpringConfig; use ferese_core::LayoutMode; -use ferese_layout::{ColumnWidth, ViewportFocusStrategy}; +use ferese_layout::{ColumnWidth, GapConfig, ViewportFocusStrategy}; use serde::Deserialize; #[derive(Debug, Default, Deserialize)] @@ -86,9 +86,26 @@ impl Default for SpringSettings { } } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Deserialize)] struct LayoutConfig { mode: Option, + #[serde(default = "default_inner_gap")] + inner_gap: f64, + #[serde(default = "default_outer_gap")] + outer_gap: f64, + #[serde(default)] + smart_gaps: bool, +} + +impl Default for LayoutConfig { + fn default() -> Self { + Self { + mode: None, + inner_gap: default_inner_gap(), + outer_gap: default_outer_gap(), + smart_gaps: false, + } + } } #[derive(Clone, Copy, Debug, Deserialize)] @@ -138,6 +155,10 @@ pub enum ConfigError { field: &'static str, value: f64, }, + InvalidLayoutValue { + field: &'static str, + value: f64, + }, } impl fmt::Display for ConfigError { @@ -156,6 +177,9 @@ impl fmt::Display for ConfigError { Self::InvalidAnimationValue { field, value } => { write!(formatter, "invalid animations.{field} value {value}") } + Self::InvalidLayoutValue { field, value } => { + write!(formatter, "invalid layout.{field} value {value}") + } } } } @@ -165,7 +189,9 @@ impl Error for ConfigError { match self { Self::Read { source, .. } => Some(source), Self::Parse { source, .. } => Some(source), - Self::InvalidColumnWidth { .. } | Self::InvalidAnimationValue { .. } => None, + Self::InvalidColumnWidth { .. } + | Self::InvalidAnimationValue { .. } + | Self::InvalidLayoutValue { .. } => None, } } } @@ -191,6 +217,17 @@ impl Config { } } + pub fn gap_config(&self) -> Result { + let inner = nonnegative_layout_value(self.layout.inner_gap, "inner_gap")?; + let outer = nonnegative_layout_value(self.layout.outer_gap, "outer_gap")?; + + Ok(GapConfig { + inner, + outer, + smart: self.layout.smart_gaps, + }) + } + pub fn default_column_width(&self) -> Result { parse_column_width( self.scrolling @@ -286,10 +323,26 @@ fn positive_animation_value(value: f64, field: &'static str) -> Result Result { + if value.is_finite() && value >= 0.0 { + Ok(value) + } else { + Err(ConfigError::InvalidLayoutValue { field, value }) + } +} + const fn enabled_by_default() -> bool { true } +const fn default_inner_gap() -> f64 { + 10.0 +} + +const fn default_outer_gap() -> f64 { + 10.0 +} + const fn default_animation_speed() -> f64 { 1.0 } @@ -367,6 +420,7 @@ mod tests { config.default_column_width().unwrap(), ColumnWidth::Proportion(0.5) ); + assert_eq!(config.gap_config().unwrap(), GapConfig::default()); } #[test] @@ -449,6 +503,22 @@ mod tests { assert!(damping.spring_config().is_err()); } + #[test] + fn parses_and_validates_layout_gaps() { + let configured = parse("[layout]\ninner_gap = 6.0\nouter_gap = 14.0\nsmart_gaps = true"); + let invalid = parse("[layout]\nouter_gap = -1.0"); + + assert_eq!( + configured.gap_config().unwrap(), + GapConfig { + inner: 6.0, + outer: 14.0, + smart: true, + } + ); + assert!(invalid.gap_config().is_err()); + } + #[test] fn derives_critical_viewport_damping() { let config = parse(""); diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 58483bf..9f9ee1b 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -28,6 +28,7 @@ fn main() -> Result<(), Box> { let display = Display::new()?; let runtime = RuntimeConfig { layout_mode: config.layout_mode(), + gap_config: config.gap_config()?, default_column_width: config.default_column_width()?, scrolling_focus_strategy: config.scrolling_focus_strategy(), column_width_presets: config.width_presets()?, diff --git a/compositor/src/state.rs b/compositor/src/state.rs index f68224f..f97aced 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -90,6 +90,7 @@ pub struct Ferese { scrolling_world_x: HashMap, pub focused_window: Option, column_width_presets: Vec, + gap_config: GapConfig, animations_enabled: bool, animation_speed: f64, spring_config: SpringConfig, @@ -137,6 +138,7 @@ pub struct Ferese { pub struct RuntimeConfig { pub layout_mode: LayoutMode, + pub gap_config: GapConfig, pub default_column_width: ColumnWidth, pub scrolling_focus_strategy: ViewportFocusStrategy, pub column_width_presets: Vec, @@ -363,6 +365,7 @@ impl Ferese { scrolling_world_x: HashMap::new(), focused_window: None, column_width_presets: config.column_width_presets, + gap_config: config.gap_config, animations_enabled: config.animations_enabled, animation_speed: config.animation_speed, spring_config: config.spring_config, @@ -699,7 +702,7 @@ impl Ferese { }; let layout = match workspace.layout.geometry_with_constraints( bounds, - GapConfig::default(), + self.gap_config, &constraints, focused, ) { @@ -1162,7 +1165,7 @@ impl Ferese { match self .workspaces - .center_window(window, bounds, GapConfig::default(), &constraints) + .center_window(window, bounds, self.gap_config, &constraints) { Ok(true) => self.relayout(), Ok(false) => {} @@ -1401,7 +1404,7 @@ impl Ferese { .workspaces .workspace_mut(workspace)? .layout - .geometry_with_constraints(bounds, GapConfig::default(), &constraints, focused) + .geometry_with_constraints(bounds, self.gap_config, &constraints, focused) .ok()? .geometry .get(&window) @@ -1416,7 +1419,7 @@ impl Ferese { self.workspaces .active_mut() .layout - .geometry_with_constraints(bounds, GapConfig::default(), &constraints, focused) + .geometry_with_constraints(bounds, self.gap_config, &constraints, focused) } fn window_constraints(&self) -> HashMap { diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index b0146cc..3512ee3 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -1025,7 +1025,10 @@ mod tests { ) .unwrap(); - assert_eq!(first.geometry[&WindowId(1)].width, 1_000.0); + assert_eq!( + first.geometry[&WindowId(1)], + Rect::new(10.0, 10.0, 980.0, 780.0) + ); workspaces.switch_to_numeric(2).unwrap(); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling); } diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index 3cd4fcc..216504b 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -120,7 +120,7 @@ impl Default for GapConfig { Self { inner: 10.0, outer: 10.0, - smart: true, + smart: false, } } } @@ -1191,12 +1191,31 @@ mod tests { let bounds = Rect::new(0.0, 0.0, 100.0, 80.0); let geometry = tree - .geometry_with_gaps(bounds, GapConfig::default()) + .geometry_with_gaps( + bounds, + GapConfig { + smart: true, + ..GapConfig::default() + }, + ) .unwrap(); assert_eq!(geometry[&WindowId(1)], bounds); } + #[test] + fn default_gaps_keep_outer_spacing_for_one_window() { + let mut tree = LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.5) + .unwrap(); + + let geometry = tree + .geometry_with_gaps(Rect::new(0.0, 0.0, 100.0, 80.0), GapConfig::default()) + .unwrap(); + + assert_eq!(geometry[&WindowId(1)], Rect::new(10.0, 10.0, 80.0, 60.0)); + } + #[test] fn directional_neighbor_prefers_primary_distance() { let mut tree = LayoutTree::default(); @@ -1552,7 +1571,7 @@ mod tests { assert_eq!( result.geometry[&WindowId(1)], - Rect::new(0.0, 0.0, 60.0, 40.0) + Rect::new(10.0, 10.0, 60.0, 40.0) ); assert_eq!(result.warnings.len(), 2); } diff --git a/docs/ferese-window-manager-spec.md b/docs/ferese-window-manager-spec.md index ac1655d..9c660e7 100644 --- a/docs/ferese-window-manager-spec.md +++ b/docs/ferese-window-manager-spec.md @@ -624,8 +624,9 @@ mode, center creates or joins a stack and an edge creates a split. Shared gap defaults live under `[layout]`; mode-specific behavior lives under `[scrolling]` and `[tiling]`. `inner_gap` separates managed windows and -`outer_gap` separates the layout from output bounds. With `smart_gaps = true`, -a lone managed window has no outer gap. +`outer_gap` separates the layout from output bounds. Outer gaps are retained by +default. With the explicit opt-in `smart_gaps = true`, a lone managed window has +no outer gap. ## 7. Geometry and animation @@ -1083,7 +1084,7 @@ mass = 1.0 mode = "scrolling" inner_gap = 10.0 outer_gap = 10.0 -smart_gaps = true +smart_gaps = false [scrolling] default_column_width = 0.5