Files
2026-07-13 13:05:33 +08:00

299 lines
11 KiB
Swift

import Foundation
public struct SnapshotRenderNode: Equatable {
public var role: String
public var roleDescription: String?
public var title: String?
public var label: String?
public var linkText: String?
public var value: String?
public var placeholder: String?
public var url: String?
public var traits: [String]
public var rawActions: [String]
public var childCount: Int
public var summary: String?
public var rowSummary: String?
public var webAreaDepth: Int?
public init(
role: String,
roleDescription: String? = nil,
title: String? = nil,
label: String? = nil,
linkText: String? = nil,
value: String? = nil,
placeholder: String? = nil,
url: String? = nil,
traits: [String] = [],
rawActions: [String] = [],
childCount: Int = 0,
summary: String? = nil,
rowSummary: String? = nil,
webAreaDepth: Int? = nil
) {
self.role = role
self.roleDescription = roleDescription
self.title = title
self.label = label
self.linkText = linkText
self.value = value
self.placeholder = placeholder
self.url = url
self.traits = traits
self.rawActions = rawActions
self.childCount = childCount
self.summary = summary
self.rowSummary = rowSummary
self.webAreaDepth = webAreaDepth
}
}
public struct SnapshotTabStripCompaction: Equatable {
public var retainedIndexes: Set<Int>
public var omittedCount: Int
public init(retainedIndexes: Set<Int>, omittedCount: Int) {
self.retainedIndexes = retainedIndexes
self.omittedCount = omittedCount
}
}
public enum SnapshotRenderHeuristics {
public static func shouldProbeSecureTextMetadata(role: String) -> Bool {
let normalized = role.lowercased()
return normalized.contains("text") ||
normalized.contains("field") ||
normalized.contains("password") ||
normalized.contains("search") ||
normalized.contains("combo")
}
public static func supportsAttribute(_ attribute: String, advertisedAttributes: Set<String>?) -> Bool {
guard let advertisedAttributes else { return true }
return advertisedAttributes.contains(attribute)
}
public static func displayName(_ node: SnapshotRenderNode) -> String? {
if let title = clean(node.title) {
return title
}
if node.role == "AXLink", let url = clean(node.url), let text = clean(node.linkText ?? node.label ?? node.value) {
return "[\(markdownEscaped(text))](\(url))"
}
if node.role == "AXWebArea" {
return clean(node.label) ?? clean(node.value)
}
if ["AXButton", "AXPopUpButton", "AXImage"].contains(node.role) {
return clean(node.label)
}
if ["AXRow", "AXCell", "AXOutlineRow"].contains(node.role) {
return clean(node.rowSummary)
}
return clean(node.label)
}
public static func meaningfulActions(_ rawActions: [String], role: String) -> [String] {
let noisy: Set<String> = [
"AXPress",
"AXShowDefaultUI",
"AXShowAlternateUI",
"AXShowMenu",
"AXScrollToVisible",
"AXConfirm",
"AXRaise",
]
return rawActions.filter { action in
if noisy.contains(action) { return false }
if role == "AXMenu" || role == "AXMenuItem" {
return action != "AXCancel" && action != "AXPick"
}
if role == "AXScrollArea",
(rawActions.contains("AXScrollUpByPage") || rawActions.contains("AXScrollDownByPage")),
action == "AXScrollLeftByPage" || action == "AXScrollRightByPage" {
return false
}
return true
}
}
public static func shouldElide(_ node: SnapshotRenderNode) -> Bool {
guard node.role == "AXGroup" || node.role == "AXUnknown" else { return false }
guard displayName(node) == nil,
node.traits.isEmpty,
meaningfulActions(node.rawActions, role: node.role).isEmpty,
clean(node.summary) == nil
else {
return false
}
if node.webAreaDepth != nil, node.childCount > 1 {
return false
}
return true
}
public static func shouldSuppressChildren(_ node: SnapshotRenderNode) -> Bool {
if node.role == "AXMenuBarItem" {
return true
}
let name = displayName(node)
if node.role == "AXLink" && name?.hasPrefix("[") == true {
return true
}
let hasCompactLabel = name != nil || clean(node.value) != nil || clean(node.summary) != nil
return hasCompactLabel && compactControlRoles.contains(node.role)
}
public static func shouldSuppressChildren(role: String, name: String? = nil) -> Bool {
shouldSuppressChildren(SnapshotRenderNode(role: role, title: name))
}
public static func tabStripCompaction(
parent: SnapshotRenderNode,
children: [SnapshotRenderNode]
) -> SnapshotTabStripCompaction? {
guard isBrowserTabStripContainer(parent) else { return nil }
let tabIndexes = Set(children.indices.filter { isBrowserTabLike(children[$0]) })
guard tabIndexes.count >= 10 else { return nil }
let selectedTabIndexes = Set(tabIndexes.filter { isSelectedBrowserTab(children[$0]) })
guard !selectedTabIndexes.isEmpty else { return nil }
let nonTabIndexes = Set(children.indices.filter { !tabIndexes.contains($0) })
let retainedIndexes = nonTabIndexes.union(selectedTabIndexes)
let omittedCount = tabIndexes.count - selectedTabIndexes.count
guard omittedCount > 0 else { return nil }
return SnapshotTabStripCompaction(retainedIndexes: retainedIndexes, omittedCount: omittedCount)
}
public static func line(index: Int, node: SnapshotRenderNode) -> String {
let name = displayName(node)
let roleText = roleText(node)
let meaningful = meaningfulActions(node.rawActions, role: node.role)
var line = roleText.isEmpty ? "\(index)" : "\(index) \(roleText)"
if !node.traits.isEmpty { line += " (\(node.traits.joined(separator: ", ")))" }
if let name { line += " \(sanitize(name))" }
if node.role != "AXLink", let description = clean(node.label), description != name {
line += ", Description: \(sanitize(description))"
}
if let valueSegment = formattedValueSegment(roleText: roleText, name: name, value: node.value) {
line += valueSegment
}
if let placeholder = clean(node.placeholder), placeholder != name, placeholder != node.value {
line += name == nil && clean(node.value) == nil ? " Placeholder: \(sanitize(placeholder))" : ", Placeholder: \(sanitize(placeholder))"
}
if let summary = clean(node.summary), summary != name {
line += ", Text: \(sanitize(summary))"
} else if let rowSummary = clean(node.rowSummary), rowSummary != name {
line += ", Text: \(sanitize(rowSummary))"
}
if !meaningful.isEmpty {
line += ", Secondary Actions: \(meaningful.map(prettyAction).joined(separator: ", "))"
}
return line
}
public static func roleText(_ node: SnapshotRenderNode) -> String {
if node.role == "AXGroup" || node.role == "AXUnknown" {
return "container"
}
if node.role == "AXLink" {
return "link"
}
if node.role == "AXWebArea" {
return clean(node.roleDescription) ?? "html content"
}
if node.role == "AXMenuBarItem" {
return ""
}
if let value = clean(node.roleDescription) {
return value.lowercased()
}
if node.role.hasPrefix("AX") {
return splitCamelCase(String(node.role.dropFirst(2))).lowercased()
}
return node.role
}
public static func prettyAction(_ action: String) -> String {
if action == "AXZoomWindow" {
return "zoom the window"
}
let stripped = action.hasPrefix("AX") ? String(action.dropFirst(2)) : action
return splitCamelCase(stripped.replacingOccurrences(of: "ByPage", with: "")).lowercased()
}
public static func sanitize(_ value: String) -> String {
value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ")
}
private static func clean(_ value: String?) -> String? {
guard let value else { return nil }
let sanitized = sanitize(value)
return sanitized.isEmpty ? nil : sanitized
}
private static func formattedValueSegment(roleText: String, name: String?, value: String?) -> String? {
guard let value = clean(value), value != name else { return nil }
if roleText == "heading", Int(value) != nil {
return nil
}
let clean = sanitize(value)
if roleText == "text" || roleText == "text entry area" || roleText == "scroll bar" || roleText == "value indicator" {
return " \(clean)"
}
return ", Value: \(clean)"
}
private static func markdownEscaped(_ value: String) -> String {
sanitize(value)
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "[", with: "\\[")
.replacingOccurrences(of: "]", with: "\\]")
}
private static func isBrowserTabStripContainer(_ node: SnapshotRenderNode) -> Bool {
let roleText = roleText(node)
let title = clean(node.title)?.lowercased() ?? ""
let label = clean(node.label)?.lowercased() ?? ""
let description = clean(node.roleDescription)?.lowercased() ?? ""
return roleText == "scroll area" ||
description == "tab bar" ||
title == "tab bar" ||
label == "tab bar"
}
private static func isBrowserTabLike(_ node: SnapshotRenderNode) -> Bool {
let roleDescription = clean(node.roleDescription)?.lowercased() ?? ""
return node.role == "AXTab" || roleDescription == "tab"
}
private static func isSelectedBrowserTab(_ node: SnapshotRenderNode) -> Bool {
node.traits.contains("selected") || clean(node.value) == "1"
}
private static func splitCamelCase(_ value: String) -> String {
var result = ""
for character in value {
if character.isUppercase, !result.isEmpty {
result.append(" ")
}
result.append(character)
}
return result
}
private static let compactControlRoles: Set<String> = [
"AXButton",
"AXCheckBox",
"AXComboBox",
"AXDisclosureTriangle",
"AXHeading",
"AXMenuItem",
"AXPopUpButton",
"AXRadioButton",
"AXStaticText",
"AXTab",
]
}