detached 3305370

Merge pull request 'feat(core): move across monitors at cascade band edges' (#36) from feat/cascade-directional-moves into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/36
commit
3305370e8b990cf53fea0327d2e7c397ec7335bd
author
bnema · · 2026-10-10 15:35 UTC
merge of
680a5e7 + 6daa024 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
5 files · +292 -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 +13 -7
@@ -222,6 +222,12 @@ func (c *Core) applyAction(a Action) Effect {
222222 // with none (the workspace or stash edge), the move goes to the
223223 // neighbor monitor and fullscreen stays.
224224 if w.pinned() && !w.stashOverCover() {
225+ // A cascade fullscreen tile at its band edge leaves for the
226+ // neighbor monitor, like any tile there, and stays fullscreen.
227+ if i := c.neighbor(d); i >= 0 && !w.onFloat() && w.policy().wraps && w.leavesBand(w.Focus+dir) {
228+ c.focusScreen = i
229+ return Effect{}
230+ }
225231 if !w.FocusColumn(dir) {
226232 if i := c.neighbor(d); i >= 0 {
227233 c.focusScreen = i
@@ -232,7 +238,7 @@ func (c *Core) applyAction(a Action) Effect {
232238 // The first move from a float stays here unless nothing is under it:
233239 // a native float leaves for the stash or the columns, and the stash
234240 // keeps the focus at its ends.
235- edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
241+ edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
236242 if i := c.neighbor(d); edge && i >= 0 {
237243 c.focusScreen = i
238244 return Effect{}
@@ -263,15 +269,15 @@ func (c *Core) applyAction(a Action) Effect {
263269 c.cur().mon.normalize()
264270 return Effect{}
265271 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.
272+ // Past the edge of the workspace or cascade band the column moves to
273+ // the neighbor screen, on the side facing this one, and focus
274+ // follows it. With no screen there, a cascade column crosses bands.
268275 w := c.cur().mon.Current()
269- d := dirRight
276+ dir, d := 1, dirRight
270277 if a == ActionMoveColumnLeft {
271- d = dirLeft
278+ dir, d = -1, dirLeft
272279 }
273- edge := (d == dirLeft && w.Focus == 0) || (d == dirRight && w.Focus == len(w.Columns)-1)
274- if i := c.neighbor(d); edge && i >= 0 {
280+ if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
275281 col, ok := w.takeColumn()
276282 if !ok {
277283 return Effect{}
@@ -222,6 +222,12 @@ func (c *Core) applyAction(a Action) Effect {
222 // with none (the workspace or stash edge), the move goes to the222 // with none (the workspace or stash edge), the move goes to the
223 // neighbor monitor and fullscreen stays.223 // neighbor monitor and fullscreen stays.
224 if w.pinned() && !w.stashOverCover() {224 if w.pinned() && !w.stashOverCover() {
225+ // A cascade fullscreen tile at its band edge leaves for the
226+ // neighbor monitor, like any tile there, and stays fullscreen.
227+ if i := c.neighbor(d); i >= 0 && !w.onFloat() && w.policy().wraps && w.leavesBand(w.Focus+dir) {
228+ c.focusScreen = i
229+ return Effect{}
230+ }
225 if !w.FocusColumn(dir) {231 if !w.FocusColumn(dir) {
226 if i := c.neighbor(d); i >= 0 {232 if i := c.neighbor(d); i >= 0 {
227 c.focusScreen = i233 c.focusScreen = i
@@ -232,7 +238,7 @@ func (c *Core) applyAction(a Action) Effect {
232 // The first move from a float stays here unless nothing is under it:238 // 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 stash239 // a native float leaves for the stash or the columns, and the stash
234 // keeps the focus at its ends.240 // keeps the focus at its ends.
235- edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())241+ edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
236 if i := c.neighbor(d); edge && i >= 0 {242 if i := c.neighbor(d); edge && i >= 0 {
237 c.focusScreen = i243 c.focusScreen = i
238 return Effect{}244 return Effect{}
@@ -263,15 +269,15 @@ func (c *Core) applyAction(a Action) Effect {
263 c.cur().mon.normalize()269 c.cur().mon.normalize()
264 return Effect{}270 return Effect{}
265 case ActionMoveColumnLeft, ActionMoveColumnRight:271 case ActionMoveColumnLeft, ActionMoveColumnRight:
266- // Past the edge the column moves to the neighbor screen, on the272+ // Past the edge of the workspace or cascade band the column moves to
267- // side facing this one, and focus follows it.273+ // the neighbor screen, on the side facing this one, and focus
274+ // follows it. With no screen there, a cascade column crosses bands.
268 w := c.cur().mon.Current()275 w := c.cur().mon.Current()
269- d := dirRight276+ dir, d := 1, dirRight
270 if a == ActionMoveColumnLeft {277 if a == ActionMoveColumnLeft {
271- d = dirLeft278+ dir, d = -1, dirLeft
272 }279 }
273- edge := (d == dirLeft && w.Focus == 0) || (d == dirRight && w.Focus == len(w.Columns)-1)280+ 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()281 col, ok := w.takeColumn()
276 if !ok {282 if !ok {
277 return Effect{}283 return Effect{}
M internal/core/cascade.go +21 -0
@@ -102,6 +102,27 @@ 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. Past the first
114+// or last band nothing moves.
115+func (w *Workspace) moveBand(dir int) {
116+ i := w.laneNeighbor(dir)
117+ if i < 0 {
118+ return
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+}
125+
105126 // laneNeighbor is the closest column to the focus in the lane d lanes away,
106127 // the focus's offset in its lane kept; -1 past the first or last lane.
107128 func (w *Workspace) laneNeighbor(d int) int {
@@ -102,6 +102,27 @@ 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. Past the first
114+// or last band nothing moves.
115+func (w *Workspace) moveBand(dir int) {
116+ i := w.laneNeighbor(dir)
117+ if i < 0 {
118+ return
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+}
125+
105 // laneNeighbor is the closest column to the focus in the lane d lanes away,126 // 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.127 // the focus's offset in its lane kept; -1 past the first or last lane.
107 func (w *Workspace) laneNeighbor(d int) int {128 func (w *Workspace) laneNeighbor(d int) int {
A internal/core/cascade_moves_test.go +252 -0
@@ -0,0 +1,252 @@
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+ wantFocus(t, c, "M", 1)
167+ w.FocusID(4)
168+ c.applyAction(ActionMoveWindowDown)
169+ wantColumns(t, w, 1, 2, 3, 4, 5)
170+ wantFocus(t, c, "M", 4)
171+}
172+
173+// A maximized column leaving its band gives up maximization: the band it
174+// joins shows all its columns.
175+func TestCascadeMoveWindowEndsMaximization(t *testing.T) {
176+ c, w := cascadeMovesCore(t, 5, 3, false)
177+ w.FocusID(5)
178+ w.ToggleFullWidth()
179+ c.applyAction(ActionMoveWindowUp)
180+ wantColumns(t, w, 1, 5, 3, 4, 2)
181+ if w.Columns[1].FullWidth || len(w.maximized) != 0 {
182+ t.Fatalf("still maximized: full width %t, MRU %v", w.Columns[1].FullWidth, w.maximized)
183+ }
184+}
185+
186+// A fullscreen tile at its band edge leaves for the neighbor monitor and
187+// stays fullscreen, as a tile there does.
188+func TestCascadeFullscreenBandEdgeFocusesMonitor(t *testing.T) {
189+ c, w := cascadeMovesCore(t, 5, 3, true)
190+ w.FocusID(3)
191+ w.ToggleFullscreen()
192+ c.applyAction(ActionFocusColumnRight)
193+ if got := c.cur().name(); got != "R" {
194+ t.Fatalf("focused screen %q, want R", got)
195+ }
196+ if id, _ := w.Focused(); w.fullscreen != 3 || id != 3 {
197+ t.Fatalf("fullscreen %d, focused %d on M, want 3 and 3", w.fullscreen, id)
198+ }
199+}
200+
201+// From a native float over the columns, the first move leaves the float
202+// on this monitor, even with the focused column at its band edge.
203+func TestCascadeFloatAtBandEdgeStaysOnMonitor(t *testing.T) {
204+ c, w := cascadeMovesCore(t, 5, 3, true)
205+ w.FocusID(3)
206+ w.AddFloating(9, 100, 100)
207+ c.applyAction(ActionFocusColumnRight)
208+ if got := c.cur().name(); got != "M" {
209+ t.Fatalf("focused screen %q, want M", got)
210+ }
211+ if w.floatFocus {
212+ t.Fatal("focus still on the float")
213+ }
214+}
215+
216+// A stacked window moves inside its column first, then the column changes
217+// band.
218+func TestCascadeMoveWindowStackedFirst(t *testing.T) {
219+ c, w := cascadeMovesCore(t, 5, 3, false)
220+ w.FocusID(5)
221+ w.ConsumeOrExpel(-1) // bands {1 2 3} {4/5}
222+ if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{4, 5}) {
223+ t.Fatalf("stack %v, want [4 5]", got)
224+ }
225+ c.applyAction(ActionMoveWindowUp)
226+ if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{5, 4}) {
227+ t.Fatalf("stack %v, want [5 4]", got)
228+ }
229+ c.applyAction(ActionMoveWindowUp)
230+ wantColumns(t, w, 5, 2, 3, 1)
231+ wantFocus(t, c, "M", 5)
232+}
233+
234+// A fullscreen window keeps its band.
235+func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
236+ c, w := cascadeMovesCore(t, 5, 3, false)
237+ w.FocusID(4)
238+ w.ToggleFullscreen()
239+ c.applyAction(ActionMoveWindowUp)
240+ wantColumns(t, w, 1, 2, 3, 4, 5)
241+}
242+
243+// Scroll overflow has no bands: up/down stop at the column edge.
244+func TestScrollMoveWindowStopsAtColumnEdge(t *testing.T) {
245+ c, w := cascadeMovesCore(t, 2, 3, false)
246+ w.Overflow = OverflowScroll
247+ w.FocusID(2)
248+ c.applyAction(ActionMoveWindowUp)
249+ c.applyAction(ActionMoveWindowDown)
250+ wantColumns(t, w, 1, 2)
251+ wantFocus(t, c, "M", 2)
252+}
@@ -0,0 +1,252 @@
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+ wantFocus(t, c, "M", 1)
167+ w.FocusID(4)
168+ c.applyAction(ActionMoveWindowDown)
169+ wantColumns(t, w, 1, 2, 3, 4, 5)
170+ wantFocus(t, c, "M", 4)
171+}
172+
173+// A maximized column leaving its band gives up maximization: the band it
174+// joins shows all its columns.
175+func TestCascadeMoveWindowEndsMaximization(t *testing.T) {
176+ c, w := cascadeMovesCore(t, 5, 3, false)
177+ w.FocusID(5)
178+ w.ToggleFullWidth()
179+ c.applyAction(ActionMoveWindowUp)
180+ wantColumns(t, w, 1, 5, 3, 4, 2)
181+ if w.Columns[1].FullWidth || len(w.maximized) != 0 {
182+ t.Fatalf("still maximized: full width %t, MRU %v", w.Columns[1].FullWidth, w.maximized)
183+ }
184+}
185+
186+// A fullscreen tile at its band edge leaves for the neighbor monitor and
187+// stays fullscreen, as a tile there does.
188+func TestCascadeFullscreenBandEdgeFocusesMonitor(t *testing.T) {
189+ c, w := cascadeMovesCore(t, 5, 3, true)
190+ w.FocusID(3)
191+ w.ToggleFullscreen()
192+ c.applyAction(ActionFocusColumnRight)
193+ if got := c.cur().name(); got != "R" {
194+ t.Fatalf("focused screen %q, want R", got)
195+ }
196+ if id, _ := w.Focused(); w.fullscreen != 3 || id != 3 {
197+ t.Fatalf("fullscreen %d, focused %d on M, want 3 and 3", w.fullscreen, id)
198+ }
199+}
200+
201+// From a native float over the columns, the first move leaves the float
202+// on this monitor, even with the focused column at its band edge.
203+func TestCascadeFloatAtBandEdgeStaysOnMonitor(t *testing.T) {
204+ c, w := cascadeMovesCore(t, 5, 3, true)
205+ w.FocusID(3)
206+ w.AddFloating(9, 100, 100)
207+ c.applyAction(ActionFocusColumnRight)
208+ if got := c.cur().name(); got != "M" {
209+ t.Fatalf("focused screen %q, want M", got)
210+ }
211+ if w.floatFocus {
212+ t.Fatal("focus still on the float")
213+ }
214+}
215+
216+// A stacked window moves inside its column first, then the column changes
217+// band.
218+func TestCascadeMoveWindowStackedFirst(t *testing.T) {
219+ c, w := cascadeMovesCore(t, 5, 3, false)
220+ w.FocusID(5)
221+ w.ConsumeOrExpel(-1) // bands {1 2 3} {4/5}
222+ if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{4, 5}) {
223+ t.Fatalf("stack %v, want [4 5]", got)
224+ }
225+ c.applyAction(ActionMoveWindowUp)
226+ if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{5, 4}) {
227+ t.Fatalf("stack %v, want [5 4]", got)
228+ }
229+ c.applyAction(ActionMoveWindowUp)
230+ wantColumns(t, w, 5, 2, 3, 1)
231+ wantFocus(t, c, "M", 5)
232+}
233+
234+// A fullscreen window keeps its band.
235+func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
236+ c, w := cascadeMovesCore(t, 5, 3, false)
237+ w.FocusID(4)
238+ w.ToggleFullscreen()
239+ c.applyAction(ActionMoveWindowUp)
240+ wantColumns(t, w, 1, 2, 3, 4, 5)
241+}
242+
243+// Scroll overflow has no bands: up/down stop at the column edge.
244+func TestScrollMoveWindowStopsAtColumnEdge(t *testing.T) {
245+ c, w := cascadeMovesCore(t, 2, 3, false)
246+ w.Overflow = OverflowScroll
247+ w.FocusID(2)
248+ c.applyAction(ActionMoveWindowUp)
249+ c.applyAction(ActionMoveWindowDown)
250+ wantColumns(t, w, 1, 2)
251+ wantFocus(t, c, "M", 2)
252+}
M internal/core/layout.go +2 -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,7 @@ 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+ w.moveBand(dir)
927929 return
928930 }
929931 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,7 @@ 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+ w.moveBand(dir)
927 return929 return
928 }930 }
929 if c.Focus == 0 || to == 0 {931 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