feat(hints): detect targets with the nefer-hints subprocess
Hint mode now gets its targets only from nefer-hints, a persistent detector subprocess (binary protocol over stdin/stdout, lazy start, restart after a failure). The OCR and AT-SPI sources, the text-click modifiers and hint.targets/hint.text-click are removed. New keys: hint.detector, hint.model, hint.threshold.
- commit
- 596b55394e36120807d399835617c4645cfd22a3
- author
- brice · · 2026-10-21 13:28 UTC
- parent
- e0a8611
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
39 files · +1011 -4831
M .mockery.yml +2 -19
| @@ -113,28 +113,11 @@ packages: | |||
| 113 | 113 | filename: proc_tree_mock_test.go | |
| 114 | 114 | github.com/bnema/neferwl/internal/adapters/hints: | |
| 115 | 115 | interfaces: | |
| 116 | - | languageRunner: | |
| 116 | + | detector: | |
| 117 | 117 | config: | |
| 118 | 118 | dir: internal/adapters/hints | |
| 119 | 119 | pkgname: hints | |
| 120 | - | filename: language_runner_mock_test.go | |
| 121 | - | targetSource: | |
| 122 | - | config: | |
| 123 | - | dir: internal/adapters/hints | |
| 124 | - | pkgname: hints | |
| 125 | - | filename: target_source_mock_test.go | |
| 126 | - | github.com/bnema/neferwl/internal/adapters/atspi: | |
| 127 | - | interfaces: | |
| 128 | - | transport: | |
| 129 | - | config: | |
| 130 | - | dir: internal/adapters/atspi | |
| 131 | - | pkgname: atspi | |
| 132 | - | filename: transport_mock_test.go | |
| 133 | - | connector: | |
| 134 | - | config: | |
| 135 | - | dir: internal/adapters/atspi | |
| 136 | - | pkgname: atspi | |
| 137 | - | filename: connector_mock_test.go | |
| 120 | + | filename: detector_mock_test.go | |
| 138 | 121 | github.com/bnema/neferwl/internal/adapters/screencast: | |
| 139 | 122 | interfaces: | |
| 140 | 123 | bus: | |
D internal/adapters/atspi/bus_test.go +0 -197
| @@ -1,197 +0,0 @@ | |||
| 1 | - | package atspi | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "os" | |
| 6 | - | "os/exec" | |
| 7 | - | "strings" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/zerobus" | |
| 12 | - | "github.com/stretchr/testify/require" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // startBus starts a private dbus-daemon, so the test never touches the | |
| 16 | - | // desktop's buses. It skips when the daemon is not installed. | |
| 17 | - | func startBus(t *testing.T) string { | |
| 18 | - | t.Helper() | |
| 19 | - | bin, err := exec.LookPath("dbus-daemon") | |
| 20 | - | if err != nil { | |
| 21 | - | if os.Getenv("CI") != "" { | |
| 22 | - | t.Fatal("dbus-daemon not installed") | |
| 23 | - | } | |
| 24 | - | t.Skip("dbus-daemon not installed") | |
| 25 | - | } | |
| 26 | - | cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1") | |
| 27 | - | out, err := cmd.StdoutPipe() | |
| 28 | - | require.NoError(t, err) | |
| 29 | - | require.NoError(t, cmd.Start()) | |
| 30 | - | t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) | |
| 31 | - | line, err := bufio.NewReader(out).ReadString('\n') | |
| 32 | - | require.NoError(t, err) | |
| 33 | - | return strings.TrimSpace(line) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // TestDoDecodesMatchesPipelined runs Do against an app on a real bus: the | |
| 37 | - | // GetMatches reply is decoded, and replies to several calls sent before any | |
| 38 | - | // is read are matched by serial. | |
| 39 | - | func TestDoDecodesMatchesPipelined(t *testing.T) { | |
| 40 | - | addr := startBus(t) | |
| 41 | - | app, err := zerobus.Dial(addr) | |
| 42 | - | require.NoError(t, err) | |
| 43 | - | t.Cleanup(func() { _ = app.Close() }) | |
| 44 | - | _, err = app.RequestName("org.test.App", 0) | |
| 45 | - | require.NoError(t, err) | |
| 46 | - | ||
| 47 | - | done := make(chan struct{}) | |
| 48 | - | go func() { | |
| 49 | - | defer close(done) | |
| 50 | - | for { | |
| 51 | - | m, err := app.ReadMessage() | |
| 52 | - | if err != nil { | |
| 53 | - | return | |
| 54 | - | } | |
| 55 | - | if m.Type != zerobus.TypeMethodCall { | |
| 56 | - | continue | |
| 57 | - | } | |
| 58 | - | req := &zerobus.Message{Sender: strings.Clone(m.Sender), Serial: m.Serial} | |
| 59 | - | switch m.Member { | |
| 60 | - | case "GetMatches": | |
| 61 | - | // Check the rule: struct, then sort, count, traverse. | |
| 62 | - | r := m.Body() | |
| 63 | - | r.Struct() | |
| 64 | - | end := r.Array('i') | |
| 65 | - | var states []int32 | |
| 66 | - | for r.More(end) { | |
| 67 | - | states = append(states, r.Int32()) | |
| 68 | - | } | |
| 69 | - | _ = r.Int32() | |
| 70 | - | ad := r.Array('{') | |
| 71 | - | _ = ad | |
| 72 | - | _ = r.Int32() | |
| 73 | - | end = r.Array('i') | |
| 74 | - | var roles []int32 | |
| 75 | - | for r.More(end) { | |
| 76 | - | roles = append(roles, r.Int32()) | |
| 77 | - | } | |
| 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 | |
| 79 | - | if !ok { | |
| 80 | - | app.NewError(req, "org.test.Error.BadRule", "s").Str("bad rule") | |
| 81 | - | _, _ = app.Send() | |
| 82 | - | continue | |
| 83 | - | } | |
| 84 | - | e := app.NewReply(req, "a(so)") | |
| 85 | - | a := e.BeginArray('(') | |
| 86 | - | for _, p := range []string{"/one", "/two"} { | |
| 87 | - | e.Struct() | |
| 88 | - | e.Str(":1.99") | |
| 89 | - | e.ObjectPath(p) | |
| 90 | - | } | |
| 91 | - | e.EndArray(a) | |
| 92 | - | case "GetExtents": | |
| 93 | - | e := app.NewReply(req, "(iiii)") | |
| 94 | - | e.Struct() | |
| 95 | - | e.Int32(1) | |
| 96 | - | e.Int32(2) | |
| 97 | - | e.Int32(30) | |
| 98 | - | e.Int32(40) | |
| 99 | - | case "GetRole": | |
| 100 | - | app.NewReply(req, "u").Uint32(roleLink) | |
| 101 | - | case "GetState": | |
| 102 | - | e := app.NewReply(req, "au") | |
| 103 | - | a := e.BeginArray('u') | |
| 104 | - | e.Uint32(stShowing) | |
| 105 | - | e.Uint32(0) | |
| 106 | - | e.EndArray(a) | |
| 107 | - | default: | |
| 108 | - | app.NewError(req, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no") | |
| 109 | - | } | |
| 110 | - | _, _ = app.Send() | |
| 111 | - | } | |
| 112 | - | }() | |
| 113 | - | ||
| 114 | - | cc, err := zerobus.Dial(addr) | |
| 115 | - | require.NoError(t, err) | |
| 116 | - | b, err := newConn(cc) | |
| 117 | - | require.NoError(t, err) | |
| 118 | - | t.Cleanup(func() { _ = b.Close() }) | |
| 119 | - | ||
| 120 | - | calls := []call{ | |
| 121 | - | {op: opMatches, name: "org.test.App", path: "/frame", rule: matchRule{linksOnly: true}}, | |
| 122 | - | {op: opExtents, name: "org.test.App", path: "/one", arg: coordWindow}, | |
| 123 | - | {op: opRole, name: "org.test.App", path: "/one"}, | |
| 124 | - | {op: opState, name: "org.test.App", path: "/one"}, | |
| 125 | - | {op: opChildren, name: "org.test.App", path: "/one"}, | |
| 126 | - | } | |
| 127 | - | rep := b.Do(time.Now().Add(2*time.Second), time.Second, calls) | |
| 128 | - | require.Len(t, rep, 5) | |
| 129 | - | require.NoError(t, rep[0].err) | |
| 130 | - | require.Equal(t, []ref{{":1.99", "/one"}, {":1.99", "/two"}}, rep[0].refs) | |
| 131 | - | require.NoError(t, rep[1].err) | |
| 132 | - | require.Equal(t, [4]int32{1, 2, 30, 40}, rep[1].rect) | |
| 133 | - | require.Equal(t, uint32(roleLink), rep[2].u) | |
| 134 | - | require.Equal(t, uint64(stShowing), rep[3].state) | |
| 135 | - | var be *busError | |
| 136 | - | require.ErrorAs(t, rep[4].err, &be) | |
| 137 | - | require.Equal(t, "org.freedesktop.DBus.Error.UnknownMethod", be.name) | |
| 138 | - | ||
| 139 | - | // A call to a name nobody owns fails fast, others still succeed. | |
| 140 | - | rep = b.Do(time.Now().Add(2*time.Second), time.Second, []call{ | |
| 141 | - | {op: opRole, name: "org.test.Nobody", path: "/x"}, | |
| 142 | - | {op: opRole, name: "org.test.App", path: "/x"}, | |
| 143 | - | }) | |
| 144 | - | require.Error(t, rep[0].err) | |
| 145 | - | require.NoError(t, rep[1].err) | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | func TestDoTimesOutOnSilentApp(t *testing.T) { | |
| 149 | - | addr := startBus(t) | |
| 150 | - | app, err := zerobus.Dial(addr) | |
| 151 | - | require.NoError(t, err) | |
| 152 | - | t.Cleanup(func() { _ = app.Close() }) | |
| 153 | - | _, err = app.RequestName("org.test.Mute", 0) | |
| 154 | - | require.NoError(t, err) | |
| 155 | - | ||
| 156 | - | cc, err := zerobus.Dial(addr) | |
| 157 | - | require.NoError(t, err) | |
| 158 | - | b, err := newConn(cc) | |
| 159 | - | require.NoError(t, err) | |
| 160 | - | t.Cleanup(func() { _ = b.Close() }) | |
| 161 | - | ||
| 162 | - | t0 := time.Now() | |
| 163 | - | rep := b.Do(time.Now().Add(time.Second), 50*time.Millisecond, []call{{op: opRole, name: "org.test.Mute", path: "/x"}}) | |
| 164 | - | require.ErrorIs(t, rep[0].err, ErrTimeout) | |
| 165 | - | require.Less(t, time.Since(t0), 500*time.Millisecond) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // A call left unanswered for idle gets ErrTimeout without breaking the | |
| 169 | - | // connection; a batch that hits its deadline breaks it, and Do returns near | |
| 170 | - | // the deadline even though the idle timeout is longer. | |
| 171 | - | func TestDoDeadlineBreaksConnection(t *testing.T) { | |
| 172 | - | addr := startBus(t) | |
| 173 | - | app, err := zerobus.Dial(addr) | |
| 174 | - | require.NoError(t, err) | |
| 175 | - | t.Cleanup(func() { _ = app.Close() }) | |
| 176 | - | _, err = app.RequestName("org.test.Mute", 0) | |
| 177 | - | require.NoError(t, err) | |
| 178 | - | ||
| 179 | - | cc, err := zerobus.Dial(addr) | |
| 180 | - | require.NoError(t, err) | |
| 181 | - | b, err := newConn(cc) | |
| 182 | - | require.NoError(t, err) | |
| 183 | - | t.Cleanup(func() { _ = b.Close() }) | |
| 184 | - | ||
| 185 | - | mute := []call{{op: opRole, name: "org.test.Mute", path: "/x"}} | |
| 186 | - | rep := b.Do(time.Now().Add(time.Second), 40*time.Millisecond, mute) | |
| 187 | - | require.ErrorIs(t, rep[0].err, ErrTimeout) | |
| 188 | - | require.NotErrorIs(t, rep[0].err, ErrBroken) | |
| 189 | - | ||
| 190 | - | t0 := time.Now() | |
| 191 | - | rep = b.Do(time.Now().Add(60*time.Millisecond), time.Second, mute) | |
| 192 | - | require.ErrorIs(t, rep[0].err, ErrBroken) | |
| 193 | - | require.Less(t, time.Since(t0), 300*time.Millisecond) | |
| 194 | - | // The socket is closed: the next batch fails at once. | |
| 195 | - | rep = b.Do(time.Now().Add(time.Second), time.Second, mute) | |
| 196 | - | require.ErrorIs(t, rep[0].err, ErrBroken) | |
| 197 | - | } | |
D internal/adapters/atspi/connector_mock_test.go +0 -109
| @@ -1,109 +0,0 @@ | |||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |
| 2 | - | // github.com/vektra/mockery | |
| 3 | - | // template: testify | |
| 4 | - | ||
| 5 | - | package atspi | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | "context" | |
| 9 | - | ||
| 10 | - | mock "github.com/stretchr/testify/mock" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // newMockconnector creates a new instance of mockconnector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | - | // The first argument is typically a *testing.T value. | |
| 15 | - | func newMockconnector(t interface { | |
| 16 | - | mock.TestingT | |
| 17 | - | Cleanup(func()) | |
| 18 | - | }) *mockconnector { | |
| 19 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | - | helper.Helper() | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | mock := &mockconnector{} | |
| 24 | - | mock.Mock.Test(t) | |
| 25 | - | ||
| 26 | - | t.Cleanup(func() { | |
| 27 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | - | helper.Helper() | |
| 29 | - | } | |
| 30 | - | mock.AssertExpectations(t) | |
| 31 | - | }) | |
| 32 | - | ||
| 33 | - | return mock | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // mockconnector is an autogenerated mock type for the connector type | |
| 37 | - | type mockconnector struct { | |
| 38 | - | mock.Mock | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | type mockconnector_Expecter struct { | |
| 42 | - | mock *mock.Mock | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func (_m *mockconnector) EXPECT() *mockconnector_Expecter { | |
| 46 | - | return &mockconnector_Expecter{mock: &_m.Mock} | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Connect provides a mock function for the type mockconnector | |
| 50 | - | func (_mock *mockconnector) Connect(ctx context.Context) (transport, error) { | |
| 51 | - | ret := _mock.Called(ctx) | |
| 52 | - | ||
| 53 | - | if len(ret) == 0 { | |
| 54 | - | panic("no return value specified for Connect") | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | var r0 transport | |
| 58 | - | var r1 error | |
| 59 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) (transport, error)); ok { | |
| 60 | - | return returnFunc(ctx) | |
| 61 | - | } | |
| 62 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) transport); ok { | |
| 63 | - | r0 = returnFunc(ctx) | |
| 64 | - | } else { | |
| 65 | - | if ret.Get(0) != nil { | |
| 66 | - | r0 = ret.Get(0).(transport) | |
| 67 | - | } | |
| 68 | - | } | |
| 69 | - | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 70 | - | r1 = returnFunc(ctx) | |
| 71 | - | } else { | |
| 72 | - | r1 = ret.Error(1) | |
| 73 | - | } | |
| 74 | - | return r0, r1 | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // mockconnector_Connect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Connect' | |
| 78 | - | type mockconnector_Connect_Call struct { | |
| 79 | - | *mock.Call | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // Connect is a helper method to define mock.On call | |
| 83 | - | // - ctx context.Context | |
| 84 | - | func (_e *mockconnector_Expecter) Connect(ctx any) *mockconnector_Connect_Call { | |
| 85 | - | return &mockconnector_Connect_Call{Call: _e.mock.On("Connect", ctx)} | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func (_c *mockconnector_Connect_Call) Run(run func(ctx context.Context)) *mockconnector_Connect_Call { | |
| 89 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 90 | - | var arg0 context.Context | |
| 91 | - | if args[0] != nil { | |
| 92 | - | arg0 = args[0].(context.Context) | |
| 93 | - | } | |
| 94 | - | run( | |
| 95 | - | arg0, | |
| 96 | - | ) | |
| 97 | - | }) | |
| 98 | - | return _c | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func (_c *mockconnector_Connect_Call) Return(transportMoqParam transport, err error) *mockconnector_Connect_Call { | |
| 102 | - | _c.Call.Return(transportMoqParam, err) | |
| 103 | - | return _c | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func (_c *mockconnector_Connect_Call) RunAndReturn(run func(ctx context.Context) (transport, error)) *mockconnector_Connect_Call { | |
| 107 | - | _c.Call.Return(run) | |
| 108 | - | return _c | |
| 109 | - | } | |
D internal/adapters/atspi/doc.go +0 -10
| @@ -1,10 +0,0 @@ | |||
| 1 | - | // Package atspi finds hint targets in the accessibility tree of the apps on | |
| 2 | - | // screen. It talks to the AT-SPI registry over the accessibility bus with | |
| 3 | - | // zerobus, from one connection used by one goroutine at a time, and pipelines | |
| 4 | - | // the calls it needs: several are sent before their replies are read, and the | |
| 5 | - | // replies are matched by serial. | |
| 6 | - | // | |
| 7 | - | // The approach (Collection.GetMatches on the window frame, bounded walk when | |
| 8 | - | // the toolkit has no Collection, window-relative extents) is adapted from | |
| 9 | - | // neru's AT-SPI client, https://github.com/y3owk1n/neru (MIT licence). | |
| 10 | - | package atspi | |
D internal/adapters/atspi/model.go +0 -206
| @@ -1,206 +0,0 @@ | |||
| 1 | - | package atspi | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/neferwl/internal/ports" | |
| 4 | - | ||
| 5 | - | // 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. | |
| 7 | - | 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 | |
| 19 | - | roleDocumentFrame = 82 | |
| 20 | - | roleDocumentWeb = 95 | |
| 21 | - | ||
| 22 | - | stateActive = 1 | |
| 23 | - | stateShowing = 25 | |
| 24 | - | stateVisible = 30 | |
| 25 | - | ||
| 26 | - | roleWords = 5 // int32 words of a role set in a MatchRule | |
| 27 | - | stateWords = 2 // int32 words of a state set in a MatchRule | |
| 28 | - | ||
| 29 | - | // AtspiCollectionMatchType and AtspiCollectionSortOrder values. | |
| 30 | - | matchAll = 1 | |
| 31 | - | matchAny = 2 | |
| 32 | - | sortCanonical = 1 | |
| 33 | - | matchCount = 500 | |
| 34 | - | maxDepth = 30 | |
| 35 | - | maxWalkNodes = 3000 | |
| 36 | - | maxFrames = 32 | |
| 37 | - | sizeTolerance = 40 | |
| 38 | - | maxMatchesRead = matchCount | |
| 39 | - | ) | |
| 40 | - | ||
| 41 | - | // clickableRoles are the roles that become hint targets. | |
| 42 | - | var clickableRoles = []uint32{ | |
| 43 | - | roleLink, rolePushButton, roleToggleButton, roleCheckBox, | |
| 44 | - | roleRadioButton, roleMenuItem, rolePageTab, roleComboBox, | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // matchRule is the Collection query: showing and visible objects of the | |
| 48 | - | // clickable roles (only links when linksOnly), plus web documents for coverage. | |
| 49 | - | type matchRule struct{ linksOnly bool } | |
| 50 | - | ||
| 51 | - | func (r matchRule) roles() []uint32 { | |
| 52 | - | if r.linksOnly { | |
| 53 | - | return []uint32{roleLink, roleDocumentFrame, roleDocumentWeb} | |
| 54 | - | } | |
| 55 | - | return append(append([]uint32(nil), clickableRoles...), roleDocumentFrame, roleDocumentWeb) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // roleBits packs role ids into the bit set of a MatchRule: bit id%32 of word | |
| 59 | - | // id/32. | |
| 60 | - | func roleBits(ids []uint32) []int32 { | |
| 61 | - | words := make([]int32, roleWords) | |
| 62 | - | for _, id := range ids { | |
| 63 | - | if w := int(id / 32); w < len(words) { | |
| 64 | - | words[w] |= int32(uint32(1) << (id % 32)) | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | return words | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // stateBits packs state ids into the bit set of a MatchRule. | |
| 71 | - | func stateBits(ids ...uint32) []int32 { | |
| 72 | - | words := make([]int32, stateWords) | |
| 73 | - | for _, id := range ids { | |
| 74 | - | if w := int(id / 32); w < len(words) { | |
| 75 | - | words[w] |= int32(uint32(1) << (id % 32)) | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | return words | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | func hasState(set uint64, state uint) bool { return set&(1<<state) != 0 } | |
| 82 | - | ||
| 83 | - | // isClickable reports whether role makes a target. | |
| 84 | - | func isClickable(role uint32, linksOnly bool) bool { | |
| 85 | - | if linksOnly { | |
| 86 | - | return role == roleLink | |
| 87 | - | } | |
| 88 | - | for _, r := range clickableRoles { | |
| 89 | - | if r == role { | |
| 90 | - | return true | |
| 91 | - | } | |
| 92 | - | } | |
| 93 | - | return false | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // isWindowRole reports whether role is a top-level window role. | |
| 97 | - | func isWindowRole(role uint32) bool { | |
| 98 | - | return role == roleFrame || role == roleWindow || role == roleDialog | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | // kindOf maps a role to the hint kind: links are links, the rest buttons. | |
| 102 | - | func kindOf(role uint32) ports.HintKind { | |
| 103 | - | if role == roleLink { | |
| 104 | - | return ports.HintLink | |
| 105 | - | } | |
| 106 | - | return ports.HintButton | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // frameInfo is a showing top-level window of an app. | |
| 110 | - | type frameInfo struct { | |
| 111 | - | ref ref | |
| 112 | - | active bool | |
| 113 | - | w, h int // size of the frame, from its window extents | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | // matchFrames pairs the windows of one app with its frames, one to one. A | |
| 117 | - | // window is paired with a frame when the pairing is certain: | |
| 118 | - | // - one window and one frame; | |
| 119 | - | // - the focused window with the one ACTIVE frame; that frame is then | |
| 120 | - | // reserved and no other window can take it; | |
| 121 | - | // - otherwise the one unreserved frame whose size is within sizeTolerance | |
| 122 | - | // of the window, provided no other unmatched window claims it as well. | |
| 123 | - | // | |
| 124 | - | // Ambiguous windows are left out. The result maps the index of a window in | |
| 125 | - | // wins to the index of its frame in frames. | |
| 126 | - | func matchFrames(wins []ports.HintWindow, frames []frameInfo) map[int]int { | |
| 127 | - | out := map[int]int{} | |
| 128 | - | if len(wins) == 1 && len(frames) == 1 { | |
| 129 | - | out[0] = 0 | |
| 130 | - | return out | |
| 131 | - | } | |
| 132 | - | // Active matches first: they are reserved against the size matches. | |
| 133 | - | active, n := -1, 0 | |
| 134 | - | for j, f := range frames { | |
| 135 | - | if f.active { | |
| 136 | - | active, n = j, n+1 | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | reserved := make([]bool, len(frames)) | |
| 140 | - | done := make([]bool, len(wins)) | |
| 141 | - | if n == 1 { | |
| 142 | - | focused := -1 | |
| 143 | - | count := 0 | |
| 144 | - | for i, w := range wins { | |
| 145 | - | if w.Focused { | |
| 146 | - | focused, count = i, count+1 | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | if count == 1 { | |
| 150 | - | out[focused] = active | |
| 151 | - | reserved[active] = true | |
| 152 | - | done[focused] = true | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | claims := make([][]int, len(wins)) // size candidates per window | |
| 156 | - | owners := make([]int, len(frames)) // windows that size-match a frame | |
| 157 | - | for i, w := range wins { | |
| 158 | - | if done[i] { | |
| 159 | - | continue | |
| 160 | - | } | |
| 161 | - | for j, f := range frames { | |
| 162 | - | if !reserved[j] && absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance { | |
| 163 | - | claims[i] = append(claims[i], j) | |
| 164 | - | owners[j]++ | |
| 165 | - | } | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | for i := range wins { | |
| 169 | - | if !done[i] && len(claims[i]) == 1 && owners[claims[i][0]] == 1 { | |
| 170 | - | out[i] = claims[i][0] | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | return out | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | func absInt(v int) int { | |
| 177 | - | if v < 0 { | |
| 178 | - | return -v | |
| 179 | - | } | |
| 180 | - | return v | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | // element is a match with its extents. | |
| 184 | - | type element struct { | |
| 185 | - | role uint32 | |
| 186 | - | x, y, w, h int32 // window-relative | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | // toTarget converts an element of a window into a logical output-local | |
| 190 | - | // target: the element extents are relative to the top-level frame, whose own | |
| 191 | - | // window-relative origin is (fx, fy); the result is clipped to the window. | |
| 192 | - | // It reports false for an empty result. | |
| 193 | - | func toTarget(win ports.Rect, fx, fy int32, e element) (ports.HintTarget, bool) { | |
| 194 | - | if e.w <= 0 || e.h <= 0 { | |
| 195 | - | return ports.HintTarget{}, false | |
| 196 | - | } | |
| 197 | - | r := ports.Rect{X: win.X + int(e.x-fx), Y: win.Y + int(e.y-fy), W: int(e.w), H: int(e.h)} | |
| 198 | - | x0, y0 := max(r.X, win.X), max(r.Y, win.Y) | |
| 199 | - | x1, y1 := min(r.X+r.W, win.X+win.W), min(r.Y+r.H, win.Y+win.H) | |
| 200 | - | if x1 <= x0 || y1 <= y0 { | |
| 201 | - | return ports.HintTarget{}, false | |
| 202 | - | } | |
| 203 | - | return ports.HintTarget{Rect: ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, Kind: kindOf(e.role)}, true | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | func isDocument(role uint32) bool { return role == roleDocumentFrame || role == roleDocumentWeb } | |
D internal/adapters/atspi/model_test.go +0 -117
| @@ -1,117 +0,0 @@ | |||
| 1 | - | package atspi | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/neferwl/internal/ports" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | func TestToTargetConvertsWindowCoordinates(t *testing.T) { | |
| 12 | - | win := ports.Rect{X: 100, Y: 50, W: 800, H: 600} | |
| 13 | - | // The frame origin (fx, fy) is subtracted: toolkit offsets cancel out. | |
| 14 | - | got, ok := toTarget(win, 10, 20, element{role: roleLink, x: 110, y: 120, w: 40, h: 16}) | |
| 15 | - | require.True(t, ok) | |
| 16 | - | require.Equal(t, ports.HintTarget{Rect: ports.Rect{X: 200, Y: 150, W: 40, H: 16}, Kind: ports.HintLink}, got) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | func TestToTargetClipsToWindow(t *testing.T) { | |
| 20 | - | win := ports.Rect{X: 100, Y: 50, W: 200, H: 100} | |
| 21 | - | got, ok := toTarget(win, 0, 0, element{role: rolePushButton, x: 180, y: 90, w: 60, h: 30}) | |
| 22 | - | require.True(t, ok) | |
| 23 | - | require.Equal(t, ports.Rect{X: 280, Y: 140, W: 20, H: 10}, got.Rect) | |
| 24 | - | require.Equal(t, ports.HintButton, got.Kind) | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | func TestToTargetDropsEmptyAndOutside(t *testing.T) { | |
| 28 | - | win := ports.Rect{X: 0, Y: 0, W: 100, H: 100} | |
| 29 | - | for name, e := range map[string]element{ | |
| 30 | - | "zero width": {role: roleLink, x: 1, y: 1, w: 0, h: 10}, | |
| 31 | - | "neg height": {role: roleLink, x: 1, y: 1, w: 10, h: -1}, | |
| 32 | - | "outside": {role: roleLink, x: 500, y: 1, w: 10, h: 10}, | |
| 33 | - | "above": {role: roleLink, x: 1, y: -50, w: 10, h: 10}, | |
| 34 | - | "left of win": {role: roleLink, x: -30, y: 1, w: 10, h: 10}, | |
| 35 | - | } { | |
| 36 | - | _, ok := toTarget(win, 0, 0, e) | |
| 37 | - | require.False(t, ok, name) | |
| 38 | - | } | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | func TestRoleKindMapping(t *testing.T) { | |
| 42 | - | require.Equal(t, ports.HintLink, kindOf(roleLink)) | |
| 43 | - | for _, r := range []uint32{rolePushButton, roleToggleButton, roleCheckBox, roleRadioButton, roleMenuItem, rolePageTab, roleComboBox} { | |
| 44 | - | require.Equal(t, ports.HintButton, kindOf(r), r) | |
| 45 | - | require.True(t, isClickable(r, false), r) | |
| 46 | - | require.False(t, isClickable(r, true), r) | |
| 47 | - | } | |
| 48 | - | require.True(t, isClickable(roleLink, true)) | |
| 49 | - | require.False(t, isClickable(roleFrame, false)) | |
| 50 | - | require.True(t, isWindowRole(roleFrame) && isWindowRole(roleWindow) && isWindowRole(roleDialog)) | |
| 51 | - | require.False(t, isWindowRole(roleLink)) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | func TestRoleAndStateBits(t *testing.T) { | |
| 55 | - | require.Equal(t, []int32{0, 0, 0, 0, 0}, roleBits(nil)) | |
| 56 | - | // link is role 88: word 2, bit 24; push button is 43: word 1, bit 11. | |
| 57 | - | require.Equal(t, []int32{0, 1 << 11, 1 << 24, 0, 0}, roleBits([]uint32{roleLink, rolePushButton})) | |
| 58 | - | // showing is state 25, visible 30: both in word 0. | |
| 59 | - | require.Equal(t, []int32{1<<25 | 1<<30, 0}, stateBits(stateShowing, stateVisible)) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func wins(ws ...ports.HintWindow) []ports.HintWindow { return ws } | |
| 63 | - | ||
| 64 | - | func win(id ports.WindowID, w, h int, focused bool) ports.HintWindow { | |
| 65 | - | return ports.HintWindow{ID: id, PID: 1, Rect: ports.Rect{W: w, H: h}, Focused: focused} | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | func TestMatchFramesSingle(t *testing.T) { | |
| 69 | - | got := matchFrames(wins(win(1, 800, 600, false)), []frameInfo{{w: 100, h: 100}}) | |
| 70 | - | require.Equal(t, map[int]int{0: 0}, got) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func TestMatchFramesActive(t *testing.T) { | |
| 74 | - | frames := []frameInfo{{w: 800, h: 600}, {w: 800, h: 600, active: true}} | |
| 75 | - | got := matchFrames(wins(win(1, 800, 600, true), win(2, 800, 600, false)), frames) | |
| 76 | - | // The focused window takes the ACTIVE frame and reserves it; the other | |
| 77 | - | // window then only has the remaining frame to match. | |
| 78 | - | require.Equal(t, map[int]int{0: 1, 1: 0}, got) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | func TestMatchFramesSize(t *testing.T) { | |
| 82 | - | frames := []frameInfo{{w: 1000, h: 700}, {w: 400, h: 300}} | |
| 83 | - | got := matchFrames(wins(win(1, 1000, 700, false), win(2, 420, 290, false)), frames) | |
| 84 | - | require.Equal(t, map[int]int{0: 0, 1: 1}, got) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | func TestMatchFramesSizeTolerance(t *testing.T) { | |
| 88 | - | frames := []frameInfo{{w: 840, h: 640}, {w: 100, h: 100}} | |
| 89 | - | other := win(2, 90, 90, false) | |
| 90 | - | // 40 px off still matches; 41 px does not. | |
| 91 | - | require.Equal(t, map[int]int{0: 0, 1: 1}, matchFrames(wins(win(1, 800, 600, false), other), frames)) | |
| 92 | - | require.Equal(t, map[int]int{1: 1}, matchFrames(wins(win(1, 799, 600, false), other), frames)) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | func TestMatchFramesAmbiguous(t *testing.T) { | |
| 96 | - | // Two windows and two frames of the same size, none active: skipped. | |
| 97 | - | frames := []frameInfo{{w: 800, h: 600}, {w: 810, h: 600}} | |
| 98 | - | got := matchFrames(wins(win(1, 800, 600, false), win(2, 800, 600, false)), frames) | |
| 99 | - | require.Empty(t, got) | |
| 100 | - | // One window, two same-size frames, not focused: skipped. | |
| 101 | - | require.Empty(t, matchFrames(wins(win(1, 800, 600, false)), frames)) | |
| 102 | - | // No frame close enough. | |
| 103 | - | require.Empty(t, matchFrames(wins(win(1, 200, 200, false), win(2, 300, 300, false)), frames)) | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // The active frame is reserved by the focused window: an unfocused window of | |
| 107 | - | // the same size as that frame must not be paired with it as well. | |
| 108 | - | func TestMatchFramesOneToOne(t *testing.T) { | |
| 109 | - | frames := []frameInfo{{ref: ref{"a", "/active"}, active: true, w: 400, h: 300}, {ref: ref{"a", "/idle"}, w: 800, h: 600}} | |
| 110 | - | got := matchFrames(wins(win(1, 800, 600, true), win(2, 400, 300, false)), frames) | |
| 111 | - | seen := map[int]bool{} | |
| 112 | - | for _, f := range got { | |
| 113 | - | require.False(t, seen[f], "frame %d assigned twice: %v", f, got) | |
| 114 | - | seen[f] = true | |
| 115 | - | } | |
| 116 | - | require.Equal(t, map[int]int{0: 0}, got) | |
| 117 | - | } | |
D internal/adapters/atspi/source.go +0 -481
| @@ -1,481 +0,0 @@ | |||
| 1 | - | package atspi | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "strings" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/zerobus" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 13 | - | "github.com/bnema/neferwl/internal/ports" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | const ( | |
| 17 | - | // callTimeout is how long a batch waits for the next reply. | |
| 18 | - | callTimeout = 100 * time.Millisecond | |
| 19 | - | // budget is the whole time Targets may take. | |
| 20 | - | budget = 300 * time.Millisecond | |
| 21 | - | ) | |
| 22 | - | ||
| 23 | - | // connectBudget bounds the whole connection setup (session bus, | |
| 24 | - | // GetAddress, IsEnabled, dial of the accessibility bus) of one attempt. | |
| 25 | - | const connectBudget = 300 * time.Millisecond | |
| 26 | - | ||
| 27 | - | // retryEvery is the minimum time between two connection attempts. | |
| 28 | - | const retryEvery = 5 * time.Second | |
| 29 | - | ||
| 30 | - | // connector opens a transport on the accessibility bus. | |
| 31 | - | type connector interface { | |
| 32 | - | Connect(ctx context.Context) (transport, error) | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // stallWarnAfter is how long an attempt may be in flight before it is | |
| 36 | - | // reported as a stalled accessibility bus. | |
| 37 | - | const stallWarnAfter = 5 * time.Second | |
| 38 | - | ||
| 39 | - | // dialResult is the outcome of a connection attempt. | |
| 40 | - | type dialResult struct { | |
| 41 | - | tr transport | |
| 42 | - | err error | |
| 43 | - | at time.Time // when the attempt finished | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // Source reads hint targets from the accessibility tree. It owns one | |
| 47 | - | // connection to the accessibility bus. It is NOT safe for concurrent use: | |
| 48 | - | // it is owned by the hints worker goroutine, which calls Targets and, | |
| 49 | - | // when it returns, Close. The connection is made on the first Targets call | |
| 50 | - | // that needs it, never at startup, and made again after a failure, at most | |
| 51 | - | // once every retryEvery; meanwhile Targets finds nothing and OCR takes over. | |
| 52 | - | // | |
| 53 | - | // At most one connection attempt (one goroutine and one socket) exists at | |
| 54 | - | // any time: an attempt that outlives the wait of ensure stays in flight, is | |
| 55 | - | // polled by the later Targets calls and is never doubled. | |
| 56 | - | type Source struct { | |
| 57 | - | tr transport | |
| 58 | - | dial connector | |
| 59 | - | // attempt delivers the outcome of the attempt in flight (buffered, so | |
| 60 | - | // the attempt goroutine never blocks); nil when none runs. | |
| 61 | - | attempt chan dialResult | |
| 62 | - | attemptStarted time.Time | |
| 63 | - | warnedStall bool | |
| 64 | - | retryAt time.Time | |
| 65 | - | closed bool | |
| 66 | - | // wait is how long ensure waits for a new attempt; connectBudget if 0. | |
| 67 | - | wait time.Duration | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // New returns a Source. It does not touch the bus: the connection is made by | |
| 71 | - | // the first Targets call. | |
| 72 | - | func New() *Source { return &Source{dial: busConnector{}} } | |
| 73 | - | ||
| 74 | - | // busConnector connects to the session bus, enables accessibility and dials | |
| 75 | - | // the accessibility bus. | |
| 76 | - | type busConnector struct{} | |
| 77 | - | ||
| 78 | - | func (busConnector) Connect(ctx context.Context) (transport, error) { | |
| 79 | - | log := logging.For(ctx, "atspi") | |
| 80 | - | // zerobus has no deadline or context API: SessionBus and Dial run the | |
| 81 | - | // socket dial, SASL auth and Hello without a bound, and the watchdog | |
| 82 | - | // below cannot exist before the connection does. A stall there is | |
| 83 | - | // bounded by the single-in-flight rule of Source.ensure, not by a | |
| 84 | - | // deadline. Follow-up: a deadline-capable zerobus API. | |
| 85 | - | sess, err := zerobus.SessionBus() | |
| 86 | - | if err != nil { | |
| 87 | - | return nil, fmt.Errorf("atspi: session bus: %w", err) | |
| 88 | - | } | |
| 89 | - | defer func() { _ = sess.Close() }() | |
| 90 | - | // Call has no deadline: closing the connection ends the wait for a | |
| 91 | - | // silent service. | |
| 92 | - | stop := time.AfterFunc(connectBudget, func() { _ = sess.Close() }) | |
| 93 | - | defer stop.Stop() | |
| 94 | - | ||
| 95 | - | sess.NewCall(a11yBusName, a11yBusPath, a11yBusName, "GetAddress", "") | |
| 96 | - | m, err := sess.Call() | |
| 97 | - | if err != nil { | |
| 98 | - | return nil, fmt.Errorf("atspi: org.a11y.Bus GetAddress: %w", err) | |
| 99 | - | } | |
| 100 | - | r := m.Body() | |
| 101 | - | addr := r.Str() | |
| 102 | - | if r.Err() != nil || addr == "" { | |
| 103 | - | return nil, errors.New("atspi: no accessibility bus address") | |
| 104 | - | } | |
| 105 | - | addr = strings.Clone(addr) | |
| 106 | - | ||
| 107 | - | e := sess.NewCall(a11yBusName, a11yBusPath, ifaceProps, "Set", "ssv") | |
| 108 | - | e.Str(a11yStatus) | |
| 109 | - | e.Str("IsEnabled") | |
| 110 | - | e.Variant("b") | |
| 111 | - | e.Bool(true) | |
| 112 | - | if _, err := sess.Call(); err != nil { | |
| 113 | - | // Toolkits that already see the bus keep working. | |
| 114 | - | log.Warn().Err(err).Msg("could not enable accessibility") | |
| 115 | - | } else { | |
| 116 | - | log.Info().Msg("accessibility enabled") | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | zc, err := zerobus.Dial(addr) | |
| 120 | - | if err != nil { | |
| 121 | - | return nil, fmt.Errorf("atspi: accessibility bus: %w", err) | |
| 122 | - | } | |
| 123 | - | tr, err := newConn(zc) | |
| 124 | - | if err != nil { | |
| 125 | - | return nil, err | |
| 126 | - | } | |
| 127 | - | log.Info().Msg("accessibility bus connected") | |
| 128 | - | return tr, nil | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // ensure returns the connection, making one if none exists and the last | |
| 132 | - | // attempt is old enough. It waits at most the budget for a new attempt; an | |
| 133 | - | // attempt that does not answer in time stays in flight (abandoned, never | |
| 134 | - | // doubled) and is polled without blocking by the next calls. | |
| 135 | - | func (s *Source) ensure(ctx context.Context) transport { | |
| 136 | - | if s.closed { | |
| 137 | - | return nil | |
| 138 | - | } | |
| 139 | - | if s.tr != nil { | |
| 140 | - | return s.tr | |
| 141 | - | } | |
| 142 | - | if s.dial == nil { | |
| 143 | - | return nil | |
| 144 | - | } | |
| 145 | - | now := time.Now() | |
| 146 | - | if s.attempt != nil { | |
| 147 | - | select { | |
| 148 | - | case r := <-s.attempt: | |
| 149 | - | s.finish(ctx, r) | |
| 150 | - | default: | |
| 151 | - | if !s.warnedStall && now.Sub(s.attemptStarted) > stallWarnAfter { | |
| 152 | - | s.warnedStall = true | |
| 153 | - | log := logging.For(ctx, "atspi") | |
| 154 | - | log.Warn().Dur("for", now.Sub(s.attemptStarted)).Msg("accessibility bus not answering") | |
| 155 | - | } | |
| 156 | - | } | |
| 157 | - | return s.tr | |
| 158 | - | } | |
| 159 | - | if now.Before(s.retryAt) { | |
| 160 | - | return nil | |
| 161 | - | } | |
| 162 | - | ch := make(chan dialResult, 1) | |
| 163 | - | s.attempt = ch | |
| 164 | - | s.attemptStarted = now | |
| 165 | - | s.warnedStall = false | |
| 166 | - | dial := s.dial | |
| 167 | - | go func() { | |
| 168 | - | tr, err := dial.Connect(ctx) | |
| 169 | - | ch <- dialResult{tr: tr, err: err, at: time.Now()} | |
| 170 | - | }() | |
| 171 | - | wait := s.wait | |
| 172 | - | if wait <= 0 { | |
| 173 | - | wait = connectBudget | |
| 174 | - | } | |
| 175 | - | timer := time.NewTimer(wait) | |
| 176 | - | defer timer.Stop() | |
| 177 | - | select { | |
| 178 | - | case r := <-ch: | |
| 179 | - | s.finish(ctx, r) | |
| 180 | - | case <-timer.C: | |
| 181 | - | log := logging.For(ctx, "atspi") | |
| 182 | - | log.Debug().Dur("after", wait).Msg("accessibility connect attempt abandoned in flight") | |
| 183 | - | case <-ctx.Done(): | |
| 184 | - | } | |
| 185 | - | return s.tr | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | // finish takes the outcome of the attempt in flight: a transport is kept, | |
| 189 | - | // an error starts the backoff from the time the attempt finished. | |
| 190 | - | func (s *Source) finish(ctx context.Context, r dialResult) { | |
| 191 | - | s.attempt = nil | |
| 192 | - | if r.err != nil || r.tr == nil { | |
| 193 | - | s.retryAt = r.at.Add(retryEvery) | |
| 194 | - | err := r.err | |
| 195 | - | if err == nil { | |
| 196 | - | err = errors.New("atspi: no transport") | |
| 197 | - | } | |
| 198 | - | log := logging.For(ctx, "atspi") | |
| 199 | - | log.Warn().Err(err).Msg("accessibility hint targets unavailable, using OCR only") | |
| 200 | - | return | |
| 201 | - | } | |
| 202 | - | s.tr = r.tr | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | // drop closes a broken connection; the next attempt reconnects. | |
| 206 | - | func (s *Source) drop() { | |
| 207 | - | s.retryAt = time.Now().Add(retryEvery) | |
| 208 | - | if s.tr != nil { | |
| 209 | - | _ = s.tr.Close() | |
| 210 | - | s.tr = nil | |
| 211 | - | } | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | // Close closes the accessibility bus connection, and the one an attempt | |
| 215 | - | // still in flight may deliver late. After Close, Targets finds nothing and | |
| 216 | - | // never connects. Like Targets it is called by the owner goroutine only. | |
| 217 | - | func (s *Source) Close() error { | |
| 218 | - | if s.closed { | |
| 219 | - | return nil | |
| 220 | - | } | |
| 221 | - | s.closed = true | |
| 222 | - | var err error | |
| 223 | - | if s.tr != nil { | |
| 224 | - | err = s.tr.Close() | |
| 225 | - | s.tr = nil | |
| 226 | - | } | |
| 227 | - | if ch := s.attempt; ch != nil { | |
| 228 | - | s.attempt = nil | |
| 229 | - | // One reaper at most: Close runs once, and one attempt exists. | |
| 230 | - | go func() { | |
| 231 | - | if r := <-ch; r.tr != nil { | |
| 232 | - | _ = r.tr.Close() | |
| 233 | - | } | |
| 234 | - | }() | |
| 235 | - | } | |
| 236 | - | return err | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | // appInfo is an application of the registry whose process shows a window. | |
| 240 | - | type appInfo struct { | |
| 241 | - | root ref | |
| 242 | - | pid int | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | // winMatch is a window with the frame it was paired with. | |
| 246 | - | type winMatch struct { | |
| 247 | - | win ports.HintWindow | |
| 248 | - | frame ref | |
| 249 | - | fx int32 | |
| 250 | - | fy int32 | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | // Targets returns the clickable targets of the accessibility tree for | |
| 254 | - | // windows (logical output-local, clipped to each window) and the set of | |
| 255 | - | // windows those targets cover. A window is covered when its tree answered | |
| 256 | - | // with a link target or web document; uncovered windows also get OCR. It | |
| 257 | - | // returns what is ready within the budget. | |
| 258 | - | func (s *Source) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) { | |
| 259 | - | covered := map[ports.WindowID]bool{} | |
| 260 | - | deadline := time.Now().Add(budget) | |
| 261 | - | if d, ok := ctx.Deadline(); ok && d.Before(deadline) { | |
| 262 | - | deadline = d | |
| 263 | - | } | |
| 264 | - | wanted := map[int]bool{} | |
| 265 | - | for _, w := range windows { | |
| 266 | - | if w.PID > 0 { | |
| 267 | - | wanted[w.PID] = true | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | if len(wanted) == 0 { | |
| 271 | - | return nil, covered, nil | |
| 272 | - | } | |
| 273 | - | tr := s.ensure(ctx) | |
| 274 | - | if tr == nil { | |
| 275 | - | return nil, covered, nil | |
| 276 | - | } | |
| 277 | - | broken := false | |
| 278 | - | do := func(calls []call) []reply { | |
| 279 | - | if len(calls) == 0 || ctx.Err() != nil { | |
| 280 | - | return make([]reply, len(calls)) | |
| 281 | - | } | |
| 282 | - | if broken { | |
| 283 | - | out := make([]reply, len(calls)) | |
| 284 | - | for i := range out { | |
| 285 | - | out[i].err = ErrBroken | |
| 286 | - | } | |
| 287 | - | return out | |
| 288 | - | } | |
| 289 | - | out := tr.Do(deadline, callTimeout, calls) | |
| 290 | - | for _, r := range out { | |
| 291 | - | if errors.Is(r.err, ErrBroken) { | |
| 292 | - | broken = true | |
| 293 | - | s.drop() | |
| 294 | - | break | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | - | return out | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | // Applications of the registry, kept when their process shows a window. | |
| 301 | - | rep := do([]call{{op: opChildren, name: registryName, path: registryRoot}}) | |
| 302 | - | if len(rep) == 0 || rep[0].err != nil { | |
| 303 | - | if len(rep) > 0 { | |
| 304 | - | return nil, covered, fmt.Errorf("atspi: registry: %w", rep[0].err) | |
| 305 | - | } | |
| 306 | - | return nil, covered, nil | |
| 307 | - | } | |
| 308 | - | apps := rep[0].refs | |
| 309 | - | calls := make([]call, len(apps)) | |
| 310 | - | for i, a := range apps { | |
| 311 | - | calls[i] = call{op: opPID, name: a.name} | |
| 312 | - | } | |
| 313 | - | var kept []appInfo | |
| 314 | - | for i, r := range do(calls) { | |
| 315 | - | if r.err == nil && wanted[int(r.u)] { | |
| 316 | - | kept = append(kept, appInfo{root: apps[i], pid: int(r.u)}) | |
| 317 | - | } | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | // The showing windows of each kept app. | |
| 321 | - | calls = calls[:0] | |
| 322 | - | for _, a := range kept { | |
| 323 | - | calls = append(calls, call{op: opChildren, name: a.root.name, path: a.root.path}) | |
| 324 | - | } | |
| 325 | - | var cands []ref | |
| 326 | - | var candApp []int | |
| 327 | - | for i, r := range do(calls) { | |
| 328 | - | if r.err != nil { | |
| 329 | - | continue | |
| 330 | - | } | |
| 331 | - | for _, c := range r.refs[:min(len(r.refs), maxFrames)] { | |
| 332 | - | cands = append(cands, c) | |
| 333 | - | candApp = append(candApp, i) | |
| 334 | - | } | |
| 335 | - | } | |
| 336 | - | calls = calls[:0] | |
| 337 | - | for _, c := range cands { | |
| 338 | - | calls = append(calls, | |
| 339 | - | call{op: opRole, name: c.name, path: c.path}, | |
| 340 | - | call{op: opState, name: c.name, path: c.path}, | |
| 341 | - | call{op: opExtents, name: c.name, path: c.path, arg: coordWindow}) | |
| 342 | - | } | |
| 343 | - | info := do(calls) | |
| 344 | - | type frame struct { | |
| 345 | - | frameInfo | |
| 346 | - | pid int | |
| 347 | - | fx, fy int32 | |
| 348 | - | } | |
| 349 | - | var frames []frame | |
| 350 | - | for i, c := range cands { | |
| 351 | - | role, state, ext := info[3*i], info[3*i+1], info[3*i+2] | |
| 352 | - | if role.err != nil || state.err != nil || ext.err != nil || !isWindowRole(role.u) || !hasState(state.state, stateShowing) { | |
| 353 | - | continue | |
| 354 | - | } | |
| 355 | - | frames = append(frames, frame{ | |
| 356 | - | frameInfo: frameInfo{ref: c, active: hasState(state.state, stateActive), w: int(ext.rect[2]), h: int(ext.rect[3])}, | |
| 357 | - | pid: kept[candApp[i]].pid, fx: ext.rect[0], fy: ext.rect[1], | |
| 358 | - | }) | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | // Pair the windows with the frames, process by process. | |
| 362 | - | var matched []winMatch | |
| 363 | - | for pid := range wanted { | |
| 364 | - | var wins []ports.HintWindow | |
| 365 | - | for _, w := range windows { | |
| 366 | - | if w.PID == pid { | |
| 367 | - | wins = append(wins, w) | |
| 368 | - | } | |
| 369 | - | } | |
| 370 | - | var fs []frameInfo | |
| 371 | - | var origin []frame | |
| 372 | - | for _, f := range frames { | |
| 373 | - | if f.pid == pid { | |
| 374 | - | fs = append(fs, f.frameInfo) | |
| 375 | - | origin = append(origin, f) | |
| 376 | - | } | |
| 377 | - | } | |
| 378 | - | for wi, fi := range matchFrames(wins, fs) { | |
| 379 | - | matched = append(matched, winMatch{win: wins[wi], frame: origin[fi].ref, fx: origin[fi].fx, fy: origin[fi].fy}) | |
| 380 | - | } | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | targets := s.elements(do, matched, linksOnly, covered) | |
| 384 | - | log := logging.For(ctx, "atspi") | |
| 385 | - | log.Debug().Int("apps", len(kept)).Int("frames", len(frames)).Int("windows", len(matched)).Int("targets", len(targets)).Msg("atspi targets") | |
| 386 | - | return targets, covered, nil | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | // elements reads the clickable elements of the matched frames. | |
| 390 | - | func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly bool, covered map[ports.WindowID]bool) []ports.HintTarget { | |
| 391 | - | calls := make([]call, len(matched)) | |
| 392 | - | for i, m := range matched { | |
| 393 | - | calls[i] = call{op: opMatches, name: m.frame.name, path: m.frame.path, rule: matchRule{linksOnly: linksOnly}} | |
| 394 | - | } | |
| 395 | - | found := make([][]ref, len(matched)) | |
| 396 | - | var walk []int // frames without Collection | |
| 397 | - | for i, r := range do(calls) { | |
| 398 | - | if r.err != nil { | |
| 399 | - | var be *busError | |
| 400 | - | if errors.As(r.err, &be) { | |
| 401 | - | walk = append(walk, i) | |
| 402 | - | } | |
| 403 | - | continue | |
| 404 | - | } | |
| 405 | - | found[i] = r.refs | |
| 406 | - | } | |
| 407 | - | for _, i := range walk { | |
| 408 | - | found[i] = s.walk(do, matched[i].frame, linksOnly) | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | calls = calls[:0] | |
| 412 | - | var owner []int | |
| 413 | - | for i, refs := range found { | |
| 414 | - | for _, r := range refs { | |
| 415 | - | calls = append(calls, | |
| 416 | - | call{op: opRole, name: r.name, path: r.path}, | |
| 417 | - | call{op: opExtents, name: r.name, path: r.path, arg: coordWindow}) | |
| 418 | - | owner = append(owner, i) | |
| 419 | - | } | |
| 420 | - | } | |
| 421 | - | info := do(calls) | |
| 422 | - | var out []ports.HintTarget | |
| 423 | - | for k, i := range owner { | |
| 424 | - | role, ext := info[2*k], info[2*k+1] | |
| 425 | - | if role.err == nil && isDocument(role.u) { | |
| 426 | - | covered[matched[i].win.ID] = true | |
| 427 | - | continue | |
| 428 | - | } | |
| 429 | - | if role.err != nil || ext.err != nil || !isClickable(role.u, linksOnly) { | |
| 430 | - | continue | |
| 431 | - | } | |
| 432 | - | m := matched[i] | |
| 433 | - | t, ok := toTarget(m.win.Rect, m.fx, m.fy, element{role: role.u, x: ext.rect[0], y: ext.rect[1], w: ext.rect[2], h: ext.rect[3]}) | |
| 434 | - | if !ok { | |
| 435 | - | continue | |
| 436 | - | } | |
| 437 | - | t.Window = m.win.ID | |
| 438 | - | out = append(out, t) | |
| 439 | - | if t.Kind == ports.HintLink { | |
| 440 | - | covered[m.win.ID] = true | |
| 441 | - | } | |
| 442 | - | } | |
| 443 | - | return out | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | // walk is the fallback for toolkits without Collection: a breadth-first | |
| 447 | - | // walk of at most maxDepth levels and maxWalkNodes nodes, returning the | |
| 448 | - | // clickable nodes. | |
| 449 | - | func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref { | |
| 450 | - | var out []ref | |
| 451 | - | level := []ref{root} | |
| 452 | - | seen := 0 | |
| 453 | - | for depth := 0; depth < maxDepth && len(level) > 0 && seen < maxWalkNodes; depth++ { | |
| 454 | - | calls := make([]call, 0, 3*len(level)) | |
| 455 | - | for _, n := range level { | |
| 456 | - | calls = append(calls, | |
| 457 | - | call{op: opChildren, name: n.name, path: n.path}, | |
| 458 | - | call{op: opRole, name: n.name, path: n.path}, | |
| 459 | - | call{op: opState, name: n.name, path: n.path}) | |
| 460 | - | } | |
| 461 | - | rep := do(calls) | |
| 462 | - | room := max(0, maxWalkNodes-seen-len(level)) | |
| 463 | - | next := make([]ref, 0, room) | |
| 464 | - | for i, n := range level { | |
| 465 | - | ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2] | |
| 466 | - | if state.err == nil && !hasState(state.state, stateShowing) { | |
| 467 | - | continue | |
| 468 | - | } | |
| 469 | - | if role.err == nil && (isClickable(role.u, linksOnly) || isDocument(role.u)) && state.err == nil && hasState(state.state, stateVisible) { | |
| 470 | - | out = append(out, n) | |
| 471 | - | } | |
| 472 | - | if ch.err == nil { | |
| 473 | - | take := min(len(ch.refs), room-len(next)) | |
| 474 | - | next = append(next, ch.refs[:take]...) | |
| 475 | - | } | |
| 476 | - | } | |
| 477 | - | seen += len(level) | |
| 478 | - | level = next | |
| 479 | - | } | |
| 480 | - | return out | |
| 481 | - | } | |
D internal/adapters/atspi/source_test.go +0 -355
| @@ -1,355 +0,0 @@ | |||
| 1 | - | package atspi | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "runtime" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/stretchr/testify/mock" | |
| 12 | - | "github.com/stretchr/testify/require" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/neferwl/internal/ports" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | const ( | |
| 18 | - | stActive = 1 << stateActive | |
| 19 | - | stShowing = 1 << stateShowing | |
| 20 | - | ) | |
| 21 | - | ||
| 22 | - | // tree answers calls like an app: registry with one app (:1.5, pid 77) with | |
| 23 | - | // one showing frame whose links sit at window-relative extents. | |
| 24 | - | func answerTree(frameExt [4]int32, matches []ref, ext map[string][4]int32, roles map[string]uint32, noCollection bool) func(time.Time, time.Duration, []call) []reply { | |
| 25 | - | return func(_ time.Time, _ time.Duration, calls []call) []reply { | |
| 26 | - | out := make([]reply, len(calls)) | |
| 27 | - | for i, c := range calls { | |
| 28 | - | switch { | |
| 29 | - | case c.op == opChildren && c.name == registryName: | |
| 30 | - | out[i].refs = []ref{{":1.5", "/root"}, {":1.9", "/root"}} | |
| 31 | - | case c.op == opPID: | |
| 32 | - | out[i].u = map[string]uint32{":1.5": 77, ":1.9": 99}[c.name] | |
| 33 | - | case c.op == opChildren && c.path == "/root": | |
| 34 | - | out[i].refs = []ref{{":1.5", "/frame"}} | |
| 35 | - | case c.op == opRole && c.path == "/frame": | |
| 36 | - | out[i].u = roleFrame | |
| 37 | - | case c.op == opState && c.path == "/frame": | |
| 38 | - | out[i].state = stShowing | stActive | |
| 39 | - | case c.op == opExtents && c.path == "/frame": | |
| 40 | - | out[i].rect = frameExt | |
| 41 | - | case c.op == opMatches: | |
| 42 | - | if noCollection { | |
| 43 | - | out[i].err = &busError{name: "org.freedesktop.DBus.Error.UnknownMethod"} | |
| 44 | - | } else { | |
| 45 | - | out[i].refs = matches | |
| 46 | - | } | |
| 47 | - | case c.op == opChildren && c.path == "/frame": | |
| 48 | - | out[i].refs = matches | |
| 49 | - | case c.op == opChildren: | |
| 50 | - | case c.op == opRole: | |
| 51 | - | out[i].u = roles[c.path] | |
| 52 | - | case c.op == opState: | |
| 53 | - | out[i].state = stShowing | 1<<stateVisible | |
| 54 | - | case c.op == opExtents: | |
| 55 | - | out[i].rect = ext[c.path] | |
| 56 | - | } | |
| 57 | - | } | |
| 58 | - | return out | |
| 59 | - | } | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func TestTargetsCoversWindowAndConverts(t *testing.T) { | |
| 63 | - | tr := newMocktransport(t) | |
| 64 | - | matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}, {":1.5", "/zero"}} | |
| 65 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 66 | - | [4]int32{4, 30, 800, 600}, matches, | |
| 67 | - | map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}, "/zero": {5, 5, 0, 0}}, | |
| 68 | - | map[string]uint32{"/a": roleLink, "/b": rolePushButton, "/zero": roleLink}, false)) | |
| 69 | - | s := &Source{tr: tr} | |
| 70 | - | ||
| 71 | - | windows := []ports.HintWindow{ | |
| 72 | - | {ID: 1, PID: 77, Rect: ports.Rect{X: 100, Y: 200, W: 800, H: 600}, Focused: true}, | |
| 73 | - | {ID: 2, PID: 5, Rect: ports.Rect{X: 0, Y: 0, W: 50, H: 50}}, | |
| 74 | - | } | |
| 75 | - | targets, covered, err := s.Targets(context.Background(), windows, false) | |
| 76 | - | require.NoError(t, err) | |
| 77 | - | require.Equal(t, map[ports.WindowID]bool{1: true}, covered) | |
| 78 | - | require.Equal(t, []ports.HintTarget{ | |
| 79 | - | {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink, Window: 1}, | |
| 80 | - | {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton, Window: 1}, | |
| 81 | - | }, targets) | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | func TestTargetsLinksOnlyDropsButtons(t *testing.T) { | |
| 85 | - | tr := newMocktransport(t) | |
| 86 | - | matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}} | |
| 87 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 88 | - | [4]int32{0, 0, 800, 600}, matches, | |
| 89 | - | map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}}, | |
| 90 | - | map[string]uint32{"/a": roleLink, "/b": rolePushButton}, false)) | |
| 91 | - | s := &Source{tr: tr} | |
| 92 | - | targets, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, true) | |
| 93 | - | require.NoError(t, err) | |
| 94 | - | require.Len(t, targets, 1) | |
| 95 | - | require.Equal(t, ports.HintLink, targets[0].Kind) | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | func TestTargetsWalkWithoutCollection(t *testing.T) { | |
| 99 | - | tr := newMocktransport(t) | |
| 100 | - | matches := []ref{{":1.5", "/a"}} | |
| 101 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 102 | - | [4]int32{0, 0, 800, 600}, matches, | |
| 103 | - | map[string][4]int32{"/a": {14, 130, 50, 16}}, | |
| 104 | - | map[string]uint32{"/a": roleLink}, true)) | |
| 105 | - | s := &Source{tr: tr} | |
| 106 | - | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false) | |
| 107 | - | require.NoError(t, err) | |
| 108 | - | require.Len(t, targets, 1) | |
| 109 | - | require.True(t, covered[1]) | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | func TestTargetsNoPIDMatchCoversNothing(t *testing.T) { | |
| 113 | - | tr := newMocktransport(t) | |
| 114 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree([4]int32{}, nil, nil, nil, false)) | |
| 115 | - | s := &Source{tr: tr} | |
| 116 | - | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 1234, Rect: ports.Rect{W: 8, H: 6}}}, false) | |
| 117 | - | require.NoError(t, err) | |
| 118 | - | require.Empty(t, targets) | |
| 119 | - | require.Empty(t, covered) | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func TestTargetsWithoutPIDsAsksNothing(t *testing.T) { | |
| 123 | - | s := &Source{tr: newMocktransport(t)} | |
| 124 | - | _, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 6}}}, false) | |
| 125 | - | require.NoError(t, err) | |
| 126 | - | require.Empty(t, covered) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func TestCoverageRequiresLinkOrDocument(t *testing.T) { | |
| 130 | - | for _, collection := range []bool{true, false} { | |
| 131 | - | for _, role := range []uint32{rolePushButton, roleDocumentFrame, roleDocumentWeb, roleLink} { | |
| 132 | - | t.Run(fmt.Sprintf("collection=%t/role=%d", collection, role), func(t *testing.T) { | |
| 133 | - | tr := newMocktransport(t) | |
| 134 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 135 | - | [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)) | |
| 136 | - | s := &Source{tr: tr} | |
| 137 | - | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false) | |
| 138 | - | require.NoError(t, err) | |
| 139 | - | require.Equal(t, role != rolePushButton, covered[1]) | |
| 140 | - | if isDocument(role) { | |
| 141 | - | require.Empty(t, targets) | |
| 142 | - | } else { | |
| 143 | - | require.Len(t, targets, 1) | |
| 144 | - | } | |
| 145 | - | }) | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | var oneWindow = []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}} | |
| 151 | - | ||
| 152 | - | // New does not touch the bus; the first Targets call connects, once. | |
| 153 | - | func TestConnectsLazilyOnFirstTargets(t *testing.T) { | |
| 154 | - | tr := newMocktransport(t) | |
| 155 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 156 | - | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |
| 157 | - | c := newMockconnector(t) | |
| 158 | - | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |
| 159 | - | s := &Source{dial: c} | |
| 160 | - | for range 2 { | |
| 161 | - | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 162 | - | require.NoError(t, err) | |
| 163 | - | require.Len(t, targets, 1) | |
| 164 | - | } | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | // Without PIDs nothing is asked and nothing is connected. | |
| 168 | - | func TestNoConnectWithoutWindows(t *testing.T) { | |
| 169 | - | s := &Source{dial: newMockconnector(t)} | |
| 170 | - | _, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1}}, false) | |
| 171 | - | require.NoError(t, err) | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | // A failed connection is retried no sooner than retryEvery; meanwhile no | |
| 175 | - | // targets are returned. | |
| 176 | - | func TestConnectRetriesWithBackoff(t *testing.T) { | |
| 177 | - | c := newMockconnector(t) | |
| 178 | - | c.EXPECT().Connect(mock.Anything).Return(nil, errors.New("down")).Once() | |
| 179 | - | s := &Source{dial: c} | |
| 180 | - | for range 3 { | |
| 181 | - | targets, covered, err := s.Targets(context.Background(), oneWindow, false) | |
| 182 | - | require.NoError(t, err) | |
| 183 | - | require.Empty(t, targets) | |
| 184 | - | require.Empty(t, covered) | |
| 185 | - | } | |
| 186 | - | tr := newMocktransport(t) | |
| 187 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 188 | - | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |
| 189 | - | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |
| 190 | - | s.retryAt = time.Now().Add(-time.Second) | |
| 191 | - | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 192 | - | require.NoError(t, err) | |
| 193 | - | require.Len(t, targets, 1) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | // A broken connection is closed and dropped; the next attempt reconnects. | |
| 197 | - | func TestBrokenConnectionIsDropped(t *testing.T) { | |
| 198 | - | tr := newMocktransport(t) | |
| 199 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 200 | - | func(_ time.Time, _ time.Duration, calls []call) []reply { | |
| 201 | - | out := make([]reply, len(calls)) | |
| 202 | - | for i := range out { | |
| 203 | - | out[i].err = ErrBroken | |
| 204 | - | } | |
| 205 | - | return out | |
| 206 | - | }).Once() | |
| 207 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 208 | - | c := newMockconnector(t) | |
| 209 | - | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |
| 210 | - | s := &Source{dial: c} | |
| 211 | - | _, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 212 | - | require.Error(t, err) | |
| 213 | - | require.Nil(t, s.tr) | |
| 214 | - | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 215 | - | require.NoError(t, err) | |
| 216 | - | require.Empty(t, targets) // backoff: no new attempt yet | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | const testWait = 20 * time.Millisecond | |
| 220 | - | ||
| 221 | - | // blockedConnector returns a connector whose Connect blocks until release is | |
| 222 | - | func blockedConnector(t *testing.T, release <-chan struct{}, tr transport, err error, times int) *mockconnector { | |
| 223 | - | t.Helper() | |
| 224 | - | c := newMockconnector(t) | |
| 225 | - | c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) { | |
| 226 | - | <-release | |
| 227 | - | return tr, err | |
| 228 | - | }).Times(times) | |
| 229 | - | return c | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | // A silent service cannot hold Targets beyond the wait, is not doubled while | |
| 233 | - | // in flight, and its late transport is used. | |
| 234 | - | func TestConnectIsBoundedAndNotDoubled(t *testing.T) { | |
| 235 | - | release := make(chan struct{}) | |
| 236 | - | tr := newMocktransport(t) | |
| 237 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 238 | - | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |
| 239 | - | c := blockedConnector(t, release, tr, nil, 1) | |
| 240 | - | s := &Source{dial: c, wait: testWait} | |
| 241 | - | t0 := time.Now() | |
| 242 | - | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 243 | - | require.NoError(t, err) | |
| 244 | - | require.Empty(t, targets) | |
| 245 | - | require.Less(t, time.Since(t0), testWait+200*time.Millisecond) | |
| 246 | - | require.NotNil(t, s.attempt) | |
| 247 | - | // Still in flight: no second Connect (Times(1)), and no blocking. | |
| 248 | - | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |
| 249 | - | require.NoError(t, err) | |
| 250 | - | require.Empty(t, targets) | |
| 251 | - | // It connects late: the next call uses it. | |
| 252 | - | close(release) | |
| 253 | - | require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond) | |
| 254 | - | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |
| 255 | - | require.NoError(t, err) | |
| 256 | - | require.Len(t, targets, 1) | |
| 257 | - | require.Nil(t, s.attempt) | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | // An abandoned attempt that fails applies the backoff, then one new attempt. | |
| 261 | - | func TestAbandonedAttemptErrorBacksOff(t *testing.T) { | |
| 262 | - | release := make(chan struct{}) | |
| 263 | - | tr := newMocktransport(t) | |
| 264 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 265 | - | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |
| 266 | - | c := newMockconnector(t) | |
| 267 | - | c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) { | |
| 268 | - | <-release | |
| 269 | - | return nil, errors.New("down") | |
| 270 | - | }).Once() | |
| 271 | - | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |
| 272 | - | s := &Source{dial: c, wait: testWait} | |
| 273 | - | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 274 | - | require.NoError(t, err) | |
| 275 | - | require.Empty(t, targets) | |
| 276 | - | close(release) | |
| 277 | - | require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond) | |
| 278 | - | // The error is collected; the backoff runs from its finish time. | |
| 279 | - | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |
| 280 | - | require.NoError(t, err) | |
| 281 | - | require.Empty(t, targets) | |
| 282 | - | require.Nil(t, s.attempt) | |
| 283 | - | require.WithinDuration(t, time.Now().Add(retryEvery), s.retryAt, time.Second) | |
| 284 | - | targets, _, err = s.Targets(context.Background(), oneWindow, false) // backoff: no Connect | |
| 285 | - | require.NoError(t, err) | |
| 286 | - | require.Empty(t, targets) | |
| 287 | - | s.retryAt = time.Now().Add(-time.Second) | |
| 288 | - | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |
| 289 | - | require.NoError(t, err) | |
| 290 | - | require.Len(t, targets, 1) | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | // Close with an attempt in flight closes the late transport; afterwards | |
| 294 | - | // Targets finds nothing and never connects. | |
| 295 | - | func TestCloseReapsLateTransport(t *testing.T) { | |
| 296 | - | release := make(chan struct{}) | |
| 297 | - | closed := make(chan struct{}) | |
| 298 | - | tr := newMocktransport(t) | |
| 299 | - | tr.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once() | |
| 300 | - | c := blockedConnector(t, release, tr, nil, 1) | |
| 301 | - | s := &Source{dial: c, wait: testWait} | |
| 302 | - | _, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 303 | - | require.NoError(t, err) | |
| 304 | - | require.NoError(t, s.Close()) | |
| 305 | - | close(release) | |
| 306 | - | select { | |
| 307 | - | case <-closed: | |
| 308 | - | case <-time.After(5 * time.Second): | |
| 309 | - | t.Fatal("late transport not closed") | |
| 310 | - | } | |
| 311 | - | targets, covered, err := s.Targets(context.Background(), oneWindow, false) | |
| 312 | - | require.NoError(t, err) | |
| 313 | - | require.Empty(t, targets) | |
| 314 | - | require.Empty(t, covered) | |
| 315 | - | require.NoError(t, s.Close()) // idempotent | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | func TestWalkWideTreeStaysWithinBudget(t *testing.T) { | |
| 319 | - | tr := newMocktransport(t) | |
| 320 | - | children := make([]ref, 4096) | |
| 321 | - | for i := range children { | |
| 322 | - | children[i] = ref{":1.5", fmt.Sprintf("/node%d", i)} | |
| 323 | - | } | |
| 324 | - | visited := 0 | |
| 325 | - | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 326 | - | func(_ time.Time, _ time.Duration, calls []call) []reply { | |
| 327 | - | visited += len(calls) / 3 | |
| 328 | - | require.LessOrEqual(t, visited, maxWalkNodes) | |
| 329 | - | rep := make([]reply, len(calls)) | |
| 330 | - | for i, c := range calls { | |
| 331 | - | switch c.op { | |
| 332 | - | case opChildren: | |
| 333 | - | rep[i].refs = children | |
| 334 | - | case opRole: | |
| 335 | - | rep[i].u = roleLink | |
| 336 | - | case opState: | |
| 337 | - | rep[i].state = stShowing | 1<<stateVisible | |
| 338 | - | } | |
| 339 | - | } | |
| 340 | - | return rep | |
| 341 | - | }).Twice() | |
| 342 | - | s := &Source{tr: tr} | |
| 343 | - | // A full second level replies with another 4096 children each. There is | |
| 344 | - | // no remaining budget, so none of those millions of refs may be copied. | |
| 345 | - | var out []ref | |
| 346 | - | var before, after runtime.MemStats | |
| 347 | - | runtime.ReadMemStats(&before) | |
| 348 | - | out = s.walk(func(calls []call) []reply { return tr.Do(time.Time{}, time.Second, calls) }, ref{":1.5", "/frame"}, false) | |
| 349 | - | runtime.ReadMemStats(&after) | |
| 350 | - | require.Len(t, out, maxWalkNodes) | |
| 351 | - | require.Equal(t, maxWalkNodes, visited) | |
| 352 | - | // Normal replies and call batches take only a few MB; the old append- | |
| 353 | - | // then-truncate path copied hundreds of MB for this tree. | |
| 354 | - | require.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(32<<20)) | |
| 355 | - | } | |
D internal/adapters/atspi/transport.go +0 -362
| @@ -1,362 +0,0 @@ | |||
| 1 | - | package atspi | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "strings" | |
| 7 | - | "sync/atomic" | |
| 8 | - | "syscall" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/zerobus" | |
| 12 | - | "golang.org/x/sys/unix" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // D-Bus names, paths and interfaces. | |
| 16 | - | const ( | |
| 17 | - | busDaemon = "org.freedesktop.DBus" | |
| 18 | - | busDaemonPath = "/org/freedesktop/DBus" | |
| 19 | - | ifaceProps = "org.freedesktop.DBus.Properties" | |
| 20 | - | ||
| 21 | - | a11yBusName = "org.a11y.Bus" | |
| 22 | - | a11yBusPath = "/org/a11y/bus" | |
| 23 | - | a11yStatus = "org.a11y.Status" | |
| 24 | - | registryName = "org.a11y.atspi.Registry" | |
| 25 | - | registryRoot = "/org/a11y/atspi/accessible/root" | |
| 26 | - | ||
| 27 | - | ifaceAccessible = "org.a11y.atspi.Accessible" | |
| 28 | - | ifaceComponent = "org.a11y.atspi.Component" | |
| 29 | - | ifaceCollection = "org.a11y.atspi.Collection" | |
| 30 | - | ||
| 31 | - | // coordWindow is ATSPI_COORD_TYPE_WINDOW: extents relative to the | |
| 32 | - | // top-level window. | |
| 33 | - | coordWindow = 1 | |
| 34 | - | ||
| 35 | - | // maxInflight bounds the calls sent before a reply is read, so the bus | |
| 36 | - | // never waits on a full socket while this side waits on it. | |
| 37 | - | maxInflight = 64 | |
| 38 | - | // maxRefs bounds the objects read from one reply. | |
| 39 | - | maxRefs = 4096 | |
| 40 | - | ) | |
| 41 | - | ||
| 42 | - | // op is a call the source makes. | |
| 43 | - | type op uint8 | |
| 44 | - | ||
| 45 | - | const ( | |
| 46 | - | opChildren op = iota // Accessible.GetChildren -> refs | |
| 47 | - | opPID // GetConnectionUnixProcessID(name) -> u | |
| 48 | - | opRole // Accessible.GetRole -> u | |
| 49 | - | opState // Accessible.GetState -> state | |
| 50 | - | opExtents // Component.GetExtents(arg) -> rect | |
| 51 | - | opMatches // Collection.GetMatches(rule) -> refs | |
| 52 | - | ) | |
| 53 | - | ||
| 54 | - | // ref is an accessible object: a bus name and a path. | |
| 55 | - | type ref struct{ name, path string } | |
| 56 | - | ||
| 57 | - | // call is one request of a batch. name and path address the object; for | |
| 58 | - | // opPID, name is the bus name asked about. | |
| 59 | - | type call struct { | |
| 60 | - | op op | |
| 61 | - | name, path string | |
| 62 | - | arg uint32 | |
| 63 | - | rule matchRule | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // reply answers the call at the same index of the batch. | |
| 67 | - | type reply struct { | |
| 68 | - | err error | |
| 69 | - | refs []ref | |
| 70 | - | u uint32 | |
| 71 | - | state uint64 | |
| 72 | - | rect [4]int32 // x, y, w, h | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // transport sends batches of calls on one bus connection. It is used by one | |
| 76 | - | // goroutine at a time. | |
| 77 | - | type transport interface { | |
| 78 | - | // Do sends calls pipelined and returns one reply per call, in order. | |
| 79 | - | // The batch ends at deadline. A call not answered idle after it was | |
| 80 | - | // sent gets ErrTimeout. When the connection fails or the deadline | |
| 81 | - | // finds calls unanswered, those calls get an error wrapping | |
| 82 | - | // ErrBroken: the connection must be dropped. | |
| 83 | - | Do(deadline time.Time, idle time.Duration, calls []call) []reply | |
| 84 | - | Close() error | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | // ErrTimeout is the error of a call not answered in time. | |
| 88 | - | var ErrTimeout = errors.New("atspi: call timed out") | |
| 89 | - | ||
| 90 | - | // busError is a D-Bus error reply. | |
| 91 | - | type busError struct{ name, msg string } | |
| 92 | - | ||
| 93 | - | func (e *busError) Error() string { | |
| 94 | - | if e.msg == "" { | |
| 95 | - | return e.name | |
| 96 | - | } | |
| 97 | - | return e.name + ": " + e.msg | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // conn is a transport on a zerobus connection. | |
| 101 | - | type conn struct { | |
| 102 | - | c *zerobus.Conn | |
| 103 | - | raw syscall.RawConn | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func newConn(c *zerobus.Conn) (*conn, error) { | |
| 107 | - | raw, err := c.SyscallConn() | |
| 108 | - | if err != nil { | |
| 109 | - | _ = c.Close() | |
| 110 | - | return nil, err | |
| 111 | - | } | |
| 112 | - | return &conn{c: c, raw: raw}, nil | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func (b *conn) Close() error { return b.c.Close() } | |
| 116 | - | ||
| 117 | - | // readable waits until a message can be read or d passes. | |
| 118 | - | func (b *conn) readable(d time.Duration) (bool, error) { | |
| 119 | - | if b.c.Buffered() { | |
| 120 | - | return true, nil | |
| 121 | - | } | |
| 122 | - | ms := int((d + time.Millisecond - 1) / time.Millisecond) | |
| 123 | - | if ms < 1 { | |
| 124 | - | ms = 1 | |
| 125 | - | } | |
| 126 | - | var n int | |
| 127 | - | var perr error | |
| 128 | - | if err := b.raw.Control(func(fd uintptr) { | |
| 129 | - | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |
| 130 | - | for { | |
| 131 | - | n, perr = unix.Poll(fds, ms) | |
| 132 | - | if perr != unix.EINTR { | |
| 133 | - | return | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | }); err != nil { | |
| 137 | - | return false, err | |
| 138 | - | } | |
| 139 | - | return n > 0, perr | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // ErrBroken is the error of calls on a connection that failed or was closed | |
| 143 | - | // at the deadline; the owner must drop it and connect again. | |
| 144 | - | var ErrBroken = errors.New("atspi: connection broken") | |
| 145 | - | ||
| 146 | - | // errDeadline is the error of calls still unanswered at the batch deadline: | |
| 147 | - | // late replies and half-read messages leave the connection unusable. | |
| 148 | - | var errDeadline = fmt.Errorf("%w: %w", ErrBroken, ErrTimeout) | |
| 149 | - | ||
| 150 | - | // watchdogGrace is how long after the deadline the watchdog closes a socket | |
| 151 | - | // that the batch loop could not leave by itself (a stuck read or write). | |
| 152 | - | const watchdogGrace = 20 * time.Millisecond | |
| 153 | - | ||
| 154 | - | // Do sends the calls pipelined. A watchdog closes the socket shortly after | |
| 155 | - | // deadline, so neither a blocked write nor a read stuck in the middle of a | |
| 156 | - | // message can outlive the batch; the connection is then broken. Each call | |
| 157 | - | // has its own idle timeout, counted from its send: traffic for other calls | |
| 158 | - | // does not extend it. | |
| 159 | - | func (b *conn) Do(deadline time.Time, idle time.Duration, calls []call) []reply { | |
| 160 | - | out := make([]reply, len(calls)) | |
| 161 | - | answered := make([]bool, len(calls)) | |
| 162 | - | type inflight struct { | |
| 163 | - | idx int | |
| 164 | - | sent time.Time | |
| 165 | - | } | |
| 166 | - | pending := make(map[uint32]inflight, min(len(calls), maxInflight)) | |
| 167 | - | next, done := 0, 0 | |
| 168 | - | var fired atomic.Bool | |
| 169 | - | watchdog := time.AfterFunc(time.Until(deadline)+watchdogGrace, func() { | |
| 170 | - | fired.Store(true) | |
| 171 | - | _ = b.c.Close() | |
| 172 | - | }) | |
| 173 | - | defer watchdog.Stop() | |
| 174 | - | // finish gives err to every call without an answer. | |
| 175 | - | finish := func(err error) []reply { | |
| 176 | - | if !errors.Is(err, ErrBroken) && (fired.Load() || !errors.Is(err, ErrTimeout)) { | |
| 177 | - | err = fmt.Errorf("%w: %w", ErrBroken, err) | |
| 178 | - | } | |
| 179 | - | if errors.Is(err, ErrBroken) { | |
| 180 | - | _ = b.c.Close() // late replies would desynchronize the next batch | |
| 181 | - | } | |
| 182 | - | for i := range out { | |
| 183 | - | if !answered[i] { | |
| 184 | - | out[i].err = err | |
| 185 | - | } | |
| 186 | - | } | |
| 187 | - | return out | |
| 188 | - | } | |
| 189 | - | for done < len(calls) { | |
| 190 | - | for next < len(calls) && len(pending) < maxInflight { | |
| 191 | - | if !time.Now().Before(deadline) { | |
| 192 | - | return finish(errDeadline) | |
| 193 | - | } | |
| 194 | - | serial, err := b.send(calls[next]) | |
| 195 | - | if err != nil { | |
| 196 | - | return finish(err) | |
| 197 | - | } | |
| 198 | - | pending[serial] = inflight{idx: next, sent: time.Now()} | |
| 199 | - | next++ | |
| 200 | - | } | |
| 201 | - | // Calls that waited longer than idle get no reply. | |
| 202 | - | now := time.Now() | |
| 203 | - | var oldest time.Time | |
| 204 | - | for serial, p := range pending { | |
| 205 | - | if now.Sub(p.sent) >= idle { | |
| 206 | - | out[p.idx].err = ErrTimeout | |
| 207 | - | answered[p.idx] = true | |
| 208 | - | done++ | |
| 209 | - | delete(pending, serial) | |
| 210 | - | } else if oldest.IsZero() || p.sent.Before(oldest) { | |
| 211 | - | oldest = p.sent | |
| 212 | - | } | |
| 213 | - | } | |
| 214 | - | if len(pending) == 0 { | |
| 215 | - | continue | |
| 216 | - | } | |
| 217 | - | wait := min(time.Until(oldest.Add(idle)), time.Until(deadline)) | |
| 218 | - | if wait <= 0 { | |
| 219 | - | if !time.Now().Before(deadline) { | |
| 220 | - | break | |
| 221 | - | } | |
| 222 | - | continue | |
| 223 | - | } | |
| 224 | - | ok, err := b.readable(wait) | |
| 225 | - | if err != nil { | |
| 226 | - | return finish(err) | |
| 227 | - | } | |
| 228 | - | if !ok { | |
| 229 | - | continue | |
| 230 | - | } | |
| 231 | - | m, err := b.c.ReadMessage() | |
| 232 | - | if err != nil { | |
| 233 | - | return finish(err) | |
| 234 | - | } | |
| 235 | - | p, found := pending[m.ReplySerial] | |
| 236 | - | if !found || (m.Type != zerobus.TypeMethodReturn && m.Type != zerobus.TypeError) { | |
| 237 | - | // A signal, or the late reply of an earlier batch. | |
| 238 | - | continue | |
| 239 | - | } | |
| 240 | - | delete(pending, m.ReplySerial) | |
| 241 | - | decode(m, calls[p.idx], &out[p.idx]) | |
| 242 | - | answered[p.idx] = true | |
| 243 | - | done++ | |
| 244 | - | } | |
| 245 | - | if done == len(calls) { | |
| 246 | - | return out | |
| 247 | - | } | |
| 248 | - | return finish(errDeadline) | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | // send builds and sends one call and returns its serial. | |
| 252 | - | func (b *conn) send(c call) (uint32, error) { | |
| 253 | - | switch c.op { | |
| 254 | - | case opChildren: | |
| 255 | - | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetChildren", "") | |
| 256 | - | case opPID: | |
| 257 | - | b.c.NewCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s").Str(c.name) | |
| 258 | - | case opRole: | |
| 259 | - | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetRole", "") | |
| 260 | - | case opState: | |
| 261 | - | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetState", "") | |
| 262 | - | case opExtents: | |
| 263 | - | b.c.NewCall(c.name, c.path, ifaceComponent, "GetExtents", "u").Uint32(c.arg) | |
| 264 | - | case opMatches: | |
| 265 | - | e := b.c.NewCall(c.name, c.path, ifaceCollection, "GetMatches", "(aiia{ss}iaiiasib)uib") | |
| 266 | - | encodeRule(e, c.rule) | |
| 267 | - | e.Uint32(sortCanonical) | |
| 268 | - | e.Int32(matchCount) | |
| 269 | - | e.Bool(true) | |
| 270 | - | } | |
| 271 | - | return b.c.Send() | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | // encodeRule writes the MatchRule struct (aiia{ss}iaiiasib): states, state | |
| 275 | - | // match, attributes, attribute match, roles, role match, interfaces, | |
| 276 | - | // interface match, invert. | |
| 277 | - | func encodeRule(e *zerobus.Encoder, r matchRule) { | |
| 278 | - | e.Struct() | |
| 279 | - | a := e.BeginArray('i') | |
| 280 | - | for _, w := range stateBits(stateShowing, stateVisible) { | |
| 281 | - | e.Int32(w) | |
| 282 | - | } | |
| 283 | - | e.EndArray(a) | |
| 284 | - | e.Int32(matchAll) | |
| 285 | - | m := e.BeginArray('{') | |
| 286 | - | e.EndArray(m) | |
| 287 | - | e.Int32(matchAll) | |
| 288 | - | a = e.BeginArray('i') | |
| 289 | - | for _, w := range roleBits(r.roles()) { | |
| 290 | - | e.Int32(w) | |
| 291 | - | } | |
| 292 | - | e.EndArray(a) | |
| 293 | - | e.Int32(matchAny) | |
| 294 | - | s := e.BeginArray('s') | |
| 295 | - | e.EndArray(s) | |
| 296 | - | e.Int32(matchAll) | |
| 297 | - | e.Bool(false) | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | // decode reads the reply m of c into out. | |
| 301 | - | func decode(m *zerobus.Message, c call, out *reply) { | |
| 302 | - | if m.Type == zerobus.TypeError { | |
| 303 | - | be := &busError{name: strings.Clone(m.ErrorName)} | |
| 304 | - | if strings.HasPrefix(m.Signature, "s") { | |
| 305 | - | be.msg = strings.Clone(m.Body().Str()) | |
| 306 | - | } | |
| 307 | - | out.err = be | |
| 308 | - | return | |
| 309 | - | } | |
| 310 | - | r := m.Body() | |
| 311 | - | want := func(sig string) bool { | |
| 312 | - | if m.Signature != sig { | |
| 313 | - | out.err = fmt.Errorf("atspi: reply signature %q, want %q", m.Signature, sig) | |
| 314 | - | return false | |
| 315 | - | } | |
| 316 | - | return true | |
| 317 | - | } | |
| 318 | - | switch c.op { | |
| 319 | - | case opChildren, opMatches: | |
| 320 | - | if !want("a(so)") { | |
| 321 | - | return | |
| 322 | - | } | |
| 323 | - | end := r.Array('(') | |
| 324 | - | for r.More(end) { | |
| 325 | - | if len(out.refs) >= maxRefs { | |
| 326 | - | break | |
| 327 | - | } | |
| 328 | - | r.Struct() | |
| 329 | - | name := r.Str() | |
| 330 | - | path := r.ObjectPath() | |
| 331 | - | if r.Err() != nil { | |
| 332 | - | break | |
| 333 | - | } | |
| 334 | - | out.refs = append(out.refs, ref{strings.Clone(name), strings.Clone(path)}) | |
| 335 | - | } | |
| 336 | - | case opPID, opRole: | |
| 337 | - | if want("u") { | |
| 338 | - | out.u = r.Uint32() | |
| 339 | - | } | |
| 340 | - | case opState: | |
| 341 | - | if !want("au") { | |
| 342 | - | return | |
| 343 | - | } | |
| 344 | - | end := r.Array('u') | |
| 345 | - | var words [2]uint32 | |
| 346 | - | for i := 0; r.More(end); i++ { | |
| 347 | - | w := r.Uint32() | |
| 348 | - | if i < len(words) { | |
| 349 | - | words[i] = w | |
| 350 | - | } | |
| 351 | - | } | |
| 352 | - | out.state = uint64(words[0]) | uint64(words[1])<<32 | |
| 353 | - | case opExtents: | |
| 354 | - | if want("(iiii)") { | |
| 355 | - | r.Struct() | |
| 356 | - | out.rect = [4]int32{r.Int32(), r.Int32(), r.Int32(), r.Int32()} | |
| 357 | - | } | |
| 358 | - | } | |
| 359 | - | if out.err == nil && r.Err() != nil { | |
| 360 | - | out.err = r.Err() | |
| 361 | - | } | |
| 362 | - | } | |
D internal/adapters/atspi/transport_mock_test.go +0 -156
| @@ -1,156 +0,0 @@ | |||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |
| 2 | - | // github.com/vektra/mockery | |
| 3 | - | // template: testify | |
| 4 | - | ||
| 5 | - | package atspi | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | mock "github.com/stretchr/testify/mock" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // newMocktransport creates a new instance of mocktransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | - | // The first argument is typically a *testing.T value. | |
| 15 | - | func newMocktransport(t interface { | |
| 16 | - | mock.TestingT | |
| 17 | - | Cleanup(func()) | |
| 18 | - | }) *mocktransport { | |
| 19 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | - | helper.Helper() | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | mock := &mocktransport{} | |
| 24 | - | mock.Mock.Test(t) | |
| 25 | - | ||
| 26 | - | t.Cleanup(func() { | |
| 27 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | - | helper.Helper() | |
| 29 | - | } | |
| 30 | - | mock.AssertExpectations(t) | |
| 31 | - | }) | |
| 32 | - | ||
| 33 | - | return mock | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // mocktransport is an autogenerated mock type for the transport type | |
| 37 | - | type mocktransport struct { | |
| 38 | - | mock.Mock | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | type mocktransport_Expecter struct { | |
| 42 | - | mock *mock.Mock | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func (_m *mocktransport) EXPECT() *mocktransport_Expecter { | |
| 46 | - | return &mocktransport_Expecter{mock: &_m.Mock} | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Close provides a mock function for the type mocktransport | |
| 50 | - | func (_mock *mocktransport) Close() error { | |
| 51 | - | ret := _mock.Called() | |
| 52 | - | ||
| 53 | - | if len(ret) == 0 { | |
| 54 | - | panic("no return value specified for Close") | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | var r0 error | |
| 58 | - | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 59 | - | r0 = returnFunc() | |
| 60 | - | } else { | |
| 61 | - | r0 = ret.Error(0) | |
| 62 | - | } | |
| 63 | - | return r0 | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // mocktransport_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |
| 67 | - | type mocktransport_Close_Call struct { | |
| 68 | - | *mock.Call | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Close is a helper method to define mock.On call | |
| 72 | - | func (_e *mocktransport_Expecter) Close() *mocktransport_Close_Call { | |
| 73 | - | return &mocktransport_Close_Call{Call: _e.mock.On("Close")} | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func (_c *mocktransport_Close_Call) Run(run func()) *mocktransport_Close_Call { | |
| 77 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 78 | - | run() | |
| 79 | - | }) | |
| 80 | - | return _c | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | func (_c *mocktransport_Close_Call) Return(err error) *mocktransport_Close_Call { | |
| 84 | - | _c.Call.Return(err) | |
| 85 | - | return _c | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func (_c *mocktransport_Close_Call) RunAndReturn(run func() error) *mocktransport_Close_Call { | |
| 89 | - | _c.Call.Return(run) | |
| 90 | - | return _c | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // Do provides a mock function for the type mocktransport | |
| 94 | - | func (_mock *mocktransport) Do(deadline time.Time, idle time.Duration, calls []call) []reply { | |
| 95 | - | ret := _mock.Called(deadline, idle, calls) | |
| 96 | - | ||
| 97 | - | if len(ret) == 0 { | |
| 98 | - | panic("no return value specified for Do") | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | var r0 []reply | |
| 102 | - | if returnFunc, ok := ret.Get(0).(func(time.Time, time.Duration, []call) []reply); ok { | |
| 103 | - | r0 = returnFunc(deadline, idle, calls) | |
| 104 | - | } else { | |
| 105 | - | if ret.Get(0) != nil { | |
| 106 | - | r0 = ret.Get(0).([]reply) | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | return r0 | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // mocktransport_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do' | |
| 113 | - | type mocktransport_Do_Call struct { | |
| 114 | - | *mock.Call | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Do is a helper method to define mock.On call | |
| 118 | - | // - deadline time.Time | |
| 119 | - | // - idle time.Duration | |
| 120 | - | // - calls []call | |
| 121 | - | func (_e *mocktransport_Expecter) Do(deadline any, idle any, calls any) *mocktransport_Do_Call { | |
| 122 | - | return &mocktransport_Do_Call{Call: _e.mock.On("Do", deadline, idle, calls)} | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | func (_c *mocktransport_Do_Call) Run(run func(deadline time.Time, idle time.Duration, calls []call)) *mocktransport_Do_Call { | |
| 126 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 127 | - | var arg0 time.Time | |
| 128 | - | if args[0] != nil { | |
| 129 | - | arg0 = args[0].(time.Time) | |
| 130 | - | } | |
| 131 | - | var arg1 time.Duration | |
| 132 | - | if args[1] != nil { | |
| 133 | - | arg1 = args[1].(time.Duration) | |
| 134 | - | } | |
| 135 | - | var arg2 []call | |
| 136 | - | if args[2] != nil { | |
| 137 | - | arg2 = args[2].([]call) | |
| 138 | - | } | |
| 139 | - | run( | |
| 140 | - | arg0, | |
| 141 | - | arg1, | |
| 142 | - | arg2, | |
| 143 | - | ) | |
| 144 | - | }) | |
| 145 | - | return _c | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | func (_c *mocktransport_Do_Call) Return(replyMoqParams []reply) *mocktransport_Do_Call { | |
| 149 | - | _c.Call.Return(replyMoqParams) | |
| 150 | - | return _c | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | func (_c *mocktransport_Do_Call) RunAndReturn(run func(deadline time.Time, idle time.Duration, calls []call) []reply) *mocktransport_Do_Call { | |
| 154 | - | _c.Call.Return(run) | |
| 155 | - | return _c | |
| 156 | - | } | |
M internal/adapters/config/config.go +42 -39
| @@ -166,7 +166,9 @@ func Defaults() ports.Config { | |||
| 166 | 166 | c.Terminal.AutoOpen = "first" | |
| 167 | 167 | // Hint mode is experimental: off unless the config turns it on. | |
| 168 | 168 | c.Hint.Enable = false | |
| 169 | - | c.Hint.TextClick = ports.ModCtrl | ports.ModShift | |
| 169 | + | c.Hint.Detector = defaultHintDetector | |
| 170 | + | c.Hint.Model = defaultHintModel() | |
| 171 | + | c.Hint.Threshold = defaultHintThreshold | |
| 170 | 172 | c.Xwayland = "xwayland-satellite" | |
| 171 | 173 | c.Background.Color = "#111111" | |
| 172 | 174 | c.Floating.Dim = 0.3 | |
| @@ -895,21 +897,23 @@ func set(c *ports.Config, key, v string) error { | |||
| 895 | 897 | } else { | |
| 896 | 898 | c.Hint.Foreground = v | |
| 897 | 899 | } | |
| 898 | - | case "hint.targets": | |
| 899 | - | switch v { | |
| 900 | - | case "clickable": | |
| 901 | - | c.Hint.LinksOnly = false | |
| 902 | - | case "links": | |
| 903 | - | c.Hint.LinksOnly = true | |
| 904 | - | default: | |
| 905 | - | return fmt.Errorf("must be clickable or links") | |
| 900 | + | case "hint.detector": | |
| 901 | + | if v == "" { | |
| 902 | + | v = defaultHintDetector | |
| 906 | 903 | } | |
| 907 | - | case "hint.text-click": | |
| 908 | - | m, err := parseTextClick(v) | |
| 909 | - | if err != nil { | |
| 910 | - | return err | |
| 904 | + | c.Hint.Detector = expandHome(v) | |
| 905 | + | case "hint.model": | |
| 906 | + | if v == "" { | |
| 907 | + | c.Hint.Model = defaultHintModel() | |
| 908 | + | break | |
| 911 | 909 | } | |
| 912 | - | c.Hint.TextClick = m | |
| 910 | + | c.Hint.Model = expandHome(v) | |
| 911 | + | case "hint.threshold": | |
| 912 | + | n, err := strconv.ParseFloat(v, 64) | |
| 913 | + | if err != nil || math.IsNaN(n) || n < 0.01 || n > 0.99 { | |
| 914 | + | return fmt.Errorf("must be a number from 0.01 to 0.99") | |
| 915 | + | } | |
| 916 | + | c.Hint.Threshold = n | |
| 913 | 917 | case "focus.flash-width": | |
| 914 | 918 | n, err := strconv.Atoi(v) | |
| 915 | 919 | if err != nil || n < 1 || n > 8 { | |
| @@ -1333,34 +1337,33 @@ func checkAction(v string) error { | |||
| 1333 | 1337 | return nil | |
| 1334 | 1338 | } | |
| 1335 | 1339 | ||
| 1336 | - | // parseTextClick parses hint.text-click: `+`-separated modifier names as in | |
| 1337 | - | // binds, or none. cmd is an alias of super here (it is not resolved through | |
| 1338 | - | // keyboard.cmd, so the result does not depend on key order). | |
| 1339 | - | func parseTextClick(v string) (ports.Mods, error) { | |
| 1340 | - | if strings.ToLower(v) == "none" { | |
| 1341 | - | return 0, nil | |
| 1342 | - | } | |
| 1343 | - | var mods ports.Mods | |
| 1344 | - | for _, p := range strings.Split(v, "+") { | |
| 1345 | - | var m ports.Mods | |
| 1346 | - | switch strings.ToLower(strings.TrimSpace(p)) { | |
| 1347 | - | case "shift": | |
| 1348 | - | m = ports.ModShift | |
| 1349 | - | case "ctrl": | |
| 1350 | - | m = ports.ModCtrl | |
| 1351 | - | case "alt": | |
| 1352 | - | m = ports.ModAlt | |
| 1353 | - | case "super", "cmd": | |
| 1354 | - | m = ports.ModSuper | |
| 1355 | - | default: | |
| 1356 | - | return 0, fmt.Errorf("unknown modifier %q (use shift, ctrl, alt, super or cmd joined by +, or none)", p) | |
| 1340 | + | const ( | |
| 1341 | + | defaultHintDetector = "nefer-hints" | |
| 1342 | + | defaultHintThreshold = 0.05 | |
| 1343 | + | ) | |
| 1344 | + | ||
| 1345 | + | // defaultHintModel is $XDG_DATA_HOME/nefer-hints/models, or | |
| 1346 | + | // ~/.local/share/nefer-hints/models when XDG_DATA_HOME is unset. | |
| 1347 | + | func defaultHintModel() string { | |
| 1348 | + | base := os.Getenv("XDG_DATA_HOME") | |
| 1349 | + | if base == "" { | |
| 1350 | + | home, err := os.UserHomeDir() | |
| 1351 | + | if err != nil { | |
| 1352 | + | return "" | |
| 1357 | 1353 | } | |
| 1358 | - | if mods&m != 0 { | |
| 1359 | - | return 0, fmt.Errorf("duplicate modifier %q", p) | |
| 1354 | + | base = filepath.Join(home, ".local", "share") | |
| 1355 | + | } | |
| 1356 | + | return filepath.Join(base, "nefer-hints", "models") | |
| 1357 | + | } | |
| 1358 | + | ||
| 1359 | + | // expandHome replaces a leading ~/ with the home directory. | |
| 1360 | + | func expandHome(p string) string { | |
| 1361 | + | if rest, ok := strings.CutPrefix(p, "~/"); ok { | |
| 1362 | + | if home, err := os.UserHomeDir(); err == nil { | |
| 1363 | + | return filepath.Join(home, rest) | |
| 1360 | 1364 | } | |
| 1361 | - | mods |= m | |
| 1362 | 1365 | } | |
| 1363 | - | return mods, nil | |
| 1366 | + | return p | |
| 1364 | 1367 | } | |
| 1365 | 1368 | ||
| 1366 | 1369 | // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`: | |
M internal/adapters/config/config_test.go +41 -41
| @@ -954,26 +954,51 @@ func TestLoadRejectsFIFO(t *testing.T) { | |||
| 954 | 954 | } | |
| 955 | 955 | } | |
| 956 | 956 | ||
| 957 | - | func TestHintTargets(t *testing.T) { | |
| 958 | - | if Defaults().Hint.LinksOnly { | |
| 959 | - | t.Fatal("hint targets default must be clickable") | |
| 957 | + | func TestHintBind(t *testing.T) { | |
| 958 | + | c, w := parseString(t, "bind.cmd+g = hint\n") | |
| 959 | + | if len(w) != 0 || c.Binds["Cmd+g"] != "hint" { | |
| 960 | + | t.Fatal(c.Binds["Cmd+g"], w) | |
| 960 | 961 | } | |
| 961 | - | c, w := parseString(t, "hint.targets = links\n") | |
| 962 | - | if !c.Hint.LinksOnly || len(w) != 0 { | |
| 963 | - | t.Fatal(c.Hint, w) | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | func TestHintRemovedKeysAreUnknown(t *testing.T) { | |
| 965 | + | for _, line := range []string{"hint.targets = links", "hint.text-click = none"} { | |
| 966 | + | _, w := parseString(t, line+"\n") | |
| 967 | + | if len(w) != 1 { | |
| 968 | + | t.Errorf("%q: %v", line, w) | |
| 969 | + | } | |
| 964 | 970 | } | |
| 965 | - | c, w = parseString(t, "hint.targets = clickable\n") | |
| 966 | - | if c.Hint.LinksOnly || len(w) != 0 { | |
| 967 | - | t.Fatal(c.Hint, w) | |
| 971 | + | } | |
| 972 | + | ||
| 973 | + | func TestHintDetectorConfig(t *testing.T) { | |
| 974 | + | t.Setenv("HOME", "/home/u") | |
| 975 | + | t.Setenv("XDG_DATA_HOME", "") | |
| 976 | + | d := Defaults().Hint | |
| 977 | + | if d.Detector != "nefer-hints" || d.Model != "/home/u/.local/share/nefer-hints/models" || d.Threshold != 0.05 { | |
| 978 | + | t.Fatalf("defaults: %+v", d) | |
| 968 | 979 | } | |
| 969 | - | // An invalid value warns and keeps the default. | |
| 970 | - | c, w = parseString(t, "hint.targets = buttons\n") | |
| 971 | - | if c.Hint.LinksOnly || len(w) != 1 { | |
| 972 | - | t.Fatal(c.Hint, w) | |
| 980 | + | t.Setenv("XDG_DATA_HOME", "/data") | |
| 981 | + | if m := Defaults().Hint.Model; m != "/data/nefer-hints/models" { | |
| 982 | + | t.Fatal(m) | |
| 973 | 983 | } | |
| 974 | - | c, w = parseString(t, "bind.cmd+g = hint\n") | |
| 975 | - | if len(w) != 0 || c.Binds["Cmd+g"] != "hint" { | |
| 976 | - | t.Fatal(c.Binds["Cmd+g"], w) | |
| 984 | + | c, w := parseString(t, "hint.detector = ~/bin/nh\nhint.model = ~/m\nhint.threshold = 0.3\n") | |
| 985 | + | if len(w) != 0 || c.Hint.Detector != "/home/u/bin/nh" || c.Hint.Model != "/home/u/m" || c.Hint.Threshold != 0.3 { | |
| 986 | + | t.Fatalf("%+v %v", c.Hint, w) | |
| 987 | + | } | |
| 988 | + | c, w = parseString(t, "hint.detector = /opt/nh\nhint.model = /opt/models\n") | |
| 989 | + | if len(w) != 0 || c.Hint.Detector != "/opt/nh" || c.Hint.Model != "/opt/models" { | |
| 990 | + | t.Fatalf("%+v %v", c.Hint, w) | |
| 991 | + | } | |
| 992 | + | for _, v := range []string{"0", "0.009", "1", "0.995", "NaN", "high", "-1"} { | |
| 993 | + | c, w := parseString(t, "hint.threshold = "+v+"\n") | |
| 994 | + | if len(w) != 1 || c.Hint.Threshold != 0.05 { | |
| 995 | + | t.Errorf("hint.threshold = %s: %v %v", v, c.Hint.Threshold, w) | |
| 996 | + | } | |
| 997 | + | } | |
| 998 | + | for _, v := range []string{"0.01", "0.99"} { | |
| 999 | + | if c, w := parseString(t, "hint.threshold = "+v+"\n"); len(w) != 0 || c.Hint.Threshold == 0.05 { | |
| 1000 | + | t.Errorf("hint.threshold = %s: %v", v, w) | |
| 1001 | + | } | |
| 977 | 1002 | } | |
| 978 | 1003 | } | |
| 979 | 1004 | ||
| @@ -1021,31 +1046,6 @@ func TestHintSizeBounds(t *testing.T) { | |||
| 1021 | 1046 | } | |
| 1022 | 1047 | } | |
| 1023 | 1048 | ||
| 1024 | - | func TestHintTextClick(t *testing.T) { | |
| 1025 | - | if got := Defaults().Hint.TextClick; got != ports.ModCtrl|ports.ModShift { | |
| 1026 | - | t.Fatalf("default %d", got) | |
| 1027 | - | } | |
| 1028 | - | for in, want := range map[string]ports.Mods{ | |
| 1029 | - | "ctrl+shift": ports.ModCtrl | ports.ModShift, | |
| 1030 | - | "Alt": ports.ModAlt, | |
| 1031 | - | "super": ports.ModSuper, | |
| 1032 | - | "cmd+shift": ports.ModSuper | ports.ModShift, | |
| 1033 | - | "none": 0, | |
| 1034 | - | "shift+ctrl+alt": ports.ModShift | ports.ModCtrl | ports.ModAlt, | |
| 1035 | - | } { | |
| 1036 | - | c, w := parseString(t, "hint.text-click = "+in+"\n") | |
| 1037 | - | if c.Hint.TextClick != want || len(w) != 0 { | |
| 1038 | - | t.Errorf("%q: %d %v", in, c.Hint.TextClick, w) | |
| 1039 | - | } | |
| 1040 | - | } | |
| 1041 | - | for _, bad := range []string{"hyper", "ctrl+", "ctrl+ctrl", "ctrl+none", ""} { | |
| 1042 | - | c, w := parseString(t, "hint.text-click = "+bad+"\n") | |
| 1043 | - | if len(w) != 1 || c.Hint.TextClick != ports.ModCtrl|ports.ModShift { | |
| 1044 | - | t.Errorf("%q: %d %v", bad, c.Hint.TextClick, w) | |
| 1045 | - | } | |
| 1046 | - | } | |
| 1047 | - | } | |
| 1048 | - | ||
| 1049 | 1049 | func TestHintEnable(t *testing.T) { | |
| 1050 | 1050 | if Defaults().Hint.Enable { | |
| 1051 | 1051 | t.Fatal("hint mode must be off by default") | |
D internal/adapters/hints/benchmark_test.go +0 -81
| @@ -1,81 +0,0 @@ | |||
| 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 | - | "strconv" | |
| 12 | - | "testing" | |
| 13 | - | "time" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // TestFrameBenchmark logs counts and timings, never filenames or OCR text. | |
| 17 | - | func TestFrameBenchmark(t *testing.T) { | |
| 18 | - | dir := os.Getenv("NEFERWL_HINT_BENCH_DIR") | |
| 19 | - | if dir == "" { | |
| 20 | - | t.Skip("set NEFERWL_HINT_BENCH_DIR to a local PNG directory") | |
| 21 | - | } | |
| 22 | - | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 23 | - | t.Skip("tesseract not on PATH") | |
| 24 | - | } | |
| 25 | - | files, err := filepath.Glob(filepath.Join(dir, "*.png")) | |
| 26 | - | if err != nil || len(files) == 0 { | |
| 27 | - | t.Fatal("no PNG frames") | |
| 28 | - | } | |
| 29 | - | // Settings under test: NEFERWL_HINT_BENCH_WORKERS=<n>, NEFERWL_HINT_BENCH_PNG=speed|none. | |
| 30 | - | if v, _ := strconv.Atoi(os.Getenv("NEFERWL_HINT_BENCH_WORKERS")); v > 0 { | |
| 31 | - | ocrWorkers = v | |
| 32 | - | } | |
| 33 | - | if os.Getenv("NEFERWL_HINT_BENCH_PGM") == "0" { | |
| 34 | - | rawPGM = false | |
| 35 | - | } | |
| 36 | - | if os.Getenv("NEFERWL_HINT_BENCH_PNG") == "none" { | |
| 37 | - | pngLevel = png.NoCompression | |
| 38 | - | } | |
| 39 | - | for i, file := range files { | |
| 40 | - | f, err := os.Open(file) | |
| 41 | - | if err != nil { | |
| 42 | - | t.Fatal("open frame failed") | |
| 43 | - | } | |
| 44 | - | src, err := png.Decode(f) | |
| 45 | - | _ = f.Close() | |
| 46 | - | if err != nil { | |
| 47 | - | t.Fatal("decode frame failed") | |
| 48 | - | } | |
| 49 | - | img := image.NewRGBA(image.Rect(0, 0, src.Bounds().Dx(), src.Bounds().Dy())) | |
| 50 | - | draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src) | |
| 51 | - | start := time.Now() | |
| 52 | - | words, bands, err := readWords(context.Background(), toGray(img)) | |
| 53 | - | if err != nil { | |
| 54 | - | t.Fatal("recognition failed") | |
| 55 | - | } | |
| 56 | - | 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) | |
| 57 | - | if os.Getenv("NEFERWL_HINT_BENCH_STRIPS") == "" { | |
| 58 | - | continue | |
| 59 | - | } | |
| 60 | - | // Experimental strips use known OCR word regions, not pixel targets. | |
| 61 | - | // This is an oracle comparison, not a production optimization. | |
| 62 | - | lines := make([]image.Rectangle, 0, len(words)) | |
| 63 | - | for _, word := range words { | |
| 64 | - | lines = append(lines, word.Rect) | |
| 65 | - | } | |
| 66 | - | for _, normalized := range []bool{false, true} { | |
| 67 | - | begin := time.Now() | |
| 68 | - | compact, _, err := readStrips(context.Background(), img, lines, 8, normalized) | |
| 69 | - | if err != nil { | |
| 70 | - | t.Fatal("strip recognition failed") | |
| 71 | - | } | |
| 72 | - | 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))) | |
| 73 | - | } | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func toGray(img *image.RGBA) *image.Gray { | |
| 78 | - | g := image.NewGray(img.Bounds()) | |
| 79 | - | draw.Draw(g, g.Bounds(), img, img.Bounds().Min, draw.Src) | |
| 80 | - | return g | |
| 81 | - | } | |
A internal/adapters/hints/detector.go +352 -0
| @@ -0,0 +1,352 @@ | |||
| 1 | + | package hints | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "context" | |
| 7 | + | "encoding/binary" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "math" | |
| 12 | + | "os/exec" | |
| 13 | + | "strconv" | |
| 14 | + | "sync" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // detFrame is a captured output as the detector receives it: BGRA pixels in | |
| 21 | + | // m, valid only during the Detect call. It holds the mapping, not the bytes, | |
| 22 | + | // so that recording a call (mocks print their arguments) never reads memory | |
| 23 | + | // that was unmapped since. | |
| 24 | + | type detFrame struct { | |
| 25 | + | width, height, stride int | |
| 26 | + | m *mapping | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // detection is one box found by the detector, in frame (physical) pixels. | |
| 30 | + | type detection struct { | |
| 31 | + | x1, y1, x2, y2, score float32 | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // detector finds clickable elements in a frame. The worker goroutine is its | |
| 35 | + | // only caller. | |
| 36 | + | type detector interface { | |
| 37 | + | // Detect returns the boxes found in f. An error is for this call only: | |
| 38 | + | // the detector recovers by itself on the next one. | |
| 39 | + | Detect(ctx context.Context, f detFrame) ([]detection, error) | |
| 40 | + | // Close releases the detector. It is called once, by the worker | |
| 41 | + | // goroutine, when the worker returns. | |
| 42 | + | Close() error | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | const ( | |
| 46 | + | protocolVersion = 1 | |
| 47 | + | helloTimeout = 10 * time.Second | |
| 48 | + | requestTimeout = 2 * time.Second | |
| 49 | + | imgsz = 1280 | |
| 50 | + | // maxString and maxBoxes bound what a misbehaving child can make us | |
| 51 | + | // allocate. | |
| 52 | + | maxString = 1 << 16 | |
| 53 | + | maxBoxes = 1 << 20 | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | var ( | |
| 57 | + | magicHello = [4]byte{'N', 'H', 'N', 'T'} | |
| 58 | + | magicRequest = [4]byte{'N', 'H', 'R', 'Q'} | |
| 59 | + | magicResponse = [4]byte{'N', 'H', 'R', 'S'} | |
| 60 | + | ||
| 61 | + | // errProtocol is a framing error: the stream cannot be trusted any more. | |
| 62 | + | errProtocol = errors.New("hints: detector protocol error") | |
| 63 | + | // errDetector is an error the detector reported itself; it keeps serving. | |
| 64 | + | errDetector = errors.New("hints: detector error") | |
| 65 | + | ) | |
| 66 | + | ||
| 67 | + | var le = binary.LittleEndian | |
| 68 | + | ||
| 69 | + | // writeRequest writes one whole-frame request (no region, default | |
| 70 | + | // confidence) followed by the pixels. | |
| 71 | + | func writeRequest(w io.Writer, seq uint64, f detFrame) error { | |
| 72 | + | var h [4 + 4 + 8 + 5*4 + 4]byte | |
| 73 | + | copy(h[0:], magicRequest[:]) | |
| 74 | + | le.PutUint32(h[4:], protocolVersion) | |
| 75 | + | le.PutUint64(h[8:], seq) | |
| 76 | + | le.PutUint32(h[16:], uint32(f.width)) | |
| 77 | + | le.PutUint32(h[20:], uint32(f.height)) | |
| 78 | + | le.PutUint32(h[24:], uint32(f.stride)) | |
| 79 | + | le.PutUint32(h[28:], formatXRGB8888) | |
| 80 | + | le.PutUint32(h[32:], 0) // nregions | |
| 81 | + | le.PutUint32(h[36:], math.Float32bits(0)) // confidence: startup default | |
| 82 | + | if _, err := w.Write(h[:]); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | _, err := w.Write(f.m.b[:f.height*f.stride]) | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func readMagic(r io.Reader, want [4]byte) error { | |
| 90 | + | var m [4]byte | |
| 91 | + | if _, err := io.ReadFull(r, m[:]); err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | if m != want { | |
| 95 | + | return fmt.Errorf("%w: bad magic %q", errProtocol, m[:]) | |
| 96 | + | } | |
| 97 | + | return nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func readU32(r io.Reader) (uint32, error) { | |
| 101 | + | var b [4]byte | |
| 102 | + | _, err := io.ReadFull(r, b[:]) | |
| 103 | + | return le.Uint32(b[:]), err | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func readString(r io.Reader) (string, error) { | |
| 107 | + | n, err := readU32(r) | |
| 108 | + | if err != nil { | |
| 109 | + | return "", err | |
| 110 | + | } | |
| 111 | + | if n > maxString { | |
| 112 | + | return "", fmt.Errorf("%w: string of %d bytes", errProtocol, n) | |
| 113 | + | } | |
| 114 | + | b := make([]byte, n) | |
| 115 | + | _, err = io.ReadFull(r, b) | |
| 116 | + | return string(b), err | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // readHello reads the hello: the device name, or the error of a failed start. | |
| 120 | + | func readHello(r io.Reader) (device string, err error) { | |
| 121 | + | if err := readMagic(r, magicHello); err != nil { | |
| 122 | + | return "", err | |
| 123 | + | } | |
| 124 | + | version, err := readU32(r) | |
| 125 | + | if err != nil { | |
| 126 | + | return "", err | |
| 127 | + | } | |
| 128 | + | if version != protocolVersion { | |
| 129 | + | return "", fmt.Errorf("%w: version %d", errProtocol, version) | |
| 130 | + | } | |
| 131 | + | status, err := readU32(r) | |
| 132 | + | if err != nil { | |
| 133 | + | return "", err | |
| 134 | + | } | |
| 135 | + | msg, err := readString(r) | |
| 136 | + | if err != nil { | |
| 137 | + | return "", err | |
| 138 | + | } | |
| 139 | + | if status != 0 { | |
| 140 | + | return "", fmt.Errorf("%w: start failed: %s", errDetector, msg) | |
| 141 | + | } | |
| 142 | + | if _, err := readU32(r); err != nil { // imgsz | |
| 143 | + | return "", err | |
| 144 | + | } | |
| 145 | + | return msg, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // readResponse reads the response to request seq. | |
| 149 | + | func readResponse(r io.Reader, seq uint64) ([]detection, error) { | |
| 150 | + | if err := readMagic(r, magicResponse); err != nil { | |
| 151 | + | return nil, err | |
| 152 | + | } | |
| 153 | + | var h [16]byte | |
| 154 | + | if _, err := io.ReadFull(r, h[:]); err != nil { | |
| 155 | + | return nil, err | |
| 156 | + | } | |
| 157 | + | if v := le.Uint32(h[0:]); v != protocolVersion { | |
| 158 | + | return nil, fmt.Errorf("%w: version %d", errProtocol, v) | |
| 159 | + | } | |
| 160 | + | if got := le.Uint64(h[4:]); got != seq { | |
| 161 | + | return nil, fmt.Errorf("%w: response %d to request %d", errProtocol, got, seq) | |
| 162 | + | } | |
| 163 | + | if status := le.Uint32(h[12:]); status != 0 { | |
| 164 | + | msg, err := readString(r) | |
| 165 | + | if err != nil { | |
| 166 | + | return nil, err | |
| 167 | + | } | |
| 168 | + | return nil, fmt.Errorf("%w: status %d: %s", errDetector, status, msg) | |
| 169 | + | } | |
| 170 | + | var t [8]byte // inference ms, nboxes | |
| 171 | + | if _, err := io.ReadFull(r, t[:]); err != nil { | |
| 172 | + | return nil, err | |
| 173 | + | } | |
| 174 | + | n := le.Uint32(t[4:]) | |
| 175 | + | if n > maxBoxes { | |
| 176 | + | return nil, fmt.Errorf("%w: %d boxes", errProtocol, n) | |
| 177 | + | } | |
| 178 | + | out := make([]detection, 0, min(n, 4096)) | |
| 179 | + | var rec [24]byte | |
| 180 | + | for range n { | |
| 181 | + | if _, err := io.ReadFull(r, rec[:]); err != nil { | |
| 182 | + | return nil, err | |
| 183 | + | } | |
| 184 | + | f := func(i int) float32 { return math.Float32frombits(le.Uint32(rec[i*4:])) } | |
| 185 | + | out = append(out, detection{x1: f(0), y1: f(1), x2: f(2), y2: f(3), score: f(4)}) | |
| 186 | + | } | |
| 187 | + | return out, nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // procDetector runs the nefer-hints binary as a persistent child. It starts | |
| 191 | + | // on the first Detect, and again after the child died or misbehaved. | |
| 192 | + | type procDetector struct { | |
| 193 | + | command, model string | |
| 194 | + | conf float64 | |
| 195 | + | helloTimeout time.Duration | |
| 196 | + | reqTimeout time.Duration | |
| 197 | + | ||
| 198 | + | proc *process | |
| 199 | + | seq uint64 | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func newProcDetector(command, model string, threshold float64) *procDetector { | |
| 203 | + | return &procDetector{command: command, model: model, conf: threshold, helloTimeout: helloTimeout, reqTimeout: requestTimeout} | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // process is one running child. | |
| 207 | + | type process struct { | |
| 208 | + | cmd *exec.Cmd | |
| 209 | + | in io.WriteCloser | |
| 210 | + | out *bufio.Reader | |
| 211 | + | once sync.Once | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // kill stops the child and reaps it; safe to call more than once. | |
| 215 | + | func (p *process) kill() { | |
| 216 | + | p.once.Do(func() { | |
| 217 | + | _ = p.cmd.Process.Kill() | |
| 218 | + | _ = p.in.Close() | |
| 219 | + | _ = p.cmd.Wait() | |
| 220 | + | }) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // interrupt kills the child without waiting: it unblocks a pending read or | |
| 224 | + | // write from a timer or a context callback. kill reaps it afterwards. | |
| 225 | + | func (p *process) interrupt() { _ = p.cmd.Process.Kill() } | |
| 226 | + | ||
| 227 | + | func (d *procDetector) start(ctx context.Context) error { | |
| 228 | + | log := logging.For(ctx, "hints") | |
| 229 | + | cmd := exec.Command(d.command, "--model", d.model, "--imgsz", strconv.Itoa(imgsz), | |
| 230 | + | "--conf", strconv.FormatFloat(d.conf, 'f', -1, 64), "--gpu", "auto") | |
| 231 | + | cmd.Stderr = &lineLog{log: func(line string) { log.Debug().Str("detector", d.command).Msg(line) }} | |
| 232 | + | in, err := cmd.StdinPipe() | |
| 233 | + | if err != nil { | |
| 234 | + | return err | |
| 235 | + | } | |
| 236 | + | out, err := cmd.StdoutPipe() | |
| 237 | + | if err != nil { | |
| 238 | + | return err | |
| 239 | + | } | |
| 240 | + | if err := cmd.Start(); err != nil { | |
| 241 | + | return fmt.Errorf("hints: start detector: %w", err) | |
| 242 | + | } | |
| 243 | + | p := &process{cmd: cmd, in: in, out: bufio.NewReaderSize(out, 1<<16)} | |
| 244 | + | t0 := time.Now() | |
| 245 | + | timer := time.AfterFunc(d.helloTimeout, p.interrupt) | |
| 246 | + | stop := context.AfterFunc(ctx, p.interrupt) | |
| 247 | + | device, err := readHello(p.out) | |
| 248 | + | stop() | |
| 249 | + | timedOut := !timer.Stop() | |
| 250 | + | if err != nil { | |
| 251 | + | p.kill() | |
| 252 | + | if timedOut { | |
| 253 | + | err = errors.New("hello timed out") | |
| 254 | + | } | |
| 255 | + | return fmt.Errorf("hints: detector hello: %w", err) | |
| 256 | + | } | |
| 257 | + | d.proc = p | |
| 258 | + | log.Info().Str("device", device).Float64("start_ms", ms(time.Since(t0))).Msg("hint detector ready") | |
| 259 | + | return nil | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (d *procDetector) Detect(ctx context.Context, f detFrame) ([]detection, error) { | |
| 263 | + | if d.proc == nil { | |
| 264 | + | if err := d.start(ctx); err != nil { | |
| 265 | + | return nil, err | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | p := d.proc | |
| 269 | + | d.seq++ | |
| 270 | + | timer := time.AfterFunc(d.reqTimeout, p.interrupt) | |
| 271 | + | stop := context.AfterFunc(ctx, p.interrupt) | |
| 272 | + | dets, err := d.roundTrip(p, f) | |
| 273 | + | stop() | |
| 274 | + | timedOut := !timer.Stop() | |
| 275 | + | if err == nil { | |
| 276 | + | return dets, nil | |
| 277 | + | } | |
| 278 | + | if errors.Is(err, errDetector) { | |
| 279 | + | return nil, err // the child answered and keeps serving | |
| 280 | + | } | |
| 281 | + | d.drop() | |
| 282 | + | switch { | |
| 283 | + | case timedOut: | |
| 284 | + | return nil, fmt.Errorf("hints: detector timed out after %s", d.reqTimeout) | |
| 285 | + | case ctx.Err() != nil: | |
| 286 | + | return nil, ctx.Err() | |
| 287 | + | } | |
| 288 | + | return nil, fmt.Errorf("hints: detector failed: %w", err) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (d *procDetector) roundTrip(p *process, f detFrame) ([]detection, error) { | |
| 292 | + | bw := bufio.NewWriterSize(p.in, 1<<16) | |
| 293 | + | if err := writeRequest(bw, d.seq, f); err != nil { | |
| 294 | + | return nil, err | |
| 295 | + | } | |
| 296 | + | if err := bw.Flush(); err != nil { | |
| 297 | + | return nil, err | |
| 298 | + | } | |
| 299 | + | return readResponse(p.out, d.seq) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // drop kills the current child: the next Detect starts a new one. | |
| 303 | + | func (d *procDetector) drop() { | |
| 304 | + | if d.proc != nil { | |
| 305 | + | d.proc.kill() | |
| 306 | + | d.proc = nil | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // Close asks the child to stop by closing its stdin (it exits 0 on EOF), then | |
| 311 | + | // kills it if it does not. | |
| 312 | + | func (d *procDetector) Close() error { | |
| 313 | + | p := d.proc | |
| 314 | + | d.proc = nil | |
| 315 | + | if p == nil { | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | _ = p.in.Close() | |
| 319 | + | done := make(chan struct{}) | |
| 320 | + | go func() { _ = p.cmd.Wait(); close(done) }() | |
| 321 | + | select { | |
| 322 | + | case <-done: | |
| 323 | + | case <-time.After(time.Second): | |
| 324 | + | _ = p.cmd.Process.Kill() | |
| 325 | + | <-done | |
| 326 | + | } | |
| 327 | + | return nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // lineLog is an io.Writer that hands each complete line to log. | |
| 331 | + | type lineLog struct { | |
| 332 | + | log func(string) | |
| 333 | + | buf []byte | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (l *lineLog) Write(b []byte) (int, error) { | |
| 337 | + | l.buf = append(l.buf, b...) | |
| 338 | + | for { | |
| 339 | + | i := bytes.IndexByte(l.buf, '\n') | |
| 340 | + | if i < 0 { | |
| 341 | + | break | |
| 342 | + | } | |
| 343 | + | if line := string(l.buf[:i]); line != "" { | |
| 344 | + | l.log(line) | |
| 345 | + | } | |
| 346 | + | l.buf = l.buf[i+1:] | |
| 347 | + | } | |
| 348 | + | if len(l.buf) > maxString { | |
| 349 | + | l.buf = l.buf[:0] | |
| 350 | + | } | |
| 351 | + | return len(b), nil | |
| 352 | + | } | |
A internal/adapters/hints/detector_mock_test.go +159 -0
| @@ -0,0 +1,159 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package hints | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // newMockdetector creates a new instance of mockdetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func newMockdetector(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *mockdetector { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &mockdetector{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { | |
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | + | helper.Helper() | |
| 29 | + | } | |
| 30 | + | mock.AssertExpectations(t) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | return mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // mockdetector is an autogenerated mock type for the detector type | |
| 37 | + | type mockdetector struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type mockdetector_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *mockdetector) EXPECT() *mockdetector_Expecter { | |
| 46 | + | return &mockdetector_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Close provides a mock function for the type mockdetector | |
| 50 | + | func (_mock *mockdetector) Close() error { | |
| 51 | + | ret := _mock.Called() | |
| 52 | + | ||
| 53 | + | if len(ret) == 0 { | |
| 54 | + | panic("no return value specified for Close") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var r0 error | |
| 58 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 59 | + | r0 = returnFunc() | |
| 60 | + | } else { | |
| 61 | + | r0 = ret.Error(0) | |
| 62 | + | } | |
| 63 | + | return r0 | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // mockdetector_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |
| 67 | + | type mockdetector_Close_Call struct { | |
| 68 | + | *mock.Call | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Close is a helper method to define mock.On call | |
| 72 | + | func (_e *mockdetector_Expecter) Close() *mockdetector_Close_Call { | |
| 73 | + | return &mockdetector_Close_Call{Call: _e.mock.On("Close")} | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (_c *mockdetector_Close_Call) Run(run func()) *mockdetector_Close_Call { | |
| 77 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 78 | + | run() | |
| 79 | + | }) | |
| 80 | + | return _c | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (_c *mockdetector_Close_Call) Return(err error) *mockdetector_Close_Call { | |
| 84 | + | _c.Call.Return(err) | |
| 85 | + | return _c | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *mockdetector_Close_Call) RunAndReturn(run func() error) *mockdetector_Close_Call { | |
| 89 | + | _c.Call.Return(run) | |
| 90 | + | return _c | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Detect provides a mock function for the type mockdetector | |
| 94 | + | func (_mock *mockdetector) Detect(ctx context.Context, f detFrame) ([]detection, error) { | |
| 95 | + | ret := _mock.Called(ctx, f) | |
| 96 | + | ||
| 97 | + | if len(ret) == 0 { | |
| 98 | + | panic("no return value specified for Detect") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | var r0 []detection | |
| 102 | + | var r1 error | |
| 103 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, detFrame) ([]detection, error)); ok { | |
| 104 | + | return returnFunc(ctx, f) | |
| 105 | + | } | |
| 106 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, detFrame) []detection); ok { | |
| 107 | + | r0 = returnFunc(ctx, f) | |
| 108 | + | } else { | |
| 109 | + | if ret.Get(0) != nil { | |
| 110 | + | r0 = ret.Get(0).([]detection) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, detFrame) error); ok { | |
| 114 | + | r1 = returnFunc(ctx, f) | |
| 115 | + | } else { | |
| 116 | + | r1 = ret.Error(1) | |
| 117 | + | } | |
| 118 | + | return r0, r1 | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // mockdetector_Detect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detect' | |
| 122 | + | type mockdetector_Detect_Call struct { | |
| 123 | + | *mock.Call | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Detect is a helper method to define mock.On call | |
| 127 | + | // - ctx context.Context | |
| 128 | + | // - f detFrame | |
| 129 | + | func (_e *mockdetector_Expecter) Detect(ctx any, f any) *mockdetector_Detect_Call { | |
| 130 | + | return &mockdetector_Detect_Call{Call: _e.mock.On("Detect", ctx, f)} | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (_c *mockdetector_Detect_Call) Run(run func(ctx context.Context, f detFrame)) *mockdetector_Detect_Call { | |
| 134 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 135 | + | var arg0 context.Context | |
| 136 | + | if args[0] != nil { | |
| 137 | + | arg0 = args[0].(context.Context) | |
| 138 | + | } | |
| 139 | + | var arg1 detFrame | |
| 140 | + | if args[1] != nil { | |
| 141 | + | arg1 = args[1].(detFrame) | |
| 142 | + | } | |
| 143 | + | run( | |
| 144 | + | arg0, | |
| 145 | + | arg1, | |
| 146 | + | ) | |
| 147 | + | }) | |
| 148 | + | return _c | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (_c *mockdetector_Detect_Call) Return(detectionMoqParams []detection, err error) *mockdetector_Detect_Call { | |
| 152 | + | _c.Call.Return(detectionMoqParams, err) | |
| 153 | + | return _c | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (_c *mockdetector_Detect_Call) RunAndReturn(run func(ctx context.Context, f detFrame) ([]detection, error)) *mockdetector_Detect_Call { | |
| 157 | + | _c.Call.Return(run) | |
| 158 | + | return _c | |
| 159 | + | } | |
A internal/adapters/hints/detector_test.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package hints | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "math" | |
| 8 | + | "os" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // u32, u64 and f32 build the byte fixtures of the protocol. | |
| 16 | + | func u32(v uint32) []byte { return binary.LittleEndian.AppendUint32(nil, v) } | |
| 17 | + | func u64(v uint64) []byte { return binary.LittleEndian.AppendUint64(nil, v) } | |
| 18 | + | func f32(v float32) []byte { return u32(math.Float32bits(v)) } | |
| 19 | + | func cat(parts ...[]byte) []byte { | |
| 20 | + | return bytes.Join(parts, nil) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestWriteRequest(t *testing.T) { | |
| 24 | + | var buf bytes.Buffer | |
| 25 | + | pix := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} | |
| 26 | + | require.NoError(t, writeRequest(&buf, 0x0102030405, detFrame{width: 2, height: 1, stride: 12, m: &mapping{b: append(pix, 99)}})) | |
| 27 | + | want := cat([]byte("NHRQ"), u32(1), u64(0x0102030405), u32(2), u32(1), u32(12), u32(1), u32(0), f32(0), pix) | |
| 28 | + | require.Equal(t, want, buf.Bytes()) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestReadHello(t *testing.T) { | |
| 32 | + | dev, err := readHello(bytes.NewReader(cat([]byte("NHNT"), u32(1), u32(0), u32(6), []byte("Vulkan"), u32(1280)))) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, "Vulkan", dev) | |
| 35 | + | ||
| 36 | + | _, err = readHello(bytes.NewReader(cat([]byte("NHNT"), u32(1), u32(1), u32(4), []byte("boom")))) | |
| 37 | + | require.ErrorIs(t, err, errDetector) | |
| 38 | + | require.ErrorContains(t, err, "boom") | |
| 39 | + | ||
| 40 | + | _, err = readHello(bytes.NewReader(cat([]byte("XXXX"), u32(1)))) | |
| 41 | + | require.ErrorIs(t, err, errProtocol) | |
| 42 | + | _, err = readHello(bytes.NewReader(cat([]byte("NHNT"), u32(2), u32(0)))) | |
| 43 | + | require.ErrorIs(t, err, errProtocol) | |
| 44 | + | _, err = readHello(bytes.NewReader(cat([]byte("NHNT"), u32(1), u32(0), u32(1<<20)))) | |
| 45 | + | require.ErrorIs(t, err, errProtocol) | |
| 46 | + | _, err = readHello(bytes.NewReader([]byte("NHNT"))) | |
| 47 | + | require.Error(t, err) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestReadResponse(t *testing.T) { | |
| 51 | + | head := func(seq uint64, status uint32) []byte { return cat([]byte("NHRS"), u32(1), u64(seq), u32(status)) } | |
| 52 | + | ||
| 53 | + | ok := cat(head(5, 0), f32(12.5), u32(2), | |
| 54 | + | f32(1), f32(2), f32(30), f32(40), f32(0.9), u32(0), | |
| 55 | + | f32(5), f32(6), f32(7), f32(8), f32(0.25), u32(0)) | |
| 56 | + | got, err := readResponse(bytes.NewReader(ok), 5) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.Equal(t, []detection{{1, 2, 30, 40, 0.9}, {5, 6, 7, 8, 0.25}}, got) | |
| 59 | + | ||
| 60 | + | got, err = readResponse(bytes.NewReader(cat(head(5, 0), f32(1), u32(0))), 5) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | require.Empty(t, got) | |
| 63 | + | ||
| 64 | + | _, err = readResponse(bytes.NewReader(cat(head(5, 1), u32(3), []byte("bad"))), 5) | |
| 65 | + | require.ErrorIs(t, err, errDetector) | |
| 66 | + | require.ErrorContains(t, err, "bad") | |
| 67 | + | ||
| 68 | + | _, err = readResponse(bytes.NewReader(ok), 6) // wrong sequence | |
| 69 | + | require.ErrorIs(t, err, errProtocol) | |
| 70 | + | _, err = readResponse(bytes.NewReader(cat([]byte("NHRX"), u32(1))), 5) | |
| 71 | + | require.ErrorIs(t, err, errProtocol) | |
| 72 | + | _, err = readResponse(bytes.NewReader(cat([]byte("NHRS"), u32(9), u64(5), u32(0))), 5) | |
| 73 | + | require.ErrorIs(t, err, errProtocol) | |
| 74 | + | _, err = readResponse(bytes.NewReader(cat(head(5, 0), f32(1), u32(1<<30))), 5) | |
| 75 | + | require.ErrorIs(t, err, errProtocol) | |
| 76 | + | _, err = readResponse(bytes.NewReader(ok[:len(ok)-3]), 5) // truncated | |
| 77 | + | require.Error(t, err) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestLineLog(t *testing.T) { | |
| 81 | + | var lines []string | |
| 82 | + | l := &lineLog{log: func(s string) { lines = append(lines, s) }} | |
| 83 | + | _, _ = l.Write([]byte("one\ntw")) | |
| 84 | + | _, _ = l.Write([]byte("o\n\nthree")) | |
| 85 | + | require.Equal(t, []string{"one", "two"}, lines) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // The real detector, when the environment names it: | |
| 89 | + | // NEFERWL_TEST_DETECTOR (binary) and NEFERWL_TEST_DETECTOR_MODEL (models dir). | |
| 90 | + | func TestProcDetectorIntegration(t *testing.T) { | |
| 91 | + | bin, model := os.Getenv("NEFERWL_TEST_DETECTOR"), os.Getenv("NEFERWL_TEST_DETECTOR_MODEL") | |
| 92 | + | if bin == "" || model == "" { | |
| 93 | + | t.Skip("NEFERWL_TEST_DETECTOR and NEFERWL_TEST_DETECTOR_MODEL not set") | |
| 94 | + | } | |
| 95 | + | d := newProcDetector(bin, model, 0.05) | |
| 96 | + | t.Cleanup(func() { _ = d.Close() }) | |
| 97 | + | m := &mapping{b: make([]byte, 1280*720*4), live: new(int)} | |
| 98 | + | for i := range m.b { | |
| 99 | + | m.b[i] = 0xf0 // a blank light frame | |
| 100 | + | } | |
| 101 | + | f := detFrame{width: 1280, height: 720, stride: 1280 * 4, m: m} | |
| 102 | + | ctx := context.Background() | |
| 103 | + | ||
| 104 | + | dets, err := d.Detect(ctx, f) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | require.Empty(t, dets) // nothing clickable on a blank frame | |
| 107 | + | first := d.proc | |
| 108 | + | require.NotNil(t, first) | |
| 109 | + | _, err = d.Detect(ctx, f) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | require.Same(t, first, d.proc, "the child is kept alive between scans") | |
| 112 | + | ||
| 113 | + | // A dead child fails one scan, and the next one starts a new child. | |
| 114 | + | require.NoError(t, first.cmd.Process.Kill()) | |
| 115 | + | time.Sleep(50 * time.Millisecond) | |
| 116 | + | _, err = d.Detect(ctx, f) | |
| 117 | + | require.Error(t, err) | |
| 118 | + | require.Nil(t, d.proc) | |
| 119 | + | _, err = d.Detect(ctx, f) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | require.NotSame(t, first, d.proc) | |
| 122 | + | ||
| 123 | + | // A wrong geometry is the detector's own error: the child stays. | |
| 124 | + | proc := d.proc | |
| 125 | + | _, err = d.Detect(ctx, detFrame{width: 1280, height: 720, stride: 4, m: m}) | |
| 126 | + | require.ErrorIs(t, err, errDetector) | |
| 127 | + | require.Same(t, proc, d.proc) | |
| 128 | + | require.NoError(t, d.Close()) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestProcDetectorMissingBinary(t *testing.T) { | |
| 132 | + | d := newProcDetector("/nonexistent/nefer-hints", t.TempDir(), 0.05) | |
| 133 | + | _, err := d.Detect(context.Background(), detFrame{width: 1, height: 1, stride: 4, m: &mapping{b: make([]byte, 4)}}) | |
| 134 | + | require.Error(t, err) | |
| 135 | + | require.Nil(t, d.proc) | |
| 136 | + | require.NoError(t, d.Close()) | |
| 137 | + | } | |
M internal/adapters/hints/dump.go +37 -79
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "image" | |
| 8 | 8 | "image/color" | |
| 9 | 9 | "image/png" | |
| 10 | + | "math" | |
| 10 | 11 | "os" | |
| 11 | 12 | "path/filepath" | |
| 12 | 13 | "sort" | |
| @@ -25,78 +26,53 @@ const ( | |||
| 25 | 26 | ||
| 26 | 27 | // Options tunes Run. | |
| 27 | 28 | type Options struct { | |
| 29 | + | // Command is the nefer-hints binary: a path, or a name looked up in PATH. | |
| 30 | + | Command string | |
| 31 | + | // ModelDir is the directory of the detector's model files. | |
| 32 | + | ModelDir string | |
| 33 | + | // Threshold is the minimum detection score, 0.01 to 0.99. | |
| 34 | + | Threshold float64 | |
| 28 | 35 | // DumpDir, when not empty, receives the scanned frames (PNG, annotated | |
| 29 | 36 | // PNG and JSON). They contain the screen in clear. | |
| 30 | 37 | DumpDir string | |
| 31 | - | // Source, when not nil, finds targets in the accessibility tree of the | |
| 32 | - | // windows on screen; OCR reads the rest. | |
| 33 | - | Source targetSource | |
| 38 | + | ||
| 39 | + | // detector replaces the subprocess in tests. | |
| 40 | + | detector detector | |
| 34 | 41 | } | |
| 35 | 42 | ||
| 36 | 43 | // dumpJob is one scan to write. pix is the capture buffer of the scan, owned | |
| 37 | 44 | // by the job: writeDump converts it in place. | |
| 38 | 45 | type dumpJob struct { | |
| 39 | - | when time.Time | |
| 40 | - | req ports.HintScanRequest | |
| 41 | - | pix []byte | |
| 42 | - | m *mapping // owns pix; released after the dump | |
| 43 | - | stride int | |
| 44 | - | targets []ports.HintTarget | |
| 45 | - | crops []image.Rectangle | |
| 46 | - | words []OCRWord | |
| 47 | - | // atspi is the number of leading targets that come from the | |
| 48 | - | // accessibility tree; the others come from OCR. | |
| 49 | - | atspi int | |
| 46 | + | when time.Time | |
| 47 | + | req ports.HintScanRequest | |
| 48 | + | pix []byte | |
| 49 | + | m *mapping // owns pix; released after the dump | |
| 50 | + | stride int | |
| 51 | + | targets []ports.HintTarget | |
| 52 | + | dets []detection // detector boxes, physical pixels | |
| 50 | 53 | captureD time.Duration | |
| 51 | - | scanD time.Duration | |
| 52 | - | atspiD time.Duration | |
| 54 | + | detectD time.Duration | |
| 53 | 55 | } | |
| 54 | 56 | ||
| 55 | 57 | type dumpTarget struct { | |
| 56 | - | Kind string `json:"kind"` | |
| 57 | - | Source string `json:"source"` | |
| 58 | - | Rect [4]int `json:"rect"` | |
| 58 | + | Rect [4]int `json:"rect"` // x1, y1, x2, y2, logical output-local | |
| 59 | + | Score float32 `json:"score"` | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | 62 | type dumpMeta struct { | |
| 62 | - | Output string `json:"output"` | |
| 63 | - | Width int `json:"width"` | |
| 64 | - | Height int `json:"height"` | |
| 65 | - | Stride int `json:"stride"` | |
| 66 | - | Scale float64 `json:"scale"` | |
| 67 | - | LinksOnly bool `json:"links_only"` | |
| 68 | - | CaptureMS float64 `json:"capture_ms"` | |
| 69 | - | ScanMS float64 `json:"scan_ms"` | |
| 70 | - | AtspiMS float64 `json:"atspi_ms"` | |
| 71 | - | Crops []image.Rectangle `json:"ocr_crops"` | |
| 72 | - | Words []OCRWord `json:"words"` | |
| 73 | - | Targets []dumpTarget `json:"targets"` | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func kindName(k ports.HintKind) string { | |
| 77 | - | switch k { | |
| 78 | - | case ports.HintLink: | |
| 79 | - | return "link" | |
| 80 | - | case ports.HintButton: | |
| 81 | - | return "button" | |
| 82 | - | default: | |
| 83 | - | return "text" | |
| 84 | - | } | |
| 63 | + | Output string `json:"output"` | |
| 64 | + | Width int `json:"width"` | |
| 65 | + | Height int `json:"height"` | |
| 66 | + | Stride int `json:"stride"` | |
| 67 | + | Scale float64 `json:"scale"` | |
| 68 | + | CaptureMS float64 `json:"capture_ms"` | |
| 69 | + | DetectMS float64 `json:"detect_ms"` | |
| 70 | + | TotalMS float64 `json:"total_ms"` | |
| 71 | + | Targets []dumpTarget `json:"targets"` | |
| 85 | 72 | } | |
| 86 | 73 | ||
| 87 | - | func kindColor(k ports.HintKind) color.RGBA { | |
| 88 | - | switch k { | |
| 89 | - | case ports.HintLink: | |
| 90 | - | return color.RGBA{0x00, 0xc8, 0x53, 0xff} | |
| 91 | - | case ports.HintButton: | |
| 92 | - | return color.RGBA{0x29, 0x62, 0xff, 0xff} | |
| 93 | - | default: | |
| 94 | - | return color.RGBA{0x80, 0x80, 0x80, 0xff} | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | // atspiColor is the outline of targets found in the accessibility tree. | |
| 99 | - | var atspiColor = color.RGBA{0xff, 0x00, 0xff, 0xff} | |
| 74 | + | // boxColor is the outline of the detector boxes. | |
| 75 | + | var boxColor = color.RGBA{0x00, 0xc8, 0x53, 0xff} | |
| 100 | 76 | ||
| 101 | 77 | func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 } | |
| 102 | 78 | ||
| @@ -121,21 +97,8 @@ func writeDump(dir string, j *dumpJob) error { | |||
| 121 | 97 | return err | |
| 122 | 98 | } | |
| 123 | 99 | ||
| 124 | - | scale := j.req.Scale | |
| 125 | - | if scale <= 0 { | |
| 126 | - | scale = 1 | |
| 127 | - | } | |
| 128 | - | for _, r := range j.crops { | |
| 129 | - | outline(img, r, color.RGBA{0x80, 0x80, 0x80, 0xff}) | |
| 130 | - | } | |
| 131 | - | for i, t := range j.targets { | |
| 132 | - | r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale), | |
| 133 | - | int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale)) | |
| 134 | - | c := kindColor(t.Kind) | |
| 135 | - | if i < j.atspi { | |
| 136 | - | c = atspiColor | |
| 137 | - | } | |
| 138 | - | outline(img, r, c) | |
| 100 | + | for _, d := range j.dets { | |
| 101 | + | outline(img, image.Rect(int(d.x1), int(d.y1), int(math.Ceil(float64(d.x2))), int(math.Ceil(float64(d.y2)))), boxColor) | |
| 139 | 102 | } | |
| 140 | 103 | if err := writePNG(prefix+".annotated.png", img); err != nil { | |
| 141 | 104 | return err | |
| @@ -143,17 +106,12 @@ func writeDump(dir string, j *dumpJob) error { | |||
| 143 | 106 | ||
| 144 | 107 | meta := dumpMeta{ | |
| 145 | 108 | Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride, | |
| 146 | - | Scale: j.req.Scale, LinksOnly: j.req.LinksOnly, | |
| 147 | - | CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD), AtspiMS: ms(j.atspiD), | |
| 148 | - | Crops: j.crops, Words: j.words, | |
| 109 | + | Scale: j.req.Scale, | |
| 110 | + | CaptureMS: ms(j.captureD), DetectMS: ms(j.detectD), TotalMS: ms(j.captureD + j.detectD), | |
| 149 | 111 | Targets: make([]dumpTarget, 0, len(j.targets)), | |
| 150 | 112 | } | |
| 151 | - | for i, t := range j.targets { | |
| 152 | - | src := "ocr" | |
| 153 | - | if i < j.atspi { | |
| 154 | - | src = "atspi" | |
| 155 | - | } | |
| 156 | - | meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Source: src, Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}}) | |
| 113 | + | for _, t := range j.targets { | |
| 114 | + | meta.Targets = append(meta.Targets, dumpTarget{Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}, Score: t.Score}) | |
| 157 | 115 | } | |
| 158 | 116 | data, err := json.Marshal(meta) | |
| 159 | 117 | if err != nil { | |
M internal/adapters/hints/hints.go +51 -296
| @@ -1,15 +1,14 @@ | |||
| 1 | 1 | // Package hints finds clickable targets for hint mode: it captures the last | |
| 2 | - | // frame of an output through the capture pipeline and reads uncovered windows with OCR. | |
| 2 | + | // frame of an output through the capture pipeline and sends it to the | |
| 3 | + | // nefer-hints detector, a persistent subprocess. | |
| 3 | 4 | package hints | |
| 4 | 5 | ||
| 5 | 6 | import ( | |
| 6 | 7 | "context" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "fmt" | |
| 9 | - | "hash/maphash" | |
| 10 | 10 | "image" | |
| 11 | 11 | "math" | |
| 12 | - | "strings" | |
| 13 | 12 | "time" | |
| 14 | 13 | ||
| 15 | 14 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| @@ -39,39 +38,18 @@ func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch | |||
| 39 | 38 | return run(ctx, requests, captures, captured, results, captureTimeout, opts) | |
| 40 | 39 | } | |
| 41 | 40 | ||
| 42 | - | // targetSource finds hint targets in the accessibility tree of windows. It | |
| 43 | - | // returns the targets (logical output-local) and the windows they cover. | |
| 44 | - | type targetSource interface { | |
| 45 | - | Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) | |
| 46 | - | // Close releases the source. It is called once, by the worker goroutine, | |
| 47 | - | // when the worker returns; the source is not safe for concurrent use. | |
| 48 | - | Close() error | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // atspiResult is what the source found for one request. | |
| 52 | - | type atspiResult struct { | |
| 53 | - | targets []ports.HintTarget | |
| 54 | - | covered map[ports.WindowID]bool | |
| 55 | - | dur time.Duration | |
| 56 | - | err error | |
| 57 | - | } | |
| 58 | - | ||
| 59 | 41 | type worker struct { | |
| 60 | - | source targetSource | |
| 61 | - | cache map[bandKey]bandCache | |
| 62 | - | cacheSeed maphash.Seed | |
| 63 | - | ocr ocrState | |
| 64 | - | warnedSource bool | |
| 65 | - | n uint64 | |
| 66 | - | dumpDir string | |
| 67 | - | pending *dumpJob | |
| 42 | + | det detector | |
| 43 | + | n uint64 | |
| 44 | + | dumpDir string | |
| 45 | + | pending *dumpJob | |
| 68 | 46 | // orphans are capture buffers the output may still write: they are | |
| 69 | 47 | // released when its CaptureDone finally arrives. | |
| 70 | 48 | orphans map[uint64]*mapping | |
| 71 | 49 | live int // mappings not released yet | |
| 72 | 50 | } | |
| 73 | 51 | ||
| 74 | - | // frame is the captured output: BGRA pixels, nil when no capture was needed. | |
| 52 | + | // frame is the captured output: BGRA pixels. | |
| 75 | 53 | type frame struct { | |
| 76 | 54 | pix []byte | |
| 77 | 55 | stride int | |
| @@ -79,11 +57,12 @@ type frame struct { | |||
| 79 | 57 | } | |
| 80 | 58 | ||
| 81 | 59 | 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 { | |
| 82 | - | w := &worker{dumpDir: opts.DumpDir, source: opts.Source} | |
| 83 | - | if opts.Source != nil { | |
| 84 | - | // The worker goroutine is the only one to touch the source. | |
| 85 | - | defer func() { _ = opts.Source.Close() }() | |
| 60 | + | w := &worker{dumpDir: opts.DumpDir, det: opts.detector} | |
| 61 | + | if w.det == nil { | |
| 62 | + | w.det = newProcDetector(opts.Command, opts.ModelDir, opts.Threshold) | |
| 86 | 63 | } | |
| 64 | + | // The worker goroutine is the only one to touch the detector. | |
| 65 | + | defer func() { _ = w.det.Close() }() | |
| 87 | 66 | for { | |
| 88 | 67 | select { | |
| 89 | 68 | case <-ctx.Done(): | |
| @@ -130,25 +109,6 @@ func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.Hi | |||
| 130 | 109 | } | |
| 131 | 110 | } | |
| 132 | 111 | ||
| 133 | - | // needFrame reports whether the scan must capture the output: OCR has a | |
| 134 | - | // window to read, or the debug dump wants the frame. With OCR unavailable | |
| 135 | - | // (tesseract missing) and no dump, the accessibility targets are the result. | |
| 136 | - | func (w *worker) needFrame(ctx context.Context, req ports.HintScanRequest, ar atspiResult) bool { | |
| 137 | - | ocr := w.ocrReady(ctx) | |
| 138 | - | if w.dumpDir != "" { | |
| 139 | - | return true | |
| 140 | - | } | |
| 141 | - | if !ocr { | |
| 142 | - | return false | |
| 143 | - | } | |
| 144 | - | for _, win := range req.Windows { | |
| 145 | - | if !ar.covered[win.ID] && !windowCrop(win.Rect, req.Scale, req.Width, req.Height).Empty() { | |
| 146 | - | return true | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | return false | |
| 150 | - | } | |
| 151 | - | ||
| 152 | 112 | func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) ports.HintScanResult { | |
| 153 | 113 | log := logging.For(ctx, "hints") | |
| 154 | 114 | res := ports.HintScanResult{Seq: req.Seq, Output: req.Output} | |
| @@ -161,16 +121,8 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests | |||
| 161 | 121 | if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels { | |
| 162 | 122 | return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height)) | |
| 163 | 123 | } | |
| 164 | - | // The accessibility query is always waited for: the source is not shared | |
| 165 | - | // between requests. The capture only starts once it is known whether any | |
| 166 | - | // window needs OCR. | |
| 167 | - | atspi := w.startSource(ctx, req) | |
| 168 | - | defer func() { <-atspi.done }() | |
| 169 | - | if !w.needFrame(ctx, req, atspi.wait()) { | |
| 170 | - | return w.scan(ctx, req, frame{}, 0, res, atspi) | |
| 171 | - | } | |
| 172 | 124 | // The capture buffer is anonymous mapped memory, outside the Go heap. It | |
| 173 | - | // is released once the crops are extracted, or by the dump job. | |
| 125 | + | // is released once the detector answered, or by the dump job. | |
| 174 | 126 | m := w.alloc(req.Width * req.Height * 4) | |
| 175 | 127 | keep := false // the mapping outlives this call: dump job or pending capture | |
| 176 | 128 | defer func() { | |
| @@ -225,7 +177,7 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests | |||
| 225 | 177 | if done.Err != nil { | |
| 226 | 178 | return fail(fmt.Errorf("hints: capture: %w", done.Err)) | |
| 227 | 179 | } | |
| 228 | - | res = w.scan(ctx, req, frame{pix: buf, stride: stride, m: m}, time.Since(start), res, atspi) | |
| 180 | + | res = w.scan(ctx, req, frame{pix: buf, stride: stride, m: m}, time.Since(start), res) | |
| 229 | 181 | keep = w.pending != nil && w.pending.m == m | |
| 230 | 182 | return res | |
| 231 | 183 | case other := <-requests: | |
| @@ -255,253 +207,56 @@ func (w *worker) reap(id uint64) { | |||
| 255 | 207 | } | |
| 256 | 208 | } | |
| 257 | 209 | ||
| 258 | - | // bandJob is one OCR band of one window crop. | |
| 259 | - | type bandJob struct { | |
| 260 | - | win int // index into the scan's windows | |
| 261 | - | key bandKey // | |
| 262 | - | ext image.Rectangle // crop-local rows read, with context | |
| 263 | - | own [2]int // crop-local rows owned: words centred there are kept | |
| 264 | - | hash uint64 | |
| 265 | - | hit bool | |
| 266 | - | gray *image.Gray | |
| 267 | - | words []OCRWord | |
| 268 | - | err error | |
| 269 | - | } | |
| 210 | + | // minTarget is the smallest target side kept, in logical pixels. | |
| 211 | + | const minTarget = 4 | |
| 270 | 212 | ||
| 271 | - | func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult, atspi *pendingSource) ports.HintScanResult { | |
| 213 | + | // scan sends the frame to the detector and turns the boxes into targets. It | |
| 214 | + | // releases the frame, or hands it to the dump job. | |
| 215 | + | func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult { | |
| 272 | 216 | log := logging.For(ctx, "hints") | |
| 273 | - | ar := atspi.wait() | |
| 274 | - | if ar.err != nil && !w.warnedSource { | |
| 275 | - | log.Warn().Err(ar.err).Msg("accessibility targets unavailable; using OCR only") | |
| 276 | - | w.warnedSource = true | |
| 277 | - | } | |
| 278 | - | out := make([]ports.HintTarget, 0, len(ar.targets)) | |
| 279 | - | for _, t := range ar.targets { | |
| 280 | - | if !req.LinksOnly || t.Kind == ports.HintLink { | |
| 281 | - | out = append(out, t) | |
| 282 | - | } | |
| 283 | - | } | |
| 284 | - | nAtspi := len(out) | |
| 285 | 217 | t0 := time.Now() | |
| 286 | - | if w.cacheSeed == (maphash.Seed{}) { | |
| 287 | - | w.cacheSeed = maphash.MakeSeed() | |
| 288 | - | } | |
| 289 | - | nextCache := make(map[bandKey]bandCache) | |
| 290 | - | var crops []image.Rectangle | |
| 291 | - | var allWords []OCRWord | |
| 292 | - | pixels, links := 0, 0 | |
| 293 | - | ocrOn := fr.pix != nil && w.ocrReady(ctx) | |
| 294 | - | // Phase 1 reads the frame: the bands that miss the cache are copied, as | |
| 295 | - | // grayscale, into one arena. Phase 2 (OCR) does not need the frame. | |
| 296 | - | var jobs []*bandJob | |
| 297 | - | var rects []image.Rectangle // crop of each window that has bands | |
| 298 | - | var arena *mapping | |
| 299 | - | if ocrOn { | |
| 300 | - | type pending struct { | |
| 301 | - | r image.Rectangle | |
| 302 | - | jobs []*bandJob | |
| 303 | - | } | |
| 304 | - | var wins []pending | |
| 305 | - | need := 0 | |
| 306 | - | for _, win := range req.Windows { | |
| 307 | - | if ar.covered[win.ID] { | |
| 308 | - | continue | |
| 309 | - | } | |
| 310 | - | r := windowCrop(win.Rect, req.Scale, req.Width, req.Height) | |
| 311 | - | if r.Empty() { | |
| 312 | - | continue | |
| 313 | - | } | |
| 314 | - | crops = append(crops, r) | |
| 315 | - | pixels += r.Dx() * r.Dy() | |
| 316 | - | p := pending{r: r} | |
| 317 | - | for _, b := range bandsOf(r.Dy()) { | |
| 318 | - | ext := image.Rect(0, b.top, r.Dx(), b.bottom) | |
| 319 | - | j := &bandJob{win: len(wins), ext: ext, own: [2]int{b.ownTop, b.ownBottom}, | |
| 320 | - | key: bandKey{output: req.Output, window: win.ID, band: b.idx, bands: b.n}} | |
| 321 | - | j.hash = w.cropHash(fr.pix, fr.stride, ext.Add(r.Min)) | |
| 322 | - | entry, ok := w.cache[j.key] | |
| 323 | - | if ok && entry.hash == j.hash && entry.ext == ext { | |
| 324 | - | j.hit, j.words = true, entry.words | |
| 325 | - | } else { | |
| 326 | - | need += ext.Dx() * ext.Dy() | |
| 327 | - | } | |
| 328 | - | p.jobs = append(p.jobs, j) | |
| 329 | - | } | |
| 330 | - | wins = append(wins, p) | |
| 331 | - | } | |
| 332 | - | if need > 0 { | |
| 333 | - | arena = w.alloc(need) | |
| 334 | - | } | |
| 335 | - | off := 0 | |
| 336 | - | for _, p := range wins { | |
| 337 | - | rects = append(rects, p.r) | |
| 338 | - | for _, j := range p.jobs { | |
| 339 | - | if !j.hit { | |
| 340 | - | n := j.ext.Dx() * j.ext.Dy() | |
| 341 | - | j.gray = cropGray(fr.pix, fr.stride, j.ext.Add(p.r.Min), arena.b[off:off+n]) | |
| 342 | - | off += n | |
| 343 | - | } | |
| 344 | - | jobs = append(jobs, j) | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | } else if w.dumpDir != "" { | |
| 348 | - | for _, win := range req.Windows { | |
| 349 | - | if !ar.covered[win.ID] { | |
| 350 | - | if r := windowCrop(win.Rect, req.Scale, req.Width, req.Height); !r.Empty() { | |
| 351 | - | crops = append(crops, r) | |
| 352 | - | } | |
| 353 | - | } | |
| 354 | - | } | |
| 218 | + | dets, err := w.det.Detect(ctx, detFrame{width: req.Width, height: req.Height, stride: fr.stride, m: fr.m}) | |
| 219 | + | detectDur := time.Since(t0) | |
| 220 | + | if err != nil { | |
| 221 | + | fr.m.unmap() | |
| 222 | + | log.Info().Err(err).Str("output", req.Output).Float64("detect_ms", ms(detectDur)).Msg("hint scan failed") | |
| 223 | + | res.Err = err | |
| 224 | + | return res | |
| 355 | 225 | } | |
| 356 | - | if w.dumpDir != "" && fr.pix != nil { | |
| 226 | + | res.Targets = targetsOf(dets, req.Scale) | |
| 227 | + | if w.dumpDir != "" { | |
| 357 | 228 | // The dump reads the frame after the result is sent and releases it. | |
| 358 | - | w.pending = &dumpJob{when: time.Now(), req: req, pix: fr.pix, m: fr.m, stride: fr.stride, crops: crops, captureD: captureDur, atspiD: ar.dur} | |
| 229 | + | w.pending = &dumpJob{when: time.Now(), req: req, pix: fr.pix, m: fr.m, stride: fr.stride, targets: res.Targets, dets: dets, captureD: captureDur, detectD: detectDur} | |
| 359 | 230 | } else { | |
| 360 | - | fr.m.unmap() // the crops are extracted: give the frame back | |
| 231 | + | fr.m.unmap() | |
| 361 | 232 | } | |
| 362 | - | fr.pix = nil | |
| 363 | - | ||
| 364 | - | hits := 0 | |
| 365 | - | if len(jobs) > 0 { | |
| 366 | - | w.recognize(ctx, jobs) | |
| 367 | - | for _, j := range jobs { | |
| 368 | - | if j.hit { | |
| 369 | - | hits++ | |
| 370 | - | } | |
| 371 | - | } | |
| 372 | - | } | |
| 373 | - | arena.unmap() | |
| 374 | - | ||
| 375 | - | // Merge the bands of each window, in order. | |
| 376 | - | k := 0 | |
| 377 | - | for wi, r := range rects { | |
| 378 | - | var words []OCRWord | |
| 379 | - | for ; k < len(jobs) && jobs[k].win == wi; k++ { | |
| 380 | - | j := jobs[k] | |
| 381 | - | if j.err != nil { | |
| 382 | - | failureWarning.Do(func() { log.Warn().Err(j.err).Msg("OCR unavailable; install tesseract and tesseract-data-eng") }) | |
| 383 | - | continue | |
| 384 | - | } | |
| 385 | - | nextCache[j.key] = bandCache{hash: j.hash, ext: j.ext, words: j.words} | |
| 386 | - | for _, word := range j.words { | |
| 387 | - | word.Rect = word.Rect.Add(r.Min) | |
| 388 | - | if word.Underlined { | |
| 389 | - | word.UnderL, word.UnderR = word.UnderL+r.Min.X, word.UnderR+r.Min.X | |
| 390 | - | } | |
| 391 | - | if !duplicateWord(words, word) { | |
| 392 | - | words = append(words, word) | |
| 393 | - | } | |
| 394 | - | } | |
| 395 | - | } | |
| 396 | - | allWords = append(allWords, words...) | |
| 397 | - | targets := textLinks(words, req.Scale) | |
| 398 | - | links += len(targets) | |
| 399 | - | out = append(out, targets...) | |
| 400 | - | } | |
| 401 | - | w.cache = nextCache | |
| 402 | - | ocrDur := time.Since(t0) | |
| 403 | - | res.Targets = out | |
| 404 | - | if p := w.pending; p != nil { | |
| 405 | - | p.targets, p.atspi, p.words, p.scanD = out, nAtspi, allWords, ocrDur | |
| 406 | - | } | |
| 407 | - | log.Info().Str("output", req.Output). | |
| 408 | - | Float64("atspi_ms", ms(ar.dur)).Int("atspi_windows", len(ar.covered)).Int("atspi_targets", nAtspi). | |
| 409 | - | Int("ocr_windows", len(rects)).Float64("ocr_ms", ms(ocrDur)).Int("ocr_px", pixels). | |
| 410 | - | Int("ocr_bands", len(jobs)).Int("band_hits", hits). | |
| 411 | - | Int("words", len(allWords)).Int("ocr_links", links).Bool("cache_hit", len(jobs) > 0 && hits == len(jobs)). | |
| 412 | - | Int("targets", len(out)).Msg("hint scan") | |
| 233 | + | log.Info().Str("output", req.Output).Float64("capture_ms", ms(captureDur)).Float64("detect_ms", ms(detectDur)). | |
| 234 | + | Int("boxes", len(dets)).Int("targets", len(res.Targets)).Msg("hint scan") | |
| 413 | 235 | return res | |
| 414 | 236 | } | |
| 415 | 237 | ||
| 416 | - | // duplicateWord reports whether words already holds word (the same text at | |
| 417 | - | // the same place), as happens where two bands overlap. | |
| 418 | - | func duplicateWord(words []OCRWord, word OCRWord) bool { | |
| 419 | - | for _, old := range words { | |
| 420 | - | if strings.EqualFold(word.Text, old.Text) && overlap(word.Rect, old.Rect) > .5 { | |
| 421 | - | return true | |
| 422 | - | } | |
| 423 | - | } | |
| 424 | - | return false | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | // bandKey identifies one OCR band of a window on an output. | |
| 428 | - | type bandKey struct { | |
| 429 | - | output string | |
| 430 | - | window ports.WindowID | |
| 431 | - | band int | |
| 432 | - | bands int | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | // bandCache is the OCR result of a band whose pixels hash to hash. words are | |
| 436 | - | // crop-local and only the ones the band owns. | |
| 437 | - | type bandCache struct { | |
| 438 | - | hash uint64 | |
| 439 | - | ext image.Rectangle | |
| 440 | - | words []OCRWord | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | // windowCrop rounds outwards in physical pixels, then intersects the output. | |
| 444 | - | func windowCrop(r ports.Rect, scale float64, width, height int) image.Rectangle { | |
| 238 | + | // targetsOf converts boxes in physical pixels to logical output-local | |
| 239 | + | // targets, rounding outwards, and drops the ones under minTarget on a side. | |
| 240 | + | func targetsOf(dets []detection, scale float64) []ports.HintTarget { | |
| 445 | 241 | if scale <= 0 { | |
| 446 | 242 | scale = 1 | |
| 447 | 243 | } | |
| 448 | - | if r.W <= 0 || r.H <= 0 { | |
| 449 | - | return image.Rectangle{} | |
| 450 | - | } | |
| 451 | - | return image.Rect(int(math.Floor(float64(r.X)*scale)), int(math.Floor(float64(r.Y)*scale)), | |
| 452 | - | 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)) | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | // cropHash hashes the raw pixels of r without copying them. | |
| 456 | - | func (w *worker) cropHash(buf []byte, stride int, r image.Rectangle) uint64 { | |
| 457 | - | var h maphash.Hash | |
| 458 | - | h.SetSeed(w.cacheSeed) | |
| 459 | - | for y := r.Min.Y; y < r.Max.Y; y++ { | |
| 460 | - | _, _ = h.Write(buf[y*stride+r.Min.X*4 : y*stride+r.Max.X*4]) | |
| 461 | - | } | |
| 462 | - | return h.Sum64() | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | // cropGray converts the BGRA pixels of r to 8-bit luma into dst, which must | |
| 466 | - | // hold r.Dx()*r.Dy() bytes; only this band is converted, not the output. | |
| 467 | - | func cropGray(buf []byte, stride int, r image.Rectangle, dst []byte) *image.Gray { | |
| 468 | - | img := &image.Gray{Pix: dst[:r.Dx()*r.Dy()], Stride: r.Dx(), Rect: image.Rect(0, 0, r.Dx(), r.Dy())} | |
| 469 | - | for y := 0; y < r.Dy(); y++ { | |
| 470 | - | src := buf[(r.Min.Y+y)*stride+r.Min.X*4:][:r.Dx()*4] | |
| 471 | - | row := img.Pix[y*img.Stride:][:r.Dx()] | |
| 472 | - | for x := range row { | |
| 473 | - | p := src[x*4 : x*4+4 : x*4+4] | |
| 474 | - | row[x] = uint8((uint32(p[2])*299 + uint32(p[1])*587 + uint32(p[0])*114 + 500) / 1000) | |
| 244 | + | out := make([]ports.HintTarget, 0, len(dets)) | |
| 245 | + | for _, d := range dets { | |
| 246 | + | v := [4]float64{float64(d.x1), float64(d.y1), float64(d.x2), float64(d.y2)} | |
| 247 | + | ok := true | |
| 248 | + | for _, f := range v { | |
| 249 | + | ok = ok && !math.IsNaN(f) && !math.IsInf(f, 0) | |
| 475 | 250 | } | |
| 251 | + | if !ok { | |
| 252 | + | continue | |
| 253 | + | } | |
| 254 | + | x1, y1 := int(math.Floor(v[0]/scale)), int(math.Floor(v[1]/scale)) | |
| 255 | + | x2, y2 := int(math.Ceil(v[2]/scale)), int(math.Ceil(v[3]/scale)) | |
| 256 | + | if x2-x1 < minTarget || y2-y1 < minTarget { | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | out = append(out, ports.HintTarget{Rect: ports.Rect{X: x1, Y: y1, W: x2 - x1, H: y2 - y1}, Score: d.score}) | |
| 476 | 260 | } | |
| 477 | - | return img | |
| 478 | - | } | |
| 479 | - | ||
| 480 | - | // pendingSource is an accessibility query in flight. | |
| 481 | - | type pendingSource struct { | |
| 482 | - | done chan struct{} | |
| 483 | - | res atspiResult | |
| 484 | - | } | |
| 485 | - | ||
| 486 | - | // startSource starts the accessibility query of req, if there is a source and | |
| 487 | - | // a window to ask about. done is closed when res is set. | |
| 488 | - | func (w *worker) startSource(ctx context.Context, req ports.HintScanRequest) *pendingSource { | |
| 489 | - | p := &pendingSource{done: make(chan struct{})} | |
| 490 | - | if w.source == nil || len(req.Windows) == 0 { | |
| 491 | - | close(p.done) | |
| 492 | - | return p | |
| 493 | - | } | |
| 494 | - | go func() { | |
| 495 | - | defer close(p.done) | |
| 496 | - | t0 := time.Now() | |
| 497 | - | targets, covered, err := w.source.Targets(ctx, req.Windows, req.LinksOnly) | |
| 498 | - | p.res = atspiResult{targets: targets, covered: covered, dur: time.Since(t0), err: err} | |
| 499 | - | }() | |
| 500 | - | return p | |
| 501 | - | } | |
| 502 | - | ||
| 503 | - | // wait returns the result of the query. | |
| 504 | - | func (p *pendingSource) wait() atspiResult { | |
| 505 | - | <-p.done | |
| 506 | - | return p.res | |
| 261 | + | return out | |
| 507 | 262 | } | |
M internal/adapters/hints/hints_test.go +108 -178
| @@ -6,10 +6,9 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "image" | |
| 9 | - | "image/draw" | |
| 10 | 9 | _ "image/png" | |
| 10 | + | "math" | |
| 11 | 11 | "os" | |
| 12 | - | "os/exec" | |
| 13 | 12 | "path/filepath" | |
| 14 | 13 | "strings" | |
| 15 | 14 | "testing" | |
| @@ -39,6 +38,13 @@ func startCapturing(t *testing.T, timeout time.Duration) *harness { | |||
| 39 | 38 | ||
| 40 | 39 | func startWith(t *testing.T, timeout time.Duration, opts Options) *harness { | |
| 41 | 40 | t.Helper() | |
| 41 | + | if opts.detector == nil { | |
| 42 | + | // Tests of the capture flow do not care what the detector finds. | |
| 43 | + | det := newMockdetector(t) | |
| 44 | + | det.EXPECT().Detect(mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |
| 45 | + | det.EXPECT().Close().Return(nil).Maybe() | |
| 46 | + | opts.detector = det | |
| 47 | + | } | |
| 42 | 48 | h := &harness{ | |
| 43 | 49 | requests: make(chan ports.HintScanRequest, 1), | |
| 44 | 50 | captures: make(chan ports.CaptureRequest, 4), | |
| @@ -55,51 +61,67 @@ func startWith(t *testing.T, timeout time.Duration, opts Options) *harness { | |||
| 55 | 61 | return h | |
| 56 | 62 | } | |
| 57 | 63 | ||
| 58 | - | func loadBGRA(t *testing.T) (pix []byte, w, hgt int) { | |
| 59 | - | t.Helper() | |
| 60 | - | f, err := os.Open("testdata/browser.png") | |
| 61 | - | require.NoError(t, err) | |
| 62 | - | defer f.Close() | |
| 63 | - | img, _, err := image.Decode(f) | |
| 64 | - | require.NoError(t, err) | |
| 65 | - | b := img.Bounds() | |
| 66 | - | rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) | |
| 67 | - | draw.Draw(rgba, rgba.Bounds(), img, b.Min, draw.Src) | |
| 68 | - | for i := 0; i < len(rgba.Pix); i += 4 { | |
| 69 | - | rgba.Pix[i], rgba.Pix[i+2] = rgba.Pix[i+2], rgba.Pix[i] | |
| 70 | - | rgba.Pix[i+3] = 0xff | |
| 71 | - | } | |
| 72 | - | return rgba.Pix, b.Dx(), b.Dy() | |
| 73 | - | } | |
| 74 | - | ||
| 75 | 64 | func TestHintScan(t *testing.T) { | |
| 76 | - | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 77 | - | t.Skip("tesseract not on PATH") | |
| 78 | - | } | |
| 79 | - | pix, w, hgt := loadBGRA(t) | |
| 80 | - | h := start(t, 5*time.Second) | |
| 81 | - | ||
| 82 | - | // Plays the output owner. | |
| 65 | + | det := newMockdetector(t) | |
| 66 | + | det.EXPECT().Close().Return(nil).Once() | |
| 67 | + | var got detFrame | |
| 68 | + | det.EXPECT().Detect(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f detFrame) ([]detection, error) { | |
| 69 | + | got = f | |
| 70 | + | return []detection{{x1: 10, y1: 20, x2: 110, y2: 60, score: 0.8}}, nil | |
| 71 | + | }).Once() | |
| 72 | + | h := startWith(t, 5*time.Second, Options{detector: det}) | |
| 83 | 73 | go func() { | |
| 84 | 74 | creq := <-h.captures | |
| 85 | - | copy(creq.Mapped, pix) | |
| 86 | 75 | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 87 | 76 | }() | |
| 88 | - | ||
| 89 | - | 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}}}} | |
| 77 | + | h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: 64, Height: 32, Scale: 1} | |
| 90 | 78 | res := <-h.results | |
| 91 | 79 | require.NoError(t, res.Err) | |
| 92 | 80 | require.Equal(t, uint64(7), res.Seq) | |
| 93 | 81 | require.Equal(t, "HEADLESS-1", res.Output) | |
| 94 | - | var links int | |
| 95 | - | for _, tg := range res.Targets { | |
| 96 | - | require.Positive(t, tg.Rect.W) | |
| 97 | - | require.Positive(t, tg.Rect.H) | |
| 98 | - | if tg.Kind == ports.HintLink { | |
| 99 | - | links++ | |
| 82 | + | require.Equal(t, []ports.HintTarget{{Rect: ports.Rect{X: 10, Y: 20, W: 100, H: 40}, Score: 0.8}}, res.Targets) | |
| 83 | + | require.Equal(t, 64, got.width) | |
| 84 | + | require.Equal(t, 32, got.height) | |
| 85 | + | require.Equal(t, 64*4, got.stride) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestTargetsOf(t *testing.T) { | |
| 89 | + | got := targetsOf([]detection{ | |
| 90 | + | {x1: 11, y1: 21, x2: 100.2, y2: 61, score: 0.5}, // scale 2: x 5..51, y 10..31 | |
| 91 | + | {x1: 0, y1: 0, x2: 5, y2: 40, score: 0.9}, // 2.5 -> 3 logical wide: dropped | |
| 92 | + | {x1: 0, y1: 0, x2: 40, y2: 5, score: 0.9}, // 3 logical high: dropped | |
| 93 | + | {x1: 0, y1: 0, x2: 8, y2: 8, score: 0.4}, // exactly 4x4: kept | |
| 94 | + | {x1: float32(math.NaN()), y1: 0, x2: 50, y2: 50}, | |
| 95 | + | }, 2) | |
| 96 | + | require.Equal(t, []ports.HintTarget{ | |
| 97 | + | {Rect: ports.Rect{X: 5, Y: 10, W: 46, H: 21}, Score: 0.5}, | |
| 98 | + | {Rect: ports.Rect{X: 0, Y: 0, W: 4, H: 4}, Score: 0.4}, | |
| 99 | + | }, got) | |
| 100 | + | // A zero scale reads as 1. | |
| 101 | + | require.Len(t, targetsOf([]detection{{x2: 10, y2: 10}}, 0), 1) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // A detector error fails the scan; the worker goes on and the buffer is released. | |
| 105 | + | func TestHintDetectorErrorFailsScan(t *testing.T) { | |
| 106 | + | boom := errors.New("detector gone") | |
| 107 | + | det := newMockdetector(t) | |
| 108 | + | det.EXPECT().Close().Return(nil).Once() | |
| 109 | + | det.EXPECT().Detect(mock.Anything, mock.Anything).Return(nil, boom).Once() | |
| 110 | + | det.EXPECT().Detect(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 111 | + | h := startWith(t, 5*time.Second, Options{detector: det}) | |
| 112 | + | for i, want := range []error{boom, nil} { | |
| 113 | + | go func() { | |
| 114 | + | creq := <-h.captures | |
| 115 | + | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 116 | + | }() | |
| 117 | + | h.requests <- ports.HintScanRequest{Seq: uint64(i + 1), Output: "o", Width: 16, Height: 16, Scale: 1} | |
| 118 | + | res := <-h.results | |
| 119 | + | if want != nil { | |
| 120 | + | require.ErrorIs(t, res.Err, want) | |
| 121 | + | } else { | |
| 122 | + | require.NoError(t, res.Err) | |
| 100 | 123 | } | |
| 101 | 124 | } | |
| 102 | - | require.GreaterOrEqual(t, links, 1) | |
| 103 | 125 | } | |
| 104 | 126 | ||
| 105 | 127 | func TestHintRequestShape(t *testing.T) { | |
| @@ -166,19 +188,6 @@ func TestHintBadSize(t *testing.T) { | |||
| 166 | 188 | require.ErrorIs(t, res.Err, ErrBadRequest) | |
| 167 | 189 | } | |
| 168 | 190 | ||
| 169 | - | func scanOnce(t *testing.T, h *harness, pix []byte, w, hgt int, seq uint64) ports.HintScanResult { | |
| 170 | - | t.Helper() | |
| 171 | - | go func() { | |
| 172 | - | creq := <-h.captures | |
| 173 | - | copy(creq.Mapped, pix) | |
| 174 | - | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 175 | - | }() | |
| 176 | - | 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}}}} | |
| 177 | - | res := <-h.results | |
| 178 | - | require.NoError(t, res.Err) | |
| 179 | - | return res | |
| 180 | - | } | |
| 181 | - | ||
| 182 | 191 | // waitFiles waits for the dump the worker writes after sending the result. | |
| 183 | 192 | func waitFiles(t *testing.T, dir string, n int) []os.DirEntry { | |
| 184 | 193 | t.Helper() | |
| @@ -190,19 +199,32 @@ func waitFiles(t *testing.T, dir string, n int) []os.DirEntry { | |||
| 190 | 199 | return entries | |
| 191 | 200 | } | |
| 192 | 201 | ||
| 202 | + | // scanOnce runs one scan on a 64x32 frame and waits for its result. | |
| 203 | + | func scanOnce(t *testing.T, h *harness, seq uint64) ports.HintScanResult { | |
| 204 | + | t.Helper() | |
| 205 | + | go func() { | |
| 206 | + | creq := <-h.captures | |
| 207 | + | for i := 0; i < len(creq.Mapped); i += 4 { | |
| 208 | + | creq.Mapped[i], creq.Mapped[i+1], creq.Mapped[i+2] = 10, 20, 30 | |
| 209 | + | } | |
| 210 | + | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 211 | + | }() | |
| 212 | + | h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: 64, Height: 32, Scale: 1} | |
| 213 | + | res := <-h.results | |
| 214 | + | require.NoError(t, res.Err) | |
| 215 | + | return res | |
| 216 | + | } | |
| 217 | + | ||
| 193 | 218 | func TestHintDump(t *testing.T) { | |
| 194 | - | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 195 | - | t.Skip("tesseract not on PATH") | |
| 196 | - | } | |
| 197 | - | pix, w, hgt := loadBGRA(t) | |
| 219 | + | det := newMockdetector(t) | |
| 220 | + | det.EXPECT().Close().Return(nil).Once() | |
| 221 | + | det.EXPECT().Detect(mock.Anything, mock.Anything).Return([]detection{{x1: 8, y1: 4, x2: 40, y2: 20, score: 0.75}}, nil).Once() | |
| 198 | 222 | dir := filepath.Join(t.TempDir(), "hints") | |
| 199 | - | h := startWith(t, 5*time.Second, Options{DumpDir: dir}) | |
| 200 | - | res := scanOnce(t, h, pix, w, hgt, 1) | |
| 201 | - | require.NotEmpty(t, res.Targets) | |
| 223 | + | h := startWith(t, 5*time.Second, Options{DumpDir: dir, detector: det}) | |
| 224 | + | res := scanOnce(t, h, 1) | |
| 225 | + | require.Len(t, res.Targets, 1) | |
| 202 | 226 | ||
| 203 | 227 | entries := waitFiles(t, dir, 3) | |
| 204 | - | // A second request is only served once the dump is written. | |
| 205 | - | _ = scanOnceDone(t, h) | |
| 206 | 228 | var png, ann, js string | |
| 207 | 229 | for _, e := range entries { | |
| 208 | 230 | info, err := e.Info() | |
| @@ -228,61 +250,45 @@ func TestHintDump(t *testing.T) { | |||
| 228 | 250 | data, err := os.ReadFile(filepath.Join(dir, js)) | |
| 229 | 251 | require.NoError(t, err) | |
| 230 | 252 | var meta struct { | |
| 231 | - | Output string `json:"output"` | |
| 232 | - | Width int `json:"width"` | |
| 233 | - | Height int `json:"height"` | |
| 234 | - | Stride int `json:"stride"` | |
| 235 | - | Scale float64 `json:"scale"` | |
| 236 | - | LinksOnly bool `json:"links_only"` | |
| 237 | - | Targets []struct { | |
| 238 | - | Kind string `json:"kind"` | |
| 239 | - | Rect [4]int `json:"rect"` | |
| 253 | + | Output string `json:"output"` | |
| 254 | + | Width int `json:"width"` | |
| 255 | + | Height int `json:"height"` | |
| 256 | + | Stride int `json:"stride"` | |
| 257 | + | Scale float64 `json:"scale"` | |
| 258 | + | DetectMS float64 `json:"detect_ms"` | |
| 259 | + | TotalMS float64 `json:"total_ms"` | |
| 260 | + | Targets []struct { | |
| 261 | + | Rect [4]int `json:"rect"` | |
| 262 | + | Score float32 `json:"score"` | |
| 240 | 263 | } `json:"targets"` | |
| 241 | 264 | } | |
| 242 | 265 | require.NoError(t, json.Unmarshal(data, &meta)) | |
| 243 | 266 | require.Equal(t, "HEADLESS-1", meta.Output) | |
| 244 | - | require.Equal(t, w, meta.Width) | |
| 245 | - | require.Equal(t, hgt, meta.Height) | |
| 246 | - | require.Equal(t, w*4, meta.Stride) | |
| 267 | + | require.Equal(t, 64, meta.Width) | |
| 268 | + | require.Equal(t, 32, meta.Height) | |
| 269 | + | require.Equal(t, 64*4, meta.Stride) | |
| 247 | 270 | require.Equal(t, 1.0, meta.Scale) | |
| 248 | - | require.Len(t, meta.Targets, len(res.Targets)) | |
| 249 | - | for i, tg := range res.Targets { | |
| 250 | - | require.Equal(t, [4]int{tg.Rect.X, tg.Rect.Y, tg.Rect.X + tg.Rect.W, tg.Rect.Y + tg.Rect.H}, meta.Targets[i].Rect) | |
| 251 | - | } | |
| 271 | + | require.GreaterOrEqual(t, meta.TotalMS, meta.DetectMS) | |
| 272 | + | require.Len(t, meta.Targets, 1) | |
| 273 | + | require.Equal(t, [4]int{8, 4, 40, 20}, meta.Targets[0].Rect) | |
| 274 | + | require.Equal(t, float32(0.75), meta.Targets[0].Score) | |
| 252 | 275 | ||
| 253 | 276 | f, err := os.Open(filepath.Join(dir, png)) | |
| 254 | 277 | require.NoError(t, err) | |
| 255 | 278 | defer f.Close() | |
| 256 | 279 | img, _, err := image.Decode(f) | |
| 257 | 280 | require.NoError(t, err) | |
| 258 | - | require.Equal(t, image.Rect(0, 0, w, hgt), img.Bounds()) | |
| 281 | + | require.Equal(t, image.Rect(0, 0, 64, 32), img.Bounds()) | |
| 259 | 282 | r, g, b, _ := img.At(0, 0).RGBA() | |
| 260 | - | require.Equal(t, uint32(pix[2])*0x101, r) | |
| 261 | - | require.Equal(t, uint32(pix[1])*0x101, g) | |
| 262 | - | require.Equal(t, uint32(pix[0])*0x101, b) | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | // scanOnceDone sends a request that fails fast on a blank capture and waits | |
| 266 | - | // for its result, which proves the previous dump finished. | |
| 267 | - | func scanOnceDone(t *testing.T, h *harness) ports.HintScanResult { | |
| 268 | - | t.Helper() | |
| 269 | - | go func() { | |
| 270 | - | creq := <-h.captures | |
| 271 | - | h.captured <- ports.CaptureDone{ID: creq.ID, Err: errors.New("stop")} | |
| 272 | - | }() | |
| 273 | - | h.requests <- ports.HintScanRequest{Seq: 99, Output: "x", Width: 8, Height: 8, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 8}}}} | |
| 274 | - | return <-h.results | |
| 283 | + | require.Equal(t, uint32(30)*0x101, r) | |
| 284 | + | require.Equal(t, uint32(20)*0x101, g) | |
| 285 | + | require.Equal(t, uint32(10)*0x101, b) | |
| 275 | 286 | } | |
| 276 | 287 | ||
| 277 | 288 | func TestHintDumpDisabled(t *testing.T) { | |
| 278 | - | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 279 | - | t.Skip("tesseract not on PATH") | |
| 280 | - | } | |
| 281 | - | pix, w, hgt := loadBGRA(t) | |
| 282 | 289 | dir := filepath.Join(t.TempDir(), "hints") | |
| 283 | 290 | h := start(t, 5*time.Second) | |
| 284 | - | scanOnce(t, h, pix, w, hgt, 1) | |
| 285 | - | scanOnceDone(t, h) | |
| 291 | + | scanOnce(t, h, 1) | |
| 286 | 292 | _, err := os.Stat(dir) | |
| 287 | 293 | require.ErrorIs(t, err, os.ErrNotExist) | |
| 288 | 294 | } | |
| @@ -302,67 +308,25 @@ func TestHintDumpRetention(t *testing.T) { | |||
| 302 | 308 | require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name()) | |
| 303 | 309 | } | |
| 304 | 310 | ||
| 305 | - | func TestScanMergesSource(t *testing.T) { | |
| 306 | - | pix, w, hgt := loadBGRA(t) | |
| 307 | - | src := newMocktargetSource(t) | |
| 308 | - | src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return | |
| 309 | - | wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{X: 0, Y: 0, W: w, H: hgt}}} | |
| 310 | - | at := []ports.HintTarget{{Rect: ports.Rect{X: 5, Y: 5, W: 30, H: 10}, Kind: ports.HintLink}} | |
| 311 | - | src.EXPECT().Targets(mock.Anything, wins, false).Return(at, map[ports.WindowID]bool{4: true}, nil).Once() | |
| 312 | - | h := startWith(t, 5*time.Second, Options{Source: src}) | |
| 313 | - | go func() { | |
| 314 | - | creq := <-h.captures | |
| 315 | - | copy(creq.Mapped, pix) | |
| 316 | - | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 317 | - | }() | |
| 318 | - | h.requests <- ports.HintScanRequest{Seq: 1, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: wins} | |
| 319 | - | res := <-h.results | |
| 320 | - | require.NoError(t, res.Err) | |
| 321 | - | // The whole frame is covered: only the accessibility target remains. | |
| 322 | - | require.Equal(t, at, res.Targets) | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | func TestScanSourceErrorUsesOCR(t *testing.T) { | |
| 326 | - | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 327 | - | t.Skip("tesseract not on PATH") | |
| 328 | - | } | |
| 329 | - | pix, w, hgt := loadBGRA(t) | |
| 330 | - | src := newMocktargetSource(t) | |
| 331 | - | src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return | |
| 332 | - | wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}} | |
| 333 | - | src.EXPECT().Targets(mock.Anything, wins, false).Return(nil, nil, errors.New("bus gone")).Once() | |
| 334 | - | h := startWith(t, 5*time.Second, Options{Source: src}) | |
| 335 | - | go func() { | |
| 336 | - | creq := <-h.captures | |
| 337 | - | copy(creq.Mapped, pix) | |
| 338 | - | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 339 | - | }() | |
| 340 | - | h.requests <- ports.HintScanRequest{Seq: 1, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: wins} | |
| 341 | - | res := <-h.results | |
| 342 | - | require.NoError(t, res.Err) | |
| 343 | - | require.NotEmpty(t, res.Targets) | |
| 344 | - | } | |
| 345 | - | ||
| 346 | 311 | // The worker keeps no frame: the mapped buffer belongs to the scan, and to the | |
| 347 | 312 | // dump job until it is written; every mapping is released afterwards. | |
| 348 | 313 | func TestWorkerHoldsNoFrameAfterScan(t *testing.T) { | |
| 349 | 314 | for _, dumpDir := range []string{"", filepath.Join(t.TempDir(), "hints")} { | |
| 350 | - | // OCR is "installed" but its binary cannot run: only the memory matters. | |
| 351 | - | w := &worker{dumpDir: dumpDir, ocr: ocrState{done: true, binary: "/nonexistent/tesseract", lang: "eng"}} | |
| 352 | - | requests := make(chan ports.HintScanRequest) | |
| 315 | + | det := newMockdetector(t) | |
| 316 | + | det.EXPECT().Detect(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 317 | + | w := &worker{dumpDir: dumpDir, det: det} | |
| 353 | 318 | captures := make(chan ports.CaptureRequest, 1) | |
| 354 | 319 | captured := make(chan ports.CaptureDone, 1) | |
| 355 | - | results := make(chan ports.HintScanResult, 1) | |
| 356 | 320 | go func() { | |
| 357 | 321 | creq := <-captures | |
| 358 | 322 | captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 359 | 323 | }() | |
| 360 | - | req := ports.HintScanRequest{Output: "o", Width: 64, Height: 32, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 32}}}} | |
| 361 | - | res := w.serve(context.Background(), req, requests, captures, captured, results, 5*time.Second) | |
| 324 | + | req := ports.HintScanRequest{Output: "o", Width: 64, Height: 32, Scale: 1} | |
| 325 | + | res := w.serve(context.Background(), req, make(chan ports.HintScanRequest), captures, captured, make(chan ports.HintScanResult, 1), 5*time.Second) | |
| 362 | 326 | require.NoError(t, res.Err) | |
| 363 | 327 | if dumpDir == "" { | |
| 364 | 328 | require.Nil(t, w.pending) | |
| 365 | - | require.Zero(t, w.live, "frame and crops are released before the result") | |
| 329 | + | require.Zero(t, w.live, "the frame is released before the result") | |
| 366 | 330 | } else { | |
| 367 | 331 | require.NotNil(t, w.pending) | |
| 368 | 332 | require.Equal(t, 1, w.live, "only the frame, kept for the dump") | |
| @@ -370,7 +334,6 @@ func TestWorkerHoldsNoFrameAfterScan(t *testing.T) { | |||
| 370 | 334 | w.dump(context.Background()) | |
| 371 | 335 | require.Nil(t, w.pending) | |
| 372 | 336 | require.Zero(t, w.live) | |
| 373 | - | require.Empty(t, w.cache) // OCR failed: nothing cached | |
| 374 | 337 | } | |
| 375 | 338 | } | |
| 376 | 339 | ||
| @@ -389,39 +352,6 @@ func TestTimedOutCaptureIsReleasedWhenDone(t *testing.T) { | |||
| 389 | 352 | require.Zero(t, w.live) | |
| 390 | 353 | } | |
| 391 | 354 | ||
| 392 | - | // Without tesseract OCR is off for good: no capture, only accessibility targets. | |
| 393 | - | func TestNoTesseractSkipsCapture(t *testing.T) { | |
| 394 | - | t.Setenv("PATH", t.TempDir()) | |
| 395 | - | button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{W: 5, H: 5}} | |
| 396 | - | covered := ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: 30, W: 5, H: 5}} | |
| 397 | - | wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}, {ID: 2, Rect: ports.Rect{X: 20, W: 20, H: 20}}} | |
| 398 | - | src := newMocktargetSource(t) | |
| 399 | - | src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return | |
| 400 | - | src.EXPECT().Targets(mock.Anything, wins, false).Return([]ports.HintTarget{button, covered}, map[ports.WindowID]bool{2: true}, nil).Times(2) | |
| 401 | - | h := startWith(t, time.Second, Options{Source: src}) | |
| 402 | - | for seq := uint64(1); seq <= 2; seq++ { | |
| 403 | - | h.requests <- ports.HintScanRequest{Seq: seq, Output: "o", Width: 40, Height: 20, Scale: 1, Windows: wins} | |
| 404 | - | res := <-h.results | |
| 405 | - | require.NoError(t, res.Err) | |
| 406 | - | require.Equal(t, []ports.HintTarget{button, covered}, res.Targets) | |
| 407 | - | } | |
| 408 | - | require.Empty(t, h.captures) | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | // With debug dumps on, the frame is still captured (for the dump only). | |
| 412 | - | func TestNoTesseractDumpStillCaptures(t *testing.T) { | |
| 413 | - | t.Setenv("PATH", t.TempDir()) | |
| 414 | - | dir := filepath.Join(t.TempDir(), "hints") | |
| 415 | - | h := startWith(t, 5*time.Second, Options{DumpDir: dir}) | |
| 416 | - | go func() { | |
| 417 | - | creq := <-h.captures | |
| 418 | - | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 419 | - | }() | |
| 420 | - | h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 40, Height: 20, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 40, H: 20}}}} | |
| 421 | - | require.NoError(t, (<-h.results).Err) | |
| 422 | - | waitFiles(t, dir, 3) | |
| 423 | - | } | |
| 424 | - | ||
| 425 | 355 | // A capture that completes after its scan timed out is released while the | |
| 426 | 356 | // worker is idle, without another scan, and its reply does not linger. | |
| 427 | 357 | func TestHintLateCaptureReaped(t *testing.T) { | |
D internal/adapters/hints/hybrid_test.go +0 -177
| @@ -1,177 +0,0 @@ | |||
| 1 | - | package hints | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "hash/maphash" | |
| 6 | - | "image" | |
| 7 | - | "image/draw" | |
| 8 | - | "image/png" | |
| 9 | - | "os" | |
| 10 | - | "os/exec" | |
| 11 | - | "runtime" | |
| 12 | - | "testing" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/neferwl/internal/ports" | |
| 15 | - | "github.com/stretchr/testify/require" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | func TestWindowCrop(t *testing.T) { | |
| 19 | - | for _, tc := range []struct { | |
| 20 | - | name string | |
| 21 | - | r ports.Rect | |
| 22 | - | scale float64 | |
| 23 | - | want image.Rectangle | |
| 24 | - | }{ | |
| 25 | - | {"unit", ports.Rect{X: 10, Y: 20, W: 100, H: 50}, 1, image.Rect(10, 20, 110, 70)}, | |
| 26 | - | {"fractional", ports.Rect{X: 11, Y: 21, W: 100, H: 50}, 1.2, image.Rect(13, 25, 134, 86)}, | |
| 27 | - | {"off output", ports.Rect{X: -10, Y: 20, W: 100, H: 100}, 1.2, image.Rect(0, 24, 108, 100)}, | |
| 28 | - | {"outside", ports.Rect{X: 1000, Y: 20, W: 100, H: 100}, 1, image.Rectangle{}}, | |
| 29 | - | } { | |
| 30 | - | t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, windowCrop(tc.r, tc.scale, 200, 100)) }) | |
| 31 | - | } | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func sourceResult(ar atspiResult) *pendingSource { | |
| 35 | - | p := &pendingSource{done: make(chan struct{}), res: ar} | |
| 36 | - | close(p.done) | |
| 37 | - | return p | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | func TestHybridCacheAndMerge(t *testing.T) { | |
| 41 | - | t.Setenv("PATH", t.TempDir()) // Cached OCR must not need tesseract. | |
| 42 | - | buf := make([]byte, 200*100*4) | |
| 43 | - | wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}, {ID: 2, Rect: ports.Rect{X: 100, W: 100, H: 100}}} | |
| 44 | - | req := ports.HintScanRequest{Output: "o", Width: 200, Height: 100, Scale: 1, Windows: wins} | |
| 45 | - | seed := maphash.MakeSeed() | |
| 46 | - | r := windowCrop(wins[1].Rect, 1, 200, 100) | |
| 47 | - | oldWorkers := ocrWorkers | |
| 48 | - | ocrWorkers = 1 // one band covers the crop | |
| 49 | - | t.Cleanup(func() { ocrWorkers = oldWorkers }) | |
| 50 | - | ext := image.Rect(0, 0, 100, 100) | |
| 51 | - | entry := bandCache{hash: (&worker{cacheSeed: seed}).cropHash(buf, 800, r), ext: ext, words: []OCRWord{{Text: "example.org", Rect: image.Rect(10, 20, 90, 40)}}} | |
| 52 | - | button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 1, Y: 1, W: 10, H: 10}} | |
| 53 | - | link := ports.HintTarget{Kind: ports.HintLink, Text: true, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}} | |
| 54 | - | uncoveredButton := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 101, Y: 1, W: 10, H: 10}} | |
| 55 | - | ar := atspiResult{targets: []ports.HintTarget{button, uncoveredButton}, covered: map[ports.WindowID]bool{1: true}} | |
| 56 | - | w := worker{ocr: ocrState{done: true, binary: "tesseract", lang: "eng"}, cacheSeed: seed, cache: map[bandKey]bandCache{{output: "o", window: 2, band: 0, bands: 1}: entry}} | |
| 57 | - | got := w.scan(context.Background(), req, frame{pix: buf, stride: 800}, 0, ports.HintScanResult{}, sourceResult(ar)) | |
| 58 | - | require.Equal(t, []ports.HintTarget{button, uncoveredButton, link}, got.Targets) | |
| 59 | - | req.LinksOnly = true | |
| 60 | - | got = w.scan(context.Background(), req, frame{pix: buf, stride: 800}, 0, ports.HintScanResult{}, sourceResult(ar)) | |
| 61 | - | require.Equal(t, []ports.HintTarget{link}, got.Targets) | |
| 62 | - | // Changing one pixel invalidates only the crop, never leaving stale links. | |
| 63 | - | buf[100*4]++ | |
| 64 | - | got = w.scan(context.Background(), req, frame{pix: buf, stride: 800}, 0, ports.HintScanResult{}, sourceResult(ar)) | |
| 65 | - | require.Empty(t, got.Targets) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | func TestMissingTesseractKeepsAccessibilityButtons(t *testing.T) { | |
| 69 | - | t.Setenv("PATH", t.TempDir()) | |
| 70 | - | w := worker{} | |
| 71 | - | button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{W: 5, H: 5}} | |
| 72 | - | req := ports.HintScanRequest{Width: 20, Height: 20, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}}} | |
| 73 | - | got := w.scan(context.Background(), req, frame{pix: make([]byte, 1600), stride: 80}, 0, ports.HintScanResult{}, sourceResult(atspiResult{targets: []ports.HintTarget{button}})) | |
| 74 | - | require.NoError(t, got.Err) | |
| 75 | - | require.Equal(t, []ports.HintTarget{button}, got.Targets) | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | func TestNoWindowsProducesNoPixelTargets(t *testing.T) { | |
| 79 | - | pix, width, height := loadBGRA(t) | |
| 80 | - | w := worker{} | |
| 81 | - | got := w.scan(context.Background(), ports.HintScanRequest{Width: width, Height: height}, frame{pix: pix, stride: width * 4}, 0, ports.HintScanResult{}, sourceResult(atspiResult{})) | |
| 82 | - | require.Empty(t, got.Targets) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | func TestStripMappingAndPolarity(t *testing.T) { | |
| 86 | - | img := image.NewRGBA(image.Rect(0, 0, 200, 200)) | |
| 87 | - | strips := packLines(img, []image.Rectangle{image.Rect(20, 20, 160, 40), image.Rect(20, 80, 160, 112)}, 2, true) | |
| 88 | - | require.Len(t, strips, 2) | |
| 89 | - | for _, s := range strips { | |
| 90 | - | for _, off := range s.offsets { | |
| 91 | - | require.Equal(t, 1, off.factor) | |
| 92 | - | require.Equal(t, uint8(255), s.img.GrayAt(off.packed.Min.X, off.packed.Min.Y).Y) | |
| 93 | - | } | |
| 94 | - | } | |
| 95 | - | small := packLines(img, []image.Rectangle{image.Rect(20, 20, 100, 30)}, 1, true) | |
| 96 | - | require.Equal(t, 2, small[0].offsets[0].factor) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | func TestCropGrayAndHash(t *testing.T) { | |
| 100 | - | buf := make([]byte, 4*4*4) | |
| 101 | - | for y := 0; y < 4; y++ { | |
| 102 | - | for x := 0; x < 4; x++ { | |
| 103 | - | copy(buf[y*16+x*4:], []byte{0, 0, 255, 255}) // BGRA red | |
| 104 | - | } | |
| 105 | - | } | |
| 106 | - | g := cropGray(buf, 16, image.Rect(1, 1, 3, 3), make([]byte, 4)) | |
| 107 | - | require.Equal(t, image.Rect(0, 0, 2, 2), g.Bounds()) | |
| 108 | - | require.Equal(t, uint8(76), g.GrayAt(1, 1).Y) | |
| 109 | - | w := worker{cacheSeed: maphash.MakeSeed()} | |
| 110 | - | a := w.cropHash(buf, 16, image.Rect(1, 1, 3, 3)) | |
| 111 | - | buf[0]++ // outside the crop | |
| 112 | - | require.Equal(t, a, w.cropHash(buf, 16, image.Rect(1, 1, 3, 3))) | |
| 113 | - | buf[16+4]++ | |
| 114 | - | require.NotEqual(t, a, w.cropHash(buf, 16, image.Rect(1, 1, 3, 3))) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | func TestUpscaleBandGray(t *testing.T) { | |
| 118 | - | g := image.NewGray(image.Rect(0, 0, 3, 2)) | |
| 119 | - | for i := range g.Pix { | |
| 120 | - | g.Pix[i] = uint8(i + 1) | |
| 121 | - | } | |
| 122 | - | sub := g.SubImage(image.Rect(1, 1, 3, 2)).(*image.Gray) | |
| 123 | - | up := upscaleBand(sub) | |
| 124 | - | require.Equal(t, image.Rect(0, 0, 4, 2), up.Bounds()) | |
| 125 | - | require.Equal(t, []uint8{5, 5, 6, 6, 5, 5, 6, 6}, up.Pix) | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // A blinking cursor changes one band: the other bands keep their cached | |
| 129 | - | // words, and only the changed one is recognized again. | |
| 130 | - | func TestBandCacheSurvivesCursorBlink(t *testing.T) { | |
| 131 | - | binary, err := exec.LookPath("tesseract") | |
| 132 | - | if err != nil { | |
| 133 | - | t.Skip("tesseract not on PATH") | |
| 134 | - | } | |
| 135 | - | old := ocrWorkers | |
| 136 | - | ocrWorkers = 4 | |
| 137 | - | t.Cleanup(func() { ocrWorkers = old }) | |
| 138 | - | f, err := os.Open("testdata/terminal.png") | |
| 139 | - | require.NoError(t, err) | |
| 140 | - | defer f.Close() | |
| 141 | - | src, err := png.Decode(f) | |
| 142 | - | require.NoError(t, err) | |
| 143 | - | w, h := src.Bounds().Dx(), src.Bounds().Dy() | |
| 144 | - | rgba := image.NewRGBA(image.Rect(0, 0, w, h)) | |
| 145 | - | draw.Draw(rgba, rgba.Bounds(), src, src.Bounds().Min, draw.Src) | |
| 146 | - | buf := make([]byte, w*h*4) | |
| 147 | - | for i := 0; i < len(buf); i += 4 { | |
| 148 | - | buf[i], buf[i+1], buf[i+2], buf[i+3] = rgba.Pix[i+2], rgba.Pix[i+1], rgba.Pix[i], 0xff | |
| 149 | - | } | |
| 150 | - | _ = binary | |
| 151 | - | wk := &worker{} | |
| 152 | - | req := ports.HintScanRequest{Output: "o", Width: w, Height: h, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}}} | |
| 153 | - | first := wk.scan(context.Background(), req, frame{pix: buf, stride: w * 4}, 0, ports.HintScanResult{}, sourceResult(atspiResult{})) | |
| 154 | - | require.NotEmpty(t, first.Targets) | |
| 155 | - | require.Len(t, wk.cache, min(runtime.GOMAXPROCS(0), 4)) | |
| 156 | - | ||
| 157 | - | // The same frame: every band hits. | |
| 158 | - | again := wk.scan(context.Background(), req, frame{pix: buf, stride: w * 4}, 0, ports.HintScanResult{}, sourceResult(atspiResult{})) | |
| 159 | - | require.Equal(t, first.Targets, again.Targets) | |
| 160 | - | ||
| 161 | - | // One pixel at the bottom blinks: only the last band (and the context of | |
| 162 | - | // none other) is invalidated. | |
| 163 | - | before := map[bandKey]uint64{} | |
| 164 | - | for k, v := range wk.cache { | |
| 165 | - | before[k] = v.hash | |
| 166 | - | } | |
| 167 | - | buf[((h-1)*w+3)*4] ^= 0xff | |
| 168 | - | blink := wk.scan(context.Background(), req, frame{pix: buf, stride: w * 4}, 0, ports.HintScanResult{}, sourceResult(atspiResult{})) | |
| 169 | - | require.Equal(t, first.Targets, blink.Targets) | |
| 170 | - | changed := 0 | |
| 171 | - | for k, v := range wk.cache { | |
| 172 | - | if before[k] != v.hash { | |
| 173 | - | changed++ | |
| 174 | - | } | |
| 175 | - | } | |
| 176 | - | require.Equal(t, 1, changed) | |
| 177 | - | } | |
D internal/adapters/hints/language_runner_mock_test.go +0 -130
| @@ -1,130 +0,0 @@ | |||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |
| 2 | - | // github.com/vektra/mockery | |
| 3 | - | // template: testify | |
| 4 | - | ||
| 5 | - | package hints | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | "context" | |
| 9 | - | ||
| 10 | - | mock "github.com/stretchr/testify/mock" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // newMocklanguageRunner creates a new instance of mocklanguageRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | - | // The first argument is typically a *testing.T value. | |
| 15 | - | func newMocklanguageRunner(t interface { | |
| 16 | - | mock.TestingT | |
| 17 | - | Cleanup(func()) | |
| 18 | - | }) *mocklanguageRunner { | |
| 19 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | - | helper.Helper() | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | mock := &mocklanguageRunner{} | |
| 24 | - | mock.Mock.Test(t) | |
| 25 | - | ||
| 26 | - | t.Cleanup(func() { | |
| 27 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | - | helper.Helper() | |
| 29 | - | } | |
| 30 | - | mock.AssertExpectations(t) | |
| 31 | - | }) | |
| 32 | - | ||
| 33 | - | return mock | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // mocklanguageRunner is an autogenerated mock type for the languageRunner type | |
| 37 | - | type mocklanguageRunner struct { | |
| 38 | - | mock.Mock | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | type mocklanguageRunner_Expecter struct { | |
| 42 | - | mock *mock.Mock | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func (_m *mocklanguageRunner) EXPECT() *mocklanguageRunner_Expecter { | |
| 46 | - | return &mocklanguageRunner_Expecter{mock: &_m.Mock} | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Output provides a mock function for the type mocklanguageRunner | |
| 50 | - | func (_mock *mocklanguageRunner) Output(context1 context.Context, s string, strings ...string) ([]byte, error) { | |
| 51 | - | var tmpRet mock.Arguments | |
| 52 | - | if len(strings) > 0 { | |
| 53 | - | tmpRet = _mock.Called(context1, s, strings) | |
| 54 | - | } else { | |
| 55 | - | tmpRet = _mock.Called(context1, s) | |
| 56 | - | } | |
| 57 | - | ret := tmpRet | |
| 58 | - | ||
| 59 | - | if len(ret) == 0 { | |
| 60 | - | panic("no return value specified for Output") | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | var r0 []byte | |
| 64 | - | var r1 error | |
| 65 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...string) ([]byte, error)); ok { | |
| 66 | - | return returnFunc(context1, s, strings...) | |
| 67 | - | } | |
| 68 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...string) []byte); ok { | |
| 69 | - | r0 = returnFunc(context1, s, strings...) | |
| 70 | - | } else { | |
| 71 | - | if ret.Get(0) != nil { | |
| 72 | - | r0 = ret.Get(0).([]byte) | |
| 73 | - | } | |
| 74 | - | } | |
| 75 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, ...string) error); ok { | |
| 76 | - | r1 = returnFunc(context1, s, strings...) | |
| 77 | - | } else { | |
| 78 | - | r1 = ret.Error(1) | |
| 79 | - | } | |
| 80 | - | return r0, r1 | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | // mocklanguageRunner_Output_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Output' | |
| 84 | - | type mocklanguageRunner_Output_Call struct { | |
| 85 | - | *mock.Call | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | // Output is a helper method to define mock.On call | |
| 89 | - | // - context1 context.Context | |
| 90 | - | // - s string | |
| 91 | - | // - strings ...string | |
| 92 | - | func (_e *mocklanguageRunner_Expecter) Output(context1 any, s any, strings ...any) *mocklanguageRunner_Output_Call { | |
| 93 | - | return &mocklanguageRunner_Output_Call{Call: _e.mock.On("Output", | |
| 94 | - | append([]any{context1, s}, strings...)...)} | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | func (_c *mocklanguageRunner_Output_Call) Run(run func(context1 context.Context, s string, strings ...string)) *mocklanguageRunner_Output_Call { | |
| 98 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 99 | - | var arg0 context.Context | |
| 100 | - | if args[0] != nil { | |
| 101 | - | arg0 = args[0].(context.Context) | |
| 102 | - | } | |
| 103 | - | var arg1 string | |
| 104 | - | if args[1] != nil { | |
| 105 | - | arg1 = args[1].(string) | |
| 106 | - | } | |
| 107 | - | var arg2 []string | |
| 108 | - | var variadicArgs []string | |
| 109 | - | if len(args) > 2 { | |
| 110 | - | variadicArgs = args[2].([]string) | |
| 111 | - | } | |
| 112 | - | arg2 = variadicArgs | |
| 113 | - | run( | |
| 114 | - | arg0, | |
| 115 | - | arg1, | |
| 116 | - | arg2..., | |
| 117 | - | ) | |
| 118 | - | }) | |
| 119 | - | return _c | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func (_c *mocklanguageRunner_Output_Call) Return(bytes []byte, err error) *mocklanguageRunner_Output_Call { | |
| 123 | - | _c.Call.Return(bytes, err) | |
| 124 | - | return _c | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | func (_c *mocklanguageRunner_Output_Call) RunAndReturn(run func(context1 context.Context, s string, strings ...string) ([]byte, error)) *mocklanguageRunner_Output_Call { | |
| 128 | - | _c.Call.Return(run) | |
| 129 | - | return _c | |
| 130 | - | } | |
D internal/adapters/hints/ocr.go +0 -460
| @@ -1,460 +0,0 @@ | |||
| 1 | - | package hints | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "bytes" | |
| 6 | - | "context" | |
| 7 | - | "fmt" | |
| 8 | - | "image" | |
| 9 | - | "image/png" | |
| 10 | - | "io" | |
| 11 | - | "math" | |
| 12 | - | "net" | |
| 13 | - | "os" | |
| 14 | - | "os/exec" | |
| 15 | - | "path/filepath" | |
| 16 | - | "regexp" | |
| 17 | - | "runtime" | |
| 18 | - | "sort" | |
| 19 | - | "strconv" | |
| 20 | - | "strings" | |
| 21 | - | "sync" | |
| 22 | - | "time" | |
| 23 | - | ||
| 24 | - | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 25 | - | "github.com/bnema/neferwl/internal/ports" | |
| 26 | - | ) | |
| 27 | - | ||
| 28 | - | // OCRWord is a recognized word in physical output coordinates. | |
| 29 | - | type OCRWord struct { | |
| 30 | - | Text string `json:"text"` | |
| 31 | - | Rect image.Rectangle `json:"rect"` | |
| 32 | - | Confidence float64 `json:"confidence"` | |
| 33 | - | // Underlined is set when a thin line runs under the word. UnderL and | |
| 34 | - | // UnderR are the x extent of that line (crop-local), which can reach the | |
| 35 | - | // neighbouring words of one underlined link. | |
| 36 | - | Underlined bool `json:"underlined,omitempty"` | |
| 37 | - | UnderL int `json:"under_l,omitempty"` | |
| 38 | - | UnderR int `json:"under_r,omitempty"` | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | var languageWarning sync.Once | |
| 42 | - | var failureWarning sync.Once | |
| 43 | - | ||
| 44 | - | // languageRunner bounds the external language-discovery process independently | |
| 45 | - | // of recognition, which has not started yet when discovery runs. | |
| 46 | - | type languageRunner interface { | |
| 47 | - | Output(context.Context, string, ...string) ([]byte, error) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | type execLanguageRunner struct{} | |
| 51 | - | ||
| 52 | - | func (execLanguageRunner) Output(ctx context.Context, binary string, args ...string) ([]byte, error) { | |
| 53 | - | return exec.CommandContext(ctx, binary, args...).Output() | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | const languageTimeout = 5 * time.Second | |
| 57 | - | ||
| 58 | - | func ocrLanguage(ctx context.Context, binary string) (string, error) { | |
| 59 | - | return discoverLanguage(ctx, binary, execLanguageRunner{}, languageTimeout) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func discoverLanguage(ctx context.Context, binary string, runner languageRunner, timeout time.Duration) (string, error) { | |
| 63 | - | dir := os.Getenv("TESSDATA_PREFIX") | |
| 64 | - | if dir == "" { | |
| 65 | - | dir = "/usr/share/tessdata" | |
| 66 | - | } | |
| 67 | - | if _, err := os.Stat(filepath.Join(dir, "eng.traineddata")); err == nil { | |
| 68 | - | return "eng", nil | |
| 69 | - | } | |
| 70 | - | ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 71 | - | defer cancel() | |
| 72 | - | out, err := runner.Output(ctx, binary, "--list-langs") | |
| 73 | - | if err != nil { | |
| 74 | - | return "", err | |
| 75 | - | } | |
| 76 | - | for _, line := range strings.Split(string(out), "\n")[1:] { | |
| 77 | - | lang := strings.TrimSpace(line) | |
| 78 | - | if lang != "" && lang != "osd" { | |
| 79 | - | languageWarning.Do(func() { | |
| 80 | - | log := logging.For(ctx, "hints") | |
| 81 | - | log.Warn().Str("language", lang).Msg("English OCR data missing; install tesseract-data-eng") | |
| 82 | - | }) | |
| 83 | - | return lang, nil | |
| 84 | - | } | |
| 85 | - | } | |
| 86 | - | return "", fmt.Errorf("no OCR language available") | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func parseTSV(data []byte, offset image.Point) ([]OCRWord, error) { | |
| 90 | - | var words []OCRWord | |
| 91 | - | scanner := bufio.NewScanner(bytes.NewReader(data)) | |
| 92 | - | scanner.Buffer(make([]byte, 4096), 1024*1024) | |
| 93 | - | for scanner.Scan() { | |
| 94 | - | fields := strings.SplitN(scanner.Text(), "\t", 12) | |
| 95 | - | if len(fields) != 12 || fields[0] != "5" { | |
| 96 | - | continue | |
| 97 | - | } | |
| 98 | - | var values [4]int | |
| 99 | - | for i := range values { | |
| 100 | - | v, err := strconv.Atoi(fields[6+i]) | |
| 101 | - | if err != nil { | |
| 102 | - | return nil, fmt.Errorf("OCR TSV coordinate: %w", err) | |
| 103 | - | } | |
| 104 | - | values[i] = v | |
| 105 | - | } | |
| 106 | - | conf, err := strconv.ParseFloat(fields[10], 64) | |
| 107 | - | if err != nil { | |
| 108 | - | return nil, fmt.Errorf("OCR TSV confidence: %w", err) | |
| 109 | - | } | |
| 110 | - | if values[2] <= 0 || values[3] <= 0 || strings.TrimSpace(fields[11]) == "" { | |
| 111 | - | continue | |
| 112 | - | } | |
| 113 | - | r := image.Rect(values[0], values[1], values[0]+values[2], values[1]+values[3]).Add(offset) | |
| 114 | - | words = append(words, OCRWord{Text: fields[11], Rect: r, Confidence: conf}) | |
| 115 | - | } | |
| 116 | - | return words, scanner.Err() | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // ocrWorkers is the maximum number of bands recognized in parallel. 4 keeps | |
| 120 | - | // the tesseract processes at about 60% of the memory of 8 for the same time. | |
| 121 | - | var ocrWorkers = 4 | |
| 122 | - | ||
| 123 | - | // pngLevel is the compression of non-grayscale images piped to tesseract. | |
| 124 | - | var pngLevel = png.BestSpeed | |
| 125 | - | ||
| 126 | - | // rawPGM feeds grayscale images to tesseract as binary PGM instead of PNG. | |
| 127 | - | var rawPGM = true | |
| 128 | - | ||
| 129 | - | // ocrState is the tesseract installation, resolved once. | |
| 130 | - | type ocrState struct { | |
| 131 | - | done bool | |
| 132 | - | binary string | |
| 133 | - | lang string | |
| 134 | - | err error | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | func (s *ocrState) resolve(ctx context.Context) error { | |
| 138 | - | if !s.done { | |
| 139 | - | s.done = true | |
| 140 | - | if s.binary, s.err = exec.LookPath("tesseract"); s.err == nil { | |
| 141 | - | s.lang, s.err = ocrLanguage(ctx, s.binary) | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | return s.err | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // ocrReady resolves tesseract on the first call. When it is missing, OCR is | |
| 148 | - | // disabled for the life of the process and the warning is logged once. | |
| 149 | - | func (w *worker) ocrReady(ctx context.Context) bool { | |
| 150 | - | if err := w.ocr.resolve(ctx); err != nil { | |
| 151 | - | failureWarning.Do(func() { | |
| 152 | - | log := logging.For(ctx, "hints") | |
| 153 | - | log.Warn().Err(err).Msg("OCR unavailable; install tesseract and tesseract-data-eng") | |
| 154 | - | }) | |
| 155 | - | return false | |
| 156 | - | } | |
| 157 | - | return true | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | // band is a horizontal slice of a window crop recognized by one tesseract | |
| 161 | - | // process. It reads rows [top,bottom) and owns the words centred in | |
| 162 | - | // [ownTop,ownBottom); the margin only supplies context. | |
| 163 | - | type band struct { | |
| 164 | - | idx, n int | |
| 165 | - | top, bottom int | |
| 166 | - | ownTop, ownBottom int | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | func bandsOf(height int) []band { | |
| 170 | - | n := min(runtime.GOMAXPROCS(0), ocrWorkers, height) | |
| 171 | - | bands := make([]band, 0, max(n, 0)) | |
| 172 | - | for i := range n { | |
| 173 | - | ownTop, ownBottom := i*height/n, (i+1)*height/n | |
| 174 | - | bands = append(bands, band{idx: i, n: n, top: max(0, ownTop-40), bottom: min(height, ownBottom+40), ownTop: ownTop, ownBottom: ownBottom}) | |
| 175 | - | } | |
| 176 | - | return bands | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // recognize reads the bands of jobs that missed the cache, in parallel. | |
| 180 | - | func (w *worker) recognize(ctx context.Context, jobs []*bandJob) { | |
| 181 | - | ctx, cancel := context.WithTimeout(ctx, 15*time.Second) | |
| 182 | - | defer cancel() | |
| 183 | - | // One scan-wide pool: at most ocrWorkers tesseract processes run at once, | |
| 184 | - | // whatever the number of windows. | |
| 185 | - | sem := make(chan struct{}, max(ocrWorkers, 1)) | |
| 186 | - | var wg sync.WaitGroup | |
| 187 | - | for _, j := range jobs { | |
| 188 | - | if j.hit { | |
| 189 | - | continue | |
| 190 | - | } | |
| 191 | - | select { | |
| 192 | - | case sem <- struct{}{}: | |
| 193 | - | case <-ctx.Done(): | |
| 194 | - | j.err = ctx.Err() | |
| 195 | - | continue | |
| 196 | - | } | |
| 197 | - | wg.Go(func() { | |
| 198 | - | defer func() { <-sem }() | |
| 199 | - | j.words, j.err = readBand(ctx, &w.ocr, j) | |
| 200 | - | }) | |
| 201 | - | } | |
| 202 | - | wg.Wait() | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | // readBand recognizes one band; the words are crop-local and owned by it. | |
| 206 | - | func readBand(ctx context.Context, st *ocrState, j *bandJob) ([]OCRWord, error) { | |
| 207 | - | words, err := recognizeBand(ctx, st.binary, st.lang, j.gray) | |
| 208 | - | if err != nil { | |
| 209 | - | return nil, err | |
| 210 | - | } | |
| 211 | - | heights := make([]int, 0, len(words)) | |
| 212 | - | for _, word := range words { | |
| 213 | - | if len(word.Text) >= 3 { | |
| 214 | - | heights = append(heights, word.Rect.Dy()) | |
| 215 | - | } | |
| 216 | - | } | |
| 217 | - | sort.Ints(heights) | |
| 218 | - | if len(heights) > 0 && heights[len(heights)/2] < 12 { | |
| 219 | - | words, err = recognizeBand(ctx, st.binary, st.lang, upscaleBand(j.gray)) | |
| 220 | - | if err != nil { | |
| 221 | - | return nil, err | |
| 222 | - | } | |
| 223 | - | for i := range words { | |
| 224 | - | r := words[i].Rect | |
| 225 | - | words[i].Rect = image.Rect(r.Min.X/2, r.Min.Y/2, (r.Max.X+1)/2, (r.Max.Y+1)/2) | |
| 226 | - | } | |
| 227 | - | } | |
| 228 | - | markUnderlined(j.gray, words) | |
| 229 | - | owned := words[:0] | |
| 230 | - | for _, word := range words { | |
| 231 | - | // Overlap supplies context, but each word belongs to exactly one band. | |
| 232 | - | word.Rect = word.Rect.Add(image.Pt(0, j.ext.Min.Y)) | |
| 233 | - | if center := (word.Rect.Min.Y + word.Rect.Max.Y) / 2; center >= j.own[0] && center < j.own[1] { | |
| 234 | - | owned = append(owned, word) | |
| 235 | - | } | |
| 236 | - | } | |
| 237 | - | return owned, nil | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // readWords recognizes the words of img, a window crop, in parallel bands. | |
| 241 | - | func readWords(ctx context.Context, img *image.Gray) ([]OCRWord, int, error) { | |
| 242 | - | w := &worker{} | |
| 243 | - | if err := w.ocr.resolve(ctx); err != nil { | |
| 244 | - | return nil, 0, err | |
| 245 | - | } | |
| 246 | - | var jobs []*bandJob | |
| 247 | - | for _, b := range bandsOf(img.Bounds().Dy()) { | |
| 248 | - | ext := image.Rect(0, b.top, img.Bounds().Dx(), b.bottom) | |
| 249 | - | jobs = append(jobs, &bandJob{ext: ext, own: [2]int{b.ownTop, b.ownBottom}, gray: img.SubImage(ext).(*image.Gray)}) | |
| 250 | - | } | |
| 251 | - | w.recognize(ctx, jobs) | |
| 252 | - | var words []OCRWord | |
| 253 | - | for _, j := range jobs { | |
| 254 | - | if j.err != nil { | |
| 255 | - | return nil, len(jobs), j.err | |
| 256 | - | } | |
| 257 | - | for _, word := range j.words { | |
| 258 | - | if !duplicateWord(words, word) { | |
| 259 | - | words = append(words, word) | |
| 260 | - | } | |
| 261 | - | } | |
| 262 | - | } | |
| 263 | - | return words, len(jobs), nil | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | // The link patterns compile on the first OCR link check, not at process | |
| 267 | - | // start: with hint mode off nothing here costs memory or CPU. | |
| 268 | - | var domainPattern = sync.OnceValue(func() *regexp.Regexp { | |
| 269 | - | return regexp.MustCompile(`(?i)^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+([a-z]{2,24})(?::[0-9]+)?(?:[/?#]\S*)?$`) | |
| 270 | - | }) | |
| 271 | - | var schemePattern = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile(`(?i)^(?:https?|htps|file|ftp):/{1,2}\S+$`) }) | |
| 272 | - | var emailPattern = sync.OnceValue(func() *regexp.Regexp { | |
| 273 | - | return regexp.MustCompile(`(?i)^[a-z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@(?:[a-z0-9-]+\.)+[a-z]{2,24}$`) | |
| 274 | - | }) | |
| 275 | - | var fileExtensions = sync.OnceValue(func() []string { | |
| 276 | - | return 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") | |
| 277 | - | }) | |
| 278 | - | ||
| 279 | - | func isTextLink(text string) bool { | |
| 280 | - | text = strings.Trim(text, "()[]<>.,;:'\"") | |
| 281 | - | if schemePattern().MatchString(text) || (strings.HasPrefix(strings.ToLower(text), "www.") && len(text) > 4) || emailPattern().MatchString(text) { | |
| 282 | - | return true | |
| 283 | - | } | |
| 284 | - | host := text | |
| 285 | - | if h, _, err := net.SplitHostPort(text); err == nil { | |
| 286 | - | host = h | |
| 287 | - | } | |
| 288 | - | if ip := net.ParseIP(host); ip != nil && ip.To4() != nil { | |
| 289 | - | return true | |
| 290 | - | } | |
| 291 | - | m := domainPattern().FindStringSubmatch(text) | |
| 292 | - | if m == nil { | |
| 293 | - | return false | |
| 294 | - | } | |
| 295 | - | for _, ext := range fileExtensions() { | |
| 296 | - | if strings.EqualFold(m[1], ext) { | |
| 297 | - | return false | |
| 298 | - | } | |
| 299 | - | } | |
| 300 | - | return true | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | func textLinks(words []OCRWord, scale float64) []ports.HintTarget { | |
| 304 | - | if scale <= 0 { | |
| 305 | - | scale = 1 | |
| 306 | - | } | |
| 307 | - | words = append([]OCRWord(nil), words...) | |
| 308 | - | sort.Slice(words, func(i, j int) bool { | |
| 309 | - | if words[i].Rect.Min.Y != words[j].Rect.Min.Y { | |
| 310 | - | return words[i].Rect.Min.Y < words[j].Rect.Min.Y | |
| 311 | - | } | |
| 312 | - | return words[i].Rect.Min.X < words[j].Rect.Min.X | |
| 313 | - | }) | |
| 314 | - | var out []ports.HintTarget | |
| 315 | - | var urls []image.Rectangle // boxes of the URL targets, in pixels | |
| 316 | - | used := make([]bool, len(words)) | |
| 317 | - | for i, word := range words { | |
| 318 | - | if used[i] { | |
| 319 | - | continue | |
| 320 | - | } | |
| 321 | - | text, rect := word.Text, word.Rect | |
| 322 | - | // Grow only touching words on the same baseline; ordinary prose is not joined. | |
| 323 | - | for { | |
| 324 | - | next := -1 | |
| 325 | - | for j, candidate := range words { | |
| 326 | - | if j == i || used[j] || candidate.Rect.Min.X < rect.Max.X-2 { | |
| 327 | - | continue | |
| 328 | - | } | |
| 329 | - | gap := candidate.Rect.Min.X - rect.Max.X | |
| 330 | - | if gap > max(3, min(rect.Dy(), candidate.Rect.Dy())/5) { | |
| 331 | - | continue | |
| 332 | - | } | |
| 333 | - | if abs(candidate.Rect.Max.Y-rect.Max.Y) > max(3, rect.Dy()/3) { | |
| 334 | - | continue | |
| 335 | - | } | |
| 336 | - | if next < 0 || candidate.Rect.Min.X < words[next].Rect.Min.X { | |
| 337 | - | next = j | |
| 338 | - | } | |
| 339 | - | } | |
| 340 | - | if next < 0 { | |
| 341 | - | break | |
| 342 | - | } | |
| 343 | - | text += words[next].Text | |
| 344 | - | rect = rect.Union(words[next].Rect) | |
| 345 | - | used[next] = true | |
| 346 | - | } | |
| 347 | - | if !isTextLink(text) { | |
| 348 | - | continue | |
| 349 | - | } | |
| 350 | - | x, y := int(math.Floor(float64(rect.Min.X)/scale)), int(math.Floor(float64(rect.Min.Y)/scale)) | |
| 351 | - | right, bottom := int(math.Ceil(float64(rect.Max.X)/scale)), int(math.Ceil(float64(rect.Max.Y)/scale)) | |
| 352 | - | out = append(out, ports.HintTarget{Kind: ports.HintLink, Text: true, Rect: ports.Rect{X: x, Y: y, W: right - x, H: bottom - y}}) | |
| 353 | - | urls = append(urls, rect) | |
| 354 | - | } | |
| 355 | - | return append(out, underlinedLinks(words, urls, scale)...) | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | func abs(x int) int { | |
| 359 | - | if x < 0 { | |
| 360 | - | return -x | |
| 361 | - | } | |
| 362 | - | return x | |
| 363 | - | } | |
| 364 | - | func overlap(a, b image.Rectangle) float64 { | |
| 365 | - | intersection := a.Intersect(b) | |
| 366 | - | area := min(a.Dx()*a.Dy(), b.Dx()*b.Dy()) | |
| 367 | - | if intersection.Empty() || area <= 0 { | |
| 368 | - | return 0 | |
| 369 | - | } | |
| 370 | - | return float64(intersection.Dx()*intersection.Dy()) / float64(area) | |
| 371 | - | } | |
| 372 | - | func targetRect(t ports.HintTarget) image.Rectangle { | |
| 373 | - | return image.Rect(t.Rect.X, t.Rect.Y, t.Rect.X+t.Rect.W, t.Rect.Y+t.Rect.H) | |
| 374 | - | } | |
| 375 | - | func recognizeBand(ctx context.Context, binary, lang string, img image.Image) ([]OCRWord, error) { | |
| 376 | - | return recognizeImage(ctx, binary, lang, img, "11", false) | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | // pgmReader streams a grayscale image as binary PGM (P5), row by row. | |
| 380 | - | type pgmReader struct { | |
| 381 | - | head []byte | |
| 382 | - | img *image.Gray | |
| 383 | - | y int | |
| 384 | - | row []byte | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | func newPGMReader(img *image.Gray) *pgmReader { | |
| 388 | - | b := img.Bounds() | |
| 389 | - | return &pgmReader{head: fmt.Appendf(nil, "P5\n%d %d\n255\n", b.Dx(), b.Dy()), img: img} | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | func (r *pgmReader) Read(p []byte) (int, error) { | |
| 393 | - | n := 0 | |
| 394 | - | for n < len(p) { | |
| 395 | - | if len(r.head) > 0 { | |
| 396 | - | c := copy(p[n:], r.head) | |
| 397 | - | r.head, n = r.head[c:], n+c | |
| 398 | - | continue | |
| 399 | - | } | |
| 400 | - | if len(r.row) == 0 { | |
| 401 | - | b := r.img.Bounds() | |
| 402 | - | if r.y >= b.Dy() { | |
| 403 | - | break | |
| 404 | - | } | |
| 405 | - | r.row = r.img.Pix[(r.y+b.Min.Y-r.img.Rect.Min.Y)*r.img.Stride+(b.Min.X-r.img.Rect.Min.X):][:b.Dx()] | |
| 406 | - | r.y++ | |
| 407 | - | } | |
| 408 | - | c := copy(p[n:], r.row) | |
| 409 | - | r.row, n = r.row[c:], n+c | |
| 410 | - | } | |
| 411 | - | if n == 0 { | |
| 412 | - | return 0, io.EOF | |
| 413 | - | } | |
| 414 | - | return n, nil | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | func recognizeImage(ctx context.Context, binary, lang string, img image.Image, psm string, normalized bool) ([]OCRWord, error) { | |
| 418 | - | args := []string{"stdin", "stdout", "--psm", psm, "-l", lang} | |
| 419 | - | if normalized { | |
| 420 | - | args = append(args, "-c", "tessedit_do_invert=0") | |
| 421 | - | } | |
| 422 | - | args = append(args, "tsv") | |
| 423 | - | cmd := exec.CommandContext(ctx, binary, args...) | |
| 424 | - | cmd.Env = append(os.Environ(), "OMP_THREAD_LIMIT=1") | |
| 425 | - | if g, ok := img.(*image.Gray); ok && rawPGM { | |
| 426 | - | // Raw grayscale: no deflate, no encoder buffers. | |
| 427 | - | cmd.Stdin = newPGMReader(g) | |
| 428 | - | } else { | |
| 429 | - | // The PNG streams into tesseract; no encoded copy is held. | |
| 430 | - | pr, pw := io.Pipe() | |
| 431 | - | defer func() { _ = pr.Close() }() | |
| 432 | - | go func() { | |
| 433 | - | enc := png.Encoder{CompressionLevel: pngLevel} | |
| 434 | - | _ = pw.CloseWithError(enc.Encode(pw, img)) | |
| 435 | - | }() | |
| 436 | - | cmd.Stdin = pr | |
| 437 | - | } | |
| 438 | - | var stderr bytes.Buffer | |
| 439 | - | cmd.Stderr = &stderr | |
| 440 | - | output, err := cmd.Output() | |
| 441 | - | if err != nil { | |
| 442 | - | return nil, fmt.Errorf("tesseract: %w: %s", err, stderr.String()) | |
| 443 | - | } | |
| 444 | - | return parseTSV(output, image.Point{}) | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | // upscaleBand doubles a grayscale band with nearest-neighbour sampling. | |
| 448 | - | func upscaleBand(src *image.Gray) *image.Gray { | |
| 449 | - | b := src.Bounds() | |
| 450 | - | dst := image.NewGray(image.Rect(0, 0, b.Dx()*2, b.Dy()*2)) | |
| 451 | - | for y := 0; y < b.Dy(); y++ { | |
| 452 | - | row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride+(b.Min.X-src.Rect.Min.X):][:b.Dx()] | |
| 453 | - | top := dst.Pix[2*y*dst.Stride:][:dst.Stride] | |
| 454 | - | for x, v := range row { | |
| 455 | - | top[2*x], top[2*x+1] = v, v | |
| 456 | - | } | |
| 457 | - | copy(dst.Pix[(2*y+1)*dst.Stride:], top) | |
| 458 | - | } | |
| 459 | - | return dst | |
| 460 | - | } | |
D internal/adapters/hints/ocr_test.go +0 -272
| @@ -1,272 +0,0 @@ | |||
| 1 | - | package hints | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "image" | |
| 7 | - | "image/draw" | |
| 8 | - | "image/png" | |
| 9 | - | "io" | |
| 10 | - | "os" | |
| 11 | - | "os/exec" | |
| 12 | - | "path/filepath" | |
| 13 | - | "reflect" | |
| 14 | - | "strings" | |
| 15 | - | "testing" | |
| 16 | - | "time" | |
| 17 | - | ||
| 18 | - | "github.com/stretchr/testify/mock" | |
| 19 | - | ||
| 20 | - | "github.com/bnema/neferwl/internal/ports" | |
| 21 | - | ) | |
| 22 | - | ||
| 23 | - | func TestTextLink(t *testing.T) { | |
| 24 | - | 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"} | |
| 25 | - | no := []string{"ermite-runner", "main.go", "README.md", "v1.2.3", "1.5", "e.g.", "report.txt", "source.rs", "alpha", "999.999.999.999"} | |
| 26 | - | for _, s := range yes { | |
| 27 | - | t.Run(s, func(t *testing.T) { | |
| 28 | - | if !isTextLink(s) { | |
| 29 | - | t.Errorf("not accepted: %s", s) | |
| 30 | - | } | |
| 31 | - | }) | |
| 32 | - | } | |
| 33 | - | for _, s := range no { | |
| 34 | - | t.Run(s, func(t *testing.T) { | |
| 35 | - | if isTextLink(s) { | |
| 36 | - | t.Errorf("incorrectly accepted: %s", s) | |
| 37 | - | } | |
| 38 | - | }) | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | func TestParseTSV(t *testing.T) { | |
| 42 | - | 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" | |
| 43 | - | words, err := parseTSV([]byte(data), image.Pt(0, 40)) | |
| 44 | - | if err != nil || len(words) != 1 { | |
| 45 | - | t.Fatalf("%v %v", words, err) | |
| 46 | - | } | |
| 47 | - | if words[0].Rect != image.Rect(10, 60, 40, 72) || words[0].Confidence != 91.5 { | |
| 48 | - | t.Fatal(words) | |
| 49 | - | } | |
| 50 | - | _, err = parseTSV([]byte(strings.ReplaceAll(data, "\t10\t", "\tbad\t")), image.Point{}) | |
| 51 | - | if err == nil { | |
| 52 | - | t.Fatal("expected malformed coordinate error") | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | func TestJoinedOCRLink(t *testing.T) { | |
| 56 | - | words := []OCRWord{{Text: "https:/", Rect: image.Rect(20, 20, 90, 40)}, {Text: "/example.org", Rect: image.Rect(91, 20, 200, 40)}} | |
| 57 | - | got := textLinks(words, 2) | |
| 58 | - | if len(got) != 1 || got[0].Rect != (ports.Rect{X: 10, Y: 10, W: 90, H: 10}) { | |
| 59 | - | t.Fatal(got) | |
| 60 | - | } | |
| 61 | - | } | |
| 62 | - | func TestRealOCR(t *testing.T) { | |
| 63 | - | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 64 | - | t.Skip("tesseract not on PATH") | |
| 65 | - | } | |
| 66 | - | for _, name := range []string{"browser", "terminal"} { | |
| 67 | - | t.Run(name, func(t *testing.T) { | |
| 68 | - | f, err := os.Open("testdata/" + name + ".png") | |
| 69 | - | if err != nil { | |
| 70 | - | t.Fatal(err) | |
| 71 | - | } | |
| 72 | - | defer f.Close() | |
| 73 | - | src, err := png.Decode(f) | |
| 74 | - | if err != nil { | |
| 75 | - | t.Fatal(err) | |
| 76 | - | } | |
| 77 | - | img := image.NewRGBA(src.Bounds()) | |
| 78 | - | draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src) | |
| 79 | - | start := time.Now() | |
| 80 | - | words, bands, err := readWords(context.Background(), toGray(img)) | |
| 81 | - | if err != nil { | |
| 82 | - | t.Fatal(err) | |
| 83 | - | } | |
| 84 | - | links := textLinks(words, 1) | |
| 85 | - | 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) | |
| 86 | - | for _, w := range words { | |
| 87 | - | t.Logf("%s %v", w.Text, w.Rect) | |
| 88 | - | } | |
| 89 | - | if name == "terminal" { | |
| 90 | - | 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)} { | |
| 91 | - | found := false | |
| 92 | - | for _, l := range links { | |
| 93 | - | if overlap(targetRect(l), r) > .5 { | |
| 94 | - | found = true | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | if !found { | |
| 98 | - | t.Errorf("missing terminal URL near %v", r) | |
| 99 | - | } | |
| 100 | - | } | |
| 101 | - | if len(links) != 5 { | |
| 102 | - | t.Errorf("want 5 terminal links, got %d: %v", len(links), links) | |
| 103 | - | } | |
| 104 | - | } else if len(links) == 0 { | |
| 105 | - | t.Error("no browser links") | |
| 106 | - | } | |
| 107 | - | }) | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func TestSmallGlyphOCR(t *testing.T) { | |
| 112 | - | binary, err := exec.LookPath("tesseract") | |
| 113 | - | if err != nil { | |
| 114 | - | t.Skip("tesseract not on PATH") | |
| 115 | - | } | |
| 116 | - | f, err := os.Open("testdata/terminal.png") | |
| 117 | - | if err != nil { | |
| 118 | - | t.Fatal(err) | |
| 119 | - | } | |
| 120 | - | defer f.Close() | |
| 121 | - | src, err := png.Decode(f) | |
| 122 | - | if err != nil { | |
| 123 | - | t.Fatal(err) | |
| 124 | - | } | |
| 125 | - | small := image.NewGray(image.Rect(0, 0, 640, 200)) | |
| 126 | - | for y := 0; y < 200; y++ { | |
| 127 | - | for x := 0; x < 640; x++ { | |
| 128 | - | small.Set(x, y, src.At(x*2, y*2)) | |
| 129 | - | } | |
| 130 | - | } | |
| 131 | - | lang, err := ocrLanguage(context.Background(), binary) | |
| 132 | - | if err != nil { | |
| 133 | - | t.Fatal(err) | |
| 134 | - | } | |
| 135 | - | for _, factor := range []int{1, 2} { | |
| 136 | - | var img image.Image = small | |
| 137 | - | if factor == 2 { | |
| 138 | - | img = upscaleBand(small) | |
| 139 | - | } | |
| 140 | - | start := time.Now() | |
| 141 | - | words, err := recognizeBand(context.Background(), binary, lang, img) | |
| 142 | - | if err != nil { | |
| 143 | - | t.Fatal(err) | |
| 144 | - | } | |
| 145 | - | t.Logf("glyph experiment %dx: %.1f ms, words=%d links=%d", factor, ms(time.Since(start)), len(words), len(textLinks(words, 1))) | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | func TestPGMReader(t *testing.T) { | |
| 150 | - | g := image.NewGray(image.Rect(0, 0, 3, 3)) | |
| 151 | - | for i := range g.Pix { | |
| 152 | - | g.Pix[i] = uint8(i + 1) | |
| 153 | - | } | |
| 154 | - | sub := g.SubImage(image.Rect(1, 1, 3, 3)).(*image.Gray) | |
| 155 | - | got, err := io.ReadAll(newPGMReader(sub)) | |
| 156 | - | if err != nil { | |
| 157 | - | t.Fatal(err) | |
| 158 | - | } | |
| 159 | - | if want := "P5\n2 2\n255\n" + string([]byte{5, 6, 8, 9}); string(got) != want { | |
| 160 | - | t.Fatalf("%q", got) | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // Tesseract must read the raw PGM exactly as it reads the PNG. | |
| 165 | - | func TestPGMMatchesPNG(t *testing.T) { | |
| 166 | - | binary, err := exec.LookPath("tesseract") | |
| 167 | - | if err != nil { | |
| 168 | - | t.Skip("tesseract not on PATH") | |
| 169 | - | } | |
| 170 | - | f, err := os.Open("testdata/terminal.png") | |
| 171 | - | if err != nil { | |
| 172 | - | t.Fatal(err) | |
| 173 | - | } | |
| 174 | - | defer f.Close() | |
| 175 | - | src, err := png.Decode(f) | |
| 176 | - | if err != nil { | |
| 177 | - | t.Fatal(err) | |
| 178 | - | } | |
| 179 | - | rgba := image.NewRGBA(src.Bounds()) | |
| 180 | - | draw.Draw(rgba, rgba.Bounds(), src, src.Bounds().Min, draw.Src) | |
| 181 | - | gray := toGray(rgba) | |
| 182 | - | lang, err := ocrLanguage(context.Background(), binary) | |
| 183 | - | if err != nil { | |
| 184 | - | t.Fatal(err) | |
| 185 | - | } | |
| 186 | - | defer func(v bool) { rawPGM = v }(rawPGM) | |
| 187 | - | var res [2][]OCRWord | |
| 188 | - | for i, raw := range []bool{false, true} { | |
| 189 | - | rawPGM = raw | |
| 190 | - | if res[i], err = recognizeBand(context.Background(), binary, lang, gray); err != nil { | |
| 191 | - | t.Fatal(err) | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | if len(res[0]) == 0 || !reflect.DeepEqual(res[0], res[1]) { | |
| 195 | - | t.Fatalf("PNG %d words, PGM %d words", len(res[0]), len(res[1])) | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // TestRecognizeCapsProcesses runs the bands of several windows through a | |
| 200 | - | // stand-in tesseract that logs when each process starts and ends. | |
| 201 | - | func TestRecognizeCapsProcesses(t *testing.T) { | |
| 202 | - | dir := t.TempDir() | |
| 203 | - | logPath := filepath.Join(dir, "log") | |
| 204 | - | script := filepath.Join(dir, "tesseract") | |
| 205 | - | body := "#!/bin/sh\ncat >/dev/null\necho s >> " + logPath + "\nsleep 0.1\necho e >> " + logPath + "\nprintf 'level\\tpage_num\\tblock_num\\tpar_num\\tline_num\\tword_num\\tleft\\ttop\\twidth\\theight\\tconf\\ttext\\n'\n" | |
| 206 | - | if err := os.WriteFile(script, []byte(body), 0o700); err != nil { | |
| 207 | - | t.Fatal(err) | |
| 208 | - | } | |
| 209 | - | old := ocrWorkers | |
| 210 | - | ocrWorkers = 2 | |
| 211 | - | t.Cleanup(func() { ocrWorkers = old }) | |
| 212 | - | w := &worker{ocr: ocrState{done: true, binary: script, lang: "eng"}} | |
| 213 | - | var jobs []*bandJob | |
| 214 | - | for range 8 { // several windows' bands | |
| 215 | - | g := image.NewGray(image.Rect(0, 0, 8, 8)) | |
| 216 | - | jobs = append(jobs, &bandJob{ext: g.Rect, own: [2]int{0, 8}, gray: g}) | |
| 217 | - | } | |
| 218 | - | w.recognize(context.Background(), jobs) | |
| 219 | - | data, err := os.ReadFile(logPath) | |
| 220 | - | if err != nil { | |
| 221 | - | t.Fatal(err) | |
| 222 | - | } | |
| 223 | - | cur, peak, starts := 0, 0, 0 | |
| 224 | - | for _, l := range strings.Fields(string(data)) { | |
| 225 | - | if l == "s" { | |
| 226 | - | cur++ | |
| 227 | - | starts++ | |
| 228 | - | peak = max(peak, cur) | |
| 229 | - | } else { | |
| 230 | - | cur-- | |
| 231 | - | } | |
| 232 | - | } | |
| 233 | - | if starts != 8 || peak > 2 { | |
| 234 | - | t.Fatalf("starts=%d peak=%d", starts, peak) | |
| 235 | - | } | |
| 236 | - | for _, j := range jobs { | |
| 237 | - | if j.err != nil { | |
| 238 | - | t.Fatal(j.err) | |
| 239 | - | } | |
| 240 | - | } | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | func TestLanguageDiscoveryTimeout(t *testing.T) { | |
| 244 | - | t.Setenv("TESSDATA_PREFIX", t.TempDir()) | |
| 245 | - | runner := newMocklanguageRunner(t) | |
| 246 | - | parent := context.Background() | |
| 247 | - | runner.EXPECT().Output(mock.Anything, "tesseract", []string{"--list-langs"}).RunAndReturn( | |
| 248 | - | func(ctx context.Context, _ string, _ ...string) ([]byte, error) { | |
| 249 | - | deadline, ok := ctx.Deadline() | |
| 250 | - | if !ok || time.Until(deadline) > languageTimeout { | |
| 251 | - | t.Fatal("language discovery has no bounded deadline") | |
| 252 | - | } | |
| 253 | - | <-ctx.Done() | |
| 254 | - | return nil, ctx.Err() | |
| 255 | - | }).Once() | |
| 256 | - | lang, err := discoverLanguage(parent, "tesseract", runner, 0) | |
| 257 | - | if lang != "" || !errors.Is(err, context.DeadlineExceeded) || parent.Err() != nil { | |
| 258 | - | t.Fatalf("language=%q error=%v parent=%v", lang, err, parent.Err()) | |
| 259 | - | } | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | func TestLanguageDiscoveryFailureKeepsAccessibilityOnly(t *testing.T) { | |
| 263 | - | w := &worker{ocr: ocrState{done: true, err: context.DeadlineExceeded}} | |
| 264 | - | ar := atspiResult{targets: []ports.HintTarget{{Window: 1, Rect: ports.Rect{W: 20, H: 10}}}} | |
| 265 | - | req := ports.HintScanRequest{Width: 100, Height: 100, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}}} | |
| 266 | - | if w.needFrame(context.Background(), req, ar) { | |
| 267 | - | t.Fatal("timed out discovery must not require an OCR capture") | |
| 268 | - | } | |
| 269 | - | if err := w.ocr.resolve(context.Background()); !errors.Is(err, context.DeadlineExceeded) { | |
| 270 | - | t.Fatalf("discovery failure was not retained: %v", err) | |
| 271 | - | } | |
| 272 | - | } | |
D internal/adapters/hints/scan_bench_test.go +0 -49
| @@ -1,49 +0,0 @@ | |||
| 1 | - | package hints | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "os" | |
| 6 | - | "os/exec" | |
| 7 | - | "runtime" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/neferwl/internal/ports" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // BenchmarkScan runs whole scans of a 5120x2160 frame with a noisy window | |
| 15 | - | // (the cache never hits) and reports the Go heap bytes allocated per scan. | |
| 16 | - | func BenchmarkScan(b *testing.B) { | |
| 17 | - | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 18 | - | b.Skip("tesseract not on PATH") | |
| 19 | - | } | |
| 20 | - | const w, h = 5120, 2160 | |
| 21 | - | ctx := context.Background() | |
| 22 | - | w0 := &worker{} | |
| 23 | - | requests := make(chan ports.HintScanRequest) | |
| 24 | - | captures := make(chan ports.CaptureRequest, 1) | |
| 25 | - | captured := make(chan ports.CaptureDone, 1) | |
| 26 | - | results := make(chan ports.HintScanResult, 1) | |
| 27 | - | req := ports.HintScanRequest{Output: "o", Width: w, Height: h, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}}} | |
| 28 | - | if os.Getenv("NEFERWL_HINT_BENCH_PGM") == "0" { | |
| 29 | - | rawPGM = false | |
| 30 | - | } | |
| 31 | - | b.ReportAllocs() | |
| 32 | - | var peak uint64 | |
| 33 | - | for i := range b.N { | |
| 34 | - | go func() { | |
| 35 | - | creq := <-captures | |
| 36 | - | creq.Mapped[0] = byte(i) // each scan differs: no cache hit | |
| 37 | - | creq.Mapped[1] = byte(i >> 8) | |
| 38 | - | captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 39 | - | }() | |
| 40 | - | res := w0.serve(ctx, req, requests, captures, captured, results, 5*time.Second) | |
| 41 | - | if res.Err != nil { | |
| 42 | - | b.Fatal(res.Err) | |
| 43 | - | } | |
| 44 | - | var m runtime.MemStats | |
| 45 | - | runtime.ReadMemStats(&m) | |
| 46 | - | peak = max(peak, m.HeapSys-m.HeapReleased) | |
| 47 | - | } | |
| 48 | - | b.ReportMetric(float64(peak)/1e6, "heapMB") | |
| 49 | - | } | |
D internal/adapters/hints/strips_test.go +0 -124
| @@ -1,124 +0,0 @@ | |||
| 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 | - | } | |
D internal/adapters/hints/target_source_mock_test.go +0 -174
| @@ -1,174 +0,0 @@ | |||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |
| 2 | - | // github.com/vektra/mockery | |
| 3 | - | // template: testify | |
| 4 | - | ||
| 5 | - | package hints | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | "context" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/neferwl/internal/ports" | |
| 11 | - | mock "github.com/stretchr/testify/mock" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // newMocktargetSource creates a new instance of mocktargetSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | - | // The first argument is typically a *testing.T value. | |
| 16 | - | func newMocktargetSource(t interface { | |
| 17 | - | mock.TestingT | |
| 18 | - | Cleanup(func()) | |
| 19 | - | }) *mocktargetSource { | |
| 20 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | - | helper.Helper() | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | mock := &mocktargetSource{} | |
| 25 | - | mock.Mock.Test(t) | |
| 26 | - | ||
| 27 | - | t.Cleanup(func() { | |
| 28 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | - | helper.Helper() | |
| 30 | - | } | |
| 31 | - | mock.AssertExpectations(t) | |
| 32 | - | }) | |
| 33 | - | ||
| 34 | - | return mock | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // mocktargetSource is an autogenerated mock type for the targetSource type | |
| 38 | - | type mocktargetSource struct { | |
| 39 | - | mock.Mock | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | type mocktargetSource_Expecter struct { | |
| 43 | - | mock *mock.Mock | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | func (_m *mocktargetSource) EXPECT() *mocktargetSource_Expecter { | |
| 47 | - | return &mocktargetSource_Expecter{mock: &_m.Mock} | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Close provides a mock function for the type mocktargetSource | |
| 51 | - | func (_mock *mocktargetSource) Close() error { | |
| 52 | - | ret := _mock.Called() | |
| 53 | - | ||
| 54 | - | if len(ret) == 0 { | |
| 55 | - | panic("no return value specified for Close") | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | var r0 error | |
| 59 | - | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 60 | - | r0 = returnFunc() | |
| 61 | - | } else { | |
| 62 | - | r0 = ret.Error(0) | |
| 63 | - | } | |
| 64 | - | return r0 | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | // mocktargetSource_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |
| 68 | - | type mocktargetSource_Close_Call struct { | |
| 69 | - | *mock.Call | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | // Close is a helper method to define mock.On call | |
| 73 | - | func (_e *mocktargetSource_Expecter) Close() *mocktargetSource_Close_Call { | |
| 74 | - | return &mocktargetSource_Close_Call{Call: _e.mock.On("Close")} | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func (_c *mocktargetSource_Close_Call) Run(run func()) *mocktargetSource_Close_Call { | |
| 78 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 79 | - | run() | |
| 80 | - | }) | |
| 81 | - | return _c | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | func (_c *mocktargetSource_Close_Call) Return(err error) *mocktargetSource_Close_Call { | |
| 85 | - | _c.Call.Return(err) | |
| 86 | - | return _c | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func (_c *mocktargetSource_Close_Call) RunAndReturn(run func() error) *mocktargetSource_Close_Call { | |
| 90 | - | _c.Call.Return(run) | |
| 91 | - | return _c | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // Targets provides a mock function for the type mocktargetSource | |
| 95 | - | func (_mock *mocktargetSource) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) { | |
| 96 | - | ret := _mock.Called(ctx, windows, linksOnly) | |
| 97 | - | ||
| 98 | - | if len(ret) == 0 { | |
| 99 | - | panic("no return value specified for Targets") | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | var r0 []ports.HintTarget | |
| 103 | - | var r1 map[ports.WindowID]bool | |
| 104 | - | var r2 error | |
| 105 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, []ports.HintWindow, bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)); ok { | |
| 106 | - | return returnFunc(ctx, windows, linksOnly) | |
| 107 | - | } | |
| 108 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, []ports.HintWindow, bool) []ports.HintTarget); ok { | |
| 109 | - | r0 = returnFunc(ctx, windows, linksOnly) | |
| 110 | - | } else { | |
| 111 | - | if ret.Get(0) != nil { | |
| 112 | - | r0 = ret.Get(0).([]ports.HintTarget) | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, []ports.HintWindow, bool) map[ports.WindowID]bool); ok { | |
| 116 | - | r1 = returnFunc(ctx, windows, linksOnly) | |
| 117 | - | } else { | |
| 118 | - | if ret.Get(1) != nil { | |
| 119 | - | r1 = ret.Get(1).(map[ports.WindowID]bool) | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | if returnFunc, ok := ret.Get(2).(func(context.Context, []ports.HintWindow, bool) error); ok { | |
| 123 | - | r2 = returnFunc(ctx, windows, linksOnly) | |
| 124 | - | } else { | |
| 125 | - | r2 = ret.Error(2) | |
| 126 | - | } | |
| 127 | - | return r0, r1, r2 | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | // mocktargetSource_Targets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Targets' | |
| 131 | - | type mocktargetSource_Targets_Call struct { | |
| 132 | - | *mock.Call | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // Targets is a helper method to define mock.On call | |
| 136 | - | // - ctx context.Context | |
| 137 | - | // - windows []ports.HintWindow | |
| 138 | - | // - linksOnly bool | |
| 139 | - | func (_e *mocktargetSource_Expecter) Targets(ctx any, windows any, linksOnly any) *mocktargetSource_Targets_Call { | |
| 140 | - | return &mocktargetSource_Targets_Call{Call: _e.mock.On("Targets", ctx, windows, linksOnly)} | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | func (_c *mocktargetSource_Targets_Call) Run(run func(ctx context.Context, windows []ports.HintWindow, linksOnly bool)) *mocktargetSource_Targets_Call { | |
| 144 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 145 | - | var arg0 context.Context | |
| 146 | - | if args[0] != nil { | |
| 147 | - | arg0 = args[0].(context.Context) | |
| 148 | - | } | |
| 149 | - | var arg1 []ports.HintWindow | |
| 150 | - | if args[1] != nil { | |
| 151 | - | arg1 = args[1].([]ports.HintWindow) | |
| 152 | - | } | |
| 153 | - | var arg2 bool | |
| 154 | - | if args[2] != nil { | |
| 155 | - | arg2 = args[2].(bool) | |
| 156 | - | } | |
| 157 | - | run( | |
| 158 | - | arg0, | |
| 159 | - | arg1, | |
| 160 | - | arg2, | |
| 161 | - | ) | |
| 162 | - | }) | |
| 163 | - | return _c | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | func (_c *mocktargetSource_Targets_Call) Return(hintTargets []ports.HintTarget, windowIDToBool map[ports.WindowID]bool, err error) *mocktargetSource_Targets_Call { | |
| 167 | - | _c.Call.Return(hintTargets, windowIDToBool, err) | |
| 168 | - | return _c | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | func (_c *mocktargetSource_Targets_Call) RunAndReturn(run func(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error)) *mocktargetSource_Targets_Call { | |
| 172 | - | _c.Call.Return(run) | |
| 173 | - | return _c | |
| 174 | - | } | |
D internal/adapters/hints/testdata/browser.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/adapters/hints/testdata/terminal.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/adapters/hints/testdata/underline.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/adapters/hints/underline.go +0 -246
| @@ -1,246 +0,0 @@ | |||
| 1 | - | package hints | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "image" | |
| 5 | - | "math" | |
| 6 | - | "sort" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/neferwl/internal/ports" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // underlineCandidate is a word with a thin line under it. | |
| 12 | - | type underlineCandidate struct { | |
| 13 | - | word int | |
| 14 | - | row int // the line's row | |
| 15 | - | from, to int // x extent of the continuous line through the word | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | // markUnderlined sets Underlined, UnderL and UnderR on the words of a band | |
| 19 | - | // that have a thin line under them. words are in the coordinates of g. | |
| 20 | - | // Lines that run on far beyond the word (rules, table borders, input boxes) | |
| 21 | - | // do not count, unless the extension is made of underlined words too. | |
| 22 | - | func markUnderlined(src *image.Gray, words []OCRWord) { | |
| 23 | - | // Words are relative to the band's own origin, whatever the image is a | |
| 24 | - | // view of. | |
| 25 | - | b := src.Bounds() | |
| 26 | - | g := &image.Gray{Pix: src.Pix[src.PixOffset(b.Min.X, b.Min.Y):], Stride: src.Stride, Rect: image.Rect(0, 0, b.Dx(), b.Dy())} | |
| 27 | - | var cands []underlineCandidate | |
| 28 | - | for i, w := range words { | |
| 29 | - | // A single glyph, or a box narrower than tall, has a flat bottom | |
| 30 | - | // stroke (8, L, 2) that is not an underline. | |
| 31 | - | if len([]rune(w.Text)) < 2 || w.Rect.Dx() < w.Rect.Dy() { | |
| 32 | - | continue | |
| 33 | - | } | |
| 34 | - | if c, ok := underlineOf(g, w.Rect); ok { | |
| 35 | - | c.word = i | |
| 36 | - | cands = append(cands, c) | |
| 37 | - | } | |
| 38 | - | } | |
| 39 | - | for _, c := range cands { | |
| 40 | - | w := words[c.word] | |
| 41 | - | h := w.Rect.Dy() | |
| 42 | - | if (w.Rect.Min.X-c.from > h && !covered(words, cands, c, -1)) || | |
| 43 | - | (c.to-w.Rect.Max.X > h && !covered(words, cands, c, 1)) { | |
| 44 | - | continue | |
| 45 | - | } | |
| 46 | - | words[c.word].Underlined, words[c.word].UnderL, words[c.word].UnderR = true, c.from, c.to | |
| 47 | - | } | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // covered reports whether the line of c, on the given side of its word, is | |
| 51 | - | // explained by underlined words on the same line up to within a glyph of its | |
| 52 | - | // end. | |
| 53 | - | func covered(words []OCRWord, cands []underlineCandidate, c underlineCandidate, dir int) bool { | |
| 54 | - | w := words[c.word].Rect | |
| 55 | - | h := w.Dy() | |
| 56 | - | cur := w.Min.X | |
| 57 | - | if dir > 0 { | |
| 58 | - | cur = w.Max.X | |
| 59 | - | } | |
| 60 | - | for { | |
| 61 | - | if (dir < 0 && cur-c.from <= h) || (dir > 0 && c.to-cur <= h) { | |
| 62 | - | return true | |
| 63 | - | } | |
| 64 | - | next := -1 | |
| 65 | - | for _, o := range cands { | |
| 66 | - | r := words[o.word].Rect | |
| 67 | - | if abs(r.Max.Y-w.Max.Y) > max(3, h/3) { | |
| 68 | - | continue | |
| 69 | - | } | |
| 70 | - | var gap, reach int | |
| 71 | - | if dir < 0 { | |
| 72 | - | gap, reach = cur-r.Max.X, r.Min.X | |
| 73 | - | } else { | |
| 74 | - | gap, reach = r.Min.X-cur, r.Max.X | |
| 75 | - | } | |
| 76 | - | if gap < -2 || gap > h { | |
| 77 | - | continue | |
| 78 | - | } | |
| 79 | - | if next < 0 || (dir < 0 && reach < next) || (dir > 0 && reach > next) { | |
| 80 | - | next = reach | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | if next < 0 || (dir < 0 && next >= cur) || (dir > 0 && next <= cur) { | |
| 84 | - | return false | |
| 85 | - | } | |
| 86 | - | cur = next | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | // underlineOf looks for an underline under the word box r in g. | |
| 91 | - | func underlineOf(g *image.Gray, r image.Rectangle) (underlineCandidate, bool) { | |
| 92 | - | b := g.Bounds() | |
| 93 | - | box := r.Intersect(b) | |
| 94 | - | h := r.Dy() | |
| 95 | - | if box.Dx() < 4 || h < 4 { | |
| 96 | - | return underlineCandidate{}, false | |
| 97 | - | } | |
| 98 | - | bg, ink := contrastOf(g, box) | |
| 99 | - | thr := min(40, max(20, ink/2)) | |
| 100 | - | if ink < thr+10 { | |
| 101 | - | return underlineCandidate{}, false | |
| 102 | - | } | |
| 103 | - | top := max(b.Min.Y, r.Max.Y-(h*15+99)/100) | |
| 104 | - | bottom := min(b.Max.Y, r.Max.Y+(h*35+99)/100+1) | |
| 105 | - | maxThick := max(2, h/6) | |
| 106 | - | rowOK := func(y int) bool { | |
| 107 | - | row := g.Pix[(y-g.Rect.Min.Y)*g.Stride:] | |
| 108 | - | n := 0 | |
| 109 | - | for x := box.Min.X; x < box.Max.X; x++ { | |
| 110 | - | if abs(int(row[x-g.Rect.Min.X])-bg) >= thr { | |
| 111 | - | n++ | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | return n*100 >= box.Dx()*85 | |
| 115 | - | } | |
| 116 | - | thick, first := 0, 0 | |
| 117 | - | for y := top; y <= bottom; y++ { | |
| 118 | - | if y < bottom && rowOK(y) { | |
| 119 | - | if thick == 0 { | |
| 120 | - | first = y | |
| 121 | - | } | |
| 122 | - | thick++ | |
| 123 | - | continue | |
| 124 | - | } | |
| 125 | - | if thick > 0 { | |
| 126 | - | if thick > maxThick { | |
| 127 | - | return underlineCandidate{}, false // a block, not a line | |
| 128 | - | } | |
| 129 | - | row := first + thick/2 | |
| 130 | - | from, to := box.Min.X, box.Max.X | |
| 131 | - | line := g.Pix[(row-g.Rect.Min.Y)*g.Stride:] | |
| 132 | - | on := func(x int) bool { return abs(int(line[x-g.Rect.Min.X])-bg) >= thr } | |
| 133 | - | for from > b.Min.X && on(from-1) { | |
| 134 | - | from-- | |
| 135 | - | } | |
| 136 | - | for to < b.Max.X && on(to) { | |
| 137 | - | to++ | |
| 138 | - | } | |
| 139 | - | return underlineCandidate{row: row, from: from, to: to}, true | |
| 140 | - | } | |
| 141 | - | } | |
| 142 | - | return underlineCandidate{}, false | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // contrastOf returns the background level of box (the most common level, | |
| 146 | - | // found in bins of 16 so antialiasing does not split it, then refined to the | |
| 147 | - | // mean of that bin) and the contrast of its ink (the deviation reached by | |
| 148 | - | // the strongest 3% of pixels). | |
| 149 | - | func contrastOf(g *image.Gray, box image.Rectangle) (bg, ink int) { | |
| 150 | - | var hist [256]int | |
| 151 | - | for y := box.Min.Y; y < box.Max.Y; y++ { | |
| 152 | - | for _, v := range g.Pix[(y-g.Rect.Min.Y)*g.Stride+box.Min.X-g.Rect.Min.X:][:box.Dx()] { | |
| 153 | - | hist[v]++ | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | total := box.Dx() * box.Dy() | |
| 157 | - | var bins [16]int | |
| 158 | - | for v, c := range hist { | |
| 159 | - | bins[v>>4] += c | |
| 160 | - | } | |
| 161 | - | best := 0 | |
| 162 | - | for i, c := range bins { | |
| 163 | - | if c > bins[best] { | |
| 164 | - | best = i | |
| 165 | - | } | |
| 166 | - | } | |
| 167 | - | sum, n := 0, 0 | |
| 168 | - | for v := best << 4; v < (best+1)<<4; v++ { | |
| 169 | - | sum += v * hist[v] | |
| 170 | - | n += hist[v] | |
| 171 | - | } | |
| 172 | - | bg = sum / max(n, 1) | |
| 173 | - | var dev [256]int | |
| 174 | - | for v, c := range hist { | |
| 175 | - | dev[abs(v-bg)] += c | |
| 176 | - | } | |
| 177 | - | n = 0 | |
| 178 | - | for d := 255; d >= 0; d-- { | |
| 179 | - | n += dev[d] | |
| 180 | - | if n*100 >= total*3 { | |
| 181 | - | return bg, d | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | return bg, 0 | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | // underlinedLinks returns the targets for underlined words, joining the | |
| 188 | - | // words of one continuous underline. A group that overlaps a URL target (in | |
| 189 | - | // pixels) is left to it. Coordinates are scaled to logical. | |
| 190 | - | func underlinedLinks(words []OCRWord, urls []image.Rectangle, scale float64) []ports.HintTarget { | |
| 191 | - | var us []OCRWord | |
| 192 | - | for _, w := range words { | |
| 193 | - | if w.Underlined { | |
| 194 | - | us = append(us, w) | |
| 195 | - | } | |
| 196 | - | } | |
| 197 | - | sort.Slice(us, func(i, j int) bool { return us[i].Rect.Min.X < us[j].Rect.Min.X }) | |
| 198 | - | used := make([]bool, len(us)) | |
| 199 | - | var out []ports.HintTarget | |
| 200 | - | for i := range us { | |
| 201 | - | if used[i] { | |
| 202 | - | continue | |
| 203 | - | } | |
| 204 | - | used[i] = true | |
| 205 | - | rect, last := us[i].Rect, us[i] | |
| 206 | - | chars, width := len(last.Text), last.Rect.Dx() | |
| 207 | - | for { | |
| 208 | - | next := -1 | |
| 209 | - | for j := range us { | |
| 210 | - | c := us[j] | |
| 211 | - | if used[j] || c.Rect.Min.X < rect.Max.X-2 || abs(c.Rect.Max.Y-last.Rect.Max.Y) > max(3, rect.Dy()/3) { | |
| 212 | - | continue | |
| 213 | - | } | |
| 214 | - | avg := float64(width+c.Rect.Dx()) / float64(max(1, chars+len(c.Text))) | |
| 215 | - | gap := c.Rect.Min.X - rect.Max.X | |
| 216 | - | if float64(gap) > 1.2*avg || c.Rect.Min.X > last.UnderR+2 { | |
| 217 | - | continue | |
| 218 | - | } | |
| 219 | - | if next < 0 || c.Rect.Min.X < us[next].Rect.Min.X { | |
| 220 | - | next = j | |
| 221 | - | } | |
| 222 | - | } | |
| 223 | - | if next < 0 { | |
| 224 | - | break | |
| 225 | - | } | |
| 226 | - | used[next] = true | |
| 227 | - | last = us[next] | |
| 228 | - | rect = rect.Union(last.Rect) | |
| 229 | - | chars += len(last.Text) | |
| 230 | - | width += last.Rect.Dx() | |
| 231 | - | } | |
| 232 | - | dup := false | |
| 233 | - | for _, u := range urls { | |
| 234 | - | if overlap(rect, u) > .5 { | |
| 235 | - | dup = true | |
| 236 | - | } | |
| 237 | - | } | |
| 238 | - | if dup { | |
| 239 | - | continue | |
| 240 | - | } | |
| 241 | - | x, y := int(math.Floor(float64(rect.Min.X)/scale)), int(math.Floor(float64(rect.Min.Y)/scale)) | |
| 242 | - | right, bottom := int(math.Ceil(float64(rect.Max.X)/scale)), int(math.Ceil(float64(rect.Max.Y)/scale)) | |
| 243 | - | out = append(out, ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: x, Y: y, W: right - x, H: bottom - y}}) | |
| 244 | - | } | |
| 245 | - | return out | |
| 246 | - | } | |
D internal/adapters/hints/underline_test.go +0 -252
| @@ -1,252 +0,0 @@ | |||
| 1 | - | package hints | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "image" | |
| 5 | - | "image/color" | |
| 6 | - | "os" | |
| 7 | - | "os/exec" | |
| 8 | - | "testing" | |
| 9 | - | ||
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/neferwl/internal/ports" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // canvas is a synthetic grayscale band: bg everywhere, ink for text. | |
| 16 | - | type canvas struct { | |
| 17 | - | g *image.Gray | |
| 18 | - | bg, ink uint8 | |
| 19 | - | s int // scale | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func newCanvas(w, h int, bg, ink uint8, s int) *canvas { | |
| 23 | - | g := image.NewGray(image.Rect(0, 0, w*s, h*s)) | |
| 24 | - | for i := range g.Pix { | |
| 25 | - | g.Pix[i] = bg | |
| 26 | - | } | |
| 27 | - | return &canvas{g, bg, ink, s} | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | func (c *canvas) fill(x0, y0, x1, y1 int, v uint8) { | |
| 31 | - | for y := y0 * c.s; y < y1*c.s; y++ { | |
| 32 | - | for x := x0 * c.s; x < x1*c.s; x++ { | |
| 33 | - | c.g.SetGray(x, y, color.Gray{v}) | |
| 34 | - | } | |
| 35 | - | } | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // word draws a 10 px high word of x-height body at (x,y) with the given | |
| 39 | - | // width: text-like noisy ink (a stem every 3 px) and optional descenders. | |
| 40 | - | // It returns the OCR box (descenders excluded, as a box around the body). | |
| 41 | - | func (c *canvas) word(x, y, w int, descender bool) OCRWord { | |
| 42 | - | for i := 0; i < w; i += 3 { | |
| 43 | - | c.fill(x+i, y, x+i+1, y+10, c.ink) | |
| 44 | - | } | |
| 45 | - | box := image.Rect(x*c.s, y*c.s, (x+w)*c.s, (y+10)*c.s) | |
| 46 | - | if descender { | |
| 47 | - | for i := 0; i < w; i += 6 { | |
| 48 | - | c.fill(x+i, y+10, x+i+1, y+13, c.ink) | |
| 49 | - | } | |
| 50 | - | box.Max.Y = (y + 13) * c.s | |
| 51 | - | } | |
| 52 | - | return OCRWord{Text: "word", Rect: box} | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func (c *canvas) underline(x0, x1, y int) { c.fill(x0, y, x1, y+1, c.ink) } | |
| 56 | - | ||
| 57 | - | func scanUnder(c *canvas, words ...OCRWord) []ports.HintTarget { | |
| 58 | - | markUnderlined(c.g, words) | |
| 59 | - | return textLinks(words, float64(c.s)) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func TestUnderline(t *testing.T) { | |
| 63 | - | for _, tc := range []struct { | |
| 64 | - | name string | |
| 65 | - | bg, ink uint8 | |
| 66 | - | }{{"dark-bg", 10, 204}, {"light-bg", 245, 30}} { | |
| 67 | - | for _, s := range []int{1, 2} { | |
| 68 | - | t.Run(tc.name, func(t *testing.T) { | |
| 69 | - | mk := func() *canvas { return newCanvas(200, 60, tc.bg, tc.ink, s) } | |
| 70 | - | ||
| 71 | - | c := mk() | |
| 72 | - | w := c.word(10, 20, 40, false) | |
| 73 | - | c.underline(10, 50, 32) | |
| 74 | - | got := scanUnder(c, w) | |
| 75 | - | require.Len(t, got, 1) | |
| 76 | - | require.Equal(t, ports.HintLink, got[0].Kind) | |
| 77 | - | require.False(t, got[0].Text) | |
| 78 | - | require.Equal(t, ports.WindowID(0), got[0].Window) | |
| 79 | - | require.Equal(t, 10, got[0].Rect.X) | |
| 80 | - | ||
| 81 | - | c = mk() | |
| 82 | - | require.Empty(t, scanUnder(c, c.word(10, 20, 40, false)), "plain word") | |
| 83 | - | ||
| 84 | - | c = mk() | |
| 85 | - | require.Empty(t, scanUnder(c, c.word(10, 20, 40, true)), "descenders") | |
| 86 | - | ||
| 87 | - | c = mk() | |
| 88 | - | w = c.word(10, 20, 40, false) | |
| 89 | - | c.underline(0, 200, 32) | |
| 90 | - | require.Empty(t, scanUnder(c, w), "rule across") | |
| 91 | - | ||
| 92 | - | c = mk() | |
| 93 | - | w = c.word(10, 20, 40, false) | |
| 94 | - | c.fill(10, 24, 50, 25, c.ink) | |
| 95 | - | require.Empty(t, scanUnder(c, w), "strikethrough") | |
| 96 | - | ||
| 97 | - | // Two words with a continuous underline: one target. | |
| 98 | - | c = mk() | |
| 99 | - | a, b := c.word(10, 20, 40, false), c.word(56, 20, 40, false) | |
| 100 | - | c.underline(10, 96, 32) | |
| 101 | - | got = scanUnder(c, a, b) | |
| 102 | - | require.Len(t, got, 1) | |
| 103 | - | require.Equal(t, 10, got[0].Rect.X) | |
| 104 | - | require.Equal(t, 86, got[0].Rect.W) | |
| 105 | - | ||
| 106 | - | // A gap in the underline: two targets. | |
| 107 | - | c = mk() | |
| 108 | - | a, b = c.word(10, 20, 40, false), c.word(56, 20, 40, false) | |
| 109 | - | c.underline(10, 50, 32) | |
| 110 | - | c.underline(56, 96, 32) | |
| 111 | - | require.Len(t, scanUnder(c, a, b), 2) | |
| 112 | - | }) | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // A box border under a word, with the word a part of a table row, is not a | |
| 118 | - | // link; neither is an underline that continues beyond into unrelated text. | |
| 119 | - | func TestUnderlineRejectsBox(t *testing.T) { | |
| 120 | - | c := newCanvas(300, 60, 10, 204, 1) | |
| 121 | - | w := c.word(100, 20, 40, false) | |
| 122 | - | c.underline(20, 280, 32) | |
| 123 | - | c.fill(20, 10, 21, 33, 204) | |
| 124 | - | c.fill(279, 10, 280, 33, 204) | |
| 125 | - | require.Empty(t, scanUnder(c, w)) | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // The line must be thin. | |
| 129 | - | func TestUnderlineRejectsBlock(t *testing.T) { | |
| 130 | - | c := newCanvas(100, 60, 10, 204, 1) | |
| 131 | - | w := c.word(10, 20, 40, false) | |
| 132 | - | c.fill(10, 31, 50, 36, 204) | |
| 133 | - | require.Empty(t, scanUnder(c, w)) | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | func TestUnderlinedURLIsOneTextTarget(t *testing.T) { | |
| 137 | - | c := newCanvas(300, 60, 10, 204, 1) | |
| 138 | - | w := c.word(10, 20, 100, false) | |
| 139 | - | w.Text = "example.org" | |
| 140 | - | c.underline(10, 110, 32) | |
| 141 | - | got := scanUnder(c, w) | |
| 142 | - | require.Len(t, got, 1) | |
| 143 | - | require.True(t, got[0].Text) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // ocrTestdata reads the words of a testdata image with the real tesseract. | |
| 147 | - | func ocrTestdata(t *testing.T, name string) (*image.Gray, []OCRWord) { | |
| 148 | - | t.Helper() | |
| 149 | - | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 150 | - | t.Skip("tesseract not on PATH") | |
| 151 | - | } | |
| 152 | - | f, err := os.Open("testdata/" + name) | |
| 153 | - | require.NoError(t, err) | |
| 154 | - | defer f.Close() | |
| 155 | - | img, _, err := image.Decode(f) | |
| 156 | - | require.NoError(t, err) | |
| 157 | - | g := image.NewGray(img.Bounds()) | |
| 158 | - | for y := 0; y < g.Rect.Dy(); y++ { | |
| 159 | - | for x := 0; x < g.Rect.Dx(); x++ { | |
| 160 | - | g.Set(x, y, img.At(x, y)) | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | words, _, err := readWords(t.Context(), g) | |
| 164 | - | require.NoError(t, err) | |
| 165 | - | return g, words | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // headingRule finds the thin horizontal rule under the heading of | |
| 169 | - | // browser.png: the first row, from y=150, with a 900 px run of non-white | |
| 170 | - | // pixels. It returns the rule as a rect. | |
| 171 | - | func headingRule(t *testing.T, g *image.Gray) image.Rectangle { | |
| 172 | - | t.Helper() | |
| 173 | - | for y := 150; y < 220; y++ { | |
| 174 | - | run, start := 0, 0 | |
| 175 | - | for x := 0; x < g.Rect.Dx(); x++ { | |
| 176 | - | if g.GrayAt(x, y).Y < 240 { | |
| 177 | - | if run == 0 { | |
| 178 | - | start = x | |
| 179 | - | } | |
| 180 | - | run++ | |
| 181 | - | if run >= 900 { | |
| 182 | - | return image.Rect(start, y, start+run, y+1) | |
| 183 | - | } | |
| 184 | - | } else { | |
| 185 | - | run = 0 | |
| 186 | - | } | |
| 187 | - | } | |
| 188 | - | } | |
| 189 | - | t.Fatal("heading rule not found") | |
| 190 | - | return image.Rectangle{} | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | // browser.png has blue links and no underlined link: the only underline-only | |
| 194 | - | // target is a known false positive, and the heading rule is not taken for an | |
| 195 | - | // underline. | |
| 196 | - | func TestUnderlineOnBrowserTestdata(t *testing.T) { | |
| 197 | - | g, words := ocrTestdata(t, "browser.png") | |
| 198 | - | rule := headingRule(t, g) | |
| 199 | - | // Known false positive: the word "as" (flat glyph bottoms, ~(878,353)) is | |
| 200 | - | // flagged underlined by the detector. Anything else is a regression. | |
| 201 | - | var under []ports.HintTarget | |
| 202 | - | for _, tg := range textLinks(words, 1) { | |
| 203 | - | if !tg.Text { | |
| 204 | - | under = append(under, tg) | |
| 205 | - | } | |
| 206 | - | } | |
| 207 | - | require.Len(t, under, 1, "underline-only targets: %v", under) | |
| 208 | - | require.True(t, under[0].Rect.X > 860 && under[0].Rect.X < 900 && under[0].Rect.Y > 340 && under[0].Rect.Y < 370, "unexpected target %v", under[0].Rect) | |
| 209 | - | for _, tg := range textLinks(words, 1) { | |
| 210 | - | r := image.Rect(tg.Rect.X, tg.Rect.Y, tg.Rect.X+tg.Rect.W, tg.Rect.Y+tg.Rect.H) | |
| 211 | - | require.False(t, r.Overlaps(rule), "target %v overlaps the heading rule %v", tg.Rect, rule) | |
| 212 | - | } | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | // underline.png is a real render (Chromium) of a generic page: underlined | |
| 216 | - | // links `demo`, `source` and `ermite-runner hosts`, a table with row rules | |
| 217 | - | // and an input box, which must yield nothing. | |
| 218 | - | func TestUnderlineOnRealRender(t *testing.T) { | |
| 219 | - | _, words := ocrTestdata(t, "underline.png") | |
| 220 | - | near := func(r ports.Rect, x0, y0, x1, y1 int) bool { | |
| 221 | - | const tol = 8 | |
| 222 | - | abs := func(v int) int { return max(v, -v) } | |
| 223 | - | return abs(r.X-x0) <= tol && abs(r.Y-y0) <= tol && abs(r.X+r.W-x1) <= tol && abs(r.Y+r.H-y1) <= tol | |
| 224 | - | } | |
| 225 | - | var got []ports.HintTarget | |
| 226 | - | for _, tg := range textLinks(words, 1) { | |
| 227 | - | if !tg.Text { | |
| 228 | - | got = append(got, tg) | |
| 229 | - | } | |
| 230 | - | } | |
| 231 | - | require.Len(t, got, 3, "targets: %v", got) | |
| 232 | - | for _, want := range [][4]int{{148, 122, 196, 137}, {306, 126, 364, 137}, {122, 165, 294, 180}} { | |
| 233 | - | found := false | |
| 234 | - | for _, tg := range got { | |
| 235 | - | found = found || near(tg.Rect, want[0], want[1], want[2], want[3]) | |
| 236 | - | } | |
| 237 | - | require.True(t, found, "no target near %v in %v", want, got) | |
| 238 | - | } | |
| 239 | - | // The table (y 215..335) and the input box (y 360..410) give nothing. | |
| 240 | - | for _, tg := range textLinks(words, 1) { | |
| 241 | - | require.False(t, tg.Rect.Y > 200 && tg.Rect.Y < 420, "unexpected target %v", tg.Rect) | |
| 242 | - | } | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | // A single glyph with a flat bottom stroke (8, L) is not underlined text. | |
| 246 | - | func TestUnderlineIgnoresSingleGlyph(t *testing.T) { | |
| 247 | - | c := newCanvas(100, 60, 10, 204, 1) | |
| 248 | - | w := c.word(10, 20, 10, false) | |
| 249 | - | w.Text = "8" | |
| 250 | - | c.underline(10, 20, 32) | |
| 251 | - | require.Empty(t, scanUnder(c, w)) | |
| 252 | - | } | |
M internal/app/app.go +6 -6
| @@ -84,7 +84,7 @@ const busRetry = 2 * time.Second | |||
| 84 | 84 | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | |
| 85 | 85 | kb := cur.Keyboard | |
| 86 | 86 | devCfg := cur.InputDevicesConfig | |
| 87 | - | hintOn := cur.Hint.Enable | |
| 87 | + | hint := cur.Hint | |
| 88 | 88 | for { | |
| 89 | 89 | var ev ports.ConfigChanged | |
| 90 | 90 | select { | |
| @@ -92,11 +92,11 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||
| 92 | 92 | return | |
| 93 | 93 | case ev = <-in: | |
| 94 | 94 | } | |
| 95 | - | // hint.enable is read at startup: nothing of hint mode starts or | |
| 96 | - | // stops on a reload. | |
| 97 | - | if ev.Config.Hint.Enable != hintOn { | |
| 98 | - | log.Info().Bool("running", hintOn).Msg("hint.enable changes apply after restart") | |
| 99 | - | ev.Config.Hint.Enable = hintOn | |
| 95 | + | // hint.enable, hint.detector, hint.model and hint.threshold are read | |
| 96 | + | // at startup: nothing of hint mode starts or stops on a reload. | |
| 97 | + | if h := ev.Config.Hint; h.Enable != hint.Enable || h.Detector != hint.Detector || h.Model != hint.Model || h.Threshold != hint.Threshold { | |
| 98 | + | log.Info().Bool("running", hint.Enable).Msg("hint.enable, hint.detector, hint.model and hint.threshold apply after restart") | |
| 99 | + | ev.Config.Hint.Enable, ev.Config.Hint.Detector, ev.Config.Hint.Model, ev.Config.Hint.Threshold = hint.Enable, hint.Detector, hint.Model, hint.Threshold | |
| 100 | 100 | } | |
| 101 | 101 | next := ev.Config.Keyboard | |
| 102 | 102 | layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options | |
R internal/app/hintdebug.go → internal/app/hint.go +7 -10 renamed, 66% similar
| @@ -5,21 +5,18 @@ import ( | |||
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/neferwl/internal/adapters/atspi" | |
| 9 | 8 | "github.com/bnema/neferwl/internal/adapters/hints" | |
| 10 | 9 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | // hintOptions builds the hint worker options: the accessibility source, and, | |
| 14 | - | // when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames, | |
| 15 | - | // $XDG_STATE_HOME/neferwl/hints. The accessibility bus is only contacted | |
| 16 | - | // by the first scan; without it the worker reads windows with OCR only. | |
| 17 | - | func hintOptions(ctx context.Context) hints.Options { | |
| 13 | + | // hintOptions builds the hint worker options from the configuration (read at | |
| 14 | + | // startup) and, when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned | |
| 15 | + | // frames, $XDG_STATE_HOME/neferwl/hints. The detector process only starts at | |
| 16 | + | // the first scan. | |
| 17 | + | func hintOptions(ctx context.Context, cfg ports.Config) hints.Options { | |
| 18 | 18 | opts := hintDump(ctx) | |
| 19 | - | // The source connects on its first scan, and again after a failure. The | |
| 20 | - | // hints worker owns it and closes it when it returns. | |
| 21 | - | src := atspi.New() | |
| 22 | - | opts.Source = src | |
| 19 | + | opts.Command, opts.ModelDir, opts.Threshold = cfg.Hint.Detector, cfg.Hint.Model, cfg.Hint.Threshold | |
| 23 | 20 | return opts | |
| 24 | 21 | } | |
| 25 | 22 | ||
M internal/app/hintoptin_test.go +18 -2
| @@ -45,7 +45,6 @@ func TestHintOffStartsNoHintGoroutine(t *testing.T) { | |||
| 45 | 45 | buf := make([]byte, 1<<20) | |
| 46 | 46 | dump := string(buf[:runtime.Stack(buf, true)]) | |
| 47 | 47 | require.NotContains(t, dump, "adapters/hints.") | |
| 48 | - | require.NotContains(t, dump, "adapters/atspi.") | |
| 49 | 48 | require.NotContains(t, dump, "routeCaptured") | |
| 50 | 49 | select { | |
| 51 | 50 | case <-done: | |
| @@ -64,5 +63,22 @@ func TestRelayConfigKeepsStartupHintEnable(t *testing.T) { | |||
| 64 | 63 | next := config.Defaults() | |
| 65 | 64 | next.Hint.Enable = true | |
| 66 | 65 | in <- ports.ConfigChanged{Config: next} | |
| 67 | - | require.False(t, (<-out).Config.Hint.Enable) | |
| 66 | + | got := (<-out).Config.Hint | |
| 67 | + | require.False(t, got.Enable) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestRelayConfigKeepsStartupHintDetector(t *testing.T) { | |
| 71 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 72 | + | defer cancel() | |
| 73 | + | cur := config.Defaults() | |
| 74 | + | in := make(chan ports.ConfigChanged, 1) | |
| 75 | + | out := make(chan ports.ConfigChanged, 1) | |
| 76 | + | go relayConfig(ctx, cur, in, out, make(chan *xkb.Keymap, 1), make(chan ports.InputDevicesConfig, 1), newCursors(clock.System{}, 0), make(chan ports.ClientCommand, 1), logging.For(ctx, "config")) | |
| 77 | + | next := config.Defaults() | |
| 78 | + | next.Hint.Detector, next.Hint.Model, next.Hint.Threshold = "/other/nh", "/other/models", 0.5 | |
| 79 | + | in <- ports.ConfigChanged{Config: next} | |
| 80 | + | got := (<-out).Config.Hint | |
| 81 | + | require.Equal(t, cur.Hint.Detector, got.Detector) | |
| 82 | + | require.Equal(t, cur.Hint.Model, got.Model) | |
| 83 | + | require.Equal(t, cur.Hint.Threshold, got.Threshold) | |
| 68 | 84 | } | |
M internal/app/session.go +2 -2
| @@ -102,9 +102,9 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 102 | 102 | return s, err | |
| 103 | 103 | } | |
| 104 | 104 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | |
| 105 | - | // Hint mode off: no accessibility connection, no debug env, nothing. | |
| 105 | + | // Hint mode off: no detector, no debug env, nothing. | |
| 106 | 106 | if opts.Config.Hint.Enable { | |
| 107 | - | s.hintOpts = hintOptions(ctx) | |
| 107 | + | s.hintOpts = hintOptions(ctx, opts.Config) | |
| 108 | 108 | } | |
| 109 | 109 | // Every output renders on the same GPU: its formats are the clients'. | |
| 110 | 110 | s.dmabuf = vulkan.Probe() | |
M internal/core/hint.go +15 -43
| @@ -36,7 +36,6 @@ type hintTarget struct { | |||
| 36 | 36 | source hintSourceGeom // source window and its request-time client geometry | |
| 37 | 37 | label string | |
| 38 | 38 | rect ports.Rect | |
| 39 | - | text bool // found by OCR | |
| 40 | 39 | } | |
| 41 | 40 | ||
| 42 | 41 | type hintSourceGeom struct { | |
| @@ -52,7 +51,7 @@ type hintState struct { | |||
| 52 | 51 | // one on screen ends it. | |
| 53 | 52 | ws *Workspace | |
| 54 | 53 | geom hintGeom | |
| 55 | - | windows []ports.HintWindow // visible windows at request time | |
| 54 | + | windows []hintSourceGeom // visible windows and their client geometry at request time | |
| 56 | 55 | targets []hintTarget | |
| 57 | 56 | typed string | |
| 58 | 57 | } | |
| @@ -91,7 +90,7 @@ func (c *Core) hintRequest() { | |||
| 91 | 90 | return | |
| 92 | 91 | } | |
| 93 | 92 | wins := c.hintWindows(sc) | |
| 94 | - | req := ports.HintScanRequest{Seq: c.hint.seq + 1, Output: sc.name(), Width: sc.info.Width, Height: sc.info.Height, Scale: sc.scale, LinksOnly: c.cfg.Hint.LinksOnly, Windows: wins} | |
| 93 | + | req := ports.HintScanRequest{Seq: c.hint.seq + 1, Output: sc.name(), Width: sc.info.Width, Height: sc.info.Height, Scale: sc.scale} | |
| 95 | 94 | select { | |
| 96 | 95 | case c.ch.HintScans <- req: | |
| 97 | 96 | default: | |
| @@ -101,10 +100,11 @@ func (c *Core) hintRequest() { | |||
| 101 | 100 | } | |
| 102 | 101 | ||
| 103 | 102 | // hintWindows lists the windows drawn on sc, bottom to top, with their client | |
| 104 | - | // area (placements are output-local). | |
| 105 | - | func (c *Core) hintWindows(sc *screen) []ports.HintWindow { | |
| 103 | + | // area (placements are output-local). A target is only clicked while the | |
| 104 | + | // geometry of its window is the one seen here. | |
| 105 | + | func (c *Core) hintWindows(sc *screen) []hintSourceGeom { | |
| 106 | 106 | view := sc.mon.viewport() | |
| 107 | - | var out []ports.HintWindow | |
| 107 | + | var out []hintSourceGeom | |
| 108 | 108 | for _, p := range sc.shownLayout() { | |
| 109 | 109 | if p.Hidden || p.Preview > 0 || !view.shows(p) { | |
| 110 | 110 | continue | |
| @@ -113,10 +113,7 @@ func (c *Core) hintWindows(sc *screen) []ports.HintWindow { | |||
| 113 | 113 | if r.W <= 0 || r.H <= 0 { | |
| 114 | 114 | continue | |
| 115 | 115 | } | |
| 116 | - | w := ports.HintWindow{ID: p.ID, Rect: r, Focused: p.Focused} | |
| 117 | - | rec := c.windows.lookup(p.ID) | |
| 118 | - | w.PID, w.AppID = rec.client.PID, rec.client.AppID | |
| 119 | - | out = append(out, w) | |
| 116 | + | out = append(out, hintSourceGeom{id: p.ID, rect: r}) | |
| 120 | 117 | } | |
| 121 | 118 | return out | |
| 122 | 119 | } | |
| @@ -167,11 +164,11 @@ func (c *Core) hintResult(res ports.HintScanResult) bool { | |||
| 167 | 164 | // A layer surface, or a window of another output. | |
| 168 | 165 | continue | |
| 169 | 166 | } | |
| 170 | - | source, ok := c.hintSource(sc, t, root) | |
| 167 | + | source, ok := c.hintSource(sc, root) | |
| 171 | 168 | if !ok { | |
| 172 | 169 | continue | |
| 173 | 170 | } | |
| 174 | - | kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect, text: t.Text}) | |
| 171 | + | kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect}) | |
| 175 | 172 | } | |
| 176 | 173 | if len(kept) == 0 { | |
| 177 | 174 | c.hintCancel() | |
| @@ -196,14 +193,11 @@ func (c *Core) hintResult(res ports.HintScanResult) bool { | |||
| 196 | 193 | } | |
| 197 | 194 | ||
| 198 | 195 | // hintSource resolves the request-time source window only while its client | |
| 199 | - | // geometry is unchanged. This applies equally to accessibility and OCR targets. | |
| 200 | - | func (c *Core) hintSource(sc *screen, t ports.HintTarget, root WindowID) (hintSourceGeom, bool) { | |
| 201 | - | if t.Window != 0 && root != t.Window { | |
| 202 | - | return hintSourceGeom{}, false | |
| 203 | - | } | |
| 196 | + | // geometry is unchanged. | |
| 197 | + | func (c *Core) hintSource(sc *screen, root WindowID) (hintSourceGeom, bool) { | |
| 204 | 198 | for _, w := range c.hint.windows { | |
| 205 | - | if w.ID == root && c.hintSourceUnchanged(sc, hintSourceGeom{id: w.ID, rect: w.Rect}) { | |
| 206 | - | return hintSourceGeom{id: w.ID, rect: w.Rect}, true | |
| 199 | + | if w.id == root && c.hintSourceUnchanged(sc, w) { | |
| 200 | + | return w, true | |
| 207 | 201 | } | |
| 208 | 202 | } | |
| 209 | 203 | return hintSourceGeom{}, false | |
| @@ -292,11 +286,8 @@ func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) { | |||
| 292 | 286 | // synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may | |
| 293 | 287 | // still be down) from turning it into a window drag, without touching c.mods. | |
| 294 | 288 | // | |
| 295 | - | // A target found by OCR is clicked with hint.text-click held (terminals open | |
| 296 | - | // a link on a modified click). Every click gives the window the keyboard | |
| 297 | - | // first, then sends exactly the modifiers to hold (none for other targets), | |
| 298 | - | // and the real modifier state is always sent back afterwards. | |
| 299 | - | func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) { | |
| 289 | + | // The window gets the keyboard first. | |
| 290 | + | func (c *Core) hintClick(ctx context.Context, t hintTarget) error { | |
| 300 | 291 | c.hintCancel() | |
| 301 | 292 | if err := c.publish(ctx); err != nil { | |
| 302 | 293 | return err | |
| @@ -304,19 +295,9 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) { | |||
| 304 | 295 | if !c.hintClickable(t) { | |
| 305 | 296 | return nil | |
| 306 | 297 | } | |
| 307 | - | // Every click states its modifiers (possibly none): the bind's Cmd may | |
| 308 | - | // still be down and the client must not see it on a plain click. | |
| 309 | 298 | if err := c.clickFocus(ctx, t.window); err != nil { | |
| 310 | 299 | return err | |
| 311 | 300 | } | |
| 312 | - | if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: c.hintMods(t)}); err != nil { | |
| 313 | - | return err | |
| 314 | - | } | |
| 315 | - | defer func() { | |
| 316 | - | if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true, State: c.keyState}); err == nil { | |
| 317 | - | err = e | |
| 318 | - | } | |
| 319 | - | }() | |
| 320 | 301 | c.synthetic = true | |
| 321 | 302 | defer func() { c.synthetic = false }() | |
| 322 | 303 | if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil { | |
| @@ -333,15 +314,6 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) { | |||
| 333 | 314 | return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime}) | |
| 334 | 315 | } | |
| 335 | 316 | ||
| 336 | - | // hintMods are the modifiers to hold for a click on t, read at click time so | |
| 337 | - | // a reloaded configuration applies. | |
| 338 | - | func (c *Core) hintMods(t hintTarget) ports.Mods { | |
| 339 | - | if !t.text { | |
| 340 | - | return 0 | |
| 341 | - | } | |
| 342 | - | return c.cfg.Hint.TextClick | |
| 343 | - | } | |
| 344 | - | ||
| 345 | 317 | // hintClickable reports whether a click on t may go out now: unprotected, | |
| 346 | 318 | // pointer free, and the selected window is still under the target's centre. | |
| 347 | 319 | func (c *Core) hintClickable(t hintTarget) bool { | |
M internal/core/hint_test.go +25 -121
| @@ -30,13 +30,7 @@ type hintHarness struct { | |||
| 30 | 30 | // newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped. | |
| 31 | 31 | func newHint(t *testing.T, opts core.Options) *hintHarness { | |
| 32 | 32 | t.Helper() | |
| 33 | - | return newHintMods(t, opts, 0) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // newHintMods is newHint with hint.text-click set to textClick. | |
| 37 | - | func newHintMods(t *testing.T, opts core.Options, textClick ports.Mods) *hintHarness { | |
| 38 | - | t.Helper() | |
| 39 | - | return newHintConfig(t, opts, func(cfg *ports.Config) { cfg.Hint.TextClick = textClick }) | |
| 33 | + | return newHintConfig(t, opts, func(*ports.Config) {}) | |
| 40 | 34 | } | |
| 41 | 35 | ||
| 42 | 36 | // newHintConfig is newHint with the config edited by edit. | |
| @@ -144,13 +138,9 @@ func (h *hintHarness) inside(i int) ports.HintTarget { | |||
| 144 | 138 | func TestHintRequestOnBind(t *testing.T) { | |
| 145 | 139 | h := newHint(t, core.Options{}) | |
| 146 | 140 | req := h.start() | |
| 147 | - | if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.LinksOnly || req.Seq == 0 { | |
| 141 | + | if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.Seq == 0 { | |
| 148 | 142 | t.Fatalf("request %+v", req) | |
| 149 | 143 | } | |
| 150 | - | want := []ports.HintWindow{{ID: 1, PID: 4242, AppID: "org.test.app", Rect: h.win, Focused: true}} | |
| 151 | - | if !slices.Equal(req.Windows, want) { | |
| 152 | - | t.Fatalf("windows %+v, want %+v", req.Windows, want) | |
| 153 | - | } | |
| 154 | 144 | // A second bind while pending does not ask again; the pending phase | |
| 155 | 145 | // cancels on any key. | |
| 156 | 146 | h.press("g", ports.ModSuper) | |
| @@ -593,12 +583,12 @@ func TestHintMappedWindowMovedAborts(t *testing.T) { | |||
| 593 | 583 | } | |
| 594 | 584 | ||
| 595 | 585 | // commandsSent drains the commands up to a barrier, keeping the ones that | |
| 596 | - | // matter for a click: modifiers, focus, motion and buttons. | |
| 586 | + | // matter for a click: focus, motion and buttons. | |
| 597 | 587 | func (h *hintHarness) commandsSent() []ports.ClientCommand { | |
| 598 | 588 | var out []ports.ClientCommand | |
| 599 | 589 | for _, cmd := range h.barrier() { | |
| 600 | 590 | switch cmd.(type) { | |
| 601 | - | case ports.SetModifiers, ports.FocusWindow, ports.PointerMotionTo, ports.PointerButtonTo: | |
| 591 | + | case ports.FocusWindow, ports.PointerMotionTo, ports.PointerButtonTo: | |
| 602 | 592 | out = append(out, cmd) | |
| 603 | 593 | } | |
| 604 | 594 | } | |
| @@ -609,12 +599,6 @@ func commandKinds(cmds []ports.ClientCommand) []string { | |||
| 609 | 599 | var out []string | |
| 610 | 600 | for _, c := range cmds { | |
| 611 | 601 | switch v := c.(type) { | |
| 612 | - | case ports.SetModifiers: | |
| 613 | - | if v.Restore { | |
| 614 | - | out = append(out, "restore") | |
| 615 | - | } else { | |
| 616 | - | out = append(out, fmt.Sprintf("mods:%d", v.Mods)) | |
| 617 | - | } | |
| 618 | 602 | case ports.FocusWindow: | |
| 619 | 603 | out = append(out, fmt.Sprintf("focus:%d", v.ID)) | |
| 620 | 604 | case ports.PointerMotionTo: | |
| @@ -630,97 +614,25 @@ func commandKinds(cmds []ports.ClientCommand) []string { | |||
| 630 | 614 | return out | |
| 631 | 615 | } | |
| 632 | 616 | ||
| 633 | - | // An OCR target is clicked with the configured modifiers held: they go out | |
| 634 | - | // before the motion and are restored after the release. | |
| 635 | - | func TestHintTextClickHoldsModifiers(t *testing.T) { | |
| 636 | - | mods := ports.ModCtrl | ports.ModShift | |
| 637 | - | h := newHintMods(t, core.Options{}, mods) | |
| 638 | - | tg := h.inside(0) | |
| 639 | - | tg.Text = true | |
| 640 | - | h.show(tg) | |
| 617 | + | // A click on the focused window moves the pointer to the target and presses | |
| 618 | + | // and releases the left button; it states no modifiers. | |
| 619 | + | func TestHintClickSequence(t *testing.T) { | |
| 620 | + | h := newHint(t, core.Options{}) | |
| 621 | + | h.show(h.inside(0)) | |
| 641 | 622 | h.drain() | |
| 642 | 623 | h.press("f", 0) | |
| 643 | 624 | got := commandKinds(h.commandsSent()) | |
| 644 | - | want := []string{fmt.Sprintf("mods:%d", mods), "motion", "press", "release", "restore"} | |
| 625 | + | want := []string{"motion", "press", "release"} | |
| 645 | 626 | if !slices.Equal(got, want) { | |
| 646 | 627 | t.Fatalf("commands %v, want %v", got, want) | |
| 647 | 628 | } | |
| 648 | 629 | } | |
| 649 | 630 | ||
| 650 | - | // Without text targets or without modifiers the click states no modifiers | |
| 651 | - | // (depressed 0) rather than leave the held bind modifier to the client. | |
| 652 | - | func TestHintClickWithoutModifiers(t *testing.T) { | |
| 653 | - | for name, c := range map[string]struct { | |
| 654 | - | mods ports.Mods | |
| 655 | - | text bool | |
| 656 | - | }{"accessibility target": {ports.ModCtrl, false}, "none configured": {0, true}} { | |
| 657 | - | t.Run(name, func(t *testing.T) { | |
| 658 | - | h := newHintMods(t, core.Options{}, c.mods) | |
| 659 | - | tg := h.inside(0) | |
| 660 | - | tg.Text = c.text | |
| 661 | - | h.show(tg) | |
| 662 | - | h.drain() | |
| 663 | - | h.press("f", 0) | |
| 664 | - | got := commandKinds(h.commandsSent()) | |
| 665 | - | if want := []string{"mods:0", "motion", "press", "release", "restore"}; !slices.Equal(got, want) { | |
| 666 | - | t.Fatalf("commands %v, want %v", got, want) | |
| 667 | - | } | |
| 668 | - | }) | |
| 669 | - | } | |
| 670 | - | } | |
| 671 | - | ||
| 672 | - | // The bind's Super is still physically held: an accessibility click states | |
| 673 | - | // depressed 0, and the restore carries the real state of the latest key, | |
| 674 | - | // swallowed ones included (a latch a label key consumed). | |
| 675 | - | func TestHintClickHeldBindModifierAndLatchRestore(t *testing.T) { | |
| 676 | - | h := newHint(t, core.Options{}) | |
| 677 | - | tg := h.inside(0) | |
| 678 | - | h.press("g", ports.ModSuper) | |
| 679 | - | req := receive(t, h.scans) | |
| 680 | - | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg, h.inside(1)}} | |
| 681 | - | h.labelled(2, 0) | |
| 682 | - | h.drain() | |
| 683 | - | real := ports.ModState{Depressed: 64, Latched: 0, Locked: 2} | |
| 684 | - | h.send(ports.KeyEvent{Keysym: "f", Base: "f", Mods: ports.ModSuper, Pressed: true, State: real}) | |
| 685 | - | cmds := h.commandsSent() | |
| 686 | - | var restore *ports.SetModifiers | |
| 687 | - | for i, c := range cmds { | |
| 688 | - | if v, ok := c.(ports.SetModifiers); ok { | |
| 689 | - | if i == 0 && (v.Restore || v.Mods != 0) { | |
| 690 | - | t.Fatalf("first modifiers %+v, want depressed 0", v) | |
| 691 | - | } | |
| 692 | - | if v.Restore { | |
| 693 | - | restore = &v | |
| 694 | - | } | |
| 695 | - | } | |
| 696 | - | } | |
| 697 | - | if restore == nil || restore.State != real { | |
| 698 | - | t.Fatalf("restore %+v, want state %+v (commands %v)", restore, real, commandKinds(cmds)) | |
| 699 | - | } | |
| 700 | - | } | |
| 701 | - | ||
| 702 | - | // A result is kept only for the window it was scanned on. | |
| 703 | - | func TestHintResultWindowChecks(t *testing.T) { | |
| 704 | - | h := newHint(t, core.Options{}) | |
| 705 | - | req := h.start() | |
| 706 | - | // Accessibility target naming another window: dropped; naming window 1: kept. | |
| 707 | - | ok := h.inside(1) | |
| 708 | - | ok.Window = 1 | |
| 709 | - | bad := h.inside(0) | |
| 710 | - | bad.Window = 7 | |
| 711 | - | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{bad, ok}} | |
| 712 | - | s := h.labelled(1, 0) | |
| 713 | - | if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.Y != ok.Rect.Y { | |
| 714 | - | t.Fatalf("labels %+v", s.HintLabels) | |
| 715 | - | } | |
| 716 | - | } | |
| 717 | - | ||
| 718 | - | // A window that moved while the scan ran: an OCR target over it is dropped. | |
| 719 | - | func TestHintOCRResultDroppedWhenWindowMoved(t *testing.T) { | |
| 631 | + | // A window that moved while the scan ran: a target over it is dropped. | |
| 632 | + | func TestHintResultDroppedWhenWindowMoved(t *testing.T) { | |
| 720 | 633 | h := newHint(t, core.Options{}) | |
| 721 | 634 | req := h.start() | |
| 722 | 635 | tg := h.inside(0) | |
| 723 | - | tg.Text = true | |
| 724 | 636 | h.client <- ports.WindowMapped{ID: 2} | |
| 725 | 637 | h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 726 | 638 | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}} | |
| @@ -731,12 +643,12 @@ func TestHintOCRResultDroppedWhenWindowMoved(t *testing.T) { | |||
| 731 | 643 | } | |
| 732 | 644 | ||
| 733 | 645 | // A new window covering the target (not in the snapshot) drops it as well. | |
| 734 | - | func TestHintOCRResultDroppedForUnseenWindow(t *testing.T) { | |
| 646 | + | func TestHintResultDroppedForUnseenWindow(t *testing.T) { | |
| 735 | 647 | h := newHint(t, core.Options{}) | |
| 736 | 648 | req := h.start() | |
| 737 | 649 | h.client <- ports.WindowMapped{ID: 2} | |
| 738 | 650 | s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 739 | - | tg := ports.HintTarget{Text: true} | |
| 651 | + | var tg ports.HintTarget | |
| 740 | 652 | for _, w := range s.Windows { | |
| 741 | 653 | if w.ID == 2 { | |
| 742 | 654 | tg.Rect = ports.Rect{X: w.Rect.X + 5, Y: w.Rect.Y + 5, W: 20, H: 10} | |
| @@ -769,9 +681,9 @@ func TestHintCancelsWhenOutputChanges(t *testing.T) { | |||
| 769 | 681 | } | |
| 770 | 682 | } | |
| 771 | 683 | ||
| 772 | - | // A window that does not have the keyboard gets it before the modifiers. | |
| 773 | - | func TestHintTextClickFocusesWindowFirst(t *testing.T) { | |
| 774 | - | h := newHintMods(t, core.Options{}, ports.ModCtrl) | |
| 684 | + | // A window that does not have the keyboard gets it before the click. | |
| 685 | + | func TestHintClickFocusesWindowFirst(t *testing.T) { | |
| 686 | + | h := newHint(t, core.Options{}) | |
| 775 | 687 | h.client <- ports.WindowMapped{ID: 2} | |
| 776 | 688 | s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 777 | 689 | var r ports.Rect | |
| @@ -781,24 +693,20 @@ func TestHintTextClickFocusesWindowFirst(t *testing.T) { | |||
| 781 | 693 | } | |
| 782 | 694 | } | |
| 783 | 695 | h.win = r | |
| 784 | - | tg := h.inside(0) | |
| 785 | - | tg.Text = true | |
| 786 | - | h.show(tg) | |
| 696 | + | h.show(h.inside(0)) | |
| 787 | 697 | h.drain() | |
| 788 | 698 | h.press("f", 0) | |
| 789 | 699 | got := commandKinds(h.commandsSent()) | |
| 790 | - | want := []string{"focus:1", fmt.Sprintf("mods:%d", ports.ModCtrl), "motion", "press", "release", "restore"} | |
| 700 | + | want := []string{"focus:1", "motion", "press", "release"} | |
| 791 | 701 | if !slices.Equal(got, want) { | |
| 792 | 702 | t.Fatalf("commands %v, want %v", got, want) | |
| 793 | 703 | } | |
| 794 | 704 | } | |
| 795 | 705 | ||
| 796 | - | // A click that is no longer allowed sends no modifiers at all. | |
| 797 | - | func TestHintTextClickNoModifiersWhenNotClickable(t *testing.T) { | |
| 798 | - | h := newHintMods(t, core.Options{}, ports.ModCtrl) | |
| 799 | - | tg := h.inside(0) | |
| 800 | - | tg.Text = true | |
| 801 | - | h.show(tg) | |
| 706 | + | // A click that is no longer allowed sends no command at all. | |
| 707 | + | func TestHintClickNothingWhenNotClickable(t *testing.T) { | |
| 708 | + | h := newHint(t, core.Options{}) | |
| 709 | + | h.show(h.inside(0)) | |
| 802 | 710 | h.constrain(ports.ConstraintLock, ports.Rect{}) | |
| 803 | 711 | h.drain() | |
| 804 | 712 | h.press("f", 0) | |
| @@ -842,15 +750,11 @@ func TestHintDisabledNotesOnce(t *testing.T) { | |||
| 842 | 750 | // Resizing window 1 leaves the old target centre inside it. Identity alone | |
| 843 | 751 | // therefore cannot protect either admission or the eventual synthetic click. | |
| 844 | 752 | func TestHintRejectsResizedSource(t *testing.T) { | |
| 845 | - | for _, accessibility := range []bool{false, true} { | |
| 753 | + | { | |
| 846 | 754 | for _, beforeResult := range []bool{false, true} { | |
| 847 | - | t.Run(fmt.Sprintf("accessibility=%v/beforeResult=%v", accessibility, beforeResult), func(t *testing.T) { | |
| 755 | + | t.Run(fmt.Sprintf("beforeResult=%v", beforeResult), func(t *testing.T) { | |
| 848 | 756 | h := newHint(t, core.Options{}) | |
| 849 | 757 | tg := h.inside(0) | |
| 850 | - | tg.Text = !accessibility | |
| 851 | - | if accessibility { | |
| 852 | - | tg.Window = 1 | |
| 853 | - | } | |
| 854 | 758 | req := h.start() | |
| 855 | 759 | if !beforeResult { | |
| 856 | 760 | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}} | |
M internal/ports/config.go +7 -6
| @@ -170,17 +170,18 @@ type Config struct { | |||
| 170 | 170 | // Enable turns the experimental hint mode on (hint.enable). It is | |
| 171 | 171 | // read at startup: a reload keeps the startup value. | |
| 172 | 172 | Enable bool | |
| 173 | - | // LinksOnly limits the targets to links and URLs; false also | |
| 174 | - | // includes buttons (hint.targets = clickable). | |
| 175 | - | LinksOnly bool | |
| 176 | - | // TextClick are the modifiers held for a click on a link found by | |
| 177 | - | // OCR (hint.text-click); zero sends a plain click. | |
| 178 | - | TextClick Mods | |
| 179 | 173 | // Capture shows the labels in the captures requested by Wayland | |
| 180 | 174 | // clients (hint.capture); the compositor's own scans never see them. | |
| 181 | 175 | Capture bool | |
| 182 | 176 | Size float64 // logical pixels; zero selects automatic density sizing | |
| 183 | 177 | Background, Foreground string // empty derives from Border.Active | |
| 178 | + | // Detector is the nefer-hints binary (hint.detector): a path, or a | |
| 179 | + | // name looked up in PATH. Model is the directory of its model files | |
| 180 | + | // (hint.model), with a leading ~/ already expanded. Threshold is the | |
| 181 | + | // minimum detection score (hint.threshold, 0.01 to 0.99). All three | |
| 182 | + | // are read at startup. | |
| 183 | + | Detector, Model string | |
| 184 | + | Threshold float64 | |
| 184 | 185 | } | |
| 185 | 186 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 186 | 187 | Workspaces []WorkspaceConfig | |
M internal/ports/hint_messages.go +2 -31
| @@ -1,15 +1,5 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | - | // HintKind is the kind of a clickable hint target. | |
| 4 | - | type HintKind uint8 | |
| 5 | - | ||
| 6 | - | const ( | |
| 7 | - | // HintLink is a link or URL. | |
| 8 | - | HintLink HintKind = iota | |
| 9 | - | // HintButton is a button or control. | |
| 10 | - | HintButton | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | 3 | // HintScanRequest asks the hint scanner to find clickable targets in the last | |
| 14 | 4 | // frame of Output. Core sends it and expects one HintScanResult echoing Seq. | |
| 15 | 5 | type HintScanRequest struct { | |
| @@ -19,31 +9,12 @@ type HintScanRequest struct { | |||
| 19 | 9 | // (OutputInfo.Width/Height). | |
| 20 | 10 | Width, Height int | |
| 21 | 11 | Scale float64 // screen scale | |
| 22 | - | LinksOnly bool | |
| 23 | - | // Windows are the windows visible on Output, for sources that read a | |
| 24 | - | // toolkit's accessibility tree. Core owns the slice per request. | |
| 25 | - | Windows []HintWindow | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // HintWindow is a window visible on the hint output. | |
| 29 | - | type HintWindow struct { | |
| 30 | - | ID WindowID | |
| 31 | - | PID int | |
| 32 | - | AppID string | |
| 33 | - | // Rect is the client area, logical output-local. | |
| 34 | - | Rect Rect | |
| 35 | - | Focused bool | |
| 36 | 12 | } | |
| 37 | 13 | ||
| 38 | 14 | // HintTarget is a clickable region found in a frame. | |
| 39 | 15 | type HintTarget struct { | |
| 40 | - | Rect Rect // logical, output-local | |
| 41 | - | Kind HintKind | |
| 42 | - | // Text is set for targets found by OCR (read from pixels, not from an | |
| 43 | - | // accessibility tree). | |
| 44 | - | Text bool | |
| 45 | - | // Window is the source window of an accessibility target, 0 for OCR. | |
| 46 | - | Window WindowID | |
| 16 | + | Rect Rect // logical, output-local | |
| 17 | + | Score float32 // detector confidence, 0 to 1 | |
| 47 | 18 | } | |
| 48 | 19 | ||
| 49 | 20 | // HintScanResult answers a HintScanRequest. Core owns Targets. | |