Merge 9793569493baa3110f01125a8a41303365296eec into e8a5ba67e7808a959cf5c165fe3c21f2d7598db2
3 files · +160 -46
M internal/core/action.go +34 -17
| @@ -209,10 +209,11 @@ type Effect struct { | |||
| 209 | 209 | Quit bool | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 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. | |
| 216 | 217 | func (c *Core) applyAction(a Action) Effect { | |
| 217 | 218 | if !c.flashOn() { | |
| 218 | 219 | return c.dispatchAction(a) | |
| @@ -321,7 +322,7 @@ func (c *Core) dispatchAction(a Action) Effect { | |||
| 321 | 322 | // the edge (see edgeFocus) and fullscreen stays. | |
| 322 | 323 | if w.pinned() && !w.stashOverCover() { | |
| 323 | 324 | if !w.FocusColumn(dir) { | |
| 324 | - | c.edgeFocus(dir, d) | |
| 325 | + | c.edgeFocus(horizontalAxis, dir, d) | |
| 325 | 326 | } | |
| 326 | 327 | return Effect{} | |
| 327 | 328 | } | |
| @@ -329,7 +330,21 @@ func (c *Core) dispatchAction(a Action) Effect { | |||
| 329 | 330 | // a native float leaves for the stash or the columns, and the stash | |
| 330 | 331 | // keeps the focus at its ends. | |
| 331 | 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 | 348 | return Effect{} | |
| 334 | 349 | } | |
| 335 | 350 | case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | |
| @@ -388,24 +403,26 @@ func (c *Core) dispatchAction(a Action) Effect { | |||
| 388 | 403 | return c.cur().mon.Apply(a) | |
| 389 | 404 | } | |
| 390 | 405 | ||
| 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 { | |
| 396 | 412 | 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) | |
| 399 | 415 | return true | |
| 400 | 416 | } | |
| 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 | |
| 403 | 419 | 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 | 426 | func (m *Monitor) Apply(a Action) Effect { | |
| 410 | 427 | if e, ok := m.overviewAction(a); ok { | |
| 411 | 428 | return e | |
| @@ -209,10 +209,11 @@ type Effect struct { | |||||
| 209 | Quit bool | 209 | Quit bool | ||
| 210 | } | 210 | } | ||
| 211 | 211 | ||||
| 212 | - | // applyAction runs a bind action. Monitor actions change the focused | 212 | + | // 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 the | 214 | + | // 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 stash | 330 | // 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 the | 406 | + | // 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 the | 407 | + | // 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().mon | 412 | m := c.cur().mon | ||
| 397 | - | if i := c.neighbor(d); i >= 0 { | 413 | + | if to := m.occupiedStep(axis, dir); to >= 0 { |
| 398 | - | c.focusScreen = i | 414 | + | m.Focus(to) |
| 399 | return true | 415 | 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 true | 419 | 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 e | 428 | return e | ||
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 +122 -24
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // axisCore is a cascade core whose monitor "M" has three workspaces of five | |
| 6 | 9 | // 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 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) { | |
| 56 | 58 | c, m := axisCore(t, true, OverflowCascade, 3) | |
| 57 | 59 | m.Current().FocusID(23) | |
| 58 | 60 | 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) | |
| 66 | 66 | m.Current().FocusID(24) | |
| 67 | 67 | 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 | + | } | |
| 73 | 72 | m.Focus(2) | |
| 74 | 73 | m.Current().FocusID(35) | |
| 75 | 74 | c.applyAction(ActionFocusColumnRight) | |
| @@ -93,8 +92,8 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T) | |||
| 93 | 92 | } | |
| 94 | 93 | ||
| 95 | 94 | // 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) { | |
| 98 | 97 | c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600}) | |
| 99 | 98 | m := c.cur().mon | |
| 100 | 99 | m.SetOverflow(OverflowCascade) | |
| @@ -104,8 +103,8 @@ func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) { | |||
| 104 | 103 | c.applyAction(ActionFocusColumnRight) | |
| 105 | 104 | wantActive(t, m, 1) | |
| 106 | 105 | 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) | |
| 109 | 108 | } | |
| 110 | 109 | ||
| 111 | 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 | 190 | // 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) { | |
| 194 | 193 | c, m := axisCore(t, true, OverflowCascade, 3) | |
| 195 | 194 | w := m.Current() | |
| 196 | 195 | w.FocusID(23) | |
| 197 | 196 | w.ToggleFullscreen() | |
| 198 | 197 | 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 | + | } | |
| 201 | 202 | if w.fullscreen != 23 { | |
| 202 | 203 | t.Fatalf("fullscreen %d, want 23", w.fullscreen) | |
| 203 | 204 | } | |
| @@ -244,3 +245,100 @@ func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) { | |||
| 244 | 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 | + | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package core | 1 | 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 five | 8 | // 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 empty | 9 | // 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 opposite | 56 | + | // 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 the | 70 | + | 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; left | 94 | // 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().mon | 98 | 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 that | 190 | // 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 | + | } | |||