1package core
2
3import (
4 "slices"
5 "testing"
6
7 "github.com/bnema/neferwl/internal/ports"
8)
9
10func cascadeWorkspace(n int) *Workspace {
11 w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
12 w.SetOutput(900, 600)
13 for i := 1; i <= n; i++ {
14 w.AddWindow(WindowID(i))
15 }
16 return w
17}
18
19// With gaps and a width that does not divide evenly, the columns of a
20// partial band share what the gaps leave, and the configured width follows
21// the band as it fills.
22func TestCascadeBandWidthWithGaps(t *testing.T) {
23 w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
24 w.SetOutput(1000, 600)
25 w.SetGaps(8)
26 w.AddWindow(1)
27 if got := w.columnWidth(0); got != 1000-2*8 {
28 t.Fatalf("lone column width %d, want %d", got, 1000-2*8)
29 }
30 w.AddWindow(2)
31 cell := (1000 - 3*8) / 2
32 for i, x := range []int{8, 8 + cell + 8} {
33 r := w.columnRectsFor(false)[i]
34 if r.X != x || r.W != cell || w.columnWidth(i) != cell {
35 t.Fatalf("column %d rect %v width %d, want X %d W %d", i, r, w.columnWidth(i), x, cell)
36 }
37 }
38}
39
40// A partial last band after a full one shares the band width with gaps:
41// each band divides by its own column count.
42func TestCascadePartialLastBandWithGaps(t *testing.T) {
43 w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
44 w.SetOutput(1000, 600)
45 w.SetGaps(8)
46 for id := WindowID(1); id <= 5; id++ {
47 w.AddWindow(id)
48 }
49 third, half := (1000-4*8)/3, (1000-3*8)/2
50 rects := w.columnRectsFor(false)
51 for i, want := range []struct{ x, w int }{
52 {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third},
53 {8, half}, {8 + half + 8, half},
54 } {
55 if r := rects[i]; r.X != want.x || r.W != want.w {
56 t.Fatalf("column %d rect %v, want X %d W %d", i, r, want.x, want.w)
57 }
58 }
59 if rects[3].Y-rects[0].Y != 600 {
60 t.Fatalf("band spacing %d, want 600", rects[3].Y-rects[0].Y)
61 }
62}
63
64// A side panel moves and narrows every band: cells start at the usable
65// area's X and share its width.
66func TestCascadeSidePanel(t *testing.T) {
67 w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2}
68 w.SetOutput(1000, 600)
69 w.SetUsable(Rect{X: 100, W: 900, H: 600})
70 for id := WindowID(1); id <= 3; id++ {
71 w.AddWindow(id)
72 }
73 rects := w.columnRectsFor(false)
74 for i, want := range []Rect{
75 {X: 100, Y: 0, W: 450, H: 600},
76 {X: 550, Y: 0, W: 450, H: 600},
77 {X: 100, Y: 600, W: 900, H: 600},
78 } {
79 if rects[i].X != want.X || rects[i].W != want.W || rects[i].Y-rects[0].Y != want.Y {
80 t.Fatalf("column %d rect %v, want %v", i, rects[i], want)
81 }
82 }
83 // The panel edge at x=100 is no shared line: column 1 has only its
84 // right neighbor.
85 w.FocusID(1)
86 if p := previewOf(t, w.Layout(), 1); p.Neighbors != ports.SideRight {
87 t.Fatalf("neighbors %04b, want right only", p.Neighbors)
88 }
89}
90
91// Each band's columns share its width equally, as in fixed overflow: a
92// lone column fills it, a full band of three has thirds.
93func TestCascadeBandsAndReveal(t *testing.T) {
94 for _, n := range []int{0, 1, 2, 3, 4, 5, 7} {
95 w := cascadeWorkspace(n)
96 ps := w.Layout()
97 if len(ps) != n {
98 t.Fatalf("n=%d placements=%d", n, len(ps))
99 }
100 for i, p := range ps {
101 inBand := min(n-i/3*3, 3)
102 cell := 900 / inBand
103 want := Rect{X: i % 3 * cell, Y: (i/3)*600 - w.View, W: cell, H: 600}
104 if p.Rect != want {
105 t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)
106 }
107 }
108 if n > 0 && w.View != ((n-1)/3)*600 {
109 t.Fatalf("n=%d view=%d", n, w.View)
110 }
111 }
112}
113
114func TestCascadeCompactionAndNavigation(t *testing.T) {
115 w := cascadeWorkspace(7)
116 w.FocusID(2)
117 if !w.FocusWindow(1) {
118 t.Fatal("no lower band")
119 }
120 if id, _ := w.Focused(); id != 5 {
121 t.Fatalf("focus=%d", id)
122 }
123 w.RemoveWindow(1)
124 if w.View != 600 {
125 t.Fatalf("compacted view=%d", w.View)
126 }
127 w.SetMaxColumns(2)
128 if w.View != 600 {
129 t.Fatalf("changed max view=%d", w.View)
130 }
131 w.FocusID(7)
132 w.AddWindow(8)
133 if w.Columns[len(w.Columns)-1].Windows[0] != 8 {
134 t.Fatal("new column did not join the focused last band")
135 }
136 w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580})
137 if w.View != w.band(w.Focus)*580 {
138 t.Fatal("resize did not reveal band")
139 }
140 if !slices.Equal(w.snapPoints(), []float64{0, 580, 1160, 1740}) {
141 t.Fatalf("points=%v", w.snapPoints())
142 }
143}
144
145// A maximized column takes a band of its own: the columns after it pack
146// into the next bands, and unmaximizing packs them back.
147func TestCascadeMaximizedColumnOwnsBand(t *testing.T) {
148 w := cascadeWorkspace(4) // Bands {1 2 3} {4}.
149 w.FocusID(2)
150 w.ToggleFullWidth()
151 bands := func() []int {
152 var b []int
153 for i := range w.Columns {
154 b = append(b, w.band(i))
155 }
156 return b
157 }
158 if got := bands(); !slices.Equal(got, []int{0, 1, 2, 2}) {
159 t.Fatalf("bands %v, want [0 1 2 2]", got)
160 }
161 if w.View != 600 {
162 t.Fatalf("view %d, want the maximized band", w.View)
163 }
164 for _, p := range w.Layout() {
165 if p.ID == 2 && (p.Rect.W != 900 || p.Rect.Y != 0) {
166 t.Fatalf("full width=%v", p.Rect)
167 }
168 if p.ID != 2 && p.Rect.Y == 0 && !p.Hidden {
169 t.Fatalf("column %d shares the maximized band: %v", p.ID, p.Rect)
170 }
171 }
172 // Focus moves between bands without unmaximizing.
173 w.FocusID(1)
174 if !w.Columns[1].FullWidth || w.View != 0 {
175 t.Fatalf("focus left the band: maximized %t view %d", w.Columns[1].FullWidth, w.View)
176 }
177 w.FocusID(2)
178 w.ToggleFullWidth()
179 if got := bands(); !slices.Equal(got, []int{0, 0, 0, 1}) {
180 t.Fatalf("bands after unmaximize %v", got)
181 }
182}
183
184func TestCascadeCovers(t *testing.T) {
185 w := cascadeWorkspace(4)
186 w.FocusID(2)
187 w.ToggleFullscreen()
188 for _, p := range w.Layout() {
189 if p.ID == 2 && p.Rect != w.Output {
190 t.Fatalf("fullscreen=%v", p.Rect)
191 }
192 if p.ID != 2 && !p.Hidden {
193 t.Fatalf("fullscreen sibling %d visible", p.ID)
194 }
195 }
196 w.ToggleFullscreen()
197 w.FocusID(4)
198 if w.View != 600 {
199 t.Fatal("fullscreen restore lost band reveal")
200 }
201}
202
203// Band changes keep a maximized column maximized: it has its own band.
204func TestCascadeKeepsMaximizationOnBandChanges(t *testing.T) {
205 for _, move := range []struct {
206 name string
207 run func(*Workspace)
208 }{
209 {"focus", func(w *Workspace) { w.FocusWindow(1) }},
210 {"append", func(w *Workspace) { w.AddWindow(5) }},
211 {"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }},
212 } {
213 t.Run(move.name, func(t *testing.T) {
214 w := cascadeWorkspace(4)
215 w.FocusID(1)
216 w.ToggleFullWidth()
217 move.run(w)
218 if i := w.columnOf(1); !w.Columns[i].FullWidth || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth && c.Windows[0] != 1 }) {
219 t.Fatalf("maximization lost or moved: %+v", w.Columns)
220 }
221 })
222 }
223}
224
225func TestCascadeStashRestoreKeepsReloadClearsMaximization(t *testing.T) {
226 w := cascadeWorkspace(3)
227 w.FocusID(1)
228 w.ToggleFullWidth()
229 w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
230 if !w.Columns[0].FullWidth {
231 t.Fatal("restore into another band dropped the maximization")
232 }
233 m := NewMonitor()
234 m.SetOutput(900, 600)
235 for i := WindowID(1); i <= 3; i++ {
236 m.AddWindow(i)
237 }
238 m.Current().FocusID(1)
239 m.Current().ToggleFullWidth()
240 m.Current().FocusID(3)
241 m.SetOverflow(OverflowCascade)
242 for _, c := range m.Current().Columns {
243 if c.FullWidth {
244 t.Fatal("reload retained maximization")
245 }
246 }
247}
248
249// Leaving cascade drops the band offset: scroll realigns on the focus
250// instead of keeping it as a horizontal offset past the last column.
251func TestReloadCascadeToScrollResetsView(t *testing.T) {
252 m := NewMonitor()
253 m.SetOutput(900, 600)
254 m.SetMaxColumns(3)
255 m.SetOverflow(OverflowCascade)
256 for i := WindowID(1); i <= 4; i++ {
257 m.AddWindow(i)
258 }
259 if m.Current().View != 600 {
260 t.Fatalf("cascade view=%d, want 600", m.Current().View)
261 }
262 m.SetOverflow(OverflowScroll)
263 if got := m.Current().View; got != 300 {
264 t.Fatalf("scroll view=%d, want 300", got)
265 }
266}
267
268// Up/down stop at the first and last band: cascade workspaces sit side by
269// side, so a vertical key never switches workspace.
270func TestCascadeFocusWindowStopsAtEdgeBands(t *testing.T) {
271 m := NewMonitor()
272 m.SetOutput(900, 600)
273 m.SetMaxColumns(3)
274 m.SetOverflow(OverflowCascade)
275 for i := WindowID(1); i <= 4; i++ {
276 m.AddWindow(i)
277 }
278 for _, tc := range []struct {
279 focus WindowID
280 a Action
281 }{{4, ActionFocusWindowDown}, {1, ActionFocusWindowUp}} {
282 m.Current().FocusID(tc.focus)
283 m.Apply(tc.a)
284 if id, _ := m.Current().Focused(); m.Active != 0 || id != tc.focus {
285 t.Fatalf("%s from %d: active=%d focus=%d", tc.a, tc.focus, m.Active, id)
286 }
287 }
288}
289
290func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
291 m := NewMonitor()
292 m.SetOutput(900, 600)
293 m.SetOverflow(OverflowCascade)
294 for i := WindowID(1); i <= 7; i++ {
295 m.AddWindow(i)
296 }
297 g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens}
298 if n := testing.AllocsPerRun(100, func() {
299 if g.columnsChanged(m) {
300 t.Fatal("unchanged gesture invalidated")
301 }
302 }); n != 0 {
303 t.Fatalf("allocations=%v", n)
304 }
305}
306
307func TestCascadeGeometryReusesBuffer(t *testing.T) {
308 w := cascadeWorkspace(7)
309 buf := w.columnRectsInto(nil, false)
310 if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
311 t.Fatalf("allocations=%v", n)
312 }
313 // The overview's lane-encoded tiles reuse their buffer too.
314 tiles, _, _ := w.previewTiles()
315 if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 {
316 t.Fatalf("preview tile allocations=%v", n)
317 }
318}
319
320// Lanes are the bands of a cascade workspace and the whole row of any other.
321func TestLaneGeometry(t *testing.T) {
322 w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6}
323 for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} {
324 if got := w.laneOf(c.i); got != c.lane {
325 t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane)
326 }
327 }
328 for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} {
329 if first, last := w.laneBounds(lane); first != want[0] || last != want[1] {
330 t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want)
331 }
332 }
333 for _, lane := range []int{-1, 3} {
334 if first, last := w.laneBounds(lane); first <= last {
335 t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last)
336 }
337 }
338 // The closest column of the adjacent lane keeps its offset, clamped.
339 for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} {
340 w.Focus = c.focus
341 if got := w.laneNeighbor(c.d); got != c.want {
342 t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want)
343 }
344 }
345
346 s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3}
347 s.SetOutput(900, 600)
348 for i := 1; i <= 5; i++ {
349 s.AddWindow(WindowID(i))
350 }
351 for i := range s.Columns {
352 if s.laneOf(i) != 0 {
353 t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i))
354 }
355 }
356 if first, last := s.laneBounds(0); first != 0 || last != 4 {
357 t.Fatalf("scroll laneBounds(0)=%d,%d", first, last)
358 }
359 for _, lane := range []int{-1, 1} {
360 if first, last := s.laneBounds(lane); first <= last {
361 t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last)
362 }
363 }
364 for _, d := range []int{-1, 1} {
365 if got := s.laneNeighbor(d); got != -1 {
366 t.Fatalf("scroll laneNeighbor(%d)=%d", d, got)
367 }
368 }
369}
370
371func TestSlideLayoutAxisOffset(t *testing.T) {
372 m := NewMonitor()
373 m.SetOutput(900, 600)
374 m.Current().Overflow = OverflowCascade
375 m.AddWindow(1)
376 m.Focus(1)
377 m.AddWindow(2)
378 m.Current().Overflow = OverflowScroll
379 m.Focus(0)
380 m.switchAxis = m.Current().policy().workspace
381 m.switchView.off = .5
382 if p := previewOf(t, m.layoutInto(nil), 1); p.Rect.X != -450 || p.Rect.Y != 0 {
383 t.Fatalf("cascade source offset=%v", p.Rect)
384 }
385 m.stopSwitch()
386 m.Focus(1)
387 m.switchAxis = m.Current().policy().workspace
388 m.switchView.off = -.5
389 if p := previewOf(t, m.layoutInto(nil), 2); p.Rect.X != 0 || p.Rect.Y != 300 {
390 t.Fatalf("scroll source offset=%v", p.Rect)
391 }
392}
393
394func TestCascadeViewportSnap(t *testing.T) {
395 w := cascadeWorkspace(7)
396 w.FocusID(3)
397 if w.snapFocus(600, true) != 5 {
398 t.Fatalf("nearest cell=%d", w.snapFocus(600, true))
399 }
400 if w.snapFocus(1200, true) != 6 {
401 t.Fatal("short band neighbour")
402 }
403 w.SetUsable(Rect{})
404 if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
405 t.Fatalf("zero area=%v", got)
406 }
407}