detached 9793569

fix(core): cross to the neighbor screen past the vertical workspace edge

commit
9793569493baa3110f01125a8a41303365296eec
author
brice · · 2026-10-19 15:25 UTC
parent
01e6d9a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
2/2
2 files · +99 -15
M internal/core/action.go +31 -14
@@ -209,10 +209,11 @@ type Effect struct {
209209 Quit bool
210210 }
211211
212-// applyAction runs a bind action. Monitor actions change the focused
213-// screen; column focus past the edge column moves to the neighbor screen,
214-// else along the layout's workspace axis; everything else applies to the
215-// focused screen's monitor.
212+// applyAction runs a bind action. Focus past a content edge visits an occupied
213+// workspace, then a neighbor screen, then a trailing empty workspace. Vertical
214+// edges do this only with a vertical workspace axis; cascade stays within bands.
215+// Other actions apply to the focused screen's monitor unless explicitly moving
216+// between screens. Screen changes request a flash on the destination output.
216217 func (c *Core) applyAction(a Action) Effect {
217218 if !c.flashOn() {
218219 return c.dispatchAction(a)
@@ -321,7 +322,7 @@ func (c *Core) dispatchAction(a Action) Effect {
321322 // the edge (see edgeFocus) and fullscreen stays.
322323 if w.pinned() && !w.stashOverCover() {
323324 if !w.FocusColumn(dir) {
324- c.edgeFocus(dir, d)
325+ c.edgeFocus(horizontalAxis, dir, d)
325326 }
326327 return Effect{}
327328 }
@@ -329,7 +330,21 @@ func (c *Core) dispatchAction(a Action) Effect {
329330 // a native float leaves for the stash or the columns, and the stash
330331 // keeps the focus at its ends.
331332 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
332- if edge && c.edgeFocus(dir, d) {
333+ if edge && c.edgeFocus(horizontalAxis, dir, d) {
334+ return Effect{}
335+ }
336+ case ActionFocusWindowUp, ActionFocusWindowDown:
337+ m := c.cur().mon
338+ // Cascade's up/down navigation stays within its bands. Let Monitor
339+ // handle it unchanged; only vertical workspace axes cross screens.
340+ if m.Current().policy().workspace == verticalAxis {
341+ dir, d := 1, dirDown
342+ if a == ActionFocusWindowUp {
343+ dir, d = -1, dirUp
344+ }
345+ if !m.Current().FocusWindow(dir) {
346+ c.edgeFocus(verticalAxis, dir, d)
347+ }
333348 return Effect{}
334349 }
335350 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
@@ -388,13 +403,14 @@ func (c *Core) dispatchAction(a Action) Effect {
388403 return c.cur().mon.Apply(a)
389404 }
390405
391-// edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the
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-// else to the trailing empty workspace. It reports whether focus moved.
395-func (c *Core) edgeFocus(dir int, d direction) bool {
406+// edgeFocus moves focus past a content edge along axis (dir -1/1, toward d):
407+// first to an occupied workspace when axis is the workspace axis, then to the
408+// neighbor monitor, then to the trailing empty workspace. It reports whether
409+// focus moved. Callers retain responsibility for local content navigation and
410+// for restricting which layout axes may cross monitors.
411+func (c *Core) edgeFocus(axis layoutAxis, dir int, d direction) bool {
396412 m := c.cur().mon
397- if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
413+ if to := m.occupiedStep(axis, dir); to >= 0 {
398414 m.Focus(to)
399415 return true
400416 }
@@ -402,10 +418,11 @@ func (c *Core) edgeFocus(dir int, d direction) bool {
402418 c.focusScreen = i
403419 return true
404420 }
405- return m.stepWorkspace(horizontalAxis, dir)
421+ return m.stepWorkspace(axis, dir)
406422 }
407423
408-// Apply runs a bind action on the monitor.
424+// Apply runs a bind action on this monitor only. Vertical focus edges step
425+// workspaces; Core dispatch adds neighboring-screen navigation for those edges.
409426 func (m *Monitor) Apply(a Action) Effect {
410427 if e, ok := m.overviewAction(a); ok {
411428 return e
@@ -209,10 +209,11 @@ type Effect struct {
209 Quit bool209 Quit bool
210 }210 }
211 211
212-// applyAction runs a bind action. Monitor actions change the focused212+// applyAction runs a bind action. Focus past a content edge visits an occupied
213-// screen; column focus past the edge column moves to the neighbor screen,213+// workspace, then a neighbor screen, then a trailing empty workspace. Vertical
214-// else along the layout's workspace axis; everything else applies to the214+// edges do this only with a vertical workspace axis; cascade stays within bands.
215-// focused screen's monitor.215+// Other actions apply to the focused screen's monitor unless explicitly moving
216+// between screens. Screen changes request a flash on the destination output.
216 func (c *Core) applyAction(a Action) Effect {217 func (c *Core) applyAction(a Action) Effect {
217 if !c.flashOn() {218 if !c.flashOn() {
218 return c.dispatchAction(a)219 return c.dispatchAction(a)
@@ -321,7 +322,7 @@ func (c *Core) dispatchAction(a Action) Effect {
321 // the edge (see edgeFocus) and fullscreen stays.322 // the edge (see edgeFocus) and fullscreen stays.
322 if w.pinned() && !w.stashOverCover() {323 if w.pinned() && !w.stashOverCover() {
323 if !w.FocusColumn(dir) {324 if !w.FocusColumn(dir) {
324- c.edgeFocus(dir, d)325+ c.edgeFocus(horizontalAxis, dir, d)
325 }326 }
326 return Effect{}327 return Effect{}
327 }328 }
@@ -329,7 +330,21 @@ func (c *Core) dispatchAction(a Action) Effect {
329 // a native float leaves for the stash or the columns, and the stash330 // a native float leaves for the stash or the columns, and the stash
330 // keeps the focus at its ends.331 // keeps the focus at its ends.
331 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())332 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
332- if edge && c.edgeFocus(dir, d) {333+ if edge && c.edgeFocus(horizontalAxis, dir, d) {
334+ return Effect{}
335+ }
336+ case ActionFocusWindowUp, ActionFocusWindowDown:
337+ m := c.cur().mon
338+ // Cascade's up/down navigation stays within its bands. Let Monitor
339+ // handle it unchanged; only vertical workspace axes cross screens.
340+ if m.Current().policy().workspace == verticalAxis {
341+ dir, d := 1, dirDown
342+ if a == ActionFocusWindowUp {
343+ dir, d = -1, dirUp
344+ }
345+ if !m.Current().FocusWindow(dir) {
346+ c.edgeFocus(verticalAxis, dir, d)
347+ }
333 return Effect{}348 return Effect{}
334 }349 }
335 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:350 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
@@ -388,13 +403,14 @@ func (c *Core) dispatchAction(a Action) Effect {
388 return c.cur().mon.Apply(a)403 return c.cur().mon.Apply(a)
389 }404 }
390 405
391-// edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the406+// edgeFocus moves focus past a content edge along axis (dir -1/1, toward d):
392-// content edge, in order: to the occupied workspace on that side when the407+// first to an occupied workspace when axis is the workspace axis, then to the
393-// workspace axis is horizontal; else to the neighbor monitor on that side;408+// neighbor monitor, then to the trailing empty workspace. It reports whether
394-// else to the trailing empty workspace. It reports whether focus moved.409+// focus moved. Callers retain responsibility for local content navigation and
395-func (c *Core) edgeFocus(dir int, d direction) bool {410+// for restricting which layout axes may cross monitors.
411+func (c *Core) edgeFocus(axis layoutAxis, dir int, d direction) bool {
396 m := c.cur().mon412 m := c.cur().mon
397- if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {413+ if to := m.occupiedStep(axis, dir); to >= 0 {
398 m.Focus(to)414 m.Focus(to)
399 return true415 return true
400 }416 }
@@ -402,10 +418,11 @@ func (c *Core) edgeFocus(dir int, d direction) bool {
402 c.focusScreen = i418 c.focusScreen = i
403 return true419 return true
404 }420 }
405- return m.stepWorkspace(horizontalAxis, dir)421+ return m.stepWorkspace(axis, dir)
406 }422 }
407 423
408-// Apply runs a bind action on the monitor.424+// Apply runs a bind action on this monitor only. Vertical focus edges step
425+// workspaces; Core dispatch adds neighboring-screen navigation for those edges.
409 func (m *Monitor) Apply(a Action) Effect {426 func (m *Monitor) Apply(a Action) Effect {
410 if e, ok := m.overviewAction(a); ok {427 if e, ok := m.overviewAction(a); ok {
411 return e428 return e
M internal/core/workspace_axis_test.go +68 -1
@@ -1,6 +1,9 @@
11 package core
22
3-import "testing"
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "testing"
6+)
47
58 // axisCore is a cascade core whose monitor "M" has three workspaces of five
69 // windows (ids 11-15, 21-25, 31-35; bands of three) and a trailing empty
@@ -275,3 +278,67 @@ func TestColumnEdgeFocusWorkspaceBeforeScreen(t *testing.T) {
275278 })
276279 }
277280 }
281+
282+func TestVerticalEdgeFocusWorkspaceBeforeScreen(t *testing.T) {
283+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
284+ for _, tc := range []struct {
285+ name string
286+ screen, start, next, neighbor int
287+ action Action
288+ }{
289+ {"up", 1, 1, 0, 0, ActionFocusWindowUp},
290+ {"down", 0, 0, 1, 1, ActionFocusWindowDown},
291+ } {
292+ t.Run(string(overflow)+"/"+tc.name, func(t *testing.T) {
293+ c := layoutCore(tc.screen, tile{"top", 0, 0, 900, 600}, tile{"bottom", 0, 600, 900, 600})
294+ m := c.cur().mon
295+ m.SetOverflow(overflow)
296+ for ws := range 2 {
297+ m.Focus(ws)
298+ m.AddWindow(WindowID(ws + 1))
299+ }
300+ m.Focus(tc.start)
301+ c.applyAction(tc.action)
302+ if c.focusScreen != tc.screen {
303+ t.Fatal("crossed screen before occupied workspace")
304+ }
305+ wantActive(t, m, tc.next)
306+ c.applyAction(tc.action)
307+ if c.focusScreen != tc.neighbor {
308+ t.Fatalf("screen %d, want %d", c.focusScreen, tc.neighbor)
309+ }
310+ wantActive(t, m, tc.next)
311+ })
312+ }
313+ }
314+}
315+
316+func TestCascadeVerticalEdgeDoesNotCrossScreen(t *testing.T) {
317+ c := layoutCore(1, tile{"top", 0, 0, 900, 600}, tile{"middle", 0, 600, 900, 600}, tile{"bottom", 0, 1200, 900, 600})
318+ m := c.cur().mon
319+ m.SetOverflow(OverflowCascade)
320+ m.AddWindow(1)
321+ for _, a := range []Action{ActionFocusWindowUp, ActionFocusWindowDown} {
322+ c.applyAction(a)
323+ wantFocus(t, c, "middle", 1)
324+ wantActive(t, m, 0)
325+ }
326+}
327+
328+func TestVerticalEdgeFocusFlashesDestination(t *testing.T) {
329+ for _, tc := range []struct {
330+ a Action
331+ y int
332+ }{{ActionFocusWindowUp, -300}, {ActionFocusWindowDown, 300}} {
333+ t.Run(string(tc.a), func(t *testing.T) {
334+ c, _ := flashCore(t)
335+ c.cur().mon.SetOverflow(OverflowScroll)
336+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
337+ c.screens[1].x, c.screens[1].y = 0, tc.y
338+ c.applyAction(tc.a)
339+ if c.focusScreen != 1 || c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
340+ t.Fatalf("screen %d, flash %+v", c.focusScreen, c.flash.pending)
341+ }
342+ })
343+ }
344+}
@@ -1,6 +1,9 @@
1 package core1 package core
2 2
3-import "testing"3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "testing"
6+)
4 7
5 // axisCore is a cascade core whose monitor "M" has three workspaces of five8 // axisCore is a cascade core whose monitor "M" has three workspaces of five
6 // windows (ids 11-15, 21-25, 31-35; bands of three) and a trailing empty9 // windows (ids 11-15, 21-25, 31-35; bands of three) and a trailing empty
@@ -275,3 +278,67 @@ func TestColumnEdgeFocusWorkspaceBeforeScreen(t *testing.T) {
275 })278 })
276 }279 }
277 }280 }
281+
282+func TestVerticalEdgeFocusWorkspaceBeforeScreen(t *testing.T) {
283+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
284+ for _, tc := range []struct {
285+ name string
286+ screen, start, next, neighbor int
287+ action Action
288+ }{
289+ {"up", 1, 1, 0, 0, ActionFocusWindowUp},
290+ {"down", 0, 0, 1, 1, ActionFocusWindowDown},
291+ } {
292+ t.Run(string(overflow)+"/"+tc.name, func(t *testing.T) {
293+ c := layoutCore(tc.screen, tile{"top", 0, 0, 900, 600}, tile{"bottom", 0, 600, 900, 600})
294+ m := c.cur().mon
295+ m.SetOverflow(overflow)
296+ for ws := range 2 {
297+ m.Focus(ws)
298+ m.AddWindow(WindowID(ws + 1))
299+ }
300+ m.Focus(tc.start)
301+ c.applyAction(tc.action)
302+ if c.focusScreen != tc.screen {
303+ t.Fatal("crossed screen before occupied workspace")
304+ }
305+ wantActive(t, m, tc.next)
306+ c.applyAction(tc.action)
307+ if c.focusScreen != tc.neighbor {
308+ t.Fatalf("screen %d, want %d", c.focusScreen, tc.neighbor)
309+ }
310+ wantActive(t, m, tc.next)
311+ })
312+ }
313+ }
314+}
315+
316+func TestCascadeVerticalEdgeDoesNotCrossScreen(t *testing.T) {
317+ c := layoutCore(1, tile{"top", 0, 0, 900, 600}, tile{"middle", 0, 600, 900, 600}, tile{"bottom", 0, 1200, 900, 600})
318+ m := c.cur().mon
319+ m.SetOverflow(OverflowCascade)
320+ m.AddWindow(1)
321+ for _, a := range []Action{ActionFocusWindowUp, ActionFocusWindowDown} {
322+ c.applyAction(a)
323+ wantFocus(t, c, "middle", 1)
324+ wantActive(t, m, 0)
325+ }
326+}
327+
328+func TestVerticalEdgeFocusFlashesDestination(t *testing.T) {
329+ for _, tc := range []struct {
330+ a Action
331+ y int
332+ }{{ActionFocusWindowUp, -300}, {ActionFocusWindowDown, 300}} {
333+ t.Run(string(tc.a), func(t *testing.T) {
334+ c, _ := flashCore(t)
335+ c.cur().mon.SetOverflow(OverflowScroll)
336+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
337+ c.screens[1].x, c.screens[1].y = 0, tc.y
338+ c.applyAction(tc.a)
339+ if c.focusScreen != 1 || c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
340+ t.Fatalf("screen %d, flash %+v", c.focusScreen, c.flash.pending)
341+ }
342+ })
343+ }
344+}

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