detached 01e6d9a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
389389 }
390390
391391 // 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;
394394 // else to the trailing empty workspace. It reports whether focus moved.
395395 func (c *Core) edgeFocus(dir int, d direction) bool {
396396 m := c.cur().mon
397- if i := c.neighbor(d); i >= 0 {
398- c.focusScreen = i
399- return true
400- }
401397 if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
402398 m.Focus(to)
403399 return true
404400 }
401+ if i := c.neighbor(d); i >= 0 {
402+ c.focusScreen = i
403+ return true
404+ }
405405 return m.stepWorkspace(horizontalAxis, dir)
406406 }
407407
@@ -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 the391 // 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 the392+// 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().mon396 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 true399 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) {
4343 wantColumns(t, w, 2, 1, 3, 4, 5)
4444 }
4545
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) {
4948 c := layoutCore(0, tile{"L", 0, 0, 900, 600}, tile{"R", 900, 0, 900, 600})
5049 m := c.cur().mon
5150 m.SetOverflow(OverflowCascade)
@@ -58,8 +57,8 @@ func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) {
5857 m.normalize()
5958 m.Current().FocusID(2)
6059 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)
6362 }
6463
6564 // 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 occupied46+// 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().mon49 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) {
5050 }
5151 }
5252
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) {
5655 c, m := axisCore(t, true, OverflowCascade, 3)
5756 m.Current().FocusID(23)
5857 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)
6663 m.Current().FocusID(24)
6764 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+ }
7369 m.Focus(2)
7470 m.Current().FocusID(35)
7571 c.applyAction(ActionFocusColumnRight)
@@ -93,8 +89,8 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T)
9389 }
9490
9591 // 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) {
9894 c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})
9995 m := c.cur().mon
10096 m.SetOverflow(OverflowCascade)
@@ -104,8 +100,8 @@ func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) {
104100 c.applyAction(ActionFocusColumnRight)
105101 wantActive(t, m, 1)
106102 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)
109105 }
110106
111107 // AC3: up/down cross bands and stop at the last, with no workspace change.
@@ -189,15 +185,17 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
189185 }
190186
191187 // 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) {
194190 c, m := axisCore(t, true, OverflowCascade, 3)
195191 w := m.Current()
196192 w.FocusID(23)
197193 w.ToggleFullscreen()
198194 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+ }
201199 if w.fullscreen != 23 {
202200 t.Fatalf("fullscreen %d, want 23", w.fullscreen)
203201 }
@@ -244,3 +242,36 @@ func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {
244242 wantActive(t, m, tc.want)
245243 }
246244 }
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 opposite53+// 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 the67+ 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; left91 // 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().mon95 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 that187 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close