1package core_test
2
3import (
4 "context"
5 "fmt"
6 "slices"
7 "sync/atomic"
8 "testing"
9 "time"
10
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 hintHarness struct {
17 t *testing.T
18 input chan ports.InputEvent
19 client chan ports.ClientEvent
20 output chan ports.OutputEvent
21 commands chan ports.ClientCommand
22 scenes chan []ports.Scene
23 scans chan ports.HintScanRequest
24 hints chan ports.HintScanResult
25 win ports.Rect // window 1, output-local
26 // state is the security state input is stamped with.
27 state ports.SecurityState
28 config chan ports.ConfigChanged
29 cfg ports.Config
30}
31
32// newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped.
33func newHint(t *testing.T, opts core.Options) *hintHarness {
34 t.Helper()
35 return newHintConfig(t, opts, func(*ports.Config) {})
36}
37
38// newHintConfig is newHint with the config edited by edit.
39func newHintConfig(t *testing.T, opts core.Options, edit func(*ports.Config)) *hintHarness {
40 t.Helper()
41 cfg := scrollDefaults()
42 edit(&cfg)
43 cfg.Border.Width = 0
44 cfg.Layout.Gaps = 20
45 cfg.Binds["Cmd+g"] = "hint"
46 h := &hintHarness{
47 config: make(chan ports.ConfigChanged),
48 cfg: cfg,
49 t: t,
50 input: make(chan ports.InputEvent, 32),
51 client: make(chan ports.ClientEvent, 32),
52 output: make(chan ports.OutputEvent, 8),
53 commands: make(chan ports.ClientCommand, 256),
54 scenes: make(chan []ports.Scene, 1),
55 scans: make(chan ports.HintScanRequest, 1),
56 // Unbuffered: a send returns once core took the result.
57 hints: make(chan ports.HintScanResult),
58 }
59 ch := core.Channels{Config: h.config, Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes, HintScans: h.scans, Hints: h.hints}
60 c, err := core.New(cfg, ch, opts)
61 if err != nil {
62 t.Fatal(err)
63 }
64 ctx, cancel := context.WithCancel(context.Background())
65 done := make(chan error, 1)
66 go func() { done <- c.Run(ctx) }()
67 t.Cleanup(func() { cancel(); receive(t, done) })
68 h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
69 scene(t, h.scenes)
70 h.client <- ports.WindowMapped{ID: 1, AppID: "org.test.app", PID: 4242}
71 s := scene(t, h.scenes)
72 h.win = s.Windows[0].Rect
73 h.drain()
74 return h
75}
76
77func (h *hintHarness) drain() {
78 for {
79 select {
80 case <-h.commands:
81 default:
82 return
83 }
84 }
85}
86
87// press and release send one key event.
88func (h *hintHarness) press(sym string, mods ports.Mods) {
89 h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods, Pressed: true})
90}
91
92func (h *hintHarness) release(sym string, mods ports.Mods) {
93 h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods})
94}
95
96// send gives core an input event stamped with the current security state.
97func (h *hintHarness) send(ev ports.InputEvent) {
98 h.input <- ports.SecurityInput{State: h.state, Event: ev}
99}
100
101// start presses the hint bind and returns the request.
102func (h *hintHarness) start() ports.HintScanRequest {
103 h.t.Helper()
104 h.press("g", ports.ModSuper)
105 req := receive(h.t, h.scans)
106 h.release("g", 0) // the user lets go of the whole chord
107 return req
108}
109
110// show starts hint mode and answers with targets, returning the next scene.
111func (h *hintHarness) show(targets ...ports.HintTarget) ports.Scene {
112 h.t.Helper()
113 req := h.start()
114 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: targets}
115 return h.labelled(len(targets), 0)
116}
117
118// until returns the first scene, from the next one on, that satisfies ok:
119// the bind press and other input publish scenes before the one waited for.
120func (h *hintHarness) until(ok func(ports.Scene) bool) ports.Scene {
121 h.t.Helper()
122 for {
123 if s := scene(h.t, h.scenes); ok(s) {
124 return s
125 }
126 }
127}
128
129// labelled waits for a scene with n labels, all typed with typed letters.
130func (h *hintHarness) labelled(n, typed int) ports.Scene {
131 h.t.Helper()
132 return h.until(func(s ports.Scene) bool {
133 return len(s.HintLabels) == n && (n == 0 || s.HintLabels[0].Typed == typed)
134 })
135}
136
137// inside is a target of the given size inside window 1.
138func (h *hintHarness) inside(i int) ports.HintTarget {
139 return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + 100*(i%4), Y: h.win.Y + 10 + 40*(i/4), W: 40, H: 10}}
140}
141
142func TestHintRequestOnBind(t *testing.T) {
143 h := newHint(t, core.Options{})
144 req := h.start()
145 if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.Seq == 0 {
146 t.Fatalf("request %+v", req)
147 }
148 // A second bind while pending does not ask again; the pending phase
149 // cancels on any key.
150 h.press("g", ports.ModSuper)
151 select {
152 case r := <-h.scans:
153 t.Fatalf("second request %+v", r)
154 case <-time.After(50 * time.Millisecond):
155 }
156}
157
158func TestHintRequestNilScansIsNoop(t *testing.T) {
159 cfg := scrollDefaults()
160 cfg.Binds["Cmd+g"] = "hint"
161 input := make(chan ports.InputEvent, 4)
162 client := make(chan ports.ClientEvent, 4)
163 output := make(chan ports.OutputEvent, 4)
164 commands := make(chan ports.ClientCommand, 32)
165 scenes := make(chan []ports.Scene, 1)
166 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
167 if err != nil {
168 t.Fatal(err)
169 }
170 ctx, cancel := context.WithCancel(context.Background())
171 done := make(chan error, 1)
172 go func() { done <- c.Run(ctx) }()
173 defer func() { cancel(); receive(t, done) }()
174 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
175 scene(t, scenes)
176 input <- ports.KeyEvent{Keysym: "g", Base: "g", Mods: ports.ModSuper, Pressed: true}
177 if s := scene(t, scenes); len(s.HintLabels) != 0 {
178 t.Fatal(s.HintLabels)
179 }
180}
181
182func TestHintRefusedWhenProtected(t *testing.T) {
183 var state atomic.Pointer[ports.SecurityState]
184 state.Store(&ports.SecurityState{})
185 gate := portsmocks.NewMockSessionSecurity(t)
186 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe()
187 h := newHint(t, core.Options{Security: gate})
188 // Hint mode shown, then the session locks: the labels go away.
189 h.show(h.inside(0))
190 state.Store(&ports.SecurityState{Generation: 2, Protected: true})
191 h.state = *state.Load()
192 h.client <- ports.WindowMapped{ID: 2}
193 s := h.until(func(s ports.Scene) bool { return s.Security.Protected })
194 if len(s.HintLabels) != 0 {
195 t.Fatalf("labels while protected %+v", s.HintLabels)
196 }
197 // And a new request is refused while it lasts.
198 h.press("g", ports.ModSuper)
199 select {
200 case r := <-h.scans:
201 t.Fatalf("request while protected %+v", r)
202 case <-time.After(100 * time.Millisecond):
203 }
204}
205
206func TestHintResultShowsLabels(t *testing.T) {
207 h := newHint(t, core.Options{})
208 var targets []ports.HintTarget
209 for i := 0; i < 26; i++ {
210 targets = append(targets, h.inside(i))
211 }
212 s := h.show(targets...)
213 if len(s.HintLabels) != 26 {
214 t.Fatalf("%d labels", len(s.HintLabels))
215 }
216 seen := map[string]bool{}
217 for _, l := range s.HintLabels {
218 if len(l.Text) != 1 || l.Typed != 0 || seen[l.Text] {
219 t.Fatalf("label %+v", l)
220 }
221 seen[l.Text] = true
222 }
223 // Sorted by position: the first target gets the first letter.
224 if s.HintLabels[0].Text != "f" || s.HintLabels[0].Rect.X != targets[0].Rect.X || s.HintLabels[0].Rect.Y != targets[0].Rect.Y {
225 t.Fatalf("first %+v", s.HintLabels[0])
226 }
227}
228
229func TestHintTwoLetterLabels(t *testing.T) {
230 h := newHint(t, core.Options{})
231 var targets []ports.HintTarget
232 for i := 0; i < 27; i++ {
233 targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5 + 100*(i%4), Y: h.win.Y + 5 + 40*(i/4), W: 30, H: 10}})
234 }
235 s := h.show(targets...)
236 if len(s.HintLabels) != 27 {
237 t.Fatalf("%d labels", len(s.HintLabels))
238 }
239 seen := map[string]bool{}
240 for _, l := range s.HintLabels {
241 if len(l.Text) != 2 || seen[l.Text] {
242 t.Fatalf("label %+v", l)
243 }
244 seen[l.Text] = true
245 }
246 if s.HintLabels[0].Text != "ff" || s.HintLabels[1].Text != "fj" {
247 t.Fatalf("labels %v %v", s.HintLabels[0].Text, s.HintLabels[1].Text)
248 }
249}
250
251func TestHintDropsTargetsOutsideWindows(t *testing.T) {
252 h := newHint(t, core.Options{})
253 in := h.inside(0)
254 // In the gap around the window, over the background.
255 out := ports.HintTarget{Rect: ports.Rect{X: 2, Y: 2, W: 4, H: 4}}
256 if h.win.X < 8 || h.win.Y < 8 {
257 t.Fatalf("window %+v leaves no gap", h.win)
258 }
259 req := h.start()
260 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, in}}
261 s := h.labelled(1, 0)
262 if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.X != in.Rect.X {
263 t.Fatalf("labels %+v (window %+v)", s.HintLabels, h.win)
264 }
265}
266
267func TestHintTypingFilters(t *testing.T) {
268 h := newHint(t, core.Options{})
269 var targets []ports.HintTarget
270 for i := 0; i < 27; i++ {
271 targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5 + 100*(i%4), Y: h.win.Y + 5 + 40*(i/4), W: 30, H: 10}})
272 }
273 h.show(targets...)
274 h.press("f", 0)
275 s := h.labelled(26, 1)
276 if len(s.HintLabels) != 26 {
277 t.Fatalf("%d labels after f", len(s.HintLabels))
278 }
279 for _, l := range s.HintLabels {
280 if l.Typed != 1 || l.Text[0] != 'f' {
281 t.Fatalf("label %+v", l)
282 }
283 }
284 h.press("BackSpace", 0)
285 h.labelled(27, 0)
286 // Only the 27th target starts with j: its label is "jf".
287 h.press("j", 0)
288 s = h.labelled(1, 1)
289 if len(s.HintLabels) != 1 || s.HintLabels[0].Text != "jf" {
290 t.Fatalf("after j %+v", s.HintLabels)
291 }
292}
293
294func TestHintClick(t *testing.T) {
295 h := newHint(t, core.Options{})
296 tg := h.inside(0)
297 h.show(tg)
298 h.drain()
299 h.press("f", 0)
300 s := h.labelled(0, 0)
301 if len(s.HintLabels) != 0 {
302 t.Fatalf("labels after click %+v", s.HintLabels)
303 }
304 cx := float64(tg.Rect.X) + float64(tg.Rect.W)/2 - float64(h.win.X)
305 cy := float64(tg.Rect.Y) + float64(tg.Rect.H)/2 - float64(h.win.Y)
306 var gotMotion, gotPress, gotRelease, gotFocus bool
307 for len(h.commands) > 0 || !gotRelease {
308 var cmd ports.ClientCommand
309 select {
310 case cmd = <-h.commands:
311 case <-time.After(time.Second):
312 t.Fatalf("missing commands: focus %v motion %v press %v release %v", gotFocus, gotMotion, gotPress, gotRelease)
313 }
314 switch v := cmd.(type) {
315 case ports.PointerFocus:
316 if v.ID == 1 && v.X == cx && v.Y == cy {
317 gotFocus = true
318 }
319 case ports.PointerMotionTo:
320 if v.ID == 1 && v.X == cx && v.Y == cy {
321 gotMotion = true
322 }
323 case ports.PointerButtonTo:
324 if v.ID != 1 || v.Button != 0x110 {
325 t.Fatalf("button %+v", v)
326 }
327 if v.Pressed {
328 gotPress = true
329 } else {
330 gotRelease = gotPress
331 }
332 }
333 }
334 if !gotFocus || !gotMotion {
335 t.Fatalf("focus %v motion %v", gotFocus, gotMotion)
336 }
337}
338
339func TestHintEscapeCancels(t *testing.T) {
340 h := newHint(t, core.Options{})
341 h.show(h.inside(0))
342 h.drain()
343 h.press("Escape", 0)
344 s := h.labelled(0, 0)
345 if len(s.HintLabels) != 0 {
346 t.Fatalf("labels %+v", s.HintLabels)
347 }
348 h.release("Escape", 0)
349 // A later key goes to the window again: the Escape release did not.
350 h.press("x", 0)
351 cmd := command(t, h.commands)
352 if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
353 t.Fatalf("command %#v", cmd)
354 }
355 for len(h.commands) > 0 {
356 if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
357 t.Fatal("button sent")
358 }
359 }
360}
361
362func TestHintKeysNotForwarded(t *testing.T) {
363 h := newHint(t, core.Options{})
364 h.show(h.inside(0), h.inside(1))
365 h.drain()
366 // A letter that matches nothing is swallowed and does not cancel.
367 h.press("q", 0)
368 h.release("q", 0)
369 // Any other key cancels and is swallowed.
370 h.press("Return", 0)
371 s := h.labelled(0, 0)
372 if len(s.HintLabels) != 0 {
373 t.Fatalf("labels %+v", s.HintLabels)
374 }
375 h.release("Return", 0)
376 h.press("x", 0)
377 cmd := command(t, h.commands)
378 if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
379 t.Fatalf("first forwarded command %#v", cmd)
380 }
381}
382
383func TestHintStaleSeqIgnored(t *testing.T) {
384 h := newHint(t, core.Options{})
385 req := h.start()
386 h.hints <- ports.HintScanResult{Seq: req.Seq + 7, Output: req.Output, Targets: []ports.HintTarget{h.inside(0)}}
387 // A matching result afterwards shows: the stale one changed nothing.
388 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{h.inside(0), h.inside(1)}}
389 s := h.labelled(2, 0)
390 if len(s.HintLabels) != 2 {
391 t.Fatalf("labels %+v", s.HintLabels)
392 }
393}
394
395func TestHintErrorCancels(t *testing.T) {
396 h := newHint(t, core.Options{})
397 req := h.start()
398 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Err: fmt.Errorf("boom")}
399 h.press("x", 0)
400 cmd := command(t, h.commands)
401 if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
402 t.Fatalf("command %#v", cmd)
403 }
404}
405
406func TestHintWindowMovedAborts(t *testing.T) {
407 h := newHint(t, core.Options{})
408 tg := h.inside(0)
409 h.show(tg)
410 // The window closes before the label is typed: the click must not go
411 // anywhere.
412 h.client <- ports.WindowUnmapped{ID: 1}
413 h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 })
414 h.drain()
415 h.press("f", 0)
416 // With no window left a key goes nowhere; the next bind press, answered
417 // by a scan request, shows core handled the label key before it.
418 h.press("g", ports.ModSuper)
419 receive(t, h.scans)
420 for len(h.commands) > 0 {
421 if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
422 t.Fatal("button sent to a moved window")
423 }
424 }
425}
426
427// barrier returns the commands core sent for everything given to it so far:
428// a marker key goes through the same input channel and owner, and is
429// forwarded to the focused window after the commands of the earlier events.
430func (h *hintHarness) barrier() []ports.ClientCommand {
431 h.t.Helper()
432 h.press("marker", 0)
433 var out []ports.ClientCommand
434 for {
435 cmd := command(h.t, h.commands)
436 if k, ok := cmd.(ports.ForwardKey); ok && k.Key.Keysym == "marker" {
437 return out
438 }
439 out = append(out, cmd)
440 }
441}
442
443// buttonsSent drains the commands and counts pointer button commands.
444func (h *hintHarness) buttonsSent() (presses, releases int) {
445 for _, cmd := range h.barrier() {
446 if b, ok := cmd.(ports.PointerButtonTo); ok {
447 if b.Pressed {
448 presses++
449 } else {
450 releases++
451 }
452 }
453 }
454 return presses, releases
455}
456
457// The bind's modifier is still held while the label is typed: the synthetic
458// click must reach the client, not start a Cmd+left-button window drag.
459func TestHintClickWithBindModifierHeld(t *testing.T) {
460 h := newHint(t, core.Options{})
461 tg := h.inside(0)
462 h.press("g", ports.ModSuper)
463 req := receive(t, h.scans)
464 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
465 h.labelled(1, 0)
466 h.drain()
467 h.press("f", ports.ModSuper) // Super is still down
468 h.labelled(0, 0)
469 cmds := h.barrier()
470 if got, want := commandKinds(cmds), []string{"mods:0", "motion", "press", "release", "mods:restore"}; !slices.Equal(got, want) {
471 t.Fatalf("commands %v, want %v", got, want)
472 }
473 presses, releases := 0, 0
474 for _, c := range cmds {
475 if b, ok := c.(ports.PointerButtonTo); ok {
476 if b.Pressed {
477 presses++
478 } else {
479 releases++
480 }
481 }
482 }
483 if presses != 1 || releases != 1 {
484 t.Fatalf("client got %d presses, %d releases", presses, releases)
485 }
486 // No drag is left behind: a later plain motion and a click work as usual.
487 h.release("f", ports.ModSuper)
488 h.release("Super_L", 0)
489 h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)})
490 h.send(ports.PointerButton{Button: 0x110, Pressed: true})
491 h.send(ports.PointerButton{Button: 0x110})
492 if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 {
493 t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases)
494 }
495}
496
497// constrain gives window 1 a pointer constraint and waits until core applied it.
498func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) {
499 h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}
500 h.client <- ports.WindowMapped{ID: 1} // a publish after the event
501 h.until(func(ports.Scene) bool { return true })
502}
503
504func TestHintRefusedWithPointerConstraint(t *testing.T) {
505 for name, c := range map[string]ports.PointerConstraint{
506 "lock": {Mode: ports.ConstraintLock},
507 "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
508 } {
509 t.Run(name, func(t *testing.T) {
510 h := newHint(t, core.Options{})
511 h.constrain(c.Mode, c.Rect)
512 h.press("g", ports.ModSuper)
513 select {
514 case r := <-h.scans:
515 t.Fatalf("request under a %s: %+v", name, r)
516 case <-time.After(100 * time.Millisecond):
517 }
518 })
519 }
520}
521
522// A constraint that appears while the labels are shown (the pointer would be
523// held or bent away from the target) cancels the click.
524func TestHintNoClickWhenConstraintAppears(t *testing.T) {
525 for name, c := range map[string]ports.PointerConstraint{
526 "lock": {Mode: ports.ConstraintLock},
527 "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
528 } {
529 t.Run(name, func(t *testing.T) {
530 h := newHint(t, core.Options{})
531 h.show(h.inside(0))
532 h.constrain(c.Mode, c.Rect)
533 h.drain()
534 h.press("f", 0)
535 if p, r := h.buttonsSent(); p+r != 0 {
536 t.Fatalf("%d presses, %d releases under a %s", p, r, name)
537 }
538 })
539 }
540}
541
542// pressReal sends a real left press over window 1 and waits for the client to
543// see it: the window then holds the pointer grab.
544func (h *hintHarness) pressReal() {
545 h.t.Helper()
546 h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)})
547 h.send(ports.PointerButton{Button: 0x110, Pressed: true})
548 for {
549 cmd := command(h.t, h.commands)
550 if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed {
551 return
552 }
553 }
554}
555
556func TestHintRefusedWhileButtonHeld(t *testing.T) {
557 h := newHint(t, core.Options{})
558 h.pressReal()
559 h.press("g", ports.ModSuper)
560 select {
561 case r := <-h.scans:
562 t.Fatalf("request with a button held: %+v", r)
563 case <-time.After(100 * time.Millisecond):
564 }
565}
566
567// A button pressed (and so a grab taken) after the labels appeared: the label
568// is typed, no synthetic button goes out and the real press stays held.
569func TestHintNoClickWhileGrabbed(t *testing.T) {
570 h := newHint(t, core.Options{})
571 h.show(h.inside(0))
572 h.pressReal()
573 h.press("f", 0)
574 if p, r := h.buttonsSent(); p+r != 0 {
575 t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r)
576 }
577 // The real release still reaches the client: c.buttons was not touched.
578 h.send(ports.PointerButton{Button: 0x110})
579 if _, r := h.buttonsSent(); r != 1 {
580 t.Fatalf("real release delivered %d times", r)
581 }
582}
583
584// The window stays mapped but moves away from the target's centre.
585func TestHintMappedWindowMovedAborts(t *testing.T) {
586 h := newHint(t, core.Options{})
587 // A target at the right edge of window 1.
588 tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}}
589 h.show(tg)
590 h.client <- ports.WindowMapped{ID: 2}
591 s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 })
592 if s.Windows[0].Rect == h.win {
593 t.Fatalf("window 1 did not move: %+v", s.Windows)
594 }
595 h.drain()
596 h.press("f", 0)
597 if p, r := h.buttonsSent(); p+r != 0 {
598 t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
599 }
600}
601
602// commandsSent drains the commands up to a barrier, keeping the ones that
603// matter for a click: focus, motion and buttons.
604func (h *hintHarness) commandsSent() []ports.ClientCommand {
605 var out []ports.ClientCommand
606 for _, cmd := range h.barrier() {
607 switch cmd.(type) {
608 case ports.FocusWindow, ports.SetModifiers, ports.PointerMotionTo, ports.PointerButtonTo:
609 out = append(out, cmd)
610 }
611 }
612 return out
613}
614
615func commandKinds(cmds []ports.ClientCommand) []string {
616 var out []string
617 for _, c := range cmds {
618 switch v := c.(type) {
619 case ports.FocusWindow:
620 out = append(out, fmt.Sprintf("focus:%d", v.ID))
621 case ports.SetModifiers:
622 if v.Restore {
623 out = append(out, "mods:restore")
624 } else {
625 out = append(out, fmt.Sprintf("mods:%d", v.Mods))
626 }
627 case ports.PointerMotionTo:
628 out = append(out, "motion")
629 case ports.PointerButtonTo:
630 if v.Pressed {
631 out = append(out, "press")
632 } else {
633 out = append(out, "release")
634 }
635 }
636 }
637 return out
638}
639
640// A click on the focused window moves the pointer to the target and presses
641// and releases the left button; it states no modifiers.
642func TestHintClickSequence(t *testing.T) {
643 h := newHint(t, core.Options{})
644 h.show(h.inside(0))
645 h.drain()
646 h.press("f", 0)
647 got := commandKinds(h.commandsSent())
648 want := []string{"mods:0", "motion", "press", "release", "mods:restore"}
649 if !slices.Equal(got, want) {
650 t.Fatalf("commands %v, want %v", got, want)
651 }
652}
653
654// A window that moved while the scan ran: a target over it is dropped.
655func TestHintResultDroppedWhenWindowMoved(t *testing.T) {
656 h := newHint(t, core.Options{})
657 req := h.start()
658 tg := h.inside(0)
659 h.client <- ports.WindowMapped{ID: 2}
660 h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
661 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
662 // Whatever window now covers the target was not scanned: no labels.
663 if s := h.labelled(0, 0); len(s.HintLabels) != 0 {
664 t.Fatalf("labels %+v", s.HintLabels)
665 }
666}
667
668// A new window covering the target (not in the snapshot) drops it as well.
669func TestHintResultDroppedForUnseenWindow(t *testing.T) {
670 h := newHint(t, core.Options{})
671 req := h.start()
672 h.client <- ports.WindowMapped{ID: 2}
673 s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
674 var tg ports.HintTarget
675 for _, w := range s.Windows {
676 if w.ID == 2 {
677 tg.Rect = ports.Rect{X: w.Rect.X + 5, Y: w.Rect.Y + 5, W: 20, H: 10}
678 }
679 }
680 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
681 if s := h.labelled(0, 0); len(s.HintLabels) != 0 {
682 t.Fatalf("labels %+v", s.HintLabels)
683 }
684}
685
686// Targets outside the output are dropped.
687func TestHintDropsTargetsOutsideOutput(t *testing.T) {
688 h := newHint(t, core.Options{})
689 req := h.start()
690 out := ports.HintTarget{Rect: ports.Rect{X: 2000, Y: 10, W: 10, H: 10}}
691 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, h.inside(0)}}
692 if s := h.labelled(1, 0); len(s.HintLabels) != 1 {
693 t.Fatalf("labels %+v", s.HintLabels)
694 }
695}
696
697// The output changing size while labels are shown ends hint mode.
698func TestHintCancelsWhenOutputChanges(t *testing.T) {
699 h := newHint(t, core.Options{})
700 h.show(h.inside(0))
701 h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1200, Height: 800}}
702 if s := h.labelled(0, 0); len(s.HintLabels) != 0 {
703 t.Fatalf("labels %+v", s.HintLabels)
704 }
705}
706
707// A window that does not have the keyboard gets it before the click.
708func TestHintClickFocusesWindowFirst(t *testing.T) {
709 h := newHint(t, core.Options{})
710 h.client <- ports.WindowMapped{ID: 2}
711 s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
712 var r ports.Rect
713 for _, w := range s.Windows {
714 if w.ID == 1 {
715 r = w.Rect
716 }
717 }
718 h.win = r
719 h.show(h.inside(0))
720 h.drain()
721 h.press("f", 0)
722 got := commandKinds(h.commandsSent())
723 want := []string{"focus:1", "mods:0", "motion", "press", "release", "mods:restore"}
724 if !slices.Equal(got, want) {
725 t.Fatalf("commands %v, want %v", got, want)
726 }
727}
728
729// A click that is no longer allowed sends no command at all.
730func TestHintClickNothingWhenNotClickable(t *testing.T) {
731 h := newHint(t, core.Options{})
732 h.show(h.inside(0))
733 h.constrain(ports.ConstraintLock, ports.Rect{})
734 h.drain()
735 h.press("f", 0)
736 if got := commandKinds(h.commandsSent()); len(got) != 0 {
737 t.Fatalf("commands %v under a constraint", got)
738 }
739}
740
741func TestHintDisabledNotesOnce(t *testing.T) {
742 cfg := scrollDefaults()
743 cfg.Binds["Cmd+g"] = "hint"
744 var noted atomic.Int32
745 h := &hintHarness{
746 input: make(chan ports.InputEvent, 32),
747 client: make(chan ports.ClientEvent, 32),
748 output: make(chan ports.OutputEvent, 8),
749 commands: make(chan ports.ClientCommand, 256),
750 scenes: make(chan []ports.Scene, 1),
751 }
752 ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes}
753 c, err := core.New(cfg, ch, core.Options{HintDisabled: func() { noted.Add(1) }})
754 if err != nil {
755 t.Fatal(err)
756 }
757 ctx, cancel := context.WithCancel(context.Background())
758 done := make(chan error, 1)
759 go func() { done <- c.Run(ctx) }()
760 t.Cleanup(func() { cancel(); receive(t, done) })
761 h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
762 scene(t, h.scenes)
763 for range 3 {
764 h.press("g", ports.ModSuper)
765 h.release("g", 0)
766 }
767 c.Ping(ctx)
768 if got := noted.Load(); got != 1 {
769 t.Fatalf("disabled note ran %d times, want 1", got)
770 }
771}
772
773// Resizing window 1 leaves the old target centre inside it. Identity alone
774// therefore cannot protect either admission or the eventual synthetic click.
775func TestHintRejectsResizedSource(t *testing.T) {
776 {
777 for _, beforeResult := range []bool{false, true} {
778 t.Run(fmt.Sprintf("beforeResult=%v", beforeResult), func(t *testing.T) {
779 h := newHint(t, core.Options{})
780 tg := h.inside(0)
781 req := h.start()
782 if !beforeResult {
783 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
784 h.labelled(1, 0)
785 }
786 h.client <- ports.WindowMapped{ID: 2}
787 s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
788 var resized ports.Rect
789 for _, w := range s.Windows {
790 if w.ID == 1 {
791 resized = w.Rect
792 }
793 }
794 x, y := tg.Rect.X+tg.Rect.W/2, tg.Rect.Y+tg.Rect.H/2
795 if resized == h.win || x < resized.X || x >= resized.X+resized.W || y < resized.Y || y >= resized.Y+resized.H {
796 t.Fatalf("setup: old target %+v not inside resized window %+v (was %+v)", tg.Rect, resized, h.win)
797 }
798 if beforeResult {
799 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
800 h.labelled(0, 0)
801 } else {
802 h.drain()
803 h.press("f", 0)
804 for _, cmd := range h.barrier() {
805 if _, ok := cmd.(ports.PointerButtonTo); ok {
806 t.Fatal("clicked resized source window")
807 }
808 }
809 }
810 })
811 }
812 }
813}
814
815// hint.capture reaches the scene next to the labels, off by default.
816func TestHintCaptureReachesScene(t *testing.T) {
817 for _, on := range []bool{false, true} {
818 h := newHintConfig(t, core.Options{}, func(cfg *ports.Config) { cfg.Hint.Capture = on })
819 s := h.show(h.inside(0))
820 if s.HintStyle.Capture != on {
821 t.Errorf("hint.capture %v: scene has %v", on, s.HintStyle.Capture)
822 }
823 }
824}
825
826// Labels hang off the targets' top-left corners: targets whose corners are
827// closer than a label drop, the lowest score first, whatever the input order.
828func TestHintOverlappingLabelsDropLowerScore(t *testing.T) {
829 at := func(h *hintHarness, dx, dy int, score float32) ports.HintTarget {
830 return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 20 + dx, Y: h.win.Y + 20 + dy, W: 80, H: 30}, Score: score}
831 }
832 for _, rev := range []bool{false, true} {
833 h := newHint(t, core.Options{})
834 ts := []ports.HintTarget{
835 at(h, 0, 0, 0.4), // clashes with the next one, loses
836 at(h, 3, 2, 0.9), // wins
837 at(h, 300, 0, 0.1), // far away: kept, whatever its score
838 at(h, 0, 200, 0.5), // far below
839 at(h, 4, 200, 0.45), // clashes with the previous one
840 }
841 if rev {
842 slices.Reverse(ts)
843 }
844 req := h.start()
845 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: ts}
846 s := h.labelled(3, 0)
847 var got [][2]int
848 for _, l := range s.HintLabels {
849 got = append(got, [2]int{l.Rect.X - h.win.X - 20, l.Rect.Y - h.win.Y - 20})
850 }
851 want := [][2]int{{300, 0}, {3, 2}, {0, 200}} // sorted by Y, then X
852 if !slices.Equal(got, want) {
853 t.Fatalf("rev=%v: label corners %v, want %v", rev, got, want)
854 }
855 }
856}
857
858func TestHintSizeReloadCancelsShownHints(t *testing.T) {
859 h := newHint(t, core.Options{})
860 h.show(h.inside(0))
861 cfg := h.cfg
862 cfg.Hint.Size = 40
863 h.config <- ports.ConfigChanged{Config: cfg}
864 h.until(func(s ports.Scene) bool { return s.HintStyle.Size == 40 && len(s.HintLabels) == 0 })
865}