fix(wm): honor layout_flip when focusing across a boundary

Cross-boundary focus arrival picked the structural leftmost/rightmost
container, ignoring layout_flip. Since layout_flip mirrors geometry
without reordering containers, focusing into a horizontally-flipped
workspace landed on the far edge instead of the container at the edge
the user crossed toward, while intra-workspace focus (via
OperationDirection::destination) already accounts for the flip.

Extract OperationDirection::cross_boundary_edge_index, which picks the
edge index against the flipped direction like destination() does, and
use it at the eight focus-arrival call sites. Add a komorebi-layouts
test that checks the pick against the rendered rectangles.
This commit is contained in:
Matt Kotsenas
2026-07-22 14:15:07 -07:00
committed by Jeezy
parent 8a6868f2d2
commit a10b8e27c1
3 changed files with 142 additions and 16 deletions
@@ -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<usize> {
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<Axis>,
) -> 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;
@@ -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)
);
}
+40 -16
View File
@@ -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(_) => {