detached 2c13a99

feat(hints): merge accessibility and pixel targets

commit
2c13a990949f4bade8cd6b3af0ca2685708fce76
author
brice · · 2026-10-19 12:49 UTC
parent
54bfe5b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +347 -20
M .mockery.yml +7 -0
@@ -111,6 +111,13 @@ packages:
111111 dir: internal/adapters/statefile
112112 pkgname: statefile
113113 filename: proc_tree_mock_test.go
114+ github.com/bnema/neferwl/internal/adapters/hints:
115+ interfaces:
116+ targetSource:
117+ config:
118+ dir: internal/adapters/hints
119+ pkgname: hints
120+ filename: target_source_mock_test.go
114121 github.com/bnema/neferwl/internal/adapters/atspi:
115122 interfaces:
116123 transport:
@@ -111,6 +111,13 @@ packages:
111 dir: internal/adapters/statefile111 dir: internal/adapters/statefile
112 pkgname: statefile112 pkgname: statefile
113 filename: proc_tree_mock_test.go113 filename: proc_tree_mock_test.go
114+ github.com/bnema/neferwl/internal/adapters/hints:
115+ interfaces:
116+ targetSource:
117+ config:
118+ dir: internal/adapters/hints
119+ pkgname: hints
120+ filename: target_source_mock_test.go
114 github.com/bnema/neferwl/internal/adapters/atspi:121 github.com/bnema/neferwl/internal/adapters/atspi:
115 interfaces:122 interfaces:
116 transport:123 transport:
M internal/adapters/hints/dump.go +32 -12
@@ -26,23 +26,31 @@ type Options struct {
2626 // DumpDir, when not empty, receives the scanned frames (PNG, annotated
2727 // PNG and JSON). They contain the screen in clear.
2828 DumpDir string
29+ // Source, when not nil, finds targets in the accessibility tree of the
30+ // windows on screen; the pixel scan covers the rest.
31+ Source targetSource
2932 }
3033
3134 // dumpJob is one scan to write. Pix is the worker buffer, not a copy: it
3235 // stays untouched until the worker serves the next request.
3336 type dumpJob struct {
34- when time.Time
35- req ports.HintScanRequest
36- pix []byte
37- stride int
38- targets []ports.HintTarget
37+ when time.Time
38+ req ports.HintScanRequest
39+ pix []byte
40+ stride int
41+ targets []ports.HintTarget
42+ // atspi is the number of leading targets that come from the
43+ // accessibility tree; the others come from pixels.
44+ atspi int
3945 captureD time.Duration
4046 scanD time.Duration
47+ atspiD time.Duration
4148 }
4249
4350 type dumpTarget struct {
44- Kind string `json:"kind"`
45- Rect [4]int `json:"rect"`
51+ Kind string `json:"kind"`
52+ Source string `json:"source"`
53+ Rect [4]int `json:"rect"`
4654 }
4755
4856 type dumpMeta struct {
@@ -54,6 +62,7 @@ type dumpMeta struct {
5462 LinksOnly bool `json:"links_only"`
5563 CaptureMS float64 `json:"capture_ms"`
5664 ScanMS float64 `json:"scan_ms"`
65+ AtspiMS float64 `json:"atspi_ms"`
5766 Targets []dumpTarget `json:"targets"`
5867 }
5968
@@ -79,6 +88,9 @@ func kindColor(k ports.HintKind) color.RGBA {
7988 }
8089 }
8190
91+// atspiColor is the outline of targets found in the accessibility tree.
92+var atspiColor = color.RGBA{0xff, 0x00, 0xff, 0xff}
93+
8294 func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
8395
8496 // writeDump writes the three files of j into dir, then prunes old groups.
@@ -104,10 +116,14 @@ func writeDump(dir string, j *dumpJob) error {
104116 if scale <= 0 {
105117 scale = 1
106118 }
107- for _, t := range j.targets {
119+ for i, t := range j.targets {
108120 r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale),
109121 int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale))
110- outline(img, r, kindColor(t.Kind))
122+ c := kindColor(t.Kind)
123+ if i < j.atspi {
124+ c = atspiColor
125+ }
126+ outline(img, r, c)
111127 }
112128 if err := writePNG(prefix+".annotated.png", img); err != nil {
113129 return err
@@ -116,11 +132,15 @@ func writeDump(dir string, j *dumpJob) error {
116132 meta := dumpMeta{
117133 Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride,
118134 Scale: j.req.Scale, LinksOnly: j.req.LinksOnly,
119- CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD),
135+ CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD), AtspiMS: ms(j.atspiD),
120136 Targets: make([]dumpTarget, 0, len(j.targets)),
121137 }
122- for _, t := range j.targets {
123- meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}})
138+ for i, t := range j.targets {
139+ src := "pixels"
140+ if i < j.atspi {
141+ src = "atspi"
142+ }
143+ meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Source: src, Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}})
124144 }
125145 data, err := json.Marshal(meta)
126146 if err != nil {
@@ -26,23 +26,31 @@ type Options struct {
26 // DumpDir, when not empty, receives the scanned frames (PNG, annotated26 // DumpDir, when not empty, receives the scanned frames (PNG, annotated
27 // PNG and JSON). They contain the screen in clear.27 // PNG and JSON). They contain the screen in clear.
28 DumpDir string28 DumpDir string
29+ // Source, when not nil, finds targets in the accessibility tree of the
30+ // windows on screen; the pixel scan covers the rest.
31+ Source targetSource
29 }32 }
30 33
31 // dumpJob is one scan to write. Pix is the worker buffer, not a copy: it34 // dumpJob is one scan to write. Pix is the worker buffer, not a copy: it
32 // stays untouched until the worker serves the next request.35 // stays untouched until the worker serves the next request.
33 type dumpJob struct {36 type dumpJob struct {
34- when time.Time37+ when time.Time
35- req ports.HintScanRequest38+ req ports.HintScanRequest
36- pix []byte39+ pix []byte
37- stride int40+ stride int
38- targets []ports.HintTarget41+ targets []ports.HintTarget
42+ // atspi is the number of leading targets that come from the
43+ // accessibility tree; the others come from pixels.
44+ atspi int
39 captureD time.Duration45 captureD time.Duration
40 scanD time.Duration46 scanD time.Duration
47+ atspiD time.Duration
41 }48 }
42 49
43 type dumpTarget struct {50 type dumpTarget struct {
44- Kind string `json:"kind"`51+ Kind string `json:"kind"`
45- Rect [4]int `json:"rect"`52+ Source string `json:"source"`
53+ Rect [4]int `json:"rect"`
46 }54 }
47 55
48 type dumpMeta struct {56 type dumpMeta struct {
@@ -54,6 +62,7 @@ type dumpMeta struct {
54 LinksOnly bool `json:"links_only"`62 LinksOnly bool `json:"links_only"`
55 CaptureMS float64 `json:"capture_ms"`63 CaptureMS float64 `json:"capture_ms"`
56 ScanMS float64 `json:"scan_ms"`64 ScanMS float64 `json:"scan_ms"`
65+ AtspiMS float64 `json:"atspi_ms"`
57 Targets []dumpTarget `json:"targets"`66 Targets []dumpTarget `json:"targets"`
58 }67 }
59 68
@@ -79,6 +88,9 @@ func kindColor(k ports.HintKind) color.RGBA {
79 }88 }
80 }89 }
81 90
91+// atspiColor is the outline of targets found in the accessibility tree.
92+var atspiColor = color.RGBA{0xff, 0x00, 0xff, 0xff}
93+
82 func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }94 func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
83 95
84 // writeDump writes the three files of j into dir, then prunes old groups.96 // writeDump writes the three files of j into dir, then prunes old groups.
@@ -104,10 +116,14 @@ func writeDump(dir string, j *dumpJob) error {
104 if scale <= 0 {116 if scale <= 0 {
105 scale = 1117 scale = 1
106 }118 }
107- for _, t := range j.targets {119+ for i, t := range j.targets {
108 r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale),120 r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale),
109 int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale))121 int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale))
110- outline(img, r, kindColor(t.Kind))122+ c := kindColor(t.Kind)
123+ if i < j.atspi {
124+ c = atspiColor
125+ }
126+ outline(img, r, c)
111 }127 }
112 if err := writePNG(prefix+".annotated.png", img); err != nil {128 if err := writePNG(prefix+".annotated.png", img); err != nil {
113 return err129 return err
@@ -116,11 +132,15 @@ func writeDump(dir string, j *dumpJob) error {
116 meta := dumpMeta{132 meta := dumpMeta{
117 Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride,133 Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride,
118 Scale: j.req.Scale, LinksOnly: j.req.LinksOnly,134 Scale: j.req.Scale, LinksOnly: j.req.LinksOnly,
119- CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD),135+ CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD), AtspiMS: ms(j.atspiD),
120 Targets: make([]dumpTarget, 0, len(j.targets)),136 Targets: make([]dumpTarget, 0, len(j.targets)),
121 }137 }
122- for _, t := range j.targets {138+ for i, t := range j.targets {
123- meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}})139+ src := "pixels"
140+ if i < j.atspi {
141+ src = "atspi"
142+ }
143+ meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Source: src, Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}})
124 }144 }
125 data, err := json.Marshal(meta)145 data, err := json.Marshal(meta)
126 if err != nil {146 if err != nil {
M internal/adapters/hints/hints.go +101 -6
@@ -38,7 +38,22 @@ func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch
3838 return run(ctx, requests, captures, captured, results, captureTimeout, opts)
3939 }
4040
41+// targetSource finds hint targets in the accessibility tree of windows. It
42+// returns the targets (logical output-local) and the windows they cover.
43+type targetSource interface {
44+ Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)
45+}
46+
47+// atspiResult is what the source found for one request.
48+type atspiResult struct {
49+ targets []ports.HintTarget
50+ covered map[ports.WindowID]bool
51+ dur time.Duration
52+ err error
53+}
54+
4155 type worker struct {
56+ source targetSource
4257 scanner *hintscan.Scanner
4358 buf []byte
4459 dst []hintscan.Target
@@ -48,7 +63,7 @@ type worker struct {
4863 }
4964
5065 func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error {
51- w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir}
66+ w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir, source: opts.Source}
5267 for {
5368 select {
5469 case <-ctx.Done():
@@ -97,6 +112,10 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
97112 if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {
98113 return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))
99114 }
115+ // The accessibility query runs while the frame is captured and scanned.
116+ // It is always waited for: the source is not shared between requests.
117+ atspi := w.startSource(ctx, req)
118+ defer func() { <-atspi.done }()
100119 size := req.Width * req.Height * 4
101120 if cap(w.buf) < size {
102121 w.buf = make([]byte, size)
@@ -139,7 +158,7 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
139158 if done.Err != nil {
140159 return fail(fmt.Errorf("hints: capture: %w", done.Err))
141160 }
142- return w.scan(ctx, req, buf, stride, time.Since(start), res)
161+ return w.scan(ctx, req, buf, stride, time.Since(start), res, atspi)
143162 case other := <-requests:
144163 // The buffer is in use until the capture is done: answer now.
145164 busy := ports.HintScanResult{Seq: other.Seq, Output: other.Output, Err: ErrBusy}
@@ -157,7 +176,7 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
157176 }
158177 }
159178
160-func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
179+func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult, atspi *pendingSource) ports.HintScanResult {
161180 log := logging.For(ctx, "hints")
162181 kinds := hintscan.Clickable
163182 if req.LinksOnly {
@@ -175,26 +194,102 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte
175194 }
176195 w.dst = targets
177196
178- out := make([]ports.HintTarget, 0, len(targets))
197+ pix := make([]ports.HintTarget, 0, len(targets))
179198 for _, t := range targets {
180199 kind := ports.HintLink
181200 if t.Kind == hintscan.KindButton {
182201 kind = ports.HintButton
183202 }
184- out = append(out, ports.HintTarget{
203+ pix = append(pix, ports.HintTarget{
185204 Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},
186205 Kind: kind,
187206 })
188207 }
208+ ar := atspi.wait()
209+ if ar.err != nil {
210+ log.Info().Err(ar.err).Msg("accessibility targets unavailable")
211+ }
212+ out, nAtspi := merge(req.Windows, ar.targets, ar.covered, pix)
189213 res.Targets = out
190214 if w.dumpDir != "" {
191- w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, captureD: captureDur, scanD: scanDur}
215+ w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, atspi: nAtspi, captureD: captureDur, scanD: scanDur, atspiD: ar.dur}
192216 }
193217 log.Info().
194218 Str("output", req.Output).
195219 Int("targets", len(out)).
220+ Dur("atspi_ms", ar.dur).
221+ Int("atspi_apps", coveredApps(req.Windows, ar.covered)).
222+ Int("atspi_windows", len(ar.covered)).
223+ Int("atspi_targets", nAtspi).
224+ Int("pixel_targets", len(out)-nAtspi).
196225 Dur("scan", scanDur).
197226 Dur("capture", captureDur).
198227 Msg("hint scan")
199228 return res
200229 }
230+
231+// pendingSource is an accessibility query in flight.
232+type pendingSource struct {
233+ done chan struct{}
234+ res atspiResult
235+}
236+
237+// startSource starts the accessibility query of req, if there is a source and
238+// a window to ask about. done is closed when res is set.
239+func (w *worker) startSource(ctx context.Context, req ports.HintScanRequest) *pendingSource {
240+ p := &pendingSource{done: make(chan struct{})}
241+ if w.source == nil || len(req.Windows) == 0 {
242+ close(p.done)
243+ return p
244+ }
245+ go func() {
246+ defer close(p.done)
247+ t0 := time.Now()
248+ targets, covered, err := w.source.Targets(ctx, req.Windows, req.LinksOnly)
249+ p.res = atspiResult{targets: targets, covered: covered, dur: time.Since(t0), err: err}
250+ }()
251+ return p
252+}
253+
254+// wait returns the result of the query.
255+func (p *pendingSource) wait() atspiResult {
256+ <-p.done
257+ return p.res
258+}
259+
260+// merge keeps the accessibility targets of the covered windows and the pixel
261+// targets whose centre is in no covered window. The accessibility targets
262+// come first; it returns how many there are.
263+func merge(windows []ports.HintWindow, atspi []ports.HintTarget, covered map[ports.WindowID]bool, pixels []ports.HintTarget) ([]ports.HintTarget, int) {
264+ if len(covered) == 0 {
265+ return pixels, 0
266+ }
267+ out := make([]ports.HintTarget, 0, len(atspi)+len(pixels))
268+ out = append(out, atspi...)
269+ for _, t := range pixels {
270+ cx, cy := t.Rect.X+t.Rect.W/2, t.Rect.Y+t.Rect.H/2
271+ in := false
272+ for _, win := range windows {
273+ r := win.Rect
274+ if covered[win.ID] && cx >= r.X && cx < r.X+r.W && cy >= r.Y && cy < r.Y+r.H {
275+ in = true
276+ break
277+ }
278+ }
279+ if !in {
280+ out = append(out, t)
281+ }
282+ }
283+ return out, len(atspi)
284+}
285+
286+// coveredApps counts the processes of the covered windows.
287+func coveredApps(windows []ports.HintWindow, covered map[ports.WindowID]bool) int {
288+ pids := map[int]struct{}{}
289+ for _, w := range windows {
290+ if covered[w.ID] {
291+ pids[w.PID] = struct{}{}
292+ }
293+ }
294+ return len(pids)
295+}
@@ -38,7 +38,22 @@ func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch
38 return run(ctx, requests, captures, captured, results, captureTimeout, opts)38 return run(ctx, requests, captures, captured, results, captureTimeout, opts)
39 }39 }
40 40
41+// targetSource finds hint targets in the accessibility tree of windows. It
42+// returns the targets (logical output-local) and the windows they cover.
43+type targetSource interface {
44+ Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)
45+}
46+
47+// atspiResult is what the source found for one request.
48+type atspiResult struct {
49+ targets []ports.HintTarget
50+ covered map[ports.WindowID]bool
51+ dur time.Duration
52+ err error
53+}
54+
41 type worker struct {55 type worker struct {
56+ source targetSource
42 scanner *hintscan.Scanner57 scanner *hintscan.Scanner
43 buf []byte58 buf []byte
44 dst []hintscan.Target59 dst []hintscan.Target
@@ -48,7 +63,7 @@ type worker struct {
48 }63 }
49 64
50 func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error {65 func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error {
51- w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir}66+ w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir, source: opts.Source}
52 for {67 for {
53 select {68 select {
54 case <-ctx.Done():69 case <-ctx.Done():
@@ -97,6 +112,10 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
97 if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {112 if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {
98 return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))113 return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))
99 }114 }
115+ // The accessibility query runs while the frame is captured and scanned.
116+ // It is always waited for: the source is not shared between requests.
117+ atspi := w.startSource(ctx, req)
118+ defer func() { <-atspi.done }()
100 size := req.Width * req.Height * 4119 size := req.Width * req.Height * 4
101 if cap(w.buf) < size {120 if cap(w.buf) < size {
102 w.buf = make([]byte, size)121 w.buf = make([]byte, size)
@@ -139,7 +158,7 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
139 if done.Err != nil {158 if done.Err != nil {
140 return fail(fmt.Errorf("hints: capture: %w", done.Err))159 return fail(fmt.Errorf("hints: capture: %w", done.Err))
141 }160 }
142- return w.scan(ctx, req, buf, stride, time.Since(start), res)161+ return w.scan(ctx, req, buf, stride, time.Since(start), res, atspi)
143 case other := <-requests:162 case other := <-requests:
144 // The buffer is in use until the capture is done: answer now.163 // The buffer is in use until the capture is done: answer now.
145 busy := ports.HintScanResult{Seq: other.Seq, Output: other.Output, Err: ErrBusy}164 busy := ports.HintScanResult{Seq: other.Seq, Output: other.Output, Err: ErrBusy}
@@ -157,7 +176,7 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
157 }176 }
158 }177 }
159 178
160-func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {179+func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult, atspi *pendingSource) ports.HintScanResult {
161 log := logging.For(ctx, "hints")180 log := logging.For(ctx, "hints")
162 kinds := hintscan.Clickable181 kinds := hintscan.Clickable
163 if req.LinksOnly {182 if req.LinksOnly {
@@ -175,26 +194,102 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte
175 }194 }
176 w.dst = targets195 w.dst = targets
177 196
178- out := make([]ports.HintTarget, 0, len(targets))197+ pix := make([]ports.HintTarget, 0, len(targets))
179 for _, t := range targets {198 for _, t := range targets {
180 kind := ports.HintLink199 kind := ports.HintLink
181 if t.Kind == hintscan.KindButton {200 if t.Kind == hintscan.KindButton {
182 kind = ports.HintButton201 kind = ports.HintButton
183 }202 }
184- out = append(out, ports.HintTarget{203+ pix = append(pix, ports.HintTarget{
185 Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},204 Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},
186 Kind: kind,205 Kind: kind,
187 })206 })
188 }207 }
208+ ar := atspi.wait()
209+ if ar.err != nil {
210+ log.Info().Err(ar.err).Msg("accessibility targets unavailable")
211+ }
212+ out, nAtspi := merge(req.Windows, ar.targets, ar.covered, pix)
189 res.Targets = out213 res.Targets = out
190 if w.dumpDir != "" {214 if w.dumpDir != "" {
191- w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, captureD: captureDur, scanD: scanDur}215+ w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, atspi: nAtspi, captureD: captureDur, scanD: scanDur, atspiD: ar.dur}
192 }216 }
193 log.Info().217 log.Info().
194 Str("output", req.Output).218 Str("output", req.Output).
195 Int("targets", len(out)).219 Int("targets", len(out)).
220+ Dur("atspi_ms", ar.dur).
221+ Int("atspi_apps", coveredApps(req.Windows, ar.covered)).
222+ Int("atspi_windows", len(ar.covered)).
223+ Int("atspi_targets", nAtspi).
224+ Int("pixel_targets", len(out)-nAtspi).
196 Dur("scan", scanDur).225 Dur("scan", scanDur).
197 Dur("capture", captureDur).226 Dur("capture", captureDur).
198 Msg("hint scan")227 Msg("hint scan")
199 return res228 return res
200 }229 }
230+
231+// pendingSource is an accessibility query in flight.
232+type pendingSource struct {
233+ done chan struct{}
234+ res atspiResult
235+}
236+
237+// startSource starts the accessibility query of req, if there is a source and
238+// a window to ask about. done is closed when res is set.
239+func (w *worker) startSource(ctx context.Context, req ports.HintScanRequest) *pendingSource {
240+ p := &pendingSource{done: make(chan struct{})}
241+ if w.source == nil || len(req.Windows) == 0 {
242+ close(p.done)
243+ return p
244+ }
245+ go func() {
246+ defer close(p.done)
247+ t0 := time.Now()
248+ targets, covered, err := w.source.Targets(ctx, req.Windows, req.LinksOnly)
249+ p.res = atspiResult{targets: targets, covered: covered, dur: time.Since(t0), err: err}
250+ }()
251+ return p
252+}
253+
254+// wait returns the result of the query.
255+func (p *pendingSource) wait() atspiResult {
256+ <-p.done
257+ return p.res
258+}
259+
260+// merge keeps the accessibility targets of the covered windows and the pixel
261+// targets whose centre is in no covered window. The accessibility targets
262+// come first; it returns how many there are.
263+func merge(windows []ports.HintWindow, atspi []ports.HintTarget, covered map[ports.WindowID]bool, pixels []ports.HintTarget) ([]ports.HintTarget, int) {
264+ if len(covered) == 0 {
265+ return pixels, 0
266+ }
267+ out := make([]ports.HintTarget, 0, len(atspi)+len(pixels))
268+ out = append(out, atspi...)
269+ for _, t := range pixels {
270+ cx, cy := t.Rect.X+t.Rect.W/2, t.Rect.Y+t.Rect.H/2
271+ in := false
272+ for _, win := range windows {
273+ r := win.Rect
274+ if covered[win.ID] && cx >= r.X && cx < r.X+r.W && cy >= r.Y && cy < r.Y+r.H {
275+ in = true
276+ break
277+ }
278+ }
279+ if !in {
280+ out = append(out, t)
281+ }
282+ }
283+ return out, len(atspi)
284+}
285+
286+// coveredApps counts the processes of the covered windows.
287+func coveredApps(windows []ports.HintWindow, covered map[ports.WindowID]bool) int {
288+ pids := map[int]struct{}{}
289+ for _, w := range windows {
290+ if covered[w.ID] {
291+ pids[w.PID] = struct{}{}
292+ }
293+ }
294+ return len(pids)
295+}
M internal/adapters/hints/hints_test.go +58 -0
@@ -14,6 +14,7 @@ import (
1414 "testing"
1515 "time"
1616
17+ "github.com/stretchr/testify/mock"
1718 "github.com/stretchr/testify/require"
1819
1920 "github.com/bnema/neferwl/internal/ports"
@@ -285,3 +286,60 @@ func TestHintDumpRetention(t *testing.T) {
285286 require.Len(t, entries, maxDumps*3)
286287 require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())
287288 }
289+
290+func TestMergePolicy(t *testing.T) {
291+ windows := []ports.HintWindow{
292+ {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 100, H: 100}},
293+ {ID: 2, Rect: ports.Rect{X: 100, Y: 0, W: 100, H: 100}},
294+ }
295+ at := []ports.HintTarget{{Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}, Kind: ports.HintLink}}
296+ pixels := []ports.HintTarget{
297+ {Rect: ports.Rect{X: 12, Y: 12, W: 10, H: 4}}, // centre in covered window 1: dropped
298+ {Rect: ports.Rect{X: 120, Y: 10, W: 20, H: 10}}, // window 2 is not covered: kept
299+ {Rect: ports.Rect{X: 500, Y: 10, W: 20, H: 10}}, // no window: kept
300+ }
301+ out, n := merge(windows, at, map[ports.WindowID]bool{1: true}, pixels)
302+ require.Equal(t, 1, n)
303+ require.Equal(t, []ports.HintTarget{at[0], pixels[1], pixels[2]}, out)
304+
305+ // Nothing covered: pixels only.
306+ out, n = merge(windows, nil, nil, pixels)
307+ require.Zero(t, n)
308+ require.Equal(t, pixels, out)
309+}
310+
311+func TestScanMergesSource(t *testing.T) {
312+ pix, w, hgt := loadBGRA(t)
313+ src := newMocktargetSource(t)
314+ wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{X: 0, Y: 0, W: w, H: hgt}}}
315+ at := []ports.HintTarget{{Rect: ports.Rect{X: 5, Y: 5, W: 30, H: 10}, Kind: ports.HintLink}}
316+ src.EXPECT().Targets(mock.Anything, wins, false).Return(at, map[ports.WindowID]bool{4: true}, nil).Once()
317+ h := startWith(t, 5*time.Second, Options{Source: src})
318+ go func() {
319+ creq := <-h.captures
320+ copy(creq.Mapped, pix)
321+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
322+ }()
323+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: wins}
324+ res := <-h.results
325+ require.NoError(t, res.Err)
326+ // The whole frame is covered: only the accessibility target remains.
327+ require.Equal(t, at, res.Targets)
328+}
329+
330+func TestScanSourceErrorKeepsPixels(t *testing.T) {
331+ pix, w, hgt := loadBGRA(t)
332+ src := newMocktargetSource(t)
333+ wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}
334+ src.EXPECT().Targets(mock.Anything, wins, false).Return(nil, nil, errors.New("bus gone")).Once()
335+ h := startWith(t, 5*time.Second, Options{Source: src})
336+ go func() {
337+ creq := <-h.captures
338+ copy(creq.Mapped, pix)
339+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
340+ }()
341+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: wins}
342+ res := <-h.results
343+ require.NoError(t, res.Err)
344+ require.NotEmpty(t, res.Targets)
345+}
@@ -14,6 +14,7 @@ import (
14 "testing"14 "testing"
15 "time"15 "time"
16 16
17+ "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
18 19
19 "github.com/bnema/neferwl/internal/ports"20 "github.com/bnema/neferwl/internal/ports"
@@ -285,3 +286,60 @@ func TestHintDumpRetention(t *testing.T) {
285 require.Len(t, entries, maxDumps*3)286 require.Len(t, entries, maxDumps*3)
286 require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())287 require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())
287 }288 }
289+
290+func TestMergePolicy(t *testing.T) {
291+ windows := []ports.HintWindow{
292+ {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 100, H: 100}},
293+ {ID: 2, Rect: ports.Rect{X: 100, Y: 0, W: 100, H: 100}},
294+ }
295+ at := []ports.HintTarget{{Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}, Kind: ports.HintLink}}
296+ pixels := []ports.HintTarget{
297+ {Rect: ports.Rect{X: 12, Y: 12, W: 10, H: 4}}, // centre in covered window 1: dropped
298+ {Rect: ports.Rect{X: 120, Y: 10, W: 20, H: 10}}, // window 2 is not covered: kept
299+ {Rect: ports.Rect{X: 500, Y: 10, W: 20, H: 10}}, // no window: kept
300+ }
301+ out, n := merge(windows, at, map[ports.WindowID]bool{1: true}, pixels)
302+ require.Equal(t, 1, n)
303+ require.Equal(t, []ports.HintTarget{at[0], pixels[1], pixels[2]}, out)
304+
305+ // Nothing covered: pixels only.
306+ out, n = merge(windows, nil, nil, pixels)
307+ require.Zero(t, n)
308+ require.Equal(t, pixels, out)
309+}
310+
311+func TestScanMergesSource(t *testing.T) {
312+ pix, w, hgt := loadBGRA(t)
313+ src := newMocktargetSource(t)
314+ wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{X: 0, Y: 0, W: w, H: hgt}}}
315+ at := []ports.HintTarget{{Rect: ports.Rect{X: 5, Y: 5, W: 30, H: 10}, Kind: ports.HintLink}}
316+ src.EXPECT().Targets(mock.Anything, wins, false).Return(at, map[ports.WindowID]bool{4: true}, nil).Once()
317+ h := startWith(t, 5*time.Second, Options{Source: src})
318+ go func() {
319+ creq := <-h.captures
320+ copy(creq.Mapped, pix)
321+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
322+ }()
323+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: wins}
324+ res := <-h.results
325+ require.NoError(t, res.Err)
326+ // The whole frame is covered: only the accessibility target remains.
327+ require.Equal(t, at, res.Targets)
328+}
329+
330+func TestScanSourceErrorKeepsPixels(t *testing.T) {
331+ pix, w, hgt := loadBGRA(t)
332+ src := newMocktargetSource(t)
333+ wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}
334+ src.EXPECT().Targets(mock.Anything, wins, false).Return(nil, nil, errors.New("bus gone")).Once()
335+ h := startWith(t, 5*time.Second, Options{Source: src})
336+ go func() {
337+ creq := <-h.captures
338+ copy(creq.Mapped, pix)
339+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
340+ }()
341+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: wins}
342+ res := <-h.results
343+ require.NoError(t, res.Err)
344+ require.NotEmpty(t, res.Targets)
345+}
A internal/adapters/hints/target_source_mock_test.go +130 -0
@@ -0,0 +1,130 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package hints
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMocktargetSource creates a new instance of mocktargetSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMocktargetSource(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mocktargetSource {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mocktargetSource{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mocktargetSource is an autogenerated mock type for the targetSource type
38+type mocktargetSource struct {
39+ mock.Mock
40+}
41+
42+type mocktargetSource_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mocktargetSource) EXPECT() *mocktargetSource_Expecter {
47+ return &mocktargetSource_Expecter{mock: &_m.Mock}
48+}
49+
50+// Targets provides a mock function for the type mocktargetSource
51+func (_mock *mocktargetSource) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
52+ ret := _mock.Called(ctx, windows, linksOnly)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Targets")
56+ }
57+
58+ var r0 []ports.HintTarget
59+ var r1 map[ports.WindowID]bool
60+ var r2 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, []ports.HintWindow, bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)); ok {
62+ return returnFunc(ctx, windows, linksOnly)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, []ports.HintWindow, bool) []ports.HintTarget); ok {
65+ r0 = returnFunc(ctx, windows, linksOnly)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).([]ports.HintTarget)
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, []ports.HintWindow, bool) map[ports.WindowID]bool); ok {
72+ r1 = returnFunc(ctx, windows, linksOnly)
73+ } else {
74+ if ret.Get(1) != nil {
75+ r1 = ret.Get(1).(map[ports.WindowID]bool)
76+ }
77+ }
78+ if returnFunc, ok := ret.Get(2).(func(context.Context, []ports.HintWindow, bool) error); ok {
79+ r2 = returnFunc(ctx, windows, linksOnly)
80+ } else {
81+ r2 = ret.Error(2)
82+ }
83+ return r0, r1, r2
84+}
85+
86+// mocktargetSource_Targets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Targets'
87+type mocktargetSource_Targets_Call struct {
88+ *mock.Call
89+}
90+
91+// Targets is a helper method to define mock.On call
92+// - ctx context.Context
93+// - windows []ports.HintWindow
94+// - linksOnly bool
95+func (_e *mocktargetSource_Expecter) Targets(ctx any, windows any, linksOnly any) *mocktargetSource_Targets_Call {
96+ return &mocktargetSource_Targets_Call{Call: _e.mock.On("Targets", ctx, windows, linksOnly)}
97+}
98+
99+func (_c *mocktargetSource_Targets_Call) Run(run func(ctx context.Context, windows []ports.HintWindow, linksOnly bool)) *mocktargetSource_Targets_Call {
100+ _c.Call.Run(func(args mock.Arguments) {
101+ var arg0 context.Context
102+ if args[0] != nil {
103+ arg0 = args[0].(context.Context)
104+ }
105+ var arg1 []ports.HintWindow
106+ if args[1] != nil {
107+ arg1 = args[1].([]ports.HintWindow)
108+ }
109+ var arg2 bool
110+ if args[2] != nil {
111+ arg2 = args[2].(bool)
112+ }
113+ run(
114+ arg0,
115+ arg1,
116+ arg2,
117+ )
118+ })
119+ return _c
120+}
121+
122+func (_c *mocktargetSource_Targets_Call) Return(hintTargets []ports.HintTarget, windowIDToBool map[ports.WindowID]bool, err error) *mocktargetSource_Targets_Call {
123+ _c.Call.Return(hintTargets, windowIDToBool, err)
124+ return _c
125+}
126+
127+func (_c *mocktargetSource_Targets_Call) RunAndReturn(run func(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)) *mocktargetSource_Targets_Call {
128+ _c.Call.Return(run)
129+ return _c
130+}
@@ -0,0 +1,130 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package hints
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMocktargetSource creates a new instance of mocktargetSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMocktargetSource(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mocktargetSource {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mocktargetSource{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mocktargetSource is an autogenerated mock type for the targetSource type
38+type mocktargetSource struct {
39+ mock.Mock
40+}
41+
42+type mocktargetSource_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mocktargetSource) EXPECT() *mocktargetSource_Expecter {
47+ return &mocktargetSource_Expecter{mock: &_m.Mock}
48+}
49+
50+// Targets provides a mock function for the type mocktargetSource
51+func (_mock *mocktargetSource) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
52+ ret := _mock.Called(ctx, windows, linksOnly)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Targets")
56+ }
57+
58+ var r0 []ports.HintTarget
59+ var r1 map[ports.WindowID]bool
60+ var r2 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, []ports.HintWindow, bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)); ok {
62+ return returnFunc(ctx, windows, linksOnly)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, []ports.HintWindow, bool) []ports.HintTarget); ok {
65+ r0 = returnFunc(ctx, windows, linksOnly)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).([]ports.HintTarget)
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, []ports.HintWindow, bool) map[ports.WindowID]bool); ok {
72+ r1 = returnFunc(ctx, windows, linksOnly)
73+ } else {
74+ if ret.Get(1) != nil {
75+ r1 = ret.Get(1).(map[ports.WindowID]bool)
76+ }
77+ }
78+ if returnFunc, ok := ret.Get(2).(func(context.Context, []ports.HintWindow, bool) error); ok {
79+ r2 = returnFunc(ctx, windows, linksOnly)
80+ } else {
81+ r2 = ret.Error(2)
82+ }
83+ return r0, r1, r2
84+}
85+
86+// mocktargetSource_Targets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Targets'
87+type mocktargetSource_Targets_Call struct {
88+ *mock.Call
89+}
90+
91+// Targets is a helper method to define mock.On call
92+// - ctx context.Context
93+// - windows []ports.HintWindow
94+// - linksOnly bool
95+func (_e *mocktargetSource_Expecter) Targets(ctx any, windows any, linksOnly any) *mocktargetSource_Targets_Call {
96+ return &mocktargetSource_Targets_Call{Call: _e.mock.On("Targets", ctx, windows, linksOnly)}
97+}
98+
99+func (_c *mocktargetSource_Targets_Call) Run(run func(ctx context.Context, windows []ports.HintWindow, linksOnly bool)) *mocktargetSource_Targets_Call {
100+ _c.Call.Run(func(args mock.Arguments) {
101+ var arg0 context.Context
102+ if args[0] != nil {
103+ arg0 = args[0].(context.Context)
104+ }
105+ var arg1 []ports.HintWindow
106+ if args[1] != nil {
107+ arg1 = args[1].([]ports.HintWindow)
108+ }
109+ var arg2 bool
110+ if args[2] != nil {
111+ arg2 = args[2].(bool)
112+ }
113+ run(
114+ arg0,
115+ arg1,
116+ arg2,
117+ )
118+ })
119+ return _c
120+}
121+
122+func (_c *mocktargetSource_Targets_Call) Return(hintTargets []ports.HintTarget, windowIDToBool map[ports.WindowID]bool, err error) *mocktargetSource_Targets_Call {
123+ _c.Call.Return(hintTargets, windowIDToBool, err)
124+ return _c
125+}
126+
127+func (_c *mocktargetSource_Targets_Call) RunAndReturn(run func(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)) *mocktargetSource_Targets_Call {
128+ _c.Call.Return(run)
129+ return _c
130+}
M internal/app/hintdebug.go +19 -2
@@ -5,13 +5,30 @@ import (
55 "os"
66 "path/filepath"
77
8+ "github.com/bnema/neferwl/internal/adapters/atspi"
89 "github.com/bnema/neferwl/internal/adapters/hints"
910 "github.com/bnema/neferwl/internal/adapters/logging"
1011 )
1112
12-// hintOptions reads NEFERWL_HINT_DEBUG once. When it is "1", scanned frames
13-// are written under $XDG_STATE_HOME/neferwl/hints.
13+// hintOptions builds the hint worker options: the accessibility source, and,
14+// when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames,
15+// $XDG_STATE_HOME/neferwl/hints. Without the accessibility bus the worker
16+// scans pixels only.
1417 func hintOptions(ctx context.Context) hints.Options {
18+ opts := hintDump(ctx)
19+ src, err := atspi.New(ctx)
20+ if err != nil {
21+ log := logging.For(ctx, "atspi")
22+ log.Warn().Err(err).Msg("accessibility hint targets unavailable, using pixels only")
23+ return opts
24+ }
25+ context.AfterFunc(ctx, func() { _ = src.Close() })
26+ opts.Source = src
27+ return opts
28+}
29+
30+// hintDump reads NEFERWL_HINT_DEBUG once.
31+func hintDump(ctx context.Context) hints.Options {
1532 if os.Getenv("NEFERWL_HINT_DEBUG") != "1" {
1633 return hints.Options{}
1734 }
@@ -5,13 +5,30 @@ import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7 7
8+ "github.com/bnema/neferwl/internal/adapters/atspi"
8 "github.com/bnema/neferwl/internal/adapters/hints"9 "github.com/bnema/neferwl/internal/adapters/hints"
9 "github.com/bnema/neferwl/internal/adapters/logging"10 "github.com/bnema/neferwl/internal/adapters/logging"
10 )11 )
11 12
12-// hintOptions reads NEFERWL_HINT_DEBUG once. When it is "1", scanned frames13+// hintOptions builds the hint worker options: the accessibility source, and,
13-// are written under $XDG_STATE_HOME/neferwl/hints.14+// when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames,
15+// $XDG_STATE_HOME/neferwl/hints. Without the accessibility bus the worker
16+// scans pixels only.
14 func hintOptions(ctx context.Context) hints.Options {17 func hintOptions(ctx context.Context) hints.Options {
18+ opts := hintDump(ctx)
19+ src, err := atspi.New(ctx)
20+ if err != nil {
21+ log := logging.For(ctx, "atspi")
22+ log.Warn().Err(err).Msg("accessibility hint targets unavailable, using pixels only")
23+ return opts
24+ }
25+ context.AfterFunc(ctx, func() { _ = src.Close() })
26+ opts.Source = src
27+ return opts
28+}
29+
30+// hintDump reads NEFERWL_HINT_DEBUG once.
31+func hintDump(ctx context.Context) hints.Options {
15 if os.Getenv("NEFERWL_HINT_DEBUG") != "1" {32 if os.Getenv("NEFERWL_HINT_DEBUG") != "1" {
16 return hints.Options{}33 return hints.Options{}
17 }34 }

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