detached d1b51b4

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
338338 switch a {
339339 case ActionFocusWindowUp, ActionFocusWindowDown:
340340 // 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.
343344 dir := 1
344345 if a == ActionFocusWindowUp {
345346 dir = -1
346347 }
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)
351350 }
352351 return Effect{}
353352 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 := 1344 dir := 1
344 if a == ActionFocusWindowUp {345 if a == ActionFocusWindowUp {
345 dir = -1346 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 {
2929 equalCells, wraps bool
3030 }
3131
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+
3241 func (w *Workspace) policy() layoutPolicy {
3342 if w.Overflow == OverflowCascade {
3443 return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, wraps: true}
@@ -29,6 +29,15 @@ type layoutPolicy struct {
29 equalCells, wraps bool29 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) {
205205 m.normalize()
206206 }
207207
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+
208220 // MoveWorkspace swaps the active numbered workspace with its neighbor up
209221 // (dir -1) or down (dir 1); the view follows it. It never passes the
210222 // 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 up220 // MoveWorkspace swaps the active numbered workspace with its neighbor up
209 // (dir -1) or down (dir 1); the view follows it. It never passes the221 // (dir -1) or down (dir 1); the view follows it. It never passes the
210 // trailing empty workspace.222 // trailing empty workspace.

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