detached e9ffd04

feat(hints): read uncovered windows with tesseract

commit
e9ffd048a21ccc45d3cd726df982400046a03463
author
brice · · 2026-10-19 13:26 UTC
parent
78dc04f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +903 -148
M go.mod +0 -3
@@ -21,7 +21,6 @@ require (
2121 )
2222
2323 require (
24- bnema.dev/hintscan v0.0.0-00010101000000-000000000000
2524 github.com/mattn/go-colorable v0.1.15 // indirect
2625 github.com/mattn/go-isatty v0.0.22 // indirect
2726 github.com/stretchr/objx v0.5.3 // indirect
@@ -29,5 +28,3 @@ require (
2928 golang.org/x/text v0.42.0 // indirect
3029 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
3130 )
32-
33-replace bnema.dev/hintscan => ../../../hintscan/.worktrees/scanner
@@ -21,7 +21,6 @@ require (
21 )21 )
22 22
23 require (23 require (
24- bnema.dev/hintscan v0.0.0-00010101000000-000000000000
25 github.com/mattn/go-colorable v0.1.15 // indirect24 github.com/mattn/go-colorable v0.1.15 // indirect
26 github.com/mattn/go-isatty v0.0.22 // indirect25 github.com/mattn/go-isatty v0.0.22 // indirect
27 github.com/stretchr/objx v0.5.3 // indirect26 github.com/stretchr/objx v0.5.3 // indirect
@@ -29,5 +28,3 @@ require (
29 golang.org/x/text v0.42.0 // indirect28 golang.org/x/text v0.42.0 // indirect
30 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect29 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
31 )30 )
32-
33-replace bnema.dev/hintscan => ../../../hintscan/.worktrees/scanner
M internal/adapters/atspi/bus_test.go +1 -1
@@ -75,7 +75,7 @@ func TestDoDecodesMatchesPipelined(t *testing.T) {
7575 for r.More(end) {
7676 roles = append(roles, r.Int32())
7777 }
78- ok := r.Err() == nil && len(states) == 2 && states[0] == 1<<25|1<<30 && len(roles) == 5 && roles[2] == 1<<24
78+ ok := r.Err() == nil && len(states) == 2 && states[0] == 1<<25|1<<30 && len(roles) == 5 && uint32(roles[2]) == 1<<24|1<<18|1<<31
7979 if !ok {
8080 app.NewError(req, "org.test.Error.BadRule", "s").Str("bad rule")
8181 _, _ = app.Send()
@@ -75,7 +75,7 @@ func TestDoDecodesMatchesPipelined(t *testing.T) {
75 for r.More(end) {75 for r.More(end) {
76 roles = append(roles, r.Int32())76 roles = append(roles, r.Int32())
77 }77 }
78- ok := r.Err() == nil && len(states) == 2 && states[0] == 1<<25|1<<30 && len(roles) == 5 && roles[2] == 1<<2478+ ok := r.Err() == nil && len(states) == 2 && states[0] == 1<<25|1<<30 && len(roles) == 5 && uint32(roles[2]) == 1<<24|1<<18|1<<31
79 if !ok {79 if !ok {
80 app.NewError(req, "org.test.Error.BadRule", "s").Str("bad rule")80 app.NewError(req, "org.test.Error.BadRule", "s").Str("bad rule")
81 _, _ = app.Send()81 _, _ = app.Send()
M internal/adapters/atspi/model.go +18 -14
@@ -5,17 +5,19 @@ import "github.com/bnema/neferwl/internal/ports"
55 // AT-SPI constants (atspi-constants.h). The role ids are the AtspiRole enum
66 // values; the state ids are bit indices of the state set.
77 const (
8- roleCheckBox = 7
9- roleComboBox = 11
10- roleDialog = 16
11- roleFrame = 23
12- roleMenuItem = 35
13- rolePageTab = 37
14- rolePushButton = 43
15- roleRadioButton = 44
16- roleToggleButton = 62
17- roleWindow = 69
18- roleLink = 88
8+ roleCheckBox = 7
9+ roleComboBox = 11
10+ roleDialog = 16
11+ roleFrame = 23
12+ roleMenuItem = 35
13+ rolePageTab = 37
14+ rolePushButton = 43
15+ roleRadioButton = 44
16+ roleToggleButton = 62
17+ roleWindow = 69
18+ roleLink = 88
19+ roleDocumentFrame = 82
20+ roleDocumentWeb = 95
1921
2022 stateActive = 1
2123 stateShowing = 25
@@ -43,14 +45,14 @@ var clickableRoles = []uint32{
4345 }
4446
4547 // matchRule is the Collection query: showing and visible objects of the
46-// clickable roles (only links when linksOnly).
48+// clickable roles (only links when linksOnly), plus web documents for coverage.
4749 type matchRule struct{ linksOnly bool }
4850
4951 func (r matchRule) roles() []uint32 {
5052 if r.linksOnly {
51- return []uint32{roleLink}
53+ return []uint32{roleLink, roleDocumentFrame, roleDocumentWeb}
5254 }
53- return clickableRoles
55+ return append(append([]uint32(nil), clickableRoles...), roleDocumentFrame, roleDocumentWeb)
5456 }
5557
5658 // roleBits packs role ids into the bit set of a MatchRule: bit id%32 of word
@@ -186,3 +188,5 @@ func toTarget(win ports.Rect, fx, fy int32, e element) (ports.HintTarget, bool)
186188 }
187189 return ports.HintTarget{Rect: ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, Kind: kindOf(e.role)}, true
188190 }
191+
192+func isDocument(role uint32) bool { return role == roleDocumentFrame || role == roleDocumentWeb }
@@ -5,17 +5,19 @@ import "github.com/bnema/neferwl/internal/ports"
5 // AT-SPI constants (atspi-constants.h). The role ids are the AtspiRole enum5 // AT-SPI constants (atspi-constants.h). The role ids are the AtspiRole enum
6 // values; the state ids are bit indices of the state set.6 // values; the state ids are bit indices of the state set.
7 const (7 const (
8- roleCheckBox = 78+ roleCheckBox = 7
9- roleComboBox = 119+ roleComboBox = 11
10- roleDialog = 1610+ roleDialog = 16
11- roleFrame = 2311+ roleFrame = 23
12- roleMenuItem = 3512+ roleMenuItem = 35
13- rolePageTab = 3713+ rolePageTab = 37
14- rolePushButton = 4314+ rolePushButton = 43
15- roleRadioButton = 4415+ roleRadioButton = 44
16- roleToggleButton = 6216+ roleToggleButton = 62
17- roleWindow = 6917+ roleWindow = 69
18- roleLink = 8818+ roleLink = 88
19+ roleDocumentFrame = 82
20+ roleDocumentWeb = 95
19 21
20 stateActive = 122 stateActive = 1
21 stateShowing = 2523 stateShowing = 25
@@ -43,14 +45,14 @@ var clickableRoles = []uint32{
43 }45 }
44 46
45 // matchRule is the Collection query: showing and visible objects of the47 // matchRule is the Collection query: showing and visible objects of the
46-// clickable roles (only links when linksOnly).48+// clickable roles (only links when linksOnly), plus web documents for coverage.
47 type matchRule struct{ linksOnly bool }49 type matchRule struct{ linksOnly bool }
48 50
49 func (r matchRule) roles() []uint32 {51 func (r matchRule) roles() []uint32 {
50 if r.linksOnly {52 if r.linksOnly {
51- return []uint32{roleLink}53+ return []uint32{roleLink, roleDocumentFrame, roleDocumentWeb}
52 }54 }
53- return clickableRoles55+ return append(append([]uint32(nil), clickableRoles...), roleDocumentFrame, roleDocumentWeb)
54 }56 }
55 57
56 // roleBits packs role ids into the bit set of a MatchRule: bit id%32 of word58 // roleBits packs role ids into the bit set of a MatchRule: bit id%32 of word
@@ -186,3 +188,5 @@ func toTarget(win ports.Rect, fx, fy int32, e element) (ports.HintTarget, bool)
186 }188 }
187 return ports.HintTarget{Rect: ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, Kind: kindOf(e.role)}, true189 return ports.HintTarget{Rect: ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, Kind: kindOf(e.role)}, true
188 }190 }
191+
192+func isDocument(role uint32) bool { return role == roleDocumentFrame || role == roleDocumentWeb }
M internal/adapters/atspi/source.go +9 -3
@@ -93,7 +93,7 @@ type winMatch struct {
9393 // Targets returns the clickable targets of the accessibility tree for
9494 // windows (logical output-local, clipped to each window) and the set of
9595 // windows those targets cover. A window is covered when its tree answered
96-// with at least one target; the pixels of an uncovered window are kept. It
96+// with a link target or web document; uncovered windows also get OCR. It
9797 // returns what is ready within the budget.
9898 func (s *Source) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
9999 covered := map[ports.WindowID]bool{}
@@ -243,6 +243,10 @@ func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly
243243 var out []ports.HintTarget
244244 for k, i := range owner {
245245 role, ext := info[2*k], info[2*k+1]
246+ if role.err == nil && isDocument(role.u) {
247+ covered[matched[i].win.ID] = true
248+ continue
249+ }
246250 if role.err != nil || ext.err != nil || !isClickable(role.u, linksOnly) {
247251 continue
248252 }
@@ -252,7 +256,9 @@ func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly
252256 continue
253257 }
254258 out = append(out, t)
255- covered[m.win.ID] = true
259+ if t.Kind == ports.HintLink {
260+ covered[m.win.ID] = true
261+ }
256262 }
257263 return out
258264 }
@@ -279,7 +285,7 @@ func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
279285 if state.err == nil && !hasState(state.state, stateShowing) {
280286 continue
281287 }
282- if role.err == nil && isClickable(role.u, linksOnly) && state.err == nil && hasState(state.state, stateVisible) {
288+ if role.err == nil && (isClickable(role.u, linksOnly) || isDocument(role.u)) && state.err == nil && hasState(state.state, stateVisible) {
283289 out = append(out, n)
284290 }
285291 if ch.err == nil {
@@ -93,7 +93,7 @@ type winMatch struct {
93 // Targets returns the clickable targets of the accessibility tree for93 // Targets returns the clickable targets of the accessibility tree for
94 // windows (logical output-local, clipped to each window) and the set of94 // windows (logical output-local, clipped to each window) and the set of
95 // windows those targets cover. A window is covered when its tree answered95 // windows those targets cover. A window is covered when its tree answered
96-// with at least one target; the pixels of an uncovered window are kept. It96+// with a link target or web document; uncovered windows also get OCR. It
97 // returns what is ready within the budget.97 // returns what is ready within the budget.
98 func (s *Source) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {98 func (s *Source) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
99 covered := map[ports.WindowID]bool{}99 covered := map[ports.WindowID]bool{}
@@ -243,6 +243,10 @@ func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly
243 var out []ports.HintTarget243 var out []ports.HintTarget
244 for k, i := range owner {244 for k, i := range owner {
245 role, ext := info[2*k], info[2*k+1]245 role, ext := info[2*k], info[2*k+1]
246+ if role.err == nil && isDocument(role.u) {
247+ covered[matched[i].win.ID] = true
248+ continue
249+ }
246 if role.err != nil || ext.err != nil || !isClickable(role.u, linksOnly) {250 if role.err != nil || ext.err != nil || !isClickable(role.u, linksOnly) {
247 continue251 continue
248 }252 }
@@ -252,7 +256,9 @@ func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly
252 continue256 continue
253 }257 }
254 out = append(out, t)258 out = append(out, t)
255- covered[m.win.ID] = true259+ if t.Kind == ports.HintLink {
260+ covered[m.win.ID] = true
261+ }
256 }262 }
257 return out263 return out
258 }264 }
@@ -279,7 +285,7 @@ func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
279 if state.err == nil && !hasState(state.state, stateShowing) {285 if state.err == nil && !hasState(state.state, stateShowing) {
280 continue286 continue
281 }287 }
282- if role.err == nil && isClickable(role.u, linksOnly) && state.err == nil && hasState(state.state, stateVisible) {288+ if role.err == nil && (isClickable(role.u, linksOnly) || isDocument(role.u)) && state.err == nil && hasState(state.state, stateVisible) {
283 out = append(out, n)289 out = append(out, n)
284 }290 }
285 if ch.err == nil {291 if ch.err == nil {
M internal/adapters/atspi/source_test.go +22 -0
@@ -2,6 +2,7 @@ package atspi
22
33 import (
44 "context"
5+ "fmt"
56 "testing"
67 "time"
78
@@ -122,3 +123,24 @@ func TestTargetsWithoutPIDsAsksNothing(t *testing.T) {
122123 require.NoError(t, err)
123124 require.Empty(t, covered)
124125 }
126+
127+func TestCoverageRequiresLinkOrDocument(t *testing.T) {
128+ for _, collection := range []bool{true, false} {
129+ for _, role := range []uint32{rolePushButton, roleDocumentFrame, roleDocumentWeb, roleLink} {
130+ t.Run(fmt.Sprintf("collection=%t/role=%d", collection, role), func(t *testing.T) {
131+ tr := newMocktransport(t)
132+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
133+ [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {10, 20, 30, 40}}, map[string]uint32{"/a": role}, !collection))
134+ s := &Source{tr: tr}
135+ targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false)
136+ require.NoError(t, err)
137+ require.Equal(t, role != rolePushButton, covered[1])
138+ if isDocument(role) {
139+ require.Empty(t, targets)
140+ } else {
141+ require.Len(t, targets, 1)
142+ }
143+ })
144+ }
145+ }
146+}
@@ -2,6 +2,7 @@ package atspi
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -122,3 +123,24 @@ func TestTargetsWithoutPIDsAsksNothing(t *testing.T) {
122 require.NoError(t, err)123 require.NoError(t, err)
123 require.Empty(t, covered)124 require.Empty(t, covered)
124 }125 }
126+
127+func TestCoverageRequiresLinkOrDocument(t *testing.T) {
128+ for _, collection := range []bool{true, false} {
129+ for _, role := range []uint32{rolePushButton, roleDocumentFrame, roleDocumentWeb, roleLink} {
130+ t.Run(fmt.Sprintf("collection=%t/role=%d", collection, role), func(t *testing.T) {
131+ tr := newMocktransport(t)
132+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
133+ [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {10, 20, 30, 40}}, map[string]uint32{"/a": role}, !collection))
134+ s := &Source{tr: tr}
135+ targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false)
136+ require.NoError(t, err)
137+ require.Equal(t, role != rolePushButton, covered[1])
138+ if isDocument(role) {
139+ require.Empty(t, targets)
140+ } else {
141+ require.Len(t, targets, 1)
142+ }
143+ })
144+ }
145+ }
146+}
A internal/adapters/hints/benchmark_test.go +61 -0
@@ -0,0 +1,61 @@
1+package hints
2+
3+import (
4+ "context"
5+ "image"
6+ "image/draw"
7+ "image/png"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "testing"
12+ "time"
13+)
14+
15+// TestFrameBenchmark logs counts and timings, never filenames or OCR text.
16+func TestFrameBenchmark(t *testing.T) {
17+ dir := os.Getenv("NEFERWL_HINT_BENCH_DIR")
18+ if dir == "" {
19+ t.Skip("set NEFERWL_HINT_BENCH_DIR to a local PNG directory")
20+ }
21+ if _, err := exec.LookPath("tesseract"); err != nil {
22+ t.Skip("tesseract not on PATH")
23+ }
24+ files, err := filepath.Glob(filepath.Join(dir, "*.png"))
25+ if err != nil || len(files) == 0 {
26+ t.Fatal("no PNG frames")
27+ }
28+ for i, file := range files {
29+ f, err := os.Open(file)
30+ if err != nil {
31+ t.Fatal("open frame failed")
32+ }
33+ src, err := png.Decode(f)
34+ _ = f.Close()
35+ if err != nil {
36+ t.Fatal("decode frame failed")
37+ }
38+ img := image.NewRGBA(image.Rect(0, 0, src.Bounds().Dx(), src.Bounds().Dy()))
39+ draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src)
40+ start := time.Now()
41+ words, bands, err := readWords(context.Background(), img)
42+ if err != nil {
43+ t.Fatal("recognition failed")
44+ }
45+ t.Logf("frame=%d px=%d ocr_ms=%.1f words=%d links=%d bands=%d", i, img.Bounds().Dx()*img.Bounds().Dy(), ms(time.Since(start)), len(words), len(textLinks(words, 1)), bands)
46+ // Experimental strips use known OCR word regions, not pixel targets.
47+ // This is an oracle comparison, not a production optimization.
48+ lines := make([]image.Rectangle, 0, len(words))
49+ for _, word := range words {
50+ lines = append(lines, word.Rect)
51+ }
52+ for _, normalized := range []bool{false, true} {
53+ begin := time.Now()
54+ compact, _, err := readStrips(context.Background(), img, lines, 8, normalized)
55+ if err != nil {
56+ t.Fatal("strip recognition failed")
57+ }
58+ t.Logf("frame=%d strips normalized=%t ocr_ms=%.1f words=%d links=%d", i, normalized, ms(time.Since(begin)), len(compact), len(textLinks(compact, 1)))
59+ }
60+ }
61+}
@@ -0,0 +1,61 @@
1+package hints
2+
3+import (
4+ "context"
5+ "image"
6+ "image/draw"
7+ "image/png"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "testing"
12+ "time"
13+)
14+
15+// TestFrameBenchmark logs counts and timings, never filenames or OCR text.
16+func TestFrameBenchmark(t *testing.T) {
17+ dir := os.Getenv("NEFERWL_HINT_BENCH_DIR")
18+ if dir == "" {
19+ t.Skip("set NEFERWL_HINT_BENCH_DIR to a local PNG directory")
20+ }
21+ if _, err := exec.LookPath("tesseract"); err != nil {
22+ t.Skip("tesseract not on PATH")
23+ }
24+ files, err := filepath.Glob(filepath.Join(dir, "*.png"))
25+ if err != nil || len(files) == 0 {
26+ t.Fatal("no PNG frames")
27+ }
28+ for i, file := range files {
29+ f, err := os.Open(file)
30+ if err != nil {
31+ t.Fatal("open frame failed")
32+ }
33+ src, err := png.Decode(f)
34+ _ = f.Close()
35+ if err != nil {
36+ t.Fatal("decode frame failed")
37+ }
38+ img := image.NewRGBA(image.Rect(0, 0, src.Bounds().Dx(), src.Bounds().Dy()))
39+ draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src)
40+ start := time.Now()
41+ words, bands, err := readWords(context.Background(), img)
42+ if err != nil {
43+ t.Fatal("recognition failed")
44+ }
45+ t.Logf("frame=%d px=%d ocr_ms=%.1f words=%d links=%d bands=%d", i, img.Bounds().Dx()*img.Bounds().Dy(), ms(time.Since(start)), len(words), len(textLinks(words, 1)), bands)
46+ // Experimental strips use known OCR word regions, not pixel targets.
47+ // This is an oracle comparison, not a production optimization.
48+ lines := make([]image.Rectangle, 0, len(words))
49+ for _, word := range words {
50+ lines = append(lines, word.Rect)
51+ }
52+ for _, normalized := range []bool{false, true} {
53+ begin := time.Now()
54+ compact, _, err := readStrips(context.Background(), img, lines, 8, normalized)
55+ if err != nil {
56+ t.Fatal("strip recognition failed")
57+ }
58+ t.Logf("frame=%d strips normalized=%t ocr_ms=%.1f words=%d links=%d", i, normalized, ms(time.Since(begin)), len(compact), len(textLinks(compact, 1)))
59+ }
60+ }
61+}
M internal/adapters/hints/dump.go +21 -13
@@ -27,7 +27,7 @@ type Options struct {
2727 // PNG and JSON). They contain the screen in clear.
2828 DumpDir string
2929 // Source, when not nil, finds targets in the accessibility tree of the
30- // windows on screen; the pixel scan covers the rest.
30+ // windows on screen; OCR reads the rest.
3131 Source targetSource
3232 }
3333
@@ -39,8 +39,10 @@ type dumpJob struct {
3939 pix []byte
4040 stride int
4141 targets []ports.HintTarget
42+ crops []image.Rectangle
43+ words []OCRWord
4244 // atspi is the number of leading targets that come from the
43- // accessibility tree; the others come from pixels.
45+ // accessibility tree; the others come from OCR.
4446 atspi int
4547 captureD time.Duration
4648 scanD time.Duration
@@ -54,16 +56,18 @@ type dumpTarget struct {
5456 }
5557
5658 type dumpMeta struct {
57- Output string `json:"output"`
58- Width int `json:"width"`
59- Height int `json:"height"`
60- Stride int `json:"stride"`
61- Scale float64 `json:"scale"`
62- LinksOnly bool `json:"links_only"`
63- CaptureMS float64 `json:"capture_ms"`
64- ScanMS float64 `json:"scan_ms"`
65- AtspiMS float64 `json:"atspi_ms"`
66- Targets []dumpTarget `json:"targets"`
59+ Output string `json:"output"`
60+ Width int `json:"width"`
61+ Height int `json:"height"`
62+ Stride int `json:"stride"`
63+ Scale float64 `json:"scale"`
64+ LinksOnly bool `json:"links_only"`
65+ CaptureMS float64 `json:"capture_ms"`
66+ ScanMS float64 `json:"scan_ms"`
67+ AtspiMS float64 `json:"atspi_ms"`
68+ Crops []image.Rectangle `json:"ocr_crops"`
69+ Words []OCRWord `json:"words"`
70+ Targets []dumpTarget `json:"targets"`
6771 }
6872
6973 func kindName(k ports.HintKind) string {
@@ -116,6 +120,9 @@ func writeDump(dir string, j *dumpJob) error {
116120 if scale <= 0 {
117121 scale = 1
118122 }
123+ for _, r := range j.crops {
124+ outline(img, r, color.RGBA{0x80, 0x80, 0x80, 0xff})
125+ }
119126 for i, t := range j.targets {
120127 r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale),
121128 int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale))
@@ -133,10 +140,11 @@ func writeDump(dir string, j *dumpJob) error {
133140 Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride,
134141 Scale: j.req.Scale, LinksOnly: j.req.LinksOnly,
135142 CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD), AtspiMS: ms(j.atspiD),
143+ Crops: j.crops, Words: j.words,
136144 Targets: make([]dumpTarget, 0, len(j.targets)),
137145 }
138146 for i, t := range j.targets {
139- src := "pixels"
147+ src := "ocr"
140148 if i < j.atspi {
141149 src = "atspi"
142150 }
@@ -27,7 +27,7 @@ type Options struct {
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 the29 // Source, when not nil, finds targets in the accessibility tree of the
30- // windows on screen; the pixel scan covers the rest.30+ // windows on screen; OCR reads the rest.
31 Source targetSource31 Source targetSource
32 }32 }
33 33
@@ -39,8 +39,10 @@ type dumpJob struct {
39 pix []byte39 pix []byte
40 stride int40 stride int
41 targets []ports.HintTarget41 targets []ports.HintTarget
42+ crops []image.Rectangle
43+ words []OCRWord
42 // atspi is the number of leading targets that come from the44 // atspi is the number of leading targets that come from the
43- // accessibility tree; the others come from pixels.45+ // accessibility tree; the others come from OCR.
44 atspi int46 atspi int
45 captureD time.Duration47 captureD time.Duration
46 scanD time.Duration48 scanD time.Duration
@@ -54,16 +56,18 @@ type dumpTarget struct {
54 }56 }
55 57
56 type dumpMeta struct {58 type dumpMeta struct {
57- Output string `json:"output"`59+ Output string `json:"output"`
58- Width int `json:"width"`60+ Width int `json:"width"`
59- Height int `json:"height"`61+ Height int `json:"height"`
60- Stride int `json:"stride"`62+ Stride int `json:"stride"`
61- Scale float64 `json:"scale"`63+ Scale float64 `json:"scale"`
62- LinksOnly bool `json:"links_only"`64+ LinksOnly bool `json:"links_only"`
63- CaptureMS float64 `json:"capture_ms"`65+ CaptureMS float64 `json:"capture_ms"`
64- ScanMS float64 `json:"scan_ms"`66+ ScanMS float64 `json:"scan_ms"`
65- AtspiMS float64 `json:"atspi_ms"`67+ AtspiMS float64 `json:"atspi_ms"`
66- Targets []dumpTarget `json:"targets"`68+ Crops []image.Rectangle `json:"ocr_crops"`
69+ Words []OCRWord `json:"words"`
70+ Targets []dumpTarget `json:"targets"`
67 }71 }
68 72
69 func kindName(k ports.HintKind) string {73 func kindName(k ports.HintKind) string {
@@ -116,6 +120,9 @@ func writeDump(dir string, j *dumpJob) error {
116 if scale <= 0 {120 if scale <= 0 {
117 scale = 1121 scale = 1
118 }122 }
123+ for _, r := range j.crops {
124+ outline(img, r, color.RGBA{0x80, 0x80, 0x80, 0xff})
125+ }
119 for i, t := range j.targets {126 for i, t := range j.targets {
120 r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale),127 r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale),
121 int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale))128 int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale))
@@ -133,10 +140,11 @@ func writeDump(dir string, j *dumpJob) error {
133 Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride,140 Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride,
134 Scale: j.req.Scale, LinksOnly: j.req.LinksOnly,141 Scale: j.req.Scale, LinksOnly: j.req.LinksOnly,
135 CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD), AtspiMS: ms(j.atspiD),142 CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD), AtspiMS: ms(j.atspiD),
143+ Crops: j.crops, Words: j.words,
136 Targets: make([]dumpTarget, 0, len(j.targets)),144 Targets: make([]dumpTarget, 0, len(j.targets)),
137 }145 }
138 for i, t := range j.targets {146 for i, t := range j.targets {
139- src := "pixels"147+ src := "ocr"
140 if i < j.atspi {148 if i < j.atspi {
141 src = "atspi"149 src = "atspi"
142 }150 }
M internal/adapters/hints/hints.go +105 -88
@@ -1,16 +1,17 @@
11 // Package hints finds clickable targets for hint mode: it captures the last
2-// frame of an output through the capture pipeline and scans it with hintscan.
2+// frame of an output through the capture pipeline and reads uncovered windows with OCR.
33 package hints
44
55 import (
66 "context"
77 "errors"
88 "fmt"
9+ "hash/maphash"
910 "image"
11+ "image/draw"
12+ "math"
1013 "time"
1114
12- "bnema.dev/hintscan"
13-
1415 "github.com/bnema/neferwl/internal/adapters/logging"
1516 "github.com/bnema/neferwl/internal/ports"
1617 )
@@ -53,17 +54,18 @@ type atspiResult struct {
5354 }
5455
5556 type worker struct {
56- source targetSource
57- scanner *hintscan.Scanner
58- buf []byte
59- dst []hintscan.Target
60- n uint64
61- dumpDir string
62- pending *dumpJob
57+ source targetSource
58+ buf []byte
59+ cache map[cropKey]cropCache
60+ cacheSeed maphash.Seed
61+ warnedSource bool
62+ n uint64
63+ dumpDir string
64+ pending *dumpJob
6365 }
6466
6567 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 {
66- w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir, source: opts.Source}
68+ w := &worker{dumpDir: opts.DumpDir, source: opts.Source}
6769 for {
6870 select {
6971 case <-ctx.Done():
@@ -112,7 +114,7 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
112114 if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {
113115 return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))
114116 }
115- // The accessibility query runs while the frame is captured and scanned.
117+ // The accessibility query runs while the frame is captured; OCR waits for it.
116118 // It is always waited for: the source is not shared between requests.
117119 atspi := w.startSource(ctx, req)
118120 defer func() { <-atspi.done }()
@@ -178,56 +180,108 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
178180
179181 func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult, atspi *pendingSource) ports.HintScanResult {
180182 log := logging.For(ctx, "hints")
181- kinds := hintscan.Clickable
182- if req.LinksOnly {
183- kinds = hintscan.KindSet(1 << hintscan.KindLink)
183+ ar := atspi.wait()
184+ if ar.err != nil && !w.warnedSource {
185+ log.Warn().Err(ar.err).Msg("accessibility targets unavailable; using OCR only")
186+ w.warnedSource = true
184187 }
185- frame := hintscan.Frame{Pix: buf, Stride: stride, Width: req.Width, Height: req.Height, Order: hintscan.OrderBGRA, Scale: req.Scale}
186-
188+ out := make([]ports.HintTarget, 0, len(ar.targets))
189+ for _, t := range ar.targets {
190+ if !req.LinksOnly || t.Kind == ports.HintLink {
191+ out = append(out, t)
192+ }
193+ }
194+ nAtspi := len(out)
187195 t0 := time.Now()
188- targets, err := w.scanner.Scan(ctx, frame, hintscan.Options{Kinds: kinds}, w.dst)
189- scanDur := time.Since(t0)
190- if err != nil {
191- log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
192- res.Err = err
193- return res
196+ if w.cacheSeed == (maphash.Seed{}) {
197+ w.cacheSeed = maphash.MakeSeed()
194198 }
195- w.dst = targets
196-
197- pix := make([]ports.HintTarget, 0, len(targets))
198- for _, t := range targets {
199- kind := ports.HintLink
200- if t.Kind == hintscan.KindButton {
201- kind = ports.HintButton
199+ nextCache := make(map[cropKey]cropCache)
200+ var crops []image.Rectangle
201+ var allWords []OCRWord
202+ pixels, links := 0, 0
203+ cacheHit := true
204+ for _, win := range req.Windows {
205+ if ar.covered[win.ID] {
206+ continue
202207 }
203- pix = append(pix, ports.HintTarget{
204- Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},
205- Kind: kind,
206- })
207- }
208- ar := atspi.wait()
209- if ar.err != nil {
210- log.Info().Err(ar.err).Msg("accessibility targets unavailable")
208+ r := windowCrop(win.Rect, req.Scale, req.Width, req.Height)
209+ if r.Empty() {
210+ continue
211+ }
212+ crops = append(crops, r)
213+ pixels += r.Dx() * r.Dy()
214+ img := cropImage(buf, stride, r)
215+ key := cropKey{output: req.Output, window: win.ID}
216+ hash := maphash.Bytes(w.cacheSeed, img.Pix)
217+ entry, ok := w.cache[key]
218+ hit := ok && entry.hash == hash && entry.width == r.Dx() && entry.height == r.Dy()
219+ cacheHit = cacheHit && hit
220+ if !hit {
221+ words, _, err := readWords(ctx, img)
222+ if err != nil {
223+ failureWarning.Do(func() { log.Warn().Err(err).Msg("OCR unavailable; install tesseract and tesseract-data-eng") })
224+ continue
225+ }
226+ entry = cropCache{hash: hash, width: r.Dx(), height: r.Dy(), words: words}
227+ }
228+ nextCache[key] = entry
229+ words := append([]OCRWord(nil), entry.words...)
230+ for i := range words {
231+ words[i].Rect = words[i].Rect.Add(r.Min)
232+ }
233+ allWords = append(allWords, words...)
234+ targets := textLinks(words, req.Scale)
235+ links += len(targets)
236+ out = append(out, targets...)
211237 }
212- out, nAtspi := merge(req.Windows, ar.targets, ar.covered, pix)
238+ w.cache = nextCache
239+ ocrDur := time.Since(t0)
213240 res.Targets = out
214241 if w.dumpDir != "" {
215- w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, atspi: nAtspi, captureD: captureDur, scanD: scanDur, atspiD: ar.dur}
242+ w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, atspi: nAtspi, crops: crops, words: allWords, captureD: captureDur, scanD: ocrDur, atspiD: ar.dur}
216243 }
217- log.Info().
218- Str("output", req.Output).
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).
225- Dur("scan", scanDur).
226- Dur("capture", captureDur).
227- Msg("hint scan")
244+ log.Info().Str("output", req.Output).
245+ Float64("atspi_ms", ms(ar.dur)).Int("atspi_windows", len(ar.covered)).Int("atspi_targets", nAtspi).
246+ Int("ocr_windows", len(crops)).Float64("ocr_ms", ms(ocrDur)).Int("ocr_px", pixels).
247+ Int("words", len(allWords)).Int("ocr_links", links).Bool("cache_hit", len(crops) > 0 && cacheHit).
248+ Int("targets", len(out)).Msg("hint scan")
228249 return res
229250 }
230251
252+type cropKey struct {
253+ output string
254+ window ports.WindowID
255+}
256+type cropCache struct {
257+ hash uint64
258+ width, height int
259+ words []OCRWord
260+}
261+
262+// windowCrop rounds outwards in physical pixels, then intersects the output.
263+func windowCrop(r ports.Rect, scale float64, width, height int) image.Rectangle {
264+ if scale <= 0 {
265+ scale = 1
266+ }
267+ if r.W <= 0 || r.H <= 0 {
268+ return image.Rectangle{}
269+ }
270+ return image.Rect(int(math.Floor(float64(r.X)*scale)), int(math.Floor(float64(r.Y)*scale)),
271+ int(math.Ceil(float64(r.X+r.W)*scale)), int(math.Ceil(float64(r.Y+r.H)*scale))).Intersect(image.Rect(0, 0, width, height))
272+}
273+
274+func cropImage(buf []byte, stride int, r image.Rectangle) *image.RGBA {
275+ img := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
276+ // Convert only this window's BGRA pixels, not the full output.
277+ src := &image.RGBA{Pix: buf, Stride: stride, Rect: image.Rect(0, 0, stride/4, len(buf)/stride)}
278+ draw.Draw(img, img.Bounds(), src, r.Min, draw.Src)
279+ for i := 0; i < len(img.Pix); i += 4 {
280+ img.Pix[i], img.Pix[i+2], img.Pix[i+3] = img.Pix[i+2], img.Pix[i], 255
281+ }
282+ return img
283+}
284+
231285 // pendingSource is an accessibility query in flight.
232286 type pendingSource struct {
233287 done chan struct{}
@@ -256,40 +310,3 @@ func (p *pendingSource) wait() atspiResult {
256310 <-p.done
257311 return p.res
258312 }
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-}
@@ -1,16 +1,17 @@
1 // Package hints finds clickable targets for hint mode: it captures the last1 // Package hints finds clickable targets for hint mode: it captures the last
2-// frame of an output through the capture pipeline and scans it with hintscan.2+// frame of an output through the capture pipeline and reads uncovered windows with OCR.
3 package hints3 package hints
4 4
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "hash/maphash"
9 "image"10 "image"
11+ "image/draw"
12+ "math"
10 "time"13 "time"
11 14
12- "bnema.dev/hintscan"
13-
14 "github.com/bnema/neferwl/internal/adapters/logging"15 "github.com/bnema/neferwl/internal/adapters/logging"
15 "github.com/bnema/neferwl/internal/ports"16 "github.com/bnema/neferwl/internal/ports"
16 )17 )
@@ -53,17 +54,18 @@ type atspiResult struct {
53 }54 }
54 55
55 type worker struct {56 type worker struct {
56- source targetSource57+ source targetSource
57- scanner *hintscan.Scanner58+ buf []byte
58- buf []byte59+ cache map[cropKey]cropCache
59- dst []hintscan.Target60+ cacheSeed maphash.Seed
60- n uint6461+ warnedSource bool
61- dumpDir string62+ n uint64
62- pending *dumpJob63+ dumpDir string
64+ pending *dumpJob
63 }65 }
64 66
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 {67 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 {
66- w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir, source: opts.Source}68+ w := &worker{dumpDir: opts.DumpDir, source: opts.Source}
67 for {69 for {
68 select {70 select {
69 case <-ctx.Done():71 case <-ctx.Done():
@@ -112,7 +114,7 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
112 if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {114 if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {
113 return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))115 return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))
114 }116 }
115- // The accessibility query runs while the frame is captured and scanned.117+ // The accessibility query runs while the frame is captured; OCR waits for it.
116 // It is always waited for: the source is not shared between requests.118 // It is always waited for: the source is not shared between requests.
117 atspi := w.startSource(ctx, req)119 atspi := w.startSource(ctx, req)
118 defer func() { <-atspi.done }()120 defer func() { <-atspi.done }()
@@ -178,56 +180,108 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
178 180
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 {181 func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult, atspi *pendingSource) ports.HintScanResult {
180 log := logging.For(ctx, "hints")182 log := logging.For(ctx, "hints")
181- kinds := hintscan.Clickable183+ ar := atspi.wait()
182- if req.LinksOnly {184+ if ar.err != nil && !w.warnedSource {
183- kinds = hintscan.KindSet(1 << hintscan.KindLink)185+ log.Warn().Err(ar.err).Msg("accessibility targets unavailable; using OCR only")
186+ w.warnedSource = true
184 }187 }
185- frame := hintscan.Frame{Pix: buf, Stride: stride, Width: req.Width, Height: req.Height, Order: hintscan.OrderBGRA, Scale: req.Scale}188+ out := make([]ports.HintTarget, 0, len(ar.targets))
186-189+ for _, t := range ar.targets {
190+ if !req.LinksOnly || t.Kind == ports.HintLink {
191+ out = append(out, t)
192+ }
193+ }
194+ nAtspi := len(out)
187 t0 := time.Now()195 t0 := time.Now()
188- targets, err := w.scanner.Scan(ctx, frame, hintscan.Options{Kinds: kinds}, w.dst)196+ if w.cacheSeed == (maphash.Seed{}) {
189- scanDur := time.Since(t0)197+ w.cacheSeed = maphash.MakeSeed()
190- if err != nil {
191- log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
192- res.Err = err
193- return res
194 }198 }
195- w.dst = targets199+ nextCache := make(map[cropKey]cropCache)
196-200+ var crops []image.Rectangle
197- pix := make([]ports.HintTarget, 0, len(targets))201+ var allWords []OCRWord
198- for _, t := range targets {202+ pixels, links := 0, 0
199- kind := ports.HintLink203+ cacheHit := true
200- if t.Kind == hintscan.KindButton {204+ for _, win := range req.Windows {
201- kind = ports.HintButton205+ if ar.covered[win.ID] {
206+ continue
202 }207 }
203- pix = append(pix, ports.HintTarget{208+ r := windowCrop(win.Rect, req.Scale, req.Width, req.Height)
204- Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},209+ if r.Empty() {
205- Kind: kind,210+ continue
206- })211+ }
207- }212+ crops = append(crops, r)
208- ar := atspi.wait()213+ pixels += r.Dx() * r.Dy()
209- if ar.err != nil {214+ img := cropImage(buf, stride, r)
210- log.Info().Err(ar.err).Msg("accessibility targets unavailable")215+ key := cropKey{output: req.Output, window: win.ID}
216+ hash := maphash.Bytes(w.cacheSeed, img.Pix)
217+ entry, ok := w.cache[key]
218+ hit := ok && entry.hash == hash && entry.width == r.Dx() && entry.height == r.Dy()
219+ cacheHit = cacheHit && hit
220+ if !hit {
221+ words, _, err := readWords(ctx, img)
222+ if err != nil {
223+ failureWarning.Do(func() { log.Warn().Err(err).Msg("OCR unavailable; install tesseract and tesseract-data-eng") })
224+ continue
225+ }
226+ entry = cropCache{hash: hash, width: r.Dx(), height: r.Dy(), words: words}
227+ }
228+ nextCache[key] = entry
229+ words := append([]OCRWord(nil), entry.words...)
230+ for i := range words {
231+ words[i].Rect = words[i].Rect.Add(r.Min)
232+ }
233+ allWords = append(allWords, words...)
234+ targets := textLinks(words, req.Scale)
235+ links += len(targets)
236+ out = append(out, targets...)
211 }237 }
212- out, nAtspi := merge(req.Windows, ar.targets, ar.covered, pix)238+ w.cache = nextCache
239+ ocrDur := time.Since(t0)
213 res.Targets = out240 res.Targets = out
214 if w.dumpDir != "" {241 if w.dumpDir != "" {
215- w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, atspi: nAtspi, captureD: captureDur, scanD: scanDur, atspiD: ar.dur}242+ w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, atspi: nAtspi, crops: crops, words: allWords, captureD: captureDur, scanD: ocrDur, atspiD: ar.dur}
216 }243 }
217- log.Info().244+ log.Info().Str("output", req.Output).
218- Str("output", req.Output).245+ Float64("atspi_ms", ms(ar.dur)).Int("atspi_windows", len(ar.covered)).Int("atspi_targets", nAtspi).
219- Int("targets", len(out)).246+ Int("ocr_windows", len(crops)).Float64("ocr_ms", ms(ocrDur)).Int("ocr_px", pixels).
220- Dur("atspi_ms", ar.dur).247+ Int("words", len(allWords)).Int("ocr_links", links).Bool("cache_hit", len(crops) > 0 && cacheHit).
221- Int("atspi_apps", coveredApps(req.Windows, ar.covered)).248+ Int("targets", len(out)).Msg("hint scan")
222- Int("atspi_windows", len(ar.covered)).
223- Int("atspi_targets", nAtspi).
224- Int("pixel_targets", len(out)-nAtspi).
225- Dur("scan", scanDur).
226- Dur("capture", captureDur).
227- Msg("hint scan")
228 return res249 return res
229 }250 }
230 251
252+type cropKey struct {
253+ output string
254+ window ports.WindowID
255+}
256+type cropCache struct {
257+ hash uint64
258+ width, height int
259+ words []OCRWord
260+}
261+
262+// windowCrop rounds outwards in physical pixels, then intersects the output.
263+func windowCrop(r ports.Rect, scale float64, width, height int) image.Rectangle {
264+ if scale <= 0 {
265+ scale = 1
266+ }
267+ if r.W <= 0 || r.H <= 0 {
268+ return image.Rectangle{}
269+ }
270+ return image.Rect(int(math.Floor(float64(r.X)*scale)), int(math.Floor(float64(r.Y)*scale)),
271+ int(math.Ceil(float64(r.X+r.W)*scale)), int(math.Ceil(float64(r.Y+r.H)*scale))).Intersect(image.Rect(0, 0, width, height))
272+}
273+
274+func cropImage(buf []byte, stride int, r image.Rectangle) *image.RGBA {
275+ img := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
276+ // Convert only this window's BGRA pixels, not the full output.
277+ src := &image.RGBA{Pix: buf, Stride: stride, Rect: image.Rect(0, 0, stride/4, len(buf)/stride)}
278+ draw.Draw(img, img.Bounds(), src, r.Min, draw.Src)
279+ for i := 0; i < len(img.Pix); i += 4 {
280+ img.Pix[i], img.Pix[i+2], img.Pix[i+3] = img.Pix[i+2], img.Pix[i], 255
281+ }
282+ return img
283+}
284+
231 // pendingSource is an accessibility query in flight.285 // pendingSource is an accessibility query in flight.
232 type pendingSource struct {286 type pendingSource struct {
233 done chan struct{}287 done chan struct{}
@@ -256,40 +310,3 @@ func (p *pendingSource) wait() atspiResult {
256 <-p.done310 <-p.done
257 return p.res311 return p.res
258 }312 }
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 +13 -24
@@ -9,6 +9,7 @@ import (
99 "image/draw"
1010 _ "image/png"
1111 "os"
12+ "os/exec"
1213 "path/filepath"
1314 "strings"
1415 "testing"
@@ -67,6 +68,9 @@ func loadBGRA(t *testing.T) (pix []byte, w, hgt int) {
6768 }
6869
6970 func TestHintScan(t *testing.T) {
71+ if _, err := exec.LookPath("tesseract"); err != nil {
72+ t.Skip("tesseract not on PATH")
73+ }
7074 pix, w, hgt := loadBGRA(t)
7175 h := start(t, 5*time.Second)
7276
@@ -77,7 +81,7 @@ func TestHintScan(t *testing.T) {
7781 h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
7882 }()
7983
80- h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}
84+ h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: hgt}}}}
8185 res := <-h.results
8286 require.NoError(t, res.Err)
8387 require.Equal(t, uint64(7), res.Seq)
@@ -164,7 +168,7 @@ func scanOnce(t *testing.T, h *harness, pix []byte, w, hgt int, seq uint64) port
164168 copy(creq.Mapped, pix)
165169 h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
166170 }()
167- h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}
171+ h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: hgt}}}}
168172 res := <-h.results
169173 require.NoError(t, res.Err)
170174 return res
@@ -182,6 +186,9 @@ func waitFiles(t *testing.T, dir string, n int) []os.DirEntry {
182186 }
183187
184188 func TestHintDump(t *testing.T) {
189+ if _, err := exec.LookPath("tesseract"); err != nil {
190+ t.Skip("tesseract not on PATH")
191+ }
185192 pix, w, hgt := loadBGRA(t)
186193 dir := filepath.Join(t.TempDir(), "hints")
187194 h := startWith(t, 5*time.Second, Options{DumpDir: dir})
@@ -287,27 +294,6 @@ func TestHintDumpRetention(t *testing.T) {
287294 require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())
288295 }
289296
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-
311297 func TestScanMergesSource(t *testing.T) {
312298 pix, w, hgt := loadBGRA(t)
313299 src := newMocktargetSource(t)
@@ -327,7 +313,10 @@ func TestScanMergesSource(t *testing.T) {
327313 require.Equal(t, at, res.Targets)
328314 }
329315
330-func TestScanSourceErrorKeepsPixels(t *testing.T) {
316+func TestScanSourceErrorUsesOCR(t *testing.T) {
317+ if _, err := exec.LookPath("tesseract"); err != nil {
318+ t.Skip("tesseract not on PATH")
319+ }
331320 pix, w, hgt := loadBGRA(t)
332321 src := newMocktargetSource(t)
333322 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}
@@ -9,6 +9,7 @@ import (
9 "image/draw"9 "image/draw"
10 _ "image/png"10 _ "image/png"
11 "os"11 "os"
12+ "os/exec"
12 "path/filepath"13 "path/filepath"
13 "strings"14 "strings"
14 "testing"15 "testing"
@@ -67,6 +68,9 @@ func loadBGRA(t *testing.T) (pix []byte, w, hgt int) {
67 }68 }
68 69
69 func TestHintScan(t *testing.T) {70 func TestHintScan(t *testing.T) {
71+ if _, err := exec.LookPath("tesseract"); err != nil {
72+ t.Skip("tesseract not on PATH")
73+ }
70 pix, w, hgt := loadBGRA(t)74 pix, w, hgt := loadBGRA(t)
71 h := start(t, 5*time.Second)75 h := start(t, 5*time.Second)
72 76
@@ -77,7 +81,7 @@ func TestHintScan(t *testing.T) {
77 h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}81 h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
78 }()82 }()
79 83
80- h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}84+ h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: hgt}}}}
81 res := <-h.results85 res := <-h.results
82 require.NoError(t, res.Err)86 require.NoError(t, res.Err)
83 require.Equal(t, uint64(7), res.Seq)87 require.Equal(t, uint64(7), res.Seq)
@@ -164,7 +168,7 @@ func scanOnce(t *testing.T, h *harness, pix []byte, w, hgt int, seq uint64) port
164 copy(creq.Mapped, pix)168 copy(creq.Mapped, pix)
165 h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}169 h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
166 }()170 }()
167- h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}171+ h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: hgt}}}}
168 res := <-h.results172 res := <-h.results
169 require.NoError(t, res.Err)173 require.NoError(t, res.Err)
170 return res174 return res
@@ -182,6 +186,9 @@ func waitFiles(t *testing.T, dir string, n int) []os.DirEntry {
182 }186 }
183 187
184 func TestHintDump(t *testing.T) {188 func TestHintDump(t *testing.T) {
189+ if _, err := exec.LookPath("tesseract"); err != nil {
190+ t.Skip("tesseract not on PATH")
191+ }
185 pix, w, hgt := loadBGRA(t)192 pix, w, hgt := loadBGRA(t)
186 dir := filepath.Join(t.TempDir(), "hints")193 dir := filepath.Join(t.TempDir(), "hints")
187 h := startWith(t, 5*time.Second, Options{DumpDir: dir})194 h := startWith(t, 5*time.Second, Options{DumpDir: dir})
@@ -287,27 +294,6 @@ func TestHintDumpRetention(t *testing.T) {
287 require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())294 require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())
288 }295 }
289 296
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) {297 func TestScanMergesSource(t *testing.T) {
312 pix, w, hgt := loadBGRA(t)298 pix, w, hgt := loadBGRA(t)
313 src := newMocktargetSource(t)299 src := newMocktargetSource(t)
@@ -327,7 +313,10 @@ func TestScanMergesSource(t *testing.T) {
327 require.Equal(t, at, res.Targets)313 require.Equal(t, at, res.Targets)
328 }314 }
329 315
330-func TestScanSourceErrorKeepsPixels(t *testing.T) {316+func TestScanSourceErrorUsesOCR(t *testing.T) {
317+ if _, err := exec.LookPath("tesseract"); err != nil {
318+ t.Skip("tesseract not on PATH")
319+ }
331 pix, w, hgt := loadBGRA(t)320 pix, w, hgt := loadBGRA(t)
332 src := newMocktargetSource(t)321 src := newMocktargetSource(t)
333 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}322 wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}
A internal/adapters/hints/hybrid_test.go +88 -0
@@ -0,0 +1,88 @@
1+package hints
2+
3+import (
4+ "context"
5+ "hash/maphash"
6+ "image"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestWindowCrop(t *testing.T) {
14+ for _, tc := range []struct {
15+ name string
16+ r ports.Rect
17+ scale float64
18+ want image.Rectangle
19+ }{
20+ {"unit", ports.Rect{X: 10, Y: 20, W: 100, H: 50}, 1, image.Rect(10, 20, 110, 70)},
21+ {"fractional", ports.Rect{X: 11, Y: 21, W: 100, H: 50}, 1.2, image.Rect(13, 25, 134, 86)},
22+ {"off output", ports.Rect{X: -10, Y: 20, W: 100, H: 100}, 1.2, image.Rect(0, 24, 108, 100)},
23+ {"outside", ports.Rect{X: 1000, Y: 20, W: 100, H: 100}, 1, image.Rectangle{}},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, windowCrop(tc.r, tc.scale, 200, 100)) })
26+ }
27+}
28+
29+func sourceResult(ar atspiResult) *pendingSource {
30+ p := &pendingSource{done: make(chan struct{}), res: ar}
31+ close(p.done)
32+ return p
33+}
34+
35+func TestHybridCacheAndMerge(t *testing.T) {
36+ t.Setenv("PATH", t.TempDir()) // Cached OCR must not need tesseract.
37+ buf := make([]byte, 200*100*4)
38+ wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}, {ID: 2, Rect: ports.Rect{X: 100, W: 100, H: 100}}}
39+ req := ports.HintScanRequest{Output: "o", Width: 200, Height: 100, Scale: 1, Windows: wins}
40+ seed := maphash.MakeSeed()
41+ r := windowCrop(wins[1].Rect, 1, 200, 100)
42+ entry := cropCache{hash: maphash.Bytes(seed, cropImage(buf, 800, r).Pix), width: 100, height: 100, words: []OCRWord{{Text: "example.org", Rect: image.Rect(10, 20, 90, 40)}}}
43+ button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 1, Y: 1, W: 10, H: 10}}
44+ link := ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}}
45+ uncoveredButton := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 101, Y: 1, W: 10, H: 10}}
46+ ar := atspiResult{targets: []ports.HintTarget{button, uncoveredButton}, covered: map[ports.WindowID]bool{1: true}}
47+ w := worker{cacheSeed: seed, cache: map[cropKey]cropCache{{output: "o", window: 2}: entry}}
48+ got := w.scan(context.Background(), req, buf, 800, 0, ports.HintScanResult{}, sourceResult(ar))
49+ require.Equal(t, []ports.HintTarget{button, uncoveredButton, link}, got.Targets)
50+ req.LinksOnly = true
51+ got = w.scan(context.Background(), req, buf, 800, 0, ports.HintScanResult{}, sourceResult(ar))
52+ require.Equal(t, []ports.HintTarget{link}, got.Targets)
53+ // Changing one pixel invalidates only the crop, never leaving stale links.
54+ buf[100*4]++
55+ got = w.scan(context.Background(), req, buf, 800, 0, ports.HintScanResult{}, sourceResult(ar))
56+ require.Empty(t, got.Targets)
57+}
58+
59+func TestMissingTesseractKeepsAccessibilityButtons(t *testing.T) {
60+ t.Setenv("PATH", t.TempDir())
61+ w := worker{}
62+ button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{W: 5, H: 5}}
63+ req := ports.HintScanRequest{Width: 20, Height: 20, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}}}
64+ got := w.scan(context.Background(), req, make([]byte, 1600), 80, 0, ports.HintScanResult{}, sourceResult(atspiResult{targets: []ports.HintTarget{button}}))
65+ require.NoError(t, got.Err)
66+ require.Equal(t, []ports.HintTarget{button}, got.Targets)
67+}
68+
69+func TestNoWindowsProducesNoPixelTargets(t *testing.T) {
70+ pix, width, height := loadBGRA(t)
71+ w := worker{}
72+ got := w.scan(context.Background(), ports.HintScanRequest{Width: width, Height: height}, pix, width*4, 0, ports.HintScanResult{}, sourceResult(atspiResult{}))
73+ require.Empty(t, got.Targets)
74+}
75+
76+func TestStripMappingAndPolarity(t *testing.T) {
77+ img := image.NewRGBA(image.Rect(0, 0, 200, 200))
78+ strips := packLines(img, []image.Rectangle{image.Rect(20, 20, 160, 40), image.Rect(20, 80, 160, 112)}, 2, true)
79+ require.Len(t, strips, 2)
80+ for _, s := range strips {
81+ for _, off := range s.offsets {
82+ require.Equal(t, 1, off.factor)
83+ require.Equal(t, uint8(255), s.img.GrayAt(off.packed.Min.X, off.packed.Min.Y).Y)
84+ }
85+ }
86+ small := packLines(img, []image.Rectangle{image.Rect(20, 20, 100, 30)}, 1, true)
87+ require.Equal(t, 2, small[0].offsets[0].factor)
88+}
@@ -0,0 +1,88 @@
1+package hints
2+
3+import (
4+ "context"
5+ "hash/maphash"
6+ "image"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestWindowCrop(t *testing.T) {
14+ for _, tc := range []struct {
15+ name string
16+ r ports.Rect
17+ scale float64
18+ want image.Rectangle
19+ }{
20+ {"unit", ports.Rect{X: 10, Y: 20, W: 100, H: 50}, 1, image.Rect(10, 20, 110, 70)},
21+ {"fractional", ports.Rect{X: 11, Y: 21, W: 100, H: 50}, 1.2, image.Rect(13, 25, 134, 86)},
22+ {"off output", ports.Rect{X: -10, Y: 20, W: 100, H: 100}, 1.2, image.Rect(0, 24, 108, 100)},
23+ {"outside", ports.Rect{X: 1000, Y: 20, W: 100, H: 100}, 1, image.Rectangle{}},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, windowCrop(tc.r, tc.scale, 200, 100)) })
26+ }
27+}
28+
29+func sourceResult(ar atspiResult) *pendingSource {
30+ p := &pendingSource{done: make(chan struct{}), res: ar}
31+ close(p.done)
32+ return p
33+}
34+
35+func TestHybridCacheAndMerge(t *testing.T) {
36+ t.Setenv("PATH", t.TempDir()) // Cached OCR must not need tesseract.
37+ buf := make([]byte, 200*100*4)
38+ wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}, {ID: 2, Rect: ports.Rect{X: 100, W: 100, H: 100}}}
39+ req := ports.HintScanRequest{Output: "o", Width: 200, Height: 100, Scale: 1, Windows: wins}
40+ seed := maphash.MakeSeed()
41+ r := windowCrop(wins[1].Rect, 1, 200, 100)
42+ entry := cropCache{hash: maphash.Bytes(seed, cropImage(buf, 800, r).Pix), width: 100, height: 100, words: []OCRWord{{Text: "example.org", Rect: image.Rect(10, 20, 90, 40)}}}
43+ button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 1, Y: 1, W: 10, H: 10}}
44+ link := ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}}
45+ uncoveredButton := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 101, Y: 1, W: 10, H: 10}}
46+ ar := atspiResult{targets: []ports.HintTarget{button, uncoveredButton}, covered: map[ports.WindowID]bool{1: true}}
47+ w := worker{cacheSeed: seed, cache: map[cropKey]cropCache{{output: "o", window: 2}: entry}}
48+ got := w.scan(context.Background(), req, buf, 800, 0, ports.HintScanResult{}, sourceResult(ar))
49+ require.Equal(t, []ports.HintTarget{button, uncoveredButton, link}, got.Targets)
50+ req.LinksOnly = true
51+ got = w.scan(context.Background(), req, buf, 800, 0, ports.HintScanResult{}, sourceResult(ar))
52+ require.Equal(t, []ports.HintTarget{link}, got.Targets)
53+ // Changing one pixel invalidates only the crop, never leaving stale links.
54+ buf[100*4]++
55+ got = w.scan(context.Background(), req, buf, 800, 0, ports.HintScanResult{}, sourceResult(ar))
56+ require.Empty(t, got.Targets)
57+}
58+
59+func TestMissingTesseractKeepsAccessibilityButtons(t *testing.T) {
60+ t.Setenv("PATH", t.TempDir())
61+ w := worker{}
62+ button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{W: 5, H: 5}}
63+ req := ports.HintScanRequest{Width: 20, Height: 20, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}}}
64+ got := w.scan(context.Background(), req, make([]byte, 1600), 80, 0, ports.HintScanResult{}, sourceResult(atspiResult{targets: []ports.HintTarget{button}}))
65+ require.NoError(t, got.Err)
66+ require.Equal(t, []ports.HintTarget{button}, got.Targets)
67+}
68+
69+func TestNoWindowsProducesNoPixelTargets(t *testing.T) {
70+ pix, width, height := loadBGRA(t)
71+ w := worker{}
72+ got := w.scan(context.Background(), ports.HintScanRequest{Width: width, Height: height}, pix, width*4, 0, ports.HintScanResult{}, sourceResult(atspiResult{}))
73+ require.Empty(t, got.Targets)
74+}
75+
76+func TestStripMappingAndPolarity(t *testing.T) {
77+ img := image.NewRGBA(image.Rect(0, 0, 200, 200))
78+ strips := packLines(img, []image.Rectangle{image.Rect(20, 20, 160, 40), image.Rect(20, 80, 160, 112)}, 2, true)
79+ require.Len(t, strips, 2)
80+ for _, s := range strips {
81+ for _, off := range s.offsets {
82+ require.Equal(t, 1, off.factor)
83+ require.Equal(t, uint8(255), s.img.GrayAt(off.packed.Min.X, off.packed.Min.Y).Y)
84+ }
85+ }
86+ small := packLines(img, []image.Rectangle{image.Rect(20, 20, 100, 30)}, 1, true)
87+ require.Equal(t, 2, small[0].offsets[0].factor)
88+}
A internal/adapters/hints/ocr.go +298 -0
@@ -0,0 +1,298 @@
1+package hints
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "fmt"
8+ "image"
9+ "image/png"
10+ "math"
11+ "net"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "regexp"
16+ "runtime"
17+ "sort"
18+ "strconv"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "github.com/bnema/neferwl/internal/adapters/logging"
24+ "github.com/bnema/neferwl/internal/ports"
25+)
26+
27+// OCRWord is a recognized word in physical output coordinates.
28+type OCRWord struct {
29+ Text string `json:"text"`
30+ Rect image.Rectangle `json:"rect"`
31+ Confidence float64 `json:"confidence"`
32+}
33+
34+var languageWarning sync.Once
35+var failureWarning sync.Once
36+
37+func ocrLanguage(ctx context.Context, binary string) (string, error) {
38+ dir := os.Getenv("TESSDATA_PREFIX")
39+ if dir == "" {
40+ dir = "/usr/share/tessdata"
41+ }
42+ if _, err := os.Stat(filepath.Join(dir, "eng.traineddata")); err == nil {
43+ return "eng", nil
44+ }
45+ out, err := exec.CommandContext(ctx, binary, "--list-langs").Output()
46+ if err != nil {
47+ return "", err
48+ }
49+ for _, line := range strings.Split(string(out), "\n")[1:] {
50+ lang := strings.TrimSpace(line)
51+ if lang != "" && lang != "osd" {
52+ languageWarning.Do(func() {
53+ log := logging.For(ctx, "hints")
54+ log.Warn().Str("language", lang).Msg("English OCR data missing; install tesseract-data-eng")
55+ })
56+ return lang, nil
57+ }
58+ }
59+ return "", fmt.Errorf("no OCR language available")
60+}
61+
62+func parseTSV(data []byte, offset image.Point) ([]OCRWord, error) {
63+ var words []OCRWord
64+ scanner := bufio.NewScanner(bytes.NewReader(data))
65+ scanner.Buffer(make([]byte, 4096), 1024*1024)
66+ for scanner.Scan() {
67+ fields := strings.SplitN(scanner.Text(), "\t", 12)
68+ if len(fields) != 12 || fields[0] != "5" {
69+ continue
70+ }
71+ var values [4]int
72+ for i := range values {
73+ v, err := strconv.Atoi(fields[6+i])
74+ if err != nil {
75+ return nil, fmt.Errorf("OCR TSV coordinate: %w", err)
76+ }
77+ values[i] = v
78+ }
79+ conf, err := strconv.ParseFloat(fields[10], 64)
80+ if err != nil {
81+ return nil, fmt.Errorf("OCR TSV confidence: %w", err)
82+ }
83+ if values[2] <= 0 || values[3] <= 0 || strings.TrimSpace(fields[11]) == "" {
84+ continue
85+ }
86+ r := image.Rect(values[0], values[1], values[0]+values[2], values[1]+values[3]).Add(offset)
87+ words = append(words, OCRWord{Text: fields[11], Rect: r, Confidence: conf})
88+ }
89+ return words, scanner.Err()
90+}
91+
92+func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) {
93+ binary, err := exec.LookPath("tesseract")
94+ if err != nil {
95+ return nil, 0, err
96+ }
97+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
98+ defer cancel()
99+ lang, err := ocrLanguage(ctx, binary)
100+ if err != nil {
101+ return nil, 0, err
102+ }
103+ n := min(runtime.GOMAXPROCS(0), 8, img.Bounds().Dy())
104+ results := make([][]OCRWord, n)
105+ errs := make([]error, n)
106+ var wg sync.WaitGroup
107+ for i := range n {
108+ wg.Go(func() {
109+ top := max(0, i*img.Bounds().Dy()/n-40)
110+ bottom := min(img.Bounds().Dy(), (i+1)*img.Bounds().Dy()/n+40)
111+ crop := img.SubImage(image.Rect(0, top, img.Bounds().Dx(), bottom))
112+ results[i], errs[i] = recognizeBand(ctx, binary, lang, crop)
113+ if errs[i] != nil {
114+ return
115+ }
116+ heights := make([]int, 0, len(results[i]))
117+ for _, word := range results[i] {
118+ if len(word.Text) >= 3 {
119+ heights = append(heights, word.Rect.Dy())
120+ }
121+ }
122+ sort.Ints(heights)
123+ if len(heights) > 0 && heights[len(heights)/2] < 12 {
124+ larger := upscaleBand(crop)
125+ results[i], errs[i] = recognizeBand(ctx, binary, lang, larger)
126+ for j := range results[i] {
127+ r := results[i][j].Rect
128+ results[i][j].Rect = image.Rect(r.Min.X/2, r.Min.Y/2, (r.Max.X+1)/2, (r.Max.Y+1)/2)
129+ }
130+ }
131+ for j := range results[i] {
132+ results[i][j].Rect = results[i][j].Rect.Add(image.Pt(0, top))
133+ }
134+ })
135+ }
136+ wg.Wait()
137+ var words []OCRWord
138+ for i := range n {
139+ if errs[i] != nil {
140+ return nil, n, errs[i]
141+ }
142+ for _, word := range results[i] {
143+ // Overlap supplies context, but each word belongs to exactly one band.
144+ center := (word.Rect.Min.Y + word.Rect.Max.Y) / 2
145+ if center < i*img.Bounds().Dy()/n || center >= (i+1)*img.Bounds().Dy()/n {
146+ continue
147+ }
148+ duplicate := false
149+ for _, old := range words {
150+ if strings.EqualFold(word.Text, old.Text) && overlap(word.Rect, old.Rect) > .5 {
151+ duplicate = true
152+ break
153+ }
154+ }
155+ if !duplicate {
156+ words = append(words, word)
157+ }
158+ }
159+ }
160+ return words, n, nil
161+}
162+
163+var domainPattern = regexp.MustCompile(`(?i)^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+([a-z]{2,24})(?::[0-9]+)?(?:[/?#]\S*)?$`)
164+var schemePattern = regexp.MustCompile(`(?i)^(?:https?|htps|file|ftp):/{1,2}\S+$`)
165+var emailPattern = regexp.MustCompile(`(?i)^[a-z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@(?:[a-z0-9-]+\.)+[a-z]{2,24}$`)
166+var fileExtensions = strings.Fields("go md txt toml json yaml yml rs py js ts sh c h png jpg log conf lock mod sum html css")
167+
168+func isTextLink(text string) bool {
169+ text = strings.Trim(text, "()[]<>.,;:'\"")
170+ if schemePattern.MatchString(text) || (strings.HasPrefix(strings.ToLower(text), "www.") && len(text) > 4) || emailPattern.MatchString(text) {
171+ return true
172+ }
173+ host := text
174+ if h, _, err := net.SplitHostPort(text); err == nil {
175+ host = h
176+ }
177+ if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
178+ return true
179+ }
180+ m := domainPattern.FindStringSubmatch(text)
181+ if m == nil {
182+ return false
183+ }
184+ for _, ext := range fileExtensions {
185+ if strings.EqualFold(m[1], ext) {
186+ return false
187+ }
188+ }
189+ return true
190+}
191+
192+func textLinks(words []OCRWord, scale float64) []ports.HintTarget {
193+ if scale <= 0 {
194+ scale = 1
195+ }
196+ words = append([]OCRWord(nil), words...)
197+ sort.Slice(words, func(i, j int) bool {
198+ if words[i].Rect.Min.Y != words[j].Rect.Min.Y {
199+ return words[i].Rect.Min.Y < words[j].Rect.Min.Y
200+ }
201+ return words[i].Rect.Min.X < words[j].Rect.Min.X
202+ })
203+ var out []ports.HintTarget
204+ used := make([]bool, len(words))
205+ for i, word := range words {
206+ if used[i] {
207+ continue
208+ }
209+ text, rect := word.Text, word.Rect
210+ // Grow only touching words on the same baseline; ordinary prose is not joined.
211+ for {
212+ next := -1
213+ for j, candidate := range words {
214+ if j == i || used[j] || candidate.Rect.Min.X < rect.Max.X-2 {
215+ continue
216+ }
217+ gap := candidate.Rect.Min.X - rect.Max.X
218+ if gap > max(3, min(rect.Dy(), candidate.Rect.Dy())/5) {
219+ continue
220+ }
221+ if abs(candidate.Rect.Max.Y-rect.Max.Y) > max(3, rect.Dy()/3) {
222+ continue
223+ }
224+ if next < 0 || candidate.Rect.Min.X < words[next].Rect.Min.X {
225+ next = j
226+ }
227+ }
228+ if next < 0 {
229+ break
230+ }
231+ text += words[next].Text
232+ rect = rect.Union(words[next].Rect)
233+ used[next] = true
234+ }
235+ if !isTextLink(text) {
236+ continue
237+ }
238+ x, y := int(math.Floor(float64(rect.Min.X)/scale)), int(math.Floor(float64(rect.Min.Y)/scale))
239+ right, bottom := int(math.Ceil(float64(rect.Max.X)/scale)), int(math.Ceil(float64(rect.Max.Y)/scale))
240+ out = append(out, ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: x, Y: y, W: right - x, H: bottom - y}})
241+ }
242+ return out
243+}
244+
245+func abs(x int) int {
246+ if x < 0 {
247+ return -x
248+ }
249+ return x
250+}
251+func overlap(a, b image.Rectangle) float64 {
252+ intersection := a.Intersect(b)
253+ area := min(a.Dx()*a.Dy(), b.Dx()*b.Dy())
254+ if intersection.Empty() || area <= 0 {
255+ return 0
256+ }
257+ return float64(intersection.Dx()*intersection.Dy()) / float64(area)
258+}
259+func targetRect(t ports.HintTarget) image.Rectangle {
260+ return image.Rect(t.Rect.X, t.Rect.Y, t.Rect.X+t.Rect.W, t.Rect.Y+t.Rect.H)
261+}
262+func recognizeBand(ctx context.Context, binary, lang string, img image.Image) ([]OCRWord, error) {
263+ return recognizeImage(ctx, binary, lang, img, "11", false)
264+}
265+
266+func recognizeImage(ctx context.Context, binary, lang string, img image.Image, psm string, normalized bool) ([]OCRWord, error) {
267+ var input bytes.Buffer
268+ enc := png.Encoder{CompressionLevel: png.BestSpeed}
269+ if err := enc.Encode(&input, img); err != nil {
270+ return nil, err
271+ }
272+ args := []string{"stdin", "stdout", "--psm", psm, "-l", lang}
273+ if normalized {
274+ args = append(args, "-c", "tessedit_do_invert=0")
275+ }
276+ args = append(args, "tsv")
277+ cmd := exec.CommandContext(ctx, binary, args...)
278+ cmd.Env = append(os.Environ(), "OMP_THREAD_LIMIT=1")
279+ cmd.Stdin = &input
280+ var stderr bytes.Buffer
281+ cmd.Stderr = &stderr
282+ output, err := cmd.Output()
283+ if err != nil {
284+ return nil, fmt.Errorf("tesseract: %w: %s", err, stderr.String())
285+ }
286+ return parseTSV(output, image.Point{})
287+}
288+
289+func upscaleBand(src image.Image) *image.RGBA {
290+ b := src.Bounds()
291+ dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*2, b.Dy()*2))
292+ for y := 0; y < dst.Bounds().Dy(); y++ {
293+ for x := 0; x < dst.Bounds().Dx(); x++ {
294+ dst.Set(x, y, src.At(b.Min.X+x/2, b.Min.Y+y/2))
295+ }
296+ }
297+ return dst
298+}
@@ -0,0 +1,298 @@
1+package hints
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "fmt"
8+ "image"
9+ "image/png"
10+ "math"
11+ "net"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "regexp"
16+ "runtime"
17+ "sort"
18+ "strconv"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "github.com/bnema/neferwl/internal/adapters/logging"
24+ "github.com/bnema/neferwl/internal/ports"
25+)
26+
27+// OCRWord is a recognized word in physical output coordinates.
28+type OCRWord struct {
29+ Text string `json:"text"`
30+ Rect image.Rectangle `json:"rect"`
31+ Confidence float64 `json:"confidence"`
32+}
33+
34+var languageWarning sync.Once
35+var failureWarning sync.Once
36+
37+func ocrLanguage(ctx context.Context, binary string) (string, error) {
38+ dir := os.Getenv("TESSDATA_PREFIX")
39+ if dir == "" {
40+ dir = "/usr/share/tessdata"
41+ }
42+ if _, err := os.Stat(filepath.Join(dir, "eng.traineddata")); err == nil {
43+ return "eng", nil
44+ }
45+ out, err := exec.CommandContext(ctx, binary, "--list-langs").Output()
46+ if err != nil {
47+ return "", err
48+ }
49+ for _, line := range strings.Split(string(out), "\n")[1:] {
50+ lang := strings.TrimSpace(line)
51+ if lang != "" && lang != "osd" {
52+ languageWarning.Do(func() {
53+ log := logging.For(ctx, "hints")
54+ log.Warn().Str("language", lang).Msg("English OCR data missing; install tesseract-data-eng")
55+ })
56+ return lang, nil
57+ }
58+ }
59+ return "", fmt.Errorf("no OCR language available")
60+}
61+
62+func parseTSV(data []byte, offset image.Point) ([]OCRWord, error) {
63+ var words []OCRWord
64+ scanner := bufio.NewScanner(bytes.NewReader(data))
65+ scanner.Buffer(make([]byte, 4096), 1024*1024)
66+ for scanner.Scan() {
67+ fields := strings.SplitN(scanner.Text(), "\t", 12)
68+ if len(fields) != 12 || fields[0] != "5" {
69+ continue
70+ }
71+ var values [4]int
72+ for i := range values {
73+ v, err := strconv.Atoi(fields[6+i])
74+ if err != nil {
75+ return nil, fmt.Errorf("OCR TSV coordinate: %w", err)
76+ }
77+ values[i] = v
78+ }
79+ conf, err := strconv.ParseFloat(fields[10], 64)
80+ if err != nil {
81+ return nil, fmt.Errorf("OCR TSV confidence: %w", err)
82+ }
83+ if values[2] <= 0 || values[3] <= 0 || strings.TrimSpace(fields[11]) == "" {
84+ continue
85+ }
86+ r := image.Rect(values[0], values[1], values[0]+values[2], values[1]+values[3]).Add(offset)
87+ words = append(words, OCRWord{Text: fields[11], Rect: r, Confidence: conf})
88+ }
89+ return words, scanner.Err()
90+}
91+
92+func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) {
93+ binary, err := exec.LookPath("tesseract")
94+ if err != nil {
95+ return nil, 0, err
96+ }
97+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
98+ defer cancel()
99+ lang, err := ocrLanguage(ctx, binary)
100+ if err != nil {
101+ return nil, 0, err
102+ }
103+ n := min(runtime.GOMAXPROCS(0), 8, img.Bounds().Dy())
104+ results := make([][]OCRWord, n)
105+ errs := make([]error, n)
106+ var wg sync.WaitGroup
107+ for i := range n {
108+ wg.Go(func() {
109+ top := max(0, i*img.Bounds().Dy()/n-40)
110+ bottom := min(img.Bounds().Dy(), (i+1)*img.Bounds().Dy()/n+40)
111+ crop := img.SubImage(image.Rect(0, top, img.Bounds().Dx(), bottom))
112+ results[i], errs[i] = recognizeBand(ctx, binary, lang, crop)
113+ if errs[i] != nil {
114+ return
115+ }
116+ heights := make([]int, 0, len(results[i]))
117+ for _, word := range results[i] {
118+ if len(word.Text) >= 3 {
119+ heights = append(heights, word.Rect.Dy())
120+ }
121+ }
122+ sort.Ints(heights)
123+ if len(heights) > 0 && heights[len(heights)/2] < 12 {
124+ larger := upscaleBand(crop)
125+ results[i], errs[i] = recognizeBand(ctx, binary, lang, larger)
126+ for j := range results[i] {
127+ r := results[i][j].Rect
128+ results[i][j].Rect = image.Rect(r.Min.X/2, r.Min.Y/2, (r.Max.X+1)/2, (r.Max.Y+1)/2)
129+ }
130+ }
131+ for j := range results[i] {
132+ results[i][j].Rect = results[i][j].Rect.Add(image.Pt(0, top))
133+ }
134+ })
135+ }
136+ wg.Wait()
137+ var words []OCRWord
138+ for i := range n {
139+ if errs[i] != nil {
140+ return nil, n, errs[i]
141+ }
142+ for _, word := range results[i] {
143+ // Overlap supplies context, but each word belongs to exactly one band.
144+ center := (word.Rect.Min.Y + word.Rect.Max.Y) / 2
145+ if center < i*img.Bounds().Dy()/n || center >= (i+1)*img.Bounds().Dy()/n {
146+ continue
147+ }
148+ duplicate := false
149+ for _, old := range words {
150+ if strings.EqualFold(word.Text, old.Text) && overlap(word.Rect, old.Rect) > .5 {
151+ duplicate = true
152+ break
153+ }
154+ }
155+ if !duplicate {
156+ words = append(words, word)
157+ }
158+ }
159+ }
160+ return words, n, nil
161+}
162+
163+var domainPattern = regexp.MustCompile(`(?i)^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+([a-z]{2,24})(?::[0-9]+)?(?:[/?#]\S*)?$`)
164+var schemePattern = regexp.MustCompile(`(?i)^(?:https?|htps|file|ftp):/{1,2}\S+$`)
165+var emailPattern = regexp.MustCompile(`(?i)^[a-z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@(?:[a-z0-9-]+\.)+[a-z]{2,24}$`)
166+var fileExtensions = strings.Fields("go md txt toml json yaml yml rs py js ts sh c h png jpg log conf lock mod sum html css")
167+
168+func isTextLink(text string) bool {
169+ text = strings.Trim(text, "()[]<>.,;:'\"")
170+ if schemePattern.MatchString(text) || (strings.HasPrefix(strings.ToLower(text), "www.") && len(text) > 4) || emailPattern.MatchString(text) {
171+ return true
172+ }
173+ host := text
174+ if h, _, err := net.SplitHostPort(text); err == nil {
175+ host = h
176+ }
177+ if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
178+ return true
179+ }
180+ m := domainPattern.FindStringSubmatch(text)
181+ if m == nil {
182+ return false
183+ }
184+ for _, ext := range fileExtensions {
185+ if strings.EqualFold(m[1], ext) {
186+ return false
187+ }
188+ }
189+ return true
190+}
191+
192+func textLinks(words []OCRWord, scale float64) []ports.HintTarget {
193+ if scale <= 0 {
194+ scale = 1
195+ }
196+ words = append([]OCRWord(nil), words...)
197+ sort.Slice(words, func(i, j int) bool {
198+ if words[i].Rect.Min.Y != words[j].Rect.Min.Y {
199+ return words[i].Rect.Min.Y < words[j].Rect.Min.Y
200+ }
201+ return words[i].Rect.Min.X < words[j].Rect.Min.X
202+ })
203+ var out []ports.HintTarget
204+ used := make([]bool, len(words))
205+ for i, word := range words {
206+ if used[i] {
207+ continue
208+ }
209+ text, rect := word.Text, word.Rect
210+ // Grow only touching words on the same baseline; ordinary prose is not joined.
211+ for {
212+ next := -1
213+ for j, candidate := range words {
214+ if j == i || used[j] || candidate.Rect.Min.X < rect.Max.X-2 {
215+ continue
216+ }
217+ gap := candidate.Rect.Min.X - rect.Max.X
218+ if gap > max(3, min(rect.Dy(), candidate.Rect.Dy())/5) {
219+ continue
220+ }
221+ if abs(candidate.Rect.Max.Y-rect.Max.Y) > max(3, rect.Dy()/3) {
222+ continue
223+ }
224+ if next < 0 || candidate.Rect.Min.X < words[next].Rect.Min.X {
225+ next = j
226+ }
227+ }
228+ if next < 0 {
229+ break
230+ }
231+ text += words[next].Text
232+ rect = rect.Union(words[next].Rect)
233+ used[next] = true
234+ }
235+ if !isTextLink(text) {
236+ continue
237+ }
238+ x, y := int(math.Floor(float64(rect.Min.X)/scale)), int(math.Floor(float64(rect.Min.Y)/scale))
239+ right, bottom := int(math.Ceil(float64(rect.Max.X)/scale)), int(math.Ceil(float64(rect.Max.Y)/scale))
240+ out = append(out, ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: x, Y: y, W: right - x, H: bottom - y}})
241+ }
242+ return out
243+}
244+
245+func abs(x int) int {
246+ if x < 0 {
247+ return -x
248+ }
249+ return x
250+}
251+func overlap(a, b image.Rectangle) float64 {
252+ intersection := a.Intersect(b)
253+ area := min(a.Dx()*a.Dy(), b.Dx()*b.Dy())
254+ if intersection.Empty() || area <= 0 {
255+ return 0
256+ }
257+ return float64(intersection.Dx()*intersection.Dy()) / float64(area)
258+}
259+func targetRect(t ports.HintTarget) image.Rectangle {
260+ return image.Rect(t.Rect.X, t.Rect.Y, t.Rect.X+t.Rect.W, t.Rect.Y+t.Rect.H)
261+}
262+func recognizeBand(ctx context.Context, binary, lang string, img image.Image) ([]OCRWord, error) {
263+ return recognizeImage(ctx, binary, lang, img, "11", false)
264+}
265+
266+func recognizeImage(ctx context.Context, binary, lang string, img image.Image, psm string, normalized bool) ([]OCRWord, error) {
267+ var input bytes.Buffer
268+ enc := png.Encoder{CompressionLevel: png.BestSpeed}
269+ if err := enc.Encode(&input, img); err != nil {
270+ return nil, err
271+ }
272+ args := []string{"stdin", "stdout", "--psm", psm, "-l", lang}
273+ if normalized {
274+ args = append(args, "-c", "tessedit_do_invert=0")
275+ }
276+ args = append(args, "tsv")
277+ cmd := exec.CommandContext(ctx, binary, args...)
278+ cmd.Env = append(os.Environ(), "OMP_THREAD_LIMIT=1")
279+ cmd.Stdin = &input
280+ var stderr bytes.Buffer
281+ cmd.Stderr = &stderr
282+ output, err := cmd.Output()
283+ if err != nil {
284+ return nil, fmt.Errorf("tesseract: %w: %s", err, stderr.String())
285+ }
286+ return parseTSV(output, image.Point{})
287+}
288+
289+func upscaleBand(src image.Image) *image.RGBA {
290+ b := src.Bounds()
291+ dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*2, b.Dy()*2))
292+ for y := 0; y < dst.Bounds().Dy(); y++ {
293+ for x := 0; x < dst.Bounds().Dx(); x++ {
294+ dst.Set(x, y, src.At(b.Min.X+x/2, b.Min.Y+y/2))
295+ }
296+ }
297+ return dst
298+}
A internal/adapters/hints/ocr_test.go +141 -0
@@ -0,0 +1,141 @@
1+package hints
2+
3+import (
4+ "context"
5+ "image"
6+ "image/draw"
7+ "image/png"
8+ "os"
9+ "os/exec"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/neferwl/internal/ports"
15+)
16+
17+func TestTextLink(t *testing.T) {
18+ yes := []string{"ermite.bnema.dev", "google.fr", "example.org/path?q=1", "https.google.fr", "https://example.org", "file:///tmp/main.go", "ftp://host/file", "www.main.go", "user@example.org", "127.0.0.1", "192.168.1.1:8080", "(https://example.org),", "htps://google.fr", "https:/example.org"}
19+ no := []string{"ermite-runner", "main.go", "README.md", "v1.2.3", "1.5", "e.g.", "report.txt", "source.rs", "alpha", "999.999.999.999"}
20+ for _, s := range yes {
21+ t.Run(s, func(t *testing.T) {
22+ if !isTextLink(s) {
23+ t.Errorf("not accepted: %s", s)
24+ }
25+ })
26+ }
27+ for _, s := range no {
28+ t.Run(s, func(t *testing.T) {
29+ if isTextLink(s) {
30+ t.Errorf("incorrectly accepted: %s", s)
31+ }
32+ })
33+ }
34+}
35+func TestParseTSV(t *testing.T) {
36+ data := "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n5\t1\t1\t1\t1\t1\t10\t20\t30\t12\t91.5\texample.org\n"
37+ words, err := parseTSV([]byte(data), image.Pt(0, 40))
38+ if err != nil || len(words) != 1 {
39+ t.Fatalf("%v %v", words, err)
40+ }
41+ if words[0].Rect != image.Rect(10, 60, 40, 72) || words[0].Confidence != 91.5 {
42+ t.Fatal(words)
43+ }
44+ _, err = parseTSV([]byte(strings.ReplaceAll(data, "\t10\t", "\tbad\t")), image.Point{})
45+ if err == nil {
46+ t.Fatal("expected malformed coordinate error")
47+ }
48+}
49+func TestJoinedOCRLink(t *testing.T) {
50+ words := []OCRWord{{Text: "https:/", Rect: image.Rect(20, 20, 90, 40)}, {Text: "/example.org", Rect: image.Rect(91, 20, 200, 40)}}
51+ got := textLinks(words, 2)
52+ if len(got) != 1 || got[0].Rect != (ports.Rect{X: 10, Y: 10, W: 90, H: 10}) {
53+ t.Fatal(got)
54+ }
55+}
56+func TestRealOCR(t *testing.T) {
57+ if _, err := exec.LookPath("tesseract"); err != nil {
58+ t.Skip("tesseract not on PATH")
59+ }
60+ for _, name := range []string{"browser", "terminal"} {
61+ t.Run(name, func(t *testing.T) {
62+ f, err := os.Open("testdata/" + name + ".png")
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ defer f.Close()
67+ src, err := png.Decode(f)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ img := image.NewRGBA(src.Bounds())
72+ draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src)
73+ start := time.Now()
74+ words, bands, err := readWords(context.Background(), img)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ links := textLinks(words, 1)
79+ t.Logf("%s %dx%d ocr_ms=%.1f words=%d links=%d bands=%d", name, img.Bounds().Dx(), img.Bounds().Dy(), ms(time.Since(start)), len(words), len(links), bands)
80+ for _, w := range words {
81+ t.Logf("%s %v", w.Text, w.Rect)
82+ }
83+ if name == "terminal" {
84+ for _, r := range []image.Rectangle{image.Rect(160, 65, 380, 85), image.Rect(140, 90, 490, 110), image.Rect(95, 115, 630, 138), image.Rect(120, 140, 330, 163), image.Rect(385, 140, 740, 163)} {
85+ found := false
86+ for _, l := range links {
87+ if overlap(targetRect(l), r) > .5 {
88+ found = true
89+ }
90+ }
91+ if !found {
92+ t.Errorf("missing terminal URL near %v", r)
93+ }
94+ }
95+ if len(links) != 5 {
96+ t.Errorf("want 5 terminal links, got %d: %v", len(links), links)
97+ }
98+ } else if len(links) == 0 {
99+ t.Error("no browser links")
100+ }
101+ })
102+ }
103+}
104+
105+func TestSmallGlyphOCR(t *testing.T) {
106+ binary, err := exec.LookPath("tesseract")
107+ if err != nil {
108+ t.Skip("tesseract not on PATH")
109+ }
110+ f, err := os.Open("testdata/terminal.png")
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ defer f.Close()
115+ src, err := png.Decode(f)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ small := image.NewRGBA(image.Rect(0, 0, 640, 200))
120+ for y := 0; y < 200; y++ {
121+ for x := 0; x < 640; x++ {
122+ small.Set(x, y, src.At(x*2, y*2))
123+ }
124+ }
125+ lang, err := ocrLanguage(context.Background(), binary)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ for _, factor := range []int{1, 2} {
130+ var img image.Image = small
131+ if factor == 2 {
132+ img = upscaleBand(small)
133+ }
134+ start := time.Now()
135+ words, err := recognizeBand(context.Background(), binary, lang, img)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ t.Logf("glyph experiment %dx: %.1f ms, words=%d links=%d", factor, ms(time.Since(start)), len(words), len(textLinks(words, 1)))
140+ }
141+}
@@ -0,0 +1,141 @@
1+package hints
2+
3+import (
4+ "context"
5+ "image"
6+ "image/draw"
7+ "image/png"
8+ "os"
9+ "os/exec"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/neferwl/internal/ports"
15+)
16+
17+func TestTextLink(t *testing.T) {
18+ yes := []string{"ermite.bnema.dev", "google.fr", "example.org/path?q=1", "https.google.fr", "https://example.org", "file:///tmp/main.go", "ftp://host/file", "www.main.go", "user@example.org", "127.0.0.1", "192.168.1.1:8080", "(https://example.org),", "htps://google.fr", "https:/example.org"}
19+ no := []string{"ermite-runner", "main.go", "README.md", "v1.2.3", "1.5", "e.g.", "report.txt", "source.rs", "alpha", "999.999.999.999"}
20+ for _, s := range yes {
21+ t.Run(s, func(t *testing.T) {
22+ if !isTextLink(s) {
23+ t.Errorf("not accepted: %s", s)
24+ }
25+ })
26+ }
27+ for _, s := range no {
28+ t.Run(s, func(t *testing.T) {
29+ if isTextLink(s) {
30+ t.Errorf("incorrectly accepted: %s", s)
31+ }
32+ })
33+ }
34+}
35+func TestParseTSV(t *testing.T) {
36+ data := "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n5\t1\t1\t1\t1\t1\t10\t20\t30\t12\t91.5\texample.org\n"
37+ words, err := parseTSV([]byte(data), image.Pt(0, 40))
38+ if err != nil || len(words) != 1 {
39+ t.Fatalf("%v %v", words, err)
40+ }
41+ if words[0].Rect != image.Rect(10, 60, 40, 72) || words[0].Confidence != 91.5 {
42+ t.Fatal(words)
43+ }
44+ _, err = parseTSV([]byte(strings.ReplaceAll(data, "\t10\t", "\tbad\t")), image.Point{})
45+ if err == nil {
46+ t.Fatal("expected malformed coordinate error")
47+ }
48+}
49+func TestJoinedOCRLink(t *testing.T) {
50+ words := []OCRWord{{Text: "https:/", Rect: image.Rect(20, 20, 90, 40)}, {Text: "/example.org", Rect: image.Rect(91, 20, 200, 40)}}
51+ got := textLinks(words, 2)
52+ if len(got) != 1 || got[0].Rect != (ports.Rect{X: 10, Y: 10, W: 90, H: 10}) {
53+ t.Fatal(got)
54+ }
55+}
56+func TestRealOCR(t *testing.T) {
57+ if _, err := exec.LookPath("tesseract"); err != nil {
58+ t.Skip("tesseract not on PATH")
59+ }
60+ for _, name := range []string{"browser", "terminal"} {
61+ t.Run(name, func(t *testing.T) {
62+ f, err := os.Open("testdata/" + name + ".png")
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ defer f.Close()
67+ src, err := png.Decode(f)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ img := image.NewRGBA(src.Bounds())
72+ draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src)
73+ start := time.Now()
74+ words, bands, err := readWords(context.Background(), img)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ links := textLinks(words, 1)
79+ t.Logf("%s %dx%d ocr_ms=%.1f words=%d links=%d bands=%d", name, img.Bounds().Dx(), img.Bounds().Dy(), ms(time.Since(start)), len(words), len(links), bands)
80+ for _, w := range words {
81+ t.Logf("%s %v", w.Text, w.Rect)
82+ }
83+ if name == "terminal" {
84+ for _, r := range []image.Rectangle{image.Rect(160, 65, 380, 85), image.Rect(140, 90, 490, 110), image.Rect(95, 115, 630, 138), image.Rect(120, 140, 330, 163), image.Rect(385, 140, 740, 163)} {
85+ found := false
86+ for _, l := range links {
87+ if overlap(targetRect(l), r) > .5 {
88+ found = true
89+ }
90+ }
91+ if !found {
92+ t.Errorf("missing terminal URL near %v", r)
93+ }
94+ }
95+ if len(links) != 5 {
96+ t.Errorf("want 5 terminal links, got %d: %v", len(links), links)
97+ }
98+ } else if len(links) == 0 {
99+ t.Error("no browser links")
100+ }
101+ })
102+ }
103+}
104+
105+func TestSmallGlyphOCR(t *testing.T) {
106+ binary, err := exec.LookPath("tesseract")
107+ if err != nil {
108+ t.Skip("tesseract not on PATH")
109+ }
110+ f, err := os.Open("testdata/terminal.png")
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ defer f.Close()
115+ src, err := png.Decode(f)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ small := image.NewRGBA(image.Rect(0, 0, 640, 200))
120+ for y := 0; y < 200; y++ {
121+ for x := 0; x < 640; x++ {
122+ small.Set(x, y, src.At(x*2, y*2))
123+ }
124+ }
125+ lang, err := ocrLanguage(context.Background(), binary)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ for _, factor := range []int{1, 2} {
130+ var img image.Image = small
131+ if factor == 2 {
132+ img = upscaleBand(small)
133+ }
134+ start := time.Now()
135+ words, err := recognizeBand(context.Background(), binary, lang, img)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ t.Logf("glyph experiment %dx: %.1f ms, words=%d links=%d", factor, ms(time.Since(start)), len(words), len(textLinks(words, 1)))
140+ }
141+}
A internal/adapters/hints/strips_test.go +124 -0
@@ -0,0 +1,124 @@
1+package hints
2+
3+import (
4+ "context"
5+ "image"
6+ "image/color"
7+ "image/draw"
8+ "os/exec"
9+ "runtime"
10+ "sort"
11+ "sync"
12+ "time"
13+)
14+
15+type stripOffset struct {
16+ source, packed image.Rectangle
17+ factor int
18+}
19+type ocrStrip struct {
20+ img *image.Gray
21+ offsets []stripOffset
22+}
23+
24+func packLines(src *image.RGBA, lines []image.Rectangle, workers int, normalize bool) []ocrStrip {
25+ n := min(workers, len(lines))
26+ if n == 0 {
27+ return nil
28+ }
29+ groups := make([][]image.Rectangle, n)
30+ areas := make([]int, n)
31+ lines = append([]image.Rectangle(nil), lines...)
32+ sort.Slice(lines, func(i, j int) bool { return lines[i].Dx()*lines[i].Dy() > lines[j].Dx()*lines[j].Dy() })
33+ for _, r := range lines {
34+ best := 0
35+ for i := range n {
36+ if areas[i] < areas[best] {
37+ best = i
38+ }
39+ }
40+ groups[best] = append(groups[best], r)
41+ areas[best] += r.Dx() * r.Dy()
42+ }
43+ strips := make([]ocrStrip, n)
44+ for i, group := range groups {
45+ width, height := 0, 8
46+ for _, r := range group {
47+ factor := 1
48+ if r.Dy() < 12 {
49+ factor = 2
50+ }
51+ crop := r.Inset(-4).Intersect(src.Bounds())
52+ packed := image.Rect(4, height, 4+crop.Dx()*factor, height+crop.Dy()*factor)
53+ strips[i].offsets = append(strips[i].offsets, stripOffset{crop, packed, factor})
54+ width = max(width, packed.Max.X+4)
55+ height = packed.Max.Y + 8
56+ }
57+ dst := image.NewGray(image.Rect(0, 0, width, height))
58+ draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
59+ for _, off := range strips[i].offsets {
60+ // The padded perimeter estimates the background without depending on text colour.
61+ var sum, count int
62+ r := off.source
63+ for x := r.Min.X; x < r.Max.X; x++ {
64+ sum += int(color.GrayModel.Convert(src.At(x, r.Min.Y)).(color.Gray).Y)
65+ count++
66+ }
67+ invert := normalize && count > 0 && sum/count < 128
68+ for y := off.packed.Min.Y; y < off.packed.Max.Y; y++ {
69+ for x := off.packed.Min.X; x < off.packed.Max.X; x++ {
70+ v := color.GrayModel.Convert(src.At(r.Min.X+(x-off.packed.Min.X)/off.factor, r.Min.Y+(y-off.packed.Min.Y)/off.factor)).(color.Gray).Y
71+ if invert {
72+ v = 255 - v
73+ }
74+ dst.SetGray(x, y, color.Gray{Y: v})
75+ }
76+ }
77+ }
78+ strips[i].img = dst
79+ }
80+ return strips
81+}
82+
83+func readStrips(ctx context.Context, img *image.RGBA, lines []image.Rectangle, workers int, normalize bool) ([]OCRWord, int, error) {
84+ binary, err := exec.LookPath("tesseract")
85+ if err != nil {
86+ return nil, 0, err
87+ }
88+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
89+ defer cancel()
90+ lang, err := ocrLanguage(ctx, binary)
91+ if err != nil {
92+ return nil, 0, err
93+ }
94+ strips := packLines(img, lines, min(workers, runtime.GOMAXPROCS(0), 16), normalize)
95+ results := make([][]OCRWord, len(strips))
96+ errs := make([]error, len(strips))
97+ var wg sync.WaitGroup
98+ for i, s := range strips {
99+ wg.Go(func() {
100+ words, e := recognizeImage(ctx, binary, lang, s.img, "6", normalize)
101+ errs[i] = e
102+ for _, w := range words {
103+ center := image.Pt((w.Rect.Min.X+w.Rect.Max.X)/2, (w.Rect.Min.Y+w.Rect.Max.Y)/2)
104+ for _, off := range s.offsets {
105+ if center.In(off.packed) {
106+ r := w.Rect.Sub(off.packed.Min)
107+ w.Rect = image.Rect(r.Min.X/off.factor, r.Min.Y/off.factor, (r.Max.X+off.factor-1)/off.factor, (r.Max.Y+off.factor-1)/off.factor).Add(off.source.Min)
108+ results[i] = append(results[i], w)
109+ break
110+ }
111+ }
112+ }
113+ })
114+ }
115+ wg.Wait()
116+ var words []OCRWord
117+ for i := range strips {
118+ if errs[i] != nil {
119+ return nil, len(strips), errs[i]
120+ }
121+ words = append(words, results[i]...)
122+ }
123+ return words, len(strips), nil
124+}
@@ -0,0 +1,124 @@
1+package hints
2+
3+import (
4+ "context"
5+ "image"
6+ "image/color"
7+ "image/draw"
8+ "os/exec"
9+ "runtime"
10+ "sort"
11+ "sync"
12+ "time"
13+)
14+
15+type stripOffset struct {
16+ source, packed image.Rectangle
17+ factor int
18+}
19+type ocrStrip struct {
20+ img *image.Gray
21+ offsets []stripOffset
22+}
23+
24+func packLines(src *image.RGBA, lines []image.Rectangle, workers int, normalize bool) []ocrStrip {
25+ n := min(workers, len(lines))
26+ if n == 0 {
27+ return nil
28+ }
29+ groups := make([][]image.Rectangle, n)
30+ areas := make([]int, n)
31+ lines = append([]image.Rectangle(nil), lines...)
32+ sort.Slice(lines, func(i, j int) bool { return lines[i].Dx()*lines[i].Dy() > lines[j].Dx()*lines[j].Dy() })
33+ for _, r := range lines {
34+ best := 0
35+ for i := range n {
36+ if areas[i] < areas[best] {
37+ best = i
38+ }
39+ }
40+ groups[best] = append(groups[best], r)
41+ areas[best] += r.Dx() * r.Dy()
42+ }
43+ strips := make([]ocrStrip, n)
44+ for i, group := range groups {
45+ width, height := 0, 8
46+ for _, r := range group {
47+ factor := 1
48+ if r.Dy() < 12 {
49+ factor = 2
50+ }
51+ crop := r.Inset(-4).Intersect(src.Bounds())
52+ packed := image.Rect(4, height, 4+crop.Dx()*factor, height+crop.Dy()*factor)
53+ strips[i].offsets = append(strips[i].offsets, stripOffset{crop, packed, factor})
54+ width = max(width, packed.Max.X+4)
55+ height = packed.Max.Y + 8
56+ }
57+ dst := image.NewGray(image.Rect(0, 0, width, height))
58+ draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
59+ for _, off := range strips[i].offsets {
60+ // The padded perimeter estimates the background without depending on text colour.
61+ var sum, count int
62+ r := off.source
63+ for x := r.Min.X; x < r.Max.X; x++ {
64+ sum += int(color.GrayModel.Convert(src.At(x, r.Min.Y)).(color.Gray).Y)
65+ count++
66+ }
67+ invert := normalize && count > 0 && sum/count < 128
68+ for y := off.packed.Min.Y; y < off.packed.Max.Y; y++ {
69+ for x := off.packed.Min.X; x < off.packed.Max.X; x++ {
70+ v := color.GrayModel.Convert(src.At(r.Min.X+(x-off.packed.Min.X)/off.factor, r.Min.Y+(y-off.packed.Min.Y)/off.factor)).(color.Gray).Y
71+ if invert {
72+ v = 255 - v
73+ }
74+ dst.SetGray(x, y, color.Gray{Y: v})
75+ }
76+ }
77+ }
78+ strips[i].img = dst
79+ }
80+ return strips
81+}
82+
83+func readStrips(ctx context.Context, img *image.RGBA, lines []image.Rectangle, workers int, normalize bool) ([]OCRWord, int, error) {
84+ binary, err := exec.LookPath("tesseract")
85+ if err != nil {
86+ return nil, 0, err
87+ }
88+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
89+ defer cancel()
90+ lang, err := ocrLanguage(ctx, binary)
91+ if err != nil {
92+ return nil, 0, err
93+ }
94+ strips := packLines(img, lines, min(workers, runtime.GOMAXPROCS(0), 16), normalize)
95+ results := make([][]OCRWord, len(strips))
96+ errs := make([]error, len(strips))
97+ var wg sync.WaitGroup
98+ for i, s := range strips {
99+ wg.Go(func() {
100+ words, e := recognizeImage(ctx, binary, lang, s.img, "6", normalize)
101+ errs[i] = e
102+ for _, w := range words {
103+ center := image.Pt((w.Rect.Min.X+w.Rect.Max.X)/2, (w.Rect.Min.Y+w.Rect.Max.Y)/2)
104+ for _, off := range s.offsets {
105+ if center.In(off.packed) {
106+ r := w.Rect.Sub(off.packed.Min)
107+ w.Rect = image.Rect(r.Min.X/off.factor, r.Min.Y/off.factor, (r.Max.X+off.factor-1)/off.factor, (r.Max.Y+off.factor-1)/off.factor).Add(off.source.Min)
108+ results[i] = append(results[i], w)
109+ break
110+ }
111+ }
112+ }
113+ })
114+ }
115+ wg.Wait()
116+ var words []OCRWord
117+ for i := range strips {
118+ if errs[i] != nil {
119+ return nil, len(strips), errs[i]
120+ }
121+ words = append(words, results[i]...)
122+ }
123+ return words, len(strips), nil
124+}
A internal/adapters/hints/testdata/terminal.png +0 -0 binary file: no text diff

