detached 4b455b2

feat(hints): capture and scan the output for hint targets

commit
4b455b2d9e473b845a0c340bfb4ebdd4845bdfe6
author
brice · · 2026-10-19 10:36 UTC
parent
df4b2fe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +330 -0
A internal/adapters/hints/hints.go +181 -0
@@ -0,0 +1,181 @@
1+// Package hints finds clickable targets for hint mode: it captures the last
2+// frame of an output through the capture pipeline and scans it with hintscan.
3+package hints
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "image"
10+ "time"
11+
12+ "bnema.dev/hintscan"
13+
14+ "github.com/bnema/neferwl/internal/adapters/logging"
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+// ErrBusy answers a request that arrives while another one is in flight.
19+var ErrBusy = errors.New("hints: scan in progress")
20+
21+// ErrTimeout is the error of a capture that is not answered in time.
22+var ErrTimeout = errors.New("hints: capture timed out")
23+
24+// ErrBadRequest is the error of a request without a usable buffer size.
25+var ErrBadRequest = errors.New("hints: invalid output size")
26+
27+const (
28+ captureTimeout = time.Second
29+ // formatXRGB8888 is the capture format: opaque BGRA in memory.
30+ formatXRGB8888 = 1
31+ maxPixels = 1 << 28
32+)
33+
34+// Run serves requests until ctx is done. It handles one request at a time;
35+// a request that arrives meanwhile is answered with ErrBusy. Every request
36+// gets exactly one result on results.
37+func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult) error {
38+ return run(ctx, requests, captures, captured, results, captureTimeout)
39+}
40+
41+type worker struct {
42+ scanner *hintscan.Scanner
43+ buf []byte
44+ dst []hintscan.Target
45+ n uint64
46+}
47+
48+func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) error {
49+ w := &worker{scanner: hintscan.NewScanner()}
50+ for {
51+ select {
52+ case <-ctx.Done():
53+ return nil
54+ case req := <-requests:
55+ res := w.serve(ctx, req, requests, captures, captured, results, timeout)
56+ if !send(ctx, results, res) {
57+ return nil
58+ }
59+ }
60+ }
61+}
62+
63+func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool {
64+ select {
65+ case results <- res:
66+ return true
67+ case <-ctx.Done():
68+ return false
69+ }
70+}
71+
72+func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) ports.HintScanResult {
73+ log := logging.For(ctx, "hints")
74+ res := ports.HintScanResult{Seq: req.Seq, Output: req.Output}
75+ fail := func(err error) ports.HintScanResult {
76+ log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
77+ res.Err = err
78+ return res
79+ }
80+
81+ if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {
82+ return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))
83+ }
84+ size := req.Width * req.Height * 4
85+ if cap(w.buf) < size {
86+ w.buf = make([]byte, size)
87+ }
88+ buf := w.buf[:size]
89+
90+ w.n++
91+ id := ports.CaptureIDInternal | w.n
92+ stride := req.Width * 4
93+ start := time.Now()
94+ creq := ports.CaptureRequest{
95+ ID: id,
96+ Output: req.Output,
97+ Region: image.Rect(0, 0, req.Width, req.Height),
98+ Mapped: buf,
99+ Width: req.Width,
100+ Height: req.Height,
101+ Stride: stride,
102+ Format: formatXRGB8888,
103+ Indicate: false,
104+ }
105+
106+ timer := time.NewTimer(timeout)
107+ defer timer.Stop()
108+
109+ select {
110+ case captures <- creq:
111+ case <-timer.C:
112+ return fail(ErrTimeout)
113+ case <-ctx.Done():
114+ return fail(ctx.Err())
115+ }
116+
117+ for {
118+ select {
119+ case done := <-captured:
120+ if done.ID != id {
121+ continue
122+ }
123+ if done.Err != nil {
124+ return fail(fmt.Errorf("hints: capture: %w", done.Err))
125+ }
126+ return w.scan(ctx, req, buf, stride, time.Since(start), res)
127+ case other := <-requests:
128+ // The buffer is in use until the capture is done: answer now.
129+ busy := ports.HintScanResult{Seq: other.Seq, Output: other.Output, Err: ErrBusy}
130+ if !send(ctx, results, busy) {
131+ return fail(ctx.Err())
132+ }
133+ case <-timer.C:
134+ // The output may still write into buf: do not reuse it.
135+ w.buf = nil
136+ return fail(ErrTimeout)
137+ case <-ctx.Done():
138+ w.buf = nil
139+ return fail(ctx.Err())
140+ }
141+ }
142+}
143+
144+func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
145+ log := logging.For(ctx, "hints")
146+ kinds := hintscan.Clickable
147+ if req.LinksOnly {
148+ kinds = hintscan.KindSet(1 << hintscan.KindLink)
149+ }
150+ frame := hintscan.Frame{Pix: buf, Stride: stride, Width: req.Width, Height: req.Height, Order: hintscan.OrderBGRA, Scale: req.Scale}
151+
152+ t0 := time.Now()
153+ targets, err := w.scanner.Scan(ctx, frame, hintscan.Options{Kinds: kinds}, w.dst)
154+ scanDur := time.Since(t0)
155+ if err != nil {
156+ log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
157+ res.Err = err
158+ return res
159+ }
160+ w.dst = targets
161+
162+ out := make([]ports.HintTarget, 0, len(targets))
163+ for _, t := range targets {
164+ kind := ports.HintLink
165+ if t.Kind == hintscan.KindButton {
166+ kind = ports.HintButton
167+ }
168+ out = append(out, ports.HintTarget{
169+ Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},
170+ Kind: kind,
171+ })
172+ }
173+ res.Targets = out
174+ log.Info().
175+ Str("output", req.Output).
176+ Int("targets", len(out)).
177+ Dur("scan", scanDur).
178+ Dur("capture", captureDur).
179+ Msg("hint scan")
180+ return res
181+}
@@ -0,0 +1,181 @@
1+// Package hints finds clickable targets for hint mode: it captures the last
2+// frame of an output through the capture pipeline and scans it with hintscan.
3+package hints
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "image"
10+ "time"
11+
12+ "bnema.dev/hintscan"
13+
14+ "github.com/bnema/neferwl/internal/adapters/logging"
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+// ErrBusy answers a request that arrives while another one is in flight.
19+var ErrBusy = errors.New("hints: scan in progress")
20+
21+// ErrTimeout is the error of a capture that is not answered in time.
22+var ErrTimeout = errors.New("hints: capture timed out")
23+
24+// ErrBadRequest is the error of a request without a usable buffer size.
25+var ErrBadRequest = errors.New("hints: invalid output size")
26+
27+const (
28+ captureTimeout = time.Second
29+ // formatXRGB8888 is the capture format: opaque BGRA in memory.
30+ formatXRGB8888 = 1
31+ maxPixels = 1 << 28
32+)
33+
34+// Run serves requests until ctx is done. It handles one request at a time;
35+// a request that arrives meanwhile is answered with ErrBusy. Every request
36+// gets exactly one result on results.
37+func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult) error {
38+ return run(ctx, requests, captures, captured, results, captureTimeout)
39+}
40+
41+type worker struct {
42+ scanner *hintscan.Scanner
43+ buf []byte
44+ dst []hintscan.Target
45+ n uint64
46+}
47+
48+func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) error {
49+ w := &worker{scanner: hintscan.NewScanner()}
50+ for {
51+ select {
52+ case <-ctx.Done():
53+ return nil
54+ case req := <-requests:
55+ res := w.serve(ctx, req, requests, captures, captured, results, timeout)
56+ if !send(ctx, results, res) {
57+ return nil
58+ }
59+ }
60+ }
61+}
62+
63+func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool {
64+ select {
65+ case results <- res:
66+ return true
67+ case <-ctx.Done():
68+ return false
69+ }
70+}
71+
72+func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) ports.HintScanResult {
73+ log := logging.For(ctx, "hints")
74+ res := ports.HintScanResult{Seq: req.Seq, Output: req.Output}
75+ fail := func(err error) ports.HintScanResult {
76+ log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
77+ res.Err = err
78+ return res
79+ }
80+
81+ if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {
82+ return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))
83+ }
84+ size := req.Width * req.Height * 4
85+ if cap(w.buf) < size {
86+ w.buf = make([]byte, size)
87+ }
88+ buf := w.buf[:size]
89+
90+ w.n++
91+ id := ports.CaptureIDInternal | w.n
92+ stride := req.Width * 4
93+ start := time.Now()
94+ creq := ports.CaptureRequest{
95+ ID: id,
96+ Output: req.Output,
97+ Region: image.Rect(0, 0, req.Width, req.Height),
98+ Mapped: buf,
99+ Width: req.Width,
100+ Height: req.Height,
101+ Stride: stride,
102+ Format: formatXRGB8888,
103+ Indicate: false,
104+ }
105+
106+ timer := time.NewTimer(timeout)
107+ defer timer.Stop()
108+
109+ select {
110+ case captures <- creq:
111+ case <-timer.C:
112+ return fail(ErrTimeout)
113+ case <-ctx.Done():
114+ return fail(ctx.Err())
115+ }
116+
117+ for {
118+ select {
119+ case done := <-captured:
120+ if done.ID != id {
121+ continue
122+ }
123+ if done.Err != nil {
124+ return fail(fmt.Errorf("hints: capture: %w", done.Err))
125+ }
126+ return w.scan(ctx, req, buf, stride, time.Since(start), res)
127+ case other := <-requests:
128+ // The buffer is in use until the capture is done: answer now.
129+ busy := ports.HintScanResult{Seq: other.Seq, Output: other.Output, Err: ErrBusy}
130+ if !send(ctx, results, busy) {
131+ return fail(ctx.Err())
132+ }
133+ case <-timer.C:
134+ // The output may still write into buf: do not reuse it.
135+ w.buf = nil
136+ return fail(ErrTimeout)
137+ case <-ctx.Done():
138+ w.buf = nil
139+ return fail(ctx.Err())
140+ }
141+ }
142+}
143+
144+func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
145+ log := logging.For(ctx, "hints")
146+ kinds := hintscan.Clickable
147+ if req.LinksOnly {
148+ kinds = hintscan.KindSet(1 << hintscan.KindLink)
149+ }
150+ frame := hintscan.Frame{Pix: buf, Stride: stride, Width: req.Width, Height: req.Height, Order: hintscan.OrderBGRA, Scale: req.Scale}
151+
152+ t0 := time.Now()
153+ targets, err := w.scanner.Scan(ctx, frame, hintscan.Options{Kinds: kinds}, w.dst)
154+ scanDur := time.Since(t0)
155+ if err != nil {
156+ log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
157+ res.Err = err
158+ return res
159+ }
160+ w.dst = targets
161+
162+ out := make([]ports.HintTarget, 0, len(targets))
163+ for _, t := range targets {
164+ kind := ports.HintLink
165+ if t.Kind == hintscan.KindButton {
166+ kind = ports.HintButton
167+ }
168+ out = append(out, ports.HintTarget{
169+ Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},
170+ Kind: kind,
171+ })
172+ }
173+ res.Targets = out
174+ log.Info().
175+ Str("output", req.Output).
176+ Int("targets", len(out)).
177+ Dur("scan", scanDur).
178+ Dur("capture", captureDur).
179+ Msg("hint scan")
180+ return res
181+}
A internal/adapters/hints/hints_test.go +149 -0
@@ -0,0 +1,149 @@
1+package hints
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "image/draw"
8+ _ "image/png"
9+ "os"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+type harness struct {
19+ requests chan ports.HintScanRequest
20+ captures chan ports.CaptureRequest
21+ captured chan ports.CaptureDone
22+ results chan ports.HintScanResult
23+}
24+
25+func start(t *testing.T, timeout time.Duration) *harness {
26+ t.Helper()
27+ h := &harness{
28+ requests: make(chan ports.HintScanRequest, 1),
29+ captures: make(chan ports.CaptureRequest, 4),
30+ captured: make(chan ports.CaptureDone, 1),
31+ results: make(chan ports.HintScanResult, 4),
32+ }
33+ ctx, cancel := context.WithCancel(context.Background())
34+ exited := make(chan struct{})
35+ go func() {
36+ defer close(exited)
37+ _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout)
38+ }()
39+ t.Cleanup(func() { cancel(); <-exited })
40+ return h
41+}
42+
43+func loadBGRA(t *testing.T) (pix []byte, w, hgt int) {
44+ t.Helper()
45+ f, err := os.Open("testdata/browser.png")
46+ require.NoError(t, err)
47+ defer f.Close()
48+ img, _, err := image.Decode(f)
49+ require.NoError(t, err)
50+ b := img.Bounds()
51+ rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
52+ draw.Draw(rgba, rgba.Bounds(), img, b.Min, draw.Src)
53+ for i := 0; i < len(rgba.Pix); i += 4 {
54+ rgba.Pix[i], rgba.Pix[i+2] = rgba.Pix[i+2], rgba.Pix[i]
55+ rgba.Pix[i+3] = 0xff
56+ }
57+ return rgba.Pix, b.Dx(), b.Dy()
58+}
59+
60+func TestHintScan(t *testing.T) {
61+ pix, w, hgt := loadBGRA(t)
62+ h := start(t, 5*time.Second)
63+
64+ // Plays the output owner.
65+ go func() {
66+ creq := <-h.captures
67+ copy(creq.Mapped, pix)
68+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
69+ }()
70+
71+ h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}
72+ res := <-h.results
73+ require.NoError(t, res.Err)
74+ require.Equal(t, uint64(7), res.Seq)
75+ require.Equal(t, "HEADLESS-1", res.Output)
76+ var links int
77+ for _, tg := range res.Targets {
78+ require.Positive(t, tg.Rect.W)
79+ require.Positive(t, tg.Rect.H)
80+ if tg.Kind == ports.HintLink {
81+ links++
82+ }
83+ }
84+ require.GreaterOrEqual(t, links, 1)
85+}
86+
87+func TestHintRequestShape(t *testing.T) {
88+ h := start(t, 5*time.Second)
89+ got := make(chan ports.CaptureRequest, 1)
90+ go func() {
91+ creq := <-h.captures
92+ got <- creq
93+ h.captured <- ports.CaptureDone{ID: creq.ID + 1} // another ID: ignored
94+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} // blank frame
95+ }()
96+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
97+ res := <-h.results
98+ require.NoError(t, res.Err)
99+ creq := <-got
100+ require.NotZero(t, creq.ID&ports.CaptureIDInternal)
101+ require.False(t, creq.Indicate)
102+ require.Equal(t, image.Rect(0, 0, 64, 32), creq.Region)
103+ require.Len(t, creq.Mapped, 64*32*4)
104+ require.Equal(t, 64*4, creq.Stride)
105+ require.Equal(t, uint32(1), creq.Format)
106+}
107+
108+func TestHintCaptureError(t *testing.T) {
109+ h := start(t, 5*time.Second)
110+ boom := errors.New("boom")
111+ go func() {
112+ creq := <-h.captures
113+ h.captured <- ports.CaptureDone{ID: creq.ID, Err: boom}
114+ }()
115+ h.requests <- ports.HintScanRequest{Seq: 3, Output: "o", Width: 64, Height: 32, Scale: 1}
116+ res := <-h.results
117+ require.ErrorIs(t, res.Err, boom)
118+ require.Equal(t, uint64(3), res.Seq)
119+ require.Empty(t, res.Targets)
120+}
121+
122+func TestHintTimeout(t *testing.T) {
123+ h := start(t, 50*time.Millisecond)
124+ h.requests <- ports.HintScanRequest{Seq: 4, Output: "o", Width: 64, Height: 32, Scale: 1}
125+ res := <-h.results
126+ require.ErrorIs(t, res.Err, ErrTimeout)
127+ require.Equal(t, uint64(4), res.Seq)
128+}
129+
130+func TestHintBusy(t *testing.T) {
131+ h := start(t, 5*time.Second)
132+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
133+ creq := <-h.captures
134+ h.requests <- ports.HintScanRequest{Seq: 2, Output: "o", Width: 64, Height: 32, Scale: 1}
135+ res := <-h.results
136+ require.ErrorIs(t, res.Err, ErrBusy)
137+ require.Equal(t, uint64(2), res.Seq)
138+ h.captured <- ports.CaptureDone{ID: creq.ID}
139+ res = <-h.results
140+ require.NoError(t, res.Err)
141+ require.Equal(t, uint64(1), res.Seq)
142+}
143+
144+func TestHintBadSize(t *testing.T) {
145+ h := start(t, time.Second)
146+ h.requests <- ports.HintScanRequest{Seq: 5, Output: "o"}
147+ res := <-h.results
148+ require.ErrorIs(t, res.Err, ErrBadRequest)
149+}
@@ -0,0 +1,149 @@
1+package hints
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "image/draw"
8+ _ "image/png"
9+ "os"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+type harness struct {
19+ requests chan ports.HintScanRequest
20+ captures chan ports.CaptureRequest
21+ captured chan ports.CaptureDone
22+ results chan ports.HintScanResult
23+}
24+
25+func start(t *testing.T, timeout time.Duration) *harness {
26+ t.Helper()
27+ h := &harness{
28+ requests: make(chan ports.HintScanRequest, 1),
29+ captures: make(chan ports.CaptureRequest, 4),
30+ captured: make(chan ports.CaptureDone, 1),
31+ results: make(chan ports.HintScanResult, 4),
32+ }
33+ ctx, cancel := context.WithCancel(context.Background())
34+ exited := make(chan struct{})
35+ go func() {
36+ defer close(exited)
37+ _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout)
38+ }()
39+ t.Cleanup(func() { cancel(); <-exited })
40+ return h
41+}
42+
43+func loadBGRA(t *testing.T) (pix []byte, w, hgt int) {
44+ t.Helper()
45+ f, err := os.Open("testdata/browser.png")
46+ require.NoError(t, err)
47+ defer f.Close()
48+ img, _, err := image.Decode(f)
49+ require.NoError(t, err)
50+ b := img.Bounds()
51+ rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
52+ draw.Draw(rgba, rgba.Bounds(), img, b.Min, draw.Src)
53+ for i := 0; i < len(rgba.Pix); i += 4 {
54+ rgba.Pix[i], rgba.Pix[i+2] = rgba.Pix[i+2], rgba.Pix[i]
55+ rgba.Pix[i+3] = 0xff
56+ }
57+ return rgba.Pix, b.Dx(), b.Dy()
58+}
59+
60+func TestHintScan(t *testing.T) {
61+ pix, w, hgt := loadBGRA(t)
62+ h := start(t, 5*time.Second)
63+
64+ // Plays the output owner.
65+ go func() {
66+ creq := <-h.captures
67+ copy(creq.Mapped, pix)
68+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
69+ }()
70+
71+ h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}
72+ res := <-h.results
73+ require.NoError(t, res.Err)
74+ require.Equal(t, uint64(7), res.Seq)
75+ require.Equal(t, "HEADLESS-1", res.Output)
76+ var links int
77+ for _, tg := range res.Targets {
78+ require.Positive(t, tg.Rect.W)
79+ require.Positive(t, tg.Rect.H)
80+ if tg.Kind == ports.HintLink {
81+ links++
82+ }
83+ }
84+ require.GreaterOrEqual(t, links, 1)
85+}
86+
87+func TestHintRequestShape(t *testing.T) {
88+ h := start(t, 5*time.Second)
89+ got := make(chan ports.CaptureRequest, 1)
90+ go func() {
91+ creq := <-h.captures
92+ got <- creq
93+ h.captured <- ports.CaptureDone{ID: creq.ID + 1} // another ID: ignored
94+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} // blank frame
95+ }()
96+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
97+ res := <-h.results
98+ require.NoError(t, res.Err)
99+ creq := <-got
100+ require.NotZero(t, creq.ID&ports.CaptureIDInternal)
101+ require.False(t, creq.Indicate)
102+ require.Equal(t, image.Rect(0, 0, 64, 32), creq.Region)
103+ require.Len(t, creq.Mapped, 64*32*4)
104+ require.Equal(t, 64*4, creq.Stride)
105+ require.Equal(t, uint32(1), creq.Format)
106+}
107+
108+func TestHintCaptureError(t *testing.T) {
109+ h := start(t, 5*time.Second)
110+ boom := errors.New("boom")
111+ go func() {
112+ creq := <-h.captures
113+ h.captured <- ports.CaptureDone{ID: creq.ID, Err: boom}
114+ }()
115+ h.requests <- ports.HintScanRequest{Seq: 3, Output: "o", Width: 64, Height: 32, Scale: 1}
116+ res := <-h.results
117+ require.ErrorIs(t, res.Err, boom)
118+ require.Equal(t, uint64(3), res.Seq)
119+ require.Empty(t, res.Targets)
120+}
121+
122+func TestHintTimeout(t *testing.T) {
123+ h := start(t, 50*time.Millisecond)
124+ h.requests <- ports.HintScanRequest{Seq: 4, Output: "o", Width: 64, Height: 32, Scale: 1}
125+ res := <-h.results
126+ require.ErrorIs(t, res.Err, ErrTimeout)
127+ require.Equal(t, uint64(4), res.Seq)
128+}
129+
130+func TestHintBusy(t *testing.T) {
131+ h := start(t, 5*time.Second)
132+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
133+ creq := <-h.captures
134+ h.requests <- ports.HintScanRequest{Seq: 2, Output: "o", Width: 64, Height: 32, Scale: 1}
135+ res := <-h.results
136+ require.ErrorIs(t, res.Err, ErrBusy)
137+ require.Equal(t, uint64(2), res.Seq)
138+ h.captured <- ports.CaptureDone{ID: creq.ID}
139+ res = <-h.results
140+ require.NoError(t, res.Err)
141+ require.Equal(t, uint64(1), res.Seq)
142+}
143+
144+func TestHintBadSize(t *testing.T) {
145+ h := start(t, time.Second)
146+ h.requests <- ports.HintScanRequest{Seq: 5, Output: "o"}
147+ res := <-h.results
148+ require.ErrorIs(t, res.Err, ErrBadRequest)
149+}
A internal/adapters/hints/testdata/browser.png +0 -0 binary file: no text diff

binary file: no text diff

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 26

Tags 10

↑↓ moveenter openesc close