detached 40be849

refactor(core): keep stepWorkspace clamping and pick occupied workspaces in occupiedStep

commit
40be84979d50062b0bc716ea571261d7cd357f88
author
brice · · 2026-10-12 14:15 UTC
parent
6a8bd4f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +68 -18
M docs/config.md +1 -1
@@ -227,7 +227,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
227227
228228 Cascade is the default layout (`layout.overflow = cascade`); it can also be set per output or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its row of cells (see [Column widths](#column-widths)); auto columns share it equally, so a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
229229
230-Left/right focus and moves stay in the band; at its edge, `focus-column-left/right` go to the previous or next occupied workspace, so the opposite key comes back; past the first or last one, to the neighbor monitor on that side; with none there, to the trailing empty workspace; `move-column-left/right` go to the neighbor monitor, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
230+Left/right focus and moves stay in the band; at its edge, `focus-column-left/right` go first to the previous or next occupied workspace; past the first or last one, to the neighbor monitor on that side; with none there, to the trailing empty workspace; `move-column-left/right` go to the neighbor monitor, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
231231
232232 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
233233
@@ -227,7 +227,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
227 227
228 Cascade is the default layout (`layout.overflow = cascade`); it can also be set per output or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its row of cells (see [Column widths](#column-widths)); auto columns share it equally, so a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.228 Cascade is the default layout (`layout.overflow = cascade`); it can also be set per output or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its row of cells (see [Column widths](#column-widths)); auto columns share it equally, so a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
229 229
230-Left/right focus and moves stay in the band; at its edge, `focus-column-left/right` go to the previous or next occupied workspace, so the opposite key comes back; past the first or last one, to the neighbor monitor on that side; with none there, to the trailing empty workspace; `move-column-left/right` go to the neighbor monitor, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.230+Left/right focus and moves stay in the band; at its edge, `focus-column-left/right` go first to the previous or next occupied workspace; past the first or last one, to the neighbor monitor on that side; with none there, to the trailing empty workspace; `move-column-left/right` go to the neighbor monitor, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
231 231
232 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.232 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
233 233
M internal/core/action.go +8 -7
@@ -301,19 +301,20 @@ func (c *Core) applyAction(a Action) Effect {
301301 }
302302
303303 // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the
304-// content edge: to the occupied workspace on that side when the workspace
305-// axis is horizontal, so the opposite key comes back; else to the neighbor
306-// monitor; else to the trailing empty workspace. It reports whether focus
307-// moved.
304+// content edge, in order: to the occupied workspace on that side when the
305+// workspace axis is horizontal; else to the neighbor monitor; else to the
306+// trailing empty workspace. It reports whether focus moved.
308307 func (c *Core) edgeFocus(dir int, d direction) bool {
309- if c.cur().mon.stepWorkspace(horizontalAxis, dir, false) {
308+ m := c.cur().mon
309+ if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
310+ m.Focus(to)
310311 return true
311312 }
312313 if i := c.neighbor(d); i >= 0 {
313314 c.focusScreen = i
314315 return true
315316 }
316- return c.cur().mon.stepWorkspace(horizontalAxis, dir, true)
317+ return m.stepWorkspace(horizontalAxis, dir)
317318 }
318319
319320 // Apply runs a bind action on the monitor.
@@ -354,7 +355,7 @@ func (m *Monitor) Apply(a Action) Effect {
354355 dir = -1
355356 }
356357 if !m.Current().FocusWindow(dir) {
357- m.stepWorkspace(verticalAxis, dir, true)
358+ m.stepWorkspace(verticalAxis, dir)
358359 }
359360 return Effect{}
360361 case ActionToggleFullscreen:
@@ -301,19 +301,20 @@ func (c *Core) applyAction(a Action) Effect {
301 }301 }
302 302
303 // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the303 // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the
304-// content edge: to the occupied workspace on that side when the workspace304+// content edge, in order: to the occupied workspace on that side when the
305-// axis is horizontal, so the opposite key comes back; else to the neighbor305+// workspace axis is horizontal; else to the neighbor monitor; else to the
306-// monitor; else to the trailing empty workspace. It reports whether focus306+// trailing empty workspace. It reports whether focus moved.
307-// moved.
308 func (c *Core) edgeFocus(dir int, d direction) bool {307 func (c *Core) edgeFocus(dir int, d direction) bool {
309- if c.cur().mon.stepWorkspace(horizontalAxis, dir, false) {308+ m := c.cur().mon
309+ if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
310+ m.Focus(to)
310 return true311 return true
311 }312 }
312 if i := c.neighbor(d); i >= 0 {313 if i := c.neighbor(d); i >= 0 {
313 c.focusScreen = i314 c.focusScreen = i
314 return true315 return true
315 }316 }
316- return c.cur().mon.stepWorkspace(horizontalAxis, dir, true)317+ return m.stepWorkspace(horizontalAxis, dir)
317 }318 }
318 319
319 // Apply runs a bind action on the monitor.320 // Apply runs a bind action on the monitor.
@@ -354,7 +355,7 @@ func (m *Monitor) Apply(a Action) Effect {
354 dir = -1355 dir = -1
355 }356 }
356 if !m.Current().FocusWindow(dir) {357 if !m.Current().FocusWindow(dir) {
357- m.stepWorkspace(verticalAxis, dir, true)358+ m.stepWorkspace(verticalAxis, dir)
358 }359 }
359 return Effect{}360 return Effect{}
360 case ActionToggleFullscreen:361 case ActionToggleFullscreen:
M internal/core/cascade_moves_test.go +1 -1
@@ -23,6 +23,7 @@ func cascadeMovesCore(t *testing.T, n, maxCols int, sides bool) (*Core, *Workspa
2323 for id := WindowID(1); id <= WindowID(n); id++ {
2424 w.AddWindow(id)
2525 }
26+ c.cur().mon.normalize() // the trailing empty workspace, as in a session
2627 return c, w
2728 }
2829
@@ -107,7 +108,6 @@ func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
107108 func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {
108109 c, w := cascadeMovesCore(t, 5, 3, false)
109110 m := c.cur().mon
110- m.normalize() // the trailing empty workspace, as in a running session
111111 w.FocusID(4)
112112 c.applyAction(ActionFocusColumnLeft)
113113 wantFocus(t, c, "M", 4)
@@ -23,6 +23,7 @@ func cascadeMovesCore(t *testing.T, n, maxCols int, sides bool) (*Core, *Workspa
23 for id := WindowID(1); id <= WindowID(n); id++ {23 for id := WindowID(1); id <= WindowID(n); id++ {
24 w.AddWindow(id)24 w.AddWindow(id)
25 }25 }
26+ c.cur().mon.normalize() // the trailing empty workspace, as in a session
26 return c, w27 return c, w
27 }28 }
28 29
@@ -107,7 +108,6 @@ func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
107 func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {108 func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {
108 c, w := cascadeMovesCore(t, 5, 3, false)109 c, w := cascadeMovesCore(t, 5, 3, false)
109 m := c.cur().mon110 m := c.cur().mon
110- m.normalize() // the trailing empty workspace, as in a running session
111 w.FocusID(4)111 w.FocusID(4)
112 c.applyAction(ActionFocusColumnLeft)112 c.applyAction(ActionFocusColumnLeft)
113 wantFocus(t, c, "M", 4)113 wantFocus(t, c, "M", 4)
M internal/core/monitor.go +19 -9
@@ -206,21 +206,31 @@ func (m *Monitor) Focus(i int) {
206206 }
207207
208208 // 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.
210-// Past the first or the trailing workspace there is none to step to; the
211-// trailing empty workspace counts only with empty set. A hidden named
209+// axis is the current layout's workspace axis, and reports whether it did;
210+// Focus clamps past the first or the trailing workspace. A hidden named
212211 // workspace is outside the numbered list: it never steps.
213-func (m *Monitor) stepWorkspace(axis layoutAxis, dir int, empty bool) bool {
212+func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
213+ step := m.Current().policy().workspaceStep(axis, dir)
214+ if m.shown != nil || step == 0 {
215+ return false
216+ }
217+ m.Focus(m.Active + step)
218+ return true
219+}
220+
221+// occupiedStep is the numbered workspace dir (-1/1) away along axis when
222+// it is the layout's workspace axis and that workspace is not the trailing
223+// empty one, or -1.
224+func (m *Monitor) occupiedStep(axis layoutAxis, dir int) int {
214225 step := m.Current().policy().workspaceStep(axis, dir)
215226 to := m.Active + step
216227 if m.shown != nil || step == 0 || to < 0 || to >= len(m.Workspaces) {
217- return false
228+ return -1
218229 }
219- if w := m.Workspaces[to]; !empty && w.empty() && w.Name == "" {
220- return false
230+ if w := m.Workspaces[to]; w.empty() && w.Name == "" {
231+ return -1
221232 }
222- m.Focus(to)
223- return true
233+ return to
224234 }
225235
226236 // MoveWorkspace swaps the active numbered workspace with its neighbor up
@@ -206,21 +206,31 @@ func (m *Monitor) Focus(i int) {
206 }206 }
207 207
208 // stepWorkspace switches to the previous or next workspace (dir -1/1) when208 // 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.209+// axis is the current layout's workspace axis, and reports whether it did;
210-// Past the first or the trailing workspace there is none to step to; the210+// Focus clamps past the first or the trailing workspace. A hidden named
211-// trailing empty workspace counts only with empty set. A hidden named
212 // workspace is outside the numbered list: it never steps.211 // workspace is outside the numbered list: it never steps.
213-func (m *Monitor) stepWorkspace(axis layoutAxis, dir int, empty bool) bool {212+func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
213+ step := m.Current().policy().workspaceStep(axis, dir)
214+ if m.shown != nil || step == 0 {
215+ return false
216+ }
217+ m.Focus(m.Active + step)
218+ return true
219+}
220+
221+// occupiedStep is the numbered workspace dir (-1/1) away along axis when
222+// it is the layout's workspace axis and that workspace is not the trailing
223+// empty one, or -1.
224+func (m *Monitor) occupiedStep(axis layoutAxis, dir int) int {
214 step := m.Current().policy().workspaceStep(axis, dir)225 step := m.Current().policy().workspaceStep(axis, dir)
215 to := m.Active + step226 to := m.Active + step
216 if m.shown != nil || step == 0 || to < 0 || to >= len(m.Workspaces) {227 if m.shown != nil || step == 0 || to < 0 || to >= len(m.Workspaces) {
217- return false228+ return -1
218 }229 }
219- if w := m.Workspaces[to]; !empty && w.empty() && w.Name == "" {230+ if w := m.Workspaces[to]; w.empty() && w.Name == "" {
220- return false231+ return -1
221 }232 }
222- m.Focus(to)233+ return to
223- return true
224 }234 }
225 235
226 // MoveWorkspace swaps the active numbered workspace with its neighbor up236 // MoveWorkspace swaps the active numbered workspace with its neighbor up
M internal/core/workspace_axis_test.go +39 -0
@@ -196,6 +196,45 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
196196 }
197197 }
198198
199+// A covering fullscreen tile follows the same order: the occupied workspace
200+// before the monitor on that side, which comes back only past the last one.
201+func TestCascadeFullscreenStepsWorkspaceBeforeMonitor(t *testing.T) {
202+ c, m := axisCore(t, true, OverflowCascade, 3)
203+ w := m.Current()
204+ w.FocusID(23)
205+ w.ToggleFullscreen()
206+ c.applyAction(ActionFocusColumnRight)
207+ wantActive(t, m, 2)
208+ if got := c.cur().name(); got != "M" {
209+ t.Fatalf("focused screen %q, want M", got)
210+ }
211+ if w.fullscreen != 23 {
212+ t.Fatalf("fullscreen %d, want 23", w.fullscreen)
213+ }
214+ last := m.Current()
215+ last.FocusID(35)
216+ last.ToggleFullscreen()
217+ c.applyAction(ActionFocusColumnRight)
218+ wantFocus(t, c, "R", 0)
219+ if last.fullscreen != 35 {
220+ t.Fatalf("fullscreen %d, want 35", last.fullscreen)
221+ }
222+}
223+
224+// A workspace holding only a float is occupied: it comes before the monitor.
225+func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) {
226+ c, m := axisCore(t, true, OverflowCascade, 3)
227+ m.Focus(3)
228+ m.AddFloating(9, 100, 100)
229+ m.Focus(2)
230+ m.Current().FocusID(35)
231+ c.applyAction(ActionFocusColumnRight)
232+ wantActive(t, m, 3)
233+ if got := c.cur().name(); got != "M" {
234+ t.Fatalf("focused screen %q, want M", got)
235+ }
236+}
237+
199238 // AC7: a lone native float, or an empty cascade workspace, is at the edge on
200239 // both sides.
201240 func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {
@@ -196,6 +196,45 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
196 }196 }
197 }197 }
198 198
199+// A covering fullscreen tile follows the same order: the occupied workspace
200+// before the monitor on that side, which comes back only past the last one.
201+func TestCascadeFullscreenStepsWorkspaceBeforeMonitor(t *testing.T) {
202+ c, m := axisCore(t, true, OverflowCascade, 3)
203+ w := m.Current()
204+ w.FocusID(23)
205+ w.ToggleFullscreen()
206+ c.applyAction(ActionFocusColumnRight)
207+ wantActive(t, m, 2)
208+ if got := c.cur().name(); got != "M" {
209+ t.Fatalf("focused screen %q, want M", got)
210+ }
211+ if w.fullscreen != 23 {
212+ t.Fatalf("fullscreen %d, want 23", w.fullscreen)
213+ }
214+ last := m.Current()
215+ last.FocusID(35)
216+ last.ToggleFullscreen()
217+ c.applyAction(ActionFocusColumnRight)
218+ wantFocus(t, c, "R", 0)
219+ if last.fullscreen != 35 {
220+ t.Fatalf("fullscreen %d, want 35", last.fullscreen)
221+ }
222+}
223+
224+// A workspace holding only a float is occupied: it comes before the monitor.
225+func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) {
226+ c, m := axisCore(t, true, OverflowCascade, 3)
227+ m.Focus(3)
228+ m.AddFloating(9, 100, 100)
229+ m.Focus(2)
230+ m.Current().FocusID(35)
231+ c.applyAction(ActionFocusColumnRight)
232+ wantActive(t, m, 3)
233+ if got := c.cur().name(); got != "M" {
234+ t.Fatalf("focused screen %q, want M", got)
235+ }
236+}
237+
199 // AC7: a lone native float, or an empty cascade workspace, is at the edge on238 // AC7: a lone native float, or an empty cascade workspace, is at the edge on
200 // both sides.239 // both sides.
201 func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {240 func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {

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