detached 3e96bd1

fix(atspi): match frames to windows one to one and tag targets with their window

commit
3e96bd1139216eb4a92d31d3a74d7326627e5bcd
author
brice · · 2026-10-19 15:30 UTC
committer
brice · · 2026-10-19 15:30 UTC
parent
7bfc830
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +56 -26
M internal/adapters/atspi/model.go +35 -21
@@ -113,12 +113,13 @@ type frameInfo struct {
113113 w, h int // size of the frame, from its window extents
114114 }
115115
116-// matchFrames pairs the windows of one app with its frames. A window is
117-// paired with a frame when the pairing is certain:
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:
118118 // - one window and one frame;
119-// - the focused window with the one ACTIVE frame;
120-// - otherwise the one frame whose size is within sizeTolerance of the
121-// window, provided no other window claims it as well.
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.
122123 //
123124 // Ambiguous windows are left out. The result maps the index of a window in
124125 // wins to the index of its frame in frames.
@@ -128,31 +129,44 @@ func matchFrames(wins []ports.HintWindow, frames []frameInfo) map[int]int {
128129 out[0] = 0
129130 return out
130131 }
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+ }
131155 claims := make([][]int, len(wins)) // size candidates per window
132156 owners := make([]int, len(frames)) // windows that size-match a frame
133157 for i, w := range wins {
158+ if done[i] {
159+ continue
160+ }
134161 for j, f := range frames {
135- if absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance {
162+ if !reserved[j] && absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance {
136163 claims[i] = append(claims[i], j)
137164 owners[j]++
138165 }
139166 }
140167 }
141- for i, w := range wins {
142- if w.Focused {
143- active := -1
144- n := 0
145- for j, f := range frames {
146- if f.active {
147- active, n = j, n+1
148- }
149- }
150- if n == 1 {
151- out[i] = active
152- continue
153- }
154- }
155- if len(claims[i]) == 1 && owners[claims[i][0]] == 1 {
168+ for i := range wins {
169+ if !done[i] && len(claims[i]) == 1 && owners[claims[i][0]] == 1 {
156170 out[i] = claims[i][0]
157171 }
158172 }
@@ -113,12 +113,13 @@ type frameInfo struct {
113 w, h int // size of the frame, from its window extents113 w, h int // size of the frame, from its window extents
114 }114 }
115 115
116-// matchFrames pairs the windows of one app with its frames. A window is116+// matchFrames pairs the windows of one app with its frames, one to one. A
117-// paired with a frame when the pairing is certain:117+// window is paired with a frame when the pairing is certain:
118 // - one window and one frame;118 // - one window and one frame;
119-// - the focused window with the one ACTIVE frame;119+// - the focused window with the one ACTIVE frame; that frame is then
120-// - otherwise the one frame whose size is within sizeTolerance of the120+// reserved and no other window can take it;
121-// window, provided no other window claims it as well.121+// - otherwise the one unreserved frame whose size is within sizeTolerance
122+// of the window, provided no other unmatched window claims it as well.
122 //123 //
123 // Ambiguous windows are left out. The result maps the index of a window in124 // Ambiguous windows are left out. The result maps the index of a window in
124 // wins to the index of its frame in frames.125 // wins to the index of its frame in frames.
@@ -128,31 +129,44 @@ func matchFrames(wins []ports.HintWindow, frames []frameInfo) map[int]int {
128 out[0] = 0129 out[0] = 0
129 return out130 return out
130 }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+ }
131 claims := make([][]int, len(wins)) // size candidates per window155 claims := make([][]int, len(wins)) // size candidates per window
132 owners := make([]int, len(frames)) // windows that size-match a frame156 owners := make([]int, len(frames)) // windows that size-match a frame
133 for i, w := range wins {157 for i, w := range wins {
158+ if done[i] {
159+ continue
160+ }
134 for j, f := range frames {161 for j, f := range frames {
135- if absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance {162+ if !reserved[j] && absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance {
136 claims[i] = append(claims[i], j)163 claims[i] = append(claims[i], j)
137 owners[j]++164 owners[j]++
138 }165 }
139 }166 }
140 }167 }
141- for i, w := range wins {168+ for i := range wins {
142- if w.Focused {169+ if !done[i] && len(claims[i]) == 1 && owners[claims[i][0]] == 1 {
143- active := -1
144- n := 0
145- for j, f := range frames {
146- if f.active {
147- active, n = j, n+1
148- }
149- }
150- if n == 1 {
151- out[i] = active
152- continue
153- }
154- }
155- if len(claims[i]) == 1 && owners[claims[i][0]] == 1 {
156 out[i] = claims[i][0]170 out[i] = claims[i][0]
157 }171 }
158 }172 }
M internal/adapters/atspi/model_test.go +16 -3
@@ -73,9 +73,9 @@ func TestMatchFramesSingle(t *testing.T) {
7373 func TestMatchFramesActive(t *testing.T) {
7474 frames := []frameInfo{{w: 800, h: 600}, {w: 800, h: 600, active: true}}
7575 got := matchFrames(wins(win(1, 800, 600, true), win(2, 800, 600, false)), frames)
76- // The focused window takes the ACTIVE frame; the other cannot be told
77- // apart by size (both frames match it) and is left out.
78- require.Equal(t, map[int]int{0: 1}, got)
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)
7979 }
8080
8181 func TestMatchFramesSize(t *testing.T) {
@@ -102,3 +102,16 @@ func TestMatchFramesAmbiguous(t *testing.T) {
102102 // No frame close enough.
103103 require.Empty(t, matchFrames(wins(win(1, 200, 200, false), win(2, 300, 300, false)), frames))
104104 }
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+}
@@ -73,9 +73,9 @@ func TestMatchFramesSingle(t *testing.T) {
73 func TestMatchFramesActive(t *testing.T) {73 func TestMatchFramesActive(t *testing.T) {
74 frames := []frameInfo{{w: 800, h: 600}, {w: 800, h: 600, active: true}}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)75 got := matchFrames(wins(win(1, 800, 600, true), win(2, 800, 600, false)), frames)
76- // The focused window takes the ACTIVE frame; the other cannot be told76+ // The focused window takes the ACTIVE frame and reserves it; the other
77- // apart by size (both frames match it) and is left out.77+ // window then only has the remaining frame to match.
78- require.Equal(t, map[int]int{0: 1}, got)78+ require.Equal(t, map[int]int{0: 1, 1: 0}, got)
79 }79 }
80 80
81 func TestMatchFramesSize(t *testing.T) {81 func TestMatchFramesSize(t *testing.T) {
@@ -102,3 +102,16 @@ func TestMatchFramesAmbiguous(t *testing.T) {
102 // No frame close enough.102 // No frame close enough.
103 require.Empty(t, matchFrames(wins(win(1, 200, 200, false), win(2, 300, 300, false)), frames))103 require.Empty(t, matchFrames(wins(win(1, 200, 200, false), win(2, 300, 300, false)), frames))
104 }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+}
M internal/adapters/atspi/source.go +1 -0
@@ -255,6 +255,7 @@ func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly
255255 if !ok {
256256 continue
257257 }
258+ t.Window = m.win.ID
258259 out = append(out, t)
259260 if t.Kind == ports.HintLink {
260261 covered[m.win.ID] = true
@@ -255,6 +255,7 @@ func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly
255 if !ok {255 if !ok {
256 continue256 continue
257 }257 }
258+ t.Window = m.win.ID
258 out = append(out, t)259 out = append(out, t)
259 if t.Kind == ports.HintLink {260 if t.Kind == ports.HintLink {
260 covered[m.win.ID] = true261 covered[m.win.ID] = true
M internal/adapters/atspi/source_test.go +2 -2
@@ -74,8 +74,8 @@ func TestTargetsCoversWindowAndConverts(t *testing.T) {
7474 require.NoError(t, err)
7575 require.Equal(t, map[ports.WindowID]bool{1: true}, covered)
7676 require.Equal(t, []ports.HintTarget{
77- {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink},
78- {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton},
77+ {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink, Window: 1},
78+ {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton, Window: 1},
7979 }, targets)
8080 }
8181
@@ -74,8 +74,8 @@ func TestTargetsCoversWindowAndConverts(t *testing.T) {
74 require.NoError(t, err)74 require.NoError(t, err)
75 require.Equal(t, map[ports.WindowID]bool{1: true}, covered)75 require.Equal(t, map[ports.WindowID]bool{1: true}, covered)
76 require.Equal(t, []ports.HintTarget{76 require.Equal(t, []ports.HintTarget{
77- {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink},77+ {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink, Window: 1},
78- {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton},78+ {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton, Window: 1},
79 }, targets)79 }, targets)
80 }80 }
81 81
M internal/ports/hint_messages.go +2 -0
@@ -42,6 +42,8 @@ type HintTarget struct {
4242 // Text is set for targets found by OCR (read from pixels, not from an
4343 // accessibility tree).
4444 Text bool
45+ // Window is the source window of an accessibility target, 0 for OCR.
46+ Window WindowID
4547 }
4648
4749 // HintScanResult answers a HintScanRequest. Core owns Targets.
@@ -42,6 +42,8 @@ type HintTarget struct {
42 // Text is set for targets found by OCR (read from pixels, not from an42 // Text is set for targets found by OCR (read from pixels, not from an
43 // accessibility tree).43 // accessibility tree).
44 Text bool44 Text bool
45+ // Window is the source window of an accessibility target, 0 for OCR.
46+ Window WindowID
45 }47 }
46 48
47 // HintScanResult answers a HintScanRequest. Core owns Targets.49 // HintScanResult answers a HintScanRequest. Core owns Targets.

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close