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