feat/hints a471fa4

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	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}

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 25

Tags 10

↑↓ moveenter openesc close