diff --git a/komorebi-layouts/src/operation_direction.rs b/komorebi-layouts/src/operation_direction.rs index 55ff0261..c222076e 100644 --- a/komorebi-layouts/src/operation_direction.rs +++ b/komorebi-layouts/src/operation_direction.rs @@ -2,6 +2,7 @@ use std::num::NonZeroUsize; use super::Axis; use super::direction::Direction; +use crate::default_layout::DefaultLayout; use crate::default_layout::LayoutOptions; use clap::ValueEnum; use serde::Deserialize; @@ -61,4 +62,34 @@ impl OperationDirection { ) -> Option { layout.index_in_direction(self.flip(layout_flip), idx, len.get(), layout_options) } + + /// Index of the container to focus when crossing a workspace or monitor + /// boundary by moving in `self` direction into `layout`. + /// + /// `layout_flip` mirrors a layout's geometry without reordering its + /// containers, so the structural [`DefaultLayout::leftmost_index`] / + /// [`DefaultLayout::rightmost_index`] must be selected against the *flipped* + /// direction to match the adjustment [`OperationDirection::destination`] makes + /// for intra-workspace focus. Otherwise, focus crossing a boundary into a + /// flipped workspace lands on the container at the far edge instead of the + /// one the user crossed toward. + #[must_use] + pub fn cross_boundary_edge_index( + self, + layout: DefaultLayout, + len: usize, + layout_flip: Option, + ) -> usize { + match self.flip(layout_flip) { + Self::Left => layout.rightmost_index(len), + Self::Right => layout.leftmost_index(len), + Self::Up | Self::Down => { + unreachable!("only called for horizontal Left/Right crossings") + } + } + } } + +#[cfg(test)] +#[path = "operation_direction_tests.rs"] +mod tests; diff --git a/komorebi-layouts/src/operation_direction_tests.rs b/komorebi-layouts/src/operation_direction_tests.rs new file mode 100644 index 00000000..8cb76c00 --- /dev/null +++ b/komorebi-layouts/src/operation_direction_tests.rs @@ -0,0 +1,71 @@ +use crate::Arrangement; +use crate::Axis; +use crate::DefaultLayout; +use crate::OperationDirection; +use crate::Rect; +use std::num::NonZeroUsize; + +/// A horizontal flip renders BSP container 0 on the right even though it is the +/// structural "leftmost", so a flip-blind edge pick focuses the wrong container +/// when crossing a boundary. This checks `cross_boundary_edge_index` against the +/// rectangles `calculate()` produces: focus lands on the container at +/// the edge the user crossed toward. See the function's doc for why the flip +/// forces this. +#[test] +fn cross_boundary_edge_index_honors_horizontal_flip() { + let layout = DefaultLayout::BSP; + let len = 4usize; + let flip = Some(Axis::Horizontal); + let area = Rect { + left: 0, + top: 0, + right: 3440, + bottom: 1440, + }; + + // The rectangles this flipped layout renders; rects[i] belongs to + // container index i. + let rects = layout.calculate( + &area, + NonZeroUsize::new(len).unwrap(), + None, + flip, + &[], + 0, + None, + &[], + ); + let max_left = rects.iter().map(|r| r.left).max().unwrap(); // right / seam edge + let min_left = rects.iter().map(|r| r.left).min().unwrap(); // left edge + assert_ne!( + max_left, min_left, + "precondition: the flip should spread containers across both horizontal edges" + ); + + // Crossing in by moving Left = entering from the workspace's right edge, so + // focus must land on a container flush with the right edge (max left coord). + let left_arrival = OperationDirection::Left.cross_boundary_edge_index(layout, len, flip); + assert_eq!( + rects[left_arrival].left, max_left, + "focus-left arrival into a horizontally-flipped BSP workspace must land on the \ + right-edge (near-seam) container, not the far edge" + ); + + // Crossing in by moving Right = entering from the left edge. + let right_arrival = OperationDirection::Right.cross_boundary_edge_index(layout, len, flip); + assert_eq!( + rects[right_arrival].left, min_left, + "focus-right arrival into a horizontally-flipped BSP workspace must land on the \ + left-edge container" + ); + + // Without a flip, the pick is unchanged from the structural edge indices. + assert_eq!( + OperationDirection::Left.cross_boundary_edge_index(layout, len, None), + layout.rightmost_index(len) + ); + assert_eq!( + OperationDirection::Right.cross_boundary_edge_index(layout, len, None), + layout.leftmost_index(len) + ); +} diff --git a/komorebi/src/window_manager.rs b/komorebi/src/window_manager.rs index d862a6db..6b7d049c 100644 --- a/komorebi/src/window_manager.rs +++ b/komorebi/src/window_manager.rs @@ -1954,8 +1954,11 @@ impl WindowManager { match direction { OperationDirection::Left => match focused_workspace.layout { Layout::Default(layout) => { - let target_index = - layout.rightmost_index(focused_workspace.containers().len()); + let target_index = direction.cross_boundary_edge_index( + layout, + focused_workspace.containers().len(), + focused_workspace.layout_flip, + ); focused_workspace.focus_container(target_index); } Layout::Custom(_) => { @@ -1966,8 +1969,11 @@ impl WindowManager { }, OperationDirection::Right => match focused_workspace.layout { Layout::Default(layout) => { - let target_index = - layout.leftmost_index(focused_workspace.containers().len()); + let target_index = direction.cross_boundary_edge_index( + layout, + focused_workspace.containers().len(), + focused_workspace.layout_flip, + ); focused_workspace.focus_container(target_index); } Layout::Custom(_) => { @@ -2002,8 +2008,11 @@ impl WindowManager { match direction { OperationDirection::Left => match focused_workspace.layout { Layout::Default(layout) => { - let target_index = - layout.rightmost_index(focused_workspace.containers().len()); + let target_index = direction.cross_boundary_edge_index( + layout, + focused_workspace.containers().len(), + focused_workspace.layout_flip, + ); focused_workspace.focus_container(target_index); } Layout::Custom(_) => { @@ -2014,8 +2023,11 @@ impl WindowManager { }, OperationDirection::Right => match focused_workspace.layout { Layout::Default(layout) => { - let target_index = - layout.leftmost_index(focused_workspace.containers().len()); + let target_index = direction.cross_boundary_edge_index( + layout, + focused_workspace.containers().len(), + focused_workspace.layout_flip, + ); focused_workspace.focus_container(target_index); } Layout::Custom(_) => { @@ -2169,8 +2181,11 @@ impl WindowManager { match direction { OperationDirection::Left => match focused_workspace.layout { Layout::Default(layout) => { - let target_index = - layout.rightmost_index(focused_workspace.containers().len()); + let target_index = direction.cross_boundary_edge_index( + layout, + focused_workspace.containers().len(), + focused_workspace.layout_flip, + ); focused_workspace.focus_container(target_index); } Layout::Custom(_) => { @@ -2181,8 +2196,11 @@ impl WindowManager { }, OperationDirection::Right => match focused_workspace.layout { Layout::Default(layout) => { - let target_index = - layout.leftmost_index(focused_workspace.containers().len()); + let target_index = direction.cross_boundary_edge_index( + layout, + focused_workspace.containers().len(), + focused_workspace.layout_flip, + ); focused_workspace.focus_container(target_index); } Layout::Custom(_) => { @@ -2219,8 +2237,11 @@ impl WindowManager { match direction { OperationDirection::Left => match focused_workspace.layout { Layout::Default(layout) => { - let target_index = layout - .rightmost_index(focused_workspace.containers().len()); + let target_index = direction.cross_boundary_edge_index( + layout, + focused_workspace.containers().len(), + focused_workspace.layout_flip, + ); focused_workspace.focus_container(target_index); } Layout::Custom(_) => { @@ -2231,8 +2252,11 @@ impl WindowManager { }, OperationDirection::Right => match focused_workspace.layout { Layout::Default(layout) => { - let target_index = - layout.leftmost_index(focused_workspace.containers().len()); + let target_index = direction.cross_boundary_edge_index( + layout, + focused_workspace.containers().len(), + focused_workspace.layout_flip, + ); focused_workspace.focus_container(target_index); } Layout::Custom(_) => {