1package core_test
2
3import (
4 "context"
5 "slices"
6 "sync/atomic"
7 "testing"
8 "time"
9
10 "github.com/bnema/neferwl/internal/adapters/config"
11 "github.com/bnema/neferwl/internal/core"
12 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13 "github.com/bnema/neferwl/internal/ports"
14)
15
16type dragRig struct {
17 client chan ports.ClientEvent
18 input chan ports.InputEvent
19 output chan ports.OutputEvent
20 commands chan ports.ClientCommand
21 scenes chan []ports.Scene
22 last []ports.Scene
23 epoch *atomic.Value
24 // With a clock the test moves (animated rigs), frames carries the page
25 // flips and outputs lists the plugged ones: settle lands every spring
26 // before it reads the scene.
27 frames chan ports.OutputFrame
28 clock *stepClock
29 outputs []string
30 // lands counts the settles that found a spring running.
31 lands int
32}
33
34// startDrag runs core with Super as Cmd, no border, 10 px gaps, and
35// the given outputs. edit may change the config.
36func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
37 t.Helper()
38 return newDragRig(t, false, edit, outs...)
39}
40
41// startAnimatedDragRig is startDragRig with animations on and a clock the
42// test moves: settle moves it past every spring and sends the page flips,
43// so the scene it returns is the settled one, deterministically.
44func startAnimatedDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
45 t.Helper()
46 return newDragRig(t, true, func(c *ports.Config) {
47 c.Animations.On = true
48 if edit != nil {
49 edit(c)
50 }
51 }, outs...)
52}
53
54func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
55 t.Helper()
56 cfg := config.Defaults()
57 cfg.Border.Width = 0
58 cfg.Layout.Gaps = 10
59 cfg.Terminal.AutoOpen = "off"
60 if edit != nil {
61 edit(&cfg)
62 }
63 epoch := &atomic.Value{}
64 epoch.Store(ports.SecurityState{})
65 gate := portsmocks.NewMockSessionSecurity(t)
66 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }).Maybe()
67 // Unbuffered: a send returns once core took the event, so it handled
68 // every event sent before (settle relies on it).
69 r := &dragRig{
70 client: make(chan ports.ClientEvent), input: make(chan ports.InputEvent),
71 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
72 scenes: make(chan []ports.Scene, 1), epoch: epoch,
73 }
74 ch := core.Channels{Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}
75 opts := core.Options{Security: gate}
76 if animated {
77 r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
78 ch.Frames, opts.Clock = r.frames, r.clock.clock
79 // An on variant that never saw a spring would be the off variant again.
80 t.Cleanup(func() {
81 if r.lands == 0 {
82 t.Error("no spring ever ran: the animations-on variant checked nothing")
83 }
84 })
85 }
86 c, err := core.New(cfg, ch, opts)
87 if err != nil {
88 t.Fatal(err)
89 }
90 ctx, cancel := context.WithCancel(context.Background())
91 done := make(chan error, 1)
92 go func() { done <- c.Run(ctx) }()
93 t.Cleanup(func() { cancel(); <-done })
94 for _, o := range outs {
95 r.output <- ports.OutputAdded{Info: o}
96 r.outputs = append(r.outputs, o.Name)
97 r.settle(t)
98 }
99 return r
100}
101
102// in sends a raw input event; the gate is on, so it is wrapped.
103func (r *dragRig) in(ev ports.InputEvent) {
104 send(r.input, ports.InputEvent(ports.SecurityInput{State: r.epoch.Load().(ports.SecurityState), Event: ev}))
105}
106
107// send fails the test binary fast instead of hanging when core is stuck.
108func send[T any](ch chan T, v T) {
109 select {
110 case ch <- v:
111 case <-time.After(5 * time.Second):
112 panic("core stuck")
113 }
114}
115
116// barrier is a window no test maps: a resize of it changes nothing but
117// makes core publish.
118const barrier ports.WindowID = 1 << 30
119
120// settle returns the scene set once core handled every event sent so
121// far. The second barrier is taken only after the first one published. An
122// animated rig first moves its clock past every spring and flips each
123// output, so the set is the settled one.
124func (r *dragRig) settle(t *testing.T) []ports.Scene {
125 t.Helper()
126 if r.frames != nil {
127 if _, ok := r.clock.settle(t, r.frames, r.scenes, r.outputs...); ok {
128 r.lands++
129 }
130 }
131 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
132 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
133 select {
134 case s := <-r.scenes:
135 r.last = s
136 case <-time.After(5 * time.Second):
137 t.Fatal("no scene")
138 }
139 return r.last
140}
141
142// drain returns the commands sent since the last drain.
143func (r *dragRig) drain() []ports.ClientCommand {
144 var out []ports.ClientCommand
145 for {
146 select {
147 case v := <-r.commands:
148 if s, ok := v.(ports.SecurityCommand); ok {
149 v = s.Command
150 }
151 out = append(out, v)
152 default:
153 return out
154 }
155 }
156}
157
158func pointerSent(cmds []ports.ClientCommand) []ports.ClientCommand {
159 var out []ports.ClientCommand
160 for _, v := range cmds {
161 switch v.(type) {
162 case ports.PointerButtonTo, ports.PointerMotionTo, ports.ForwardKey:
163 out = append(out, v)
164 }
165 }
166 return out
167}
168
169func dragRect(t *testing.T, set []ports.Scene, output string, id ports.WindowID) ports.Rect {
170 t.Helper()
171 w, ok := windowIn(set, output, id)
172 if !ok {
173 t.Fatalf("window %d not on %s", id, output)
174 }
175 return w.Rect
176}
177
178func dragScene(set []ports.Scene, output string) ports.Scene {
179 for _, s := range set {
180 if s.Output == output {
181 return s
182 }
183 }
184 return ports.Scene{}
185}
186
187// columns lists the tiles left to right by x, then top to bottom.
188func columnsOf(set []ports.Scene, output string) [][]ports.WindowID {
189 s := dragScene(set, output)
190 var wins []ports.SceneWindow
191 for _, w := range s.Windows {
192 if !w.Hidden && !w.Floating && !w.Popup {
193 wins = append(wins, w)
194 }
195 }
196 slices.SortFunc(wins, func(a, b ports.SceneWindow) int {
197 if a.Rect.X != b.Rect.X {
198 return a.Rect.X - b.Rect.X
199 }
200 return a.Rect.Y - b.Rect.Y
201 })
202 var out [][]ports.WindowID
203 lastX := -1 << 30
204 for _, w := range wins {
205 if w.Rect.X != lastX {
206 out = append(out, nil)
207 lastX = w.Rect.X
208 }
209 out[len(out)-1] = append(out[len(out)-1], w.ID)
210 }
211 return out
212}
213
214var dragOut = ports.OutputInfo{Name: "A", Width: 1000, Height: 600}
215
216func (r *dragRig) moveTo(x, y float64, msec uint32) {
217 r.in(ports.PointerMotion{X: x, Y: y, Time: time.Duration(msec) * time.Millisecond})
218}
219
220func (r *dragRig) press(t *testing.T, button uint32, mods ports.Mods) {
221 t.Helper()
222 if mods != 0 {
223 r.in(ports.KeyEvent{Keysym: "Super_L", Mods: mods, Pressed: true})
224 }
225 r.in(ports.PointerButton{Button: button, Pressed: true})
226}
227
228func TestCmdDragMovesFloatWithoutClientEvents(t *testing.T) {
229 r := startDragRig(t, nil, dragOut)
230 r.client <- ports.WindowMapped{ID: 1}
231 r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
232 set := r.settle(t)
233 f := dragRect(t, set, "A", 2)
234 r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
235 r.settle(t)
236 r.drain()
237 r.press(t, 0x110, ports.ModSuper)
238 r.moveTo(float64(f.X+60), float64(f.Y+40), 2)
239 set = r.settle(t)
240 got := dragRect(t, set, "A", 2)
241 if got.X != f.X+50 || got.Y != f.Y+30 || got.W != f.W {
242 t.Fatalf("moved to %+v from %+v", got, f)
243 }
244 if w, _ := windowIn(set, "A", 2); !w.Floating {
245 t.Fatal("not floating")
246 }
247 r.in(ports.PointerButton{Button: 0x110})
248 r.in(ports.KeyEvent{Keysym: "Super_L"})
249 r.settle(t)
250 cmds := r.drain()
251 for _, v := range pointerSent(cmds) {
252 if _, ok := v.(ports.PointerButtonTo); ok {
253 t.Fatalf("client saw %#v", v)
254 }
255 if _, ok := v.(ports.PointerMotionTo); ok {
256 t.Fatalf("client saw %#v", v)
257 }
258 }
259 // It stays there: a free float now.
260 r.moveTo(float64(f.X+61), float64(f.Y+41), 3)
261 if got2 := dragRect(t, r.settle(t), "A", 2); got2 != got {
262 t.Fatalf("moved after release: %+v", got2)
263 }
264 // A plain press goes to the client.
265 r.drain()
266 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
267 r.settle(t)
268 found := false
269 for _, v := range r.drain() {
270 if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 && b.Pressed {
271 found = true
272 }
273 }
274 if !found {
275 t.Fatal("plain press not forwarded")
276 }
277}
278
279func TestDragEscapeRestoresFloat(t *testing.T) {
280 r := startDragRig(t, nil, dragOut)
281 r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
282 f := dragRect(t, r.settle(t), "A", 2)
283 r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
284 r.settle(t)
285 r.press(t, 0x111, ports.ModSuper)
286 r.moveTo(float64(f.X+110), float64(f.Y+60), 2)
287 if got := dragRect(t, r.settle(t), "A", 2); got.W != f.W+100 || got.H != f.H+50 || got.X != f.X {
288 t.Fatalf("resize: %+v from %+v", got, f)
289 }
290 r.drain()
291 r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
292 if got := dragRect(t, r.settle(t), "A", 2); got != f {
293 t.Fatalf("escape: %+v, want %+v", got, f)
294 }
295 r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
296 r.in(ports.PointerButton{Button: 0x111})
297 r.settle(t)
298 for _, v := range pointerSent(r.drain()) {
299 t.Fatalf("client saw %#v", v)
300 }
301}
302
303func TestDragTileZonesAndHints(t *testing.T) {
304 both(t, func(t *testing.T, animated bool) {
305 dragTileZonesAndHints(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
306 })
307}
308
309func dragTileZonesAndHints(t *testing.T, r *dragRig) {
310 for id := ports.WindowID(1); id <= 3; id++ {
311 r.client <- ports.WindowMapped{ID: id}
312 r.settle(t)
313 }
314 set := r.settle(t)
315 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
316 t.Fatal(got)
317 }
318 a, c := dragRect(t, set, "A", 1), dragRect(t, set, "A", 3)
319 // Drag 1 onto the bottom band of 3: it stacks below 3.
320 r.moveTo(float64(a.X+a.W/2), float64(a.Y+a.H/2), 1)
321 r.settle(t)
322 r.press(t, 0x110, ports.ModSuper)
323 r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H-5), 2)
324 set = r.settle(t)
325 hints := dragScene(set, "A").DropHints
326 if len(hints) != 1 || hints[0].Y+hints[0].H != c.Y+c.H || hints[0].W != c.W {
327 t.Fatalf("hints %+v", hints)
328 }
329 // Tiles do not move until the drop.
330 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
331 t.Fatal(got)
332 }
333 // The drop itself must run a spring: the guard counts from here.
334 r.lands = 0
335 r.in(ports.PointerButton{Button: 0x110})
336 set = r.settle(t)
337 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
338 t.Fatal(got)
339 }
340 if len(dragScene(set, "A").DropHints) != 0 {
341 t.Fatal("hints left")
342 }
343 // Centre of 2 with stacked 1 dragged from its right: 1 becomes a
344 // column in 2's place, 2 moves toward where 1 came from.
345 b := dragRect(t, set, "A", 2)
346 one := dragRect(t, set, "A", 1)
347 r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
348 r.settle(t)
349 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
350 r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 4)
351 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
352 t.Fatalf("swap outline %+v", hints)
353 }
354 // The drop itself must run a spring: the guard counts from here.
355 r.lands = 0
356 r.in(ports.PointerButton{Button: 0x110})
357 set = r.settle(t)
358 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
359 t.Fatal(got)
360 }
361 // Lone 3 on the centre of 2: the columns swap.
362 b, c = dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
363 r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H/2), 5)
364 r.settle(t)
365 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
366 r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
367 r.settle(t)
368 // The drop itself must run a spring: the guard counts from here.
369 r.lands = 0
370 r.in(ports.PointerButton{Button: 0x110})
371 set = r.settle(t)
372 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
373 t.Fatal(got)
374 }
375 // Escape mid tile drag: nothing moves, no hints.
376 one = dragRect(t, set, "A", 1)
377 b = dragRect(t, set, "A", 2)
378 r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 7)
379 r.settle(t)
380 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
381 r.moveTo(float64(b.X+b.W/2), float64(b.Y+5), 8)
382 if len(dragScene(r.settle(t), "A").DropHints) == 0 {
383 t.Fatal("no hint")
384 }
385 r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
386 set = r.settle(t)
387 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) || len(dragScene(set, "A").DropHints) != 0 {
388 t.Fatal(got, dragScene(set, "A").DropHints)
389 }
390}
391
392// startDragMode is the drag rig with animations off, or on and landed by
393// settle.
394func startDragMode(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
395 t.Helper()
396 if animated {
397 return startAnimatedDragRig(t, edit, outs...)
398 }
399 return startDragRig(t, func(c *ports.Config) {
400 if edit != nil {
401 edit(c)
402 }
403 c.Animations.On = false
404 }, outs...)
405}
406
407func TestDragTileGapInsert(t *testing.T) {
408 both(t, func(t *testing.T, animated bool) {
409 dragTileGapInsert(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
410 })
411}
412
413func dragTileGapInsert(t *testing.T, r *dragRig) {
414 for id := ports.WindowID(1); id <= 3; id++ {
415 r.client <- ports.WindowMapped{ID: id}
416 r.settle(t)
417 }
418 r.client <- ports.WindowMapped{ID: 4}
419 r.settle(t)
420 // Stack 4 under 3 with the keyboard, then put 4 between 1 and 2.
421 r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper, Pressed: true})
422 r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper})
423 r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
424 r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
425 r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
426 r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
427 set := r.settle(t)
428 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3, 4}}, slices.Equal) {
429 t.Fatal(got)
430 }
431 one, two, four := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 4)
432 gapX := (one.X + one.W + two.X) / 2
433 r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
434 r.settle(t)
435 r.press(t, 0x110, ports.ModSuper)
436 r.moveTo(float64(gapX), float64(one.Y+one.H/2), 2)
437 hints := dragScene(r.settle(t), "A").DropHints
438 if len(hints) != 1 || hints[0].H != one.H || hints[0].X+hints[0].W/2 != gapX {
439 t.Fatalf("gap hint %+v", hints)
440 }
441 r.in(ports.PointerButton{Button: 0x110})
442 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got[:2], [][]ports.WindowID{{1}, {4}}, slices.Equal) {
443 t.Fatal(got)
444 }
445}
446
447// release sends the button release and returns the scene set core
448// publishes for it, without moving the clock (settle would).
449func (r *dragRig) release(t *testing.T) []ports.Scene {
450 t.Helper()
451 r.in(ports.PointerButton{Button: 0x110})
452 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
453 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
454 select {
455 case r.last = <-r.scenes:
456 case <-time.After(5 * time.Second):
457 t.Fatal("no scene")
458 }
459 return r.last
460}
461
462// A dropped tile slides from where the drag scene drew it (its own slot:
463// a tile does not follow the pointer) and its neighbours re-flow; the
464// client gets one configure, for the final size. With animations off the
465// drop is instant.
466func TestDragDropTileSlides(t *testing.T) {
467 both(t, func(t *testing.T, animated bool) {
468 r := startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
469 for id := ports.WindowID(1); id <= 3; id++ {
470 r.client <- ports.WindowMapped{ID: id}
471 r.settle(t)
472 }
473 set := r.settle(t)
474 one0, two0, three0 := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
475 r.moveTo(float64(one0.X+one0.W/2), float64(one0.Y+one0.H/2), 1)
476 r.settle(t)
477 r.press(t, 0x110, ports.ModSuper)
478 // The bottom band of 3: 1 stacks below it.
479 r.moveTo(float64(three0.X+three0.W/2), float64(three0.Y+three0.H-5), 2)
480 set = r.settle(t)
481 if got := dragRect(t, set, "A", 1); got != one0 {
482 t.Fatalf("tile left its slot during the drag: %+v, was %+v", got, one0)
483 }
484 r.drain()
485 set = r.release(t)
486 if len(dragScene(set, "A").DropHints) != 0 {
487 t.Fatal("hints left")
488 }
489 if !animated {
490 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
491 t.Fatal(got)
492 }
493 // Instant: the scene right after the drop is the settled one.
494 if got := dragRect(t, set, "A", 1); got == one0 || got.X != dragRect(t, r.settle(t), "A", 1).X {
495 t.Fatalf("not dropped at once: %+v", got)
496 }
497 return
498 }
499 // Right after the release the windows are still where the drag
500 // scene drew them.
501 for id, want := range map[ports.WindowID]ports.Rect{1: one0, 2: two0, 3: three0} {
502 if got := dragRect(t, set, "A", id); got != want {
503 t.Fatalf("window %d at %+v right after the drop, want its old %+v", id, got, want)
504 }
505 }
506 mid, ok := r.clock.flip(t, r.frames, r.scenes, 30*time.Millisecond, "A")
507 if !ok {
508 t.Fatal("no frame after the drop: nothing animates")
509 }
510 r.lands++
511 // The dropped tile is between its old slot and its new one, and the
512 // column it joined re-flows too.
513 got := dragRect(t, mid, "A", 1)
514 if got == one0 || got.X <= one0.X && got.Y <= one0.Y {
515 t.Fatalf("window 1 at %+v on the first frame, still at its old %+v", got, one0)
516 }
517 if got2 := dragRect(t, mid, "A", 2); got2 == two0 {
518 t.Fatalf("neighbour 2 did not start moving: %+v", got2)
519 }
520 set = r.settle(t)
521 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
522 t.Fatal(got)
523 }
524 want := map[ports.WindowID]ports.Rect{}
525 for id := ports.WindowID(1); id <= 3; id++ {
526 want[id] = dragRect(t, set, "A", id)
527 }
528 if want[1].Y <= want[3].Y || want[1].X != want[3].X {
529 t.Fatalf("1 is not stacked below 3 once settled: %+v", want)
530 }
531 // A window is configured at most once by the drop, to its settled
532 // size, never to a size along the slide; the dropped tile always.
533 sizes := map[ports.WindowID][][2]int{}
534 for _, v := range r.drain() {
535 if v, ok := v.(ports.ConfigureWindow); ok {
536 sizes[v.ID] = append(sizes[v.ID], [2]int{v.Width, v.Height})
537 }
538 }
539 for id, rect := range want {
540 got := sizes[id]
541 if len(got) > 1 || len(got) == 1 && got[0] != [2]int{rect.W, rect.H} || id == 1 && len(got) != 1 {
542 t.Fatalf("configures of window %d %v, want at most one at %dx%d (exactly one for window 1)", id, got, rect.W, rect.H)
543 }
544 }
545 })
546}
547
548func TestDragFixedNoGapZone(t *testing.T) {
549 both(t, func(t *testing.T, animated bool) {
550 dragFixedNoGapZone(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
551 })
552}
553
554func dragFixedNoGapZone(t *testing.T, r *dragRig) {
555 for id := ports.WindowID(1); id <= 2; id++ {
556 r.client <- ports.WindowMapped{ID: id}
557 r.settle(t)
558 }
559 set := r.settle(t)
560 one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
561 r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
562 r.settle(t)
563 r.press(t, 0x110, ports.ModSuper)
564 r.moveTo(float64((one.X+one.W+two.X)/2), float64(one.Y+one.H/2), 2)
565 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
566 t.Fatalf("fixed gap hint %+v", hints)
567 }
568 // Top band of 2 stacks 1 above it.
569 r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
570 r.settle(t)
571 // The drop itself must run a spring: the guard counts from here.
572 r.lands = 0
573 r.in(ports.PointerButton{Button: 0x110})
574 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
575 t.Fatal(got)
576 }
577}
578
579func TestDragAcrossOutputs(t *testing.T) {
580 both(t, func(t *testing.T, animated bool) {
581 dragAcrossOutputs(t, startDragMode(t, animated, nil, dragOut, ports.OutputInfo{Name: "B", Width: 800, Height: 600}))
582 })
583}
584
585func dragAcrossOutputs(t *testing.T, r *dragRig) {
586 r.client <- ports.WindowMapped{ID: 1}
587 r.client <- ports.WindowMapped{ID: 2}
588 r.settle(t)
589 // Put 3 on B.
590 r.moveTo(1500, 300, 1)
591 r.settle(t)
592 r.client <- ports.WindowMapped{ID: 3}
593 set := r.settle(t)
594 if _, ok := windowIn(set, "B", 3); !ok {
595 t.Fatal("3 not on B")
596 }
597 one := dragRect(t, set, "A", 1)
598 r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 2)
599 r.settle(t)
600 r.press(t, 0x110, ports.ModSuper)
601 r.moveTo(1400, 300, 3)
602 set = r.settle(t)
603 if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
604 t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
605 }
606 r.lands = 0
607 r.in(ports.PointerButton{Button: 0x110})
608 set = r.settle(t)
609 if _, ok := windowIn(set, "B", 1); !ok {
610 t.Fatal("1 not on B")
611 }
612 if w, _ := windowIn(set, "B", 1); !w.Focused {
613 t.Fatal("1 not focused")
614 }
615}
616
617func TestClientMoveRequest(t *testing.T) {
618 r := startDragRig(t, nil, dragOut)
619 r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
620 f := dragRect(t, r.settle(t), "A", 2)
621 // Without a held button: ignored.
622 r.client <- ports.WindowMoveRequest{ID: 2}
623 r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
624 r.settle(t)
625 r.moveTo(float64(f.X+20), float64(f.Y+10), 2)
626 if got := dragRect(t, r.settle(t), "A", 2); got != f {
627 t.Fatalf("moved without a button: %+v", got)
628 }
629 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
630 r.settle(t)
631 r.client <- ports.WindowMoveRequest{ID: 2}
632 r.settle(t)
633 r.drain()
634 r.moveTo(float64(f.X+60), float64(f.Y+10), 3)
635 if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+40 {
636 t.Fatalf("move request: %+v from %+v", got, f)
637 }
638 r.in(ports.PointerButton{Button: 0x110})
639 r.settle(t)
640 for _, v := range pointerSent(r.drain()) {
641 t.Fatalf("client saw %#v", v)
642 }
643}
644
645func TestLockClearsDrag(t *testing.T) {
646 r := startDragRig(t, nil, dragOut)
647 r.client <- ports.WindowMapped{ID: 1}
648 r.client <- ports.WindowMapped{ID: 2}
649 set := r.settle(t)
650 one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
651 r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
652 r.settle(t)
653 r.press(t, 0x110, ports.ModSuper)
654 r.moveTo(float64(two.X+two.W/2), float64(two.Y+5), 2)
655 if len(dragScene(r.settle(t), "A").DropHints) == 0 {
656 t.Fatal("no hint")
657 }
658 locked := ports.SecurityState{Generation: 1, Protected: true}
659 r.epoch.Store(locked)
660 r.client <- ports.SessionLockChanged{State: locked}
661 set = r.settle(t)
662 if len(dragScene(set, "A").DropHints) != 0 {
663 t.Fatal("hints while locked")
664 }
665 unlocked := ports.SecurityState{Generation: 2}
666 r.epoch.Store(unlocked)
667 r.client <- ports.SessionLockChanged{State: unlocked}
668 r.settle(t)
669 r.in(ports.PointerButton{Button: 0x110})
670 set = r.settle(t)
671 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}}, slices.Equal) {
672 t.Fatal(got)
673 }
674}
675
676func TestDragEdgeScroll(t *testing.T) {
677 both(t, func(t *testing.T, animated bool) { dragEdgeScroll(t, startDragMode(t, animated, nil, dragOut)) })
678}
679
680func dragEdgeScroll(t *testing.T, r *dragRig) {
681 for id := ports.WindowID(1); id <= 4; id++ {
682 r.client <- ports.WindowMapped{ID: id}
683 r.settle(t)
684 }
685 // Back to the first column: 3 and 4 are off screen to the right.
686 for range 3 {
687 r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
688 r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
689 }
690 set := r.settle(t)
691 one := dragRect(t, set, "A", 1)
692 if one.X != 10 {
693 t.Fatalf("%+v", one)
694 }
695 r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1000)
696 r.settle(t)
697 r.press(t, 0x110, ports.ModSuper)
698 r.moveTo(995, 300, 1001)
699 first := dragRect(t, r.settle(t), "A", 1)
700 if first.X >= one.X {
701 t.Fatalf("no scroll: %+v", first)
702 }
703 // Under 250 ms: no second step.
704 r.moveTo(996, 300, 1100)
705 if got := dragRect(t, r.settle(t), "A", 1); got != first {
706 t.Fatalf("scrolled too soon: %+v", got)
707 }
708 r.moveTo(997, 300, 1300)
709 second := dragRect(t, r.settle(t), "A", 1)
710 r.moveTo(998, 300, 1600)
711 r.moveTo(999, 300, 1900)
712 end := dragRect(t, r.settle(t), "A", 1)
713 r.moveTo(998, 300, 2200)
714 if got := dragRect(t, r.settle(t), "A", 1); second.X >= first.X || got != end {
715 t.Fatalf("first %+v second %+v end %+v got %+v", first, second, end, got)
716 }
717 r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
718 r.settle(t)
719}
720
721func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
722 both(t, func(t *testing.T, animated bool) {
723 dragFullFixedStacksInOutlinedColumn(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
724 })
725}
726
727func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
728 for id := ports.WindowID(1); id <= 3; id++ {
729 r.client <- ports.WindowMapped{ID: id}
730 r.settle(t)
731 }
732 set := r.settle(t)
733 one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
734 // 4 joins 1's column from the top band of 1.
735 r.client <- ports.WindowMapped{ID: 4}
736 set = r.settle(t)
737 four := dragRect(t, set, "A", 4)
738 r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
739 r.settle(t)
740 r.press(t, 0x110, ports.ModSuper)
741 r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
742 r.settle(t)
743 // The drop itself must run a spring: the guard counts from here.
744 r.lands = 0
745 r.in(ports.PointerButton{Button: 0x110})
746 set = r.settle(t)
747 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {
748 t.Fatal(got)
749 }
750 one, two = dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
751 r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
752 r.settle(t)
753 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
754 r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
755 r.settle(t)
756 // The drop itself must run a spring: the guard counts from here.
757 r.lands = 0
758 r.in(ports.PointerButton{Button: 0x110})
759 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
760 t.Fatal(got)
761 }
762}
763
764func TestPressAfterCancelGrabs(t *testing.T) {
765 r := startDragRig(t, nil, dragOut)
766 r.client <- ports.WindowMapped{ID: 1}
767 r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
768 f := dragRect(t, r.settle(t), "A", 2)
769 r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
770 r.settle(t)
771 r.press(t, 0x110, ports.ModSuper)
772 r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
773 r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
774 r.in(ports.KeyEvent{Keysym: "Super_L"})
775 r.settle(t)
776 r.drain()
777 // Left is still held, swallowed. A right click on 2 goes to 2, and so
778 // does its release after the pointer left it.
779 r.in(ports.PointerButton{Button: 0x111, Pressed: true})
780 r.moveTo(5, 5, 2)
781 r.in(ports.PointerButton{Button: 0x111})
782 r.settle(t)
783 var got []ports.PointerButtonTo
784 for _, v := range r.drain() {
785 if b, ok := v.(ports.PointerButtonTo); ok {
786 got = append(got, b)
787 }
788 }
789 if len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 || got[1].Pressed {
790 t.Fatalf("%+v", got)
791 }
792}
793
794func TestClientResizeWithoutEdgesIgnored(t *testing.T) {
795 r := startDragRig(t, nil, dragOut)
796 r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
797 f := dragRect(t, r.settle(t), "A", 2)
798 r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
799 r.settle(t)
800 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
801 r.client <- ports.WindowMoveRequest{ID: 2, Resize: true}
802 r.moveTo(float64(f.X+60), float64(f.Y+60), 2)
803 if got := dragRect(t, r.settle(t), "A", 2); got != f {
804 t.Fatalf("%+v from %+v", got, f)
805 }
806}
807
808func TestClientDragUsesLastPress(t *testing.T) {
809 r := startDragRig(t, nil, dragOut)
810 r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
811 f := dragRect(t, r.settle(t), "A", 2)
812 r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
813 r.settle(t)
814 r.in(ports.PointerButton{Button: 0x111, Pressed: true})
815 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
816 r.client <- ports.WindowMoveRequest{ID: 2}
817 r.moveTo(float64(f.X+60), float64(f.Y+10), 2)
818 r.settle(t)
819 // Releasing left ends the drag: the window stays put afterwards.
820 r.in(ports.PointerButton{Button: 0x110})
821 r.moveTo(float64(f.X+90), float64(f.Y+10), 3)
822 if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+50 {
823 t.Fatalf("%+v from %+v", got, f)
824 }
825}
826
827func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
828 both(t, func(t *testing.T, animated bool) {
829 edgeScrollPublishesWithoutTarget(t, startDragMode(t, animated, nil, dragOut))
830 })
831}
832
833func edgeScrollPublishesWithoutTarget(t *testing.T, r *dragRig) {
834 for id := ports.WindowID(1); id <= 4; id++ {
835 r.client <- ports.WindowMapped{ID: id}
836 r.settle(t)
837 }
838 for range 3 {
839 r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
840 r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
841 }
842 // Drag 2 into the top gap, then along it to the right edge: no drop
843 // target before or after, but the view scrolls.
844 two := dragRect(t, r.settle(t), "A", 2)
845 edge := float64(dragOut.Width - 30)
846 if float64(two.X+two.W) <= edge {
847 t.Fatalf("2 does not reach the edge: %+v", two)
848 }
849 r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 1000)
850 r.settle(t)
851 r.press(t, 0x110, ports.ModSuper)
852 r.moveTo(float64(two.X+two.W/2), 5, 1001)
853 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
854 t.Fatalf("hint in the gap %+v", hints)
855 }
856 r.moveTo(edge, 5, 1300)
857 // An axis event publishes nothing mid-drag: once core took it, the
858 // motion's scene, if any, is in the channel.
859 r.in(ports.PointerAxis{})
860 select {
861 case set := <-r.scenes:
862 if got := dragRect(t, set, "A", 2); got.X >= two.X {
863 t.Fatalf("not scrolled: %+v", got)
864 }
865 default:
866 t.Fatal("edge scroll not published")
867 }
868}
869
870func TestModsFollowKeys(t *testing.T) {
871 r := startDragRig(t, nil, dragOut)
872 r.client <- ports.WindowMapped{ID: 1}
873 r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
874 f := dragRect(t, r.settle(t), "A", 2)
875 r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
876 r.settle(t)
877 // Super pressed then released: a click is a plain click again.
878 r.in(ports.KeyEvent{Keysym: "Super_L", Mods: ports.ModSuper, Pressed: true})
879 r.in(ports.KeyEvent{Keysym: "Super_L"})
880 r.settle(t)
881 r.drain()
882 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
883 r.settle(t)
884 found := false
885 for _, v := range r.drain() {
886 if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 {
887 found = true
888 }
889 }
890 if !found {
891 t.Fatal("click after cmd release not forwarded")
892 }
893}