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 {"dialog of cover", func(w *Workspace, cover WindowID) WindowID {
207 w.AddDialog(9, cover, 20, 10)
208 w.FocusID(cover)
209 return 9
210 }, 9, false},
211 {"modal chain of cover", func(w *Workspace, cover WindowID) WindowID {
212 w.AddDialog(9, cover, 20, 10)
213 w.SetModal(9, true)
214 w.AddDialog(8, 9, 10, 5)
215 w.SetModal(8, true)
216 w.FocusID(cover)
217 return cover
218 }, 8, false},
219 {"stashed from a focused cover dialog", func(w *Workspace, cover WindowID) WindowID {
220 w.ToggleStashVisible()
221 w.AddDialog(9, cover, 20, 10)
222 w.FocusID(9)
223 return 2
224 }, 2, true},
225 }
226 for _, cv := range covers {
227 for _, tc := range cases {
228 t.Run(cv.name+"/"+tc.name, func(t *testing.T) {
229 _, w, cover := stashCoverCore(t, cv.overflow, cv.float)
230 id := tc.setup(w, cover)
231 // A dialog of the hidden stash waits hidden under the cover.
232 if i := slices.IndexFunc(w.Floats, func(f Float) bool { return f.over == 2 }); i >= 0 && !w.stashOverCover() {
233 if p := previewOf(t, w.Layout(), w.Floats[i].ID); !p.Hidden {
234 t.Fatalf("dialog of the hidden stash shown before activation: %+v", p)
235 }
236 }
237 view := w.View
238 w.Activate(id)
239 if got, _ := w.Focused(); got != tc.focused || w.cover() != cover || w.left != 0 || w.stashOverCover() != tc.over {
240 t.Fatalf("focused %d (want %d) cover %d left %d over %v", got, tc.focused, w.cover(), w.left, w.stashOverCover())
241 }
242 ps := w.Layout()
243 if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
244 t.Fatalf("cover %+v", p)
245 }
246 f := slices.IndexFunc(ps, isID(tc.focused))
247 if f < slices.IndexFunc(ps, isID(cover)) || ps[f].Hidden {
248 t.Fatalf("focused window not over the cover: %+v", ps)
249 }
250 if tc.over && f < slices.IndexFunc(ps, isID(2)) {
251 t.Fatalf("focused window under the stash: %+v", ps)
252 }
253 if !cv.float && w.View != view {
254 t.Fatalf("view %d, was %d", w.View, view)
255 }
256 })
257 }
258 }
259}
260
261// After an activation shows the stash over the cover, hiding the stash
262// or activating the cover returns to it; a dialog of a stashed window,
263// modal or mapping meanwhile, shows with the focus and closing it
264// returns to the stash over the cover.
265func TestActivateOverCoverReturns(t *testing.T) {
266 for _, cv := range []struct {
267 name string
268 overflow Overflow
269 float bool
270 }{
271 {"fixed tile", OverflowFixed, false},
272 {"scroll tile", OverflowScroll, false},
273 {"fixed float", OverflowFixed, true},
274 {"scroll float", OverflowScroll, true},
275 } {
276 t.Run(cv.name, func(t *testing.T) {
277 _, w, cover := stashCoverCore(t, cv.overflow, cv.float)
278 w.Activate(2)
279 w.ToggleStashVisible()
280 if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
281 t.Fatalf("hide: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
282 }
283 w.Activate(2)
284 w.Activate(cover)
285 if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
286 t.Fatalf("activate cover: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
287 }
288 w.AddDialog(7, 2, 20, 10)
289 w.SetModal(7, true)
290 w.FocusID(cover)
291 w.Activate(2)
292 w.RemoveWindow(7)
293 if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
294 t.Fatalf("modal closed: focused %d over %v", id, w.stashOverCover())
295 }
296 w.AddDialog(9, 2, 20, 10)
297 w.AddDialog(8, 9, 10, 5)
298 if id, _ := w.Focused(); id != 8 || previewOf(t, w.Layout(), 8).Hidden {
299 t.Fatalf("mapped chain: focused %d", id)
300 }
301 w.RemoveWindow(8)
302 w.RemoveWindow(9)
303 if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
304 t.Fatalf("closed: focused %d over %v", id, w.stashOverCover())
305 }
306 // The cover closes: the stash stays shown and focused, the
307 // override ends with it.
308 w.RemoveWindow(cover)
309 if id, _ := w.Focused(); id != 2 || w.stashOver != 0 || !w.stashShown() {
310 t.Fatalf("cover closed: focused %d over %d shown %v", id, w.stashOver, w.stashShown())
311 }
312 })
313 }
314}
315
316// Activating a dialog that cannot show over the cover leaves fullscreen
317// as any other window does; a dialog of a stashed window whose own
318// fullscreen waited while hidden brings that window fullscreen, as
319// activating the window itself does.
320func TestActivateDialogLeavesCover(t *testing.T) {
321 t.Run("dialog of a plain float", func(t *testing.T) {
322 _, w, cover := stashCoverCore(t, OverflowFixed, false)
323 w.AddFloating(5, 20, 10)
324 w.AddDialog(9, 5, 10, 5)
325 w.FocusID(cover)
326 w.Activate(9)
327 if id, _ := w.Focused(); id != 9 || w.fullscreen != 0 || w.left != cover {
328 t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
329 }
330 })
331 t.Run("dialog of another tile", func(t *testing.T) {
332 _, w, cover := stashCoverCore(t, OverflowFixed, true)
333 w.AddDialog(9, 1, 10, 5)
334 w.FocusID(cover)
335 w.Activate(9)
336 if id, _ := w.Focused(); id != 9 || w.fullscreen != 0 || w.left != cover {
337 t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
338 }
339 })
340 t.Run("dialog of a stashed window with a pending fullscreen", func(t *testing.T) {
341 _, w, cover := stashCoverCore(t, OverflowFixed, true)
342 w.SetFullscreen(2, true)
343 w.AddDialog(9, 2, 10, 5)
344 w.FocusID(cover)
345 w.Activate(9)
346 if id, _ := w.Focused(); id != 9 || w.fullscreen != 2 || w.stashHidden || w.left != cover {
347 t.Fatalf("focused %d fullscreen %d hidden %v left %d", id, w.fullscreen, w.stashHidden, w.left)
348 }
349 if p := previewOf(t, w.Layout(), 9); p.Hidden {
350 t.Fatalf("dialog hidden: %+v", p)
351 }
352 })
353}
354
355// A dialog parent loop under a cover ends: neither dialog shows over it.
356func TestOverCoverDialogLoop(t *testing.T) {
357 _, w, _ := stashCoverCore(t, OverflowFixed, true)
358 w.AddFloating(8, 10, 5)
359 w.AddFloating(9, 10, 5)
360 w.SetDialogParent(8, 9)
361 w.SetDialogParent(9, 8)
362 ps := w.Layout()
363 if !previewOf(t, ps, 8).Hidden || !previewOf(t, ps, 9).Hidden {
364 t.Fatalf("looped dialogs shown: %+v", ps)
365 }
366}
367
368// Through core: activating a stashed window keeps a fullscreen game's
369// configure fullscreen at the output size.
370func TestCoreActivateStashKeepsGameConfigure(t *testing.T) {
371 cfg := ports.Config{}
372 cfg.Keyboard.CmdKey = "super"
373 cfg.Layout.MaxColumns = 2
374 commands := make(chan ports.ClientCommand, 256)
375 c, err := New(cfg, Channels{Commands: commands, Scenes: make(chan []ports.Scene, 1)}, Options{})
376 if err != nil {
377 t.Fatal(err)
378 }
379 c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
380 m := c.screens[0].mon
381 m.AddWindow(2)
382 w := m.Current()
383 w.ToggleWindowStash()
384 w.ToggleStashVisible()
385 m.AddFloating(4, 100, 80)
386 m.SetFullscreen(4, true)
387 ctx := context.Background()
388 if err := c.activate(ctx, 2); err != nil {
389 t.Fatal(err)
390 }
391 if err := c.publish(ctx); err != nil {
392 t.Fatal(err)
393 }
394 var last *ports.ConfigureWindow
395 for len(commands) > 0 {
396 if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 4 {
397 last = &v
398 }
399 }
400 if last == nil || !last.Fullscreen || last.Width != 100 || last.Height != 80 {
401 t.Fatalf("game configure %+v", last)
402 }
403}
404
405func isID(id WindowID) func(Placement) bool {
406 return func(p Placement) bool { return p.ID == id }
407}