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