fix(core): keep a cascade fullscreen tile on its band edge
focus-column-left/right from a fullscreen tile at the edge of its cascade band goes to the neighbor monitor and keeps fullscreen, as from any tile there, instead of crossing bands. Cover maximization, floats and outer bands in the band-move tests.
- commit
- d568f15f67f3952e46f8288cb79f5564e6a1c27c
- author
- brice · · 2026-10-10 14:15 UTC
- parent
- 79cb2cc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +56 -7
M internal/core/action.go +6 -0
| @@ -222,6 +222,12 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 222 | 222 | // with none (the workspace or stash edge), the move goes to the | |
| 223 | 223 | // neighbor monitor and fullscreen stays. | |
| 224 | 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 | 231 | if !w.FocusColumn(dir) { | |
| 226 | 232 | if i := c.neighbor(d); i >= 0 { | |
| 227 | 233 | c.focusScreen = i | |
| @@ -222,6 +222,12 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 222 | // with none (the workspace or stash edge), the move goes to the | 222 | // 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 = i | 233 | c.focusScreen = i | ||
M internal/core/cascade.go +5 -6
| @@ -110,18 +110,17 @@ func (w *Workspace) leavesBand(i int) bool { | |||
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | 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 { | |
| 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 | 116 | i := w.laneNeighbor(dir) | |
| 117 | - | if !w.policy().wraps || i < 0 { | |
| 118 | - | return false | |
| 117 | + | if i < 0 { | |
| 118 | + | return | |
| 119 | 119 | } | |
| 120 | 120 | w.unmaximize() | |
| 121 | 121 | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] | |
| 122 | 122 | w.Focus = i | |
| 123 | 123 | w.scroll() | |
| 124 | - | return true | |
| 125 | 124 | } | |
| 126 | 125 | ||
| 127 | 126 | // laneNeighbor is the closest column to the focus in the lane d lanes away, | |
| @@ -110,18 +110,17 @@ func (w *Workspace) leavesBand(i int) bool { | |||||
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | // moveBand swaps the focused cascade column with the column in the band dir | 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 | 113 | + | // away where focus-window-up/down goes; focus follows it. Past the first |
| 114 | - | // whether the column moved. | 114 | + | // or last band nothing moves. |
| 115 | - | func (w *Workspace) moveBand(dir int) bool { | 115 | + | func (w *Workspace) moveBand(dir int) { |
| 116 | i := w.laneNeighbor(dir) | 116 | i := w.laneNeighbor(dir) | ||
| 117 | - | if !w.policy().wraps || i < 0 { | 117 | + | if i < 0 { |
| 118 | - | return false | 118 | + | return |
| 119 | } | 119 | } | ||
| 120 | w.unmaximize() | 120 | w.unmaximize() | ||
| 121 | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] | 121 | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] | ||
| 122 | w.Focus = i | 122 | w.Focus = i | ||
| 123 | w.scroll() | 123 | w.scroll() | ||
| 124 | - | return true | |||
| 125 | } | 124 | } | ||
| 126 | 125 | ||||
| 127 | // 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, | ||
M internal/core/cascade_moves_test.go +45 -0
| @@ -163,9 +163,54 @@ func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) { | |||
| 163 | 163 | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 164 | 164 | w.FocusID(1) | |
| 165 | 165 | c.applyAction(ActionMoveWindowUp) | |
| 166 | + | wantFocus(t, c, "M", 1) | |
| 166 | 167 | w.FocusID(4) | |
| 167 | 168 | c.applyAction(ActionMoveWindowDown) | |
| 168 | 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 w.fullscreen != 3 { | |
| 197 | + | t.Fatalf("fullscreen %d, want 3", w.fullscreen) | |
| 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 | + | } | |
| 169 | 214 | } | |
| 170 | 215 | ||
| 171 | 216 | // A stacked window moves inside its column first, then the column changes | |
| @@ -163,9 +163,54 @@ func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) { | |||||
| 163 | c, w := cascadeMovesCore(t, 5, 3, false) | 163 | c, w := cascadeMovesCore(t, 5, 3, false) | ||
| 164 | w.FocusID(1) | 164 | w.FocusID(1) | ||
| 165 | c.applyAction(ActionMoveWindowUp) | 165 | c.applyAction(ActionMoveWindowUp) | ||
| 166 | + | wantFocus(t, c, "M", 1) | |||
| 166 | w.FocusID(4) | 167 | w.FocusID(4) | ||
| 167 | c.applyAction(ActionMoveWindowDown) | 168 | c.applyAction(ActionMoveWindowDown) | ||
| 168 | wantColumns(t, w, 1, 2, 3, 4, 5) | 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 w.fullscreen != 3 { | |||
| 197 | + | t.Fatalf("fullscreen %d, want 3", w.fullscreen) | |||
| 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 | + | } | |||
| 169 | } | 214 | } | ||
| 170 | 215 | ||||
| 171 | // A stacked window moves inside its column first, then the column changes | 216 | // A stacked window moves inside its column first, then the column changes | ||
M internal/core/layout.go +0 -1
| @@ -925,7 +925,6 @@ func (w *Workspace) MoveWindow(dir int) { | |||
| 925 | 925 | c := &w.Columns[w.Focus] | |
| 926 | 926 | to := c.Focus + dir | |
| 927 | 927 | if to < 0 || to >= len(c.Windows) { | |
| 928 | - | // Past the column, a cascade column changes band. | |
| 929 | 928 | w.moveBand(dir) | |
| 930 | 929 | return | |
| 931 | 930 | } | |
| @@ -925,7 +925,6 @@ func (w *Workspace) MoveWindow(dir int) { | |||||
| 925 | c := &w.Columns[w.Focus] | 925 | c := &w.Columns[w.Focus] | ||
| 926 | to := c.Focus + dir | 926 | to := c.Focus + dir | ||
| 927 | 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) | 928 | w.moveBand(dir) | ||
| 930 | return | 929 | return | ||
| 931 | } | 930 | } | ||