detached 79cb2cc

feat(core): move across monitors at cascade band edges

Left/right focus and column moves stop at the edge of the cascade band and go to the neighbor monitor first; with none, they still cross bands in column order. move-window-up/down past the column moves a cascade column to the adjacent band, where focus-window-up/down goes.
commit
79cb2cc90c349703dca0cdbcfaf8521eb1d51b4c
author
brice · · 2026-10-10 14:10 UTC
parent
680a5e7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +243 -11
M docs/config.md +4 -4
@@ -228,7 +228,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
228228
229229 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 width equally, as in `fixed` overflow: 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.
230230
231-Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
231+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 width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
232232
233233 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.
234234
@@ -254,10 +254,10 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
254254 | `switch-column-prev` | The same, in the opposite direction |
255255 | `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 |
256256 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |
257-| `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
257+| `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 |
258258 | `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 |
259-| `move-column-left/right` | Move the focused column |
260-| `move-window-up/down` | Move the focused window up or down in its column; focus follows it |
259+| `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 |
260+| `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 |
261261 | `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow |
262262 | `set-window-height <+N%\|-N%>` | Give the focused window N% more or less of its column's height; the other rows give or take it in proportion. Each row keeps at least 10%. A window joining or leaving the column resets equal rows. Does nothing past 9 rows |
263263 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
@@ -228,7 +228,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
228 228
229 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 width equally, as in `fixed` overflow: 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 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 width equally, as in `fixed` overflow: 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.
230 230
231-Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.231+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 width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
232 232
233 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 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.
234 234
@@ -254,10 +254,10 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
254 | `switch-column-prev` | The same, in the opposite direction |254 | `switch-column-prev` | The same, in the opposite direction |
255 | `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 |255 | `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 |
256 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |256 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |
257-| `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |257+| `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 |
258 | `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 |258 | `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 |
259-| `move-column-left/right` | Move the focused column |259+| `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 |
260-| `move-window-up/down` | Move the focused window up or down in its column; focus follows it |260+| `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 |
261 | `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow |261 | `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow |
262 | `set-window-height <+N%\|-N%>` | Give the focused window N% more or less of its column's height; the other rows give or take it in proportion. Each row keeps at least 10%. A window joining or leaving the column resets equal rows. Does nothing past 9 rows |262 | `set-window-height <+N%\|-N%>` | Give the focused window N% more or less of its column's height; the other rows give or take it in proportion. Each row keeps at least 10%. A window joining or leaving the column resets equal rows. Does nothing past 9 rows |
263 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |263 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
M internal/core/action.go +7 -7
@@ -232,7 +232,7 @@ func (c *Core) applyAction(a Action) Effect {
232232 // The first move from a float stays here unless nothing is under it:
233233 // a native float leaves for the stash or the columns, and the stash
234234 // keeps the focus at its ends.
235- edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
235+ edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
236236 if i := c.neighbor(d); edge && i >= 0 {
237237 c.focusScreen = i
238238 return Effect{}
@@ -263,15 +263,15 @@ func (c *Core) applyAction(a Action) Effect {
263263 c.cur().mon.normalize()
264264 return Effect{}
265265 case ActionMoveColumnLeft, ActionMoveColumnRight:
266- // Past the edge the column moves to the neighbor screen, on the
267- // side facing this one, and focus follows it.
266+ // Past the edge of the workspace or cascade band the column moves to
267+ // the neighbor screen, on the side facing this one, and focus
268+ // follows it. With no screen there, a cascade column crosses bands.
268269 w := c.cur().mon.Current()
269- d := dirRight
270+ dir, d := 1, dirRight
270271 if a == ActionMoveColumnLeft {
271- d = dirLeft
272+ dir, d = -1, dirLeft
272273 }
273- edge := (d == dirLeft && w.Focus == 0) || (d == dirRight && w.Focus == len(w.Columns)-1)
274- if i := c.neighbor(d); edge && i >= 0 {
274+ if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
275275 col, ok := w.takeColumn()
276276 if !ok {
277277 return Effect{}
@@ -232,7 +232,7 @@ func (c *Core) applyAction(a Action) Effect {
232 // The first move from a float stays here unless nothing is under it:232 // The first move from a float stays here unless nothing is under it:
233 // a native float leaves for the stash or the columns, and the stash233 // a native float leaves for the stash or the columns, and the stash
234 // keeps the focus at its ends.234 // keeps the focus at its ends.
235- edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())235+ edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
236 if i := c.neighbor(d); edge && i >= 0 {236 if i := c.neighbor(d); edge && i >= 0 {
237 c.focusScreen = i237 c.focusScreen = i
238 return Effect{}238 return Effect{}
@@ -263,15 +263,15 @@ func (c *Core) applyAction(a Action) Effect {
263 c.cur().mon.normalize()263 c.cur().mon.normalize()
264 return Effect{}264 return Effect{}
265 case ActionMoveColumnLeft, ActionMoveColumnRight:265 case ActionMoveColumnLeft, ActionMoveColumnRight:
266- // Past the edge the column moves to the neighbor screen, on the266+ // Past the edge of the workspace or cascade band the column moves to
267- // side facing this one, and focus follows it.267+ // the neighbor screen, on the side facing this one, and focus
268+ // follows it. With no screen there, a cascade column crosses bands.
268 w := c.cur().mon.Current()269 w := c.cur().mon.Current()
269- d := dirRight270+ dir, d := 1, dirRight
270 if a == ActionMoveColumnLeft {271 if a == ActionMoveColumnLeft {
271- d = dirLeft272+ dir, d = -1, dirLeft
272 }273 }
273- edge := (d == dirLeft && w.Focus == 0) || (d == dirRight && w.Focus == len(w.Columns)-1)274+ if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
274- if i := c.neighbor(d); edge && i >= 0 {
275 col, ok := w.takeColumn()275 col, ok := w.takeColumn()
276 if !ok {276 if !ok {
277 return Effect{}277 return Effect{}
M internal/core/cascade.go +22 -0
@@ -102,6 +102,28 @@ func (w *Workspace) laneBounds(lane int) (first, last int) {
102102 return first, min(first+k, len(w.Columns)) - 1
103103 }
104104
105+// leavesBand reports whether column i, where a left/right move from the
106+// focused column goes, is past the workspace or in another cascade band:
107+// the move then goes to the neighbor monitor first.
108+func (w *Workspace) leavesBand(i int) bool {
109+ return i < 0 || i >= len(w.Columns) || !w.sameBand(i, w.Focus)
110+}
111+
112+// moveBand swaps the focused cascade column with the column in the band dir
113+// away where focus-window-up/down goes; focus follows it. It reports
114+// whether the column moved.
115+func (w *Workspace) moveBand(dir int) bool {
116+ i := w.laneNeighbor(dir)
117+ if !w.policy().wraps || i < 0 {
118+ return false
119+ }
120+ w.unmaximize()
121+ w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus]
122+ w.Focus = i
123+ w.scroll()
124+ return true
125+}
126+
105127 // laneNeighbor is the closest column to the focus in the lane d lanes away,
106128 // the focus's offset in its lane kept; -1 past the first or last lane.
107129 func (w *Workspace) laneNeighbor(d int) int {
@@ -102,6 +102,28 @@ func (w *Workspace) laneBounds(lane int) (first, last int) {
102 return first, min(first+k, len(w.Columns)) - 1102 return first, min(first+k, len(w.Columns)) - 1
103 }103 }
104 104
105+// leavesBand reports whether column i, where a left/right move from the
106+// focused column goes, is past the workspace or in another cascade band:
107+// the move then goes to the neighbor monitor first.
108+func (w *Workspace) leavesBand(i int) bool {
109+ return i < 0 || i >= len(w.Columns) || !w.sameBand(i, w.Focus)
110+}
111+
112+// moveBand swaps the focused cascade column with the column in the band dir
113+// away where focus-window-up/down goes; focus follows it. It reports
114+// whether the column moved.
115+func (w *Workspace) moveBand(dir int) bool {
116+ i := w.laneNeighbor(dir)
117+ if !w.policy().wraps || i < 0 {
118+ return false
119+ }
120+ w.unmaximize()
121+ w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus]
122+ w.Focus = i
123+ w.scroll()
124+ return true
125+}
126+
105 // laneNeighbor is the closest column to the focus in the lane d lanes away,127 // laneNeighbor is the closest column to the focus in the lane d lanes away,
106 // the focus's offset in its lane kept; -1 past the first or last lane.128 // the focus's offset in its lane kept; -1 past the first or last lane.
107 func (w *Workspace) laneNeighbor(d int) int {129 func (w *Workspace) laneNeighbor(d int) int {
A internal/core/cascade_moves_test.go +207 -0
@@ -0,0 +1,207 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// cascadeMovesCore is a core whose focused screen holds a cascade of
9+// windows 1..n in bands of maxCols. With sides, screens L and R sit on
10+// either side of it; otherwise it is alone.
11+func cascadeMovesCore(t *testing.T, n, maxCols int, sides bool) (*Core, *Workspace) {
12+ t.Helper()
13+ tiles := []tile{{"M", 900, 0, 900, 600}}
14+ focus := 0
15+ if sides {
16+ tiles = []tile{{"L", 0, 0, 900, 600}, tiles[0], {"R", 1800, 0, 900, 600}}
17+ focus = 1
18+ }
19+ c := layoutCore(focus, tiles...)
20+ w := c.cur().mon.Current()
21+ w.Overflow = OverflowCascade
22+ w.SetMaxColumns(maxCols)
23+ for id := WindowID(1); id <= WindowID(n); id++ {
24+ w.AddWindow(id)
25+ }
26+ return c, w
27+}
28+
29+// columnOrder is the first window of each column, in column order.
30+func columnOrder(w *Workspace) []WindowID {
31+ var ids []WindowID
32+ for _, c := range w.Columns {
33+ ids = append(ids, c.Windows[0])
34+ }
35+ return ids
36+}
37+
38+func screenWorkspace(c *Core, name string) *Workspace {
39+ for _, s := range c.screens {
40+ if s.name() == name {
41+ return s.mon.Current()
42+ }
43+ }
44+ return nil
45+}
46+
47+func wantColumns(t *testing.T, w *Workspace, want ...WindowID) {
48+ t.Helper()
49+ if got := columnOrder(w); !slices.Equal(got, want) {
50+ t.Fatalf("columns %v, want %v", got, want)
51+ }
52+}
53+
54+func wantFocus(t *testing.T, c *Core, screen string, id WindowID) {
55+ t.Helper()
56+ if got := c.cur().name(); got != screen {
57+ t.Fatalf("focused screen %q, want %q", got, screen)
58+ }
59+ if got, _ := c.cur().mon.Current().Focused(); got != id {
60+ t.Fatalf("focused window %d, want %d", got, id)
61+ }
62+}
63+
64+// At the edge of its band, a cascade column goes to the neighbor monitor
65+// on that side, and focus with it; bands {1 2 3} {4 5}.
66+func TestCascadeBandEdgeMovesToNeighborMonitor(t *testing.T) {
67+ for _, tc := range []struct {
68+ name string
69+ from WindowID
70+ action Action
71+ screen string
72+ here, moved []WindowID
73+ focusedAfter WindowID
74+ }{
75+ {"move left from band start", 4, ActionMoveColumnLeft, "L", []WindowID{1, 2, 3, 5}, []WindowID{4}, 4},
76+ {"move right from band end", 3, ActionMoveColumnRight, "R", []WindowID{1, 2, 4, 5}, []WindowID{3}, 3},
77+ {"move right from last column", 5, ActionMoveColumnRight, "R", []WindowID{1, 2, 3, 4}, []WindowID{5}, 5},
78+ {"focus left from band start", 4, ActionFocusColumnLeft, "L", []WindowID{1, 2, 3, 4, 5}, nil, 0},
79+ {"focus right from band end", 3, ActionFocusColumnRight, "R", []WindowID{1, 2, 3, 4, 5}, nil, 0},
80+ } {
81+ t.Run(tc.name, func(t *testing.T) {
82+ c, w := cascadeMovesCore(t, 5, 3, true)
83+ w.FocusID(tc.from)
84+ c.applyAction(tc.action)
85+ wantColumns(t, w, tc.here...)
86+ wantColumns(t, screenWorkspace(c, tc.screen), tc.moved...)
87+ wantFocus(t, c, tc.screen, tc.focusedAfter)
88+ })
89+ }
90+}
91+
92+// Inside a band, left/right stay on the screen.
93+func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
94+ c, w := cascadeMovesCore(t, 5, 3, true)
95+ w.FocusID(5)
96+ c.applyAction(ActionMoveColumnLeft)
97+ wantColumns(t, w, 1, 2, 3, 5, 4)
98+ wantFocus(t, c, "M", 5)
99+ c.applyAction(ActionFocusColumnRight)
100+ wantFocus(t, c, "M", 4)
101+}
102+
103+// With no monitor on that side, left/right cross into the neighbor band in
104+// column order.
105+func TestCascadeBandEdgeWithoutMonitorCrossesBands(t *testing.T) {
106+ c, w := cascadeMovesCore(t, 5, 3, false)
107+ w.FocusID(4)
108+ c.applyAction(ActionFocusColumnLeft)
109+ wantFocus(t, c, "M", 3)
110+ c.applyAction(ActionMoveColumnRight)
111+ wantColumns(t, w, 1, 2, 4, 3, 5)
112+ wantFocus(t, c, "M", 3)
113+}
114+
115+// With one column per band, every column is at both edges of its band.
116+func TestCascadeOneColumnBandsLeaveForMonitor(t *testing.T) {
117+ c, w := cascadeMovesCore(t, 2, 1, true)
118+ w.FocusID(2)
119+ c.applyAction(ActionMoveColumnLeft)
120+ wantColumns(t, w, 1)
121+ wantColumns(t, screenWorkspace(c, "L"), 2)
122+ wantFocus(t, c, "L", 2)
123+}
124+
125+// Fixed overflow keeps its column order edge: a non-edge column swaps.
126+func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) {
127+ c, w := cascadeMovesCore(t, 2, 3, true)
128+ w.Overflow = OverflowFixed
129+ w.FocusID(2)
130+ c.applyAction(ActionMoveColumnLeft)
131+ wantColumns(t, w, 2, 1)
132+ wantFocus(t, c, "M", 2)
133+}
134+
135+// At the top or bottom of its column, move-window-up/down moves a cascade
136+// column to the adjacent band, where focus-window-up/down goes; bands
137+// {1 2 3} {4 5}.
138+func TestCascadeMoveWindowCrossesBands(t *testing.T) {
139+ c, w := cascadeMovesCore(t, 5, 3, false)
140+ w.FocusID(5)
141+ c.applyAction(ActionMoveWindowUp)
142+ wantColumns(t, w, 1, 5, 3, 4, 2)
143+ wantFocus(t, c, "M", 5)
144+ c.applyAction(ActionMoveWindowDown)
145+ wantColumns(t, w, 1, 2, 3, 4, 5)
146+ wantFocus(t, c, "M", 5)
147+}
148+
149+// The last band is partial: the move clamps to its last column, and going
150+// back up keeps the new offset, as focus does.
151+func TestCascadeMoveWindowPartialBand(t *testing.T) {
152+ c, w := cascadeMovesCore(t, 5, 3, false)
153+ w.FocusID(3)
154+ c.applyAction(ActionMoveWindowDown)
155+ wantColumns(t, w, 1, 2, 5, 4, 3)
156+ c.applyAction(ActionMoveWindowUp)
157+ wantColumns(t, w, 1, 3, 5, 4, 2)
158+ wantFocus(t, c, "M", 3)
159+}
160+
161+// No band past the first or last: nothing moves.
162+func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) {
163+ c, w := cascadeMovesCore(t, 5, 3, false)
164+ w.FocusID(1)
165+ c.applyAction(ActionMoveWindowUp)
166+ w.FocusID(4)
167+ c.applyAction(ActionMoveWindowDown)
168+ wantColumns(t, w, 1, 2, 3, 4, 5)
169+}
170+
171+// A stacked window moves inside its column first, then the column changes
172+// band.
173+func TestCascadeMoveWindowStackedFirst(t *testing.T) {
174+ c, w := cascadeMovesCore(t, 5, 3, false)
175+ w.FocusID(5)
176+ w.ConsumeOrExpel(-1) // bands {1 2 3} {4/5}
177+ if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{4, 5}) {
178+ t.Fatalf("stack %v, want [4 5]", got)
179+ }
180+ c.applyAction(ActionMoveWindowUp)
181+ if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{5, 4}) {
182+ t.Fatalf("stack %v, want [5 4]", got)
183+ }
184+ c.applyAction(ActionMoveWindowUp)
185+ wantColumns(t, w, 5, 2, 3, 1)
186+ wantFocus(t, c, "M", 5)
187+}
188+
189+// A fullscreen window keeps its band.
190+func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
191+ c, w := cascadeMovesCore(t, 5, 3, false)
192+ w.FocusID(4)
193+ w.ToggleFullscreen()
194+ c.applyAction(ActionMoveWindowUp)
195+ wantColumns(t, w, 1, 2, 3, 4, 5)
196+}
197+
198+// Scroll overflow has no bands: up/down stop at the column edge.
199+func TestScrollMoveWindowStopsAtColumnEdge(t *testing.T) {
200+ c, w := cascadeMovesCore(t, 2, 3, false)
201+ w.Overflow = OverflowScroll
202+ w.FocusID(2)
203+ c.applyAction(ActionMoveWindowUp)
204+ c.applyAction(ActionMoveWindowDown)
205+ wantColumns(t, w, 1, 2)
206+ wantFocus(t, c, "M", 2)
207+}
@@ -0,0 +1,207 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// cascadeMovesCore is a core whose focused screen holds a cascade of
9+// windows 1..n in bands of maxCols. With sides, screens L and R sit on
10+// either side of it; otherwise it is alone.
11+func cascadeMovesCore(t *testing.T, n, maxCols int, sides bool) (*Core, *Workspace) {
12+ t.Helper()
13+ tiles := []tile{{"M", 900, 0, 900, 600}}
14+ focus := 0
15+ if sides {
16+ tiles = []tile{{"L", 0, 0, 900, 600}, tiles[0], {"R", 1800, 0, 900, 600}}
17+ focus = 1
18+ }
19+ c := layoutCore(focus, tiles...)
20+ w := c.cur().mon.Current()
21+ w.Overflow = OverflowCascade
22+ w.SetMaxColumns(maxCols)
23+ for id := WindowID(1); id <= WindowID(n); id++ {
24+ w.AddWindow(id)
25+ }
26+ return c, w
27+}
28+
29+// columnOrder is the first window of each column, in column order.
30+func columnOrder(w *Workspace) []WindowID {
31+ var ids []WindowID
32+ for _, c := range w.Columns {
33+ ids = append(ids, c.Windows[0])
34+ }
35+ return ids
36+}
37+
38+func screenWorkspace(c *Core, name string) *Workspace {
39+ for _, s := range c.screens {
40+ if s.name() == name {
41+ return s.mon.Current()
42+ }
43+ }
44+ return nil
45+}
46+
47+func wantColumns(t *testing.T, w *Workspace, want ...WindowID) {
48+ t.Helper()
49+ if got := columnOrder(w); !slices.Equal(got, want) {
50+ t.Fatalf("columns %v, want %v", got, want)
51+ }
52+}
53+
54+func wantFocus(t *testing.T, c *Core, screen string, id WindowID) {
55+ t.Helper()
56+ if got := c.cur().name(); got != screen {
57+ t.Fatalf("focused screen %q, want %q", got, screen)
58+ }
59+ if got, _ := c.cur().mon.Current().Focused(); got != id {
60+ t.Fatalf("focused window %d, want %d", got, id)
61+ }
62+}
63+
64+// At the edge of its band, a cascade column goes to the neighbor monitor
65+// on that side, and focus with it; bands {1 2 3} {4 5}.
66+func TestCascadeBandEdgeMovesToNeighborMonitor(t *testing.T) {
67+ for _, tc := range []struct {
68+ name string
69+ from WindowID
70+ action Action
71+ screen string
72+ here, moved []WindowID
73+ focusedAfter WindowID
74+ }{
75+ {"move left from band start", 4, ActionMoveColumnLeft, "L", []WindowID{1, 2, 3, 5}, []WindowID{4}, 4},
76+ {"move right from band end", 3, ActionMoveColumnRight, "R", []WindowID{1, 2, 4, 5}, []WindowID{3}, 3},
77+ {"move right from last column", 5, ActionMoveColumnRight, "R", []WindowID{1, 2, 3, 4}, []WindowID{5}, 5},
78+ {"focus left from band start", 4, ActionFocusColumnLeft, "L", []WindowID{1, 2, 3, 4, 5}, nil, 0},
79+ {"focus right from band end", 3, ActionFocusColumnRight, "R", []WindowID{1, 2, 3, 4, 5}, nil, 0},
80+ } {
81+ t.Run(tc.name, func(t *testing.T) {
82+ c, w := cascadeMovesCore(t, 5, 3, true)
83+ w.FocusID(tc.from)
84+ c.applyAction(tc.action)
85+ wantColumns(t, w, tc.here...)
86+ wantColumns(t, screenWorkspace(c, tc.screen), tc.moved...)
87+ wantFocus(t, c, tc.screen, tc.focusedAfter)
88+ })
89+ }
90+}
91+
92+// Inside a band, left/right stay on the screen.
93+func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
94+ c, w := cascadeMovesCore(t, 5, 3, true)
95+ w.FocusID(5)
96+ c.applyAction(ActionMoveColumnLeft)
97+ wantColumns(t, w, 1, 2, 3, 5, 4)
98+ wantFocus(t, c, "M", 5)
99+ c.applyAction(ActionFocusColumnRight)
100+ wantFocus(t, c, "M", 4)
101+}
102+
103+// With no monitor on that side, left/right cross into the neighbor band in
104+// column order.
105+func TestCascadeBandEdgeWithoutMonitorCrossesBands(t *testing.T) {
106+ c, w := cascadeMovesCore(t, 5, 3, false)
107+ w.FocusID(4)
108+ c.applyAction(ActionFocusColumnLeft)
109+ wantFocus(t, c, "M", 3)
110+ c.applyAction(ActionMoveColumnRight)
111+ wantColumns(t, w, 1, 2, 4, 3, 5)
112+ wantFocus(t, c, "M", 3)
113+}
114+
115+// With one column per band, every column is at both edges of its band.
116+func TestCascadeOneColumnBandsLeaveForMonitor(t *testing.T) {
117+ c, w := cascadeMovesCore(t, 2, 1, true)
118+ w.FocusID(2)
119+ c.applyAction(ActionMoveColumnLeft)
120+ wantColumns(t, w, 1)
121+ wantColumns(t, screenWorkspace(c, "L"), 2)
122+ wantFocus(t, c, "L", 2)
123+}
124+
125+// Fixed overflow keeps its column order edge: a non-edge column swaps.
126+func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) {
127+ c, w := cascadeMovesCore(t, 2, 3, true)
128+ w.Overflow = OverflowFixed
129+ w.FocusID(2)
130+ c.applyAction(ActionMoveColumnLeft)
131+ wantColumns(t, w, 2, 1)
132+ wantFocus(t, c, "M", 2)
133+}
134+
135+// At the top or bottom of its column, move-window-up/down moves a cascade
136+// column to the adjacent band, where focus-window-up/down goes; bands
137+// {1 2 3} {4 5}.
138+func TestCascadeMoveWindowCrossesBands(t *testing.T) {
139+ c, w := cascadeMovesCore(t, 5, 3, false)
140+ w.FocusID(5)
141+ c.applyAction(ActionMoveWindowUp)
142+ wantColumns(t, w, 1, 5, 3, 4, 2)
143+ wantFocus(t, c, "M", 5)
144+ c.applyAction(ActionMoveWindowDown)
145+ wantColumns(t, w, 1, 2, 3, 4, 5)
146+ wantFocus(t, c, "M", 5)
147+}
148+
149+// The last band is partial: the move clamps to its last column, and going
150+// back up keeps the new offset, as focus does.
151+func TestCascadeMoveWindowPartialBand(t *testing.T) {
152+ c, w := cascadeMovesCore(t, 5, 3, false)
153+ w.FocusID(3)
154+ c.applyAction(ActionMoveWindowDown)
155+ wantColumns(t, w, 1, 2, 5, 4, 3)
156+ c.applyAction(ActionMoveWindowUp)
157+ wantColumns(t, w, 1, 3, 5, 4, 2)
158+ wantFocus(t, c, "M", 3)
159+}
160+
161+// No band past the first or last: nothing moves.
162+func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) {
163+ c, w := cascadeMovesCore(t, 5, 3, false)
164+ w.FocusID(1)
165+ c.applyAction(ActionMoveWindowUp)
166+ w.FocusID(4)
167+ c.applyAction(ActionMoveWindowDown)
168+ wantColumns(t, w, 1, 2, 3, 4, 5)
169+}
170+
171+// A stacked window moves inside its column first, then the column changes
172+// band.
173+func TestCascadeMoveWindowStackedFirst(t *testing.T) {
174+ c, w := cascadeMovesCore(t, 5, 3, false)
175+ w.FocusID(5)
176+ w.ConsumeOrExpel(-1) // bands {1 2 3} {4/5}
177+ if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{4, 5}) {
178+ t.Fatalf("stack %v, want [4 5]", got)
179+ }
180+ c.applyAction(ActionMoveWindowUp)
181+ if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{5, 4}) {
182+ t.Fatalf("stack %v, want [5 4]", got)
183+ }
184+ c.applyAction(ActionMoveWindowUp)
185+ wantColumns(t, w, 5, 2, 3, 1)
186+ wantFocus(t, c, "M", 5)
187+}
188+
189+// A fullscreen window keeps its band.
190+func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
191+ c, w := cascadeMovesCore(t, 5, 3, false)
192+ w.FocusID(4)
193+ w.ToggleFullscreen()
194+ c.applyAction(ActionMoveWindowUp)
195+ wantColumns(t, w, 1, 2, 3, 4, 5)
196+}
197+
198+// Scroll overflow has no bands: up/down stop at the column edge.
199+func TestScrollMoveWindowStopsAtColumnEdge(t *testing.T) {
200+ c, w := cascadeMovesCore(t, 2, 3, false)
201+ w.Overflow = OverflowScroll
202+ w.FocusID(2)
203+ c.applyAction(ActionMoveWindowUp)
204+ c.applyAction(ActionMoveWindowDown)
205+ wantColumns(t, w, 1, 2)
206+ wantFocus(t, c, "M", 2)
207+}
M internal/core/layout.go +3 -0
@@ -917,6 +917,7 @@ func (w *Workspace) MoveColumn(dir int) {
917917
918918 // MoveWindow moves the focused tile up or down (dir -1/1) inside its
919919 // column; focus follows it. A slot window leaving row 0 releases the slot.
920+// Past the column, a cascade column moves to the adjacent band.
920921 func (w *Workspace) MoveWindow(dir int) {
921922 if w.onFloat() || len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.fullscreenHides() {
922923 return
@@ -924,6 +925,8 @@ func (w *Workspace) MoveWindow(dir int) {
924925 c := &w.Columns[w.Focus]
925926 to := c.Focus + dir
926927 if to < 0 || to >= len(c.Windows) {
928+ // Past the column, a cascade column changes band.
929+ w.moveBand(dir)
927930 return
928931 }
929932 if c.Focus == 0 || to == 0 {
@@ -917,6 +917,7 @@ func (w *Workspace) MoveColumn(dir int) {
917 917
918 // MoveWindow moves the focused tile up or down (dir -1/1) inside its918 // MoveWindow moves the focused tile up or down (dir -1/1) inside its
919 // column; focus follows it. A slot window leaving row 0 releases the slot.919 // column; focus follows it. A slot window leaving row 0 releases the slot.
920+// Past the column, a cascade column moves to the adjacent band.
920 func (w *Workspace) MoveWindow(dir int) {921 func (w *Workspace) MoveWindow(dir int) {
921 if w.onFloat() || len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.fullscreenHides() {922 if w.onFloat() || len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.fullscreenHides() {
922 return923 return
@@ -924,6 +925,8 @@ func (w *Workspace) MoveWindow(dir int) {
924 c := &w.Columns[w.Focus]925 c := &w.Columns[w.Focus]
925 to := c.Focus + dir926 to := c.Focus + dir
926 if to < 0 || to >= len(c.Windows) {927 if to < 0 || to >= len(c.Windows) {
928+ // Past the column, a cascade column changes band.
929+ w.moveBand(dir)
927 return930 return
928 }931 }
929 if c.Focus == 0 || to == 0 {932 if c.Focus == 0 || to == 0 {

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 23

Tags 10

↑↓ moveenter openesc close