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