Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M5R6B8TAZ57JZG7CAA2WW9TZ
anchor: beneath
created: 2026-10-25T00:11:11Z
norm: '1'
---

Chrome's vertical tab strip covers its tabs with empty AXGroups that the AX hit test returns; only such childless, actionless groups give way to what is beneath
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Notable changes to Desktop Tools, newest first. Each release's GitHub release no

- `desktop_inspect` takes its screenshot after reading the window's Accessibility tree, not during it. Reading some SwiftUI lists makes them load rows and grow, which moved content after the screenshot was taken, so a point read off it could click whatever moved under it. An inspection takes about as long as its tree read longer. ([#159](https://github.com/githubnext/desktop-tools/issues/159))
- `return` is accepted as a key name wherever keys are named (`desktop_key`, `desktop_system_key`), as an alias of `enter`. `desktop_system_key` results name the app that was frontmost when the keys were sent (`frontmost`), and `desktop_wait`'s description states its argument rules: `element_appears` and `element_gone` need an element with a role plus a title, value or identifier, and `timeout_ms` is an integer from 100 to 45000. ([#199](https://github.com/githubnext/desktop-tools/issues/199))
- A background point click on a tab in Chrome's vertical tab strip switches to that tab. Chrome lays empty groups over its vertical tabs, and the Accessibility hit test returns one of those, so the click was refused with "No pressable accessibility element". When the element at the point is such an empty group, the click now presses the smallest pressable element beneath it, such as the tab or its close button. ([#202](https://github.com/githubnext/desktop-tools/issues/202))

- An open menu's window, such as a SwiftUI `MenuBarExtra` status item's NSMenu or one of its submenus, can be inspected: `desktop_inspect` reads the open `AXMenu` and lists its items with `checked` for a checkmark, so they can be clicked by element. `desktop_menubar_click` on an item whose menu is open closes the menu without choosing an item (`closed_menu`), and `desktop_element_action` `AXCancel` on the menu closes it too. A window opened by a status item is `inspectable` only when it has Accessibility elements to read. ([#193](https://github.com/githubnext/desktop-tools/issues/193))
- Typing refused because secure keyboard entry is on now names the app and window holding it (`secure_input_holder`: pid, name, bundle ID, window title), read from the window server's session, such as a system password dialog behind the target, and asks to have the user dismiss it. ([#176](https://github.com/githubnext/desktop-tools/issues/176))
- `desktop_hover` and foreground `desktop_drag` no longer count a window that lets the pointer through, such as a screen recorder's frame, as covering the target: the cover check asks the window server which window takes pointer events at the point. An unverified hover or a cover refusal now names the window found there. `desktop_focus` and the other window and app tools say which `target` field is missing or wrong, such as a pid and window passed at the top level, and a window tool no longer asks for an application's target. ([#196](https://github.com/githubnext/desktop-tools/issues/196))
Expand Down
29 changes: 28 additions & 1 deletion native2/Sources/DesktopMac/Pointer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,14 @@ extension Mac {
throw Refusal("ELEMENT_NOT_FOUND", "No Accessibility element is at that point in the window.")
}
var candidates = [hit] + descendants(of: hit, containing: point)
var ancestors: [AXUIElement] = []
var parent = hit
for _ in 0..<8 {
guard let next = AX.element(AX.value(parent, kAXParentAttribute)) else { break }
candidates.append(next)
ancestors.append(next)
parent = next
}
candidates += beneathOverlay(hit, ancestors: ancestors, containing: point) + ancestors
let roles = candidates.map { AX.string($0, kAXRoleAttribute) ?? "" }
let enabled = candidates.map { (AX.value($0, kAXEnabledAttribute) as? Bool) != false }
let pick: (AXUIElement, String)? = {
Expand Down Expand Up @@ -379,6 +381,31 @@ extension Mac {
return AXUIElementCopyActionNames(element, &names) == .success ? names as? [String] ?? [] : []
}

/// Elements under an empty overlay at the point, smallest first. Chrome's vertical tab strip lays childless,
/// actionless groups over its tabs, and the Accessibility hit test returns one of those, whose ancestors hold
/// nothing pressable (#202). When the hit is such a group, the tree of its parent, then grandparent, is searched
/// for elements at the point instead, so the tab or its close button is found.
private static func beneathOverlay(_ hit: AXUIElement, ancestors: [AXUIElement], containing point: CGPoint) -> [AXUIElement] {
beneath(
hit, ancestors: ancestors, role: { AX.string($0, kAXRoleAttribute) ?? "" },
isLeaf: { (AX.value($0, kAXChildrenAttribute) as? [AXUIElement] ?? []).isEmpty }, actions: actions,
atPoint: { descendants(of: $0, containing: point) }, area: { AX.frame($0).map { $0.width * $0.height } ?? .greatestFiniteMagnitude },
same: { CFEqual($0, $1) }
)
}

static func beneath<Node>(
_ hit: Node, ancestors: [Node], role: (Node) -> String, isLeaf: (Node) -> Bool, actions: (Node) -> [String],
atPoint: (Node) -> [Node], area: (Node) -> CGFloat, same: (Node, Node) -> Bool
) -> [Node] {
guard role(hit) == "AXGroup", isLeaf(hit), actions(hit).isEmpty else { return [] }
for root in ancestors.prefix(2) {
let found = atPoint(root).filter { !same($0, hit) && actions($0).contains(kAXPressAction) }
if !found.isEmpty { return found.sorted { area($0) < area($1) } }
}
return []
}

/// Up to 256 descendants, 8 levels deep, whose frames contain the point, breadth first.
private static func descendants(of root: AXUIElement, containing point: CGPoint) -> [AXUIElement] {
var found: [AXUIElement] = []
Expand Down
38 changes: 38 additions & 0 deletions native2/Tests/RuntimeTests/OverlayClickTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
@testable import DesktopMac
import CoreGraphics
import Testing

/// Chrome's vertical tab strip lays empty groups over its tabs; a point click must find the tab beneath (#202).
struct OverlayClickTests {
struct Node {
let name: String
var role = "AXGroup"
var actions: [String] = []
var area: CGFloat = 100
var leaf = true
}

static func beneath(_ hit: Node, under: [Node], ancestors: Int = 1) -> [String] {
Mac.beneath(
hit, ancestors: (0..<ancestors).map { Node(name: "ancestor\($0)", leaf: false) }, role: \.role, isLeaf: \.leaf,
actions: \.actions, atPoint: { _ in [hit] + under }, area: \.area, same: { $0.name == $1.name }
).map(\.name)
}

@Test
func `an empty overlay gives way to the pressable elements beneath, smallest first`() {
let tab = Node(name: "tab", role: "AXRadioButton", actions: ["AXPress"], area: 6000)
let close = Node(name: "close", role: "AXButton", actions: ["AXPress"], area: 800)
let row = Node(name: "row", area: 9000, leaf: false)
#expect(Self.beneath(Node(name: "overlay", area: 200_000), under: [row, tab, close]) == ["close", "tab"])
}

@Test
func `a hit with children or actions is not an overlay`() {
let tab = Node(name: "tab", role: "AXRadioButton", actions: ["AXPress"])
#expect(Self.beneath(Node(name: "group", leaf: false), under: [tab]).isEmpty)
#expect(Self.beneath(Node(name: "group", actions: ["AXScrollToVisible"]), under: [tab]).isEmpty)
#expect(Self.beneath(Node(name: "text", role: "AXStaticText"), under: [tab]).isEmpty)
#expect(Self.beneath(Node(name: "overlay"), under: [tab], ancestors: 0).isEmpty)
}
}
Loading