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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 228 | 228 | ||
| 229 | 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 | 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 | 254 | | `switch-column-prev` | The same, in the opposite direction | | |
| 255 | 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 | 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 | 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 | | |
| 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 | | |
| 261 | 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 | 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 | 263 | | `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 { | |||
| 232 | 232 | // The first move from a float stays here unless nothing is under it: | |
| 233 | 233 | // a native float leaves for the stash or the columns, and the stash | |
| 234 | 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 | 236 | if i := c.neighbor(d); edge && i >= 0 { | |
| 237 | 237 | c.focusScreen = i | |
| 238 | 238 | return Effect{} | |
| @@ -263,15 +263,15 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 263 | 263 | c.cur().mon.normalize() | |
| 264 | 264 | return Effect{} | |
| 265 | 265 | 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. | |
| 268 | 269 | w := c.cur().mon.Current() | |
| 269 | - | d := dirRight | |
| 270 | + | dir, d := 1, dirRight | |
| 270 | 271 | if a == ActionMoveColumnLeft { | |
| 271 | - | d = dirLeft | |
| 272 | + | 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); edge && i >= 0 { | |
| 274 | + | if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 { | |
| 275 | 275 | col, ok := w.takeColumn() | |
| 276 | 276 | if !ok { | |
| 277 | 277 | 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 stash | 233 | // 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 = i | 237 | 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 the | 266 | + | // 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 := dirRight | 270 | + | dir, d := 1, dirRight |
| 270 | if a == ActionMoveColumnLeft { | 271 | if a == ActionMoveColumnLeft { | ||
| 271 | - | d = dirLeft | 272 | + | 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) { | |||
| 102 | 102 | 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 | 127 | // laneNeighbor is the closest column to the focus in the lane d lanes away, | |
| 106 | 128 | // the focus's offset in its lane kept; -1 past the first or last lane. | |
| 107 | 129 | 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)) - 1 | 102 | 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) { | |||
| 917 | 917 | ||
| 918 | 918 | // MoveWindow moves the focused tile up or down (dir -1/1) inside its | |
| 919 | 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 | 921 | func (w *Workspace) MoveWindow(dir int) { | |
| 921 | 922 | if w.onFloat() || len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.fullscreenHides() { | |
| 922 | 923 | return | |
| @@ -924,6 +925,8 @@ func (w *Workspace) MoveWindow(dir int) { | |||
| 924 | 925 | c := &w.Columns[w.Focus] | |
| 925 | 926 | to := c.Focus + dir | |
| 926 | 927 | if to < 0 || to >= len(c.Windows) { | |
| 928 | + | // Past the column, a cascade column changes band. | |
| 929 | + | w.moveBand(dir) | |
| 927 | 930 | return | |
| 928 | 931 | } | |
| 929 | 932 | 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 its | 918 | // 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 | return | 923 | 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 + dir | 926 | 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 | return | 930 | return | ||
| 928 | } | 931 | } | ||
| 929 | if c.Focus == 0 || to == 0 { | 932 | if c.Focus == 0 || to == 0 { | ||