detached de9da51

Merge pull request 'feat(core): cascade focus-column-left/right switch workspace at the band edge' (#40) from feat/workspace-axis-nav into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/40
commit
de9da51b78781ac70fb2fccf5fe87e762bd9f7bf
author
bnema · · 2026-10-12 13:16 UTC
merge of
8ceaf67 + e0bc7dc · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
8 files · +276 -32
M docs/config.md +6 -4
@@ -227,13 +227,15 @@ 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 they go to the neighbor monitor on that side, 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 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.
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 crosses into the next band instead). 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 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.
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.
237239
238240 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
239241
@@ -265,8 +267,8 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
265267 | `switch-column-prev` | The same, in the opposite direction |
266268 | `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 |
267269 | `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 |
268-| `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
269-| `focus-window-up/down` | Focus in the column; past the edge, the next workspace. 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 |
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 |
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 |
270272 | `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 |
271273 | `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 |
272274 | `set-column-width <+N\|-N>` | Widen or narrow the focused column by N cells (1-16), never below one cell and without cycling back to equal shares. In `cascade` and `fixed` overflow the cells come from, or go to, the other columns of the row; in `scroll` overflow the column grows on its own, up to `max-columns` cells |
@@ -227,13 +227,15 @@ 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 they go to the neighbor monitor on that side, 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 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.
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 crosses into the next band instead). 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 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.
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.
237 239
238 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.240 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
239 241
@@ -265,8 +267,8 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
265 | `switch-column-prev` | The same, in the opposite direction |267 | `switch-column-prev` | The same, in the opposite direction |
266 | `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 |
267 | `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 |
268-| `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's. 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 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 |
269-| `focus-window-up/down` | Focus in the column; past the edge, the next workspace. 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 |
270 | `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 |
271 | `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 |
272 | `set-column-width <+N\|-N>` | Widen or narrow the focused column by N cells (1-16), never below one cell and without cycling back to equal shares. In `cascade` and `fixed` overflow the cells come from, or go to, the other columns of the row; in `scroll` overflow the column grows on its own, up to `max-columns` cells |274 | `set-column-width <+N\|-N>` | Widen or narrow the focused column by N cells (1-16), never below one cell and without cycling back to equal shares. In `cascade` and `fixed` overflow the cells come from, or go to, the other columns of the row; in `scroll` overflow the column grows on its own, up to `max-columns` cells |
M internal/core/action.go +23 -20
@@ -197,8 +197,9 @@ type Effect struct {
197197 }
198198
199199 // applyAction runs a bind action. Monitor actions change the focused
200-// screen; column focus past the edge column moves to the neighbor screen;
201-// everything else applies to the focused screen's monitor.
200+// screen; column focus past the edge column moves to the neighbor screen,
201+// else along the layout's workspace axis; everything else applies to the
202+// focused screen's monitor.
202203 func (c *Core) applyAction(a Action) Effect {
203204 if e, ok := c.cur().mon.overviewAction(a); ok {
204205 return e
@@ -229,29 +230,32 @@ func (c *Core) applyAction(a Action) Effect {
229230 dir, d = -1, dirLeft
230231 }
231232 // A covering fullscreen window is left for the window on that side;
232- // with none (the workspace or stash edge), the move goes to the
233- // neighbor monitor and fullscreen stays.
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.
234236 if w.pinned() && !w.stashOverCover() {
235- // A cascade fullscreen tile at its band edge leaves for the
236- // neighbor monitor, like any tile there, and stays fullscreen.
237- if i := c.neighbor(d); i >= 0 && !w.onFloat() && w.policy().wraps && w.leavesBand(w.Focus+dir) {
238- c.focusScreen = i
239- return Effect{}
240- }
241237 if !w.FocusColumn(dir) {
242238 if i := c.neighbor(d); i >= 0 {
243239 c.focusScreen = i
240+ } else {
241+ c.cur().mon.stepWorkspace(horizontalAxis, dir)
244242 }
245243 }
246244 return Effect{}
247245 }
248246 // The first move from a float stays here unless nothing is under it:
249247 // a native float leaves for the stash or the columns, and the stash
250- // keeps the focus at its ends.
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.
251250 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
252- if i := c.neighbor(d); edge && i >= 0 {
253- c.focusScreen = i
254- return Effect{}
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+ }
255259 }
256260 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
257261 dir, d := 1, dirRight
@@ -338,16 +342,15 @@ func (m *Monitor) Apply(a Action) Effect {
338342 switch a {
339343 case ActionFocusWindowUp, ActionFocusWindowDown:
340344 // 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.
345+ // move to the next workspace if the layout's workspace axis is
346+ // vertical. Cascade workspaces sit side by side: up/down stop at the
347+ // first and last band.
343348 dir := 1
344349 if a == ActionFocusWindowUp {
345350 dir = -1
346351 }
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)
352+ if !m.Current().FocusWindow(dir) {
353+ m.stepWorkspace(verticalAxis, dir)
351354 }
352355 return Effect{}
353356 case ActionToggleFullscreen:
@@ -197,8 +197,9 @@ type Effect struct {
197 }197 }
198 198
199 // applyAction runs a bind action. Monitor actions change the focused199 // applyAction runs a bind action. Monitor actions change the focused
200-// screen; column focus past the edge column moves to the neighbor screen;200+// screen; column focus past the edge column moves to the neighbor screen,
201-// everything else applies to the focused screen's monitor.201+// else along the layout's workspace axis; everything else applies to the
202+// focused screen's monitor.
202 func (c *Core) applyAction(a Action) Effect {203 func (c *Core) applyAction(a Action) Effect {
203 if e, ok := c.cur().mon.overviewAction(a); ok {204 if e, ok := c.cur().mon.overviewAction(a); ok {
204 return e205 return e
@@ -229,29 +230,32 @@ func (c *Core) applyAction(a Action) Effect {
229 dir, d = -1, dirLeft230 dir, d = -1, dirLeft
230 }231 }
231 // 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;
232- // with none (the workspace or stash edge), the move goes to the233+ // with none (the workspace, band or stash edge), the move goes to the
233- // neighbor monitor and fullscreen stays.234+ // neighbor monitor, else along the workspace axis, and fullscreen
235+ // stays.
234 if w.pinned() && !w.stashOverCover() {236 if w.pinned() && !w.stashOverCover() {
235- // A cascade fullscreen tile at its band edge leaves for the
236- // neighbor monitor, like any tile there, and stays fullscreen.
237- if i := c.neighbor(d); i >= 0 && !w.onFloat() && w.policy().wraps && w.leavesBand(w.Focus+dir) {
238- c.focusScreen = i
239- return Effect{}
240- }
241 if !w.FocusColumn(dir) {237 if !w.FocusColumn(dir) {
242 if i := c.neighbor(d); i >= 0 {238 if i := c.neighbor(d); i >= 0 {
243 c.focusScreen = i239 c.focusScreen = i
240+ } else {
241+ c.cur().mon.stepWorkspace(horizontalAxis, dir)
244 }242 }
245 }243 }
246 return Effect{}244 return Effect{}
247 }245 }
248 // The first move from a float stays here unless nothing is under it:246 // The first move from a float stays here unless nothing is under it:
249 // a native float leaves for the stash or the columns, and the stash247 // a native float leaves for the stash or the columns, and the stash
250- // keeps the focus at its ends.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.
251 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())250 edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
252- if i := c.neighbor(d); edge && i >= 0 {251+ if edge {
253- c.focusScreen = i252+ if i := c.neighbor(d); i >= 0 {
254- return Effect{}253+ c.focusScreen = i
254+ return Effect{}
255+ }
256+ if c.cur().mon.stepWorkspace(horizontalAxis, dir) {
257+ return Effect{}
258+ }
255 }259 }
256 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:260 case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
257 dir, d := 1, dirRight261 dir, d := 1, dirRight
@@ -338,16 +342,15 @@ func (m *Monitor) Apply(a Action) Effect {
338 switch a {342 switch a {
339 case ActionFocusWindowUp, ActionFocusWindowDown:343 case ActionFocusWindowUp, ActionFocusWindowDown:
340 // Past the column and any on-screen neighbor or demoted float,344 // Past the column and any on-screen neighbor or demoted float,
341- // move to the next workspace. Cascade workspaces sit side by side:345+ // move to the next workspace if the layout's workspace axis is
342- // up/down stop at the first and last band.346+ // vertical. Cascade workspaces sit side by side: up/down stop at the
347+ // first and last band.
343 dir := 1348 dir := 1
344 if a == ActionFocusWindowUp {349 if a == ActionFocusWindowUp {
345 dir = -1350 dir = -1
346 }351 }
347- // A hidden workspace is outside the vertical list.352+ if !m.Current().FocusWindow(dir) {
348- w := m.Current()353+ m.stepWorkspace(verticalAxis, dir)
349- if !w.FocusWindow(dir) && m.shown == nil && !w.policy().wraps {
350- m.Focus(m.Active + dir)
351 }354 }
352 return Effect{}355 return Effect{}
353 case ActionToggleFullscreen:356 case ActionToggleFullscreen:
M internal/core/cascade_moves_test.go +23 -6
@@ -100,16 +100,33 @@ func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
100100 wantFocus(t, c, "M", 4)
101101 }
102102
103-// With no monitor on that side, left/right cross into the neighbor band in
104-// column order.
105-func TestCascadeBandEdgeWithoutMonitorCrossesBands(t *testing.T) {
103+// With no monitor on that side, left/right never cross into the neighbor
104+// band: at the band edge they step the workspace (the first has none before
105+// it; the last is the trailing empty one). up/down cross bands, and
106+// move-column still crosses in column order.
107+func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {
106108 c, w := cascadeMovesCore(t, 5, 3, false)
109+ m := c.cur().mon
107110 w.FocusID(4)
108111 c.applyAction(ActionFocusColumnLeft)
109- wantFocus(t, c, "M", 3)
110- c.applyAction(ActionMoveColumnRight)
112+ wantFocus(t, c, "M", 4)
113+ if m.Active != 0 {
114+ t.Fatalf("active %d, want 0", m.Active)
115+ }
116+ w.FocusID(3)
117+ c.applyAction(ActionFocusColumnRight)
118+ if m.Active != 1 || m.Current() == w {
119+ t.Fatalf("active %d, want the trailing empty workspace", m.Active)
120+ }
121+ m.Focus(0)
122+ w.FocusID(4)
123+ c.applyAction(ActionMoveColumnLeft)
111124 wantColumns(t, w, 1, 2, 4, 3, 5)
112- wantFocus(t, c, "M", 3)
125+ wantFocus(t, c, "M", 4)
126+ w.FocusID(4)
127+ c.applyAction(ActionMoveColumnRight)
128+ wantColumns(t, w, 1, 2, 3, 4, 5)
129+ wantFocus(t, c, "M", 4)
113130 }
114131
115132 // With one column per band, every column is at both edges of its band.
@@ -100,16 +100,33 @@ func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
100 wantFocus(t, c, "M", 4)100 wantFocus(t, c, "M", 4)
101 }101 }
102 102
103-// With no monitor on that side, left/right cross into the neighbor band in103+// With no monitor on that side, left/right never cross into the neighbor
104-// column order.104+// band: at the band edge they step the workspace (the first has none before
105-func TestCascadeBandEdgeWithoutMonitorCrossesBands(t *testing.T) {105+// it; the last is the trailing empty one). up/down cross bands, and
106+// move-column still crosses in column order.
107+func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) {
106 c, w := cascadeMovesCore(t, 5, 3, false)108 c, w := cascadeMovesCore(t, 5, 3, false)
109+ m := c.cur().mon
107 w.FocusID(4)110 w.FocusID(4)
108 c.applyAction(ActionFocusColumnLeft)111 c.applyAction(ActionFocusColumnLeft)
109- wantFocus(t, c, "M", 3)112+ wantFocus(t, c, "M", 4)
110- c.applyAction(ActionMoveColumnRight)113+ if m.Active != 0 {
114+ t.Fatalf("active %d, want 0", m.Active)
115+ }
116+ w.FocusID(3)
117+ c.applyAction(ActionFocusColumnRight)
118+ if m.Active != 1 || m.Current() == w {
119+ t.Fatalf("active %d, want the trailing empty workspace", m.Active)
120+ }
121+ m.Focus(0)
122+ w.FocusID(4)
123+ c.applyAction(ActionMoveColumnLeft)
111 wantColumns(t, w, 1, 2, 4, 3, 5)124 wantColumns(t, w, 1, 2, 4, 3, 5)
112- wantFocus(t, c, "M", 3)125+ wantFocus(t, c, "M", 4)
126+ w.FocusID(4)
127+ c.applyAction(ActionMoveColumnRight)
128+ wantColumns(t, w, 1, 2, 3, 4, 5)
129+ wantFocus(t, c, "M", 4)
113 }130 }
114 131
115 // With one column per band, every column is at both edges of its band.132 // With one column per band, every column is at both edges of its band.
M internal/core/layout.go +2 -2
@@ -717,7 +717,7 @@ func (w *Workspace) focusColumn(dir int) {
717717 // columnToward returns the column focus-column-left/right (dir -1/1) goes
718718 // to, or -1 at the edge. Scroll overflow follows the column order; fixed
719719 // overflow stacks columns (spiral), so it follows the screen: the column
720-// on that side.
720+// on that side. Cascade stays in its band: past the band edge is the edge.
721721 func (w *Workspace) columnToward(dir int) int {
722722 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
723723 return -1
@@ -725,7 +725,7 @@ func (w *Workspace) columnToward(dir int) int {
725725 if w.onScreenFocus() {
726726 return w.screenNeighbor(dir, 0)
727727 }
728- if i := w.Focus + dir; i >= 0 && i < len(w.Columns) {
728+ if i := w.Focus + dir; !w.leavesBand(i) {
729729 return i
730730 }
731731 return -1
@@ -717,7 +717,7 @@ func (w *Workspace) focusColumn(dir int) {
717 // columnToward returns the column focus-column-left/right (dir -1/1) goes717 // columnToward returns the column focus-column-left/right (dir -1/1) goes
718 // to, or -1 at the edge. Scroll overflow follows the column order; fixed718 // to, or -1 at the edge. Scroll overflow follows the column order; fixed
719 // overflow stacks columns (spiral), so it follows the screen: the column719 // overflow stacks columns (spiral), so it follows the screen: the column
720-// on that side.720+// on that side. Cascade stays in its band: past the band edge is the edge.
721 func (w *Workspace) columnToward(dir int) int {721 func (w *Workspace) columnToward(dir int) int {
722 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {722 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
723 return -1723 return -1
@@ -725,7 +725,7 @@ func (w *Workspace) columnToward(dir int) int {
725 if w.onScreenFocus() {725 if w.onScreenFocus() {
726 return w.screenNeighbor(dir, 0)726 return w.screenNeighbor(dir, 0)
727 }727 }
728- if i := w.Focus + dir; i >= 0 && i < len(w.Columns) {728+ if i := w.Focus + dir; !w.leavesBand(i) {
729 return i729 return i
730 }730 }
731 return -1731 return -1
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.
A internal/core/workspace_axis_test.go +172 -0
@@ -0,0 +1,172 @@
1+package core
2+
3+import "testing"
4+
5+// 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
7+// one. Workspace 2 is active. With sides, monitors L and R flank M.
8+func axisCore(t *testing.T, sides bool, overflow Overflow, maxCols int) (*Core, *Monitor) {
9+ t.Helper()
10+ c, _ := cascadeMovesCore(t, 0, maxCols, sides)
11+ m := c.cur().mon
12+ m.SetOverflow(overflow)
13+ m.SetMaxColumns(maxCols)
14+ for ws := range 3 {
15+ m.Focus(ws)
16+ for i := 1; i <= 5; i++ {
17+ m.AddWindow(WindowID((ws+1)*10 + i))
18+ }
19+ }
20+ m.Focus(1)
21+ return c, m
22+}
23+
24+func wantActive(t *testing.T, m *Monitor, active int) {
25+ t.Helper()
26+ if m.Active != active {
27+ t.Fatalf("active workspace %d, want %d", m.Active, active)
28+ }
29+}
30+
31+// AC1: past the band edge with no monitor, left/right step the workspace.
32+func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
33+ c, m := axisCore(t, false, OverflowCascade, 3)
34+ m.Current().FocusID(23) // last column of band 1, not the last band
35+ c.applyAction(ActionFocusColumnRight)
36+ wantActive(t, m, 2)
37+ m.Focus(1)
38+ m.Current().FocusID(24) // first column of band 2
39+ c.applyAction(ActionFocusColumnLeft)
40+ wantActive(t, m, 0)
41+ // The first workspace stays put at the start.
42+ m.Current().FocusID(11)
43+ c.applyAction(ActionFocusColumnLeft)
44+ wantActive(t, m, 0)
45+ // Inside the band, focus moves and the workspace stays.
46+ c.applyAction(ActionFocusColumnRight)
47+ wantActive(t, m, 0)
48+ if id, _ := m.Current().Focused(); id != 12 {
49+ t.Fatalf("focused %d, want 12", id)
50+ }
51+}
52+
53+// AC2: a monitor on that side wins over the workspace step.
54+func TestCascadeFocusColumnPrefersNeighborMonitor(t *testing.T) {
55+ c, m := axisCore(t, true, OverflowCascade, 3)
56+ m.Current().FocusID(23)
57+ c.applyAction(ActionFocusColumnRight)
58+ wantFocus(t, c, "R", 0)
59+ wantActive(t, m, 1)
60+ c.focusScreen = 1
61+ m.Current().FocusID(24)
62+ c.applyAction(ActionFocusColumnLeft)
63+ wantFocus(t, c, "L", 0)
64+ wantActive(t, m, 1)
65+}
66+
67+// AC3: up/down cross bands and stop at the last, with no workspace change.
68+func TestCascadeFocusWindowDownStaysOnWorkspace(t *testing.T) {
69+ c, m := axisCore(t, false, OverflowCascade, 3)
70+ m.Current().FocusID(22)
71+ c.applyAction(ActionFocusWindowDown)
72+ wantFocus(t, c, "M", 25)
73+ wantActive(t, m, 1)
74+ c.applyAction(ActionFocusWindowDown)
75+ wantFocus(t, c, "M", 25)
76+ wantActive(t, m, 1)
77+}
78+
79+// AC4: scroll and fixed switch workspace with up/down at the column
80+// bottom; left/right at the edge do nothing without a monitor.
81+func TestScrollFixedWorkspaceAxisIsVertical(t *testing.T) {
82+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
83+ t.Run(string(overflow), func(t *testing.T) {
84+ c, m := axisCore(t, false, overflow, 3)
85+ m.Current().FocusID(25)
86+ c.applyAction(ActionFocusColumnRight)
87+ wantActive(t, m, 1)
88+ wantFocus(t, c, "M", 25)
89+ c.applyAction(ActionFocusColumnLeft)
90+ c.applyAction(ActionFocusColumnLeft)
91+ c.applyAction(ActionFocusColumnLeft)
92+ c.applyAction(ActionFocusColumnLeft)
93+ c.applyAction(ActionFocusColumnLeft)
94+ wantActive(t, m, 1)
95+ c.applyAction(ActionFocusWindowDown)
96+ wantActive(t, m, 2)
97+ m.Focus(1)
98+ m.Current().FocusID(21) // top of the first column
99+ c.applyAction(ActionFocusWindowUp)
100+ wantActive(t, m, 0)
101+ })
102+ }
103+}
104+
105+// AC5: a shown named workspace is outside the workspace list.
106+func TestCascadeNamedWorkspaceDoesNotStep(t *testing.T) {
107+ c, m := axisCore(t, false, OverflowCascade, 3)
108+ m.SetNamed([]NamedWorkspace{{Name: "n"}})
109+ m.ToggleNamed("n")
110+ shown := m.shown
111+ if shown == nil {
112+ t.Fatal("named workspace not shown")
113+ }
114+ shown.Overflow = OverflowCascade
115+ shown.SetMaxColumns(3)
116+ for id := WindowID(41); id <= 45; id++ {
117+ m.AddWindow(id)
118+ }
119+ shown.FocusID(43)
120+ c.applyAction(ActionFocusColumnRight)
121+ if m.shown != shown || m.Active != 1 {
122+ t.Fatalf("shown %p want %p, active %d", m.shown, shown, m.Active)
123+ }
124+ wantFocus(t, c, "M", 43)
125+ shown.FocusID(41)
126+ c.applyAction(ActionFocusColumnLeft)
127+ if m.shown != shown || m.Active != 1 {
128+ t.Fatalf("shown %p want %p, active %d", m.shown, shown, m.Active)
129+ }
130+ wantFocus(t, c, "M", 41)
131+}
132+
133+// AC6: a covering fullscreen tile at the band edge steps the workspace and
134+// stays fullscreen.
135+func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
136+ c, m := axisCore(t, false, OverflowCascade, 3)
137+ w := m.Current()
138+ w.FocusID(23)
139+ w.ToggleFullscreen()
140+ c.applyAction(ActionFocusColumnRight)
141+ wantActive(t, m, 2)
142+ if w.fullscreen != 23 {
143+ t.Fatalf("fullscreen %d, want 23", w.fullscreen)
144+ }
145+}
146+
147+// AC7: a lone native float, or an empty cascade workspace, is at the edge on
148+// both sides.
149+func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {
150+ c, m := axisCore(t, false, OverflowCascade, 3)
151+ m.Focus(3) // the trailing empty workspace
152+ if !m.Current().empty() {
153+ t.Fatal("workspace 4 not empty")
154+ }
155+ c.applyAction(ActionFocusColumnLeft)
156+ wantActive(t, m, 2)
157+
158+ // A lone native float on its own workspace, past the trailing one.
159+ for _, tc := range []struct {
160+ a Action
161+ want int
162+ }{{ActionFocusColumnLeft, 2}, {ActionFocusColumnRight, 4}} {
163+ c, m = axisCore(t, false, OverflowCascade, 3)
164+ m.Focus(3)
165+ m.AddFloating(9, 100, 100)
166+ if w := m.Current(); len(w.Columns) != 0 || !w.floatFocus {
167+ t.Fatalf("columns %d floatFocus %v, want a lone focused float", len(w.Columns), w.floatFocus)
168+ }
169+ c.applyAction(tc.a)
170+ wantActive(t, m, tc.want)
171+ }
172+}
@@ -0,0 +1,172 @@
1+package core
2+
3+import "testing"
4+
5+// 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
7+// one. Workspace 2 is active. With sides, monitors L and R flank M.
8+func axisCore(t *testing.T, sides bool, overflow Overflow, maxCols int) (*Core, *Monitor) {
9+ t.Helper()
10+ c, _ := cascadeMovesCore(t, 0, maxCols, sides)
11+ m := c.cur().mon
12+ m.SetOverflow(overflow)
13+ m.SetMaxColumns(maxCols)
14+ for ws := range 3 {
15+ m.Focus(ws)
16+ for i := 1; i <= 5; i++ {
17+ m.AddWindow(WindowID((ws+1)*10 + i))
18+ }
19+ }
20+ m.Focus(1)
21+ return c, m
22+}
23+
24+func wantActive(t *testing.T, m *Monitor, active int) {
25+ t.Helper()
26+ if m.Active != active {
27+ t.Fatalf("active workspace %d, want %d", m.Active, active)
28+ }
29+}
30+
31+// AC1: past the band edge with no monitor, left/right step the workspace.
32+func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
33+ c, m := axisCore(t, false, OverflowCascade, 3)
34+ m.Current().FocusID(23) // last column of band 1, not the last band
35+ c.applyAction(ActionFocusColumnRight)
36+ wantActive(t, m, 2)
37+ m.Focus(1)
38+ m.Current().FocusID(24) // first column of band 2
39+ c.applyAction(ActionFocusColumnLeft)
40+ wantActive(t, m, 0)
41+ // The first workspace stays put at the start.
42+ m.Current().FocusID(11)
43+ c.applyAction(ActionFocusColumnLeft)
44+ wantActive(t, m, 0)
45+ // Inside the band, focus moves and the workspace stays.
46+ c.applyAction(ActionFocusColumnRight)
47+ wantActive(t, m, 0)
48+ if id, _ := m.Current().Focused(); id != 12 {
49+ t.Fatalf("focused %d, want 12", id)
50+ }
51+}
52+
53+// AC2: a monitor on that side wins over the workspace step.
54+func TestCascadeFocusColumnPrefersNeighborMonitor(t *testing.T) {
55+ c, m := axisCore(t, true, OverflowCascade, 3)
56+ m.Current().FocusID(23)
57+ c.applyAction(ActionFocusColumnRight)
58+ wantFocus(t, c, "R", 0)
59+ wantActive(t, m, 1)
60+ c.focusScreen = 1
61+ m.Current().FocusID(24)
62+ c.applyAction(ActionFocusColumnLeft)
63+ wantFocus(t, c, "L", 0)
64+ wantActive(t, m, 1)
65+}
66+
67+// AC3: up/down cross bands and stop at the last, with no workspace change.
68+func TestCascadeFocusWindowDownStaysOnWorkspace(t *testing.T) {
69+ c, m := axisCore(t, false, OverflowCascade, 3)
70+ m.Current().FocusID(22)
71+ c.applyAction(ActionFocusWindowDown)
72+ wantFocus(t, c, "M", 25)
73+ wantActive(t, m, 1)
74+ c.applyAction(ActionFocusWindowDown)
75+ wantFocus(t, c, "M", 25)
76+ wantActive(t, m, 1)
77+}
78+
79+// AC4: scroll and fixed switch workspace with up/down at the column
80+// bottom; left/right at the edge do nothing without a monitor.
81+func TestScrollFixedWorkspaceAxisIsVertical(t *testing.T) {
82+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
83+ t.Run(string(overflow), func(t *testing.T) {
84+ c, m := axisCore(t, false, overflow, 3)
85+ m.Current().FocusID(25)
86+ c.applyAction(ActionFocusColumnRight)
87+ wantActive(t, m, 1)
88+ wantFocus(t, c, "M", 25)
89+ c.applyAction(ActionFocusColumnLeft)
90+ c.applyAction(ActionFocusColumnLeft)
91+ c.applyAction(ActionFocusColumnLeft)
92+ c.applyAction(ActionFocusColumnLeft)
93+ c.applyAction(ActionFocusColumnLeft)
94+ wantActive(t, m, 1)
95+ c.applyAction(ActionFocusWindowDown)
96+ wantActive(t, m, 2)
97+ m.Focus(1)
98+ m.Current().FocusID(21) // top of the first column
99+ c.applyAction(ActionFocusWindowUp)
100+ wantActive(t, m, 0)
101+ })
102+ }
103+}
104+
105+// AC5: a shown named workspace is outside the workspace list.
106+func TestCascadeNamedWorkspaceDoesNotStep(t *testing.T) {
107+ c, m := axisCore(t, false, OverflowCascade, 3)
108+ m.SetNamed([]NamedWorkspace{{Name: "n"}})
109+ m.ToggleNamed("n")
110+ shown := m.shown
111+ if shown == nil {
112+ t.Fatal("named workspace not shown")
113+ }
114+ shown.Overflow = OverflowCascade
115+ shown.SetMaxColumns(3)
116+ for id := WindowID(41); id <= 45; id++ {
117+ m.AddWindow(id)
118+ }
119+ shown.FocusID(43)
120+ c.applyAction(ActionFocusColumnRight)
121+ if m.shown != shown || m.Active != 1 {
122+ t.Fatalf("shown %p want %p, active %d", m.shown, shown, m.Active)
123+ }
124+ wantFocus(t, c, "M", 43)
125+ shown.FocusID(41)
126+ c.applyAction(ActionFocusColumnLeft)
127+ if m.shown != shown || m.Active != 1 {
128+ t.Fatalf("shown %p want %p, active %d", m.shown, shown, m.Active)
129+ }
130+ wantFocus(t, c, "M", 41)
131+}
132+
133+// AC6: a covering fullscreen tile at the band edge steps the workspace and
134+// stays fullscreen.
135+func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
136+ c, m := axisCore(t, false, OverflowCascade, 3)
137+ w := m.Current()
138+ w.FocusID(23)
139+ w.ToggleFullscreen()
140+ c.applyAction(ActionFocusColumnRight)
141+ wantActive(t, m, 2)
142+ if w.fullscreen != 23 {
143+ t.Fatalf("fullscreen %d, want 23", w.fullscreen)
144+ }
145+}
146+
147+// AC7: a lone native float, or an empty cascade workspace, is at the edge on
148+// both sides.
149+func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {
150+ c, m := axisCore(t, false, OverflowCascade, 3)
151+ m.Focus(3) // the trailing empty workspace
152+ if !m.Current().empty() {
153+ t.Fatal("workspace 4 not empty")
154+ }
155+ c.applyAction(ActionFocusColumnLeft)
156+ wantActive(t, m, 2)
157+
158+ // A lone native float on its own workspace, past the trailing one.
159+ for _, tc := range []struct {
160+ a Action
161+ want int
162+ }{{ActionFocusColumnLeft, 2}, {ActionFocusColumnRight, 4}} {
163+ c, m = axisCore(t, false, OverflowCascade, 3)
164+ m.Focus(3)
165+ m.AddFloating(9, 100, 100)
166+ if w := m.Current(); len(w.Columns) != 0 || !w.floatFocus {
167+ t.Fatalf("columns %d floatFocus %v, want a lone focused float", len(w.Columns), w.floatFocus)
168+ }
169+ c.applyAction(tc.a)
170+ wantActive(t, m, tc.want)
171+ }
172+}

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