1package core
2
3import (
4 "context"
5 "slices"
6 "testing"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11func TestToggleWindowStash(t *testing.T) {
12 for _, stacked := range []bool{false, true} {
13 t.Run(map[bool]string{false: "column", true: "stack"}[stacked], func(t *testing.T) {
14 w := workspace()
15 w.border = 2
16 w.AddWindow(1)
17 w.AddWindow(2)
18 w.Columns[1].Cells = 3
19 if stacked {
20 w.Columns[1].Windows = []WindowID{3, 2}
21 w.Columns[1].Focus = 1
22 }
23 w.Apply(ActionToggleWindowStash)
24 if len(w.Stash) != 1 || len(w.Floats) != 0 || !w.Stash[0].back.holdsStack(Column{Windows: []WindowID{3}}) && stacked {
25 t.Fatalf("stash %+v", w.Stash)
26 }
27 p := placement(w, 2)
28 if !p.Focused || !p.Floating || p.Rect != (Rect{X: 10, Y: 8, W: 80, H: 64}) {
29 t.Fatalf("placement %+v", p)
30 }
31 w.ResizeFloating(2, 12, 14)
32 if placement(w, 2).Rect != p.Rect {
33 t.Fatal("client resize moved stashed window")
34 }
35 w.Apply(ActionToggleWindowStash)
36 id, _ := w.Focused()
37 if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Cells != 3 || !slices.Contains(w.Columns[1].Windows, 2) {
38 t.Fatalf("round trip: %+v", w)
39 }
40 })
41 }
42 w := workspace()
43 w.AddFloating(4, 20, 10)
44 w.Apply(ActionToggleWindowStash)
45 if len(w.Floats) != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0] != 4 {
46 t.Fatalf("native float: %+v", w)
47 }
48}
49
50// placement returns the placement of id in the workspace layout.
51func placement(w *Workspace, id WindowID) Placement {
52 for _, p := range w.Layout() {
53 if p.ID == id {
54 return p
55 }
56 }
57 return Placement{}
58}
59
60// The stash is a strip: new windows go last, selected and centred; the
61// neighbors peek in beside it, stash.gap away; focus moves inside it and stops at its
62// ends; up and down do nothing there.
63func TestStashStrip(t *testing.T) {
64 w := workspace()
65 w.stashGap = 5
66 for id := WindowID(1); id <= 4; id++ {
67 w.AddWindow(id)
68 }
69 for _, id := range []WindowID{4, 3, 2} {
70 w.FocusID(id)
71 w.Apply(ActionToggleWindowStash)
72 }
73 if got := []WindowID{w.Stash[0].ID, w.Stash[1].ID, w.Stash[2].ID}; !slices.Equal(got, []WindowID{4, 3, 2}) {
74 t.Fatalf("order %v", got)
75 }
76 if id, _ := w.Focused(); id != 2 || w.stashAt != 2 {
77 t.Fatalf("focused %d at %d", id, w.stashAt)
78 }
79 center := Rect{X: 10, Y: 8, W: 80, H: 64}
80 if p := placement(w, 2); p.Rect != center || p.Peek || p.Hidden {
81 t.Fatalf("selected %+v", p)
82 }
83 if p := placement(w, 3); !p.Peek || p.Rect != (Rect{X: 5 - 80, Y: 8, W: 80, H: 64}) {
84 t.Fatalf("left peek %+v", p)
85 }
86 if p := placement(w, 4); !p.Hidden {
87 t.Fatalf("far window %+v", p)
88 }
89 w.Apply(ActionFocusColumnRight)
90 if id, _ := w.Focused(); id != 2 {
91 t.Fatalf("moved past the end: %d", id)
92 }
93 w.Apply(ActionFocusColumnLeft)
94 if p := placement(w, 2); !p.Peek || p.Rect != (Rect{X: 95, Y: 8, W: 80, H: 64}) {
95 t.Fatalf("right peek %+v", p)
96 }
97 w.Apply(ActionFocusColumnLeft)
98 w.Apply(ActionFocusColumnLeft)
99 w.Apply(ActionFocusWindowUp)
100 if id, _ := w.Focused(); id != 4 {
101 t.Fatalf("focused %d, want the first", id)
102 }
103 // Column actions do nothing while the stash has the focus.
104 w.Apply(ActionMoveColumnLeft)
105 w.Apply(ActionCycleColumnWidth)
106 if w.Columns[0].Windows[0] != 1 || w.Columns[0].Cells != 0 {
107 t.Fatalf("column changed: %+v", w.Columns)
108 }
109 // A gap as wide as the margin leaves nothing to show: neighbors wait
110 // off screen.
111 w.stashGap = 10
112 if p := placement(w, 3); !p.Hidden || p.Peek {
113 t.Fatalf("no peek: %+v", p)
114 }
115}
116
117// Neighbors show only in a margin that is left: with an odd width the gap
118// can match the narrower side, and the wider one must not show a sliver.
119func TestStashNeighborNeedsAMargin(t *testing.T) {
120 w := &Workspace{}
121 w.SetOutput(1001, 600)
122 w.SetMaxColumns(2)
123 w.stashGap = 10
124 for id := WindowID(1); id <= 3; id++ {
125 w.AddWindow(id)
126 }
127 for _, id := range []WindowID{3, 2} {
128 w.FocusID(id)
129 w.Apply(ActionToggleWindowStash)
130 }
131 w.FocusID(3)
132 if p := placement(w, 3); p.Rect.X != 100 || p.Rect.W != 800 {
133 t.Fatalf("setup: selected at %+v, want X 100 W 800", p.Rect)
134 }
135 for _, id := range []WindowID{2} {
136 if p := placement(w, id); !p.Hidden || p.Peek {
137 t.Fatalf("window %d shows as a sliver where no margin is left: %+v", id, p)
138 }
139 }
140}
141
142// Hiding the stash is the way out: tiles get the focus, a new stashed
143// window shows it again, and the native floats stay as they are.
144func TestToggleStashVisible(t *testing.T) {
145 w := workspace()
146 w.Apply(ActionToggleStashVisible)
147 w.AddWindow(1)
148 w.AddWindow(2)
149 w.AddWindow(3)
150 w.Apply(ActionToggleWindowStash)
151 w.FocusID(2)
152 w.Apply(ActionToggleWindowStash)
153 w.Apply(ActionFocusColumnLeft)
154 w.Apply(ActionToggleStashVisible)
155 if id, _ := w.Focused(); id != 1 || !w.stashHidden {
156 t.Fatalf("focused %d hidden %v", id, w.stashHidden)
157 }
158 for _, id := range []WindowID{2, 3} {
159 if p := placement(w, id); !p.Hidden || p.Focused {
160 t.Fatalf("hidden %+v", p)
161 }
162 }
163 if w.FocusID(2) {
164 t.Fatal("focused hidden stash window")
165 }
166 // Shown from a tile: the stash takes the focus on its selected window.
167 w.Apply(ActionToggleStashVisible)
168 if id, _ := w.Focused(); id != 3 || w.stashHidden {
169 t.Fatalf("restored focus %d", id)
170 }
171 w.Apply(ActionToggleStashVisible)
172 w.FocusID(1)
173 w.Apply(ActionToggleWindowStash)
174 if id, _ := w.Focused(); id != 1 || w.stashHidden || w.stashAt != 2 {
175 t.Fatalf("new stashed window: focused %d hidden %v", id, w.stashHidden)
176 }
177 // A native dialog stays when the stash hides.
178 w.AddFloating(9, 10, 10)
179 w.Apply(ActionToggleStashVisible)
180 if p := placement(w, 9); p.Hidden || !w.stashHidden {
181 t.Fatalf("dialog hidden with the stash: %+v", p)
182 }
183 if id, _ := w.Focused(); id != 9 {
184 t.Fatalf("focused %d, want the dialog", id)
185 }
186}
187
188// Closing the selected stashed window selects its right neighbor, else its
189// left one, else gives the focus back to the tiles.
190func TestStashClose(t *testing.T) {
191 w := workspace()
192 for id := WindowID(1); id <= 4; id++ {
193 w.AddWindow(id)
194 }
195 for _, id := range []WindowID{4, 3, 2} {
196 w.FocusID(id)
197 w.Apply(ActionToggleWindowStash)
198 }
199 w.FocusID(3) // the middle one
200 w.RemoveWindow(3)
201 if id, _ := w.Focused(); id != 2 {
202 t.Fatalf("after middle: %d", id)
203 }
204 w.RemoveWindow(2)
205 if id, _ := w.Focused(); id != 4 {
206 t.Fatalf("after last: %d", id)
207 }
208 w.RemoveWindow(4)
209 if id, _ := w.Focused(); id != 1 || len(w.Stash) != 0 || w.stashFocus {
210 t.Fatalf("after all: %d", id)
211 }
212}
213
214// A fullscreen window is exclusive: a new window waits hidden without
215// moving the view, in scroll and fixed overflow, and shows once it leaves.
216func TestExclusiveFullscreenNewWindow(t *testing.T) {
217 for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
218 w := workspace()
219 w.Overflow = overflow
220 w.AddWindow(1)
221 w.SetFullscreen(1, true)
222 w.AddWindow(2)
223 for _, p := range w.Layout() {
224 if p.ID == 1 && (!p.Fullscreen || !p.Focused || p.Hidden) || p.ID == 2 && !p.Hidden {
225 t.Fatalf("overflow %v: %+v", overflow, p)
226 }
227 }
228 if w.ToggleStashVisible(); w.stashHidden {
229 t.Fatal("floats toggled under fullscreen")
230 }
231 w.SetFullscreen(1, false)
232 for _, p := range w.Layout() {
233 if p.ID == 2 && p.Hidden {
234 t.Fatalf("overflow %v: new window still hidden", overflow)
235 }
236 }
237 }
238}
239
240// A window mapped under a covering fullscreen float with no column waits
241// hidden as the first column.
242func TestFullscreenFloatNewWindowNoColumns(t *testing.T) {
243 w := workspace()
244 w.AddFloating(1, 300, 200)
245 w.SetFullscreen(1, true)
246 w.AddWindow(2)
247 if len(w.Columns) != 1 || w.Columns[0].Windows[0] != 2 {
248 t.Fatalf("columns %+v", w.Columns)
249 }
250 if id, _ := w.Focused(); id != 1 {
251 t.Fatalf("focus %d", id)
252 }
253}
254
255// Under a covering fullscreen float, or any fixed-overflow fullscreen,
256// binds act on the window on screen, never on a column it hides.
257func TestFullscreenPinsFocus(t *testing.T) {
258 w := workspace()
259 w.AddWindow(1)
260 w.AddWindow(2)
261 w.AddFloating(3, 20, 10)
262 w.SetFullscreen(3, true)
263 w.focusCover()
264 w.Apply(ActionMoveColumnLeft)
265 w.Apply(ActionCycleColumnWidth)
266 if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Cells != 0 {
267 t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)
268 }
269 if e := w.Apply(ActionCloseWindow); e.Close != 3 {
270 t.Fatalf("close %d, want the fullscreen window", e.Close)
271 }
272}
273
274// A focus move from a covering fullscreen window leaves fullscreen for the
275// window on that side; with none there, fullscreen stays.
276func TestFocusMoveLeavesFullscreen(t *testing.T) {
277 w := workspace()
278 w.AddWindow(1)
279 w.AddWindow(2)
280 w.AddFloating(3, 20, 10)
281 w.SetFullscreen(3, true)
282 w.FocusColumn(-1)
283 if id, _ := w.Focused(); id == 3 || w.fullscreen != 0 {
284 t.Fatalf("float: focused %d fullscreen %d", id, w.fullscreen)
285 }
286 f := workspace()
287 f.Overflow = OverflowFixed
288 f.AddWindow(1)
289 f.AddWindow(2)
290 f.SetFullscreen(2, true)
291 if f.FocusColumn(1); f.fullscreen != 2 || f.Focus != 1 {
292 t.Fatalf("fixed edge: focus %d fullscreen %d", f.Focus, f.fullscreen)
293 }
294 if f.FocusColumn(-1); f.fullscreen != 0 || f.Focus != 0 {
295 t.Fatalf("fixed: focus %d fullscreen %d", f.Focus, f.fullscreen)
296 }
297 // Side by side: nothing above, so up keeps fullscreen and reports
298 // the edge (the monitor goes to the workspace above).
299 f.Focus = 1
300 f.SetFullscreen(2, true)
301 if f.FocusWindow(-1) || f.fullscreen != 2 || f.Focus != 1 {
302 t.Fatalf("fixed up: focus %d fullscreen %d", f.Focus, f.fullscreen)
303 }
304 // Stacked: up reaches the window above in the column.
305 f.SetFullscreen(2, false)
306 f.stackWindow(2, 1)
307 f.FocusID(2)
308 f.SetFullscreen(2, true)
309 if !f.FocusWindow(-1) || f.fullscreen != 0 {
310 t.Fatalf("stacked up: fullscreen %d", f.fullscreen)
311 }
312 if id, _ := f.Focused(); id != 1 {
313 t.Fatalf("stacked up: focused %d", id)
314 }
315 // Scroll overflow: moving off a fullscreen column still works; it no
316 // longer covers.
317 s := workspace()
318 s.AddWindow(1)
319 s.AddWindow(2)
320 s.SetFullscreen(2, true)
321 if s.FocusColumn(-1); s.Focus != 0 || s.cover() != 0 {
322 t.Fatalf("scroll: focus %d cover %d", s.Focus, s.cover())
323 }
324}
325
326// A fullscreen window does not float until it leaves fullscreen.
327func TestToggleWindowStashIgnoresFullscreen(t *testing.T) {
328 w := workspace()
329 w.AddWindow(1)
330 w.SetFullscreen(1, true)
331 w.Apply(ActionToggleWindowStash)
332 if len(w.Stash) != 0 || w.fullscreen != 1 {
333 t.Fatalf("fullscreen window floated: %+v", w)
334 }
335 w.SetFullscreen(1, false)
336 w.Apply(ActionToggleWindowStash)
337 if len(w.Stash) != 1 {
338 t.Fatal("window did not float after leaving fullscreen")
339 }
340}
341
342// A lone maximized column comes back maximized.
343func TestToggleWindowStashKeepsFullWidth(t *testing.T) {
344 w := workspace()
345 w.Overflow = OverflowFixed
346 w.AddWindow(1)
347 w.AddWindow(2)
348 w.Apply(ActionMaximizeColumn)
349 w.Apply(ActionToggleWindowStash)
350 w.Apply(ActionToggleWindowStash)
351 if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].FullWidth {
352 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
353 }
354}
355
356// A lone wide column (fixed overflow) comes back at its width in cells.
357func TestToggleWindowStashKeepsCells(t *testing.T) {
358 w := workspace()
359 w.Overflow = OverflowFixed
360 w.SetMaxColumns(3)
361 w.AddWindow(1)
362 w.AddWindow(2)
363 w.Apply(ActionCycleColumnWidth)
364 if w.Columns[w.Focus].Cells != 2 {
365 t.Fatal("column not wider", w.Columns)
366 }
367 w.Apply(ActionToggleWindowStash)
368 w.Apply(ActionToggleWindowStash)
369 if id, _ := w.Focused(); id != 2 || w.Columns[w.Focus].Cells != 2 {
370 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
371 }
372}
373
374// A client fullscreen request of a hidden stashed window leaves it hidden
375// on its workspace, even in fixed overflow (which moves fullscreen windows
376// out).
377func TestHiddenFloatFullscreenRequest(t *testing.T) {
378 m := newMonitor("", "")
379 m.SetOutput(100, 80)
380 m.SetOverflow(OverflowFixed)
381 m.AddWindow(1)
382 m.AddWindow(2)
383 w := m.Current()
384 w.ToggleWindowStash()
385 w.ToggleStashVisible()
386 m.SetFullscreen(2, true)
387 if m.Current() != w || w.stashIndex(2) < 0 || !w.stashHidden || w.fullscreen != 0 {
388 t.Fatalf("hidden stash left or showed: %+v", w)
389 }
390 w.ToggleStashVisible()
391 if w.fullscreen != 2 {
392 t.Fatalf("fullscreen lost on show: %d", w.fullscreen)
393 }
394}
395
396// A fullscreen stashed window (a game in scanout) keeps the stash shown:
397// hiding it would drop scanout and VRR.
398func TestToggleStashVisibleFullscreen(t *testing.T) {
399 w := workspace()
400 w.AddWindow(1)
401 w.AddWindow(2)
402 w.ToggleWindowStash()
403 w.SetFullscreen(2, true)
404 w.Apply(ActionToggleStashVisible)
405 if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 || w.stashHidden {
406 t.Fatalf("fullscreen stash hidden: focused %d fullscreen %d", id, w.fullscreen)
407 }
408}
409
410func TestFloatingSceneDimAndConfigure(t *testing.T) {
411 cfg := ports.Config{}
412 cfg.Keyboard.CmdKey = "super"
413 cfg.Layout.MaxColumns = 2
414 cfg.Floating.Dim = 0.3
415 cfg.Border.Width = 2
416 commands := make(chan ports.ClientCommand, 128)
417 scenes := make(chan []ports.Scene, 1)
418 c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
419 if err != nil {
420 t.Fatal(err)
421 }
422 w := c.cur().mon.Current()
423 c.cur().mon.SetOutput(100, 80)
424 w.AddWindow(1)
425 ctx := context.Background()
426 check := func(want float64) {
427 t.Helper()
428 if err := c.publish(ctx); err != nil {
429 t.Fatal(err)
430 }
431 if s := (<-scenes)[0]; s.Dim != want {
432 t.Fatalf("dim %v want %v", s.Dim, want)
433 }
434 }
435 check(0)
436 w.ToggleWindowStash()
437 check(0.3)
438 found := false
439 for len(commands) > 0 {
440 if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 1 {
441 if v.Floating {
442 found = true
443 if v.Width != 76 || v.Height != 60 {
444 t.Fatalf("imposed configure %+v", v)
445 }
446 }
447 }
448 }
449 if !found {
450 t.Fatal("missing imposed configure")
451 }
452 w.AddFloating(2, 20, 10)
453 check(0.3)
454 found = false
455 for len(commands) > 0 {
456 if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 2 {
457 found = true
458 if v.Width != 0 || v.Height != 0 {
459 t.Fatalf("native configure %+v", v)
460 }
461 }
462 }
463 if !found {
464 t.Fatal("missing native configure")
465 }
466 // Hiding the stash leaves the dialog, and its veil.
467 w.ToggleStashVisible()
468 check(0.3)
469 w.RemoveWindow(2)
470 check(0)
471 w.ToggleStashVisible()
472 check(0.3)
473 w.AddFloating(2, 20, 10)
474 w.SetFullscreen(1, true)
475 check(0)
476 w.SetFullscreen(1, false)
477 w.ToggleWindowStash()
478 w.SetFullscreen(1, true)
479 check(0)
480 // A dialog of a fullscreen tile waits hidden: no veil over the game.
481 w.SetFullscreen(1, false)
482 w.RemoveWindow(1)
483 w.RemoveWindow(2)
484 w.AddWindow(3)
485 w.SetFullscreen(3, true)
486 check(0)
487 w.AddFloating(4, 20, 10)
488 check(0)
489 w.SetFullscreen(3, false)
490 check(0.3)
491}
492
493// A stashed float's own fullscreen, asked while the stash was hidden,
494// comes back with it when it is activated: it replaces the cover.
495func TestActivateHiddenFloat(t *testing.T) {
496 w := workspace()
497 w.AddWindow(1)
498 w.AddWindow(2)
499 w.ToggleWindowStash()
500 w.ToggleStashVisible()
501 w.FocusID(1)
502 w.SetFullscreen(2, true)
503 w.SetFullscreen(1, true)
504 w.Activate(2)
505 if id, _ := w.Focused(); id != 2 || w.stashHidden || w.fullscreen != 2 {
506 t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen)
507 }
508}