detached 26371c8

Merge 9793569493baa3110f01125a8a41303365296eec into e8a5ba67e7808a959cf5c165fe3c21f2d7598db2

commit
26371c850c32d5bc7bb5ee451c23d6a2668b273f
author
ermite · · 2026-10-19 15:39 UTC
merge of
e8a5ba6 + 9793569 · diff against the first parent
signature
unsigned
3 files · +160 -46
M internal/core/action.go +34 -17
@@ -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,24 +403,26 @@ 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 neighbor monitor on that side; else to the
393-// occupied workspace on that side when the workspace axis is horizontal;
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 i := c.neighbor(d); i >= 0 {
398- c.focusScreen = i
413+ if to := m.occupiedStep(axis, dir); to >= 0 {
414+ m.Focus(to)
399415 return true
400416 }
401- if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
402- m.Focus(to)
417+ if i := c.neighbor(d); i >= 0 {
418+ 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,24 +403,26 @@ 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 neighbor monitor on that side; else to the407+// first to an occupied workspace when axis is the workspace axis, then to the
393-// occupied workspace on that side when the workspace axis is horizontal;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 i := c.neighbor(d); i >= 0 {413+ if to := m.occupiedStep(axis, dir); to >= 0 {
398- c.focusScreen = i414+ m.Focus(to)
399 return true415 return true
400 }416 }
401- if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {417+ if i := c.neighbor(d); i >= 0 {
402- m.Focus(to)418+ 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/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 +122 -24
@@ -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
@@ -50,26 +53,22 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
5053 }
5154 }
5255
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) {
56+// Occupied workspaces win over monitors; monitors still precede trailing empties.
57+func TestCascadeFocusColumnGoesToWorkspaceBeforeMonitor(t *testing.T) {
5658 c, m := axisCore(t, true, OverflowCascade, 3)
5759 m.Current().FocusID(23)
5860 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.
61+ wantActive(t, m, 2)
62+ if c.cur().mon != m {
63+ t.Fatal("left monitor before workspace")
64+ }
65+ m.Focus(1)
6666 m.Current().FocusID(24)
6767 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
68+ wantActive(t, m, 0)
69+ if c.cur().mon != m {
70+ t.Fatal("left monitor before workspace")
71+ }
7372 m.Focus(2)
7473 m.Current().FocusID(35)
7574 c.applyAction(ActionFocusColumnRight)
@@ -93,8 +92,8 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T)
9392 }
9493
9594 // 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) {
95+// from its empty band returns to the occupied workspace before that monitor.
96+func TestCascadeStepRightThenLeftReturnsToWorkspace(t *testing.T) {
9897 c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})
9998 m := c.cur().mon
10099 m.SetOverflow(OverflowCascade)
@@ -104,8 +103,8 @@ func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) {
104103 c.applyAction(ActionFocusColumnRight)
105104 wantActive(t, m, 1)
106105 c.applyAction(ActionFocusColumnLeft)
107- wantFocus(t, c, "L", 0)
108- wantActive(t, m, 1)
106+ wantFocus(t, c, "M", 5)
107+ wantActive(t, m, 0)
109108 }
110109
111110 // AC3: up/down cross bands and stop at the last, with no workspace change.
@@ -189,15 +188,17 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
189188 }
190189
191190 // A covering fullscreen tile follows the same order: the monitor on that
192-// side before the occupied workspace.
193-func TestCascadeFullscreenGoesToMonitorBeforeWorkspace(t *testing.T) {
191+// side after the occupied workspace.
192+func TestCascadeFullscreenGoesToWorkspaceBeforeMonitor(t *testing.T) {
194193 c, m := axisCore(t, true, OverflowCascade, 3)
195194 w := m.Current()
196195 w.FocusID(23)
197196 w.ToggleFullscreen()
198197 c.applyAction(ActionFocusColumnRight)
199- wantFocus(t, c, "R", 0)
200- wantActive(t, m, 1)
198+ wantActive(t, m, 2)
199+ if c.cur().mon != m {
200+ t.Fatal("left monitor before workspace")
201+ }
201202 if w.fullscreen != 23 {
202203 t.Fatalf("fullscreen %d, want 23", w.fullscreen)
203204 }
@@ -244,3 +245,100 @@ func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {
244245 wantActive(t, m, tc.want)
245246 }
246247 }
248+
249+// Column edges visit occupied workspaces before crossing outputs, in both directions.
250+func TestColumnEdgeFocusWorkspaceBeforeScreen(t *testing.T) {
251+ for _, tc := range []struct {
252+ name string
253+ screen, start, next, neighbor int
254+ action Action
255+ }{
256+ {"left", 1, 1, 0, 0, ActionFocusColumnLeft},
257+ {"right", 0, 0, 1, 1, ActionFocusColumnRight},
258+ } {
259+ t.Run(tc.name, func(t *testing.T) {
260+ c := layoutCore(tc.screen, tile{"laptop", 0, 0, 1920, 1080}, tile{"4K", 1920, 0, 3840, 2160})
261+ m := c.cur().mon
262+ m.SetOverflow(OverflowCascade)
263+ for ws := range 2 {
264+ m.Focus(ws)
265+ m.AddWindow(WindowID(ws + 1))
266+ }
267+ m.Focus(tc.start)
268+ c.applyAction(tc.action)
269+ if c.focusScreen != tc.screen {
270+ t.Fatalf("screen %d, want %d before crossing workspace", c.focusScreen, tc.screen)
271+ }
272+ wantActive(t, m, tc.next)
273+ c.applyAction(tc.action)
274+ if c.focusScreen != tc.neighbor {
275+ t.Fatalf("screen %d, want neighbor %d", c.focusScreen, tc.neighbor)
276+ }
277+ wantActive(t, m, tc.next)
278+ })
279+ }
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+}
@@ -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
@@ -50,26 +53,22 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
50 }53 }
51 }54 }
52 55
53-// A monitor on that side wins over an occupied workspace, so the opposite56+// Occupied workspaces win over monitors; monitors still precede trailing empties.
54-// key brings focus back; the workspace is reached only with no monitor.57+func TestCascadeFocusColumnGoesToWorkspaceBeforeMonitor(t *testing.T) {
55-func TestCascadeFocusColumnGoesToMonitorBeforeWorkspace(t *testing.T) {
56 c, m := axisCore(t, true, OverflowCascade, 3)58 c, m := axisCore(t, true, OverflowCascade, 3)
57 m.Current().FocusID(23)59 m.Current().FocusID(23)
58 c.applyAction(ActionFocusColumnRight)60 c.applyAction(ActionFocusColumnRight)
59- wantFocus(t, c, "R", 0)61+ wantActive(t, m, 2)
60- wantActive(t, m, 1)62+ if c.cur().mon != m {
61- // Back with the opposite key.63+ t.Fatal("left monitor before workspace")
62- c.applyAction(ActionFocusColumnLeft)64+ }
63- wantFocus(t, c, "M", 23)65+ 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)66 m.Current().FocusID(24)
67 c.applyAction(ActionFocusColumnLeft)67 c.applyAction(ActionFocusColumnLeft)
68- wantFocus(t, c, "L", 0)68+ wantActive(t, m, 0)
69- wantActive(t, m, 1)69+ if c.cur().mon != m {
70- // From the last occupied workspace: the monitor on the right, not the70+ t.Fatal("left monitor before workspace")
71- // trailing empty workspace.71+ }
72- c.focusScreen = 1
73 m.Focus(2)72 m.Focus(2)
74 m.Current().FocusID(35)73 m.Current().FocusID(35)
75 c.applyAction(ActionFocusColumnRight)74 c.applyAction(ActionFocusColumnRight)
@@ -93,8 +92,8 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T)
93 }92 }
94 93
95 // One monitor on the left only. Right steps to the next workspace; left94 // 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.95+// from its empty band returns to the occupied workspace before that monitor.
97-func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) {96+func TestCascadeStepRightThenLeftReturnsToWorkspace(t *testing.T) {
98 c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})97 c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})
99 m := c.cur().mon98 m := c.cur().mon
100 m.SetOverflow(OverflowCascade)99 m.SetOverflow(OverflowCascade)
@@ -104,8 +103,8 @@ func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) {
104 c.applyAction(ActionFocusColumnRight)103 c.applyAction(ActionFocusColumnRight)
105 wantActive(t, m, 1)104 wantActive(t, m, 1)
106 c.applyAction(ActionFocusColumnLeft)105 c.applyAction(ActionFocusColumnLeft)
107- wantFocus(t, c, "L", 0)106+ wantFocus(t, c, "M", 5)
108- wantActive(t, m, 1)107+ wantActive(t, m, 0)
109 }108 }
110 109
111 // AC3: up/down cross bands and stop at the last, with no workspace change.110 // AC3: up/down cross bands and stop at the last, with no workspace change.
@@ -189,15 +188,17 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
189 }188 }
190 189
191 // A covering fullscreen tile follows the same order: the monitor on that190 // A covering fullscreen tile follows the same order: the monitor on that
192-// side before the occupied workspace.191+// side after the occupied workspace.
193-func TestCascadeFullscreenGoesToMonitorBeforeWorkspace(t *testing.T) {192+func TestCascadeFullscreenGoesToWorkspaceBeforeMonitor(t *testing.T) {
194 c, m := axisCore(t, true, OverflowCascade, 3)193 c, m := axisCore(t, true, OverflowCascade, 3)
195 w := m.Current()194 w := m.Current()
196 w.FocusID(23)195 w.FocusID(23)
197 w.ToggleFullscreen()196 w.ToggleFullscreen()
198 c.applyAction(ActionFocusColumnRight)197 c.applyAction(ActionFocusColumnRight)
199- wantFocus(t, c, "R", 0)198+ wantActive(t, m, 2)
200- wantActive(t, m, 1)199+ if c.cur().mon != m {
200+ t.Fatal("left monitor before workspace")
201+ }
201 if w.fullscreen != 23 {202 if w.fullscreen != 23 {
202 t.Fatalf("fullscreen %d, want 23", w.fullscreen)203 t.Fatalf("fullscreen %d, want 23", w.fullscreen)
203 }204 }
@@ -244,3 +245,100 @@ func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {
244 wantActive(t, m, tc.want)245 wantActive(t, m, tc.want)
245 }246 }
246 }247 }
248+
249+// Column edges visit occupied workspaces before crossing outputs, in both directions.
250+func TestColumnEdgeFocusWorkspaceBeforeScreen(t *testing.T) {
251+ for _, tc := range []struct {
252+ name string
253+ screen, start, next, neighbor int
254+ action Action
255+ }{
256+ {"left", 1, 1, 0, 0, ActionFocusColumnLeft},
257+ {"right", 0, 0, 1, 1, ActionFocusColumnRight},
258+ } {
259+ t.Run(tc.name, func(t *testing.T) {
260+ c := layoutCore(tc.screen, tile{"laptop", 0, 0, 1920, 1080}, tile{"4K", 1920, 0, 3840, 2160})
261+ m := c.cur().mon
262+ m.SetOverflow(OverflowCascade)
263+ for ws := range 2 {
264+ m.Focus(ws)
265+ m.AddWindow(WindowID(ws + 1))
266+ }
267+ m.Focus(tc.start)
268+ c.applyAction(tc.action)
269+ if c.focusScreen != tc.screen {
270+ t.Fatalf("screen %d, want %d before crossing workspace", c.focusScreen, tc.screen)
271+ }
272+ wantActive(t, m, tc.next)
273+ c.applyAction(tc.action)
274+ if c.focusScreen != tc.neighbor {
275+ t.Fatalf("screen %d, want neighbor %d", c.focusScreen, tc.neighbor)
276+ }
277+ wantActive(t, m, tc.next)
278+ })
279+ }
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 26

Tags 10

↑↓ moveenter openesc close