fix: preserve default outer gaps

This commit is contained in:
Samuel Onoja committed 2026-09-25 02:43:53 +01:00
1 parent ed529ae900
commit 94a83c83bb
7 files changed
+119 -14

No files matched your search

+8
View File
@@ -37,6 +37,14 @@ default_column_width = "full"
`default_column_width = 1.0` is equivalent. `Super+M` switches the active `default_column_width = 1.0` is equivalent. `Super+M` switches the active
workspace between scrolling and tree modes. 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 Scrolling keeps windows in stable workspace coordinates and animates the
viewport only when focus must be revealed. Set viewport only when focus must be revealed. Set
`focus_strategy = "center_on_focus"` under `[scrolling]` to center focused `focus_strategy = "center_on_focus"` under `[scrolling]` to center focused
+73 -3
View File
@@ -7,7 +7,7 @@ use std::path::PathBuf;
use ferese_animation::SpringConfig; use ferese_animation::SpringConfig;
use ferese_core::LayoutMode; use ferese_core::LayoutMode;
use ferese_layout::{ColumnWidth, ViewportFocusStrategy}; use ferese_layout::{ColumnWidth, GapConfig, ViewportFocusStrategy};
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
@@ -86,9 +86,26 @@ impl Default for SpringSettings {
} }
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Deserialize)]
struct LayoutConfig { struct LayoutConfig {
mode: Option<LayoutModeValue>, mode: Option<LayoutModeValue>,
#[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)] #[derive(Clone, Copy, Debug, Deserialize)]
@@ -138,6 +155,10 @@ pub enum ConfigError {
field: &'static str, field: &'static str,
value: f64, value: f64,
}, },
InvalidLayoutValue {
field: &'static str,
value: f64,
},
} }
impl fmt::Display for ConfigError { impl fmt::Display for ConfigError {
@@ -156,6 +177,9 @@ impl fmt::Display for ConfigError {
Self::InvalidAnimationValue { field, value } => { Self::InvalidAnimationValue { field, value } => {
write!(formatter, "invalid animations.{field} value {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 { match self {
Self::Read { source, .. } => Some(source), Self::Read { source, .. } => Some(source),
Self::Parse { 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<GapConfig, ConfigError> {
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<ColumnWidth, ConfigError> { pub fn default_column_width(&self) -> Result<ColumnWidth, ConfigError> {
parse_column_width( parse_column_width(
self.scrolling self.scrolling
@@ -286,10 +323,26 @@ fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, Conf
} }
} }
fn nonnegative_layout_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() && value >= 0.0 {
Ok(value)
} else {
Err(ConfigError::InvalidLayoutValue { field, value })
}
}
const fn enabled_by_default() -> bool { const fn enabled_by_default() -> bool {
true true
} }
const fn default_inner_gap() -> f64 {
10.0
}
const fn default_outer_gap() -> f64 {
10.0
}
const fn default_animation_speed() -> f64 { const fn default_animation_speed() -> f64 {
1.0 1.0
} }
@@ -367,6 +420,7 @@ mod tests {
config.default_column_width().unwrap(), config.default_column_width().unwrap(),
ColumnWidth::Proportion(0.5) ColumnWidth::Proportion(0.5)
); );
assert_eq!(config.gap_config().unwrap(), GapConfig::default());
} }
#[test] #[test]
@@ -449,6 +503,22 @@ mod tests {
assert!(damping.spring_config().is_err()); 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] #[test]
fn derives_critical_viewport_damping() { fn derives_critical_viewport_damping() {
let config = parse(""); let config = parse("");
+1
View File
@@ -28,6 +28,7 @@ fn main() -> Result<(), Box<dyn Error>> {
let display = Display::new()?; let display = Display::new()?;
let runtime = RuntimeConfig { let runtime = RuntimeConfig {
layout_mode: config.layout_mode(), layout_mode: config.layout_mode(),
gap_config: config.gap_config()?,
default_column_width: config.default_column_width()?, default_column_width: config.default_column_width()?,
scrolling_focus_strategy: config.scrolling_focus_strategy(), scrolling_focus_strategy: config.scrolling_focus_strategy(),
column_width_presets: config.width_presets()?, column_width_presets: config.width_presets()?,
+7 -4
View File
@@ -90,6 +90,7 @@ pub struct Ferese {
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>, scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
pub focused_window: Option<WindowId>, pub focused_window: Option<WindowId>,
column_width_presets: Vec<ColumnWidth>, column_width_presets: Vec<ColumnWidth>,
gap_config: GapConfig,
animations_enabled: bool, animations_enabled: bool,
animation_speed: f64, animation_speed: f64,
spring_config: SpringConfig, spring_config: SpringConfig,
@@ -137,6 +138,7 @@ pub struct Ferese {
pub struct RuntimeConfig { pub struct RuntimeConfig {
pub layout_mode: LayoutMode, pub layout_mode: LayoutMode,
pub gap_config: GapConfig,
pub default_column_width: ColumnWidth, pub default_column_width: ColumnWidth,
pub scrolling_focus_strategy: ViewportFocusStrategy, pub scrolling_focus_strategy: ViewportFocusStrategy,
pub column_width_presets: Vec<ColumnWidth>, pub column_width_presets: Vec<ColumnWidth>,
@@ -363,6 +365,7 @@ impl Ferese {
scrolling_world_x: HashMap::new(), scrolling_world_x: HashMap::new(),
focused_window: None, focused_window: None,
column_width_presets: config.column_width_presets, column_width_presets: config.column_width_presets,
gap_config: config.gap_config,
animations_enabled: config.animations_enabled, animations_enabled: config.animations_enabled,
animation_speed: config.animation_speed, animation_speed: config.animation_speed,
spring_config: config.spring_config, spring_config: config.spring_config,
@@ -699,7 +702,7 @@ impl Ferese {
}; };
let layout = match workspace.layout.geometry_with_constraints( let layout = match workspace.layout.geometry_with_constraints(
bounds, bounds,
GapConfig::default(), self.gap_config,
&constraints, &constraints,
focused, focused,
) { ) {
@@ -1162,7 +1165,7 @@ impl Ferese {
match self match self
.workspaces .workspaces
.center_window(window, bounds, GapConfig::default(), &constraints) .center_window(window, bounds, self.gap_config, &constraints)
{ {
Ok(true) => self.relayout(), Ok(true) => self.relayout(),
Ok(false) => {} Ok(false) => {}
@@ -1401,7 +1404,7 @@ impl Ferese {
.workspaces .workspaces
.workspace_mut(workspace)? .workspace_mut(workspace)?
.layout .layout
.geometry_with_constraints(bounds, GapConfig::default(), &constraints, focused) .geometry_with_constraints(bounds, self.gap_config, &constraints, focused)
.ok()? .ok()?
.geometry .geometry
.get(&window) .get(&window)
@@ -1416,7 +1419,7 @@ impl Ferese {
self.workspaces self.workspaces
.active_mut() .active_mut()
.layout .layout
.geometry_with_constraints(bounds, GapConfig::default(), &constraints, focused) .geometry_with_constraints(bounds, self.gap_config, &constraints, focused)
} }
fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> { fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> {
+4 -1
View File
@@ -1025,7 +1025,10 @@ mod tests {
) )
.unwrap(); .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(); workspaces.switch_to_numeric(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
} }
+22 -3
View File
@@ -120,7 +120,7 @@ impl Default for GapConfig {
Self { Self {
inner: 10.0, inner: 10.0,
outer: 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 bounds = Rect::new(0.0, 0.0, 100.0, 80.0);
let geometry = tree let geometry = tree
.geometry_with_gaps(bounds, GapConfig::default()) .geometry_with_gaps(
bounds,
GapConfig {
smart: true,
..GapConfig::default()
},
)
.unwrap(); .unwrap();
assert_eq!(geometry[&WindowId(1)], bounds); 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] #[test]
fn directional_neighbor_prefers_primary_distance() { fn directional_neighbor_prefers_primary_distance() {
let mut tree = LayoutTree::default(); let mut tree = LayoutTree::default();
@@ -1552,7 +1571,7 @@ mod tests {
assert_eq!( assert_eq!(
result.geometry[&WindowId(1)], 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); assert_eq!(result.warnings.len(), 2);
} }
+4 -3
View File
@@ -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 Shared gap defaults live under `[layout]`; mode-specific behavior lives under
`[scrolling]` and `[tiling]`. `inner_gap` separates managed windows and `[scrolling]` and `[tiling]`. `inner_gap` separates managed windows and
`outer_gap` separates the layout from output bounds. With `smart_gaps = true`, `outer_gap` separates the layout from output bounds. Outer gaps are retained by
a lone managed window has no outer gap. default. With the explicit opt-in `smart_gaps = true`, a lone managed window has
no outer gap.
## 7. Geometry and animation ## 7. Geometry and animation
@@ -1083,7 +1084,7 @@ mass = 1.0
mode = "scrolling" mode = "scrolling"
inner_gap = 10.0 inner_gap = 10.0
outer_gap = 10.0 outer_gap = 10.0
smart_gaps = true smart_gaps = false
[scrolling] [scrolling]
default_column_width = 0.5 default_column_width = 0.5