fix(core): cross to the neighbor screen past the vertical workspace edge
2 files · +99 -15
M internal/core/action.go +31 -14
| @@ -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,13 +403,14 @@ 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 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 { | |
| 396 | 412 | m := c.cur().mon | |
| 397 | - | if to := m.occupiedStep(horizontalAxis, dir); to >= 0 { | |
| 413 | + | if to := m.occupiedStep(axis, dir); to >= 0 { | |
| 398 | 414 | m.Focus(to) | |
| 399 | 415 | return true | |
| 400 | 416 | } | |
| @@ -402,10 +418,11 @@ func (c *Core) edgeFocus(dir int, d direction) bool { | |||
| 402 | 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,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 the | 406 | + | // 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 the | 407 | + | // 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().mon | 412 | 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 true | 415 | return true | ||
| 400 | } | 416 | } | ||
| @@ -402,10 +418,11 @@ func (c *Core) edgeFocus(dir int, d direction) bool { | |||||
| 402 | c.focusScreen = i | 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/workspace_axis_test.go +68 -1
| @@ -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 | |
| @@ -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 | + | } | |
| @@ -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 | ||
| @@ -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 | + | } | |||