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

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