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