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(2)
132 w.AddWindow(8)
133 if w.Columns[len(w.Columns)-1].Windows[0] != 8 {
134 t.Fatal("new column did not append")
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
145func TestCascadeCovers(t *testing.T) {
146 w := cascadeWorkspace(4)
147 w.FocusID(2)
148 w.ToggleFullWidth()
149 ps := w.Layout()
150 for _, p := range ps {
151 if p.ID == 2 && p.Rect.W != 900 {
152 t.Fatalf("full width=%v", p.Rect)
153 }
154 if (p.ID == 1 || p.ID == 3) && !p.Hidden {
155 t.Fatalf("sibling %d visible", p.ID)
156 }
157 }
158 w.ToggleFullWidth()
159 w.ToggleFullscreen()
160 for _, p := range w.Layout() {
161 if p.ID == 2 && p.Rect != w.Output {
162 t.Fatalf("fullscreen=%v", p.Rect)
163 }
164 if p.ID != 2 && !p.Hidden {
165 t.Fatalf("fullscreen sibling %d visible", p.ID)
166 }
167 }
168 w.ToggleFullscreen()
169 w.FocusID(4)
170 if w.View != 600 {
171 t.Fatal("fullscreen restore lost band reveal")
172 }
173}
174
175func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
176 for _, move := range []struct {
177 name string
178 run func(*Workspace)
179 }{
180 {"focus", func(w *Workspace) { w.FocusWindow(1) }},
181 {"append", func(w *Workspace) { w.AddWindow(5) }},
182 {"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }},
183 } {
184 t.Run(move.name, func(t *testing.T) {
185 w := cascadeWorkspace(4)
186 w.FocusID(1)
187 w.ToggleFullWidth()
188 move.run(w)
189 for _, col := range w.Columns {
190 if col.FullWidth {
191 t.Fatal("stale maximization")
192 }
193 }
194 })
195 }
196}
197
198func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
199 w := cascadeWorkspace(3)
200 w.FocusID(1)
201 w.ToggleFullWidth()
202 w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
203 for _, c := range w.Columns {
204 if c.FullWidth {
205 t.Fatal("restore retained maximization")
206 }
207 }
208 m := NewMonitor()
209 m.SetOutput(900, 600)
210 for i := WindowID(1); i <= 3; i++ {
211 m.AddWindow(i)
212 }
213 m.Current().FocusID(1)
214 m.Current().ToggleFullWidth()
215 m.Current().FocusID(3)
216 m.SetOverflow(OverflowCascade)
217 for _, c := range m.Current().Columns {
218 if c.FullWidth {
219 t.Fatal("reload retained maximization")
220 }
221 }
222}
223
224// Leaving cascade drops the band offset: scroll realigns on the focus
225// instead of keeping it as a horizontal offset past the last column.
226func TestReloadCascadeToScrollResetsView(t *testing.T) {
227 m := NewMonitor()
228 m.SetOutput(900, 600)
229 m.SetMaxColumns(3)
230 m.SetOverflow(OverflowCascade)
231 for i := WindowID(1); i <= 4; i++ {
232 m.AddWindow(i)
233 }
234 if m.Current().View != 600 {
235 t.Fatalf("cascade view=%d, want 600", m.Current().View)
236 }
237 m.SetOverflow(OverflowScroll)
238 if got := m.Current().View; got != 300 {
239 t.Fatalf("scroll view=%d, want 300", got)
240 }
241}
242
243// Up/down stop at the first and last band: cascade workspaces sit side by
244// side, so a vertical key never switches workspace.
245func TestCascadeFocusWindowStopsAtEdgeBands(t *testing.T) {
246 m := NewMonitor()
247 m.SetOutput(900, 600)
248 m.SetMaxColumns(3)
249 m.SetOverflow(OverflowCascade)
250 for i := WindowID(1); i <= 4; i++ {
251 m.AddWindow(i)
252 }
253 for _, tc := range []struct {
254 focus WindowID
255 a Action
256 }{{4, ActionFocusWindowDown}, {1, ActionFocusWindowUp}} {
257 m.Current().FocusID(tc.focus)
258 m.Apply(tc.a)
259 if id, _ := m.Current().Focused(); m.Active != 0 || id != tc.focus {
260 t.Fatalf("%s from %d: active=%d focus=%d", tc.a, tc.focus, m.Active, id)
261 }
262 }
263}
264
265func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
266 m := NewMonitor()
267 m.SetOutput(900, 600)
268 m.SetOverflow(OverflowCascade)
269 for i := WindowID(1); i <= 7; i++ {
270 m.AddWindow(i)
271 }
272 g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens}
273 if n := testing.AllocsPerRun(100, func() {
274 if g.columnsChanged(m) {
275 t.Fatal("unchanged gesture invalidated")
276 }
277 }); n != 0 {
278 t.Fatalf("allocations=%v", n)
279 }
280}
281
282func TestCascadeGeometryReusesBuffer(t *testing.T) {
283 w := cascadeWorkspace(7)
284 buf := w.columnRectsInto(nil, false)
285 if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
286 t.Fatalf("allocations=%v", n)
287 }
288 // The overview's lane-encoded tiles reuse their buffer too.
289 tiles, _, _ := w.previewTiles()
290 if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 {
291 t.Fatalf("preview tile allocations=%v", n)
292 }
293}
294
295// Lanes are the bands of a cascade workspace and the whole row of any other.
296func TestLaneGeometry(t *testing.T) {
297 w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6}
298 for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} {
299 if got := w.laneOf(c.i); got != c.lane {
300 t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane)
301 }
302 }
303 for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} {
304 if first, last := w.laneBounds(lane); first != want[0] || last != want[1] {
305 t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want)
306 }
307 }
308 for _, lane := range []int{-1, 3} {
309 if first, last := w.laneBounds(lane); first <= last {
310 t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last)
311 }
312 }
313 // The closest column of the adjacent lane keeps its offset, clamped.
314 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}} {
315 w.Focus = c.focus
316 if got := w.laneNeighbor(c.d); got != c.want {
317 t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want)
318 }
319 }
320
321 s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3}
322 s.SetOutput(900, 600)
323 for i := 1; i <= 5; i++ {
324 s.AddWindow(WindowID(i))
325 }
326 for i := range s.Columns {
327 if s.laneOf(i) != 0 {
328 t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i))
329 }
330 }
331 if first, last := s.laneBounds(0); first != 0 || last != 4 {
332 t.Fatalf("scroll laneBounds(0)=%d,%d", first, last)
333 }
334 for _, lane := range []int{-1, 1} {
335 if first, last := s.laneBounds(lane); first <= last {
336 t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last)
337 }
338 }
339 for _, d := range []int{-1, 1} {
340 if got := s.laneNeighbor(d); got != -1 {
341 t.Fatalf("scroll laneNeighbor(%d)=%d", d, got)
342 }
343 }
344}
345
346func TestSlideLayoutAxisOffset(t *testing.T) {
347 m := NewMonitor()
348 m.SetOutput(900, 600)
349 m.Current().Overflow = OverflowCascade
350 m.AddWindow(1)
351 m.Focus(1)
352 m.AddWindow(2)
353 m.Current().Overflow = OverflowScroll
354 m.Focus(0)
355 m.switchAxis = m.Current().policy().workspace
356 m.switchView.off = .5
357 if p := previewOf(t, m.layoutInto(nil), 1); p.Rect.X != -450 || p.Rect.Y != 0 {
358 t.Fatalf("cascade source offset=%v", p.Rect)
359 }
360 m.stopSwitch()
361 m.Focus(1)
362 m.switchAxis = m.Current().policy().workspace
363 m.switchView.off = -.5
364 if p := previewOf(t, m.layoutInto(nil), 2); p.Rect.X != 0 || p.Rect.Y != 300 {
365 t.Fatalf("scroll source offset=%v", p.Rect)
366 }
367}
368
369func TestCascadeViewportSnap(t *testing.T) {
370 w := cascadeWorkspace(7)
371 w.FocusID(3)
372 if w.snapFocus(600, true) != 5 {
373 t.Fatalf("nearest cell=%d", w.snapFocus(600, true))
374 }
375 if w.snapFocus(1200, true) != 6 {
376 t.Fatal("short band neighbour")
377 }
378 w.SetUsable(Rect{})
379 if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
380 t.Fatalf("zero area=%v", got)
381 }
382}