1package core
2
3import (
4 "slices"
5 "testing"
6)
7
8// cycle-column-width in a full band pushes the column at the far end of
9// the band out: the rightmost down, or the leftmost up from the last
10// column; the end of the cycle brings it back.
11func TestCascadeCyclePushesNeighbor(t *testing.T) {
12 for _, tc := range []struct {
13 name string
14 n, k int
15 focus WindowID
16 pushed [][]WindowID
17 }{
18 {"middle pushes rightmost down", 3, 3, 2, [][]WindowID{{1, 2}, {3}}},
19 {"first pushes rightmost down", 3, 3, 1, [][]WindowID{{1, 2}, {3}}},
20 {"last pushes leftmost up", 3, 3, 3, [][]WindowID{{1}, {2, 3}}},
21 {"last of four pushes leftmost up", 4, 4, 4, [][]WindowID{{1}, {2, 3, 4}}},
22 {"second of four pushes rightmost down", 4, 4, 2, [][]WindowID{{1, 2, 3}, {4}}},
23 {"joins the band below", 5, 3, 2, [][]WindowID{{1, 2}, {3, 4, 5}}},
24 } {
25 t.Run(tc.name, func(t *testing.T) {
26 _, w := cascadeMovesCore(t, tc.n, tc.k, false)
27 w.FocusID(tc.focus)
28 before := bandIDs(w)
29 w.Apply(ActionCycleColumnWidth)
30 wantBands(t, w, tc.pushed...)
31 if id, _ := w.Focused(); id != tc.focus {
32 t.Fatalf("focus %d, want %d", id, tc.focus)
33 }
34 if got := w.Columns[w.Focus].Cells; got != 2 {
35 t.Fatalf("focused cells %d, want 2", got)
36 }
37 w.Apply(ActionCycleColumnWidth)
38 wantBands(t, w, before...)
39 for _, c := range w.Columns {
40 if c.Cells != 0 {
41 t.Fatalf("cells not back to equal: %+v", w.Columns)
42 }
43 }
44 })
45 }
46}
47
48// A pushed column that moved meanwhile stays where it is.
49func TestCascadeCycleRestoreSkipsMovedColumn(t *testing.T) {
50 c, w := cascadeMovesCore(t, 3, 3, false)
51 w.FocusID(2)
52 w.Apply(ActionCycleColumnWidth) // {1 2} {3}
53 w.FocusID(3)
54 c.applyAction(ActionMoveWindowUp) // {3 1 2}: 3 is back by hand.
55 w.FocusID(2)
56 w.Apply(ActionCycleColumnWidth)
57 wantBands(t, w, []WindowID{3, 1, 2})
58 if got := cellsOf(w); !slices.Equal(got, []int{0, 0, 0}) {
59 t.Fatalf("cells %v, want equal", got)
60 }
61}
62
63// maximize-column isolates the column: the left ones go up, the right ones
64// down, together; maximizing again forms the band back.
65func TestCascadeMaximizeSplitsAndRestoresBand(t *testing.T) {
66 for _, tc := range []struct {
67 name string
68 n, k int
69 focus WindowID
70 split [][]WindowID
71 }{
72 {"middle", 3, 3, 2, [][]WindowID{{1}, {2}, {3}}},
73 {"first", 3, 3, 1, [][]WindowID{{1}, {2, 3}}},
74 {"left side moves together", 4, 4, 3, [][]WindowID{{1, 2}, {3}, {4}}},
75 {"left joins the band above", 5, 3, 5, [][]WindowID{{1, 2, 3}, {4}, {5}}},
76 } {
77 t.Run(tc.name, func(t *testing.T) {
78 _, w := cascadeMovesCore(t, tc.n, tc.k, false)
79 w.FocusID(tc.focus)
80 before := bandIDs(w)
81 w.ToggleFullWidth()
82 wantBands(t, w, tc.split...)
83 if !w.Columns[w.Focus].FullWidth {
84 t.Fatal("not maximized")
85 }
86 w.ToggleFullWidth()
87 wantBands(t, w, before...)
88 if w.Columns[w.Focus].FullWidth {
89 t.Fatal("still maximized")
90 }
91 })
92 }
93}
94
95// Columns on the left that fit the band above join it at its end.
96func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) {
97 c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
98 w.FocusID(2)
99 c.applyAction(ActionMoveWindowDown) // {1 3} {4 2 5}
100 w.FocusID(2)
101 w.ToggleFullWidth()
102 wantBands(t, w, []WindowID{1, 3, 4}, []WindowID{2}, []WindowID{5})
103 w.ToggleFullWidth()
104 wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
105}
106
107// No column is hidden by a maximized cascade column: focusing each band
108// shows its windows.
109func TestCascadeMaximizeHidesNothing(t *testing.T) {
110 _, w := cascadeMovesCore(t, 3, 3, false)
111 w.FocusID(2)
112 w.ToggleFullWidth() // {1} {2*} {3}
113 for id := WindowID(1); id <= 3; id++ {
114 w.FocusID(id)
115 var found bool
116 for _, p := range w.Layout() {
117 if p.ID == id {
118 found = true
119 if p.Hidden || p.Rect.W <= 0 || p.Rect.H <= 0 {
120 t.Fatalf("window %d not shown: %+v", id, p)
121 }
122 }
123 }
124 if !found {
125 t.Fatalf("no placement for window %d", id)
126 }
127 }
128}
129
130// A new window joins the focused band when it has room, else it starts a
131// band of its own right below it; other bands do not move.
132func TestCascadeSpawnInFocusedBand(t *testing.T) {
133 _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
134 w.FocusID(4)
135 w.AddWindow(6)
136 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
137 w.FocusID(2)
138 w.AddWindow(7)
139 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
140 if id, _ := w.Focused(); id != 7 {
141 t.Fatalf("focus %d, want 7", id)
142 }
143 w.AddWindow(8)
144 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7, 8}, []WindowID{4, 5, 6})
145}
146
147// A band keeps its columns: closing one leaves the others in place, and a
148// band left empty goes away.
149func TestCascadeCloseKeepsBands(t *testing.T) {
150 c, w := cascadeMovesCore(t, 5, 3, false)
151 w.FocusID(5)
152 c.applyAction(ActionMoveWindowDown) // {1 2 3} {4} {5}
153 w.RemoveWindow(2)
154 wantBands(t, w, []WindowID{1, 3}, []WindowID{4}, []WindowID{5})
155 w.RemoveWindow(4)
156 wantBands(t, w, []WindowID{1, 3}, []WindowID{5})
157}
158
159// A smaller max-columns sends the extra columns of a band to a new band
160// right below it; a larger one keeps the bands.
161func TestCascadeMaxColumnsShrinkSplitsBand(t *testing.T) {
162 _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
163 w.SetMaxColumns(2)
164 wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
165 w.SetMaxColumns(3)
166 wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
167}
168
169// Switching to cascade from another overflow packs the columns in order.
170func TestCascadeOverflowSwitchRepacks(t *testing.T) {
171 m := newMonitor("", "")
172 m.SetOutput(900, 600)
173 m.SetMaxColumns(3)
174 m.SetOverflow(OverflowCascade)
175 for id := WindowID(1); id <= 4; id++ {
176 m.AddWindow(id)
177 }
178 w := m.Current()
179 w.FocusID(2)
180 w.ToggleFullWidth() // {1} {2*} {3 4}
181 m.SetOverflow(OverflowScroll)
182 m.SetOverflow(OverflowCascade)
183 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4})
184}
185
186// Moves inside a band keep the band starts in place.
187func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) {
188 c, w := cascadeMovesCore(t, 5, 3, false)
189 w.FocusID(4)
190 c.applyAction(ActionMoveColumnRight)
191 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4})
192}
193
194// Review regressions: push memory survives a maximize in between, finds a
195// column whose first window closed, and leaves columns without room in
196// place.
197func TestCascadeRestoreBandMemory(t *testing.T) {
198 t.Run("cycle then maximize", func(t *testing.T) {
199 _, w := cascadeMovesCore(t, 3, 3, false)
200 w.FocusID(2)
201 w.Apply(ActionCycleColumnWidth) // {1 2} {3}
202 w.ToggleFullWidth() // {1} {2*} {3}
203 w.ToggleFullWidth() // {1 2 3}
204 wantBands(t, w, []WindowID{1, 2, 3})
205 })
206 t.Run("cycle then maximize from either end keeps the order", func(t *testing.T) {
207 for _, focus := range []WindowID{1, 3} {
208 _, w := cascadeMovesCore(t, 3, 3, false)
209 w.FocusID(focus)
210 w.Apply(ActionCycleColumnWidth)
211 w.ToggleFullWidth()
212 w.ToggleFullWidth()
213 wantBands(t, w, []WindowID{1, 2, 3})
214 }
215 })
216 t.Run("a window that left the pushed column does not bring its new column", func(t *testing.T) {
217 _, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
218 w.FocusID(4)
219 w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
220 w.FocusID(2)
221 w.ToggleFullWidth() // {1} {2*} {3/4 5}
222 w.stackWindow(4, 5) // {1} {2*} {3 5/4}
223 w.FocusID(5)
224 w.MoveColumn(-1) // {1} {2*} {5/4 3}
225 w.FocusID(2)
226 w.ToggleFullWidth()
227 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5})
228 })
229 t.Run("a window expelled from the pushed column stays", func(t *testing.T) {
230 _, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
231 w.FocusID(4)
232 w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
233 w.FocusID(2)
234 w.ToggleFullWidth() // {1} {2*} {3/4 5}
235 w.FocusID(4)
236 w.ConsumeOrExpel(-1) // {1} {2*} {4 3 5}
237 w.FocusID(2)
238 w.ToggleFullWidth()
239 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5})
240 })
241 t.Run("a pushed column moved away has no stand-in", func(t *testing.T) {
242 c, w := cascadeMovesCore(t, 5, 4, false) // {1 2 3 4} {5}
243 w.FocusID(4)
244 w.ConsumeOrExpel(-1) // {1 2 3/4} {5}
245 w.FocusID(2)
246 w.ToggleFullWidth() // {1} {2*} {3/4 5}
247 w.FocusID(4)
248 w.ConsumeOrExpel(-1) // {1} {2*} {4 3 5}
249 w.FocusID(3)
250 c.applyAction(ActionMoveWindowDown) // {1} {2*} {4 5} {3}
251 w.FocusID(2)
252 w.ToggleFullWidth()
253 wantBands(t, w, []WindowID{1, 2}, []WindowID{4, 5}, []WindowID{3})
254 })
255 t.Run("first window of a pushed column closed", func(t *testing.T) {
256 _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
257 w.FocusID(4)
258 w.ConsumeOrExpel(-1) // {1 2 3/4}
259 w.FocusID(2)
260 w.ToggleFullWidth() // {1} {2*} {3/4}
261 w.RemoveWindow(3)
262 w.FocusID(2)
263 w.ToggleFullWidth()
264 wantBands(t, w, []WindowID{1, 2, 4})
265 })
266 t.Run("no room keeps the other band as it is", func(t *testing.T) {
267 c, w := cascadeMovesCore(t, 3, 3, false)
268 w.FocusID(2)
269 w.Apply(ActionCycleColumnWidth) // {1 2} {3}
270 w.AddWindow(6) // {1 2 6} {3}
271 w.FocusID(3)
272 w.AddWindow(5) // {1 2 6} {3 5}
273 w.AddWindow(4) // {1 2 6} {3 5 4}
274 c.applyAction(ActionMoveColumnLeft)
275 c.applyAction(ActionMoveColumnLeft) // {1 2 6} {4 3 5}
276 w.FocusID(2)
277 w.Apply(ActionCycleColumnWidth) // End of the cycle: 3 has no room.
278 wantBands(t, w, []WindowID{1, 2, 6}, []WindowID{4, 3, 5})
279 if got := cellsOf(w)[:3]; !slices.Equal(got, []int{0, 0, 0}) {
280 t.Fatalf("cells %v, want equal", got)
281 }
282 })
283}
284
285// Quiet placement (window rules) and slot windows join the focused band
286// like a new window, keeping the focus.
287func TestCascadeQuietAndSlotPlacement(t *testing.T) {
288 _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
289 w.FocusID(4)
290 w.addColumnQuiet(Column{Windows: []WindowID{6}})
291 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6})
292 w.FocusID(2)
293 w.addColumnQuiet(Column{Windows: []WindowID{7}})
294 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6})
295 if id, _ := w.Focused(); id != 2 {
296 t.Fatalf("focus %d, want 2", id)
297 }
298
299 _, w = cascadeMovesCore(t, 2, 3, false) // {1 2}
300 w.AddSlotWindow(9, 1, 0)
301 wantBands(t, w, []WindowID{9, 1, 2})
302 if id, _ := w.Focused(); id != 2 {
303 t.Fatalf("focus %d, want 2", id)
304 }
305}
306
307// Reapplying the config with a named max-columns above the default keeps
308// the named workspace's bands.
309func TestCascadeNamedMaxColumnsKeepsBands(t *testing.T) {
310 m := newMonitor("", "")
311 m.SetOutput(900, 600)
312 m.SetOverflow(OverflowCascade)
313 m.SetMaxColumns(3)
314 m.SetNamed([]NamedWorkspace{{Name: "wide", MaxColumns: 5}})
315 m.ToggleNamed("wide")
316 w := m.Current()
317 for id := WindowID(1); id <= 5; id++ {
318 w.AddWindow(id)
319 }
320 wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
321 m.SetMaxColumns(3)
322 wantBands(t, w, []WindowID{1, 2, 3, 4, 5})
323}
324
325func bandIDs(w *Workspace) [][]WindowID {
326 var out [][]WindowID
327 w.walkBands(func(_, first, last int) bool {
328 var b []WindowID
329 for _, c := range w.Columns[first : last+1] {
330 b = append(b, c.Windows[0])
331 }
332 out = append(out, slices.Clone(b))
333 return true
334 })
335 return out
336}