Skip to content

Commit 5c3a0e3

Browse files
authored
fix: improve large list traversal with progressive checks (#30)
1 parent b572f58 commit 5c3a0e3

3 files changed

Lines changed: 127 additions & 82 deletions

File tree

configs/default-config.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ clickable_roles = [
3939
"AXCheckBox",
4040
"AXRadioButton",
4141
"AXPopUpButton",
42-
"AXMenuItem",
43-
"AXMenuBarItem",
44-
"AXDockItem",
4542
"AXLink",
4643
"AXTextField",
4744
"AXTextArea",
45+
"AXMenuItem", # for menubar hints - TODO: this should be injected when enabled, but now we need to put it here
46+
"AXMenuBarItem", # for menubar hints - TODO: this should be injected when enabled, but now we need to put it here
47+
"AXDockItem", # for dock hints - TODO: this should be injected when enabled, but now we need to put it here
4848
]
4949

5050
# Global accessibility roles that are treated as scrollable

internal/accessibility/query.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ func GetDockClickableElements() ([]*TreeNode, error) {
150150
defer dock.Release()
151151

152152
opts := DefaultTreeOptions()
153+
opts.IncludeOutOfBounds = true
153154
opts.MaxDepth = 8
154155
opts.FilterFunc = func(info *ElementInfo) bool {
155156
if info.Size.X < 6 || info.Size.Y < 6 {

internal/accessibility/tree.go

Lines changed: 123 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package accessibility
22

33
import (
4-
"context"
5-
"sync"
4+
"image"
5+
6+
"github.com/y3owk1n/govim/internal/logger"
7+
"go.uber.org/zap"
68
)
79

810
// TreeNode represents a node in the accessibility tree
@@ -15,18 +17,17 @@ type TreeNode struct {
1517

1618
// TreeOptions configures tree traversal
1719
type TreeOptions struct {
18-
MaxDepth int
19-
IncludeInvisible bool
20-
FilterFunc func(*ElementInfo) bool
21-
MaxConcurrent int
20+
MaxDepth int
21+
FilterFunc func(*ElementInfo) bool
22+
IncludeOutOfBounds bool
2223
}
2324

2425
// DefaultTreeOptions returns default tree traversal options
2526
func DefaultTreeOptions() TreeOptions {
2627
return TreeOptions{
27-
MaxDepth: 10,
28-
MaxConcurrent: 10,
29-
FilterFunc: nil,
28+
MaxDepth: 10,
29+
FilterFunc: nil,
30+
IncludeOutOfBounds: false,
3031
}
3132
}
3233

@@ -41,36 +42,84 @@ func BuildTree(root *Element, opts TreeOptions) (*TreeNode, error) {
4142
return nil, err
4243
}
4344

45+
// Calculate window bounds for spatial filtering
46+
windowBounds := image.Rect(
47+
info.Position.X,
48+
info.Position.Y,
49+
info.Position.X+info.Size.X,
50+
info.Position.Y+info.Size.Y,
51+
)
52+
53+
// Add padding to catch elements slightly outside
54+
windowBounds = expandRectangle(windowBounds, 50)
55+
4456
node := &TreeNode{
4557
Element: root,
4658
Info: info,
4759
}
4860

4961
if opts.MaxDepth > 0 {
50-
buildTreeRecursive(node, 1, opts)
62+
buildTreeRecursive(node, 1, opts, windowBounds)
5163
}
5264

5365
return node, nil
5466
}
5567

56-
func buildTreeRecursive(parent *TreeNode, depth int, opts TreeOptions) {
68+
func buildTreeRecursive(parent *TreeNode, depth int, opts TreeOptions, windowBounds image.Rectangle) {
5769
if depth >= opts.MaxDepth {
5870
return
5971
}
60-
6172
children, err := parent.Element.GetChildren()
6273
if err != nil || len(children) == 0 {
6374
return
6475
}
6576

66-
parent.Children = make([]*TreeNode, 0, len(children))
77+
totalChildren := len(children)
78+
79+
// Smart sampling for large containers
80+
indicesToCheck := getIndicesToCheck(totalChildren, parent.Info.Role)
81+
82+
// Print sampling stats for large containers
83+
if totalChildren > 50 {
84+
logger.Debug("Sampling",
85+
zap.String("role", parent.Info.Role),
86+
zap.Int("total_children", totalChildren),
87+
zap.Int("indices_to_check", len(indicesToCheck)),
88+
zap.Float64("percent", float64(len(indicesToCheck))/float64(totalChildren)*100),
89+
)
90+
}
6791

68-
for _, child := range children {
92+
parent.Children = make([]*TreeNode, 0, len(indicesToCheck))
93+
94+
checkedCount := 0
95+
addedCount := 0
96+
97+
for _, idx := range indicesToCheck {
98+
if idx >= len(children) {
99+
continue
100+
}
101+
child := children[idx]
69102
info, err := child.GetInfo()
70103
if err != nil {
71104
continue
72105
}
73106

107+
checkedCount++
108+
109+
// Skip elements that are completely outside the window bounds
110+
// UNLESS IncludeOutOfBounds is true
111+
if !opts.IncludeOutOfBounds {
112+
elementRect := image.Rect(
113+
info.Position.X,
114+
info.Position.Y,
115+
info.Position.X+info.Size.X,
116+
info.Position.Y+info.Size.Y,
117+
)
118+
if !elementRect.Overlaps(windowBounds) {
119+
continue
120+
}
121+
}
122+
74123
// Apply filter if provided
75124
if opts.FilterFunc != nil && !opts.FilterFunc(info) {
76125
continue
@@ -82,90 +131,85 @@ func buildTreeRecursive(parent *TreeNode, depth int, opts TreeOptions) {
82131
Parent: parent,
83132
Children: []*TreeNode{},
84133
}
85-
86134
parent.Children = append(parent.Children, childNode)
87-
buildTreeRecursive(childNode, depth+1, opts)
135+
addedCount++
136+
buildTreeRecursive(childNode, depth+1, opts, windowBounds)
88137
}
89-
}
90138

91-
// BuildTreeConcurrent builds an accessibility tree using concurrent traversal
92-
func BuildTreeConcurrent(root *Element, opts TreeOptions) (*TreeNode, error) {
93-
if root == nil {
94-
return nil, nil
139+
// Summary for this container
140+
if totalChildren > 50 {
141+
logger.Debug("Result: ",
142+
zap.Int("checked_count", checkedCount),
143+
zap.Int("added_count", addedCount),
144+
zap.Int("skipped_count", totalChildren-checkedCount),
145+
)
95146
}
147+
}
96148

97-
info, err := root.GetInfo()
98-
if err != nil {
99-
return nil, err
149+
// getIndicesToCheck returns which indices to check based on container type and size
150+
func getIndicesToCheck(totalChildren int, role string) []int {
151+
// For non-list containers, check all children
152+
if role != "AXList" && role != "AXTable" && role != "AXOutline" {
153+
indices := make([]int, totalChildren)
154+
for i := range indices {
155+
indices[i] = i
156+
}
157+
return indices
100158
}
101159

102-
node := &TreeNode{
103-
Element: root,
104-
Info: info,
160+
// For small lists, check everything
161+
if totalChildren <= 50 {
162+
indices := make([]int, totalChildren)
163+
for i := range indices {
164+
indices[i] = i
165+
}
166+
return indices
105167
}
106168

107-
if opts.MaxDepth > 0 {
108-
ctx := context.Background()
109-
sem := make(chan struct{}, opts.MaxConcurrent)
110-
var wg sync.WaitGroup
169+
// For very large lists (>1000), be MUCH more conservative
170+
if totalChildren > 1000 {
171+
indices := make([]int, 0, 50)
111172

112-
buildTreeConcurrentRecursive(ctx, node, 1, opts, sem, &wg)
113-
wg.Wait()
114-
}
173+
// First 20 items
174+
for i := 0; i < 20 && i < totalChildren; i++ {
175+
indices = append(indices, i)
176+
}
115177

116-
return node, nil
117-
}
178+
// Sample every 100th item in the middle (or every 5% of total, whichever is larger)
179+
step := max(100, totalChildren/20)
180+
for i := 20; i < totalChildren-20; i += step {
181+
indices = append(indices, i)
182+
}
118183

119-
func buildTreeConcurrentRecursive(ctx context.Context, parent *TreeNode, depth int, opts TreeOptions, sem chan struct{}, wg *sync.WaitGroup) {
120-
if depth >= opts.MaxDepth {
121-
return
122-
}
184+
// Last 20 items
185+
start := max(totalChildren-20, 20)
186+
for i := start; i < totalChildren; i++ {
187+
indices = append(indices, i)
188+
}
123189

124-
children, err := parent.Element.GetChildren()
125-
if err != nil || len(children) == 0 {
126-
return
190+
return indices
127191
}
128192

129-
parent.Children = make([]*TreeNode, 0, len(children))
130-
var mu sync.Mutex
131-
132-
for _, child := range children {
133-
wg.Add(1)
134-
go func(child *Element) {
135-
defer wg.Done()
136-
137-
// Acquire semaphore
138-
select {
139-
case sem <- struct{}{}:
140-
defer func() { <-sem }()
141-
case <-ctx.Done():
142-
return
143-
}
144-
145-
info, err := child.GetInfo()
146-
if err != nil {
147-
return
148-
}
149-
150-
// Apply filter if provided
151-
if opts.FilterFunc != nil && !opts.FilterFunc(info) {
152-
return
153-
}
193+
// For medium lists (50-1000), use the original strategy
194+
indices := make([]int, 0, 100)
154195

155-
childNode := &TreeNode{
156-
Element: child,
157-
Info: info,
158-
Parent: parent,
159-
Children: []*TreeNode{},
160-
}
196+
// First 30
197+
for i := 0; i < 30 && i < totalChildren; i++ {
198+
indices = append(indices, i)
199+
}
161200

162-
mu.Lock()
163-
parent.Children = append(parent.Children, childNode)
164-
mu.Unlock()
201+
// Sample middle (every 10th)
202+
for i := 30; i < totalChildren-20; i += 10 {
203+
indices = append(indices, i)
204+
}
165205

166-
buildTreeConcurrentRecursive(ctx, childNode, depth+1, opts, sem, wg)
167-
}(child)
206+
// Last 20
207+
start := max(totalChildren-20, 30)
208+
for i := start; i < totalChildren; i++ {
209+
indices = append(indices, i)
168210
}
211+
212+
return indices
169213
}
170214

171215
// FindClickableElements finds all clickable elements in the tree

0 commit comments

Comments
 (0)