binary file: no text diff

M internal/app/hintdebug.go +2 -2
@@ -13,13 +13,13 @@ import (
1313 // hintOptions builds the hint worker options: the accessibility source, and,
1414 // when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames,
1515 // $XDG_STATE_HOME/neferwl/hints. Without the accessibility bus the worker
16-// scans pixels only.
16+// reads uncovered windows with OCR only.
1717 func hintOptions(ctx context.Context) hints.Options {
1818 opts := hintDump(ctx)
1919 src, err := atspi.New(ctx)
2020 if err != nil {
2121 log := logging.For(ctx, "atspi")
22- log.Warn().Err(err).Msg("accessibility hint targets unavailable, using pixels only")
22+ log.Warn().Err(err).Msg("accessibility hint targets unavailable, using OCR only")
2323 return opts
2424 }
2525 context.AfterFunc(ctx, func() { _ = src.Close() })
@@ -13,13 +13,13 @@ import (
13 // hintOptions builds the hint worker options: the accessibility source, and,13 // hintOptions builds the hint worker options: the accessibility source, and,
14 // when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames,14 // when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames,
15 // $XDG_STATE_HOME/neferwl/hints. Without the accessibility bus the worker15 // $XDG_STATE_HOME/neferwl/hints. Without the accessibility bus the worker
16-// scans pixels only.16+// reads uncovered windows with OCR only.
17 func hintOptions(ctx context.Context) hints.Options {17 func hintOptions(ctx context.Context) hints.Options {
18 opts := hintDump(ctx)18 opts := hintDump(ctx)
19 src, err := atspi.New(ctx)19 src, err := atspi.New(ctx)
20 if err != nil {20 if err != nil {
21 log := logging.For(ctx, "atspi")21 log := logging.For(ctx, "atspi")
22- log.Warn().Err(err).Msg("accessibility hint targets unavailable, using pixels only")22+ log.Warn().Err(err).Msg("accessibility hint targets unavailable, using OCR only")
23 return opts23 return opts
24 }24 }
25 context.AfterFunc(ctx, func() { _ = src.Close() })25 context.AfterFunc(ctx, func() { _ = src.Close() })

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close