From 88ed8a36e92da1cdfccaa3a6cb5d5868db8e60cf Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Tue, 5 May 2026 21:54:50 +0200 Subject: [PATCH] feat: add column width preset cycling for scrolling layout Adds resize_window_preset_next and resize_window_preset_prev commands that cycle the focused column through configurable width presets. New config option: layout.scrolling.column_width_presets --- rift.default.toml | 5 + src/bin/rift-cli.rs | 11 ++ src/common/config.rs | 15 ++ src/layout_engine/engine.rs | 26 +++ src/layout_engine/systems.rs | 1 + src/layout_engine/systems/scrolling.rs | 214 +++++++++++++++++++++++++ 6 files changed, 272 insertions(+) diff --git a/rift.default.toml b/rift.default.toml index c3f1d536..8f3ecc5e 100644 --- a/rift.default.toml +++ b/rift.default.toml @@ -95,6 +95,10 @@ column_width_ratio = 0.7 # min_column_width_ratio / max_column_width_ratio: clamp bounds used by resize commands min_column_width_ratio = 0.3 max_column_width_ratio = 0.9 +# Preset column widths cycled by resize_window_preset_next / resize_window_preset_prev. +# Each value is a fraction of screen width (0.0–1.0). +# Empty: disables preset cycling. +column_width_presets = [0.33, 0.50, 0.66, 0.99] # Alignment for the focused column: "left", "center", or "right" alignment = "center" # Horizontal focus behavior: @@ -359,6 +363,7 @@ comb1 = "Alt + Shift" # - toggle_stack / toggle_orientation / unjoin_windows # - toggle_focus_floating / toggle_window_floating / toggle_fullscreen / toggle_fullscreen_within_gaps # - resize_window_grow / resize_window_shrink / resize_window_by = 0.05 +# - resize_window_preset_next / resize_window_preset_prev # - swap_windows = [123, 456] # - exec = "command" | exec = ["cmd", "arg1", "..."] # - move_mouse_to_display = "left"|"right"|"up"|"down"|N|"" diff --git a/src/bin/rift-cli.rs b/src/bin/rift-cli.rs index 8ac4da04..acf3ff4b 100644 --- a/src/bin/rift-cli.rs +++ b/src/bin/rift-cli.rs @@ -158,6 +158,11 @@ enum WindowCommands { /// rift-cli execute window resize-by --amount 0.05 # grow by 5% /// rift-cli execute window resize-by --amount -0.10 # shrink by 10% ResizeBy { amount: f64 }, + /// Cycle the selected window to the next column width preset. + /// The presets are defined in settings.layout.scrolling.column_width_presets. + ResizePresetNext, + /// Cycle the selected window to the previous column width preset. + ResizePresetPrev, /// Close a window by window server identifier Close { /// Window Id (window server id or idx from window id) @@ -576,6 +581,12 @@ fn map_window_command(cmd: WindowCommands) -> Result { WindowCommands::ResizeBy { amount } => Ok(RiftCommand::Reactor(reactor::Command::Layout( LC::ResizeWindowBy { amount }, ))), + WindowCommands::ResizePresetNext => Ok(RiftCommand::Reactor(reactor::Command::Layout( + LC::ResizeWindowPresetNext, + ))), + WindowCommands::ResizePresetPrev => Ok(RiftCommand::Reactor(reactor::Command::Layout( + LC::ResizeWindowPresetPrev, + ))), WindowCommands::Close { window_id } => { let wsid = parse_window_server_id(&window_id)?; Ok(RiftCommand::Reactor(reactor::Command::Reactor( diff --git a/src/common/config.rs b/src/common/config.rs index badaaa60..41dcdc2b 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -657,6 +657,11 @@ pub struct ScrollingLayoutSettings { /// Trackpad gestures for scrolling layout #[serde(default)] pub gestures: ScrollingGestureSettings, + /// Preset column widths cycled by ResizeWindowPresetCycle. + /// Each value is a fraction of screen width (0.0–1.0). + /// Empty: preset cycling is disabled. + #[serde(default)] + pub column_width_presets: Vec, } impl Default for ScrollingLayoutSettings { @@ -669,6 +674,7 @@ impl Default for ScrollingLayoutSettings { alignment: ScrollingAlignment::default(), focus_navigation_style: ScrollingFocusNavigationStyle::default(), gestures: ScrollingGestureSettings::default(), + column_width_presets: Vec::new(), } } } @@ -965,6 +971,15 @@ impl ScrollingLayoutSettings { )); } + for (i, preset) in self.column_width_presets.iter().enumerate() { + if !(0.0..=1.0).contains(preset) { + issues.push(format!( + "layout.scrolling.column_width_presets[{}] must be between 0.0 and 1.0, got {}", + i, preset + )); + } + } + issues } } diff --git a/src/layout_engine/engine.rs b/src/layout_engine/engine.rs index 55c2eea1..8401bfcc 100644 --- a/src/layout_engine/engine.rs +++ b/src/layout_engine/engine.rs @@ -64,6 +64,10 @@ pub enum LayoutCommand { ResizeWindowBy { amount: f64, }, + /// Cycle the selected window to the next column width preset. + ResizeWindowPresetNext, + /// Cycle the selected window to the previous column width preset. + ResizeWindowPresetPrev, /// Scroll the strip by a normalized delta (scaled by column step width) ScrollStrip { @@ -1647,6 +1651,28 @@ impl LayoutEngine { self.workspace_tree_mut(workspace_id).resize_selection_by(layout, amount); EventResponse::default() } + LayoutCommand::ResizeWindowPresetNext => { + if is_floating { + return EventResponse::default(); + } + + self.workspace_layouts.mark_last_saved(space, workspace_id, layout); + let presets = self.layout_settings.scrolling.column_width_presets.clone(); + self.workspace_tree_mut(workspace_id) + .resize_selection_preset(layout, &presets, false); + EventResponse::default() + } + LayoutCommand::ResizeWindowPresetPrev => { + if is_floating { + return EventResponse::default(); + } + + self.workspace_layouts.mark_last_saved(space, workspace_id, layout); + let presets = self.layout_settings.scrolling.column_width_presets.clone(); + self.workspace_tree_mut(workspace_id) + .resize_selection_preset(layout, &presets, true); + EventResponse::default() + } LayoutCommand::AdjustMasterRatio { delta } => { self.workspace_layouts.mark_last_saved(space, workspace_id, layout); if let LayoutSystemKind::MasterStack(s) = self.workspace_tree_mut(workspace_id) { diff --git a/src/layout_engine/systems.rs b/src/layout_engine/systems.rs index c2628553..fe5db4ad 100644 --- a/src/layout_engine/systems.rs +++ b/src/layout_engine/systems.rs @@ -164,6 +164,7 @@ pub trait LayoutSystem: Serialize + for<'de> Deserialize<'de> { fn parent_of_selection_is_stacked(&self, layout: LayoutId) -> bool; fn unjoin_selection(&mut self, _layout: LayoutId); fn resize_selection_by(&mut self, layout: LayoutId, amount: f64); + fn resize_selection_preset(&mut self, _layout: LayoutId, _presets: &[f64], _reverse: bool) {} fn rebalance(&mut self, layout: LayoutId); fn toggle_tile_orientation(&mut self, layout: LayoutId); } diff --git a/src/layout_engine/systems/scrolling.rs b/src/layout_engine/systems/scrolling.rs index f38458da..ee82e6c5 100644 --- a/src/layout_engine/systems/scrolling.rs +++ b/src/layout_engine/systems/scrolling.rs @@ -1348,6 +1348,68 @@ impl LayoutSystem for ScrollingLayoutSystem { } } + fn resize_selection_preset(&mut self, layout: LayoutId, presets: &[f64], reverse: bool) { + if presets.is_empty() { + return; + } + let niri_navigation = matches!( + self.settings.focus_navigation_style, + ScrollingFocusNavigationStyle::Niri + ); + let Some(state) = self.layout_state_mut(layout) else { + return; + }; + let base_ratio = state.column_width_ratio; + + let (effective, col_idx_opt, was_fullscreen) = + if let Some((col_idx, win_idx)) = state.selected_location() { + let wid = state.columns[col_idx].windows[win_idx]; + let was_fs = state.fullscreen.contains(&wid) + || state.fullscreen_within_gaps.contains(&wid); + state.fullscreen.remove(&wid); + state.fullscreen_within_gaps.remove(&wid); + ( + base_ratio + state.columns[col_idx].width_offset, + Some(col_idx), + was_fs, + ) + } else { + (base_ratio, None, false) + }; + + let target = if was_fullscreen { + if reverse { + *presets.last().unwrap() + } else { + presets[0] + } + } else if reverse { + presets + .iter() + .rev() + .find(|&&p| p < effective) + .copied() + .unwrap_or_else(|| *presets.last().unwrap()) + } else { + presets + .iter() + .find(|&&p| p > effective) + .copied() + .unwrap_or(presets[0]) + }; + + if let Some(col_idx) = col_idx_opt { + state.columns[col_idx].width_offset = target - base_ratio; + if niri_navigation { + state.reveal_selected_without_direction(); + } else { + state.align_scroll_to_selected(); + } + } else { + state.column_width_ratio = target; + } + } + fn rebalance(&mut self, _layout: LayoutId) {} fn toggle_tile_orientation(&mut self, _layout: LayoutId) {} @@ -2013,4 +2075,156 @@ mod tests { after.origin.x ); } + + #[test] + fn preset_next_cycles_forward() { + let mut settings = ScrollingLayoutSettings::default(); + settings.column_width_ratio = 0.33; + settings.column_width_presets = vec![0.33, 0.50, 0.66, 0.99]; + let (mut system, layout, _, _) = setup_two_windows(settings); + + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], false); + let state = system.layouts.get(layout).expect("layout state missing"); + let effective = state.column_width_ratio + state.columns[1].width_offset; + assert!( + (effective - 0.50).abs() < 0.01, + "expected second preset 0.50, got {}", + effective + ); + + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], false); + let state = system.layouts.get(layout).expect("layout state missing"); + let effective = state.column_width_ratio + state.columns[1].width_offset; + assert!( + (effective - 0.66).abs() < 0.01, + "expected third preset 0.66, got {}", + effective + ); + } + + #[test] + fn preset_prev_cycles_backward() { + let mut settings = ScrollingLayoutSettings::default(); + settings.column_width_ratio = 0.33; + settings.column_width_presets = vec![0.33, 0.50, 0.66, 0.99]; + let (mut system, layout, _, _) = setup_two_windows(settings); + + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], true); + let state = system.layouts.get(layout).expect("layout state missing"); + let effective = state.column_width_ratio + state.columns[1].width_offset; + assert!( + (effective - 0.99).abs() < 0.01, + "expected last preset 0.99, got {}", + effective + ); + + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], true); + let state = system.layouts.get(layout).expect("layout state missing"); + let effective = state.column_width_ratio + state.columns[1].width_offset; + assert!( + (effective - 0.66).abs() < 0.01, + "expected third preset 0.66, got {}", + effective + ); + } + + #[test] + fn preset_next_wraps_to_first() { + let mut settings = ScrollingLayoutSettings::default(); + settings.column_width_ratio = 0.99; + settings.column_width_presets = vec![0.33, 0.50, 0.66, 0.99]; + let (mut system, layout, _, _) = setup_two_windows(settings); + + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], false); + + let state = system.layouts.get(layout).expect("layout state missing"); + let effective = state.column_width_ratio + state.columns[1].width_offset; + assert!( + (effective - 0.33).abs() < 0.01, + "expected wrap to first preset 0.33, got {}", + effective + ); + } + + #[test] + fn preset_prev_wraps_to_last() { + let mut settings = ScrollingLayoutSettings::default(); + settings.column_width_ratio = 0.33; + settings.column_width_presets = vec![0.33, 0.50, 0.66, 0.99]; + let (mut system, layout, _, _) = setup_two_windows(settings); + + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], true); + + let state = system.layouts.get(layout).expect("layout state missing"); + let effective = state.column_width_ratio + state.columns[1].width_offset; + assert!( + (effective - 0.99).abs() < 0.01, + "expected wrap to last preset 0.99, got {}", + effective + ); + } + + #[test] + fn preset_next_from_fullscreen_goes_to_first() { + let mut settings = ScrollingLayoutSettings::default(); + settings.column_width_presets = vec![0.33, 0.50, 0.66, 0.99]; + let (mut system, layout, _, w2) = setup_two_windows(settings); + + system.toggle_fullscreen_of_selection(layout); + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], false); + + let state = system.layouts.get(layout).expect("layout state missing"); + let effective = state.column_width_ratio + state.columns[1].width_offset; + assert!( + (effective - 0.33).abs() < 0.01, + "expected first preset 0.33 after exiting fullscreen, got {}", + effective + ); + assert!(!state.fullscreen.contains(&w2)); + assert!(!state.fullscreen_within_gaps.contains(&w2)); + } + + #[test] + fn preset_prev_from_fullscreen_goes_to_last() { + let mut settings = ScrollingLayoutSettings::default(); + settings.column_width_presets = vec![0.33, 0.50, 0.66, 0.99]; + let (mut system, layout, _, w2) = setup_two_windows(settings); + + system.toggle_fullscreen_within_gaps_of_selection(layout); + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], true); + + let state = system.layouts.get(layout).expect("layout state missing"); + let effective = state.column_width_ratio + state.columns[1].width_offset; + assert!( + (effective - 0.99).abs() < 0.01, + "expected last preset 0.99 after exiting fullscreen, got {}", + effective + ); + assert!(!state.fullscreen.contains(&w2)); + assert!(!state.fullscreen_within_gaps.contains(&w2)); + } + + #[test] + fn preset_without_selection_adjusts_base_ratio() { + let mut settings = ScrollingLayoutSettings::default(); + settings.column_width_presets = vec![0.33, 0.50, 0.66, 0.99]; + let mut system = ScrollingLayoutSystem::new(&settings); + let layout = system.create_layout(); + let w1 = wid(1, 1); + system.add_window_after_selection(layout, w1); + // Clear selection + { + let state = system.layouts.get_mut(layout).expect("layout state missing"); + state.selected = None; + } + + system.resize_selection_preset(layout, &[0.33, 0.50, 0.66, 0.99], false); + + let state = system.layouts.get(layout).expect("layout state missing"); + assert!( + (state.column_width_ratio - 0.99).abs() < 0.01, + "expected base ratio set to next preset 0.99, got {}", + state.column_width_ratio + ); + } }