From 5e6c9c3d9db829a45d927c3948005866ce1e1a58 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 02:14:04 +0100 Subject: [PATCH] feat: complete scrolling layout controls --- README.md | 5 + compositor/src/config.rs | 87 +++++++++--- compositor/src/input.rs | 66 +++++++-- compositor/src/main.rs | 1 + compositor/src/state.rs | 85 ++++++++++++ crates/ferese-core/src/workspace.rs | 125 +++++++++++++++++ crates/ferese-layout/src/scrolling.rs | 193 ++++++++++++++++++++++++++ docs/ferese-window-manager-spec.md | 5 + 8 files changed, 537 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index bafb30c..f0c78cc 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ default_column_width = "full" `default_column_width = 1.0` is equivalent. `Super+M` switches the active workspace between scrolling and tree modes. +Scrolling controls use `Super+R` to cycle configured width presets, `Super+C` +to center the focused column, `Super+[` to consume the focused window into an +adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the +corresponding direction. + Text-input-v3 is available to applications by default. Input-method-v2 is hidden unless the session explicitly opts in, because an input method can request privileged keyboard access. Enable it only when launching a trusted diff --git a/compositor/src/config.rs b/compositor/src/config.rs index b1eaff3..9c2fa7e 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -32,6 +32,8 @@ enum LayoutModeValue { #[derive(Debug, Default, Deserialize)] struct ScrollingConfig { default_column_width: Option, + #[serde(default)] + width_presets: Vec, } #[derive(Clone, Debug, Deserialize)] @@ -51,7 +53,10 @@ pub enum ConfigError { path: PathBuf, source: toml::de::Error, }, - InvalidColumnWidth(String), + InvalidColumnWidth { + field: &'static str, + value: String, + }, } impl fmt::Display for ConfigError { @@ -63,9 +68,9 @@ impl fmt::Display for ConfigError { Self::Parse { path, source } => { write!(formatter, "failed to parse {}: {source}", path.display()) } - Self::InvalidColumnWidth(value) => write!( + Self::InvalidColumnWidth { field, value } => write!( formatter, - "invalid scrolling.default_column_width {value}; expected a positive number or \"full\"" + "invalid scrolling.{field} {value}; expected a positive number or \"full\"" ), } } @@ -76,7 +81,7 @@ impl Error for ConfigError { match self { Self::Read { source, .. } => Some(source), Self::Parse { source, .. } => Some(source), - Self::InvalidColumnWidth(_) => None, + Self::InvalidColumnWidth { .. } => None, } } } @@ -103,23 +108,51 @@ impl Config { } pub fn default_column_width(&self) -> Result { - match self - .scrolling - .default_column_width - .as_ref() - .unwrap_or(&ColumnWidthValue::Proportion(0.5)) - { - ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => { - Ok(ColumnWidth::Proportion(*value)) - } - ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => { - Ok(ColumnWidth::Full) - } - value => Err(ConfigError::InvalidColumnWidth(match value { + parse_column_width( + self.scrolling + .default_column_width + .as_ref() + .unwrap_or(&ColumnWidthValue::Proportion(0.5)), + "default_column_width", + ) + } + + pub fn width_presets(&self) -> Result, ConfigError> { + if self.scrolling.width_presets.is_empty() { + return Ok(vec![ + ColumnWidth::Proportion(1.0 / 3.0), + ColumnWidth::Proportion(0.5), + ColumnWidth::Proportion(2.0 / 3.0), + ColumnWidth::Full, + ]); + } + + self.scrolling + .width_presets + .iter() + .map(|value| parse_column_width(value, "width_presets")) + .collect() + } +} + +fn parse_column_width( + value: &ColumnWidthValue, + field: &'static str, +) -> Result { + match value { + ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => { + Ok(ColumnWidth::Proportion(*value)) + } + ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => { + Ok(ColumnWidth::Full) + } + value => Err(ConfigError::InvalidColumnWidth { + field, + value: match value { ColumnWidthValue::Proportion(value) => value.to_string(), ColumnWidthValue::Named(value) => format!("{value:?}"), - })), - } + }, + }), } } @@ -165,6 +198,22 @@ mod tests { ); } + #[test] + fn parses_width_presets_and_supplies_defaults() { + let defaults = parse(""); + let configured = parse("[scrolling]\nwidth_presets = [0.5, 1.0, \"full\"]"); + + assert_eq!(defaults.width_presets().unwrap().len(), 4); + assert_eq!( + configured.width_presets().unwrap(), + vec![ + ColumnWidth::Proportion(0.5), + ColumnWidth::Proportion(1.0), + ColumnWidth::Full + ] + ); + } + #[test] fn rejects_non_positive_or_unknown_column_widths() { let zero = parse("[scrolling]\ndefault_column_width = 0.0"); diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 661247a..fbde4a7 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -71,19 +71,31 @@ impl Ferese { return FilterResult::Intercept(()); } - let direction = if modifiers.logo && !modifiers.ctrl && !modifiers.alt { - match symbol { - keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left), - keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down), - keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up), - keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right), - _ => None, - } - } else { - None + let direction = match symbol { + keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left), + keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down), + keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up), + keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right), + _ => None, }; - if let Some(direction) = direction { + if modifiers.logo + && modifiers.ctrl + && !modifiers.alt + && !modifiers.shift + && let Some(direction) = direction + { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + data.resize_direction(direction); + } + + FilterResult::Intercept(()) + } else if modifiers.logo + && !modifiers.ctrl + && !modifiers.alt + && let Some(direction) = direction + { if state == KeyState::Pressed { data.intercepted_keys.insert(keycode); @@ -147,6 +159,38 @@ impl Ferese { data.toggle_layout_mode(); } + FilterResult::Intercept(()) + } else if !modifiers.shift + && matches!(symbol, keysyms::KEY_r | keysyms::KEY_R) + { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + data.cycle_focused_column_width(); + } + + FilterResult::Intercept(()) + } else if !modifiers.shift + && matches!(symbol, keysyms::KEY_c | keysyms::KEY_C) + { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + data.center_focused_column(); + } + + FilterResult::Intercept(()) + } else if !modifiers.shift && symbol == keysyms::KEY_bracketleft { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + data.consume_focused_window(); + } + + FilterResult::Intercept(()) + } else if !modifiers.shift && symbol == keysyms::KEY_bracketright { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + data.expel_focused_window(); + } + FilterResult::Intercept(()) } else if modifiers.shift && symbol == keysyms::KEY_space { if state == KeyState::Pressed { diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 432df30..c3e7fbc 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -31,6 +31,7 @@ fn main() -> Result<(), Box> { display, config.layout_mode(), config.default_column_width()?, + config.width_presets()?, )?; backends::init(launch.backend, &mut event_loop, &mut state)?; diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 09030c9..e381ce2 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -84,6 +84,7 @@ pub struct Ferese { pub window_ids: HashMap, pub window_geometry: HashMap, pub focused_window: Option, + column_width_presets: Vec, pub cursor_status: CursorImageStatus, pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) named_cursors: HashMap, @@ -273,6 +274,7 @@ impl Ferese { display: Display, default_layout_mode: LayoutMode, default_column_width: ColumnWidth, + column_width_presets: Vec, ) -> Result> { let display_handle = display.handle(); crate::handlers::screencopy::init_global(&display_handle); @@ -328,6 +330,7 @@ impl Ferese { window_ids: HashMap::new(), window_geometry: HashMap::new(), focused_window: None, + column_width_presets, cursor_status: CursorImageStatus::default_named(), cursor_theme, named_cursors, @@ -956,6 +959,88 @@ impl Ferese { } } + pub fn consume_focused_window(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + if self.workspaces.active().layout.mode() != LayoutMode::Scrolling { + return; + } + let target = self + .workspaces + .active() + .layout + .directional_neighbor(window, Direction::Left, bounds) + .ok() + .flatten() + .or_else(|| { + self.workspaces + .active() + .layout + .directional_neighbor(window, Direction::Right, bounds) + .ok() + .flatten() + }); + let Some(target) = target else { + return; + }; + + if let Err(error) = self.workspaces.stack_window(window, target) { + tracing::error!(%error, ?window, ?target, "failed to consume window into column"); + return; + } + self.relayout(); + } + + pub fn expel_focused_window(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + + match self.workspaces.extract_window(window) { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => tracing::error!(%error, ?window, "failed to expel window from column"), + } + } + + pub fn cycle_focused_column_width(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + + match self + .workspaces + .cycle_column_width(window, &self.column_width_presets) + { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"), + } + } + + pub fn center_focused_column(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + let constraints = self.window_constraints(); + + match self + .workspaces + .center_window(window, bounds, GapConfig::default(), &constraints) + { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => tracing::error!(%error, ?window, "failed to center column"), + } + } + pub fn toggle_focused_floating(&mut self) { let Some(window) = self.focused_window else { return; diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 1ab0206..185d409 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -183,6 +183,45 @@ impl WorkspaceLayout { } } + pub fn extract_window(&mut self, window: WindowId) -> Result { + match self { + Self::Scrolling(layout) => { + let grouped = layout + .columns() + .iter() + .find(|column| column.windows.contains(&window)) + .is_some_and(|column| column.windows.len() > 1); + layout.extract_to_column(window)?; + Ok(grouped) + } + Self::Tree(_) => Ok(false), + } + } + + pub fn cycle_column_width( + &mut self, + window: WindowId, + presets: &[ColumnWidth], + ) -> Result { + match self { + Self::Scrolling(layout) => layout.cycle_column_width(window, presets), + Self::Tree(_) => Ok(false), + } + } + + pub fn center_window( + &mut self, + window: WindowId, + bounds: Rect, + gaps: GapConfig, + constraints: &HashMap, + ) -> Result { + match self { + Self::Scrolling(layout) => layout.center_window(window, bounds, gaps, constraints), + Self::Tree(_) => Ok(false), + } + } + pub fn validate(&self) -> Result<(), LayoutError> { match self { Self::Scrolling(layout) => layout.validate(), @@ -472,6 +511,64 @@ impl WorkspaceSet { Ok(()) } + pub fn extract_window(&mut self, window: WindowId) -> Result { + if self.workspace_for_window(window) != Some(self.active) + || self.placement(window) != Some(WindowPlacement::Tiled) + { + return Err(WorkspaceError::InvalidState( + "extracted window is not tiled on the active workspace", + )); + } + + let changed = self.active_mut().layout.extract_window(window)?; + debug_assert!(self.validate().is_ok()); + Ok(changed) + } + + pub fn cycle_column_width( + &mut self, + window: WindowId, + presets: &[ColumnWidth], + ) -> Result { + if self.workspace_for_window(window) != Some(self.active) + || self.placement(window) != Some(WindowPlacement::Tiled) + { + return Err(WorkspaceError::InvalidState( + "resized window is not tiled on the active workspace", + )); + } + + let changed = self + .active_mut() + .layout + .cycle_column_width(window, presets)?; + debug_assert!(self.validate().is_ok()); + Ok(changed) + } + + pub fn center_window( + &mut self, + window: WindowId, + bounds: Rect, + gaps: GapConfig, + constraints: &HashMap, + ) -> Result { + if self.workspace_for_window(window) != Some(self.active) + || self.placement(window) != Some(WindowPlacement::Tiled) + { + return Err(WorkspaceError::InvalidState( + "centered window is not tiled on the active workspace", + )); + } + + let changed = self + .active_mut() + .layout + .center_window(window, bounds, gaps, constraints)?; + debug_assert!(self.validate().is_ok()); + Ok(changed) + } + pub fn ensure_numeric(&mut self, index: u32) -> Result { if index == 0 { return Err(WorkspaceError::InvalidNumericName(index)); @@ -1179,6 +1276,34 @@ mod tests { assert!(workspaces.validate().is_ok()); } + #[test] + fn scrolling_column_commands_preserve_workspace_state() { + let mut workspaces = WorkspaceSet::default(); + for id in 1..=3 { + workspaces + .insert_window(WindowId(id), Axis::Horizontal, 0.5) + .unwrap(); + } + + workspaces.stack_window(WindowId(3), WindowId(2)).unwrap(); + assert!(workspaces.extract_window(WindowId(3)).unwrap()); + assert!( + workspaces + .cycle_column_width( + WindowId(3), + &[ColumnWidth::Proportion(0.5), ColumnWidth::Full], + ) + .unwrap() + ); + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let _ = workspaces + .center_window(WindowId(3), bounds, GapConfig::default(), &HashMap::new()) + .unwrap(); + + assert_eq!(workspaces.active().last_focused, Some(WindowId(3))); + assert!(workspaces.validate().is_ok()); + } + #[test] fn randomized_workspace_sequences_preserve_all_invariants() { let mut workspaces = WorkspaceSet::default(); diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index ea31085..118da7b 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -227,6 +227,55 @@ impl ScrollingLayout { Ok(()) } + pub fn cycle_column_width( + &mut self, + window: WindowId, + presets: &[ColumnWidth], + ) -> Result { + let (column, _) = self + .window_location(window) + .ok_or(LayoutError::UnknownWindow(window))?; + let presets = presets + .iter() + .copied() + .map(normalized_width) + .collect::>(); + if presets.is_empty() { + return Ok(false); + } + + let current = normalized_width(self.columns[column].width); + let next = presets + .iter() + .position(|preset| *preset == current) + .map(|index| presets[(index + 1) % presets.len()]) + .unwrap_or(presets[0]); + self.columns[column].width = next; + Ok(next != current) + } + + pub fn center_window( + &mut self, + window: WindowId, + bounds: Rect, + gaps: GapConfig, + constraints: &HashMap, + ) -> Result { + let before = self.viewport_x; + let result = self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?; + let rect = result + .geometry + .get(&window) + .copied() + .ok_or(LayoutError::UnknownWindow(window))?; + let viewport_center = bounds.x + bounds.width / 2.0; + let window_center = rect.x + rect.width / 2.0; + self.viewport_x += window_center - viewport_center; + self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?; + + Ok(self.viewport_x != before) + } + pub fn directional_neighbor( &self, window: WindowId, @@ -472,6 +521,16 @@ impl ScrollingLayout { } pub fn validate(&self) -> Result<(), LayoutError> { + if !self.viewport_x.is_finite() || self.viewport_x < 0.0 { + return Err(LayoutError::InvalidTree( + "scrolling viewport offset is invalid", + )); + } + if normalized_width(self.default_width) != self.default_width { + return Err(LayoutError::InvalidTree( + "scrolling default column width is invalid", + )); + } if self.columns.is_empty() { return if self.active_column.is_none() { Ok(()) @@ -492,6 +551,11 @@ impl ScrollingLayout { let mut windows = HashSet::new(); for column in &self.columns { + if normalized_width(column.width) != column.width { + return Err(LayoutError::InvalidTree( + "scrolling column width is invalid", + )); + } if column.windows.is_empty() { return Err(LayoutError::InvalidTree("scrolling column is empty")); } @@ -742,4 +806,133 @@ mod tests { ); assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]); } + + #[test] + fn width_cycle_wraps_through_configured_presets() { + let mut layout = ScrollingLayout::default(); + layout.insert(window(1), None).unwrap(); + let presets = [ + ColumnWidth::Proportion(0.5), + ColumnWidth::Proportion(2.0 / 3.0), + ColumnWidth::Full, + ]; + + assert!(layout.cycle_column_width(window(1), &presets).unwrap()); + assert_eq!( + layout.columns()[0].width, + ColumnWidth::Proportion(2.0 / 3.0) + ); + assert!(layout.cycle_column_width(window(1), &presets).unwrap()); + assert_eq!(layout.columns()[0].width, ColumnWidth::Full); + assert!(layout.cycle_column_width(window(1), &presets).unwrap()); + assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5)); + } + + #[test] + fn explicit_center_places_a_middle_column_in_the_viewport_center() { + let mut layout = ScrollingLayout::default(); + for id in 1_u64..=4 { + layout + .insert( + window(id), + id.checked_sub(1).filter(|id| *id > 0).map(window), + ) + .unwrap(); + } + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4))) + .unwrap(); + + assert!( + layout + .center_window(window(2), bounds, gaps, &HashMap::new()) + .unwrap() + ); + let result = layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2))) + .unwrap(); + let rect = result.geometry[&window(2)]; + + assert_eq!(rect.x + rect.width / 2.0, 500.0); + } + + #[test] + fn randomized_scrolling_operations_preserve_invariants() { + let mut layout = ScrollingLayout::default(); + let mut windows = Vec::new(); + let mut next_window = 1_u64; + let mut random = 0x5c40_11ab_u64; + + for _ in 0..2_000 { + random = random + .wrapping_mul(2_862_933_555_777_941_757) + .wrapping_add(3_037_000_493); + + match random % 7 { + 0 if windows.len() < 48 => { + let new_window = window(next_window); + next_window += 1; + let focused = windows.get(random as usize % windows.len().max(1)).copied(); + layout.insert(new_window, focused).unwrap(); + windows.push(new_window); + } + 1 if !windows.is_empty() => { + let index = random as usize % windows.len(); + layout.remove(windows.swap_remove(index)).unwrap(); + } + 2 if windows.len() > 1 => { + let first = random as usize % windows.len(); + let mut second = random.rotate_left(17) as usize % windows.len(); + if first == second { + second = (second + 1) % windows.len(); + } + layout + .move_into_column(windows[first], windows[second]) + .unwrap(); + } + 3 if !windows.is_empty() => { + let index = random as usize % windows.len(); + layout.extract_to_column(windows[index]).unwrap(); + } + 4 if !windows.is_empty() => { + let index = random as usize % windows.len(); + let direction = random_direction(random.rotate_left(9)); + layout.move_window(windows[index], direction).unwrap(); + } + 5 if !windows.is_empty() => { + let index = random as usize % windows.len(); + let direction = random_direction(random.rotate_left(23)); + layout + .resize_window(windows[index], direction, 0.05) + .unwrap(); + } + 6 if !windows.is_empty() => { + let index = random as usize % windows.len(); + layout.focus(windows[index]).unwrap(); + } + _ => {} + } + + layout.validate().unwrap(); + assert_eq!( + layout.window_ids().collect::>().len(), + windows.len() + ); + } + } + + fn random_direction(random: u64) -> Direction { + match random % 4 { + 0 => Direction::Left, + 1 => Direction::Right, + 2 => Direction::Up, + _ => Direction::Down, + } + } } diff --git a/docs/ferese-window-manager-spec.md b/docs/ferese-window-manager-spec.md index 1c854af..8b2b3be 100644 --- a/docs/ferese-window-manager-spec.md +++ b/docs/ferese-window-manager-spec.md @@ -1242,10 +1242,15 @@ Super+Enter terminal Super+Q close window Super+H/J/K/L focus left/down/up/right Super+Shift+H/J/K/L move left/down/up/right +Super+Ctrl+H/J/K/L resize left/down/up/right Super+1..9 switch workspace Super+Shift+1..9 move window to workspace Super+F toggle fullscreen Super+M toggle scrolling/tree layout +Super+R cycle scrolling column width +Super+C center scrolling column +Super+[ consume window into adjacent column +Super+] expel window from column Super+Shift+Space toggle floating Super+Space launcher Super+Tab overview