feat(hints): merge accessibility and pixel targets
- commit
- 2c13a990949f4bade8cd6b3af0ca2685708fce76
- author
- brice · · 2026-10-19 12:49 UTC
- parent
- 54bfe5b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +347 -20
M .mockery.yml +7 -0
| @@ -111,6 +111,13 @@ packages: | |||
| 111 | 111 | dir: internal/adapters/statefile | |
| 112 | 112 | pkgname: statefile | |
| 113 | 113 | 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 | 121 | github.com/bnema/neferwl/internal/adapters/atspi: | |
| 115 | 122 | interfaces: | |
| 116 | 123 | transport: | |
| @@ -111,6 +111,13 @@ packages: | |||||
| 111 | dir: internal/adapters/statefile | 111 | dir: internal/adapters/statefile | ||
| 112 | pkgname: statefile | 112 | pkgname: statefile | ||
| 113 | filename: proc_tree_mock_test.go | 113 | 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 { | |||
| 26 | 26 | // DumpDir, when not empty, receives the scanned frames (PNG, annotated | |
| 27 | 27 | // PNG and JSON). They contain the screen in clear. | |
| 28 | 28 | 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 | 34 | // dumpJob is one scan to write. Pix is the worker buffer, not a copy: it | |
| 32 | 35 | // stays untouched until the worker serves the next request. | |
| 33 | 36 | 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 | |
| 39 | 45 | captureD time.Duration | |
| 40 | 46 | scanD time.Duration | |
| 47 | + | atspiD time.Duration | |
| 41 | 48 | } | |
| 42 | 49 | ||
| 43 | 50 | 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"` | |
| 46 | 54 | } | |
| 47 | 55 | ||
| 48 | 56 | type dumpMeta struct { | |
| @@ -54,6 +62,7 @@ type dumpMeta struct { | |||
| 54 | 62 | LinksOnly bool `json:"links_only"` | |
| 55 | 63 | CaptureMS float64 `json:"capture_ms"` | |
| 56 | 64 | ScanMS float64 `json:"scan_ms"` | |
| 65 | + | AtspiMS float64 `json:"atspi_ms"` | |
| 57 | 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 | 94 | func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 } | |
| 83 | 95 | ||
| 84 | 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 | 116 | if scale <= 0 { | |
| 105 | 117 | scale = 1 | |
| 106 | 118 | } | |
| 107 | - | for _, t := range j.targets { | |
| 119 | + | for i, t := range j.targets { | |
| 108 | 120 | r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale), | |
| 109 | 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 | 128 | if err := writePNG(prefix+".annotated.png", img); err != nil { | |
| 113 | 129 | return err | |
| @@ -116,11 +132,15 @@ func writeDump(dir string, j *dumpJob) error { | |||
| 116 | 132 | meta := dumpMeta{ | |
| 117 | 133 | Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride, | |
| 118 | 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 | 136 | Targets: make([]dumpTarget, 0, len(j.targets)), | |
| 121 | 137 | } | |
| 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}}) | |
| 124 | 144 | } | |
| 125 | 145 | data, err := json.Marshal(meta) | |
| 126 | 146 | if err != nil { | |
| @@ -26,23 +26,31 @@ type Options struct { | |||||
| 26 | // DumpDir, when not empty, receives the scanned frames (PNG, annotated | 26 | // 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 string | 28 | 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: it | 34 | // 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.Time | 37 | + | when time.Time |
| 35 | - | req ports.HintScanRequest | 38 | + | req ports.HintScanRequest |
| 36 | - | pix []byte | 39 | + | pix []byte |
| 37 | - | stride int | 40 | + | stride int |
| 38 | - | targets []ports.HintTarget | 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 | |||
| 39 | captureD time.Duration | 45 | captureD time.Duration | ||
| 40 | scanD time.Duration | 46 | 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 = 1 | 117 | 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 err | 129 | 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 | |||
| 38 | 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 | 55 | type worker struct { | |
| 56 | + | source targetSource | |
| 42 | 57 | scanner *hintscan.Scanner | |
| 43 | 58 | buf []byte | |
| 44 | 59 | dst []hintscan.Target | |
| @@ -48,7 +63,7 @@ type worker struct { | |||
| 48 | 63 | } | |
| 49 | 64 | ||
| 50 | 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 | 67 | for { | |
| 53 | 68 | select { | |
| 54 | 69 | case <-ctx.Done(): | |
| @@ -97,6 +112,10 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests | |||
| 97 | 112 | if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels { | |
| 98 | 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 | 119 | size := req.Width * req.Height * 4 | |
| 101 | 120 | if cap(w.buf) < size { | |
| 102 | 121 | w.buf = make([]byte, size) | |
| @@ -139,7 +158,7 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests | |||
| 139 | 158 | if done.Err != nil { | |
| 140 | 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 | 162 | case other := <-requests: | |
| 144 | 163 | // The buffer is in use until the capture is done: answer now. | |
| 145 | 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 | 180 | log := logging.For(ctx, "hints") | |
| 162 | 181 | kinds := hintscan.Clickable | |
| 163 | 182 | if req.LinksOnly { | |
| @@ -175,26 +194,102 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||
| 175 | 194 | } | |
| 176 | 195 | w.dst = targets | |
| 177 | 196 | ||
| 178 | - | out := make([]ports.HintTarget, 0, len(targets)) | |
| 197 | + | pix := make([]ports.HintTarget, 0, len(targets)) | |
| 179 | 198 | for _, t := range targets { | |
| 180 | 199 | kind := ports.HintLink | |
| 181 | 200 | if t.Kind == hintscan.KindButton { | |
| 182 | 201 | kind = ports.HintButton | |
| 183 | 202 | } | |
| 184 | - | out = append(out, ports.HintTarget{ | |
| 203 | + | pix = append(pix, ports.HintTarget{ | |
| 185 | 204 | Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()}, | |
| 186 | 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 | 213 | res.Targets = out | |
| 190 | 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 | 217 | log.Info(). | |
| 194 | 218 | Str("output", req.Output). | |
| 195 | 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 | 225 | Dur("scan", scanDur). | |
| 197 | 226 | Dur("capture", captureDur). | |
| 198 | 227 | Msg("hint scan") | |
| 199 | 228 | 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 | + | } | |
| @@ -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.Scanner | 57 | scanner *hintscan.Scanner | ||
| 43 | buf []byte | 58 | buf []byte | ||
| 44 | dst []hintscan.Target | 59 | 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 * 4 | 119 | 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.Clickable | 181 | 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 = targets | 195 | 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.HintLink | 199 | kind := ports.HintLink | ||
| 181 | if t.Kind == hintscan.KindButton { | 200 | if t.Kind == hintscan.KindButton { | ||
| 182 | kind = ports.HintButton | 201 | 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 = out | 213 | 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 res | 228 | 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 ( | |||
| 14 | 14 | "testing" | |
| 15 | 15 | "time" | |
| 16 | 16 | ||
| 17 | + | "github.com/stretchr/testify/mock" | |
| 17 | 18 | "github.com/stretchr/testify/require" | |
| 18 | 19 | ||
| 19 | 20 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -285,3 +286,60 @@ func TestHintDumpRetention(t *testing.T) { | |||
| 285 | 286 | require.Len(t, entries, maxDumps*3) | |
| 286 | 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 | + | } | |
| @@ -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 ( | |||
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/atspi" | |
| 8 | 9 | "github.com/bnema/neferwl/internal/adapters/hints" | |
| 9 | 10 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 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. | |
| 14 | 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 | 32 | if os.Getenv("NEFERWL_HINT_DEBUG") != "1" { | |
| 16 | 33 | return hints.Options{} | |
| 17 | 34 | } | |
| @@ -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 frames | 13 | + | // 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 | } | ||