1package core
2
3import (
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.
11func 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 c.cur().mon.normalize() // the trailing empty workspace, as in a session
27 return c, w
28}
29
30// columnOrder is the first window of each column, in column order.
31func columnOrder(w *Workspace) []WindowID {
32 var ids []WindowID
33 for _, c := range w.Columns {
34 ids = append(ids, c.Windows[0])
35 }
36 return ids
37}
38
39func screenWorkspace(c *Core, name string) *Workspace {
40 for _, s := range c.screens {
41 if s.name() == name {
42 return s.mon.Current()
43 }
44 }
45 return nil
46}
47
48func wantColumns(t *testing.T, w *Workspace, want ...WindowID) {
49 t.Helper()
50 if got := columnOrder(w); !slices.Equal(got, want) {
51 t.Fatalf("columns %v, want %v", got, want)
52 }
53}
54
55// wantBands checks the cascade bands, each by the first window of its
56// columns.
57func wantBands(t *testing.T, w *Workspace, want ...[]WindowID) {
58 t.Helper()
59 var got [][]WindowID
60 w.walkBands(func(_, first, last int) bool {
61 got = append(got, columnOrder(&Workspace{Columns: w.Columns[first : last+1]}))
62 return true
63 })
64 if !slices.EqualFunc(got, want, slices.Equal) {
65 t.Fatalf("bands %v, want %v", got, want)
66 }
67}
68
69func wantFocus(t *testing.T, c *Core, screen string, id WindowID) {
70 t.Helper()
71 if got := c.cur().name(); got != screen {
72 t.Fatalf("focused screen %q, want %q", got, screen)
73 }
74 if got, _ := c.cur().mon.Current().Focused(); got != id {
75 t.Fatalf("focused window %d, want %d", got, id)
76 }
77}
78
79// At the edge of its band, a cascade column goes to the neighbor monitor
80// on that side, and focus with it; bands {1 2 3} {4 5}.
81func TestCascadeBandEdgeMovesToNeighborMonitor(t *testing.T) {
82 for _, tc := range []struct {
83 name string
84 from WindowID
85 action Action
86 screen string
87 here, moved []WindowID
88 focusedAfter WindowID
89 }{
90 {"move left from band start", 4, ActionMoveColumnLeft, "L", []WindowID{1, 2, 3, 5}, []WindowID{4}, 4},
91 {"move right from band end", 3, ActionMoveColumnRight, "R", []WindowID{1, 2, 4, 5}, []WindowID{3}, 3},
92 {"move right from last column", 5, ActionMoveColumnRight, "R", []WindowID{1, 2, 3, 4}, []WindowID{5}, 5},
93 {"focus left from band start", 4, ActionFocusColumnLeft, "L", []WindowID{1, 2, 3, 4, 5}, nil, 0},
94 {"focus right from band end", 3, ActionFocusColumnRight, "R", []WindowID{1, 2, 3, 4, 5}, nil, 0},
95 } {
96 t.Run(tc.name, func(t *testing.T) {
97 c, w := cascadeMovesCore(t, 5, 3, true)
98 w.FocusID(tc.from)
99 c.applyAction(tc.action)
100 wantColumns(t, w, tc.here...)
101 wantColumns(t, screenWorkspace(c, tc.screen), tc.moved...)
102 wantFocus(t, c, tc.screen, tc.focusedAfter)
103 })
104 }
105}
106
107// Inside a band, left/right stay on the screen.
108func TestCascadeInsideBandStaysOnMonitor(t *testing.T) {
109 c, w := cascadeMovesCore(t, 5, 3, true)
110 w.FocusID(5)
111 c.applyAction(ActionMoveColumnLeft)
112 wantColumns(t, w, 1, 2, 3, 5, 4)
113 wantFocus(t, c, "M", 5)
114 c.applyAction(ActionFocusColumnRight)
115 wantFocus(t, c, "M", 4)
116}
117
118// With no monitor on that side, left/right never cross into the neighbor
119// band: at the band edge focus steps the workspace (the first has none
120// before it; the last is the trailing empty one) and move-column does
121// nothing. up/down cross bands.
122func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveStays(t *testing.T) {
123 c, w := cascadeMovesCore(t, 5, 3, false)
124 m := c.cur().mon
125 w.FocusID(4)
126 c.applyAction(ActionFocusColumnLeft)
127 wantFocus(t, c, "M", 4)
128 if m.Active != 0 {
129 t.Fatalf("active %d, want 0", m.Active)
130 }
131 w.FocusID(3)
132 c.applyAction(ActionFocusColumnRight)
133 if m.Active != 1 || m.Current() == w {
134 t.Fatalf("active %d, want the trailing empty workspace", m.Active)
135 }
136 m.Focus(0)
137 w.FocusID(4)
138 c.applyAction(ActionMoveColumnLeft)
139 wantColumns(t, w, 1, 2, 3, 4, 5)
140 wantFocus(t, c, "M", 4)
141 w.FocusID(3)
142 c.applyAction(ActionMoveColumnRight)
143 wantColumns(t, w, 1, 2, 3, 4, 5)
144 wantFocus(t, c, "M", 3)
145}
146
147// With one column per band, every column is at both edges of its band.
148func TestCascadeOneColumnBandsLeaveForMonitor(t *testing.T) {
149 c, w := cascadeMovesCore(t, 2, 1, true)
150 w.FocusID(2)
151 c.applyAction(ActionMoveColumnLeft)
152 wantColumns(t, w, 1)
153 wantColumns(t, screenWorkspace(c, "L"), 2)
154 wantFocus(t, c, "L", 2)
155}
156
157// Fixed overflow keeps its column order edge: a non-edge column swaps.
158func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) {
159 c, w := cascadeMovesCore(t, 2, 3, true)
160 w.Overflow = OverflowFixed
161 w.FocusID(2)
162 c.applyAction(ActionMoveColumnLeft)
163 wantColumns(t, w, 2, 1)
164 wantFocus(t, c, "M", 2)
165}
166
167// At the top or bottom of its column, move-window-up/down sends a cascade
168// column to the adjacent band: it joins it when it has room, at its offset
169// in the band, else it makes a new band between the two.
170func TestCascadeMoveWindowCrossesBands(t *testing.T) {
171 c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
172 w.FocusID(2)
173 c.applyAction(ActionMoveWindowDown)
174 wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5})
175 wantFocus(t, c, "M", 2)
176 c.applyAction(ActionMoveWindowUp)
177 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5})
178 wantFocus(t, c, "M", 2)
179}
180
181// A full band cannot take the column: a new band goes between the two,
182// and the one it left keeps the others.
183func TestCascadeMoveWindowIntoFullBandMakesBand(t *testing.T) {
184 c, w := cascadeMovesCore(t, 6, 3, false) // {1 2 3} {4 5 6}
185 w.FocusID(5)
186 c.applyAction(ActionMoveWindowUp)
187 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5}, []WindowID{4, 6})
188 wantFocus(t, c, "M", 5)
189 // Alone in its band, it passes the full band above.
190 c.applyAction(ActionMoveWindowUp)
191 wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3}, []WindowID{4, 6})
192 wantFocus(t, c, "M", 5)
193}
194
195// Past the outer bands a column sharing its band starts a new one; a
196// column alone there does not move.
197func TestCascadeMoveWindowOuterBands(t *testing.T) {
198 c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5}
199 w.FocusID(5)
200 c.applyAction(ActionMoveWindowDown)
201 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5})
202 c.applyAction(ActionMoveWindowDown)
203 wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5})
204 w.FocusID(1)
205 c.applyAction(ActionMoveWindowUp)
206 wantBands(t, w, []WindowID{1}, []WindowID{2, 3}, []WindowID{4}, []WindowID{5})
207 wantFocus(t, c, "M", 1)
208}
209
210// A band left empty goes away.
211func TestCascadeMoveWindowDropsEmptyBand(t *testing.T) {
212 c, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4}
213 w.FocusID(2)
214 c.applyAction(ActionMoveWindowDown)
215 wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2})
216 w.FocusID(4)
217 c.applyAction(ActionMoveWindowUp) // At its offset in the band: first.
218 wantBands(t, w, []WindowID{4, 1, 3}, []WindowID{2})
219}
220
221// A maximized column keeps its band to itself: moving it, or moving a
222// column toward it, makes a new band; repeating the move passes it.
223func TestCascadeMoveWindowAndMaximizedBand(t *testing.T) {
224 for _, tc := range []struct {
225 name string
226 n int
227 max, focus WindowID
228 action Action
229 want [][]WindowID
230 }{
231 // After maximizing 2: {1} {2*} {3 4}.
232 {"maximized down", 4, 2, 2, ActionMoveWindowDown, [][]WindowID{{1}, {3, 4}, {2}}},
233 {"maximized up", 4, 2, 2, ActionMoveWindowUp, [][]WindowID{{2}, {1}, {3, 4}}},
234 // After maximizing 4: {1 2 3} {4*} {5}.
235 {"into maximized below", 5, 4, 3, ActionMoveWindowDown, [][]WindowID{{1, 2}, {3}, {4}, {5}}},
236 {"into maximized above", 5, 4, 5, ActionMoveWindowUp, [][]WindowID{{1, 2, 3}, {5}, {4}}},
237 } {
238 t.Run(tc.name, func(t *testing.T) {
239 c, w := cascadeMovesCore(t, tc.n, 3, false)
240 w.FocusID(tc.max)
241 w.ToggleFullWidth()
242 w.FocusID(tc.focus)
243 c.applyAction(tc.action)
244 wantBands(t, w, tc.want...)
245 wantFocus(t, c, "M", tc.focus)
246 if !w.Columns[w.columnOf(tc.max)].FullWidth {
247 t.Fatal("maximization lost")
248 }
249 })
250 }
251}
252
253// A fullscreen tile at its band edge leaves for the neighbor monitor and
254// stays fullscreen, as a tile there does.
255func TestCascadeFullscreenBandEdgeFocusesMonitor(t *testing.T) {
256 c, w := cascadeMovesCore(t, 5, 3, true)
257 w.FocusID(3)
258 w.ToggleFullscreen()
259 c.applyAction(ActionFocusColumnRight)
260 if got := c.cur().name(); got != "R" {
261 t.Fatalf("focused screen %q, want R", got)
262 }
263 if id, _ := w.Focused(); w.fullscreen != 3 || id != 3 {
264 t.Fatalf("fullscreen %d, focused %d on M, want 3 and 3", w.fullscreen, id)
265 }
266}
267
268// From a native float over the columns, the first move leaves the float
269// on this monitor, even with the focused column at its band edge.
270func TestCascadeFloatAtBandEdgeStaysOnMonitor(t *testing.T) {
271 c, w := cascadeMovesCore(t, 5, 3, true)
272 w.FocusID(3)
273 w.AddFloating(9, 100, 100)
274 c.applyAction(ActionFocusColumnRight)
275 if got := c.cur().name(); got != "M" {
276 t.Fatalf("focused screen %q, want M", got)
277 }
278 if w.floatFocus {
279 t.Fatal("focus still on the float")
280 }
281}
282
283// A stacked window moves inside its column first, then the column changes
284// band.
285func TestCascadeMoveWindowStackedFirst(t *testing.T) {
286 c, w := cascadeMovesCore(t, 5, 3, false)
287 w.FocusID(5)
288 w.ConsumeOrExpel(-1) // bands {1 2 3} {4/5}
289 if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{4, 5}) {
290 t.Fatalf("stack %v, want [4 5]", got)
291 }
292 c.applyAction(ActionMoveWindowUp)
293 if got := w.Columns[3].Windows; !slices.Equal(got, []WindowID{5, 4}) {
294 t.Fatalf("stack %v, want [5 4]", got)
295 }
296 c.applyAction(ActionMoveWindowUp)
297 // The full band above cannot take it, so it passes it.
298 wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3})
299 wantFocus(t, c, "M", 5)
300}
301
302// A fullscreen window keeps its band.
303func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
304 c, w := cascadeMovesCore(t, 5, 3, false)
305 w.FocusID(4)
306 w.ToggleFullscreen()
307 c.applyAction(ActionMoveWindowUp)
308 wantColumns(t, w, 1, 2, 3, 4, 5)
309}
310
311// Scroll overflow has no bands: up/down stop at the column edge.
312func TestScrollMoveWindowStopsAtColumnEdge(t *testing.T) {
313 c, w := cascadeMovesCore(t, 2, 3, false)
314 w.Overflow = OverflowScroll
315 w.FocusID(2)
316 c.applyAction(ActionMoveWindowUp)
317 c.applyAction(ActionMoveWindowDown)
318 wantColumns(t, w, 1, 2)
319 wantFocus(t, c, "M", 2)
320}