detached 446d1e3

Merge pull request 'fix(core): cascade left/right step the occupied workspace before the neighbor monitor' (#41) from fix/cascade-step-hang into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/41
commit
446d1e3563034ef48c0aeb9fd20e596b7b99c646
author
bnema · · 2026-10-12 15:27 UTC
merge of
de9da51 + 40be849 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.8.0
5 files · +141 -28
M docs/config.md +3 -3
@@ -227,13 +227,13 @@ 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 neighbor monitor on that side, or, with none, to the previous or next 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
234234 In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back.
235235
236-In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus goes to the neighbor monitor at the band's edge, else to the previous or next workspace). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
236+In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus goes to the previous or next occupied workspace at the band's edge, then to the neighbor monitor, then to the trailing empty workspace). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
237237
238238 Directional focus past the edge of the content switches workspace along the layout's workspace axis: up/down in scroll and fixed overflow, left/right in cascade.
239239
@@ -267,7 +267,7 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
267267 | `switch-column-prev` | The same, in the opposite direction |
268268 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
269269 | `cycle-column-width` | Grow the focused column by one cell, taking it from the widest other column of its row (see [Column widths](#column-widths)). When no column has a cell to give, every column of the row goes back to equal shares. In scroll overflow, the column grows to `max-columns` cells, then back to auto. A lone column or a full row cannot grow: it goes back to equal shares if its cells were set, and otherwise nothing changes. A spiral never changes |
270-| `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's, and past it with no monitor the previous or next workspace. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
270+| `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's, and past it comes the previous or next occupied workspace, then the neighbor monitor, then the trailing empty workspace. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
271271 | `focus-window-up/down` | Focus in the column; past the edge, the previous or next workspace (in cascade overflow, the adjacent band, stopping at the first and last). In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends |
272272 | `move-column-left/right` | Move the focused column; at the edge, to the neighbor monitor, focus following. In cascade overflow the edge is the band's; with no monitor there, the column crosses into the adjacent band. Fixed overflow follows column order, not the screen |
273273 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it. In cascade overflow, past the column, the column moves to the adjacent band, where `focus-window-up/down` goes |
@@ -227,13 +227,13 @@ 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 neighbor monitor on that side, or, with none, to the previous or next 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
234 In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back.234 In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back.
235 235
236-In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus goes to the neighbor monitor at the band's edge, else to the previous or next workspace). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.236+In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus goes to the previous or next occupied workspace at the band's edge, then to the neighbor monitor, then to the trailing empty workspace). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
237 237
238 Directional focus past the edge of the content switches workspace along the layout's workspace axis: up/down in scroll and fixed overflow, left/right in cascade.238 Directional focus past the edge of the content switches workspace along the layout's workspace axis: up/down in scroll and fixed overflow, left/right in cascade.
239 239
@@ -267,7 +267,7 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
267 | `switch-column-prev` | The same, in the opposite direction |267 | `switch-column-prev` | The same, in the opposite direction |
268 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |268 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
269 | `cycle-column-width` | Grow the focused column by one cell, taking it from the widest other column of its row (see [Column widths](#column-widths)). When no column has a cell to give, every column of the row goes back to equal shares. In scroll overflow, the column grows to `max-columns` cells, then back to auto. A lone column or a full row cannot grow: it goes back to equal shares if its cells were set, and otherwise nothing changes. A spiral never changes |269 | `cycle-column-width` | Grow the focused column by one cell, taking it from the widest other column of its row (see [Column widths](#column-widths)). When no column has a cell to give, every column of the row goes back to equal shares. In scroll overflow, the column grows to `max-columns` cells, then back to auto. A lone column or a full row cannot grow: it goes back to equal shares if its cells were set, and otherwise nothing changes. A spiral never changes |
270-| `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's, and past it with no monitor the previous or next workspace. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |270+| `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's, and past it comes the previous or next occupied workspace, then the neighbor monitor, then the trailing empty workspace. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
271 | `focus-window-up/down` | Focus in the column; past the edge, the previous or next workspace (in cascade overflow, the adjacent band, stopping at the first and last). In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends |271 | `focus-window-up/down` | Focus in the column; past the edge, the previous or next workspace (in cascade overflow, the adjacent band, stopping at the first and last). In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends |
272 | `move-column-left/right` | Move the focused column; at the edge, to the neighbor monitor, focus following. In cascade overflow the edge is the band's; with no monitor there, the column crosses into the adjacent band. Fixed overflow follows column order, not the screen |272 | `move-column-left/right` | Move the focused column; at the edge, to the neighbor monitor, focus following. In cascade overflow the edge is the band's; with no monitor there, the column crosses into the adjacent band. Fixed overflow follows column order, not the screen |
273 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it. In cascade overflow, past the column, the column moves to the adjacent band, where `focus-window-up/down` goes |273 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it. In cascade overflow, past the column, the column moves to the adjacent band, where `focus-window-up/down` goes |
M internal/core/action.go +23 -18
@@ -230,32 +230,20 @@ func (c *Core) applyAction(a Action) Effect {
230230 dir, d = -1, dirLeft
231231 }
232232 // A covering fullscreen window is left for the window on that side;
233- // with none (the workspace, band or stash edge), the move goes to the
234- // neighbor monitor, else along the workspace axis, and fullscreen
235- // stays.
233+ // with none (the workspace, band or stash edge), the move goes past
234+ // the edge (see edgeFocus) and fullscreen stays.
236235 if w.pinned() && !w.stashOverCover() {
237236 if !w.FocusColumn(dir) {
238- if i := c.neighbor(d); i >= 0 {
239- c.focusScreen = i
240- } else {
241- c.cur().mon.stepWorkspace(horizontalAxis, dir)
242- }
237+ c.edgeFocus(dir, d)
243238 }
244239 return Effect{}
245240 }
246241 // The first move from a float stays here unless nothing is under it:
247242 // a native float leaves for the stash or the columns, and the stash
248- // keeps the focus at its ends. At the edge the move goes to the
249- // neighbor monitor, else along the workspace axis if it is horizontal.
243+ // keeps the focus at its ends.
250244 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
251- if edge {
252- if i := c.neighbor(d); i >= 0 {
253- c.focusScreen = i
254- return Effect{}
255- }
256- if c.cur().mon.stepWorkspace(horizontalAxis, dir) {
257- return Effect{}
258- }
245+ if edge && c.edgeFocus(dir, d) {
246+ return Effect{}
259247 }
260248 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
261249 dir, d := 1, dirRight
@@ -312,6 +300,23 @@ func (c *Core) applyAction(a Action) Effect {
312300 return c.cur().mon.Apply(a)
313301 }
314302
303+// edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the
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.
307+func (c *Core) edgeFocus(dir int, d direction) bool {
308+ m := c.cur().mon
309+ if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
310+ m.Focus(to)
311+ return true
312+ }
313+ if i := c.neighbor(d); i >= 0 {
314+ c.focusScreen = i
315+ return true
316+ }
317+ return m.stepWorkspace(horizontalAxis, dir)
318+}
319+
315320 // Apply runs a bind action on the monitor.
316321 func (m *Monitor) Apply(a Action) Effect {
317322 if e, ok := m.overviewAction(a); ok {
@@ -230,32 +230,20 @@ func (c *Core) applyAction(a Action) Effect {
230 dir, d = -1, dirLeft230 dir, d = -1, dirLeft
231 }231 }
232 // A covering fullscreen window is left for the window on that side;232 // A covering fullscreen window is left for the window on that side;
233- // with none (the workspace, band or stash edge), the move goes to the233+ // with none (the workspace, band or stash edge), the move goes past
234- // neighbor monitor, else along the workspace axis, and fullscreen234+ // the edge (see edgeFocus) and fullscreen stays.
235- // stays.
236 if w.pinned() && !w.stashOverCover() {235 if w.pinned() && !w.stashOverCover() {
237 if !w.FocusColumn(dir) {236 if !w.FocusColumn(dir) {
238- if i := c.neighbor(d); i >= 0 {237+ c.edgeFocus(dir, d)
239- c.focusScreen = i
240- } else {
241- c.cur().mon.stepWorkspace(horizontalAxis, dir)
242- }
243 }238 }
244 return Effect{}239 return Effect{}
245 }240 }
246 // The first move from a float stays here unless nothing is under it:241 // The first move from a float stays here unless nothing is under it:
247 // a native float leaves for the stash or the columns, and the stash242 // a native float leaves for the stash or the columns, and the stash
248- // keeps the focus at its ends. At the edge the move goes to the243+ // keeps the focus at its ends.
249- // neighbor monitor, else along the workspace axis if it is horizontal.
250 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())244 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
251- if edge {245+ if edge && c.edgeFocus(dir, d) {
252- if i := c.neighbor(d); i >= 0 {246+ return Effect{}
253- c.focusScreen = i
254- return Effect{}
255- }
256- if c.cur().mon.stepWorkspace(horizontalAxis, dir) {
257- return Effect{}
258- }
259 }247 }
260 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:248 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
261 dir, d := 1, dirRight249 dir, d := 1, dirRight
@@ -312,6 +300,23 @@ func (c *Core) applyAction(a Action) Effect {
312 return c.cur().mon.Apply(a)300 return c.cur().mon.Apply(a)
313 }301 }
314 302
303+// edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the
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.
307+func (c *Core) edgeFocus(dir int, d direction) bool {
308+ m := c.cur().mon
309+ if to := m.occupiedStep(horizontalAxis, dir); to >= 0 {
310+ m.Focus(to)
311+ return true
312+ }
313+ if i := c.neighbor(d); i >= 0 {
314+ c.focusScreen = i
315+ return true
316+ }
317+ return m.stepWorkspace(horizontalAxis, dir)
318+}
319+
315 // Apply runs a bind action on the monitor.320 // Apply runs a bind action on the monitor.
316 func (m *Monitor) Apply(a Action) Effect {321 func (m *Monitor) Apply(a Action) Effect {
317 if e, ok := m.overviewAction(a); ok {322 if e, ok := m.overviewAction(a); ok {
M internal/core/cascade_moves_test.go +1 -0
@@ -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
@@ -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
M internal/core/monitor.go +18 -2
@@ -206,8 +206,9 @@ 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. A
210-// hidden named workspace is outside the numbered list: it never steps.
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
211+// workspace is outside the numbered list: it never steps.
211212 func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
212213 step := m.Current().policy().workspaceStep(axis, dir)
213214 if m.shown != nil || step == 0 {
@@ -217,6 +218,21 @@ func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
217218 return true
218219 }
219220
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 {
225+ step := m.Current().policy().workspaceStep(axis, dir)
226+ to := m.Active + step
227+ if m.shown != nil || step == 0 || to < 0 || to >= len(m.Workspaces) {
228+ return -1
229+ }
230+ if w := m.Workspaces[to]; w.empty() && w.Name == "" {
231+ return -1
232+ }
233+ return to
234+}
235+
220236 // MoveWorkspace swaps the active numbered workspace with its neighbor up
221237 // (dir -1) or down (dir 1); the view follows it. It never passes the
222238 // trailing empty workspace.
@@ -206,8 +206,9 @@ 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. A209+// axis is the current layout's workspace axis, and reports whether it did;
210-// hidden named workspace is outside the numbered list: it never steps.210+// Focus clamps past the first or the trailing workspace. A hidden named
211+// workspace is outside the numbered list: it never steps.
211 func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {212 func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
212 step := m.Current().policy().workspaceStep(axis, dir)213 step := m.Current().policy().workspaceStep(axis, dir)
213 if m.shown != nil || step == 0 {214 if m.shown != nil || step == 0 {
@@ -217,6 +218,21 @@ func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
217 return true218 return true
218 }219 }
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 {
225+ step := m.Current().policy().workspaceStep(axis, dir)
226+ to := m.Active + step
227+ if m.shown != nil || step == 0 || to < 0 || to >= len(m.Workspaces) {
228+ return -1
229+ }
230+ if w := m.Workspaces[to]; w.empty() && w.Name == "" {
231+ return -1
232+ }
233+ return to
234+}
235+
220 // MoveWorkspace swaps the active numbered workspace with its neighbor up236 // MoveWorkspace swaps the active numbered workspace with its neighbor up
221 // (dir -1) or down (dir 1); the view follows it. It never passes the237 // (dir -1) or down (dir 1); the view follows it. It never passes the
222 // trailing empty workspace.238 // trailing empty workspace.
M internal/core/workspace_axis_test.go +96 -5
@@ -50,18 +50,70 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
5050 }
5151 }
5252
53-// AC2: a monitor on that side wins over the workspace step.
54-func TestCascadeFocusColumnPrefersNeighborMonitor(t *testing.T) {
53+// An occupied workspace wins over a monitor on that side, so the opposite
54+// key brings focus back; the monitor wins over the trailing empty
55+// workspace, so it stays reachable.
56+func TestCascadeFocusColumnStepsWorkspaceBeforeMonitor(t *testing.T) {
5557 c, m := axisCore(t, true, OverflowCascade, 3)
5658 m.Current().FocusID(23)
5759 c.applyAction(ActionFocusColumnRight)
58- wantFocus(t, c, "R", 0)
60+ wantActive(t, m, 2)
61+ if got := c.cur().name(); got != "M" {
62+ t.Fatalf("focused screen %q, want M", got)
63+ }
64+ // Back with the opposite key, from the first column of workspace 3.
65+ m.Current().FocusID(31)
66+ c.applyAction(ActionFocusColumnLeft)
5967 wantActive(t, m, 1)
60- c.focusScreen = 1
61- m.Current().FocusID(24)
68+ if got := c.cur().name(); got != "M" {
69+ t.Fatalf("focused screen %q, want M", got)
70+ }
71+ // Past the first workspace: the monitor on the left.
72+ m.Focus(0)
73+ m.Current().FocusID(11)
6274 c.applyAction(ActionFocusColumnLeft)
6375 wantFocus(t, c, "L", 0)
76+ wantActive(t, m, 0)
77+ // From the last occupied workspace: the monitor on the right, not the
78+ // trailing empty workspace.
79+ c.focusScreen = 1
80+ m.Focus(2)
81+ m.Current().FocusID(35)
82+ c.applyAction(ActionFocusColumnRight)
83+ wantFocus(t, c, "R", 0)
84+ wantActive(t, m, 2)
85+}
86+
87+// With no monitor on that side, the trailing empty workspace is the last
88+// stop, and left comes back from it.
89+func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T) {
90+ c, m := axisCore(t, false, OverflowCascade, 3)
91+ m.Focus(2)
92+ m.Current().FocusID(35)
93+ c.applyAction(ActionFocusColumnRight)
94+ wantActive(t, m, 3)
95+ if !m.Current().empty() {
96+ t.Fatal("want the trailing empty workspace")
97+ }
98+ c.applyAction(ActionFocusColumnLeft)
99+ wantActive(t, m, 2)
100+}
101+
102+// The live regression: one monitor on the left only. Right steps to the
103+// next workspace, and left from its empty band comes back to the previous
104+// workspace instead of leaving for that monitor.
105+func TestCascadeStepRightThenLeftComesBack(t *testing.T) {
106+ c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})
107+ m := c.cur().mon
108+ m.SetOverflow(OverflowCascade)
109+ m.SetMaxColumns(3)
110+ m.AddWindow(1)
111+ m.AddWindow(5)
112+ c.applyAction(ActionFocusColumnRight)
64113 wantActive(t, m, 1)
114+ c.applyAction(ActionFocusColumnLeft)
115+ wantFocus(t, c, "M", 5)
116+ wantActive(t, m, 0)
65117 }
66118
67119 // AC3: up/down cross bands and stop at the last, with no workspace change.
@@ -144,6 +196,45 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
144196 }
145197 }
146198
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+
147238 // AC7: a lone native float, or an empty cascade workspace, is at the edge on
148239 // both sides.
149240 func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {
@@ -50,18 +50,70 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
50 }50 }
51 }51 }
52 52
53-// AC2: a monitor on that side wins over the workspace step.53+// An occupied workspace wins over a monitor on that side, so the opposite
54-func TestCascadeFocusColumnPrefersNeighborMonitor(t *testing.T) {54+// key brings focus back; the monitor wins over the trailing empty
55+// workspace, so it stays reachable.
56+func TestCascadeFocusColumnStepsWorkspaceBeforeMonitor(t *testing.T) {
55 c, m := axisCore(t, true, OverflowCascade, 3)57 c, m := axisCore(t, true, OverflowCascade, 3)
56 m.Current().FocusID(23)58 m.Current().FocusID(23)
57 c.applyAction(ActionFocusColumnRight)59 c.applyAction(ActionFocusColumnRight)
58- wantFocus(t, c, "R", 0)60+ wantActive(t, m, 2)
61+ if got := c.cur().name(); got != "M" {
62+ t.Fatalf("focused screen %q, want M", got)
63+ }
64+ // Back with the opposite key, from the first column of workspace 3.
65+ m.Current().FocusID(31)
66+ c.applyAction(ActionFocusColumnLeft)
59 wantActive(t, m, 1)67 wantActive(t, m, 1)
60- c.focusScreen = 168+ if got := c.cur().name(); got != "M" {
61- m.Current().FocusID(24)69+ t.Fatalf("focused screen %q, want M", got)
70+ }
71+ // Past the first workspace: the monitor on the left.
72+ m.Focus(0)
73+ m.Current().FocusID(11)
62 c.applyAction(ActionFocusColumnLeft)74 c.applyAction(ActionFocusColumnLeft)
63 wantFocus(t, c, "L", 0)75 wantFocus(t, c, "L", 0)
76+ wantActive(t, m, 0)
77+ // From the last occupied workspace: the monitor on the right, not the
78+ // trailing empty workspace.
79+ c.focusScreen = 1
80+ m.Focus(2)
81+ m.Current().FocusID(35)
82+ c.applyAction(ActionFocusColumnRight)
83+ wantFocus(t, c, "R", 0)
84+ wantActive(t, m, 2)
85+}
86+
87+// With no monitor on that side, the trailing empty workspace is the last
88+// stop, and left comes back from it.
89+func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T) {
90+ c, m := axisCore(t, false, OverflowCascade, 3)
91+ m.Focus(2)
92+ m.Current().FocusID(35)
93+ c.applyAction(ActionFocusColumnRight)
94+ wantActive(t, m, 3)
95+ if !m.Current().empty() {
96+ t.Fatal("want the trailing empty workspace")
97+ }
98+ c.applyAction(ActionFocusColumnLeft)
99+ wantActive(t, m, 2)
100+}
101+
102+// The live regression: one monitor on the left only. Right steps to the
103+// next workspace, and left from its empty band comes back to the previous
104+// workspace instead of leaving for that monitor.
105+func TestCascadeStepRightThenLeftComesBack(t *testing.T) {
106+ c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})
107+ m := c.cur().mon
108+ m.SetOverflow(OverflowCascade)
109+ m.SetMaxColumns(3)
110+ m.AddWindow(1)
111+ m.AddWindow(5)
112+ c.applyAction(ActionFocusColumnRight)
64 wantActive(t, m, 1)113 wantActive(t, m, 1)
114+ c.applyAction(ActionFocusColumnLeft)
115+ wantFocus(t, c, "M", 5)
116+ wantActive(t, m, 0)
65 }117 }
66 118
67 // AC3: up/down cross bands and stop at the last, with no workspace change.119 // AC3: up/down cross bands and stop at the last, with no workspace change.
@@ -144,6 +196,45 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
144 }196 }
145 }197 }
146 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+
147 // 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
148 // both sides.239 // both sides.
149 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