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}
29
30// newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped.
31func newHint(t *testing.T, opts core.Options) *hintHarness {
32 t.Helper()
33 return newHintMods(t, opts, 0)
34}
35
36// newHintMods is newHint with hint.text-click set to textClick.
37func newHintMods(t *testing.T, opts core.Options, textClick ports.Mods) *hintHarness {
38 t.Helper()
39 cfg := scrollDefaults()
40 cfg.Hint.TextClick = textClick
41 cfg.Border.Width = 0
42 cfg.Layout.Gaps = 20
43 cfg.Binds["Cmd+g"] = "hint"
44 h := &hintHarness{
45 t: t,
46 input: make(chan ports.InputEvent, 32),
47 client: make(chan ports.ClientEvent, 32),
48 output: make(chan ports.OutputEvent, 8),
49 commands: make(chan ports.ClientCommand, 256),
50 scenes: make(chan []ports.Scene, 1),
51 scans: make(chan ports.HintScanRequest, 1),
52 // Unbuffered: a send returns once core took the result.
53 hints: make(chan ports.HintScanResult),
54 }
55 ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes, HintScans: h.scans, Hints: h.hints}
56 c, err := core.New(cfg, ch, opts)
57 if err != nil {
58 t.Fatal(err)
59 }
60 ctx, cancel := context.WithCancel(context.Background())
61 done := make(chan error, 1)
62 go func() { done <- c.Run(ctx) }()
63 t.Cleanup(func() { cancel(); receive(t, done) })
64 h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
65 scene(t, h.scenes)
66 h.client <- ports.WindowMapped{ID: 1, AppID: "org.test.app", PID: 4242}
67 s := scene(t, h.scenes)
68 h.win = s.Windows[0].Rect
69 h.drain()
70 return h
71}
72
73func (h *hintHarness) drain() {
74 for {
75 select {
76 case <-h.commands:
77 default:
78 return
79 }
80 }
81}
82
83// press and release send one key event.
84func (h *hintHarness) press(sym string, mods ports.Mods) {
85 h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods, Pressed: true})
86}
87
88func (h *hintHarness) release(sym string, mods ports.Mods) {
89 h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods})
90}
91
92// send gives core an input event stamped with the current security state.
93func (h *hintHarness) send(ev ports.InputEvent) {
94 h.input <- ports.SecurityInput{State: h.state, Event: ev}
95}
96
97// start presses the hint bind and returns the request.
98func (h *hintHarness) start() ports.HintScanRequest {
99 h.t.Helper()
100 h.press("g", ports.ModSuper)
101 req := receive(h.t, h.scans)
102 h.release("g", 0) // the user lets go of the whole chord
103 return req
104}
105
106// show starts hint mode and answers with targets, returning the next scene.
107func (h *hintHarness) show(targets ...ports.HintTarget) ports.Scene {
108 h.t.Helper()
109 req := h.start()
110 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: targets}
111 return h.labelled(len(targets), 0)
112}
113
114// until returns the first scene, from the next one on, that satisfies ok:
115// the bind press and other input publish scenes before the one waited for.
116func (h *hintHarness) until(ok func(ports.Scene) bool) ports.Scene {
117 h.t.Helper()
118 for {
119 if s := scene(h.t, h.scenes); ok(s) {
120 return s
121 }
122 }
123}
124
125// labelled waits for a scene with n labels, all typed with typed letters.
126func (h *hintHarness) labelled(n, typed int) ports.Scene {
127 h.t.Helper()
128 return h.until(func(s ports.Scene) bool {
129 return len(s.HintLabels) == n && (n == 0 || s.HintLabels[0].Typed == typed)
130 })
131}
132
133// inside is a target of the given size inside window 1.
134func (h *hintHarness) inside(i int) ports.HintTarget {
135 return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}}
136}
137
138func TestHintRequestOnBind(t *testing.T) {
139 h := newHint(t, core.Options{})
140 req := h.start()
141 if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.LinksOnly || req.Seq == 0 {
142 t.Fatalf("request %+v", req)
143 }
144 want := []ports.HintWindow{{ID: 1, PID: 4242, AppID: "org.test.app", Rect: h.win, Focused: true}}
145 if !slices.Equal(req.Windows, want) {
146 t.Fatalf("windows %+v, want %+v", req.Windows, want)
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, Y: h.win.Y + 5 + 14*i, 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, Y: h.win.Y + 5 + 14*i, 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 presses, releases := h.buttonsSent()
470 if presses != 1 || releases != 1 {
471 t.Fatalf("client got %d presses, %d releases", presses, releases)
472 }
473 // No drag is left behind: a later plain motion and a click work as usual.
474 h.release("f", ports.ModSuper)
475 h.release("Super_L", 0)
476 h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)})
477 h.send(ports.PointerButton{Button: 0x110, Pressed: true})
478 h.send(ports.PointerButton{Button: 0x110})
479 if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 {
480 t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases)
481 }
482}
483
484// constrain gives window 1 a pointer constraint and waits until core applied it.
485func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) {
486 h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}
487 h.client <- ports.WindowMapped{ID: 1} // a publish after the event
488 h.until(func(ports.Scene) bool { return true })
489}
490
491func TestHintRefusedWithPointerConstraint(t *testing.T) {
492 for name, c := range map[string]ports.PointerConstraint{
493 "lock": {Mode: ports.ConstraintLock},
494 "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
495 } {
496 t.Run(name, func(t *testing.T) {
497 h := newHint(t, core.Options{})
498 h.constrain(c.Mode, c.Rect)
499 h.press("g", ports.ModSuper)
500 select {
501 case r := <-h.scans:
502 t.Fatalf("request under a %s: %+v", name, r)
503 case <-time.After(100 * time.Millisecond):
504 }
505 })
506 }
507}
508
509// A constraint that appears while the labels are shown (the pointer would be
510// held or bent away from the target) cancels the click.
511func TestHintNoClickWhenConstraintAppears(t *testing.T) {
512 for name, c := range map[string]ports.PointerConstraint{
513 "lock": {Mode: ports.ConstraintLock},
514 "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
515 } {
516 t.Run(name, func(t *testing.T) {
517 h := newHint(t, core.Options{})
518 h.show(h.inside(0))
519 h.constrain(c.Mode, c.Rect)
520 h.drain()
521 h.press("f", 0)
522 if p, r := h.buttonsSent(); p+r != 0 {
523 t.Fatalf("%d presses, %d releases under a %s", p, r, name)
524 }
525 })
526 }
527}
528
529// pressReal sends a real left press over window 1 and waits for the client to
530// see it: the window then holds the pointer grab.
531func (h *hintHarness) pressReal() {
532 h.t.Helper()
533 h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)})
534 h.send(ports.PointerButton{Button: 0x110, Pressed: true})
535 for {
536 cmd := command(h.t, h.commands)
537 if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed {
538 return
539 }
540 }
541}
542
543func TestHintRefusedWhileButtonHeld(t *testing.T) {
544 h := newHint(t, core.Options{})
545 h.pressReal()
546 h.press("g", ports.ModSuper)
547 select {
548 case r := <-h.scans:
549 t.Fatalf("request with a button held: %+v", r)
550 case <-time.After(100 * time.Millisecond):
551 }
552}
553
554// A button pressed (and so a grab taken) after the labels appeared: the label
555// is typed, no synthetic button goes out and the real press stays held.
556func TestHintNoClickWhileGrabbed(t *testing.T) {
557 h := newHint(t, core.Options{})
558 h.show(h.inside(0))
559 h.pressReal()
560 h.press("f", 0)
561 if p, r := h.buttonsSent(); p+r != 0 {
562 t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r)
563 }
564 // The real release still reaches the client: c.buttons was not touched.
565 h.send(ports.PointerButton{Button: 0x110})
566 if _, r := h.buttonsSent(); r != 1 {
567 t.Fatalf("real release delivered %d times", r)
568 }
569}
570
571// The window stays mapped but moves away from the target's centre.
572func TestHintMappedWindowMovedAborts(t *testing.T) {
573 h := newHint(t, core.Options{})
574 // A target at the right edge of window 1.
575 tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}}
576 h.show(tg)
577 h.client <- ports.WindowMapped{ID: 2}
578 s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 })
579 if s.Windows[0].Rect == h.win {
580 t.Fatalf("window 1 did not move: %+v", s.Windows)
581 }
582 h.drain()
583 h.press("f", 0)
584 if p, r := h.buttonsSent(); p+r != 0 {
585 t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
586 }
587}
588
589// commandsSent drains the commands up to a barrier, keeping the ones that
590// matter for a click: modifiers, focus, motion and buttons.
591func (h *hintHarness) commandsSent() []ports.ClientCommand {
592 var out []ports.ClientCommand
593 for _, cmd := range h.barrier() {
594 switch cmd.(type) {
595 case ports.SetModifiers, ports.FocusWindow, ports.PointerMotionTo, ports.PointerButtonTo:
596 out = append(out, cmd)
597 }
598 }
599 return out
600}
601
602func commandKinds(cmds []ports.ClientCommand) []string {
603 var out []string
604 for _, c := range cmds {
605 switch v := c.(type) {
606 case ports.SetModifiers:
607 if v.Restore {
608 out = append(out, "restore")
609 } else {
610 out = append(out, fmt.Sprintf("mods:%d", v.Mods))
611 }
612 case ports.FocusWindow:
613 out = append(out, fmt.Sprintf("focus:%d", v.ID))
614 case ports.PointerMotionTo:
615 out = append(out, "motion")
616 case ports.PointerButtonTo:
617 if v.Pressed {
618 out = append(out, "press")
619 } else {
620 out = append(out, "release")
621 }
622 }
623 }
624 return out
625}
626
627// An OCR target is clicked with the configured modifiers held: they go out
628// before the motion and are restored after the release.
629func TestHintTextClickHoldsModifiers(t *testing.T) {
630 mods := ports.ModCtrl | ports.ModShift
631 h := newHintMods(t, core.Options{}, mods)
632 tg := h.inside(0)
633 tg.Text = true
634 h.show(tg)
635 h.drain()
636 h.press("f", 0)
637 got := commandKinds(h.commandsSent())
638 want := []string{fmt.Sprintf("mods:%d", mods), "motion", "press", "release", "restore"}
639 if !slices.Equal(got, want) {
640 t.Fatalf("commands %v, want %v", got, want)
641 }
642}
643
644// Without text targets or without modifiers the click states no modifiers
645// (depressed 0) rather than leave the held bind modifier to the client.
646func TestHintClickWithoutModifiers(t *testing.T) {
647 for name, c := range map[string]struct {
648 mods ports.Mods
649 text bool
650 }{"accessibility target": {ports.ModCtrl, false}, "none configured": {0, true}} {
651 t.Run(name, func(t *testing.T) {
652 h := newHintMods(t, core.Options{}, c.mods)
653 tg := h.inside(0)
654 tg.Text = c.text
655 h.show(tg)
656 h.drain()
657 h.press("f", 0)
658 got := commandKinds(h.commandsSent())
659 if want := []string{"mods:0", "motion", "press", "release", "restore"}; !slices.Equal(got, want) {
660 t.Fatalf("commands %v, want %v", got, want)
661 }
662 })
663 }
664}
665
666// The bind's Super is still physically held: an accessibility click states
667// depressed 0, and the restore carries the real state of the latest key,
668// swallowed ones included (a latch a label key consumed).
669func TestHintClickHeldBindModifierAndLatchRestore(t *testing.T) {
670 h := newHint(t, core.Options{})
671 tg := h.inside(0)
672 h.press("g", ports.ModSuper)
673 req := receive(t, h.scans)
674 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg, h.inside(1)}}
675 h.labelled(2, 0)
676 h.drain()
677 real := ports.ModState{Depressed: 64, Latched: 0, Locked: 2}
678 h.send(ports.KeyEvent{Keysym: "f", Base: "f", Mods: ports.ModSuper, Pressed: true, State: real})
679 cmds := h.commandsSent()
680 var restore *ports.SetModifiers
681 for i, c := range cmds {
682 if v, ok := c.(ports.SetModifiers); ok {
683 if i == 0 && (v.Restore || v.Mods != 0) {
684 t.Fatalf("first modifiers %+v, want depressed 0", v)
685 }
686 if v.Restore {
687 restore = &v
688 }
689 }
690 }
691 if restore == nil || restore.State != real {
692 t.Fatalf("restore %+v, want state %+v (commands %v)", restore, real, commandKinds(cmds))
693 }
694}
695
696// A result is kept only for the window it was scanned on.
697func TestHintResultWindowChecks(t *testing.T) {
698 h := newHint(t, core.Options{})
699 req := h.start()
700 // Accessibility target naming another window: dropped; naming window 1: kept.
701 ok := h.inside(1)
702 ok.Window = 1
703 bad := h.inside(0)
704 bad.Window = 7
705 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{bad, ok}}
706 s := h.labelled(1, 0)
707 if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.Y != ok.Rect.Y {
708 t.Fatalf("labels %+v", s.HintLabels)
709 }
710}
711
712// A window that moved while the scan ran: an OCR target over it is dropped.
713func TestHintOCRResultDroppedWhenWindowMoved(t *testing.T) {
714 h := newHint(t, core.Options{})
715 req := h.start()
716 tg := h.inside(0)
717 tg.Text = true
718 h.client <- ports.WindowMapped{ID: 2}
719 h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
720 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
721 // Whatever window now covers the target was not scanned: no labels.
722 if s := h.labelled(0, 0); len(s.HintLabels) != 0 {
723 t.Fatalf("labels %+v", s.HintLabels)
724 }
725}
726
727// A new window covering the target (not in the snapshot) drops it as well.
728func TestHintOCRResultDroppedForUnseenWindow(t *testing.T) {
729 h := newHint(t, core.Options{})
730 req := h.start()
731 h.client <- ports.WindowMapped{ID: 2}
732 s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
733 tg := ports.HintTarget{Text: true}
734 for _, w := range s.Windows {
735 if w.ID == 2 {
736 tg.Rect = ports.Rect{X: w.Rect.X + 5, Y: w.Rect.Y + 5, W: 20, H: 10}
737 }
738 }
739 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
740 if s := h.labelled(0, 0); len(s.HintLabels) != 0 {
741 t.Fatalf("labels %+v", s.HintLabels)
742 }
743}
744
745// Targets outside the output are dropped.
746func TestHintDropsTargetsOutsideOutput(t *testing.T) {
747 h := newHint(t, core.Options{})
748 req := h.start()
749 out := ports.HintTarget{Rect: ports.Rect{X: 2000, Y: 10, W: 10, H: 10}}
750 h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, h.inside(0)}}
751 if s := h.labelled(1, 0); len(s.HintLabels) != 1 {
752 t.Fatalf("labels %+v", s.HintLabels)
753 }
754}
755
756// The output changing size while labels are shown ends hint mode.
757func TestHintCancelsWhenOutputChanges(t *testing.T) {
758 h := newHint(t, core.Options{})
759 h.show(h.inside(0))
760 h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1200, Height: 800}}
761 if s := h.labelled(0, 0); len(s.HintLabels) != 0 {
762 t.Fatalf("labels %+v", s.HintLabels)
763 }
764}
765
766// A window that does not have the keyboard gets it before the modifiers.
767func TestHintTextClickFocusesWindowFirst(t *testing.T) {
768 h := newHintMods(t, core.Options{}, ports.ModCtrl)
769 h.client <- ports.WindowMapped{ID: 2}
770 s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
771 var r ports.Rect
772 for _, w := range s.Windows {
773 if w.ID == 1 {
774 r = w.Rect
775 }
776 }
777 h.win = r
778 tg := h.inside(0)
779 tg.Text = true
780 h.show(tg)
781 h.drain()
782 h.press("f", 0)
783 got := commandKinds(h.commandsSent())
784 want := []string{"focus:1", fmt.Sprintf("mods:%d", ports.ModCtrl), "motion", "press", "release", "restore"}
785 if !slices.Equal(got, want) {
786 t.Fatalf("commands %v, want %v", got, want)
787 }
788}
789
790// A click that is no longer allowed sends no modifiers at all.
791func TestHintTextClickNoModifiersWhenNotClickable(t *testing.T) {
792 h := newHintMods(t, core.Options{}, ports.ModCtrl)
793 tg := h.inside(0)
794 tg.Text = true
795 h.show(tg)
796 h.constrain(ports.ConstraintLock, ports.Rect{})
797 h.drain()
798 h.press("f", 0)
799 if got := commandKinds(h.commandsSent()); len(got) != 0 {
800 t.Fatalf("commands %v under a constraint", got)
801 }
802}
803
804func TestHintDisabledNotesOnce(t *testing.T) {
805 cfg := scrollDefaults()
806 cfg.Binds["Cmd+g"] = "hint"
807 var noted atomic.Int32
808 h := &hintHarness{
809 input: make(chan ports.InputEvent, 32),
810 client: make(chan ports.ClientEvent, 32),
811 output: make(chan ports.OutputEvent, 8),
812 commands: make(chan ports.ClientCommand, 256),
813 scenes: make(chan []ports.Scene, 1),
814 }
815 ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes}
816 c, err := core.New(cfg, ch, core.Options{HintDisabled: func() { noted.Add(1) }})
817 if err != nil {
818 t.Fatal(err)
819 }
820 ctx, cancel := context.WithCancel(context.Background())
821 done := make(chan error, 1)
822 go func() { done <- c.Run(ctx) }()
823 t.Cleanup(func() { cancel(); receive(t, done) })
824 h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
825 scene(t, h.scenes)
826 for range 3 {
827 h.press("g", ports.ModSuper)
828 h.release("g", 0)
829 }
830 c.Ping(ctx)
831 if got := noted.Load(); got != 1 {
832 t.Fatalf("disabled note ran %d times, want 1", got)
833 }
834}