Skip to content

Commit f8767af

Browse files
authored
fix: better heuristic for clickable detection (#51)
1 parent 5e28692 commit f8767af

5 files changed

Lines changed: 82 additions & 87 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ This is an intentional design choice to keep the project lean, maintainable, and
7272
- Sort of working in Mision Control, but it still shows hints from the frontmost app. How can we know that we are in mission control and ignore the frontmost app?
7373
- Firefox seems fine but still the first activation shows very minimal hint after setting it up, same as electron apps
7474
- Research if there's a good way to implemet visual hint mode where we can select text? Doing this with accessibility seems a little hard and vague
75-
- Current `isClickable` heuristic implementation doesn't looks good enough to cover most cases, think of a way that we can determine if the target is actually clickable with a reliable way
7675
- Add more actions to the menubar like `status`, `stop`, `start`, `current version`
7776
- Test suites, but am lazy for it
7877
- Implements launch agent with `start-service` and `stop-service`? Though I am fine just doing it in my nix config directly

configs/default-config.toml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,14 @@ clickable_roles = [
5757
"AXPopUpButton",
5858
"AXTextField",
5959
"AXSlider",
60-
"AXTabGroup",
6160
"AXTabButton",
6261
"AXSwitch",
63-
"AXToolbar",
6462
"AXDisclosureTriangle",
6563
"AXTextArea",
6664
"AXMenuButton",
6765
"AXMenuItem",
68-
"AXGroup",
69-
"AXImage",
7066
"AXCell",
67+
"AXRow",
7168
]
7269

7370
# Global accessibility roles that are treated as scrollable
@@ -89,6 +86,13 @@ scrollable_roles = [
8986
# # You can also add scrollable roles scoped to the app:
9087
# # additional_scrollable_roles = ["AXScrollArea"]
9188

89+
# For example, in `Chrome`, we can add support for tabgroups.
90+
# [[accessibility.app_configs]]
91+
# bundle_id = "com.google.Chrome"
92+
# additional_clickable_roles = ["AXTabGroup"]
93+
94+
# You can do and customise alot more when the defaults or global are not good enough.
95+
9296
[accessibility.electron_support]
9397
## Electron & Chromium support
9498
# Enables manual accessibility toggles for Electron-like apps.

internal/accessibility/query.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ func GetClickableElements() ([]*TreeNode, error) {
6767
opts := DefaultTreeOptions()
6868
if isElectron {
6969
// For Electron apps, go deeper to find web content
70-
opts.MaxDepth = 25
70+
opts.MaxDepth = 40
7171
logger.Debug("Detected Electron app, using deeper tree traversal for scroll areas")
7272
} else {
73-
opts.MaxDepth = 10
73+
opts.MaxDepth = 25
7474
}
7575
opts.FilterFunc = func(info *ElementInfo) bool {
7676
// Filter out very small elements
@@ -113,10 +113,10 @@ func GetScrollableElements() ([]*TreeNode, error) {
113113
opts := DefaultTreeOptions()
114114
if isElectron {
115115
// For Electron apps, go deeper to find web content
116-
opts.MaxDepth = 15
116+
opts.MaxDepth = 20
117117
logger.Debug("Detected Electron app, using deeper tree traversal for scroll areas")
118118
} else {
119-
opts.MaxDepth = 5
119+
opts.MaxDepth = 10
120120
}
121121

122122
tree, err := BuildTree(window, opts)
@@ -142,7 +142,7 @@ func GetMenuBarClickableElements() ([]*TreeNode, error) {
142142
defer menubar.Release()
143143

144144
opts := DefaultTreeOptions()
145-
opts.MaxDepth = 8
145+
opts.MaxDepth = 10
146146
// Filter out tiny elements
147147
opts.FilterFunc = func(info *ElementInfo) bool {
148148
if info.Size.X < 6 || info.Size.Y < 6 {
@@ -172,7 +172,7 @@ func GetDockClickableElements() ([]*TreeNode, error) {
172172
opts := DefaultTreeOptions()
173173
opts.IncludeOutOfBounds = true
174174
opts.CheckOcclusion = false
175-
opts.MaxDepth = 8
175+
opts.MaxDepth = 10
176176
opts.FilterFunc = func(info *ElementInfo) bool {
177177
if info.Size.X < 6 || info.Size.Y < 6 {
178178
return false
@@ -201,7 +201,7 @@ func GetNCClickableElements() ([]*TreeNode, error) {
201201
opts := DefaultTreeOptions()
202202
opts.IncludeOutOfBounds = true
203203
opts.CheckOcclusion = false
204-
opts.MaxDepth = 8
204+
opts.MaxDepth = 10
205205
opts.FilterFunc = func(info *ElementInfo) bool {
206206
if info.Size.X < 6 || info.Size.Y < 6 {
207207
return false

internal/bridge/accessibility.m

Lines changed: 66 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -506,99 +506,94 @@ int hasClickAction(void* element) {
506506
if (!element) return 0;
507507
AXUIElementRef axElement = (AXUIElementRef)element;
508508

509-
// Check explicit actions
509+
// Ignore hidden or disabled elements early
510+
CFBooleanRef hidden = NULL;
511+
if (AXUIElementCopyAttributeValue(axElement, kAXHiddenAttribute, (CFTypeRef *)&hidden) == kAXErrorSuccess && hidden) {
512+
if (CFBooleanGetValue(hidden)) {
513+
CFRelease(hidden);
514+
return 0;
515+
}
516+
CFRelease(hidden);
517+
}
518+
519+
CFBooleanRef enabled = NULL;
520+
bool isEnabled = true; // default to true if attribute not present
521+
if (AXUIElementCopyAttributeValue(axElement, kAXEnabledAttribute, (CFTypeRef *)&enabled) == kAXErrorSuccess && enabled) {
522+
isEnabled = CFBooleanGetValue(enabled);
523+
CFRelease(enabled);
524+
}
525+
if (!isEnabled) return 0;
526+
527+
// Get role for role-specific fallbacks
528+
CFStringRef role = NULL;
529+
if (AXUIElementCopyAttributeValue(axElement, kAXRoleAttribute, (CFTypeRef *)&role) != kAXErrorSuccess) {
530+
role = NULL;
531+
}
532+
533+
// Explicit actions are the strongest signal
510534
CFArrayRef actions = NULL;
511535
if (AXUIElementCopyActionNames(axElement, &actions) == kAXErrorSuccess && actions) {
512536
CFIndex count = CFArrayGetCount(actions);
513537
for (CFIndex i = 0; i < count; i++) {
514538
CFStringRef action = (CFStringRef)CFArrayGetValueAtIndex(actions, i);
515539
if (CFStringCompare(action, kAXPressAction, 0) == kCFCompareEqualTo ||
540+
CFStringCompare(action, CFSTR("AXShowMenu"), 0) == kCFCompareEqualTo ||
516541
CFStringCompare(action, CFSTR("AXConfirm"), 0) == kCFCompareEqualTo ||
517542
CFStringCompare(action, CFSTR("AXPick"), 0) == kCFCompareEqualTo ||
518-
CFStringCompare(action, CFSTR("AXShowMenu"), 0) == kCFCompareEqualTo) {
543+
CFStringCompare(action, CFSTR("AXRaise"), 0) == kCFCompareEqualTo) {
519544
CFRelease(actions);
545+
if (role) CFRelease(role);
520546
return 1;
521547
}
522548
}
523549
CFRelease(actions);
524550
}
525551

526-
// Focusable + enabled
527-
CFBooleanRef enabled = NULL;
528-
if (AXUIElementCopyAttributeValue(axElement, kAXEnabledAttribute, (CFTypeRef *)&enabled) == kAXErrorSuccess && enabled) {
529-
if (CFBooleanGetValue(enabled)) {
530-
CFRelease(enabled);
531-
CFBooleanRef focusable = NULL;
532-
if (AXUIElementCopyAttributeValue(axElement, kAXFocusableAttribute, (CFTypeRef *)&focusable) == kAXErrorSuccess && focusable) {
533-
int isFocus = CFBooleanGetValue(focusable);
534-
CFRelease(focusable);
535-
if (isFocus) return 1;
536-
}
537-
} else CFRelease(enabled);
538-
}
539-
540-
// Has title or label
541-
CFTypeRef title = NULL;
542-
if (AXUIElementCopyAttributeValue(axElement, kAXTitleAttribute, &title) == kAXErrorSuccess && title) {
543-
CFRelease(title);
544-
return 1;
545-
}
546-
547-
// Has description or help
548-
CFTypeRef desc = NULL;
549-
if (AXUIElementCopyAttributeValue(axElement, kAXDescriptionAttribute, &desc) == kAXErrorSuccess && desc) {
550-
CFRelease(desc);
552+
// Some elements support AXPress even if not listed in action names
553+
CFStringRef pressDesc = NULL;
554+
if (AXUIElementCopyActionDescription(axElement, kAXPressAction, &pressDesc) == kAXErrorSuccess && pressDesc) {
555+
CFRelease(pressDesc);
556+
if (role) CFRelease(role);
551557
return 1;
552558
}
553-
554-
// Has value or selected state
555-
CFTypeRef value = NULL;
556-
if (AXUIElementCopyAttributeValue(axElement, kAXValueAttribute, &value) == kAXErrorSuccess && value) {
557-
CFRelease(value);
558-
return 1;
559-
}
560-
561-
CFTypeRef selected = NULL;
562-
if (AXUIElementCopyAttributeValue(axElement, kAXSelectedAttribute, &selected) == kAXErrorSuccess && selected) {
563-
CFRelease(selected);
564-
return 1;
559+
if (pressDesc) CFRelease(pressDesc);
560+
561+
// Focusable and enabled controls are clickable (e.g., text fields)
562+
CFBooleanRef focusable = NULL;
563+
if (AXUIElementCopyAttributeValue(axElement, kAXFocusableAttribute, (CFTypeRef *)&focusable) == kAXErrorSuccess && focusable) {
564+
if (CFBooleanGetValue(focusable)) {
565+
CFRelease(focusable);
566+
// Ensure the element has a hittable rect and is not occluded by other apps
567+
CGPoint center;
568+
pid_t pid;
569+
bool visible = true;
570+
if (getElementCenter((void*)axElement, &center) && AXUIElementGetPid(axElement, &pid) == kAXErrorSuccess) {
571+
visible = isPointVisible(center, pid);
572+
}
573+
if (role) CFRelease(role);
574+
return visible ? 1 : 0;
575+
}
576+
CFRelease(focusable);
565577
}
566578

567-
// Has visual bounds
568-
CFTypeRef position = NULL;
569-
CFTypeRef size = NULL;
570-
if (AXUIElementCopyAttributeValue(axElement, kAXPositionAttribute, &position) == kAXErrorSuccess &&
571-
AXUIElementCopyAttributeValue(axElement, kAXSizeAttribute, &size) == kAXErrorSuccess &&
572-
position && size) {
573-
CFRelease(position);
574-
CFRelease(size);
575-
return 1;
579+
// Role-specific fallback for links: Treat as clickable if they expose a URL
580+
if (role && CFStringCompare(role, kAXLinkRole, 0) == kCFCompareEqualTo) {
581+
CFTypeRef urlAttr = NULL;
582+
if (AXUIElementCopyAttributeValue(axElement, kAXURLAttribute, &urlAttr) == kAXErrorSuccess && urlAttr) {
583+
CFRelease(urlAttr);
584+
CFRelease(role);
585+
return 1;
586+
}
587+
if (urlAttr) CFRelease(urlAttr);
576588
}
577-
if (position) CFRelease(position);
578-
if (size) CFRelease(size);
579589

580-
// Expanded/collapsible state
581-
CFTypeRef expanded = NULL;
582-
if (AXUIElementCopyAttributeValue(axElement, kAXExpandedAttribute, &expanded) == kAXErrorSuccess && expanded) {
583-
CFRelease(expanded);
584-
return 1;
585-
}
590+
if (role) CFRelease(role);
586591

587-
// Visible + leaf node
588-
CFBooleanRef visible = NULL;
589-
if (AXUIElementCopyAttributeValue(axElement, kAXVisibleAttribute, (CFTypeRef *)&visible) == kAXErrorSuccess && visible) {
590-
if (CFBooleanGetValue(visible)) {
591-
CFArrayRef children = NULL;
592-
if (AXUIElementCopyAttributeValue(axElement, kAXChildrenAttribute, (CFTypeRef *)&children) == kAXErrorSuccess) {
593-
if (children && CFArrayGetCount(children) == 0) {
594-
CFRelease(children);
595-
CFRelease(visible);
596-
return 1;
597-
}
598-
if (children) CFRelease(children);
599-
}
600-
}
601-
CFRelease(visible);
592+
// final check, ensure a visible bounding box and not occluded
593+
CGPoint center;
594+
pid_t pid;
595+
if (getElementCenter((void*)axElement, &center) && AXUIElementGetPid(axElement, &pid) == kAXErrorSuccess) {
596+
return isPointVisible(center, pid) ? 1 : 0;
602597
}
603598

604599
return 0;

internal/config/config.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,14 @@ func DefaultConfig() *Config {
126126
"AXPopUpButton",
127127
"AXTextField",
128128
"AXSlider",
129-
"AXTabGroup",
130129
"AXTabButton",
131130
"AXSwitch",
132-
"AXToolbar",
133131
"AXDisclosureTriangle",
134132
"AXTextArea",
135133
"AXMenuButton",
136134
"AXMenuItem",
137-
"AXGroup",
138-
"AXImage",
139135
"AXCell",
136+
"AXRow",
140137
},
141138
ScrollableRoles: []string{
142139
"AXScrollArea",

0 commit comments

Comments
 (0)