Skip to content

Commit 22b1fa3

Browse files
authored
fix: add cache for elements and improve tree walking duration (#58)
1 parent ac896fb commit 22b1fa3

5 files changed

Lines changed: 240 additions & 18 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ This is an intentional design choice to keep the project lean, maintainable, and
6060

6161
### Consideration (NOT roadmap)
6262

63+
- Dock is not working with hints now, find out why...
6364
- Consider zIndex for hints on different layers? There might be multiple layers when we are considering `Menubar`, `Dock` and `Notification bar`
6465
- Better UI representation for action menu (maybe auto edge detection like tooltip in browser, that will place itself around the element based on the space available around it)
6566
- Find a way to auto deduplicate hints that are targeting the same point

internal/accessibility/cache.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package accessibility
2+
3+
import (
4+
"sync"
5+
"time"
6+
"unsafe"
7+
)
8+
9+
// CachedInfo wraps ElementInfo with expiration time
10+
type CachedInfo struct {
11+
Info *ElementInfo
12+
ExpiresAt time.Time
13+
}
14+
15+
// InfoCache is a thread-safe cache with TTL
16+
type InfoCache struct {
17+
mu sync.RWMutex
18+
data map[uintptr]*CachedInfo
19+
ttl time.Duration
20+
stopCh chan struct{}
21+
stopped bool
22+
}
23+
24+
// NewInfoCache creates a cache with the given TTL
25+
func NewInfoCache(ttl time.Duration) *InfoCache {
26+
cache := &InfoCache{
27+
data: make(map[uintptr]*CachedInfo, 100),
28+
ttl: ttl,
29+
stopCh: make(chan struct{}),
30+
}
31+
32+
// Start cleanup goroutine
33+
go cache.cleanupLoop()
34+
35+
return cache
36+
}
37+
38+
// Get retrieves a cached value if it exists and hasn't expired
39+
func (c *InfoCache) Get(elem *Element) *ElementInfo {
40+
c.mu.RLock()
41+
defer c.mu.RUnlock()
42+
43+
key := uintptr(unsafe.Pointer(elem))
44+
cached, exists := c.data[key]
45+
46+
if !exists {
47+
return nil
48+
}
49+
50+
// Check if expired
51+
if time.Now().After(cached.ExpiresAt) {
52+
return nil
53+
}
54+
55+
return cached.Info
56+
}
57+
58+
// Set stores a value with TTL
59+
func (c *InfoCache) Set(elem *Element, info *ElementInfo) {
60+
c.mu.Lock()
61+
defer c.mu.Unlock()
62+
63+
key := uintptr(unsafe.Pointer(elem))
64+
c.data[key] = &CachedInfo{
65+
Info: info,
66+
ExpiresAt: time.Now().Add(c.ttl),
67+
}
68+
}
69+
70+
// cleanupLoop periodically removes expired entries
71+
func (c *InfoCache) cleanupLoop() {
72+
ticker := time.NewTicker(c.ttl / 2) // Cleanup at half the TTL interval
73+
defer ticker.Stop()
74+
75+
for {
76+
select {
77+
case <-ticker.C:
78+
c.cleanup()
79+
case <-c.stopCh:
80+
return
81+
}
82+
}
83+
}
84+
85+
// cleanup removes expired entries
86+
func (c *InfoCache) cleanup() {
87+
c.mu.Lock()
88+
defer c.mu.Unlock()
89+
90+
now := time.Now()
91+
for key, cached := range c.data {
92+
if now.After(cached.ExpiresAt) {
93+
delete(c.data, key)
94+
}
95+
}
96+
}
97+
98+
// Stop stops the cleanup goroutine
99+
func (c *InfoCache) Stop() {
100+
c.mu.Lock()
101+
defer c.mu.Unlock()
102+
103+
if !c.stopped {
104+
close(c.stopCh)
105+
c.stopped = true
106+
}
107+
}
108+
109+
// Clear removes all entries
110+
func (c *InfoCache) Clear() {
111+
c.mu.Lock()
112+
defer c.mu.Unlock()
113+
114+
c.data = make(map[uintptr]*CachedInfo, 100)
115+
}
116+
117+
// Size returns the number of cached entries
118+
func (c *InfoCache) Size() int {
119+
c.mu.RLock()
120+
defer c.mu.RUnlock()
121+
122+
return len(c.data)
123+
}

internal/accessibility/element.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,17 @@ func (e *Element) GetChildren() ([]*Element, error) {
221221
var count C.int
222222
var rawChildren unsafe.Pointer
223223

224-
info, err := e.GetInfo()
225-
if err == nil {
224+
info := globalCache.Get(e)
225+
if info == nil {
226+
var err error
227+
info, err = e.GetInfo()
228+
if err != nil {
229+
return nil, nil
230+
}
231+
globalCache.Set(e, info)
232+
}
233+
234+
if info != nil {
226235
switch info.Role {
227236
case "AXList", "AXTable", "AXOutline":
228237
ptr := unsafe.Pointer(C.getVisibleRows(e.ref, &count))
@@ -582,9 +591,14 @@ func (e *Element) IsClickable() bool {
582591
return false
583592
}
584593

585-
info, err := e.GetInfo()
586-
if err != nil {
587-
return false
594+
info := globalCache.Get(e)
595+
if info == nil {
596+
var err error
597+
info, err = e.GetInfo()
598+
if err != nil {
599+
return false
600+
}
601+
globalCache.Set(e, info)
588602
}
589603

590604
if !info.IsEnabled {
@@ -611,9 +625,14 @@ func (e *Element) IsScrollable() bool {
611625
return false
612626
}
613627

614-
info, err := e.GetInfo()
615-
if err != nil {
616-
return false
628+
info := globalCache.Get(e)
629+
if info == nil {
630+
var err error
631+
info, err = e.GetInfo()
632+
if err != nil {
633+
return false
634+
}
635+
globalCache.Set(e, info)
617636
}
618637

619638
// Check if the role is in the scrollable roles list

internal/accessibility/query.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ package accessibility
33
import (
44
"fmt"
55
"image"
6+
"sync"
7+
"time"
68

79
"github.com/y3owk1n/neru/internal/logger"
810
)
911

12+
var (
13+
globalCache *InfoCache
14+
cacheOnce sync.Once
15+
)
16+
1017
func rectFromInfo(info *ElementInfo) image.Rectangle {
1118
return image.Rect(
1219
info.Position.X,
@@ -45,6 +52,10 @@ func PrintTree(node *TreeNode, depth int) {
4552

4653
// GetClickableElements returns all clickable elements in the frontmost window
4754
func GetClickableElements() ([]*TreeNode, error) {
55+
cacheOnce.Do(func() {
56+
globalCache = NewInfoCache(5 * time.Second)
57+
})
58+
4859
window := GetFrontmostWindow()
4960
if window == nil {
5061
return nil, fmt.Errorf("no frontmost window")
@@ -65,6 +76,7 @@ func GetClickableElements() ([]*TreeNode, error) {
6576
}
6677

6778
opts := DefaultTreeOptions()
79+
opts.Cache = globalCache
6880
if isElectron {
6981
// For Electron apps, go deeper to find web content
7082
opts.MaxDepth = 40
@@ -91,6 +103,10 @@ func GetClickableElements() ([]*TreeNode, error) {
91103

92104
// GetScrollableElements returns all scrollable elements in the frontmost window
93105
func GetScrollableElements() ([]*TreeNode, error) {
106+
cacheOnce.Do(func() {
107+
globalCache = NewInfoCache(5 * time.Second)
108+
})
109+
94110
window := GetFrontmostWindow()
95111
if window == nil {
96112
return nil, fmt.Errorf("no frontmost window")
@@ -111,6 +127,7 @@ func GetScrollableElements() ([]*TreeNode, error) {
111127
}
112128

113129
opts := DefaultTreeOptions()
130+
opts.Cache = globalCache
114131
if isElectron {
115132
// For Electron apps, go deeper to find web content
116133
opts.MaxDepth = 20
@@ -129,6 +146,10 @@ func GetScrollableElements() ([]*TreeNode, error) {
129146

130147
// GetMenuBarClickableElements returns clickable elements from the focused app's menu bar
131148
func GetMenuBarClickableElements() ([]*TreeNode, error) {
149+
cacheOnce.Do(func() {
150+
globalCache = NewInfoCache(5 * time.Second)
151+
})
152+
132153
app := GetFocusedApplication()
133154
if app == nil {
134155
return []*TreeNode{}, nil
@@ -142,6 +163,7 @@ func GetMenuBarClickableElements() ([]*TreeNode, error) {
142163
defer menubar.Release()
143164

144165
opts := DefaultTreeOptions()
166+
opts.Cache = globalCache
145167
opts.MaxDepth = 10
146168
// Filter out tiny elements
147169
opts.FilterFunc = func(info *ElementInfo) bool {
@@ -163,15 +185,19 @@ func GetMenuBarClickableElements() ([]*TreeNode, error) {
163185

164186
// GetDockClickableElements returns clickable elements from the Dock
165187
func GetDockClickableElements() ([]*TreeNode, error) {
188+
cacheOnce.Do(func() {
189+
globalCache = NewInfoCache(5 * time.Second)
190+
})
191+
166192
dock := GetApplicationByBundleID("com.apple.dock")
167193
if dock == nil {
168194
return []*TreeNode{}, nil
169195
}
170196
defer dock.Release()
171197

172198
opts := DefaultTreeOptions()
199+
opts.Cache = globalCache
173200
opts.IncludeOutOfBounds = true
174-
opts.CheckOcclusion = false
175201
opts.MaxDepth = 10
176202
opts.FilterFunc = func(info *ElementInfo) bool {
177203
if info.Size.X < 6 || info.Size.Y < 6 {
@@ -192,15 +218,19 @@ func GetDockClickableElements() ([]*TreeNode, error) {
192218

193219
// GetNCClickableElements returns clickable elements from the Notification Center
194220
func GetNCClickableElements() ([]*TreeNode, error) {
221+
cacheOnce.Do(func() {
222+
globalCache = NewInfoCache(5 * time.Second)
223+
})
224+
195225
nc := GetApplicationByBundleID("com.apple.notificationcenterui")
196226
if nc == nil {
197227
return []*TreeNode{}, nil
198228
}
199229
defer nc.Release()
200230

201231
opts := DefaultTreeOptions()
232+
opts.Cache = globalCache
202233
opts.IncludeOutOfBounds = true
203-
opts.CheckOcclusion = false
204234
opts.MaxDepth = 10
205235
opts.FilterFunc = func(info *ElementInfo) bool {
206236
if info.Size.X < 6 || info.Size.Y < 6 {

0 commit comments

Comments
 (0)