fix(core): focus the neighbor workspace before the neighbor screen at the column edge
- commit
- 01e6d9a74f6f218a15d4dba5661ecb314f136eed
- author
- brice · · 2026-10-19 15:18 UTC
- parent
- e8a5ba6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +64 -34
M internal/core/action.go +6 -6
| @@ -389,19 +389,19 @@ func (c *Core) dispatchAction(a Action) Effect { | |||
| 389 | 389 | } | |
| 390 | 390 | ||
| 391 | 391 | // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the | |
| 392 | - | // content edge, in order: to the neighbor monitor on that side; else to the | |
| 393 | - | // occupied workspace on that side when the workspace axis is horizontal; | |
| 392 | + | // content edge, in order: to the occupied workspace on that side when the | |
| 393 | + | // workspace axis is horizontal; else to the neighbor monitor on that side; | |
| 394 | 394 | // else to the trailing empty workspace. It reports whether focus moved. | |
| 395 | 395 | func (c *Core) edgeFocus(dir int, d direction) bool { | |
| 396 | 396 | m := c.cur().mon | |
| 397 | - | if i := c.neighbor(d); i >= 0 { | |
| 398 | - | c.focusScreen = i | |
| 399 | - | return true | |
| 400 | - | } | |
| 401 | 397 | if to := m.occupiedStep(horizontalAxis, dir); to >= 0 { | |
| 402 | 398 | m.Focus(to) | |
| 403 | 399 | return true | |
| 404 | 400 | } | |
| 401 | + | if i := c.neighbor(d); i >= 0 { | |
| 402 | + | c.focusScreen = i | |
| 403 | + | return true | |
| 404 | + | } | |
| 405 | 405 | return m.stepWorkspace(horizontalAxis, dir) | |
| 406 | 406 | } | |
| 407 | 407 | ||
| @@ -389,19 +389,19 @@ func (c *Core) dispatchAction(a Action) Effect { | |||||
| 389 | } | 389 | } | ||
| 390 | 390 | ||||
| 391 | // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the | 391 | // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the | ||
| 392 | - | // content edge, in order: to the neighbor monitor on that side; else to the | 392 | + | // content edge, in order: to the occupied workspace on that side when the |
| 393 | - | // occupied workspace on that side when the workspace axis is horizontal; | 393 | + | // workspace axis is horizontal; else to the neighbor monitor on that side; |
| 394 | // else to the trailing empty workspace. It reports whether focus moved. | 394 | // else to the trailing empty workspace. It reports whether focus moved. | ||
| 395 | func (c *Core) edgeFocus(dir int, d direction) bool { | 395 | func (c *Core) edgeFocus(dir int, d direction) bool { | ||
| 396 | m := c.cur().mon | 396 | m := c.cur().mon | ||
| 397 | - | if i := c.neighbor(d); i >= 0 { | |||
| 398 | - | c.focusScreen = i | |||
| 399 | - | return true | |||
| 400 | - | } | |||
| 401 | if to := m.occupiedStep(horizontalAxis, dir); to >= 0 { | 397 | if to := m.occupiedStep(horizontalAxis, dir); to >= 0 { | ||
| 402 | m.Focus(to) | 398 | m.Focus(to) | ||
| 403 | return true | 399 | return true | ||
| 404 | } | 400 | } | ||
| 401 | + | if i := c.neighbor(d); i >= 0 { | |||
| 402 | + | c.focusScreen = i | |||
| 403 | + | return true | |||
| 404 | + | } | |||
| 405 | return m.stepWorkspace(horizontalAxis, dir) | 405 | return m.stepWorkspace(horizontalAxis, dir) | ||
| 406 | } | 406 | } | ||
| 407 | 407 | ||||
M internal/core/cascade_edges_test.go +4 -5
| @@ -43,9 +43,8 @@ func TestCascadeMoveColumnInsideBandSwaps(t *testing.T) { | |||
| 43 | 43 | wantColumns(t, w, 2, 1, 3, 4, 5) | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | - | // A neighbor monitor on the left screen's right side wins over an occupied | |
| 47 | - | // workspace there: focus-column-right at the band edge goes to the screen. | |
| 48 | - | func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) { | |
| 46 | + | // An occupied workspace wins over the neighbor monitor at the band edge. | |
| 47 | + | func TestCascadeFocusColumnEdgePrefersWorkspaceOverMonitor(t *testing.T) { | |
| 49 | 48 | c := layoutCore(0, tile{"L", 0, 0, 900, 600}, tile{"R", 900, 0, 900, 600}) | |
| 50 | 49 | m := c.cur().mon | |
| 51 | 50 | m.SetOverflow(OverflowCascade) | |
| @@ -58,8 +57,8 @@ func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) { | |||
| 58 | 57 | m.normalize() | |
| 59 | 58 | m.Current().FocusID(2) | |
| 60 | 59 | c.applyAction(ActionFocusColumnRight) | |
| 61 | - | wantFocus(t, c, "R", 0) | |
| 62 | - | wantActive(t, m, 0) | |
| 60 | + | wantFocus(t, c, "L", 3) | |
| 61 | + | wantActive(t, m, 1) | |
| 63 | 62 | } | |
| 64 | 63 | ||
| 65 | 64 | // With no monitor on that side, the occupied neighbor workspace is next. | |
| @@ -43,9 +43,8 @@ func TestCascadeMoveColumnInsideBandSwaps(t *testing.T) { | |||||
| 43 | wantColumns(t, w, 2, 1, 3, 4, 5) | 43 | wantColumns(t, w, 2, 1, 3, 4, 5) | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | - | // A neighbor monitor on the left screen's right side wins over an occupied | 46 | + | // An occupied workspace wins over the neighbor monitor at the band edge. |
| 47 | - | // workspace there: focus-column-right at the band edge goes to the screen. | 47 | + | func TestCascadeFocusColumnEdgePrefersWorkspaceOverMonitor(t *testing.T) { |
| 48 | - | func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) { | |||
| 49 | c := layoutCore(0, tile{"L", 0, 0, 900, 600}, tile{"R", 900, 0, 900, 600}) | 48 | c := layoutCore(0, tile{"L", 0, 0, 900, 600}, tile{"R", 900, 0, 900, 600}) | ||
| 50 | m := c.cur().mon | 49 | m := c.cur().mon | ||
| 51 | m.SetOverflow(OverflowCascade) | 50 | m.SetOverflow(OverflowCascade) | ||
| @@ -58,8 +57,8 @@ func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) { | |||||
| 58 | m.normalize() | 57 | m.normalize() | ||
| 59 | m.Current().FocusID(2) | 58 | m.Current().FocusID(2) | ||
| 60 | c.applyAction(ActionFocusColumnRight) | 59 | c.applyAction(ActionFocusColumnRight) | ||
| 61 | - | wantFocus(t, c, "R", 0) | 60 | + | wantFocus(t, c, "L", 3) |
| 62 | - | wantActive(t, m, 0) | 61 | + | wantActive(t, m, 1) |
| 63 | } | 62 | } | ||
| 64 | 63 | ||||
| 65 | // With no monitor on that side, the occupied neighbor workspace is next. | 64 | // With no monitor on that side, the occupied neighbor workspace is next. | ||
M internal/core/workspace_axis_test.go +54 -23
| @@ -50,26 +50,22 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) { | |||
| 50 | 50 | } | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | - | // A monitor on that side wins over an occupied workspace, so the opposite | |
| 54 | - | // key brings focus back; the workspace is reached only with no monitor. | |
| 55 | - | func TestCascadeFocusColumnGoesToMonitorBeforeWorkspace(t *testing.T) { | |
| 53 | + | // Occupied workspaces win over monitors; monitors still precede trailing empties. | |
| 54 | + | func TestCascadeFocusColumnGoesToWorkspaceBeforeMonitor(t *testing.T) { | |
| 56 | 55 | c, m := axisCore(t, true, OverflowCascade, 3) | |
| 57 | 56 | m.Current().FocusID(23) | |
| 58 | 57 | c.applyAction(ActionFocusColumnRight) | |
| 59 | - | wantFocus(t, c, "R", 0) | |
| 60 | - | wantActive(t, m, 1) | |
| 61 | - | // Back with the opposite key. | |
| 62 | - | c.applyAction(ActionFocusColumnLeft) | |
| 63 | - | wantFocus(t, c, "M", 23) | |
| 64 | - | wantActive(t, m, 1) | |
| 65 | - | // The first column of a band: the monitor on the left. | |
| 58 | + | wantActive(t, m, 2) | |
| 59 | + | if c.cur().mon != m { | |
| 60 | + | t.Fatal("left monitor before workspace") | |
| 61 | + | } | |
| 62 | + | m.Focus(1) | |
| 66 | 63 | m.Current().FocusID(24) | |
| 67 | 64 | c.applyAction(ActionFocusColumnLeft) | |
| 68 | - | wantFocus(t, c, "L", 0) | |
| 69 | - | wantActive(t, m, 1) | |
| 70 | - | // From the last occupied workspace: the monitor on the right, not the | |
| 71 | - | // trailing empty workspace. | |
| 72 | - | c.focusScreen = 1 | |
| 65 | + | wantActive(t, m, 0) | |
| 66 | + | if c.cur().mon != m { | |
| 67 | + | t.Fatal("left monitor before workspace") | |
| 68 | + | } | |
| 73 | 69 | m.Focus(2) | |
| 74 | 70 | m.Current().FocusID(35) | |
| 75 | 71 | c.applyAction(ActionFocusColumnRight) | |
| @@ -93,8 +89,8 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T) | |||
| 93 | 89 | } | |
| 94 | 90 | ||
| 95 | 91 | // One monitor on the left only. Right steps to the next workspace; left | |
| 96 | - | // from its empty band leaves for that monitor, which comes first. | |
| 97 | - | func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) { | |
| 92 | + | // from its empty band returns to the occupied workspace before that monitor. | |
| 93 | + | func TestCascadeStepRightThenLeftReturnsToWorkspace(t *testing.T) { | |
| 98 | 94 | c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600}) | |
| 99 | 95 | m := c.cur().mon | |
| 100 | 96 | m.SetOverflow(OverflowCascade) | |
| @@ -104,8 +100,8 @@ func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) { | |||
| 104 | 100 | c.applyAction(ActionFocusColumnRight) | |
| 105 | 101 | wantActive(t, m, 1) | |
| 106 | 102 | c.applyAction(ActionFocusColumnLeft) | |
| 107 | - | wantFocus(t, c, "L", 0) | |
| 108 | - | wantActive(t, m, 1) | |
| 103 | + | wantFocus(t, c, "M", 5) | |
| 104 | + | wantActive(t, m, 0) | |
| 109 | 105 | } | |
| 110 | 106 | ||
| 111 | 107 | // AC3: up/down cross bands and stop at the last, with no workspace change. | |
| @@ -189,15 +185,17 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) { | |||
| 189 | 185 | } | |
| 190 | 186 | ||
| 191 | 187 | // A covering fullscreen tile follows the same order: the monitor on that | |
| 192 | - | // side before the occupied workspace. | |
| 193 | - | func TestCascadeFullscreenGoesToMonitorBeforeWorkspace(t *testing.T) { | |
| 188 | + | // side after the occupied workspace. | |
| 189 | + | func TestCascadeFullscreenGoesToWorkspaceBeforeMonitor(t *testing.T) { | |
| 194 | 190 | c, m := axisCore(t, true, OverflowCascade, 3) | |
| 195 | 191 | w := m.Current() | |
| 196 | 192 | w.FocusID(23) | |
| 197 | 193 | w.ToggleFullscreen() | |
| 198 | 194 | c.applyAction(ActionFocusColumnRight) | |
| 199 | - | wantFocus(t, c, "R", 0) | |
| 200 | - | wantActive(t, m, 1) | |
| 195 | + | wantActive(t, m, 2) | |
| 196 | + | if c.cur().mon != m { | |
| 197 | + | t.Fatal("left monitor before workspace") | |
| 198 | + | } | |
| 201 | 199 | if w.fullscreen != 23 { | |
| 202 | 200 | t.Fatalf("fullscreen %d, want 23", w.fullscreen) | |
| 203 | 201 | } | |
| @@ -244,3 +242,36 @@ func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) { | |||
| 244 | 242 | wantActive(t, m, tc.want) | |
| 245 | 243 | } | |
| 246 | 244 | } | |
| 245 | + | ||
| 246 | + | // Column edges visit occupied workspaces before crossing outputs, in both directions. | |
| 247 | + | func TestColumnEdgeFocusWorkspaceBeforeScreen(t *testing.T) { | |
| 248 | + | for _, tc := range []struct { | |
| 249 | + | name string | |
| 250 | + | screen, start, next, neighbor int | |
| 251 | + | action Action | |
| 252 | + | }{ | |
| 253 | + | {"left", 1, 1, 0, 0, ActionFocusColumnLeft}, | |
| 254 | + | {"right", 0, 0, 1, 1, ActionFocusColumnRight}, | |
| 255 | + | } { | |
| 256 | + | t.Run(tc.name, func(t *testing.T) { | |
| 257 | + | c := layoutCore(tc.screen, tile{"laptop", 0, 0, 1920, 1080}, tile{"4K", 1920, 0, 3840, 2160}) | |
| 258 | + | m := c.cur().mon | |
| 259 | + | m.SetOverflow(OverflowCascade) | |
| 260 | + | for ws := range 2 { | |
| 261 | + | m.Focus(ws) | |
| 262 | + | m.AddWindow(WindowID(ws + 1)) | |
| 263 | + | } | |
| 264 | + | m.Focus(tc.start) | |
| 265 | + | c.applyAction(tc.action) | |
| 266 | + | if c.focusScreen != tc.screen { | |
| 267 | + | t.Fatalf("screen %d, want %d before crossing workspace", c.focusScreen, tc.screen) | |
| 268 | + | } | |
| 269 | + | wantActive(t, m, tc.next) | |
| 270 | + | c.applyAction(tc.action) | |
| 271 | + | if c.focusScreen != tc.neighbor { | |
| 272 | + | t.Fatalf("screen %d, want neighbor %d", c.focusScreen, tc.neighbor) | |
| 273 | + | } | |
| 274 | + | wantActive(t, m, tc.next) | |
| 275 | + | }) | |
| 276 | + | } | |
| 277 | + | } | |
| @@ -50,26 +50,22 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) { | |||||
| 50 | } | 50 | } | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | - | // A monitor on that side wins over an occupied workspace, so the opposite | 53 | + | // Occupied workspaces win over monitors; monitors still precede trailing empties. |
| 54 | - | // key brings focus back; the workspace is reached only with no monitor. | 54 | + | func TestCascadeFocusColumnGoesToWorkspaceBeforeMonitor(t *testing.T) { |
| 55 | - | func TestCascadeFocusColumnGoesToMonitorBeforeWorkspace(t *testing.T) { | |||
| 56 | c, m := axisCore(t, true, OverflowCascade, 3) | 55 | c, m := axisCore(t, true, OverflowCascade, 3) | ||
| 57 | m.Current().FocusID(23) | 56 | m.Current().FocusID(23) | ||
| 58 | c.applyAction(ActionFocusColumnRight) | 57 | c.applyAction(ActionFocusColumnRight) | ||
| 59 | - | wantFocus(t, c, "R", 0) | 58 | + | wantActive(t, m, 2) |
| 60 | - | wantActive(t, m, 1) | 59 | + | if c.cur().mon != m { |
| 61 | - | // Back with the opposite key. | 60 | + | t.Fatal("left monitor before workspace") |
| 62 | - | c.applyAction(ActionFocusColumnLeft) | 61 | + | } |
| 63 | - | wantFocus(t, c, "M", 23) | 62 | + | m.Focus(1) |
| 64 | - | wantActive(t, m, 1) | |||
| 65 | - | // The first column of a band: the monitor on the left. | |||
| 66 | m.Current().FocusID(24) | 63 | m.Current().FocusID(24) | ||
| 67 | c.applyAction(ActionFocusColumnLeft) | 64 | c.applyAction(ActionFocusColumnLeft) | ||
| 68 | - | wantFocus(t, c, "L", 0) | 65 | + | wantActive(t, m, 0) |
| 69 | - | wantActive(t, m, 1) | 66 | + | if c.cur().mon != m { |
| 70 | - | // From the last occupied workspace: the monitor on the right, not the | 67 | + | t.Fatal("left monitor before workspace") |
| 71 | - | // trailing empty workspace. | 68 | + | } |
| 72 | - | c.focusScreen = 1 | |||
| 73 | m.Focus(2) | 69 | m.Focus(2) | ||
| 74 | m.Current().FocusID(35) | 70 | m.Current().FocusID(35) | ||
| 75 | c.applyAction(ActionFocusColumnRight) | 71 | c.applyAction(ActionFocusColumnRight) | ||
| @@ -93,8 +89,8 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T) | |||||
| 93 | } | 89 | } | ||
| 94 | 90 | ||||
| 95 | // One monitor on the left only. Right steps to the next workspace; left | 91 | // One monitor on the left only. Right steps to the next workspace; left | ||
| 96 | - | // from its empty band leaves for that monitor, which comes first. | 92 | + | // from its empty band returns to the occupied workspace before that monitor. |
| 97 | - | func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) { | 93 | + | func TestCascadeStepRightThenLeftReturnsToWorkspace(t *testing.T) { |
| 98 | c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600}) | 94 | c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600}) | ||
| 99 | m := c.cur().mon | 95 | m := c.cur().mon | ||
| 100 | m.SetOverflow(OverflowCascade) | 96 | m.SetOverflow(OverflowCascade) | ||
| @@ -104,8 +100,8 @@ func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) { | |||||
| 104 | c.applyAction(ActionFocusColumnRight) | 100 | c.applyAction(ActionFocusColumnRight) | ||
| 105 | wantActive(t, m, 1) | 101 | wantActive(t, m, 1) | ||
| 106 | c.applyAction(ActionFocusColumnLeft) | 102 | c.applyAction(ActionFocusColumnLeft) | ||
| 107 | - | wantFocus(t, c, "L", 0) | 103 | + | wantFocus(t, c, "M", 5) |
| 108 | - | wantActive(t, m, 1) | 104 | + | wantActive(t, m, 0) |
| 109 | } | 105 | } | ||
| 110 | 106 | ||||
| 111 | // AC3: up/down cross bands and stop at the last, with no workspace change. | 107 | // AC3: up/down cross bands and stop at the last, with no workspace change. | ||
| @@ -189,15 +185,17 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) { | |||||
| 189 | } | 185 | } | ||
| 190 | 186 | ||||
| 191 | // A covering fullscreen tile follows the same order: the monitor on that | 187 | // A covering fullscreen tile follows the same order: the monitor on that | ||
| 192 | - | // side before the occupied workspace. | 188 | + | // side after the occupied workspace. |
| 193 | - | func TestCascadeFullscreenGoesToMonitorBeforeWorkspace(t *testing.T) { | 189 | + | func TestCascadeFullscreenGoesToWorkspaceBeforeMonitor(t *testing.T) { |
| 194 | c, m := axisCore(t, true, OverflowCascade, 3) | 190 | c, m := axisCore(t, true, OverflowCascade, 3) | ||
| 195 | w := m.Current() | 191 | w := m.Current() | ||
| 196 | w.FocusID(23) | 192 | w.FocusID(23) | ||
| 197 | w.ToggleFullscreen() | 193 | w.ToggleFullscreen() | ||
| 198 | c.applyAction(ActionFocusColumnRight) | 194 | c.applyAction(ActionFocusColumnRight) | ||
| 199 | - | wantFocus(t, c, "R", 0) | 195 | + | wantActive(t, m, 2) |
| 200 | - | wantActive(t, m, 1) | 196 | + | if c.cur().mon != m { |
| 197 | + | t.Fatal("left monitor before workspace") | |||
| 198 | + | } | |||
| 201 | if w.fullscreen != 23 { | 199 | if w.fullscreen != 23 { | ||
| 202 | t.Fatalf("fullscreen %d, want 23", w.fullscreen) | 200 | t.Fatalf("fullscreen %d, want 23", w.fullscreen) | ||
| 203 | } | 201 | } | ||
| @@ -244,3 +242,36 @@ func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) { | |||||
| 244 | wantActive(t, m, tc.want) | 242 | wantActive(t, m, tc.want) | ||
| 245 | } | 243 | } | ||
| 246 | } | 244 | } | ||
| 245 | + | ||||
| 246 | + | // Column edges visit occupied workspaces before crossing outputs, in both directions. | |||
| 247 | + | func TestColumnEdgeFocusWorkspaceBeforeScreen(t *testing.T) { | |||
| 248 | + | for _, tc := range []struct { | |||
| 249 | + | name string | |||
| 250 | + | screen, start, next, neighbor int | |||
| 251 | + | action Action | |||
| 252 | + | }{ | |||
| 253 | + | {"left", 1, 1, 0, 0, ActionFocusColumnLeft}, | |||
| 254 | + | {"right", 0, 0, 1, 1, ActionFocusColumnRight}, | |||
| 255 | + | } { | |||
| 256 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 257 | + | c := layoutCore(tc.screen, tile{"laptop", 0, 0, 1920, 1080}, tile{"4K", 1920, 0, 3840, 2160}) | |||
| 258 | + | m := c.cur().mon | |||
| 259 | + | m.SetOverflow(OverflowCascade) | |||
| 260 | + | for ws := range 2 { | |||
| 261 | + | m.Focus(ws) | |||
| 262 | + | m.AddWindow(WindowID(ws + 1)) | |||
| 263 | + | } | |||
| 264 | + | m.Focus(tc.start) | |||
| 265 | + | c.applyAction(tc.action) | |||
| 266 | + | if c.focusScreen != tc.screen { | |||
| 267 | + | t.Fatalf("screen %d, want %d before crossing workspace", c.focusScreen, tc.screen) | |||
| 268 | + | } | |||
| 269 | + | wantActive(t, m, tc.next) | |||
| 270 | + | c.applyAction(tc.action) | |||
| 271 | + | if c.focusScreen != tc.neighbor { | |||
| 272 | + | t.Fatalf("screen %d, want neighbor %d", c.focusScreen, tc.neighbor) | |||
| 273 | + | } | |||
| 274 | + | wantActive(t, m, tc.next) | |||
| 275 | + | }) | |||
| 276 | + | } | |||
| 277 | + | } | |||