From a63057c232eaa75ecb13f8c8407fcc9254d0a1f8 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Thu, 29 Oct 2026 12:07:22 -0700 Subject: [PATCH] feat(komorebic): pan the Scrolling layout to the left or the right komorebic pan moves the view along the row of the Scrolling layout by a column without moving focus first, and lets it come to rest with a column against an edge of the screen. Focus goes to the nearest column on the screen if the focused one has gone off it. It does nothing in the other layouts. The way that the view goes is the way that focus goes for the commands that have directions: panning to the left brings what is to the left of the screen into view, which is to move the row to the right. This is a command that a wheel can be bound to, as it is with whkd: alt + wheelup : komorebic pan left. --- docs/cli/pan.md | 16 ++++ docs/usage/scrolling-layout.md | 14 +++ komorebi-client/src/lib.rs | 1 + komorebi-layouts/src/lib.rs | 2 + komorebi-layouts/src/pan_direction.rs | 51 +++++++++++ komorebi/src/core/mod.rs | 2 + komorebi/src/lib.rs | 1 + komorebi/src/pan.rs | 30 ++++++ komorebi/src/process_command.rs | 1 + komorebi/src/scrolling.rs | 127 ++++++++++++++++++++++++++ komorebic/src/main.rs | 15 +++ mkdocs.yml | 1 + 12 files changed, 261 insertions(+) create mode 100644 docs/cli/pan.md create mode 100644 komorebi-layouts/src/pan_direction.rs create mode 100644 komorebi/src/pan.rs diff --git a/docs/cli/pan.md b/docs/cli/pan.md new file mode 100644 index 00000000..748fc0d7 --- /dev/null +++ b/docs/cli/pan.md @@ -0,0 +1,16 @@ +``` +Move the view of the Scrolling layout a column to the left or the right, which does nothing in any other layout + +Usage: pan + +Arguments: + + The way that the view goes + + [possible values: left, right] + +Options: + -h, --help + Print help + +``` diff --git a/docs/usage/scrolling-layout.md b/docs/usage/scrolling-layout.md index cefdef41..ecf3984a 100644 --- a/docs/usage/scrolling-layout.md +++ b/docs/usage/scrolling-layout.md @@ -132,6 +132,20 @@ and the screen scrolls back to where it was. If you have, focus goes to the wind on the right, or to the one before it if there is none. A window that is closed without having focus does not take focus with it. +## Panning + +[`komorebic pan`](../cli/pan.md) moves the view along the row without moving focus first. `pan left` brings what is to the +left of the screen into view, and `pan right` what is to the right, by a column. The row +comes to rest with a column against an edge of the screen, and focus goes to the nearest column on the screen if the +focused one has gone off it. + +``` +alt + wheelup : komorebic pan left +alt + wheeldown : komorebic pan right +``` + +It does nothing in the other layouts. + ## Resizing [`komorebic resize-edge`](../cli/resize-edge.md) and [`komorebic resize-axis`](../cli/resize-axis.md) change the width of diff --git a/komorebi-client/src/lib.rs b/komorebi-client/src/lib.rs index db8bbeb6..913c27fd 100644 --- a/komorebi-client/src/lib.rs +++ b/komorebi-client/src/lib.rs @@ -61,6 +61,7 @@ pub use komorebi::core::MonocleFocusBehaviour; pub use komorebi::core::MoveBehaviour; pub use komorebi::core::OperationBehaviour; pub use komorebi::core::OperationDirection; +pub use komorebi::core::PanDirection; pub use komorebi::core::PathExt; pub use komorebi::core::Rect; pub use komorebi::core::Sizing; diff --git a/komorebi-layouts/src/lib.rs b/komorebi-layouts/src/lib.rs index 8010d3dc..b657d3e4 100644 --- a/komorebi-layouts/src/lib.rs +++ b/komorebi-layouts/src/lib.rs @@ -15,6 +15,7 @@ pub mod default_layout; pub mod direction; pub mod layout; pub mod operation_direction; +pub mod pan_direction; pub mod rect; pub mod scrolling; pub mod sizing; @@ -27,5 +28,6 @@ pub use default_layout::*; pub use direction::*; pub use layout::*; pub use operation_direction::*; +pub use pan_direction::*; pub use rect::*; pub use sizing::*; diff --git a/komorebi-layouts/src/pan_direction.rs b/komorebi-layouts/src/pan_direction.rs new file mode 100644 index 00000000..dda06de1 --- /dev/null +++ b/komorebi-layouts/src/pan_direction.rs @@ -0,0 +1,51 @@ +use clap::ValueEnum; +use serde::Deserialize; +use serde::Serialize; +use strum::Display; +use strum::EnumString; + +/// Which way the view goes when the strip of the Scrolling layout is panned: to the left is to +/// see what is to the left of the screen, which is to move the strip to the right +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Display, EnumString, ValueEnum, +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum PanDirection { + Left, + Right, +} + +impl PanDirection { + /// How far the strip is moved on the screen to pan `distance` pixels this way, in pixels, + /// to the right if it is positive + #[must_use] + pub fn strip_offset(self, distance: u32) -> f64 { + match self { + Self::Left => f64::from(distance), + Self::Right => -f64::from(distance), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn the_strip_goes_the_other_way_to_the_view() { + assert!((PanDirection::Left.strip_offset(400) - 400.0).abs() < f64::EPSILON); + assert!((PanDirection::Right.strip_offset(400) + 400.0).abs() < f64::EPSILON); + assert!(PanDirection::Left.strip_offset(0).abs() < f64::EPSILON); + } + + #[test] + fn directions_have_the_names_that_the_others_have() { + assert_eq!( + ::from_str("Left").unwrap(), + PanDirection::Left + ); + assert_eq!(PanDirection::Right.to_string(), "Right"); + assert!(::from_str("Up").is_err()); + } +} diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index ee53c1f0..5c257644 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -38,6 +38,7 @@ pub use komorebi_layouts::MAX_RATIO; pub use komorebi_layouts::MAX_RATIOS; pub use komorebi_layouts::MIN_RATIO; pub use komorebi_layouts::OperationDirection; +pub use komorebi_layouts::PanDirection; pub use komorebi_layouts::Rect; pub use komorebi_layouts::ScrollingLayoutOptions; pub use komorebi_layouts::Sizing; @@ -132,6 +133,7 @@ pub enum SocketMessage { Consume(OperationDirection), Expel, ToggleColumnStack, + Pan(PanDirection), ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf), FlipLayout(Axis), ToggleWorkspaceWindowContainerBehaviour, diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index 90f24f62..4149a2f3 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -13,6 +13,7 @@ pub mod live_resize; pub mod lockable_sequence; pub mod monitor; pub mod monitor_reconciliator; +pub mod pan; pub mod process_command; pub mod process_event; pub mod process_movement; diff --git a/komorebi/src/pan.rs b/komorebi/src/pan.rs new file mode 100644 index 00000000..2af9fa5e --- /dev/null +++ b/komorebi/src/pan.rs @@ -0,0 +1,30 @@ +//! Moving the strip of the Scrolling layout across the screen without changing which window has +//! focus, by a step, which is what this has been asked for so far. + +use crate::core::PanDirection; +use crate::window_manager::WindowManager; +use color_eyre::eyre; + +impl WindowManager { + /// Move the view of the focused workspace one step, a column, to the left or the right, and + /// let it come to rest with a column against an edge of the screen. Focus goes to the nearest column in view if the focused one is + /// not. Nothing happens in a layout that is not Scrolling. + #[tracing::instrument(skip(self))] + pub fn pan(&mut self, direction: PanDirection) -> eyre::Result<()> { + let workspace = self.focused_workspace_mut()?; + + if workspace.begin_scrolling_pan().is_none() { + return Ok(()); + } + + let step = workspace + .scrolling_pan_step() + .and_then(|step| u32::try_from(step).ok()) + .unwrap_or_default(); + + workspace.pan_scrolling(direction.strip_offset(step)); + let focus_moved = workspace.end_scrolling_pan(0.0); + + self.update_focused_workspace(focus_moved && self.mouse_follows_focus, focus_moved) + } +} diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index bc839b83..8fe80a69 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -929,6 +929,7 @@ impl WindowManager { self.focused_workspace_mut()?.expel_scrolling(); self.update_focused_workspace(false, false)?; } + SocketMessage::Pan(direction) => self.pan(direction)?, SocketMessage::ChangeLayout(layout) => self.change_workspace_layout_default(layout)?, SocketMessage::CycleLayout(direction) => self.cycle_layout(direction)?, SocketMessage::LayoutRatios(ref columns, ref rows) => { diff --git a/komorebi/src/scrolling.rs b/komorebi/src/scrolling.rs index cdbdf099..4a3f32d2 100644 --- a/komorebi/src/scrolling.rs +++ b/komorebi/src/scrolling.rs @@ -23,7 +23,9 @@ use crate::workspace::Workspace; use color_eyre::eyre; use komorebi_layouts::scrolling; use komorebi_layouts::scrolling::ScrollingPlacement; +use komorebi_scrolling::ColumnWidth; use komorebi_scrolling::Options; +use komorebi_scrolling::ScrollingSpace; use komorebi_scrolling::Visibility; use std::sync::atomic::Ordering; @@ -712,6 +714,70 @@ impl Workspace { } } + /// Start moving the strip across the screen by hand, and give back the strip as it is, for + /// putting it back if the pan is given up. Only the Scrolling layout has a strip to move, and + /// not before a layout has made it. + pub(crate) fn begin_scrolling_pan(&mut self) -> Option> { + if !self.is_scrolling() || self.scrolling.is_none() { + return None; + } + + self.sync_scrolling_containers(); + + let space = self.scrolling.as_mut()?; + let saved = space.clone(); + space.begin_pan(); + + Some(saved) + } + + /// Move the strip by `dx` pixels on the screen, to the right if it is positive. Nothing else + /// changes: not which container has focus, nor the width of any. + pub(crate) fn pan_scrolling(&mut self, dx: f64) { + if let Some(space) = self.scrolling.as_mut() { + space.pan_by(dx); + } + } + + /// Let go of the strip, going at `velocity` pixels a second on the screen, to the right if it + /// is positive, and let it come to rest, which is with a column against an edge of the screen. + /// Focus goes to the nearest column that is fully in view if the focused one is not, and + /// this tells whether it moved. + pub(crate) fn end_scrolling_pan(&mut self, velocity: f64) -> bool { + let Some(space) = self.scrolling.as_mut() else { + return false; + }; + + if space.pan_end(velocity).is_none() { + return false; + } + + let focused = space.active_tile_index(); + let before = self.focused_container_idx(); + + match focused { + Some(idx) if idx != before => { + self.focus_container(idx); + true + } + _ => false, + } + } + + /// How far one step of a pan goes, if nothing says: a column of the width that a new one + /// is given. + pub(crate) fn scrolling_pan_step(&self) -> Option { + let space = self.scrolling.as_ref()?; + let viewport = f64::from(space.viewport().width); + + let step = match space.options().default_width { + ColumnWidth::Proportion(share) => viewport * share, + ColumnWidth::Fixed(width) => f64::from(width), + }; + + Some(step.round() as i32) + } + /// Centre the focused column on the screen, this once. pub(crate) fn center_scrolling_column(&mut self) { if self.focused_scrolling_column().is_some() @@ -1046,4 +1112,65 @@ mod tests { assert!(workspace.scrolling.is_none()); } + + #[test] + fn panning_moves_the_strip_and_leaves_focus_where_it_is_until_it_is_let_go() { + let mut workspace = workspace(3, 6); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(0); + let before = workspace.layout_scrolling(&AREA, 0); + + assert!(workspace.begin_scrolling_pan().is_some()); + workspace.pan_scrolling(-300.0); + let panned = workspace.layout_scrolling(&AREA, 0); + + assert_eq!( + panned[1].rect.left, + before[1].rect.left - 300, + "the strip moved" + ); + assert_eq!(workspace.focused_container_idx(), 0, "and focus did not"); + } + + #[test] + fn a_strip_that_is_let_go_comes_to_rest_and_focus_goes_to_a_column_in_view() { + let mut workspace = workspace(3, 6); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(0); + + assert!(workspace.begin_scrolling_pan().is_some()); + // three columns to the left of the screen, which is where the first three are + workspace.pan_scrolling(-1200.0); + assert!(workspace.end_scrolling_pan(0.0), "focus moved"); + + let rested = workspace.layout_scrolling(&AREA, 0); + let focused = workspace.focused_container_idx(); + + assert!(focused >= 3, "to a column that is in view, not {focused}"); + assert_eq!(rested[focused].visibility, Visibility::Visible); + } + + #[test] + fn there_is_no_strip_to_pan_in_another_layout_or_before_there_is_one() { + let mut workspace = workspace(3, 6); + assert!(workspace.begin_scrolling_pan().is_none(), "no layout yet"); + assert!(!workspace.end_scrolling_pan(0.0)); + + workspace.layout_scrolling(&AREA, 0); + workspace.layout = Layout::Default(DefaultLayout::BSP); + assert!(workspace.begin_scrolling_pan().is_none()); + } + + #[test] + fn a_step_of_a_pan_is_a_column_of_the_width_that_a_new_one_has() { + let mut workspace = workspace(3, 6); + assert_eq!(workspace.scrolling_pan_step(), None, "no layout yet"); + + workspace.layout_scrolling(&AREA, 0); + assert_eq!(workspace.scrolling_pan_step(), Some(400)); + + workspace.set_scrolling_columns(4); + workspace.layout_scrolling(&AREA, 0); + assert_eq!(workspace.scrolling_pan_step(), Some(300)); + } } diff --git a/komorebic/src/main.rs b/komorebic/src/main.rs index 1819f540..c1abe65d 100644 --- a/komorebic/src/main.rs +++ b/komorebic/src/main.rs @@ -55,6 +55,7 @@ use komorebi_client::HidingBehaviour; use komorebi_client::MoveBehaviour; use komorebi_client::OperationBehaviour; use komorebi_client::OperationDirection; +use komorebi_client::PanDirection; use komorebi_client::Rect; use komorebi_client::Sizing; use komorebi_client::SocketMessage; @@ -1009,6 +1010,13 @@ struct ScrollingLayoutColumns { count: NonZeroUsize, } +#[derive(Parser)] +struct Pan { + /// The way that the view goes + #[clap(value_enum)] + direction: PanDirection, +} + #[derive(Parser)] struct LayoutRatios { /// Column width ratios (space-separated values between 0.1 and 0.9) @@ -1302,6 +1310,10 @@ enum SubCommand { /// Stack the windows of the focused column of the Scrolling layout, or spread the focused /// stack out into a column with a window in each slot ToggleColumnStack, + /// Move the view of the Scrolling layout a column to the left or the right, which does + /// nothing in any other layout + #[clap(arg_required_else_help = true)] + Pan(Pan), /// Set the layout column and row ratios for the focused workspace LayoutRatios(LayoutRatios), /// Load a custom layout from file for the focused workspace @@ -2992,6 +3004,9 @@ if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { SubCommand::ToggleColumnStack => { send_message(&SocketMessage::ToggleColumnStack)?; } + SubCommand::Pan(args) => { + send_message(&SocketMessage::Pan(args.direction))?; + } SubCommand::LayoutRatios(args) => { if args.columns.is_none() && args.rows.is_none() { println!( diff --git a/mkdocs.yml b/mkdocs.yml index babbd451..7b55417b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -176,6 +176,7 @@ nav: - cli/scrolling-layout-full-width.md - cli/center-focus.md - cli/expand.md + - cli/pan.md - cli/consume.md - cli/expel.md - cli/toggle-column-stack.md