refactor(core): drive edge workspace focus from the layout's workspace axis
- commit
- d1b51b4bd45c2041e7ca7cb050f7542508d488fa
- author
- brice · · 2026-10-12 12:38 UTC
- committer
- brice · · 2026-10-12 12:38 UTC
- parent
- d0a3940
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +55 -6
M internal/core/action.go +5 -6
| @@ -338,16 +338,15 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 338 | 338 | switch a { | |
| 339 | 339 | case ActionFocusWindowUp, ActionFocusWindowDown: | |
| 340 | 340 | // Past the column and any on-screen neighbor or demoted float, | |
| 341 | - | // move to the next workspace. Cascade workspaces sit side by side: | |
| 342 | - | // up/down stop at the first and last band. | |
| 341 | + | // move to the next workspace if the layout's workspace axis is | |
| 342 | + | // vertical. Cascade workspaces sit side by side: up/down stop at the | |
| 343 | + | // first and last band. | |
| 343 | 344 | dir := 1 | |
| 344 | 345 | if a == ActionFocusWindowUp { | |
| 345 | 346 | dir = -1 | |
| 346 | 347 | } | |
| 347 | - | // A hidden workspace is outside the vertical list. | |
| 348 | - | w := m.Current() | |
| 349 | - | if !w.FocusWindow(dir) && m.shown == nil && !w.policy().wraps { | |
| 350 | - | m.Focus(m.Active + dir) | |
| 348 | + | if !m.Current().FocusWindow(dir) { | |
| 349 | + | m.stepWorkspace(verticalAxis, dir) | |
| 351 | 350 | } | |
| 352 | 351 | return Effect{} | |
| 353 | 352 | case ActionToggleFullscreen: | |
| @@ -338,16 +338,15 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 338 | switch a { | 338 | switch a { | ||
| 339 | case ActionFocusWindowUp, ActionFocusWindowDown: | 339 | case ActionFocusWindowUp, ActionFocusWindowDown: | ||
| 340 | // Past the column and any on-screen neighbor or demoted float, | 340 | // Past the column and any on-screen neighbor or demoted float, | ||
| 341 | - | // move to the next workspace. Cascade workspaces sit side by side: | 341 | + | // move to the next workspace if the layout's workspace axis is |
| 342 | - | // up/down stop at the first and last band. | 342 | + | // vertical. Cascade workspaces sit side by side: up/down stop at the |
| 343 | + | // first and last band. | |||
| 343 | dir := 1 | 344 | dir := 1 | ||
| 344 | if a == ActionFocusWindowUp { | 345 | if a == ActionFocusWindowUp { | ||
| 345 | dir = -1 | 346 | dir = -1 | ||
| 346 | } | 347 | } | ||
| 347 | - | // A hidden workspace is outside the vertical list. | 348 | + | if !m.Current().FocusWindow(dir) { |
| 348 | - | w := m.Current() | 349 | + | m.stepWorkspace(verticalAxis, dir) |
| 349 | - | if !w.FocusWindow(dir) && m.shown == nil && !w.policy().wraps { | |||
| 350 | - | m.Focus(m.Active + dir) | |||
| 351 | } | 350 | } | ||
| 352 | return Effect{} | 351 | return Effect{} | ||
| 353 | case ActionToggleFullscreen: | 352 | case ActionToggleFullscreen: | ||
M internal/core/layout_policy.go +9 -0
| @@ -29,6 +29,15 @@ type layoutPolicy struct { | |||
| 29 | 29 | equalCells, wraps bool | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | + | // workspaceStep is the workspace step a directional focus past the content | |
| 33 | + | // edge takes along axis: dir when axis is the workspace axis, else 0. | |
| 34 | + | func (p layoutPolicy) workspaceStep(axis layoutAxis, dir int) int { | |
| 35 | + | if axis == p.workspace { | |
| 36 | + | return dir | |
| 37 | + | } | |
| 38 | + | return 0 | |
| 39 | + | } | |
| 40 | + | ||
| 32 | 41 | func (w *Workspace) policy() layoutPolicy { | |
| 33 | 42 | if w.Overflow == OverflowCascade { | |
| 34 | 43 | return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, wraps: true} | |
| @@ -29,6 +29,15 @@ type layoutPolicy struct { | |||||
| 29 | equalCells, wraps bool | 29 | equalCells, wraps bool | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | + | // workspaceStep is the workspace step a directional focus past the content | |||
| 33 | + | // edge takes along axis: dir when axis is the workspace axis, else 0. | |||
| 34 | + | func (p layoutPolicy) workspaceStep(axis layoutAxis, dir int) int { | |||
| 35 | + | if axis == p.workspace { | |||
| 36 | + | return dir | |||
| 37 | + | } | |||
| 38 | + | return 0 | |||
| 39 | + | } | |||
| 40 | + | ||||
| 32 | func (w *Workspace) policy() layoutPolicy { | 41 | func (w *Workspace) policy() layoutPolicy { | ||
| 33 | if w.Overflow == OverflowCascade { | 42 | if w.Overflow == OverflowCascade { | ||
| 34 | return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, wraps: true} | 43 | return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, wraps: true} | ||
A internal/core/layout_policy_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestWorkspaceStepFollowsWorkspaceAxis(t *testing.T) { | |
| 6 | + | for _, tc := range []struct { | |
| 7 | + | name string | |
| 8 | + | overflow Overflow | |
| 9 | + | vertical int | |
| 10 | + | horizontal int | |
| 11 | + | }{ | |
| 12 | + | {"scroll", OverflowScroll, 1, 0}, | |
| 13 | + | {"fixed", OverflowFixed, 1, 0}, | |
| 14 | + | {"cascade", OverflowCascade, 0, 1}, | |
| 15 | + | } { | |
| 16 | + | t.Run(tc.name, func(t *testing.T) { | |
| 17 | + | w := &Workspace{Overflow: tc.overflow} | |
| 18 | + | p := w.policy() | |
| 19 | + | for _, dir := range []int{-1, 1} { | |
| 20 | + | if got := p.workspaceStep(verticalAxis, dir); got != dir*tc.vertical { | |
| 21 | + | t.Errorf("vertical step %d = %d, want %d", dir, got, dir*tc.vertical) | |
| 22 | + | } | |
| 23 | + | if got := p.workspaceStep(horizontalAxis, dir); got != dir*tc.horizontal { | |
| 24 | + | t.Errorf("horizontal step %d = %d, want %d", dir, got, dir*tc.horizontal) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestWorkspaceStepFollowsWorkspaceAxis(t *testing.T) { | |||
| 6 | + | for _, tc := range []struct { | |||
| 7 | + | name string | |||
| 8 | + | overflow Overflow | |||
| 9 | + | vertical int | |||
| 10 | + | horizontal int | |||
| 11 | + | }{ | |||
| 12 | + | {"scroll", OverflowScroll, 1, 0}, | |||
| 13 | + | {"fixed", OverflowFixed, 1, 0}, | |||
| 14 | + | {"cascade", OverflowCascade, 0, 1}, | |||
| 15 | + | } { | |||
| 16 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 17 | + | w := &Workspace{Overflow: tc.overflow} | |||
| 18 | + | p := w.policy() | |||
| 19 | + | for _, dir := range []int{-1, 1} { | |||
| 20 | + | if got := p.workspaceStep(verticalAxis, dir); got != dir*tc.vertical { | |||
| 21 | + | t.Errorf("vertical step %d = %d, want %d", dir, got, dir*tc.vertical) | |||
| 22 | + | } | |||
| 23 | + | if got := p.workspaceStep(horizontalAxis, dir); got != dir*tc.horizontal { | |||
| 24 | + | t.Errorf("horizontal step %d = %d, want %d", dir, got, dir*tc.horizontal) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | } | |||
M internal/core/monitor.go +12 -0
| @@ -205,6 +205,18 @@ func (m *Monitor) Focus(i int) { | |||
| 205 | 205 | m.normalize() | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | + | // stepWorkspace switches to the previous or next workspace (dir -1/1) when | |
| 209 | + | // axis is the current layout's workspace axis, and reports whether it did. A | |
| 210 | + | // hidden named workspace is outside the numbered list: it never steps. | |
| 211 | + | func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool { | |
| 212 | + | step := m.Current().policy().workspaceStep(axis, dir) | |
| 213 | + | if m.shown != nil || step == 0 { | |
| 214 | + | return false | |
| 215 | + | } | |
| 216 | + | m.Focus(m.Active + step) | |
| 217 | + | return true | |
| 218 | + | } | |
| 219 | + | ||
| 208 | 220 | // MoveWorkspace swaps the active numbered workspace with its neighbor up | |
| 209 | 221 | // (dir -1) or down (dir 1); the view follows it. It never passes the | |
| 210 | 222 | // trailing empty workspace. | |
| @@ -205,6 +205,18 @@ func (m *Monitor) Focus(i int) { | |||||
| 205 | m.normalize() | 205 | m.normalize() | ||
| 206 | } | 206 | } | ||
| 207 | 207 | ||||
| 208 | + | // stepWorkspace switches to the previous or next workspace (dir -1/1) when | |||
| 209 | + | // axis is the current layout's workspace axis, and reports whether it did. A | |||
| 210 | + | // hidden named workspace is outside the numbered list: it never steps. | |||
| 211 | + | func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool { | |||
| 212 | + | step := m.Current().policy().workspaceStep(axis, dir) | |||
| 213 | + | if m.shown != nil || step == 0 { | |||
| 214 | + | return false | |||
| 215 | + | } | |||
| 216 | + | m.Focus(m.Active + step) | |||
| 217 | + | return true | |||
| 218 | + | } | |||
| 219 | + | ||||
| 208 | // MoveWorkspace swaps the active numbered workspace with its neighbor up | 220 | // MoveWorkspace swaps the active numbered workspace with its neighbor up | ||
| 209 | // (dir -1) or down (dir 1); the view follows it. It never passes the | 221 | // (dir -1) or down (dir 1); the view follows it. It never passes the | ||
| 210 | // trailing empty workspace. | 222 | // trailing empty workspace. | ||