detached f2d166b

hint_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close