1package core
2
3import (
4 "context"
5 "slices"
6 "testing"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11// stashCoverCore has two screens; the first holds tile 1 and the hidden
12// stash [2 3], 3 selected, under a covering fullscreen window: tile 1, or
13// float 4 with float set.
14func stashCoverCore(t *testing.T, overflow Overflow, float bool) (*Core, *Workspace, WindowID) {
15 t.Helper()
16 cfg := ports.Config{}
17 cfg.Keyboard.CmdKey = "super"
18 cfg.Layout.Overflow = string(overflow)
19 cfg.Layout.MaxColumns = 2
20 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
21 if err != nil {
22 t.Fatal(err)
23 }
24 c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
25 c.addScreen(ports.OutputInfo{Name: "B", Width: 100, Height: 80})
26 m := c.screens[0].mon
27 for id := WindowID(1); id <= 3; id++ {
28 m.AddWindow(id)
29 }
30 w := m.Current()
31 for _, id := range []WindowID{2, 3} {
32 w.FocusID(id)
33 w.ToggleWindowStash()
34 }
35 c.applyAction(ActionToggleStashVisible) // hidden
36 cover := WindowID(1)
37 if float {
38 cover = 4
39 m.AddFloating(4, 100, 80)
40 m.SetFullscreen(4, true)
41 } else {
42 c.applyAction(ActionToggleFullscreen)
43 }
44 if w.cover() != cover || c.focusScreen != 0 {
45 t.Fatalf("setup: cover %d", w.cover())
46 }
47 return c, w, cover
48}
49
50// toggle-stash-visible is a user override: under any covering fullscreen
51// window it shows the stash on top, focused, the window fullscreen
52// behind. Moves stay in the stash, at its edges too. Toggling again
53// hides it, back on the fullscreen window.
54func TestStashShowsOverCover(t *testing.T) {
55 for _, tc := range []struct {
56 name string
57 overflow Overflow
58 float bool
59 }{
60 {"fixed tile", OverflowFixed, false},
61 {"scroll tile", OverflowScroll, false},
62 {"fixed float", OverflowFixed, true},
63 {"scroll float", OverflowScroll, true},
64 } {
65 t.Run(tc.name, func(t *testing.T) {
66 c, w, cover := stashCoverCore(t, tc.overflow, tc.float)
67 c.applyAction(ActionToggleStashVisible)
68 ps := w.Layout()
69 if id, _ := w.Focused(); id != 3 || w.cover() != cover {
70 t.Fatalf("focused %d cover %d", id, w.cover())
71 }
72 if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
73 t.Fatalf("cover %+v", p)
74 }
75 if p := previewOf(t, ps, 3); p.Hidden || ps[len(ps)-1].ID != 3 && ps[len(ps)-2].ID != 3 {
76 t.Fatalf("stash not on top: %+v", ps)
77 }
78 c.applyAction(ActionFocusColumnLeft)
79 if id, _ := w.Focused(); id != 2 || w.cover() != cover {
80 t.Fatalf("left: focused %d cover %d", id, w.cover())
81 }
82 c.applyAction(ActionFocusColumnRight)
83 c.applyAction(ActionFocusColumnRight) // stash edge: stays, like a shown stash
84 if id, _ := w.Focused(); id != 3 || c.focusScreen != 0 {
85 t.Fatalf("edge: focused %d screen %d", id, c.focusScreen)
86 }
87 c.applyAction(ActionToggleStashVisible)
88 if id, _ := w.Focused(); id != cover || w.cover() != cover || !previewOf(t, w.Layout(), 3).Hidden {
89 t.Fatalf("hide: focused %d cover %d", id, w.cover())
90 }
91 })
92 }
93}
94
95// Only the user shows the stash over the cover: a window mapping
96// meanwhile is not captured, a dialog of the cover shows above the
97// stash, and the override ends with its cover.
98func TestStashOverCoverEdges(t *testing.T) {
99 c, w, cover := stashCoverCore(t, OverflowFixed, false)
100 m := c.screens[0].mon
101 m.SetStashCapture(true)
102 c.applyAction(ActionToggleStashVisible)
103 m.AddWindow(5)
104 if w.stashIndex(5) >= 0 || !previewOf(t, w.Layout(), 5).Hidden {
105 t.Fatalf("new window shown: %+v", w.Layout())
106 }
107 w.AddDialog(6, cover, 20, 20)
108 if ps := w.Layout(); ps[len(ps)-1].ID != 6 {
109 t.Fatalf("dialog under the stash: %+v", ps)
110 }
111 if id, _ := w.Focused(); id != 6 {
112 t.Fatalf("dialog focus %d", id)
113 }
114 c.applyAction(ActionFocusColumnLeft) // back to the stash
115 if id, _ := w.Focused(); id != 3 || !w.stashOverCover() {
116 t.Fatalf("from dialog: focused %d", id)
117 }
118 w.RemoveWindow(6)
119 // The cover closes: the stash it showed over is a plain shown stash;
120 // a later fullscreen window is never covered by it.
121 w.RemoveWindow(cover)
122 if w.stashOver != 0 {
123 t.Fatal("override outlived its cover")
124 }
125 w.FocusID(5)
126 w.ToggleFullscreen()
127 if p := previewOf(t, w.Layout(), 3); !p.Hidden {
128 t.Fatalf("stash over a new cover: %+v", p)
129 }
130}
131
132// Another window's fullscreen request ends the override: the old cover
133// asking again never brings the stash back over it.
134func TestStashOverCoverOtherRequest(t *testing.T) {
135 c, w, cover := stashCoverCore(t, OverflowScroll, false)
136 m := c.screens[0].mon
137 m.AddWindow(5)
138 c.applyAction(ActionToggleStashVisible)
139 m.SetFullscreen(5, true)
140 m.SetFullscreen(cover, true)
141 if w.cover() != cover || w.stashOverCover() {
142 t.Fatalf("cover %d, override revived", w.cover())
143 }
144}
145
146// Acting on a stashed window over the cover leaves the cover for it:
147// toggle-window-stash returns it to a visible column, toggle-fullscreen
148// makes it the fullscreen window.
149func TestStashOverCoverLeaves(t *testing.T) {
150 c, w, _ := stashCoverCore(t, OverflowFixed, false)
151 c.applyAction(ActionToggleStashVisible)
152 c.applyAction(ActionToggleWindowStash)
153 if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 || previewOf(t, w.Layout(), 3).Hidden {
154 t.Fatalf("unstash: focused %d fullscreen %d", id, w.fullscreen)
155 }
156 c, w, _ = stashCoverCore(t, OverflowFixed, false)
157 c.applyAction(ActionToggleStashVisible)
158 c.applyAction(ActionToggleFullscreen)
159 if w.cover() != 3 || w.left != 1 {
160 t.Fatalf("fullscreen: cover %d left %d", w.cover(), w.left)
161 }
162}
163
164// Activating a window over a covering fullscreen window (xdg-activation,
165// taskbar) never ends that fullscreen when the window can show over it:
166// a stashed window shows the stash over the cover, and dialogs of the
167// cover or of stashed windows, and their dialogs, show above. A game
168// told it left fullscreen (configure 0x0) quits.
169func TestActivateOverCover(t *testing.T) {
170 covers := []struct {
171 name string
172 overflow Overflow
173 float bool
174 }{
175 {"fixed tile", OverflowFixed, false},
176 {"scroll tile", OverflowScroll, false},
177 {"fixed float", OverflowFixed, true},
178 {"scroll float", OverflowScroll, true},
179 }
180 cases := []struct {
181 name string
182 // setup prepares windows and returns the window to activate.
183 setup func(w *Workspace, cover WindowID) WindowID
184 // focused is the window focused after the activation.
185 focused WindowID
186 // over reports whether the stash shows over the cover then.
187 over bool
188 }{
189 {"stashed", func(*Workspace, WindowID) WindowID { return 2 }, 2, true},
190 {"modal of stashed", func(w *Workspace, _ WindowID) WindowID {
191 w.AddDialog(9, 2, 20, 10)
192 w.SetModal(9, true)
193 return 2
194 }, 9, true},
195 {"modal chain of stashed", func(w *Workspace, _ WindowID) WindowID {
196 w.AddDialog(9, 2, 20, 10)
197 w.SetModal(9, true)
198 w.AddDialog(8, 9, 10, 5)
199 w.SetModal(8, true)
200 return 2
201 }, 8, true},
202 {"dialog of stashed", func(w *Workspace, _ WindowID) WindowID {
203 w.AddDialog(9, 2, 20, 10)
204 return 9
205 }, 9, true},
206 // A dialog filling the usable area is a covering float: it stays
207 // over the stash all the same.
208 {"full-size dialog of stashed", func(w *Workspace, _ WindowID) WindowID {
209 w.AddDialog(9, 2, 100, 80)
210 return 9
211 }, 9, true},
212 {"dialog of cover", func(w *Workspace, cover WindowID) WindowID {
213 w.AddDialog(9, cover, 20, 10)
214 w.FocusID(cover)
215 return 9
216 }, 9, false},
217 {"modal chain of cover", func(w *Workspace, cover WindowID) WindowID {
218 w.AddDialog(9, cover, 20, 10)
219 w.SetModal(9, true)
220 w.AddDialog(8, 9, 10, 5)
221 w.SetModal(8, true)
222 w.FocusID(cover)
223 return cover
224 }, 8, false},
225 {"stashed from a focused cover dialog", func(w *Workspace, cover WindowID) WindowID {
226 w.ToggleStashVisible()
227 w.AddDialog(9, cover, 20, 10)
228 w.FocusID(9)
229 return 2
230 }, 2, true},
231 }
232 for _, cv := range covers {
233 for _, tc := range cases {
234 t.Run(cv.name+"/"+tc.name, func(t *testing.T) {
235 _, w, cover := stashCoverCore(t, cv.overflow, cv.float)
236 id := tc.setup(w, cover)
237 // A dialog of the hidden stash waits hidden under the cover.
238 if i := slices.IndexFunc(w.Floats, func(f Float) bool { return f.over == 2 }); i >= 0 && !w.stashOverCover() {
239 if p := previewOf(t, w.Layout(), w.Floats[i].ID); !p.Hidden {
240 t.Fatalf("dialog of the hidden stash shown before activation: %+v", p)
241 }
242 }
243 view := w.View
244 w.Activate(id)
245 if got, _ := w.Focused(); got != tc.focused || w.cover() != cover || w.left != 0 || w.stashOverCover() != tc.over {
246 t.Fatalf("focused %d (want %d) cover %d left %d over %v", got, tc.focused, w.cover(), w.left, w.stashOverCover())
247 }
248 ps := w.Layout()
249 if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
250 t.Fatalf("cover %+v", p)
251 }
252 f := slices.IndexFunc(ps, isID(tc.focused))
253 if f < slices.IndexFunc(ps, isID(cover)) || ps[f].Hidden {
254 t.Fatalf("focused window not over the cover: %+v", ps)
255 }
256 if tc.over && f < slices.IndexFunc(ps, isID(2)) {
257 t.Fatalf("focused window under the stash: %+v", ps)
258 }
259 if !cv.float && w.View != view {
260 t.Fatalf("view %d, was %d", w.View, view)
261 }
262 })
263 }
264 }
265}
266
267// After an activation shows the stash over the cover, hiding the stash
268// or activating the cover returns to it; a dialog of a stashed window,
269// modal or mapping meanwhile, shows with the focus and closing it
270// returns to the stash over the cover.
271func TestActivateOverCoverReturns(t *testing.T) {
272 for _, cv := range []struct {
273 name string
274 overflow Overflow
275 float bool
276 }{
277 {"fixed tile", OverflowFixed, false},
278 {"scroll tile", OverflowScroll, false},
279 {"fixed float", OverflowFixed, true},
280 {"scroll float", OverflowScroll, true},
281 } {
282 t.Run(cv.name, func(t *testing.T) {
283 _, w, cover := stashCoverCore(t, cv.overflow, cv.float)
284 w.Activate(2)
285 w.ToggleStashVisible()
286 if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
287 t.Fatalf("hide: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
288 }
289 w.Activate(2)
290 w.Activate(cover)
291 if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
292 t.Fatalf("activate cover: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
293 }
294 w.AddDialog(7, 2, 20, 10)
295 w.SetModal(7, true)
296 w.FocusID(cover)
297 w.Activate(2)
298 w.RemoveWindow(7)
299 if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
300 t.Fatalf("modal closed: focused %d over %v", id, w.stashOverCover())
301 }
302 w.AddDialog(9, 2, 20, 10)
303 w.AddDialog(8, 9, 10, 5)
304 if id, _ := w.Focused(); id != 8 || previewOf(t, w.Layout(), 8).Hidden {
305 t.Fatalf("mapped chain: focused %d", id)
306 }
307 w.RemoveWindow(8)
308 w.RemoveWindow(9)
309 if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
310 t.Fatalf("closed: focused %d over %v", id, w.stashOverCover())
311 }
312 // The cover closes: the stash stays shown and focused, the
313 // override ends with it.
314 w.RemoveWindow(cover)
315 if id, _ := w.Focused(); id != 2 || w.stashOver != 0 || !w.stashShown() {
316 t.Fatalf("cover closed: focused %d over %d shown %v", id, w.stashOver, w.stashShown())
317 }
318 })
319 }
320}
321
322// Activating a dialog that cannot show over the cover leaves fullscreen
323// as any other window does; a dialog of a stashed window whose own
324// fullscreen waited while hidden brings that window fullscreen, as
325// activating the window itself does.
326func TestActivateDialogLeavesCover(t *testing.T) {
327 t.Run("dialog of a plain float", func(t *testing.T) {
328 _, w, cover := stashCoverCore(t, OverflowFixed, false)
329 w.AddFloating(5, 20, 10)
330 w.AddDialog(9, 5, 10, 5)
331 w.FocusID(cover)
332 w.Activate(9)
333 if id, _ := w.Focused(); id != 9 || w.fullscreen != 0 || w.left != cover {
334 t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
335 }
336 })
337 t.Run("dialog of another tile", func(t *testing.T) {
338 _, w, cover := stashCoverCore(t, OverflowFixed, true)
339 w.AddDialog(9, 1, 10, 5)
340 w.FocusID(cover)
341 w.Activate(9)
342 if id, _ := w.Focused(); id != 9 || w.fullscreen != 0 || w.left != cover {
343 t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
344 }
345 })
346 t.Run("dialog of a stashed window with a pending fullscreen", func(t *testing.T) {
347 _, w, cover := stashCoverCore(t, OverflowFixed, true)
348 w.SetFullscreen(2, true)
349 w.AddDialog(9, 2, 10, 5)
350 w.FocusID(cover)
351 w.Activate(9)
352 if id, _ := w.Focused(); id != 9 || w.fullscreen != 2 || w.stashHidden || w.left != cover {
353 t.Fatalf("focused %d fullscreen %d hidden %v left %d", id, w.fullscreen, w.stashHidden, w.left)
354 }
355 if p := previewOf(t, w.Layout(), 9); p.Hidden {
356 t.Fatalf("dialog hidden: %+v", p)
357 }
358 })
359}
360
361// A dialog parent loop under a cover ends: neither dialog shows over it.
362func TestOverCoverDialogLoop(t *testing.T) {
363 _, w, _ := stashCoverCore(t, OverflowFixed, true)
364 w.AddFloating(8, 10, 5)
365 w.AddFloating(9, 10, 5)
366 w.SetDialogParent(8, 9)
367 w.SetDialogParent(9, 8)
368 ps := w.Layout()
369 if !previewOf(t, ps, 8).Hidden || !previewOf(t, ps, 9).Hidden {
370 t.Fatalf("looped dialogs shown: %+v", ps)
371 }
372}
373
374// Through core: activating a stashed window keeps a fullscreen game's
375// configure fullscreen at the output size.
376func TestCoreActivateStashKeepsGameConfigure(t *testing.T) {
377 cfg := ports.Config{}
378 cfg.Keyboard.CmdKey = "super"
379 cfg.Layout.MaxColumns = 2
380 commands := make(chan ports.ClientCommand, 256)
381 c, err := New(cfg, Channels{Commands: commands, Scenes: make(chan []ports.Scene, 1)}, Options{})
382 if err != nil {
383 t.Fatal(err)
384 }
385 c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
386 m := c.screens[0].mon
387 m.AddWindow(2)
388 w := m.Current()
389 w.ToggleWindowStash()
390 w.ToggleStashVisible()
391 m.AddFloating(4, 100, 80)
392 m.SetFullscreen(4, true)
393 ctx := context.Background()
394 if err := c.activate(ctx, 2); err != nil {
395 t.Fatal(err)
396 }
397 if err := c.publish(ctx); err != nil {
398 t.Fatal(err)
399 }
400 var last *ports.ConfigureWindow
401 for len(commands) > 0 {
402 if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 4 {
403 last = &v
404 }
405 }
406 if last == nil || !last.Fullscreen || last.Width != 100 || last.Height != 80 {
407 t.Fatalf("game configure %+v", last)
408 }
409}
410
411func isID(id WindowID) func(Placement) bool {
412 return func(p Placement) bool { return p.ID == id }
413}