Skip to content

Commit f6be55d

Browse files
authored
fix: apply heuristic checks for clickable elements (#38)
1 parent ab53a03 commit f6be55d

4 files changed

Lines changed: 124 additions & 32 deletions

File tree

configs/default-config.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,24 @@ accessibility_check_on_start = true
3939
# Users can add or remove roles as needed
4040
clickable_roles = [
4141
"AXButton",
42+
"AXComboBox",
4243
"AXCheckBox",
4344
"AXRadioButton",
44-
"AXPopUpButton",
4545
"AXLink",
46+
"AXPopUpButton",
4647
"AXTextField",
48+
"AXSlider",
49+
"AXTabGroup",
50+
"AXTabButton",
51+
"AXSwitch",
52+
"AXToolbar",
53+
"AXDisclosureTriangle",
4754
"AXTextArea",
55+
"AXMenuButton",
56+
"AXMenuItem",
57+
"AXGroup",
58+
"AXImage",
59+
"AXCell",
4860
]
4961

5062
# Global accessibility roles that are treated as scrollable

internal/accessibility/element.go

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -380,19 +380,11 @@ func (e *Element) IsClickable() bool {
380380
return false
381381
}
382382

383-
// Some elements (like Dock items, menu bar items) may not report as "enabled"
384-
// but are still clickable
385-
exemptRoles := map[string]bool{
386-
"AXDockItem": true,
387-
"AXMenuBarItem": true,
388-
}
383+
// We are checking this with some predefined heuristics
384+
// Not sure if its working fine, but it works for now
385+
result := C.hasClickAction(e.ref)
389386

390-
if exemptRoles[info.Role] {
391-
return true
392-
}
393-
394-
// For other roles, check if enabled
395-
return info.IsEnabled
387+
return result == 1
396388
}
397389

398390
// GetAllWindows returns all windows of the focused application

internal/bridge/accessibility.m

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -496,31 +496,112 @@ int getChildrenCount(void* element) {
496496
return result;
497497
}
498498

499+
static CFStringRef kAXLinkRole = CFSTR("AXLink");
500+
static CFStringRef kAXCheckboxRole = CFSTR("AXCheckBox");
501+
static CFStringRef kAXFocusableAttribute = CFSTR("AXFocusable");
502+
static CFStringRef kAXVisibleAttribute = CFSTR("AXVisible");
503+
499504
// Check if element has click action
500505
int hasClickAction(void* element) {
501506
if (!element) return 0;
502-
503507
AXUIElementRef axElement = (AXUIElementRef)element;
508+
509+
// Check explicit actions
504510
CFArrayRef actions = NULL;
511+
if (AXUIElementCopyActionNames(axElement, &actions) == kAXErrorSuccess && actions) {
512+
CFIndex count = CFArrayGetCount(actions);
513+
for (CFIndex i = 0; i < count; i++) {
514+
CFStringRef action = (CFStringRef)CFArrayGetValueAtIndex(actions, i);
515+
if (CFStringCompare(action, kAXPressAction, 0) == kCFCompareEqualTo ||
516+
CFStringCompare(action, CFSTR("AXConfirm"), 0) == kCFCompareEqualTo ||
517+
CFStringCompare(action, CFSTR("AXPick"), 0) == kCFCompareEqualTo ||
518+
CFStringCompare(action, CFSTR("AXShowMenu"), 0) == kCFCompareEqualTo) {
519+
CFRelease(actions);
520+
return 1;
521+
}
522+
}
523+
CFRelease(actions);
524+
}
525+
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+
}
505539

506-
AXError error = AXUIElementCopyActionNames(axElement, &actions);
507-
if (error != kAXErrorSuccess || !actions) {
508-
return 0;
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);
551+
return 1;
552+
}
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;
565+
}
566+
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;
509576
}
577+
if (position) CFRelease(position);
578+
if (size) CFRelease(size);
510579

511-
CFIndex count = CFArrayGetCount(actions);
512-
int hasPress = 0;
580+
// Expanded/collapsible state
581+
CFTypeRef expanded = NULL;
582+
if (AXUIElementCopyAttributeValue(axElement, kAXExpandedAttribute, &expanded) == kAXErrorSuccess && expanded) {
583+
CFRelease(expanded);
584+
return 1;
585+
}
513586

514-
for (CFIndex i = 0; i < count; i++) {
515-
CFStringRef action = (CFStringRef)CFArrayGetValueAtIndex(actions, i);
516-
if (CFStringCompare(action, kAXPressAction, 0) == kCFCompareEqualTo) {
517-
hasPress = 1;
518-
break;
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+
}
519600
}
601+
CFRelease(visible);
520602
}
521603

522-
CFRelease(actions);
523-
return hasPress;
604+
return 0;
524605
}
525606

526607
// Perform click

internal/config/config.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,24 @@ func DefaultConfig() *Config {
109109
AccessibilityCheckOnStart: true,
110110
ClickableRoles: []string{
111111
"AXButton",
112+
"AXComboBox",
112113
"AXCheckBox",
113114
"AXRadioButton",
114-
"AXPopUpButton",
115-
"AXMenuItem",
116-
"AXMenuBarItem",
117-
"AXDockItem",
118-
"AXApplicationDockItem",
119115
"AXLink",
116+
"AXPopUpButton",
120117
"AXTextField",
118+
"AXSlider",
119+
"AXTabGroup",
120+
"AXTabButton",
121+
"AXSwitch",
122+
"AXToolbar",
123+
"AXDisclosureTriangle",
121124
"AXTextArea",
125+
"AXMenuButton",
126+
"AXMenuItem",
127+
"AXGroup",
128+
"AXImage",
129+
"AXCell",
122130
},
123131
ScrollableRoles: []string{
124132
"AXScrollArea",
@@ -409,7 +417,6 @@ func (c *Config) GetClickableRolesForApp(bundleID string) []string {
409417
// Add menubar roles if enabled
410418
if c.General.IncludeMenubarHints {
411419
rolesMap["AXMenuBarItem"] = struct{}{}
412-
rolesMap["AXMenuItem"] = struct{}{}
413420
}
414421

415422
// Add dock roles if enabled

0 commit comments

Comments
 (0)