feat(komorebic): pan the Scrolling layout to the left or the right

komorebic pan <left|right> 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.
This commit is contained in:
LGUG2Z committed 2026-10-30 15:45:37 -07:00
1 parent dd961f50f6
commit a63057c232
12 files changed
+261

No files matched your search

+16
View File
@@ -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 <DIRECTION>
Arguments:
<DIRECTION>
The way that the view goes
[possible values: left, right]
Options:
-h, --help
Print help
```
+14
View File
@@ -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 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. 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 ## Resizing
[`komorebic resize-edge`](../cli/resize-edge.md) and [`komorebic resize-axis`](../cli/resize-axis.md) change the width of [`komorebic resize-edge`](../cli/resize-edge.md) and [`komorebic resize-axis`](../cli/resize-axis.md) change the width of
+1
View File
@@ -61,6 +61,7 @@ pub use komorebi::core::MonocleFocusBehaviour;
pub use komorebi::core::MoveBehaviour; pub use komorebi::core::MoveBehaviour;
pub use komorebi::core::OperationBehaviour; pub use komorebi::core::OperationBehaviour;
pub use komorebi::core::OperationDirection; pub use komorebi::core::OperationDirection;
pub use komorebi::core::PanDirection;
pub use komorebi::core::PathExt; pub use komorebi::core::PathExt;
pub use komorebi::core::Rect; pub use komorebi::core::Rect;
pub use komorebi::core::Sizing; pub use komorebi::core::Sizing;
+2
View File
@@ -15,6 +15,7 @@ pub mod default_layout;
pub mod direction; pub mod direction;
pub mod layout; pub mod layout;
pub mod operation_direction; pub mod operation_direction;
pub mod pan_direction;
pub mod rect; pub mod rect;
pub mod scrolling; pub mod scrolling;
pub mod sizing; pub mod sizing;
@@ -27,5 +28,6 @@ pub use default_layout::*;
pub use direction::*; pub use direction::*;
pub use layout::*; pub use layout::*;
pub use operation_direction::*; pub use operation_direction::*;
pub use pan_direction::*;
pub use rect::*; pub use rect::*;
pub use sizing::*; pub use sizing::*;
+51
View File
@@ -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!(
<PanDirection as FromStr>::from_str("Left").unwrap(),
PanDirection::Left
);
assert_eq!(PanDirection::Right.to_string(), "Right");
assert!(<PanDirection as FromStr>::from_str("Up").is_err());
}
}
+2
View File
@@ -38,6 +38,7 @@ pub use komorebi_layouts::MAX_RATIO;
pub use komorebi_layouts::MAX_RATIOS; pub use komorebi_layouts::MAX_RATIOS;
pub use komorebi_layouts::MIN_RATIO; pub use komorebi_layouts::MIN_RATIO;
pub use komorebi_layouts::OperationDirection; pub use komorebi_layouts::OperationDirection;
pub use komorebi_layouts::PanDirection;
pub use komorebi_layouts::Rect; pub use komorebi_layouts::Rect;
pub use komorebi_layouts::ScrollingLayoutOptions; pub use komorebi_layouts::ScrollingLayoutOptions;
pub use komorebi_layouts::Sizing; pub use komorebi_layouts::Sizing;
@@ -132,6 +133,7 @@ pub enum SocketMessage {
Consume(OperationDirection), Consume(OperationDirection),
Expel, Expel,
ToggleColumnStack, ToggleColumnStack,
Pan(PanDirection),
ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf), ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf),
FlipLayout(Axis), FlipLayout(Axis),
ToggleWorkspaceWindowContainerBehaviour, ToggleWorkspaceWindowContainerBehaviour,
+1
View File
@@ -13,6 +13,7 @@ pub mod live_resize;
pub mod lockable_sequence; pub mod lockable_sequence;
pub mod monitor; pub mod monitor;
pub mod monitor_reconciliator; pub mod monitor_reconciliator;
pub mod pan;
pub mod process_command; pub mod process_command;
pub mod process_event; pub mod process_event;
pub mod process_movement; pub mod process_movement;
+30
View File
@@ -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)
}
}
+1
View File
@@ -929,6 +929,7 @@ impl WindowManager {
self.focused_workspace_mut()?.expel_scrolling(); self.focused_workspace_mut()?.expel_scrolling();
self.update_focused_workspace(false, false)?; self.update_focused_workspace(false, false)?;
} }
SocketMessage::Pan(direction) => self.pan(direction)?,
SocketMessage::ChangeLayout(layout) => self.change_workspace_layout_default(layout)?, SocketMessage::ChangeLayout(layout) => self.change_workspace_layout_default(layout)?,
SocketMessage::CycleLayout(direction) => self.cycle_layout(direction)?, SocketMessage::CycleLayout(direction) => self.cycle_layout(direction)?,
SocketMessage::LayoutRatios(ref columns, ref rows) => { SocketMessage::LayoutRatios(ref columns, ref rows) => {
+127
View File
@@ -23,7 +23,9 @@ use crate::workspace::Workspace;
use color_eyre::eyre; use color_eyre::eyre;
use komorebi_layouts::scrolling; use komorebi_layouts::scrolling;
use komorebi_layouts::scrolling::ScrollingPlacement; use komorebi_layouts::scrolling::ScrollingPlacement;
use komorebi_scrolling::ColumnWidth;
use komorebi_scrolling::Options; use komorebi_scrolling::Options;
use komorebi_scrolling::ScrollingSpace;
use komorebi_scrolling::Visibility; use komorebi_scrolling::Visibility;
use std::sync::atomic::Ordering; 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<ScrollingSpace<String>> {
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<i32> {
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. /// Centre the focused column on the screen, this once.
pub(crate) fn center_scrolling_column(&mut self) { pub(crate) fn center_scrolling_column(&mut self) {
if self.focused_scrolling_column().is_some() if self.focused_scrolling_column().is_some()
@@ -1046,4 +1112,65 @@ mod tests {
assert!(workspace.scrolling.is_none()); 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));
}
} }
+15
View File
@@ -55,6 +55,7 @@ use komorebi_client::HidingBehaviour;
use komorebi_client::MoveBehaviour; use komorebi_client::MoveBehaviour;
use komorebi_client::OperationBehaviour; use komorebi_client::OperationBehaviour;
use komorebi_client::OperationDirection; use komorebi_client::OperationDirection;
use komorebi_client::PanDirection;
use komorebi_client::Rect; use komorebi_client::Rect;
use komorebi_client::Sizing; use komorebi_client::Sizing;
use komorebi_client::SocketMessage; use komorebi_client::SocketMessage;
@@ -1009,6 +1010,13 @@ struct ScrollingLayoutColumns {
count: NonZeroUsize, count: NonZeroUsize,
} }
#[derive(Parser)]
struct Pan {
/// The way that the view goes
#[clap(value_enum)]
direction: PanDirection,
}
#[derive(Parser)] #[derive(Parser)]
struct LayoutRatios { struct LayoutRatios {
/// Column width ratios (space-separated values between 0.1 and 0.9) /// 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 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 /// stack out into a column with a window in each slot
ToggleColumnStack, 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 /// Set the layout column and row ratios for the focused workspace
LayoutRatios(LayoutRatios), LayoutRatios(LayoutRatios),
/// Load a custom layout from file for the focused workspace /// Load a custom layout from file for the focused workspace
@@ -2992,6 +3004,9 @@ if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) {
SubCommand::ToggleColumnStack => { SubCommand::ToggleColumnStack => {
send_message(&SocketMessage::ToggleColumnStack)?; send_message(&SocketMessage::ToggleColumnStack)?;
} }
SubCommand::Pan(args) => {
send_message(&SocketMessage::Pan(args.direction))?;
}
SubCommand::LayoutRatios(args) => { SubCommand::LayoutRatios(args) => {
if args.columns.is_none() && args.rows.is_none() { if args.columns.is_none() && args.rows.is_none() {
println!( println!(
+1
View File
@@ -176,6 +176,7 @@ nav:
- cli/scrolling-layout-full-width.md - cli/scrolling-layout-full-width.md
- cli/center-focus.md - cli/center-focus.md
- cli/expand.md - cli/expand.md
- cli/pan.md
- cli/consume.md - cli/consume.md
- cli/expel.md - cli/expel.md
- cli/toggle-column-stack.md - cli/toggle-column-stack.md