From b62612475fbbccd8b978908b7bd97eacac306e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 13 Sep 2026 01:38:48 -0300 Subject: [PATCH] Add native macOS accessibility backend - expose eepp windows and widgets through Cocoa NSAccessibility - add lazy, stable native wrappers with safe lifecycle invalidation - support hierarchy, focus, geometry, actions, values, and UTF-16 text APIs - deliver coalesced native notifications for UI and model changes - preserve surviving secondary windows when the primary closes - add macOS semantic, notification, lifecycle, and performance harnesses - add native unit coverage and accessibility example scenarios - keep dormant accessibility overhead negligible --- .../eepp/ui/accessibility/accessibility.hpp | 2 + .../ui/accessibility/accessibilitymanager.hpp | 3 + include/eepp/ui/uiscenenode.hpp | 1 + include/eepp/ui/uitextedit.hpp | 2 + premake4.lua | 7 +- premake5.lua | 7 +- .../benchmark_accessibility_inactive.py | 14 +- .../benchmark_macos_accessibility.swift | 311 +++ .../scripts/test_macos_accessibility.swift | 761 +++++++ .../ui/accessibility/accessibilitybackend.cpp | 2 +- .../ui/accessibility/accessibilitybackend.hpp | 3 + .../accessibilitybackendmacos.mm | 1883 +++++++++++++++++ .../ui/accessibility/accessibilitymanager.cpp | 19 +- .../accessibilitywidgetresolver.cpp | 82 +- src/eepp/ui/uitextedit.cpp | 5 + src/eepp/ui/uitextinput.cpp | 1 + .../ui_accessibility/ui_accessibility.cpp | 28 +- .../unit_tests/accessibility_macos_tests.mm | 177 ++ src/tests/unit_tests/accessibility_tests.cpp | 39 +- 19 files changed, 3324 insertions(+), 23 deletions(-) create mode 100644 projects/scripts/benchmark_macos_accessibility.swift create mode 100644 projects/scripts/test_macos_accessibility.swift create mode 100644 src/eepp/ui/accessibility/accessibilitybackendmacos.mm create mode 100644 src/tests/unit_tests/accessibility_macos_tests.mm diff --git a/include/eepp/ui/accessibility/accessibility.hpp b/include/eepp/ui/accessibility/accessibility.hpp index ac490b2bd..8aad6e2a8 100644 --- a/include/eepp/ui/accessibility/accessibility.hpp +++ b/include/eepp/ui/accessibility/accessibility.hpp @@ -57,6 +57,7 @@ enum class AccessibilityState : Uint64 { Showing = 1ull << 8, Expanded = 1ull << 9, Active = 1ull << 10, + Protected = 1ull << 11, }; inline AccessibilityState operator|( AccessibilityState left, AccessibilityState right ) { @@ -81,6 +82,7 @@ enum class AccessibilityAction : Uint8 { Expand, Collapse, ScrollTo, + SetTextSelection, }; using AccessibilityActions = Uint32; diff --git a/include/eepp/ui/accessibility/accessibilitymanager.hpp b/include/eepp/ui/accessibility/accessibilitymanager.hpp index 546276c58..95f323b88 100644 --- a/include/eepp/ui/accessibility/accessibilitymanager.hpp +++ b/include/eepp/ui/accessibility/accessibilitymanager.hpp @@ -45,6 +45,9 @@ class EE_API AccessibilityManager { bool hasActiveNativeClients() const; + /** Updates the scene's cached fast-path flag after an actual native accessibility query. */ + void onNativeClientObserved(); + void update(); UISceneNode* getSceneNode() const; diff --git a/include/eepp/ui/uiscenenode.hpp b/include/eepp/ui/uiscenenode.hpp index c6cf85a69..094e613d8 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -989,6 +989,7 @@ class EE_API UISceneNode : public SceneNode { protected: friend class EE::UI::UIWindow; friend class EE::UI::UIWidget; + friend class EE::UI::AccessibilityManager; friend class EE::Window::Engine; // Engine lifecycle boundary for process-static main-thread resource deliveries. Shutdown first diff --git a/include/eepp/ui/uitextedit.hpp b/include/eepp/ui/uitextedit.hpp index 085352eeb..e349f2acb 100644 --- a/include/eepp/ui/uitextedit.hpp +++ b/include/eepp/ui/uitextedit.hpp @@ -88,6 +88,8 @@ class EE_API UITextEdit : public UICodeEditor { const TextPosition& cursor ); virtual void onTextHintsChanged(); + + virtual void onDocumentSelectionChange( const Doc::TextRange& ); }; }} // namespace EE::UI diff --git a/premake4.lua b/premake4.lua index 64783fd0b..6dd8e7eb8 100644 --- a/premake4.lua +++ b/premake4.lua @@ -1644,7 +1644,8 @@ solution "eepp" language "C++" set_targetdir("libs/" .. os.get_real() .. "/") includedirs { "include", "src" } - files { "src/eepp/ui/platform/macos/macosmenubar.mm" } + files { "src/eepp/ui/platform/macos/macosmenubar.mm", + "src/eepp/ui/accessibility/accessibilitybackendmacos.mm" } buildoptions { "-x objective-c++" } if not is_vs() then buildoptions{ "-std=c++20" } @@ -2023,6 +2024,10 @@ solution "eepp" "src/tools/ecode/plugins/git/git.cpp", "src/tools/ecode/plugins/autocomplete/snippetparser.cpp", "src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" } + if os.is("macosx") then + files { "src/tests/unit_tests/accessibility_macos_tests.mm" } + links { "Cocoa.framework" } + end eepp_module_backward_add( false ) build_link_configuration( "eepp-unit_tests", true ) diff --git a/premake5.lua b/premake5.lua index 92c22efc8..c3bc1001d 100644 --- a/premake5.lua +++ b/premake5.lua @@ -1675,7 +1675,8 @@ workspace "eepp" language "C++" cppdialect "C++20" incdirs { "include", "src" } - files { "src/eepp/ui/platform/macos/macosmenubar.mm" } + files { "src/eepp/ui/platform/macos/macosmenubar.mm", + "src/eepp/ui/accessibility/accessibilitybackendmacos.mm" } buildoptions { "-x objective-c++" } build_base_cpp_configuration( "eepp-macos-helper" ) target_dir_lib( "" ) @@ -2038,6 +2039,10 @@ workspace "eepp" "src/tools/ecode/plugins/git/git.cpp", "src/tools/ecode/plugins/autocomplete/snippetparser.cpp", "src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" } + if os.istarget("macosx") then + files { "src/tests/unit_tests/accessibility_macos_tests.mm" } + links { "Cocoa.framework" } + end filter { "system:not windows", "system:not haiku" } links { "pthread" } filter {} diff --git a/projects/scripts/benchmark_accessibility_inactive.py b/projects/scripts/benchmark_accessibility_inactive.py index 99dae168d..bb5764a45 100644 --- a/projects/scripts/benchmark_accessibility_inactive.py +++ b/projects/scripts/benchmark_accessibility_inactive.py @@ -3,8 +3,10 @@ import argparse import json import os +from pathlib import Path import statistics import subprocess +import sys import time @@ -15,6 +17,14 @@ def check(condition, message): def run_sample(args, disabled): environment = os.environ.copy() + if sys.platform == "darwin": + executable_directory = str(Path(args.executable).resolve().parent) + current_library_path = environment.get("DYLD_LIBRARY_PATH") + environment["DYLD_LIBRARY_PATH"] = ( + executable_directory + if not current_library_path + else executable_directory + os.pathsep + current_library_path + ) if disabled: environment["EEPP_DISABLE_ACCESSIBILITY"] = "1" else: @@ -55,7 +65,7 @@ def main(): parser.add_argument("--samples", type=int, default=5) parser.add_argument("--timeout", type=float, default=10) parser.add_argument("--max-initialization-ms", type=float, default=250) - parser.add_argument("--max-notification-ratio", type=float, default=1.1) + parser.add_argument("--max-notification-ratio", type=float, default=1.01) parser.add_argument("--max-wall-ratio", type=float, default=1.25) parser.add_argument("--json", action="store_true") args = parser.parse_args() @@ -85,6 +95,8 @@ def main(): output = { "disabled": disabled, "enabled": enabled, + "raw_disabled": disabled_samples, + "raw_enabled": enabled_samples, "notification_ratio": notification_ratio, "wall_ratio": wall_ratio, } diff --git a/projects/scripts/benchmark_macos_accessibility.swift b/projects/scripts/benchmark_macos_accessibility.swift new file mode 100644 index 000000000..5beec6b2e --- /dev/null +++ b/projects/scripts/benchmark_macos_accessibility.swift @@ -0,0 +1,311 @@ +#!/usr/bin/env swift + +import ApplicationServices +import Darwin +import Foundation + +struct BenchmarkFailure: Error, CustomStringConvertible { + let description: String +} + +struct Arguments { + var executable = "bin/eepp-ui-accessibility" + var items = 1_000 + var traversals = 5 + var timeout: TimeInterval = 15 +} + +func fail(_ message: String) throws -> Never { + throw BenchmarkFailure(description: message) +} + +func parseArguments() throws -> Arguments { + var result = Arguments() + var index = 1 + while index < CommandLine.arguments.count { + switch CommandLine.arguments[index] { + case "--executable": + index += 1 + guard index < CommandLine.arguments.count else { try fail("missing --executable value") } + result.executable = CommandLine.arguments[index] + case "--items": + index += 1 + guard index < CommandLine.arguments.count, + let value = Int(CommandLine.arguments[index]), value > 0 + else { try fail("invalid --items value") } + result.items = value + case "--traversals": + index += 1 + guard index < CommandLine.arguments.count, + let value = Int(CommandLine.arguments[index]), value > 0 + else { try fail("invalid --traversals value") } + result.traversals = value + case "--timeout": + index += 1 + guard index < CommandLine.arguments.count, + let value = TimeInterval(CommandLine.arguments[index]), value > 0 + else { try fail("invalid --timeout value") } + result.timeout = value + default: + try fail("unknown argument: \(CommandLine.arguments[index])") + } + index += 1 + } + return result +} + +func elapsedMilliseconds(since start: UInt64) -> Double { + Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 +} + +func optionalAttribute(_ element: AXUIElement, _ attribute: CFString) -> CFTypeRef? { + var value: CFTypeRef? + return AXUIElementCopyAttributeValue(element, attribute, &value) == .success ? value : nil +} + +func elementsAttribute(_ element: AXUIElement, _ attribute: CFString) -> [AXUIElement] { + optionalAttribute(element, attribute) as? [AXUIElement] ?? [] +} + +func stringAttribute(_ element: AXUIElement, _ attribute: CFString) -> String? { + optionalAttribute(element, attribute) as? String +} + +func descendants(_ root: AXUIElement, limit: Int) -> [AXUIElement] { + var result: [AXUIElement] = [] + var pending = elementsAttribute(root, kAXChildrenAttribute as CFString) + while !pending.isEmpty && result.count < limit { + let element = pending.removeFirst() + result.append(element) + pending.append(contentsOf: elementsAttribute(element, kAXChildrenAttribute as CFString)) + } + return result +} + +func findElement(in root: AXUIElement, role: String, name: String, limit: Int) -> AXUIElement? { + for element in descendants(root, limit: limit) { + guard stringAttribute(element, kAXRoleAttribute as CFString) == role else { continue } + let names = [ + stringAttribute(element, kAXTitleAttribute as CFString), + stringAttribute(element, kAXDescriptionAttribute as CFString), + stringAttribute(element, kAXHelpAttribute as CFString), + stringAttribute(element, kAXValueAttribute as CFString), + ] + if names.contains(where: { $0 == name }) { return element } + } + return nil +} + +func waitFor( + _ description: String, timeout: TimeInterval, process: Process, operation: () -> T? +) throws -> T { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if let result = operation() { return result } + if !process.isRunning { + try fail("process exited with status \(process.terminationStatus) while waiting for \(description)") + } + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.01, true) + } while Date() < deadline + try fail("timed out waiting for \(description)") +} + +func statistics(_ values: [Double]) -> [String: Any] { + let sorted = values.sorted() + let median: Double + if sorted.count.isMultiple(of: 2) { + median = (sorted[sorted.count / 2 - 1] + sorted[sorted.count / 2]) / 2 + } else { + median = sorted[sorted.count / 2] + } + let p95Index = min(sorted.count - 1, max(0, Int(ceil(Double(sorted.count) * 0.95)) - 1)) + return [ + "count": sorted.count, + "median": median, + "p95": sorted[p95Index], + "maximum": sorted.last!, + ] +} + +func setAttribute(_ element: AXUIElement, _ attribute: CFString, _ value: CFTypeRef) throws { + let error = AXUIElementSetAttributeValue(element, attribute, value) + if error != .success { + try fail("setting \(attribute) failed with AX error \(error.rawValue)") + } +} + +func perform(_ element: AXUIElement, _ action: CFString) throws { + let error = AXUIElementPerformAction(element, action) + if error != .success { + try fail("performing \(action) failed with AX error \(error.rawValue)") + } +} + +func run() throws { + guard AXIsProcessTrusted() else { + try fail("the benchmark requires macOS Accessibility permission") + } + let arguments = try parseArguments() + let executableURL = URL(fileURLWithPath: arguments.executable).standardizedFileURL + guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { + try fail("benchmark executable is not available at \(executableURL.path)") + } + + let process = Process() + process.executableURL = executableURL + process.arguments = [ + "--benchmark", "--benchmark-visible", "--benchmark-items", String(arguments.items), + ] + var environment = ProcessInfo.processInfo.environment + let executableDirectory = executableURL.deletingLastPathComponent().path + if let existingLibraryPath = environment["DYLD_LIBRARY_PATH"], !existingLibraryPath.isEmpty { + environment["DYLD_LIBRARY_PATH"] = "\(executableDirectory):\(existingLibraryPath)" + } else { + environment["DYLD_LIBRARY_PATH"] = executableDirectory + } + process.environment = environment + let startup = DispatchTime.now().uptimeNanoseconds + try process.run() + defer { + if process.isRunning { + process.terminate() + let deadline = Date().addingTimeInterval(2) + while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.02) } + if process.isRunning { Darwin.kill(process.processIdentifier, SIGKILL) } + } + } + + let application = AXUIElementCreateApplication(process.processIdentifier) + AXUIElementSetMessagingTimeout(application, 2) + let window: AXUIElement = try waitFor( + "visible benchmark window", timeout: arguments.timeout, process: process + ) { + elementsAttribute(application, kAXWindowsAttribute as CFString).first + } + let discoveryMilliseconds = elapsedMilliseconds(since: startup) + let searchLimit = max(4_096, arguments.items * 2) + guard let list = findElement( + in: window, role: kAXListRole, name: "Benchmark items", limit: searchLimit) + else { try fail("could not find benchmark list") } + + var rowEnumerationMilliseconds: [Double] = [] + var valueQueryMicroseconds: [Double] = [] + for _ in 0..= arguments.items else { + try fail("semantic traversal ended early at \(allElements.count) elements") + } + + guard let projectName = findElement( + in: window, role: kAXTextFieldRole, name: "Project name", limit: searchLimit) + else { try fail("could not find focus benchmark target") } + let focusStart = DispatchTime.now().uptimeNanoseconds + try setAttribute(projectName, kAXFocusedAttribute as CFString, kCFBooleanTrue) + let _: Bool = try waitFor( + "focused element", timeout: arguments.timeout, process: process + ) { + guard let focused = optionalAttribute(application, kAXFocusedUIElementAttribute as CFString) + else { return nil } + return CFEqual(focused, projectName) ? true : nil + } + let focusMilliseconds = elapsedMilliseconds(since: focusStart) + + guard let slider = findElement( + in: window, role: kAXSliderRole, name: "Volume", limit: searchLimit) + else { try fail("could not find action benchmark target") } + let actionStart = DispatchTime.now().uptimeNanoseconds + try setAttribute(slider, kAXValueAttribute as CFString, 60 as CFNumber) + let _: Bool = try waitFor( + "slider value", timeout: arguments.timeout, process: process + ) { + (optionalAttribute(slider, kAXValueAttribute as CFString) as? NSNumber)?.intValue == 60 + ? true : nil + } + let actionMilliseconds = elapsedMilliseconds(since: actionStart) + + guard let replaceModel = findElement( + in: window, role: kAXButtonRole, name: "Replace benchmark model", limit: searchLimit) + else { try fail("could not find model replacement benchmark action") } + let modelStart = DispatchTime.now().uptimeNanoseconds + try perform(replaceModel, kAXPressAction as CFString) + let _: Bool = try waitFor( + "replacement model", timeout: arguments.timeout, process: process + ) { + let rows = elementsAttribute(list, kAXRowsAttribute as CFString) + return rows.count == arguments.items + && stringAttribute(rows[0], kAXValueAttribute as CFString) + == "Updated benchmark item 0" ? true : nil + } + let modelReplacementMilliseconds = elapsedMilliseconds(since: modelStart) + + guard let openWindow = findElement( + in: window, role: kAXButtonRole, name: "Open accessibility window", limit: searchLimit) + else { try fail("could not find dynamic window benchmark action") } + let createWindowStart = DispatchTime.now().uptimeNanoseconds + try perform(openWindow, kAXPressAction as CFString) + let dynamicWindow: AXUIElement = try waitFor( + "dynamic window", timeout: arguments.timeout, process: process + ) { + elementsAttribute(application, kAXWindowsAttribute as CFString).first { + stringAttribute($0, kAXTitleAttribute as CFString) == "eepp - Accessibility Dynamic" + } + } + let createWindowMilliseconds = elapsedMilliseconds(since: createWindowStart) + let closeWindow: AXUIElement = try waitFor( + "dynamic window semantic hierarchy", timeout: arguments.timeout, process: process + ) { + findElement( + in: dynamicWindow, role: kAXButtonRole, name: "Close dynamic window", limit: 128) + } + let closeWindowStart = DispatchTime.now().uptimeNanoseconds + try perform(closeWindow, kAXPressAction as CFString) + let _: Bool = try waitFor( + "dynamic window removal", timeout: arguments.timeout, process: process + ) { + elementsAttribute(application, kAXWindowsAttribute as CFString).count == 1 ? true : nil + } + let closeWindowMilliseconds = elapsedMilliseconds(since: closeWindowStart) + + let output: [String: Any] = [ + "items": arguments.items, + "traversals": arguments.traversals, + "discovery_ms": discoveryMilliseconds, + "complete_traversal_ms": completeTraversalMilliseconds, + "element_count": allElements.count, + "row_enumeration_ms": statistics(rowEnumerationMilliseconds), + "row_value_query_us": statistics(valueQueryMicroseconds), + "focus_ms": focusMilliseconds, + "value_action_ms": actionMilliseconds, + "model_replacement_ms": modelReplacementMilliseconds, + "window_create_ms": createWindowMilliseconds, + "window_close_ms": closeWindowMilliseconds, + "raw_row_enumeration_ms": rowEnumerationMilliseconds, + "raw_row_value_query_us": valueQueryMicroseconds, + ] + let data = try JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]) + print(String(decoding: data, as: UTF8.self)) +} + +do { + try run() +} catch { + FileHandle.standardError.write(Data("macOS accessibility benchmark failed: \(error)\n".utf8)) + exit(EXIT_FAILURE) +} diff --git a/projects/scripts/test_macos_accessibility.swift b/projects/scripts/test_macos_accessibility.swift new file mode 100644 index 000000000..8ce94c00b --- /dev/null +++ b/projects/scripts/test_macos_accessibility.swift @@ -0,0 +1,761 @@ +#!/usr/bin/env swift + +import ApplicationServices +import Darwin +import Foundation + +struct HarnessFailure: Error, CustomStringConvertible { + let description: String +} + +struct RecordedNotification { + let element: AXUIElement + let name: String +} + +final class NotificationRecorder { + var events: [RecordedNotification] = [] + + func contains(_ name: CFString, target: AXUIElement, since index: Int) -> Bool { + count(name, target: target, since: index) > 0 + } + + func count(_ name: CFString, target: AXUIElement, since index: Int) -> Int { + events.dropFirst(index).filter { + $0.name == name as String && CFEqual($0.element, target) + }.count + } +} + +let notificationCallback: AXObserverCallback = { _, element, notification, context in + guard let context else { return } + let recorder = Unmanaged.fromOpaque(context).takeUnretainedValue() + recorder.events.append(RecordedNotification(element: element, name: notification as String)) +} + +func progress(_ message: String) { + FileHandle.standardError.write(Data("[macOS accessibility] \(message)\n".utf8)) +} + +func fail(_ message: String) throws -> Never { + throw HarnessFailure(description: message) +} + +func check(_ condition: @autoclosure () -> Bool, _ message: String) throws { + if !condition() { + try fail(message) + } +} + +func optionalAttribute(_ element: AXUIElement, _ attribute: CFString) -> CFTypeRef? { + var value: CFTypeRef? + return AXUIElementCopyAttributeValue(element, attribute, &value) == .success ? value : nil +} + +func stringAttribute(_ element: AXUIElement, _ attribute: CFString) -> String? { + optionalAttribute(element, attribute) as? String +} + +func numberAttribute(_ element: AXUIElement, _ attribute: CFString) -> NSNumber? { + optionalAttribute(element, attribute) as? NSNumber +} + +func elementsAttribute(_ element: AXUIElement, _ attribute: CFString) -> [AXUIElement] { + optionalAttribute(element, attribute) as? [AXUIElement] ?? [] +} + +func elementsAttributeResult( + _ element: AXUIElement, _ attribute: CFString +) -> ([AXUIElement], AXError) { + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(element, attribute, &value) + return (value as? [AXUIElement] ?? [], error) +} + +func pointAttribute(_ element: AXUIElement, _ attribute: CFString) -> CGPoint? { + guard let value = optionalAttribute(element, attribute), + CFGetTypeID(value) == AXValueGetTypeID() + else { + return nil + } + var point = CGPoint.zero + return AXValueGetValue(value as! AXValue, .cgPoint, &point) ? point : nil +} + +func sizeAttribute(_ element: AXUIElement, _ attribute: CFString) -> CGSize? { + guard let value = optionalAttribute(element, attribute), + CFGetTypeID(value) == AXValueGetTypeID() + else { + return nil + } + var size = CGSize.zero + return AXValueGetValue(value as! AXValue, .cgSize, &size) ? size : nil +} + +func rangeAttribute(_ element: AXUIElement, _ attribute: CFString) -> CFRange? { + guard let value = optionalAttribute(element, attribute), + CFGetTypeID(value) == AXValueGetTypeID() + else { + return nil + } + var range = CFRange() + return AXValueGetValue(value as! AXValue, .cfRange, &range) ? range : nil +} + +func rangeValue(location: Int, length: Int) throws -> AXValue { + var range = CFRange(location: location, length: length) + guard let value = AXValueCreate(.cfRange, &range) else { + try fail("could not create AX range value") + } + return value +} + +func rangeEquals(_ range: CFRange?, location: Int, length: Int) -> Bool { + range?.location == location && range?.length == length +} + +func parameterizedAttribute( + _ element: AXUIElement, _ attribute: CFString, _ parameter: CFTypeRef +) throws -> CFTypeRef { + var value: CFTypeRef? + let error = AXUIElementCopyParameterizedAttributeValue( + element, attribute, parameter, &value) + guard error == .success, let value else { + try fail("querying \(attribute) failed with AX error \(error.rawValue)") + } + return value +} + +func observe( + _ observer: AXObserver, _ element: AXUIElement, _ notification: CFString, + _ recorder: NotificationRecorder +) throws { + let error = AXObserverAddNotification( + observer, element, notification, Unmanaged.passUnretained(recorder).toOpaque()) + if error != .success { + try fail("observing \(notification) failed with AX error \(error.rawValue)") + } +} + +func checkSingleNotification( + _ recorder: NotificationRecorder, _ notification: CFString, target: AXUIElement, + since index: Int +) throws { + let deadline = Date().addingTimeInterval(0.1) + repeat { + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.01, true) + } while Date() < deadline + let count = recorder.count(notification, target: target, since: index) + try check( + count == 1, + "expected one \(notification) notification for \(describe(target)), received \(count)") +} + +func descendants(_ root: AXUIElement, limit: Int = 4_096) -> [AXUIElement] { + var result: [AXUIElement] = [] + var pending = elementsAttribute(root, kAXChildrenAttribute as CFString) + while !pending.isEmpty && result.count < limit { + let element = pending.removeFirst() + result.append(element) + pending.append(contentsOf: elementsAttribute(element, kAXChildrenAttribute as CFString)) + } + return result +} + +func findElement(in root: AXUIElement, role: String, name: String) -> AXUIElement? { + for element in descendants(root) { + guard stringAttribute(element, kAXRoleAttribute as CFString) == role else { continue } + let names = [ + stringAttribute(element, kAXTitleAttribute as CFString), + stringAttribute(element, kAXDescriptionAttribute as CFString), + stringAttribute(element, kAXHelpAttribute as CFString), + stringAttribute(element, kAXValueAttribute as CFString), + ] + if names.contains(where: { $0 == name }) { + return element + } + } + return nil +} + +func describe(_ element: AXUIElement?) -> String { + guard let element else { return "nil" } + let attributes: [(String, CFString)] = [ + ("role", kAXRoleAttribute as CFString), + ("title", kAXTitleAttribute as CFString), + ("description", kAXDescriptionAttribute as CFString), + ("identifier", kAXIdentifierAttribute as CFString), + ] + return attributes.map { name, attribute in + "\(name)=\(stringAttribute(element, attribute) ?? "nil")" + }.joined(separator: " ") +} + +func waitFor( + _ description: String, timeout: TimeInterval, operation: () -> T? +) throws -> T { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if let result = operation() { + return result + } + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.05, true) + } while Date() < deadline + try fail("timed out waiting for \(description)") +} + +func actionNames(_ element: AXUIElement) throws -> [String] { + var names: CFArray? + let error = AXUIElementCopyActionNames(element, &names) + guard error == .success else { + try fail("copying action names failed with AX error \(error.rawValue)") + } + return names as? [String] ?? [] +} + +func perform(_ element: AXUIElement, _ action: CFString) throws { + let error = AXUIElementPerformAction(element, action) + if error != .success { + try fail("\(action) failed with AX error \(error.rawValue)") + } +} + +func stressQueriesDuringAction( + staleElement: AXUIElement, actionElement: AXUIElement, timeout: TimeInterval +) throws { + AXUIElementSetMessagingTimeout(staleElement, 0.25) + let started = DispatchSemaphore(value: 0) + let completed = DispatchGroup() + let resultLock = NSLock() + var unexpectedErrors: [Int32] = [] + completed.enter() + DispatchQueue.global(qos: .userInitiated).async { + defer { completed.leave() } + for index in 0..<64 { + var role: CFTypeRef? + let roleError = AXUIElementCopyAttributeValue( + staleElement, kAXRoleAttribute as CFString, &role) + var children: CFTypeRef? + let childrenError = AXUIElementCopyAttributeValue( + staleElement, kAXChildrenAttribute as CFString, &children) + if index == 0 { started.signal() } + for error in [roleError, childrenError] { + if error != .success && error != .invalidUIElement && error != .cannotComplete + && error != .noValue && error != .attributeUnsupported + { + resultLock.lock() + unexpectedErrors.append(error.rawValue) + resultLock.unlock() + } + } + usleep(1_000) + } + } + guard started.wait(timeout: .now() + 1) == .success else { + try fail("concurrent AX query worker did not start") + } + try perform(actionElement, kAXPressAction as CFString) + guard completed.wait(timeout: .now() + timeout) == .success else { + try fail("concurrent AX queries did not finish after the destructive action") + } + resultLock.lock() + let errors = unexpectedErrors + resultLock.unlock() + try check(errors.isEmpty, "concurrent AX queries returned unexpected errors: \(errors)") +} + +func setAttribute(_ element: AXUIElement, _ attribute: CFString, _ value: CFTypeRef) throws { + let error = AXUIElementSetAttributeValue(element, attribute, value) + if error != .success { + try fail("setting \(attribute) failed with AX error \(error.rawValue)") + } +} + +struct Arguments { + var executable = "bin/eepp-ui-accessibility" + var timeout: TimeInterval = 10 +} + +func parseArguments() throws -> Arguments { + var result = Arguments() + var index = 1 + while index < CommandLine.arguments.count { + switch CommandLine.arguments[index] { + case "--executable": + index += 1 + guard index < CommandLine.arguments.count else { try fail("missing --executable value") } + result.executable = CommandLine.arguments[index] + case "--timeout": + index += 1 + guard index < CommandLine.arguments.count, + let timeout = TimeInterval(CommandLine.arguments[index]), timeout > 0 + else { + try fail("invalid --timeout value") + } + result.timeout = timeout + default: + try fail("unknown argument: \(CommandLine.arguments[index])") + } + index += 1 + } + return result +} + +func run() throws { + try check(AXIsProcessTrusted(), "the harness requires macOS Accessibility permission") + let arguments = try parseArguments() + let executableURL = URL(fileURLWithPath: arguments.executable).standardizedFileURL + try check( + FileManager.default.isExecutableFile(atPath: executableURL.path), + "accessibility example is not executable at \(executableURL.path)") + + let process = Process() + process.executableURL = executableURL + // This mode also selects QuitPolicy::OnLastWindowClosed, which is required to exercise + // primary-window removal while the secondary window remains alive. + process.arguments = ["--close-primary"] + var environment = ProcessInfo.processInfo.environment + let executableDirectory = executableURL.deletingLastPathComponent().path + if let existing = environment["DYLD_LIBRARY_PATH"], !existing.isEmpty { + environment["DYLD_LIBRARY_PATH"] = "\(executableDirectory):\(existing)" + } else { + environment["DYLD_LIBRARY_PATH"] = executableDirectory + } + process.environment = environment + try process.run() + defer { + if process.isRunning { + process.terminate() + let deadline = Date().addingTimeInterval(2) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + Darwin.kill(process.processIdentifier, SIGKILL) + } + } + } + + let application = AXUIElementCreateApplication(process.processIdentifier) + AXUIElementSetMessagingTimeout(application, 2) + progress("waiting for application windows") + var lastWindowStatus = "" + let windows: [AXUIElement] = try waitFor( + "two accessibility windows", timeout: arguments.timeout + ) { + let (found, error) = elementsAttributeResult(application, kAXWindowsAttribute as CFString) + let status = "AXWindows error=\(error.rawValue) count=\(found.count)" + if status != lastWindowStatus { + progress(status) + lastWindowStatus = status + } + return found.count == 2 ? found : nil + } + try check( + stringAttribute(application, kAXRoleAttribute as CFString) == kAXApplicationRole, + "application role was not AXApplication") + let windowsByTitle = Dictionary( + uniqueKeysWithValues: windows.map { + (stringAttribute($0, kAXTitleAttribute as CFString) ?? "", $0) + }) + try check( + Set(windowsByTitle.keys) + == Set(["eepp - Accessibility", "eepp - Accessibility Secondary"]), + "window titles were incorrect") + + progress("checking hierarchy and actions") + guard let primary = windowsByTitle["eepp - Accessibility"] else { + try fail("could not find the primary window by title") + } + let recorder = NotificationRecorder() + var observer: AXObserver? + let observerError = AXObserverCreate( + process.processIdentifier, notificationCallback, &observer) + guard observerError == .success, let observer else { + try fail("creating AX notification observer failed with AX error \(observerError.rawValue)") + } + let observerSource = AXObserverGetRunLoopSource(observer) + CFRunLoopAddSource(CFRunLoopGetCurrent(), observerSource, CFRunLoopMode.defaultMode) + defer { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), observerSource, CFRunLoopMode.defaultMode) + } + guard let save = findElement(in: primary, role: kAXButtonRole, name: "Save settings") else { + try fail("could not find Save settings button") + } + let saveActions = try actionNames(save) + try check(saveActions.contains(kAXPressAction), "save button did not expose AXPress") + try perform(save, kAXPressAction as CFString) + guard let metadataButton = findElement( + in: primary, role: kAXButtonRole, name: "Update accessible metadata") + else { + try fail("could not find accessible-metadata update button") + } + try observe(observer, metadataButton, kAXTitleChangedNotification as CFString, recorder) + try observe(observer, metadataButton, kAXLayoutChangedNotification as CFString, recorder) + let metadataEventStart = recorder.events.count + try perform(metadataButton, kAXPressAction as CFString) + try check( + stringAttribute(metadataButton, kAXDescriptionAttribute as CFString) + == "Updated metadata control", + "updated accessible name was not exposed as AXDescription") + try check( + stringAttribute(metadataButton, kAXHelpAttribute as CFString) + == "Updated accessibility help", + "updated accessible help was incorrect") + let _: Bool = try waitFor("metadata title notification", timeout: arguments.timeout) { + recorder.contains( + kAXTitleChangedNotification as CFString, target: metadataButton, + since: metadataEventStart) ? true : nil + } + let _: Bool = try waitFor("metadata help notification", timeout: arguments.timeout) { + recorder.contains( + kAXLayoutChangedNotification as CFString, target: metadataButton, + since: metadataEventStart) ? true : nil + } + try checkSingleNotification( + recorder, kAXTitleChangedNotification as CFString, target: metadataButton, + since: metadataEventStart) + try checkSingleNotification( + recorder, kAXLayoutChangedNotification as CFString, target: metadataButton, + since: metadataEventStart) + + guard let checkbox = findElement( + in: primary, role: kAXCheckBoxRole, name: "Enable autosave") + else { + try fail("could not find autosave checkbox") + } + try check( + numberAttribute(checkbox, kAXValueAttribute as CFString)?.intValue == 0, + "checkbox initial value was not unchecked") + try observe(observer, checkbox, kAXValueChangedNotification as CFString, recorder) + let checkboxEventStart = recorder.events.count + try perform(checkbox, kAXPressAction as CFString) + try check( + numberAttribute(checkbox, kAXValueAttribute as CFString)?.intValue == 1, + "checkbox did not toggle through AXPress") + let _: Bool = try waitFor("checkbox value notification", timeout: arguments.timeout) { + recorder.contains( + kAXValueChangedNotification as CFString, target: checkbox, since: checkboxEventStart) + ? true : nil + } + try checkSingleNotification( + recorder, kAXValueChangedNotification as CFString, target: checkbox, + since: checkboxEventStart) + + guard let projectName = findElement( + in: primary, role: kAXTextFieldRole, name: "Project name") + else { + try fail("could not find Project name text field") + } + try check( + stringAttribute(projectName, kAXValueAttribute as CFString) == "eepp", + "text field value was incorrect") + try setAttribute(projectName, kAXValueAttribute as CFString, "A😀e\u{301}" as CFString) + try check( + stringAttribute(projectName, kAXValueAttribute as CFString) == "A😀e\u{301}", + "text field value did not update") + try observe( + observer, projectName, kAXSelectedTextChangedNotification as CFString, recorder) + let textSelectionEventStart = recorder.events.count + try setAttribute( + projectName, kAXSelectedTextRangeAttribute as CFString, + try rangeValue(location: 1, length: 4)) + try check( + rangeEquals( + rangeAttribute(projectName, kAXSelectedTextRangeAttribute as CFString), location: 1, + length: 4), + "Unicode text selection did not round-trip as UTF-16") + let _: Bool = try waitFor("text selection notification", timeout: arguments.timeout) { + recorder.contains( + kAXSelectedTextChangedNotification as CFString, target: projectName, + since: textSelectionEventStart) ? true : nil + } + try checkSingleNotification( + recorder, kAXSelectedTextChangedNotification as CFString, target: projectName, + since: textSelectionEventStart) + try check( + numberAttribute(projectName, kAXNumberOfCharactersAttribute as CFString)?.intValue == 5, + "Unicode text field did not report its UTF-16 character count") + let emojiRangeValue = try parameterizedAttribute( + projectName, kAXRangeForIndexParameterizedAttribute as CFString, 1 as CFNumber) + var emojiRange = CFRange() + try check( + CFGetTypeID(emojiRangeValue) == AXValueGetTypeID() + && AXValueGetValue(emojiRangeValue as! AXValue, .cfRange, &emojiRange) + && rangeEquals(emojiRange, location: 1, length: 2), + "AXRangeForIndex did not preserve the surrogate pair") + + guard let description = findElement( + in: primary, role: kAXTextFieldRole, name: "Description") + else { + try fail("could not find Description text editor") + } + try setAttribute(description, kAXValueAttribute as CFString, "one\n😀x" as CFString) + try setAttribute( + description, kAXSelectedTextRangeAttribute as CFString, + try rangeValue(location: 4, length: 2)) + try check( + stringAttribute(description, kAXSelectedTextAttribute as CFString) == "😀", + "multiline editor selected text was incorrect") + try check( + numberAttribute(description, kAXInsertionPointLineNumberAttribute as CFString)?.intValue + == 1, + "multiline editor insertion point line was incorrect") + let selectedSubstring = try parameterizedAttribute( + description, kAXStringForRangeParameterizedAttribute as CFString, + try rangeValue(location: 4, length: 2)) + try check( + (selectedSubstring as? String) == "😀", + "AXStringForRange did not use UTF-16 ranges") + + guard let password = findElement( + in: primary, role: kAXTextFieldRole, name: "Account password") + else { + try fail("could not find secure text field") + } + try check( + stringAttribute(password, kAXSubroleAttribute as CFString) == kAXSecureTextFieldSubrole, + "password field did not expose AXSecureTextField") + try check( + stringAttribute(password, kAXValueAttribute as CFString) == nil, + "password field exposed its value") + + guard let lightTheme = findElement( + in: primary, role: kAXRadioButtonRole, name: "Light Theme") + else { + try fail("could not find Light Theme radio button") + } + try observe(observer, lightTheme, kAXValueChangedNotification as CFString, recorder) + let radioEventStart = recorder.events.count + try perform(lightTheme, kAXPressAction as CFString) + try check( + numberAttribute(lightTheme, kAXValueAttribute as CFString)?.boolValue == true, + "radio button did not select through AXPress") + let _: Bool = try waitFor("radio value notification", timeout: arguments.timeout) { + recorder.contains( + kAXValueChangedNotification as CFString, target: lightTheme, since: radioEventStart) + ? true : nil + } + try checkSingleNotification( + recorder, kAXValueChangedNotification as CFString, target: lightTheme, + since: radioEventStart) + + guard let volume = findElement(in: primary, role: kAXSliderRole, name: "Volume") else { + try fail("could not find Volume slider") + } + try check( + numberAttribute(volume, kAXMinValueAttribute as CFString)?.doubleValue == 0 + && numberAttribute(volume, kAXMaxValueAttribute as CFString)?.doubleValue == 100, + "slider range was incorrect") + try observe(observer, volume, kAXValueChangedNotification as CFString, recorder) + let sliderEventStart = recorder.events.count + try setAttribute(volume, kAXValueAttribute as CFString, 55 as CFNumber) + try check( + numberAttribute(volume, kAXValueAttribute as CFString)?.doubleValue == 55, + "slider value did not update") + let _: Bool = try waitFor("slider value notification", timeout: arguments.timeout) { + recorder.contains( + kAXValueChangedNotification as CFString, target: volume, since: sliderEventStart) + ? true : nil + } + try checkSingleNotification( + recorder, kAXValueChangedNotification as CFString, target: volume, + since: sliderEventStart) + + guard let retries = findElement( + in: primary, role: kAXIncrementorRole, name: "Retry count") + else { + try fail("could not find Retry count incrementor") + } + try perform(retries, kAXIncrementAction as CFString) + try check( + numberAttribute(retries, kAXValueAttribute as CFString)?.doubleValue == 4, + "incrementor did not increment") + + guard let progressBar = findElement( + in: primary, role: kAXProgressIndicatorRole, name: "Operation progress") + else { + try fail("could not find progress indicator") + } + try check( + numberAttribute(progressBar, kAXValueAttribute as CFString)?.doubleValue == 35, + "progress indicator value was incorrect") + + try observe( + observer, application, kAXFocusedUIElementChangedNotification as CFString, recorder) + try setAttribute(save, kAXFocusedAttribute as CFString, kCFBooleanTrue) + let _: Bool = try waitFor("application focus on Save settings", timeout: arguments.timeout) { + var focused: CFTypeRef? + let error = AXUIElementCopyAttributeValue( + application, kAXFocusedUIElementAttribute as CFString, &focused) + return error == .success && focused != nil && CFEqual(focused, save) ? true : nil + } + let focusEventStart = recorder.events.count + try setAttribute(projectName, kAXFocusedAttribute as CFString, kCFBooleanTrue) + let _: Bool = try waitFor("application focus on Project name", timeout: arguments.timeout) { + var focused: CFTypeRef? + let error = AXUIElementCopyAttributeValue( + application, kAXFocusedUIElementAttribute as CFString, &focused) + return error == .success && focused != nil && CFEqual(focused, projectName) ? true : nil + } + let _: Bool = try waitFor("focused element notification", timeout: arguments.timeout) { + recorder.contains( + kAXFocusedUIElementChangedNotification as CFString, target: projectName, + since: focusEventStart) ? true : nil + } + try checkSingleNotification( + recorder, kAXFocusedUIElementChangedNotification as CFString, target: projectName, + since: focusEventStart) + + progress("checking geometry and hit testing") + guard let position = pointAttribute(save, kAXPositionAttribute as CFString), + let size = sizeAttribute(save, kAXSizeAttribute as CFString) + else { + try fail("save button did not expose geometry") + } + try check(size.width > 0 && size.height > 0, "save button geometry was empty") + var hitElement: AXUIElement? + let hitError = AXUIElementCopyElementAtPosition( + application, Float(position.x + size.width / 2), Float(position.y + size.height / 2), + &hitElement) + try check( + hitError == .success && hitElement != nil && CFEqual(hitElement, save), + "AX hit testing at \(position.x + size.width / 2),\(position.y + size.height / 2) " + + "returned \(describe(hitElement)) instead of \(describe(save))") + + progress("checking model hierarchy and replacement") + guard let colors = findElement(in: primary, role: kAXListRole, name: "Colors") else { + try fail("could not find Colors list") + } + try check( + elementsAttribute(colors, kAXRowsAttribute as CFString).count == 3, + "list did not expose three rows") + guard let projects = findElement(in: primary, role: kAXTableRole, name: "Projects") else { + try fail("could not find Projects table") + } + let originalRows = elementsAttribute(projects, kAXRowsAttribute as CFString) + try check(originalRows.count == 2, "table did not expose two rows") + let staleRow = originalRows[0] + try observe(observer, projects, kAXLayoutChangedNotification as CFString, recorder) + try observe(observer, staleRow, kAXUIElementDestroyedNotification as CFString, recorder) + guard let replaceProjects = findElement( + in: primary, role: kAXButtonRole, name: "Replace project model") + else { + try fail("could not find model replacement button") + } + let modelEventStart = recorder.events.count + try perform(replaceProjects, kAXPressAction as CFString) + let replacementRows: [AXUIElement] = try waitFor( + "replacement table model", timeout: arguments.timeout + ) { + let rows = elementsAttribute(projects, kAXRowsAttribute as CFString) + return rows.count == 1 + && stringAttribute(rows[0], kAXValueAttribute as CFString) == "Gamma" ? rows : nil + } + try check(replacementRows.count == 1, "replacement table rows were incorrect") + let _: Bool = try waitFor("table layout notification", timeout: arguments.timeout) { + recorder.contains( + kAXLayoutChangedNotification as CFString, target: projects, since: modelEventStart) + ? true : nil + } + let _: Bool = try waitFor("stale row destruction notification", timeout: arguments.timeout) { + recorder.contains( + kAXUIElementDestroyedNotification as CFString, target: staleRow, since: modelEventStart) + ? true : nil + } + try checkSingleNotification( + recorder, kAXLayoutChangedNotification as CFString, target: projects, + since: modelEventStart) + try checkSingleNotification( + recorder, kAXUIElementDestroyedNotification as CFString, target: staleRow, + since: modelEventStart) + var staleRowRole: CFTypeRef? + try check( + AXUIElementCopyAttributeValue( + staleRow, kAXRoleAttribute as CFString, &staleRowRole) != .success, + "stale model row remained queryable") + + progress("checking dynamic window lifecycle") + guard let openWindow = findElement( + in: primary, role: kAXButtonRole, name: "Open accessibility window") + else { + try fail("could not find dynamic-window button") + } + try observe(observer, application, kAXWindowCreatedNotification as CFString, recorder) + let windowEventStart = recorder.events.count + try perform(openWindow, kAXPressAction as CFString) + let dynamicWindow: AXUIElement = try waitFor( + "dynamic third window", timeout: arguments.timeout + ) { + elementsAttribute(application, kAXWindowsAttribute as CFString).first { + stringAttribute($0, kAXTitleAttribute as CFString) == "eepp - Accessibility Dynamic" + } + } + let _: Bool = try waitFor("window creation notification", timeout: arguments.timeout) { + recorder.contains( + kAXWindowCreatedNotification as CFString, target: dynamicWindow, since: windowEventStart) + ? true : nil + } + try checkSingleNotification( + recorder, kAXWindowCreatedNotification as CFString, target: dynamicWindow, + since: windowEventStart) + let closeDynamic: AXUIElement = try waitFor( + "dynamic-window accessibility hierarchy", timeout: arguments.timeout + ) { + findElement(in: dynamicWindow, role: kAXButtonRole, name: "Close dynamic window") + } + try stressQueriesDuringAction( + staleElement: dynamicWindow, actionElement: closeDynamic, timeout: arguments.timeout) + let _: Bool = try waitFor("dynamic window removal", timeout: arguments.timeout) { + elementsAttribute(application, kAXWindowsAttribute as CFString).count == 2 ? true : nil + } + + progress("checking primary window removal") + guard let secondary = elementsAttribute(application, kAXWindowsAttribute as CFString).first( + where: { + stringAttribute($0, kAXTitleAttribute as CFString) + == "eepp - Accessibility Secondary" + }) + else { + try fail("could not find the secondary window by title") + } + guard let closePrimary = findElement( + in: secondary, role: kAXButtonRole, name: "Close primary window") + else { + try fail("could not find primary-window close button") + } + try stressQueriesDuringAction( + staleElement: save, actionElement: closePrimary, timeout: arguments.timeout) + let survivingWindow: AXUIElement = try waitFor( + "primary window removal", timeout: arguments.timeout + ) { + let found = elementsAttribute(application, kAXWindowsAttribute as CFString) + return found.count == 1 ? found[0] : nil + } + try check( + stringAttribute(survivingWindow, kAXTitleAttribute as CFString) + == "eepp - Accessibility Secondary", + "secondary window did not survive primary removal") + var staleRole: CFTypeRef? + let staleError = AXUIElementCopyAttributeValue( + save, kAXRoleAttribute as CFString, &staleRole) + try check(staleError != .success, "stale primary element remained queryable") + + progress("closing surviving secondary window") + guard let closeSecondary = findElement( + in: survivingWindow, role: kAXButtonRole, name: "Close secondary window") + else { + try fail("surviving secondary window was not queryable") + } + try perform(closeSecondary, kAXPressAction as CFString) + process.waitUntilExit() + try check(process.terminationStatus == 0, "example exited with a failure status") + print("macOS accessibility semantic and lifecycle harness passed") +} + +do { + try run() +} catch { + FileHandle.standardError.write(Data("macOS accessibility harness failed: \(error)\n".utf8)) + exit(EXIT_FAILURE) +} diff --git a/src/eepp/ui/accessibility/accessibilitybackend.cpp b/src/eepp/ui/accessibility/accessibilitybackend.cpp index f6b23f315..bdec2cb70 100644 --- a/src/eepp/ui/accessibility/accessibilitybackend.cpp +++ b/src/eepp/ui/accessibility/accessibilitybackend.cpp @@ -14,7 +14,7 @@ std::unique_ptr createNullAccessibilityBackend() { } #if EE_PLATFORM != EE_PLATFORM_LINUX && EE_PLATFORM != EE_PLATFORM_FREEBSD && \ - EE_PLATFORM != EE_PLATFORM_WIN + EE_PLATFORM != EE_PLATFORM_WIN && EE_PLATFORM != EE_PLATFORM_MACOS std::unique_ptr createAccessibilityBackend( AccessibilityManager& ) { return createNullAccessibilityBackend(); } diff --git a/src/eepp/ui/accessibility/accessibilitybackend.hpp b/src/eepp/ui/accessibility/accessibilitybackend.hpp index c4e762df7..68b19577b 100644 --- a/src/eepp/ui/accessibility/accessibilitybackend.hpp +++ b/src/eepp/ui/accessibility/accessibilitybackend.hpp @@ -19,6 +19,9 @@ class AccessibilityBackend { virtual void update() {} virtual void onEvent( const AccessibilityPendingEvent& event ) = 0; + + /** Evicts native wrappers for a model source whose identity is no longer valid. */ + virtual void onSourceInvalidated( AccessibilitySourceId ) {} }; std::unique_ptr createAccessibilityBackend( AccessibilityManager& manager ); diff --git a/src/eepp/ui/accessibility/accessibilitybackendmacos.mm b/src/eepp/ui/accessibility/accessibilitybackendmacos.mm new file mode 100644 index 000000000..f7954590b --- /dev/null +++ b/src/eepp/ui/accessibility/accessibilitybackendmacos.mm @@ -0,0 +1,1883 @@ +#define Rect AppleRect +#import +#undef Rect +#undef BSD + +#include "accessibilitybackend.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::UI; + +#if __has_feature( objc_arc ) +#define EE_OBJC_RELEASE( object ) +#define EE_OBJC_AUTORELEASE( object ) object +#else +#define EE_OBJC_RELEASE( object ) [object release] +#define EE_OBJC_AUTORELEASE( object ) [object autorelease] +#endif + +static NSString* toNSString( const String& string ) { + const std::string utf8 = string.toUtf8(); + return utf8.empty() ? @"" : [NSString stringWithUTF8String:utf8.c_str()]; +} + +static NSString* toNSString( const std::string& string ) { + return string.empty() ? @"" : [NSString stringWithUTF8String:string.c_str()]; +} + +static bool isMainThread() { + return [NSThread isMainThread]; +} + +static bool hasState( AccessibilityState states, AccessibilityState state ) { + return ( static_cast( states ) & static_cast( state ) ) != 0; +} + +static bool hasAction( AccessibilityActions actions, AccessibilityAction action ) { + return ( actions & accessibilityActionMask( action ) ) != 0; +} + +static double numericValue( const String& value, bool& valid ) { + double number = 0; + valid = String::fromString( number, value.toUtf8() ); + return number; +} + +static NSAccessibilityRole nativeRole( AccessibilityRole role ) { + switch ( role ) { + case AccessibilityRole::Application: + return NSAccessibilityApplicationRole; + case AccessibilityRole::Window: + case AccessibilityRole::Dialog: + return NSAccessibilityWindowRole; + case AccessibilityRole::Group: + case AccessibilityRole::TabPanel: + return NSAccessibilityGroupRole; + case AccessibilityRole::Button: + return NSAccessibilityButtonRole; + case AccessibilityRole::CheckBox: + return NSAccessibilityCheckBoxRole; + case AccessibilityRole::RadioButton: + return NSAccessibilityRadioButtonRole; + case AccessibilityRole::Label: + case AccessibilityRole::Text: + return NSAccessibilityStaticTextRole; + case AccessibilityRole::TextBox: + return NSAccessibilityTextFieldRole; + case AccessibilityRole::Image: + return NSAccessibilityImageRole; + case AccessibilityRole::ComboBox: + return NSAccessibilityComboBoxRole; + case AccessibilityRole::Slider: + return NSAccessibilitySliderRole; + case AccessibilityRole::SpinButton: + return NSAccessibilityIncrementorRole; + case AccessibilityRole::ProgressBar: + return NSAccessibilityProgressIndicatorRole; + case AccessibilityRole::TabList: + return NSAccessibilityTabGroupRole; + case AccessibilityRole::Tab: + return NSAccessibilityRadioButtonRole; + case AccessibilityRole::MenuBar: + return NSAccessibilityMenuBarRole; + case AccessibilityRole::Menu: + return NSAccessibilityMenuRole; + case AccessibilityRole::MenuItem: + case AccessibilityRole::CheckMenuItem: + case AccessibilityRole::RadioMenuItem: + return NSAccessibilityMenuItemRole; + case AccessibilityRole::List: + return NSAccessibilityListRole; + case AccessibilityRole::ListItem: + return NSAccessibilityRowRole; + case AccessibilityRole::Table: + return NSAccessibilityTableRole; + case AccessibilityRole::Row: + return NSAccessibilityRowRole; + case AccessibilityRole::Cell: + return NSAccessibilityCellRole; + case AccessibilityRole::Tree: + return NSAccessibilityOutlineRole; + case AccessibilityRole::TreeItem: + return NSAccessibilityRowRole; + case AccessibilityRole::None: + default: + return NSAccessibilityUnknownRole; + } +} + +static NSAccessibilitySubrole nativeSubrole( const AccessibilityNodeInfo& info ) { + if ( info.role == AccessibilityRole::TextBox && + hasState( info.states, AccessibilityState::Protected ) ) + return NSAccessibilitySecureTextFieldSubrole; + switch ( info.role ) { + case AccessibilityRole::Dialog: + return NSAccessibilityDialogSubrole; + case AccessibilityRole::Window: + return NSAccessibilityStandardWindowSubrole; + case AccessibilityRole::Tab: + return NSAccessibilityTabButtonSubrole; + case AccessibilityRole::Row: + case AccessibilityRole::ListItem: + return NSAccessibilityTableRowSubrole; + case AccessibilityRole::TreeItem: + return NSAccessibilityOutlineRowSubrole; + default: + return nil; + } +} + +static std::u16string utf16String( const String& string ) { + return string.toUtf16(); +} + +static NSUInteger codePointToUTF16( const String& string, Int32 offset ) { + const auto text = utf16String( string ); + NSUInteger codePoints = 0; + NSUInteger utf16Offset = 0; + const NSUInteger wanted = offset > 0 ? static_cast( offset ) : 0; + while ( utf16Offset < text.size() && codePoints < wanted ) { + const char16_t value = text[utf16Offset++]; + if ( value >= 0xd800 && value <= 0xdbff && utf16Offset < text.size() ) { + const char16_t next = text[utf16Offset]; + if ( next >= 0xdc00 && next <= 0xdfff ) + ++utf16Offset; + } + ++codePoints; + } + return utf16Offset; +} + +static Int32 utf16ToCodePoint( const String& string, NSUInteger offset ) { + const auto text = utf16String( string ); + NSUInteger index = 0; + Int32 codePoints = 0; + while ( index < text.size() && index < offset ) { + const char16_t value = text[index++]; + if ( value >= 0xd800 && value <= 0xdbff && index < text.size() && index < offset ) { + const char16_t next = text[index]; + if ( next >= 0xdc00 && next <= 0xdfff ) + ++index; + } + ++codePoints; + } + return codePoints; +} + +static NSRange textRange( const AccessibilityNodeInfo& info, const String& value ) { + if ( !info.text.valid ) + return NSMakeRange( 0, 0 ); + const NSUInteger start = codePointToUTF16( value, info.text.selectionStart ); + const NSUInteger end = codePointToUTF16( value, info.text.selectionEnd ); + return NSMakeRange( start, end >= start ? end - start : 0 ); +} + +static bool validRange( NSRange range, NSString* string ) { + return range.location <= string.length && range.length <= string.length - range.location; +} + +static NSArray* arrayWithObjects( const std::vector& objects ) { + NSMutableArray* result = [[NSMutableArray alloc] initWithCapacity:objects.size()]; + for ( id object : objects ) { + if ( object ) + [result addObject:object]; + } + return EE_OBJC_AUTORELEASE( result ); +} + +static void addAccessibilityChild( NSView* view, id child ) { + if ( !view || !child ) + return; + NSArray* existing = [view accessibilityChildren]; + for ( id candidate in existing ) { + if ( candidate == child ) + return; + } + NSMutableArray* children = [[NSMutableArray alloc] initWithArray:existing ? existing : @[]]; + [children addObject:child]; + [view setAccessibilityChildren:children]; + EE_OBJC_RELEASE( children ); +} + +static void removeAccessibilityChild( NSView* view, id child ) { + if ( !view || !child ) + return; + NSArray* existing = [view accessibilityChildren]; + NSMutableArray* children = [[NSMutableArray alloc] initWithArray:existing ? existing : @[]]; + [children removeObjectIdenticalTo:child]; + [view setAccessibilityChildren:children]; + EE_OBJC_RELEASE( children ); +} + +class MacAccessibilityState; +class MacAccessibilityRegistry; +@class EEPPMacAccessibilityElement; +@class EEPPMacAccessibilityWindowElement; + +static void markAccessibilityClientObserved(); + +struct NodeKey { + AccessibilitySourceId source{ 0 }; + Uint64 id{ 0 }; + + bool operator==( const NodeKey& other ) const { + return source == other.source && id == other.id; + } +}; + +struct NodeKeyHash { + size_t operator()( const NodeKey& key ) const { + return std::hash()( key.source ) ^ ( std::hash()( key.id ) + 0x9e3779b9 + + ( key.source << 6 ) + ( key.source >> 2 ) ); + } +}; + +static bool gAccessibilityClientObserved = false; + +class MacAccessibilityState : public std::enable_shared_from_this { + public: + MacAccessibilityState( AccessibilityManager& manager, NSWindow* nativeWindow, + std::string windowIdentifier ) : + mManager( &manager ), + mNativeWindow( nativeWindow ), + mWindowIdentifier( std::move( windowIdentifier ) ), + mRoot( manager.getRoot() ), + mRetainedElements( [[NSMutableArray alloc] init] ) {} + + ~MacAccessibilityState() { + mManager = nullptr; + mAlive = false; + EE_OBJC_RELEASE( mRetainedElements ); + } + + void setWindowElement( EEPPMacAccessibilityWindowElement* element ) { + mWindowElement = element; + } + + EEPPMacAccessibilityWindowElement* windowElement() const { return mWindowElement; } + + AccessibilityManager* manager() const { return mAlive && isMainThread() ? mManager : nullptr; } + // NSView is main-thread-only and NSWindow is not generally thread-safe. AppKit dispatches + // application-side accessibility requests through the UI event path; reject any unexpected + // off-main invocation instead of crossing into the eepp scene tree from that thread. + + NSWindow* nativeWindow() const { return mAlive ? mNativeWindow : nil; } + + const AccessibilityNodeRef& root() const { return mRoot; } + + bool hasActiveClient() const { return mAlive && gAccessibilityClientObserved; } + + void markClientObserved() { + if ( mAlive && isMainThread() ) + markAccessibilityClientObserved(); + } + + void invalidate() { + mAlive = false; + mManager = nullptr; + mNativeWindow = nil; + mWindowElement = nil; + mElements.clear(); + if ( mRetainedElements ) + [mRetainedElements removeAllObjects]; + } + + EEPPMacAccessibilityElement* elementFor( AccessibilityNodeRef ref ); + EEPPMacAccessibilityElement* cachedElementFor( AccessibilityNodeRef ref ) const; + bool isValid( AccessibilityNodeRef ref ) const; + void evict( AccessibilityNodeRef ref ); + void evictSource( AccessibilitySourceId source ); + void evictInvalidElements(); + + NSArray* childrenFor( AccessibilityNodeRef ref ); + NSArray* childrenFor( AccessibilityNodeRef ref, NSUInteger index, NSUInteger maxCount ); + NSUInteger childCountFor( AccessibilityNodeRef ref ); + NSUInteger indexOfChild( AccessibilityNodeRef parent, id child ); + NSArray* selectedChildrenFor( AccessibilityNodeRef ref ); + AccessibilityNodeRef parentFor( AccessibilityNodeRef ref ); + AccessibilityNodeInfo infoFor( AccessibilityNodeRef ref ) const; + AccessibilityNodeRef hitTest( NSPoint point ); + AccessibilityNodeRef focused() const; + bool perform( AccessibilityNodeRef ref, AccessibilityAction action, const String& value = {} ); + + NSRect frameFor( const AccessibilityNodeInfo& info ) const { + if ( !mAlive || !mNativeWindow || !info.boundsValid || !mManager || !isMainThread() ) + return NSZeroRect; + EE::Window::Window* window = + mManager->getSceneNode() ? mManager->getSceneNode()->getWindow() : nullptr; + // eepp world bounds are top-left-origin drawable pixels. NSAccessibility expects + // bottom-left-origin global screen points, including negative origins on secondary + // displays. contentRectForFrameRect: preserves that display origin; divide by the SDL + // drawable scale exactly once to convert pixels to Cocoa points. + const CGFloat scale = window && window->getScale() > 0 ? window->getScale() : 1; + const NSRect content = [mNativeWindow contentRectForFrameRect:[mNativeWindow frame]]; + const CGFloat width = std::max( 0, info.bounds.getWidth() / scale ); + const CGFloat height = std::max( 0, info.bounds.getHeight() / scale ); + return NSMakeRect( content.origin.x + info.bounds.Left / scale, + content.origin.y + content.size.height - + ( info.bounds.Top + info.bounds.getHeight() ) / scale, + width, height ); + } + + NSString* identifierFor( AccessibilityNodeRef ref ) const { + return toNSString( "eepp." + mWindowIdentifier + "." + std::to_string( ref.source ) + "." + + std::to_string( ref.id ) ); + } + + private: + AccessibilityManager* mManager{ nullptr }; + NSWindow* mNativeWindow{ nil }; + std::string mWindowIdentifier; + AccessibilityNodeRef mRoot; + bool mAlive{ true }; + EEPPMacAccessibilityWindowElement* mWindowElement{ nil }; + NSMutableArray* mRetainedElements{ nil }; + std::unordered_map mElements; +}; + +@interface EEPPMacAccessibilityWindowElement : NSAccessibilityElement { + @private + std::weak_ptr _state; + NSString* _identifier; +} +- (void)configureWithState:(const std::shared_ptr&)state + identifier:(NSString*)identifier; +@end + +@interface EEPPMacAccessibilityElement : NSAccessibilityElement { + @private + std::weak_ptr _state; + AccessibilityNodeRef _ref; +} +- (void)configureWithState:(const std::shared_ptr&)state + ref:(AccessibilityNodeRef)ref; +@end + +EEPPMacAccessibilityElement* MacAccessibilityState::elementFor( AccessibilityNodeRef ref ) { + if ( !mAlive || !isMainThread() || !mManager || !ref.isValid() || !mManager->isValid( ref ) ) + return nil; + const NodeKey key{ ref.source, ref.id }; + auto found = mElements.find( key ); + if ( found != mElements.end() ) + return found->second; + const auto info = mManager->getNodeInfo( ref ); + EEPPMacAccessibilityElement* element = [EEPPMacAccessibilityElement + accessibilityElementWithRole:nativeRole( info.role ) + frame:frameFor( info ) + label:info.name.empty() ? nil : toNSString( info.name ) + parent:mWindowElement]; + [element configureWithState:shared_from_this() ref:ref]; + mElements.emplace( key, element ); + [mRetainedElements addObject:element]; + return element; +} + +EEPPMacAccessibilityElement* +MacAccessibilityState::cachedElementFor( AccessibilityNodeRef ref ) const { + if ( !ref.isValid() ) + return nil; + auto found = mElements.find( NodeKey{ ref.source, ref.id } ); + return found != mElements.end() ? found->second : nil; +} + +bool MacAccessibilityState::isValid( AccessibilityNodeRef ref ) const { + return mManager && mAlive && isMainThread() && ref.isValid() && mManager->isValid( ref ); +} + +void MacAccessibilityState::evict( AccessibilityNodeRef ref ) { + auto found = mElements.find( NodeKey{ ref.source, ref.id } ); + if ( found == mElements.end() ) + return; + EEPPMacAccessibilityElement* element = found->second; + mElements.erase( found ); + [mRetainedElements removeObject:element]; +} + +void MacAccessibilityState::evictSource( AccessibilitySourceId source ) { + for ( auto it = mElements.begin(); it != mElements.end(); ) { + if ( it->first.source == source ) { + if ( hasActiveClient() ) + NSAccessibilityPostNotification( it->second, + NSAccessibilityUIElementDestroyedNotification ); + [mRetainedElements removeObject:it->second]; + it = mElements.erase( it ); + } else + ++it; + } +} + +void MacAccessibilityState::evictInvalidElements() { + if ( !manager() ) + return; + for ( auto it = mElements.begin(); it != mElements.end(); ) { + if ( !mManager->isValid( { it->first.source, it->first.id } ) ) { + if ( hasActiveClient() ) + NSAccessibilityPostNotification( it->second, + NSAccessibilityUIElementDestroyedNotification ); + [mRetainedElements removeObject:it->second]; + it = mElements.erase( it ); + } else + ++it; + } +} + +AccessibilityNodeInfo MacAccessibilityState::infoFor( AccessibilityNodeRef ref ) const { + return mManager && mAlive && isMainThread() ? mManager->getNodeInfo( ref ) + : AccessibilityNodeInfo(); +} + +NSArray* MacAccessibilityState::childrenFor( AccessibilityNodeRef ref ) { + if ( !manager() ) + return nil; + const auto& children = mManager->getChildren( ref ); + NSMutableArray* result = [[NSMutableArray alloc] initWithCapacity:children.size()]; + for ( const auto& child : children ) { + if ( auto element = elementFor( child ) ) + [result addObject:element]; + } + return EE_OBJC_AUTORELEASE( result ); +} + +NSArray* MacAccessibilityState::childrenFor( AccessibilityNodeRef ref, NSUInteger index, + NSUInteger maxCount ) { + if ( !manager() || maxCount == 0 ) + return @[]; + const auto& children = mManager->getChildren( ref ); + if ( index >= children.size() ) + return @[]; + const NSUInteger end = index + std::min( maxCount, children.size() - index ); + NSMutableArray* result = [[NSMutableArray alloc] initWithCapacity:end - index]; + for ( NSUInteger childIndex = index; childIndex < end; ++childIndex ) { + if ( auto element = elementFor( children[childIndex] ) ) + [result addObject:element]; + } + return EE_OBJC_AUTORELEASE( result ); +} + +NSUInteger MacAccessibilityState::childCountFor( AccessibilityNodeRef ref ) { + return manager() ? mManager->getChildCount( ref ) : 0; +} + +NSUInteger MacAccessibilityState::indexOfChild( AccessibilityNodeRef parent, id child ) { + if ( !manager() || !child ) + return NSNotFound; + const auto& children = mManager->getChildren( parent ); + for ( NSUInteger index = 0; index < children.size(); ++index ) { + if ( cachedElementFor( children[index] ) == child ) + return index; + } + return NSNotFound; +} + +NSArray* MacAccessibilityState::selectedChildrenFor( AccessibilityNodeRef ref ) { + if ( !manager() ) + return nil; + const auto& children = mManager->getChildren( ref ); + NSMutableArray* result = [[NSMutableArray alloc] init]; + for ( const auto& child : children ) { + if ( hasState( mManager->getNodeInfo( child ).states, AccessibilityState::Selected ) ) { + if ( auto element = elementFor( child ) ) + [result addObject:element]; + } + } + return EE_OBJC_AUTORELEASE( result ); +} + +AccessibilityNodeRef MacAccessibilityState::parentFor( AccessibilityNodeRef ref ) { + return manager() ? mManager->getParent( ref ) : AccessibilityNodeRef{}; +} + +AccessibilityNodeRef MacAccessibilityState::hitTest( NSPoint point ) { + if ( !manager() || !mNativeWindow || ![mNativeWindow isVisible] || + [mNativeWindow isMiniaturized] ) + return {}; + const NSRect content = [mNativeWindow contentRectForFrameRect:[mNativeWindow frame]]; + if ( !NSPointInRect( point, content ) ) + return {}; + EE::Window::Window* window = + mManager->getSceneNode() ? mManager->getSceneNode()->getWindow() : nullptr; + const CGFloat scale = window && window->getScale() > 0 ? window->getScale() : 1; + const Math::Vector2f local( ( point.x - content.origin.x ) * scale, + ( content.origin.y + content.size.height - point.y ) * scale ); + return mManager->hitTest( local ); +} + +AccessibilityNodeRef MacAccessibilityState::focused() const { + return mManager && mAlive && isMainThread() ? mManager->getKeyboardFocusedNode() + : AccessibilityNodeRef{}; +} + +bool MacAccessibilityState::perform( AccessibilityNodeRef ref, AccessibilityAction action, + const String& value ) { + if ( !manager() ) + return false; + if ( action == AccessibilityAction::Focus && mNativeWindow && ![mNativeWindow isKeyWindow] ) + [mNativeWindow makeKeyAndOrderFront:nil]; + return mManager->performAction( ref, { action, value } ); +} + +@implementation EEPPMacAccessibilityWindowElement + +- (void)configureWithState:(const std::shared_ptr&)state + identifier:(NSString*)identifier { + _state = state; + _identifier = [identifier copy]; +} + +- (void)dealloc { + EE_OBJC_RELEASE( _identifier ); +#if !__has_feature( objc_arc ) + [super dealloc]; +#endif +} + +- (std::shared_ptr)accessibilityState { + if ( !isMainThread() ) + return {}; + auto state = _state.lock(); + if ( state ) + state->markClientObserved(); + return state; +} + +- (BOOL)isAccessibilityElement { + auto state = [self accessibilityState]; + return state && state->nativeWindow() != nil; +} + +- (BOOL)accessibilityNotifiesWhenDestroyed { + return YES; +} + +- (NSRect)accessibilityFrame { + auto state = [self accessibilityState]; + return state && state->nativeWindow() + ? [state->nativeWindow() contentRectForFrameRect:[state->nativeWindow() frame]] + : NSZeroRect; +} + +- (NSPoint)accessibilityActivationPoint { + const NSRect frame = [self accessibilityFrame]; + return NSMakePoint( NSMidX( frame ), NSMidY( frame ) ); +} + +- (id)accessibilityTopLevelUIElement { + auto state = [self accessibilityState]; + return state ? state->nativeWindow() : nil; +} + +- (id)accessibilityWindow { + return self.accessibilityTopLevelUIElement; +} + +- (id)accessibilityParent { + auto state = [self accessibilityState]; + return state && state->nativeWindow() ? [state->nativeWindow() contentView] : nil; +} + +- (NSAccessibilityRole)accessibilityRole { + return NSAccessibilityGroupRole; +} + +- (NSAccessibilitySubrole)accessibilitySubrole { + return nil; +} + +- (NSString*)accessibilityRoleDescription { + NSAccessibilityRole role = self.accessibilityRole; + return role ? NSAccessibilityRoleDescription( role, self.accessibilitySubrole ) : nil; +} + +- (NSString*)accessibilityTitle { + return nil; +} + +- (NSString*)accessibilityIdentifier { + return _identifier; +} + +- (NSString*)accessibilityLabel { + return nil; +} + +- (BOOL)isAccessibilityEnabled { + return YES; +} + +- (BOOL)isAccessibilityFocused { + return NO; +} + +- (BOOL)isAccessibilityHidden { + auto state = [self accessibilityState]; + return !state || !state->nativeWindow() || ![state->nativeWindow() isVisible]; +} + +- (NSArray*)accessibilityChildren { + auto state = [self accessibilityState]; + return state ? state->childrenFor( state->root() ) : nil; +} + +- (NSArray*)accessibilityVisibleChildren { + return self.accessibilityChildren; +} + +- (NSArray*)accessibilityChildrenInNavigationOrder { + return self.accessibilityChildren; +} + +- (NSArray*)accessibilityContents { + return self.accessibilityChildren; +} + +- (id)accessibilityFocusedUIElement { + auto state = [self accessibilityState]; + if ( !state ) + return nil; + const auto ref = state->focused(); + return ref.isValid() ? state->elementFor( ref ) : nil; +} + +- (BOOL)accessibilityIsAttributeSettable:(NSAccessibilityAttributeName)attribute { + (void)attribute; + return NO; +} + +- (NSUInteger)accessibilityIndexOfChild:(id)child { + auto state = [self accessibilityState]; + return state ? state->indexOfChild( state->root(), child ) : NSNotFound; +} + +- (NSUInteger)accessibilityArrayAttributeCount:(NSAccessibilityAttributeName)attribute { + if ( ![attribute isEqualToString:NSAccessibilityChildrenAttribute] && + ![attribute isEqualToString:NSAccessibilityVisibleChildrenAttribute] && + ![attribute isEqualToString:NSAccessibilityChildrenInNavigationOrderAttribute] ) + return 0; + auto state = [self accessibilityState]; + return state ? state->childCountFor( state->root() ) : 0; +} + +- (NSArray*)accessibilityArrayAttributeValues:(NSAccessibilityAttributeName)attribute + index:(NSUInteger)index + maxCount:(NSUInteger)maxCount { + if ( ![attribute isEqualToString:NSAccessibilityChildrenAttribute] && + ![attribute isEqualToString:NSAccessibilityVisibleChildrenAttribute] && + ![attribute isEqualToString:NSAccessibilityChildrenInNavigationOrderAttribute] ) + return @[]; + auto state = [self accessibilityState]; + return state ? state->childrenFor( state->root(), index, maxCount ) : @[]; +} + +- (id)accessibilityAttributeValue:(NSAccessibilityAttributeName)attribute { + if ( [attribute isEqualToString:NSAccessibilityChildrenAttribute] ) + return self.accessibilityChildren; + if ( [attribute isEqualToString:NSAccessibilityVisibleChildrenAttribute] ) + return self.accessibilityVisibleChildren; + if ( [attribute isEqualToString:NSAccessibilityChildrenInNavigationOrderAttribute] ) + return self.accessibilityChildrenInNavigationOrder; + if ( [attribute isEqualToString:NSAccessibilityParentAttribute] ) + return self.accessibilityParent; + if ( [attribute isEqualToString:NSAccessibilityWindowAttribute] ) + return self.accessibilityWindow; + if ( [attribute isEqualToString:NSAccessibilityTopLevelUIElementAttribute] ) + return self.accessibilityTopLevelUIElement; + if ( [attribute isEqualToString:NSAccessibilityRoleAttribute] ) + return self.accessibilityRole; + if ( [attribute isEqualToString:NSAccessibilitySubroleAttribute] ) + return self.accessibilitySubrole; + if ( [attribute isEqualToString:NSAccessibilityRoleDescriptionAttribute] ) + return self.accessibilityRoleDescription; + if ( [attribute isEqualToString:NSAccessibilityTitleAttribute] ) + return self.accessibilityTitle; + if ( [attribute isEqualToString:NSAccessibilityIdentifierAttribute] ) + return self.accessibilityIdentifier; + if ( [attribute isEqualToString:NSAccessibilityPositionAttribute] ) + return [NSValue valueWithPoint:self.accessibilityFrame.origin]; + if ( [attribute isEqualToString:NSAccessibilitySizeAttribute] ) + return [NSValue valueWithSize:self.accessibilityFrame.size]; + if ( [attribute isEqualToString:NSAccessibilityEnabledAttribute] ) + return @( self.isAccessibilityEnabled ); + if ( [attribute isEqualToString:NSAccessibilityHiddenAttribute] ) + return @( self.isAccessibilityHidden ); + if ( [attribute isEqualToString:NSAccessibilityFocusedUIElementAttribute] ) + return self.accessibilityFocusedUIElement; + return nil; +} + +- (NSArray*)accessibilityAttributeNames { + return @[ + NSAccessibilityRoleAttribute, NSAccessibilitySubroleAttribute, + NSAccessibilityRoleDescriptionAttribute, NSAccessibilityTitleAttribute, + NSAccessibilityIdentifierAttribute, NSAccessibilityPositionAttribute, + NSAccessibilitySizeAttribute, NSAccessibilityParentAttribute, + NSAccessibilityChildrenAttribute, NSAccessibilityVisibleChildrenAttribute, + NSAccessibilityChildrenInNavigationOrderAttribute, NSAccessibilityWindowAttribute, + NSAccessibilityTopLevelUIElementAttribute, NSAccessibilityFocusedUIElementAttribute, + NSAccessibilityEnabledAttribute, NSAccessibilityHiddenAttribute + ]; +} + +- (BOOL)accessibilityIsIgnored { + return !self.isAccessibilityElement; +} + +- (id)accessibilityHitTest:(NSPoint)point { + auto state = [self accessibilityState]; + if ( !state || self.isAccessibilityHidden || !NSPointInRect( point, self.accessibilityFrame ) ) + return nil; + const auto ref = state->hitTest( point ); + return ref.isValid() ? state->elementFor( ref ) : self; +} + +@end + +@implementation EEPPMacAccessibilityElement + +- (void)configureWithState:(const std::shared_ptr&)state + ref:(AccessibilityNodeRef)ref { + _state = state; + _ref = ref; +} + +- (std::shared_ptr)accessibilityState { + if ( !isMainThread() ) + return {}; + auto state = _state.lock(); + if ( state ) + state->markClientObserved(); + return state; +} + +- (AccessibilityNodeInfo)nodeInfo { + auto state = [self accessibilityState]; + return state ? state->infoFor( _ref ) : AccessibilityNodeInfo(); +} + +- (BOOL)isAccessibilityElement { + auto state = [self accessibilityState]; + if ( !state ) + return NO; + const auto info = state->infoFor( _ref ); + return info.role != AccessibilityRole::None && + hasState( info.states, AccessibilityState::Visible ) && + hasState( info.states, AccessibilityState::Showing ); +} + +- (BOOL)accessibilityNotifiesWhenDestroyed { + return YES; +} + +- (NSRect)accessibilityFrame { + auto state = [self accessibilityState]; + return state ? state->frameFor( [self nodeInfo] ) : NSZeroRect; +} + +- (NSPoint)accessibilityActivationPoint { + const NSRect frame = self.accessibilityFrame; + return NSMakePoint( NSMidX( frame ), NSMidY( frame ) ); +} + +- (id)accessibilityTopLevelUIElement { + auto state = [self accessibilityState]; + return state && state->isValid( _ref ) ? state->nativeWindow() : nil; +} + +- (id)accessibilityWindow { + return self.accessibilityTopLevelUIElement; +} + +- (id)accessibilityParent { + auto state = [self accessibilityState]; + if ( !state || !state->isValid( _ref ) ) + return nil; + const auto parent = state->parentFor( _ref ); + if ( !parent.isValid() || parent == state->root() ) + return state->windowElement(); + return state->elementFor( parent ); +} + +- (NSAccessibilityRole)accessibilityRole { + auto state = [self accessibilityState]; + return state && state->isValid( _ref ) ? nativeRole( state->infoFor( _ref ).role ) : nil; +} + +- (NSAccessibilitySubrole)accessibilitySubrole { + return nativeSubrole( [self nodeInfo] ); +} + +- (NSString*)accessibilityRoleDescription { + NSAccessibilityRole role = self.accessibilityRole; + return role ? NSAccessibilityRoleDescription( role, self.accessibilitySubrole ) : nil; +} + +- (NSString*)accessibilityLabel { + const auto info = [self nodeInfo]; + if ( info.role == AccessibilityRole::Label || info.role == AccessibilityRole::Text || + ( info.name == info.value && + ( info.role == AccessibilityRole::ListItem || info.role == AccessibilityRole::Row || + info.role == AccessibilityRole::TreeItem ) ) ) + return nil; + const String& name = info.name; + return name.empty() ? nil : toNSString( name ); +} + +- (NSString*)accessibilityTitle { + return nil; +} + +- (NSString*)accessibilityHelp { + const String description = [self nodeInfo].description; + return description.empty() ? nil : toNSString( description ); +} + +- (NSString*)accessibilityIdentifier { + auto state = [self accessibilityState]; + return state && state->isValid( _ref ) ? state->identifierFor( _ref ) : nil; +} + +- (id)accessibilityValue { + const auto info = [self nodeInfo]; + if ( info.role == AccessibilityRole::Label || info.role == AccessibilityRole::Text ) + return info.name.empty() ? nil : toNSString( info.name ); + if ( info.role == AccessibilityRole::CheckBox || info.role == AccessibilityRole::RadioButton || + info.role == AccessibilityRole::CheckMenuItem || + info.role == AccessibilityRole::RadioMenuItem ) + return @( hasState( info.states, AccessibilityState::Checked ) || + hasState( info.states, AccessibilityState::Selected ) ); + if ( info.range.valid ) { + bool valid = false; + const double value = numericValue( info.value, valid ); + return valid ? @( value ) : nil; + } + return info.value.empty() ? nil : toNSString( info.value ); +} + +- (id)accessibilityValueDescription { + return nil; +} + +- (id)accessibilityMinValue { + const auto info = [self nodeInfo]; + return info.range.valid ? @( info.range.minimum ) : nil; +} + +- (id)accessibilityMaxValue { + const auto info = [self nodeInfo]; + return info.range.valid ? @( info.range.maximum ) : nil; +} + +- (BOOL)isAccessibilityFocused { + return hasState( [self nodeInfo].states, AccessibilityState::Focused ); +} + +- (BOOL)isAccessibilitySelected { + return hasState( [self nodeInfo].states, AccessibilityState::Selected ); +} + +- (BOOL)isAccessibilityExpanded { + return hasState( [self nodeInfo].states, AccessibilityState::Expanded ); +} + +- (BOOL)isAccessibilityEnabled { + return hasState( [self nodeInfo].states, AccessibilityState::Enabled ); +} + +- (BOOL)isAccessibilityHidden { + return !self.isAccessibilityElement; +} + +- (NSArray*)accessibilityChildren { + auto state = [self accessibilityState]; + return state ? state->childrenFor( _ref ) : nil; +} + +- (NSArray*)accessibilityVisibleChildren { + return self.accessibilityChildren; +} + +- (NSArray*)accessibilityChildrenInNavigationOrder { + return self.accessibilityChildren; +} + +- (NSArray*)accessibilityContents { + return self.accessibilityChildren; +} + +- (NSArray*)accessibilitySelectedChildren { + auto state = [self accessibilityState]; + return state ? state->selectedChildrenFor( _ref ) : nil; +} + +- (NSArray*)accessibilityRows { + const auto role = [self nodeInfo].role; + return role == AccessibilityRole::Table || role == AccessibilityRole::List || + role == AccessibilityRole::Tree + ? self.accessibilityChildren + : nil; +} + +- (NSArray*)accessibilityVisibleRows { + return self.accessibilityRows; +} + +- (NSArray*)accessibilitySelectedRows { + const auto role = [self nodeInfo].role; + return role == AccessibilityRole::Table || role == AccessibilityRole::List || + role == AccessibilityRole::Tree + ? self.accessibilitySelectedChildren + : nil; +} + +- (NSInteger)accessibilityIndex { + auto state = [self accessibilityState]; + if ( !state || !state->isValid( _ref ) ) + return NSNotFound; + const auto parent = state->parentFor( _ref ); + return parent.isValid() ? state->indexOfChild( parent, self ) : NSNotFound; +} + +- (NSArray*)accessibilityLinkedUIElements { + auto state = [self accessibilityState]; + if ( !state ) + return nil; + const auto info = state->infoFor( _ref ); + std::vector related; + for ( const auto& relation : info.relations ) { + if ( auto element = state->elementFor( relation.target ) ) + related.emplace_back( element ); + } + return arrayWithObjects( related ); +} + +- (BOOL)isAccessibilityProtectedContent { + return hasState( [self nodeInfo].states, AccessibilityState::Protected ); +} + +- (NSArray*)accessibilityActionNames { + const auto info = [self nodeInfo]; + const auto actions = info.actions; + const bool selectUsesPress = info.role == AccessibilityRole::RadioButton || + info.role == AccessibilityRole::RadioMenuItem || + info.role == AccessibilityRole::Tab; + NSMutableArray* names = [[NSMutableArray alloc] init]; + if ( !hasState( info.states, AccessibilityState::Enabled ) ) + return EE_OBJC_AUTORELEASE( names ); + if ( hasAction( actions, AccessibilityAction::Press ) || + hasAction( actions, AccessibilityAction::Toggle ) || + ( hasAction( actions, AccessibilityAction::Select ) && selectUsesPress ) ) + [names addObject:NSAccessibilityPressAction]; + if ( hasAction( actions, AccessibilityAction::Select ) && !selectUsesPress ) + [names addObject:NSAccessibilityPickAction]; + if ( hasAction( actions, AccessibilityAction::Increment ) ) + [names addObject:NSAccessibilityIncrementAction]; + if ( hasAction( actions, AccessibilityAction::Decrement ) ) + [names addObject:NSAccessibilityDecrementAction]; + if ( info.role == AccessibilityRole::ComboBox && + ( hasAction( actions, AccessibilityAction::Expand ) || + hasAction( actions, AccessibilityAction::Collapse ) ) ) + [names addObject:NSAccessibilityShowMenuAction]; +#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 260000 + if ( hasAction( actions, AccessibilityAction::ScrollTo ) ) { + if ( @available( macOS 26.0, * ) ) + [names addObject:NSAccessibilityScrollToVisibleAction]; + } +#endif + return EE_OBJC_AUTORELEASE( names ); +} + +- (NSString*)accessibilityActionDescription:(NSAccessibilityActionName)action { + return NSAccessibilityActionDescription( action ); +} + +- (BOOL)performAccessibilityAction:(AccessibilityAction)action { + auto state = [self accessibilityState]; + if ( !state ) + return NO; + const auto info = state->infoFor( _ref ); + return hasState( info.states, AccessibilityState::Enabled ) && + hasAction( info.actions, action ) && state->perform( _ref, action ); +} + +- (void)accessibilityPerformAction:(NSAccessibilityActionName)action { + if ( [action isEqualToString:NSAccessibilityPressAction] || + [action isEqualToString:NSAccessibilityConfirmAction] ) { + const auto info = [self nodeInfo]; + if ( hasAction( info.actions, AccessibilityAction::Press ) ) + [self performAccessibilityAction:AccessibilityAction::Press]; + else if ( hasAction( info.actions, AccessibilityAction::Toggle ) ) + [self performAccessibilityAction:AccessibilityAction::Toggle]; + else if ( hasAction( info.actions, AccessibilityAction::Select ) ) + [self performAccessibilityAction:AccessibilityAction::Select]; + } else if ( [action isEqualToString:NSAccessibilityPickAction] ) { + if ( hasAction( [self nodeInfo].actions, AccessibilityAction::Select ) ) + [self performAccessibilityAction:AccessibilityAction::Select]; + } else if ( [action isEqualToString:NSAccessibilityIncrementAction] ) { + [self performAccessibilityAction:AccessibilityAction::Increment]; + } else if ( [action isEqualToString:NSAccessibilityDecrementAction] ) { + [self performAccessibilityAction:AccessibilityAction::Decrement]; + } else if ( [action isEqualToString:NSAccessibilityShowMenuAction] ) { + const auto info = [self nodeInfo]; + const auto next = hasState( info.states, AccessibilityState::Expanded ) + ? AccessibilityAction::Collapse + : AccessibilityAction::Expand; + [self performAccessibilityAction:next]; + } +#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 260000 + else if ( @available( macOS 26.0, * ) ) { + if ( [action isEqualToString:NSAccessibilityScrollToVisibleAction] ) + [self performAccessibilityAction:AccessibilityAction::ScrollTo]; + } +#endif +} + +- (BOOL)accessibilityPerformPress { + const auto info = [self nodeInfo]; + if ( hasAction( info.actions, AccessibilityAction::Press ) ) + return [self performAccessibilityAction:AccessibilityAction::Press]; + if ( hasAction( info.actions, AccessibilityAction::Toggle ) ) + return [self performAccessibilityAction:AccessibilityAction::Toggle]; + if ( hasAction( info.actions, AccessibilityAction::Select ) ) + return [self performAccessibilityAction:AccessibilityAction::Select]; + return NO; +} + +- (BOOL)accessibilityPerformIncrement { + return [self performAccessibilityAction:AccessibilityAction::Increment]; +} + +- (BOOL)accessibilityPerformDecrement { + return [self performAccessibilityAction:AccessibilityAction::Decrement]; +} + +- (BOOL)accessibilityPerformShowMenu { + const auto info = [self nodeInfo]; + return [self performAccessibilityAction:( hasState( info.states, AccessibilityState::Expanded ) + ? AccessibilityAction::Collapse + : AccessibilityAction::Expand )]; +} + +- (BOOL)accessibilityIsAttributeSettable:(NSAccessibilityAttributeName)attribute { + auto state = [self accessibilityState]; + if ( !state ) + return NO; + const auto info = state->infoFor( _ref ); + if ( !hasState( info.states, AccessibilityState::Enabled ) ) + return NO; + if ( [attribute isEqualToString:NSAccessibilityFocusedAttribute] ) + return hasAction( info.actions, AccessibilityAction::Focus ); + if ( [attribute isEqualToString:NSAccessibilitySelectedAttribute] ) + return hasAction( info.actions, AccessibilityAction::Select ); + if ( [attribute isEqualToString:NSAccessibilityExpandedAttribute] ) + return hasAction( info.actions, AccessibilityAction::Expand ) || + hasAction( info.actions, AccessibilityAction::Collapse ); + if ( [attribute isEqualToString:NSAccessibilityValueAttribute] ) + return hasAction( info.actions, AccessibilityAction::SetValue ) || + hasAction( info.actions, AccessibilityAction::SetText ); + if ( [attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute] || + [attribute isEqualToString:NSAccessibilitySelectedTextRangesAttribute] ) + return hasAction( info.actions, AccessibilityAction::SetTextSelection ); + if ( [attribute isEqualToString:NSAccessibilitySelectedTextAttribute] ) + return info.text.valid && hasAction( info.actions, AccessibilityAction::SetText ); + return NO; +} + +- (void)setAccessibilityFocused:(BOOL)focused { + if ( !focused ) + return; + auto state = [self accessibilityState]; + if ( !state ) + return; + const auto info = state->infoFor( _ref ); + if ( hasAction( info.actions, AccessibilityAction::Focus ) ) + state->perform( _ref, AccessibilityAction::Focus ); +} + +- (void)setAccessibilitySelected:(BOOL)selected { + if ( !selected ) + return; + auto state = [self accessibilityState]; + if ( !state ) + return; + const auto info = state->infoFor( _ref ); + if ( hasAction( info.actions, AccessibilityAction::Select ) ) + state->perform( _ref, AccessibilityAction::Select ); +} + +- (void)setAccessibilityExpanded:(BOOL)expanded { + auto state = [self accessibilityState]; + if ( !state ) + return; + const auto info = state->infoFor( _ref ); + const auto action = expanded ? AccessibilityAction::Expand : AccessibilityAction::Collapse; + if ( hasAction( info.actions, action ) ) + state->perform( _ref, action ); +} + +- (void)setAccessibilityValue:(id)value { + auto state = [self accessibilityState]; + if ( !state || !value ) + return; + const auto info = state->infoFor( _ref ); + AccessibilityAction action = AccessibilityAction::SetValue; + if ( hasAction( info.actions, AccessibilityAction::SetText ) ) + action = AccessibilityAction::SetText; + if ( !hasAction( info.actions, action ) ) + return; + NSString* nativeValue = nil; + if ( [value isKindOfClass:[NSString class]] ) + nativeValue = value; + else if ( [value respondsToSelector:@selector( stringValue )] ) + nativeValue = [value stringValue]; + if ( nativeValue ) + state->perform( _ref, action, + String::fromUtf8( std::string( nativeValue.UTF8String ?: "" ) ) ); +} + +- (void)setAccessibilitySelectedTextRange:(NSRange)range { + auto state = [self accessibilityState]; + if ( !state ) + return; + const auto info = state->infoFor( _ref ); + NSString* nativeText = toNSString( info.value ); + if ( !info.text.valid || !validRange( range, nativeText ) || + !hasAction( info.actions, AccessibilityAction::SetTextSelection ) ) + return; + const Int32 start = utf16ToCodePoint( info.value, range.location ); + const Int32 end = utf16ToCodePoint( info.value, NSMaxRange( range ) ); + state->perform( _ref, AccessibilityAction::SetTextSelection, + String( std::to_string( start ) + ":" + std::to_string( end ) ) ); +} + +- (void)setAccessibilitySelectedTextRanges:(NSArray*)ranges { + if ( ranges.count > 0 ) + self.accessibilitySelectedTextRange = ranges.firstObject.rangeValue; +} + +- (void)setAccessibilitySelectedText:(NSString*)replacement { + auto state = [self accessibilityState]; + if ( !state || !replacement ) + return; + const auto info = state->infoFor( _ref ); + if ( !info.text.valid || !hasAction( info.actions, AccessibilityAction::SetText ) ) + return; + NSMutableString* updated = [toNSString( info.value ) mutableCopy]; + const NSRange selected = textRange( info, info.value ); + if ( !validRange( selected, updated ) ) { + EE_OBJC_RELEASE( updated ); + return; + } + [updated replaceCharactersInRange:selected withString:replacement]; + const String updatedText = String::fromUtf8( std::string( updated.UTF8String ?: "" ) ); + const NSUInteger insertionPoint = selected.location + replacement.length; + if ( state->perform( _ref, AccessibilityAction::SetText, updatedText ) && + hasAction( info.actions, AccessibilityAction::SetTextSelection ) ) { + const Int32 caret = utf16ToCodePoint( updatedText, insertionPoint ); + state->perform( _ref, AccessibilityAction::SetTextSelection, + String( std::to_string( caret ) + ":" + std::to_string( caret ) ) ); + } + EE_OBJC_RELEASE( updated ); +} + +- (void)accessibilitySetValue:(id)value forAttribute:(NSAccessibilityAttributeName)attribute { + if ( [attribute isEqualToString:NSAccessibilityFocusedAttribute] && [value boolValue] ) { + self.accessibilityFocused = YES; + } else if ( [attribute isEqualToString:NSAccessibilitySelectedAttribute] && + [value boolValue] ) { + self.accessibilitySelected = YES; + } else if ( [attribute isEqualToString:NSAccessibilityExpandedAttribute] ) { + self.accessibilityExpanded = [value boolValue]; + } else if ( [attribute isEqualToString:NSAccessibilityValueAttribute] ) { + self.accessibilityValue = value; + } else if ( [attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute] && + [value isKindOfClass:[NSValue class]] ) { + self.accessibilitySelectedTextRange = [value rangeValue]; + } else if ( [attribute isEqualToString:NSAccessibilitySelectedTextRangesAttribute] && + [value isKindOfClass:[NSArray class]] ) { + self.accessibilitySelectedTextRanges = value; + } else if ( [attribute isEqualToString:NSAccessibilitySelectedTextAttribute] && + [value isKindOfClass:[NSString class]] ) { + self.accessibilitySelectedText = value; + } +} + +- (id)accessibilityAttributeValue:(NSAccessibilityAttributeName)attribute { + auto state = [self accessibilityState]; + if ( !state || !state->isValid( _ref ) ) + return nil; + const auto info = state->infoFor( _ref ); + if ( [attribute isEqualToString:NSAccessibilityRoleAttribute] ) + return self.accessibilityRole; + if ( [attribute isEqualToString:NSAccessibilitySubroleAttribute] ) + return self.accessibilitySubrole; + if ( [attribute isEqualToString:NSAccessibilityRoleDescriptionAttribute] ) + return self.accessibilityRoleDescription; + if ( [attribute isEqualToString:NSAccessibilityDescriptionAttribute] ) + return self.accessibilityLabel; + if ( [attribute isEqualToString:NSAccessibilityHelpAttribute] ) + return self.accessibilityHelp; + if ( [attribute isEqualToString:NSAccessibilityTitleAttribute] ) + return self.accessibilityTitle; + if ( [attribute isEqualToString:NSAccessibilityValueAttribute] ) + return self.accessibilityValue; + if ( [attribute isEqualToString:NSAccessibilityMinValueAttribute] ) + return self.accessibilityMinValue; + if ( [attribute isEqualToString:NSAccessibilityMaxValueAttribute] ) + return self.accessibilityMaxValue; + if ( [attribute isEqualToString:NSAccessibilityIdentifierAttribute] ) + return self.accessibilityIdentifier; + if ( [attribute isEqualToString:NSAccessibilityEnabledAttribute] ) + return @( self.isAccessibilityEnabled ); + if ( [attribute isEqualToString:NSAccessibilityFocusedAttribute] ) + return @( self.isAccessibilityFocused ); + if ( [attribute isEqualToString:NSAccessibilitySelectedAttribute] ) + return @( self.isAccessibilitySelected ); + if ( [attribute isEqualToString:NSAccessibilityExpandedAttribute] ) + return @( self.isAccessibilityExpanded ); + if ( [attribute isEqualToString:NSAccessibilityHiddenAttribute] ) + return @( self.isAccessibilityHidden ); + if ( [attribute isEqualToString:NSAccessibilityContainsProtectedContentAttribute] ) + return @( self.isAccessibilityProtectedContent ); + if ( [attribute isEqualToString:NSAccessibilityChildrenAttribute] ) + return self.accessibilityChildren; + if ( [attribute isEqualToString:NSAccessibilityVisibleChildrenAttribute] ) + return self.accessibilityVisibleChildren; + if ( [attribute isEqualToString:NSAccessibilityChildrenInNavigationOrderAttribute] ) + return self.accessibilityChildrenInNavigationOrder; + if ( [attribute isEqualToString:NSAccessibilityContentsAttribute] ) + return self.accessibilityContents; + if ( [attribute isEqualToString:NSAccessibilitySelectedChildrenAttribute] ) + return self.accessibilitySelectedChildren; + if ( [attribute isEqualToString:NSAccessibilityRowsAttribute] ) + return self.accessibilityRows; + if ( [attribute isEqualToString:NSAccessibilityVisibleRowsAttribute] ) + return self.accessibilityVisibleRows; + if ( [attribute isEqualToString:NSAccessibilitySelectedRowsAttribute] ) + return self.accessibilitySelectedRows; + if ( [attribute isEqualToString:NSAccessibilityRowCountAttribute] ) + return @( self.accessibilityRows.count ); + if ( [attribute isEqualToString:NSAccessibilityIndexAttribute] ) + return @( self.accessibilityIndex ); + if ( [attribute isEqualToString:NSAccessibilityParentAttribute] ) + return self.accessibilityParent; + if ( [attribute isEqualToString:NSAccessibilityWindowAttribute] ) + return self.accessibilityWindow; + if ( [attribute isEqualToString:NSAccessibilityTopLevelUIElementAttribute] ) + return self.accessibilityTopLevelUIElement; + if ( [attribute isEqualToString:NSAccessibilityLinkedUIElementsAttribute] ) + return self.accessibilityLinkedUIElements; + if ( [attribute isEqualToString:NSAccessibilityPositionAttribute] ) + return [NSValue valueWithPoint:self.accessibilityFrame.origin]; + if ( [attribute isEqualToString:NSAccessibilitySizeAttribute] ) + return [NSValue valueWithSize:self.accessibilityFrame.size]; + if ( info.text.valid ) { + const String text = info.value; + NSString* string = toNSString( text ); + const NSRange selected = textRange( info, text ); + if ( [attribute isEqualToString:NSAccessibilitySelectedTextAttribute] ) + return [string substringWithRange:selected]; + if ( [attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute] ) + return [NSValue valueWithRange:selected]; + if ( [attribute isEqualToString:NSAccessibilitySelectedTextRangesAttribute] ) + return @[ [NSValue valueWithRange:selected] ]; + if ( [attribute isEqualToString:NSAccessibilityNumberOfCharactersAttribute] ) + return @( string.length ); + if ( [attribute isEqualToString:NSAccessibilityVisibleCharacterRangeAttribute] ) + return [NSValue valueWithRange:NSMakeRange( 0, string.length )]; + if ( [attribute isEqualToString:NSAccessibilityInsertionPointLineNumberAttribute] ) + return @( self.accessibilityInsertionPointLineNumber ); + } + return nil; +} + +- (NSArray*)accessibilityAttributeNames { + auto state = [self accessibilityState]; + if ( !state || !state->isValid( _ref ) ) + return @[]; + const auto info = state->infoFor( _ref ); + NSMutableArray* names = [[NSMutableArray alloc] initWithArray:@[ + NSAccessibilityRoleAttribute, NSAccessibilityRoleDescriptionAttribute, + NSAccessibilityIdentifierAttribute, NSAccessibilityEnabledAttribute, + NSAccessibilityFocusedAttribute, NSAccessibilityChildrenAttribute, + NSAccessibilityVisibleChildrenAttribute, NSAccessibilityChildrenInNavigationOrderAttribute, + NSAccessibilityContentsAttribute, NSAccessibilityParentAttribute, + NSAccessibilityWindowAttribute, NSAccessibilityTopLevelUIElementAttribute, + NSAccessibilityPositionAttribute, NSAccessibilitySizeAttribute, + NSAccessibilityHiddenAttribute + ]]; + if ( nativeSubrole( info ) ) + [names addObject:NSAccessibilitySubroleAttribute]; + const bool hasLabel = + !info.name.empty() && info.role != AccessibilityRole::Label && + info.role != AccessibilityRole::Text && + !( info.name == info.value && + ( info.role == AccessibilityRole::ListItem || info.role == AccessibilityRole::Row || + info.role == AccessibilityRole::TreeItem ) ); + if ( hasLabel ) + [names addObject:NSAccessibilityDescriptionAttribute]; + if ( !info.description.empty() ) + [names addObject:NSAccessibilityHelpAttribute]; + const bool hasBooleanValue = info.role == AccessibilityRole::CheckBox || + info.role == AccessibilityRole::RadioButton || + info.role == AccessibilityRole::CheckMenuItem || + info.role == AccessibilityRole::RadioMenuItem; + const bool hasEditableTextValue = info.role == AccessibilityRole::TextBox && + !hasState( info.states, AccessibilityState::Protected ); + if ( !info.value.empty() || info.range.valid || hasBooleanValue || hasEditableTextValue || + info.role == AccessibilityRole::ComboBox || info.role == AccessibilityRole::Label || + info.role == AccessibilityRole::Text ) + [names addObject:NSAccessibilityValueAttribute]; + if ( hasAction( info.actions, AccessibilityAction::Select ) || + info.role == AccessibilityRole::Row || info.role == AccessibilityRole::ListItem || + info.role == AccessibilityRole::TreeItem ) + [names addObject:NSAccessibilitySelectedAttribute]; + if ( hasAction( info.actions, AccessibilityAction::Expand ) || + hasAction( info.actions, AccessibilityAction::Collapse ) ) + [names addObject:NSAccessibilityExpandedAttribute]; + if ( info.role == AccessibilityRole::Table || info.role == AccessibilityRole::List || + info.role == AccessibilityRole::Tree ) { + [names addObjectsFromArray:@[ + NSAccessibilitySelectedChildrenAttribute, NSAccessibilityRowsAttribute, + NSAccessibilityVisibleRowsAttribute, NSAccessibilitySelectedRowsAttribute, + NSAccessibilityRowCountAttribute + ]]; + } + if ( info.role == AccessibilityRole::Row || info.role == AccessibilityRole::ListItem || + info.role == AccessibilityRole::TreeItem || info.role == AccessibilityRole::Cell ) + [names addObject:NSAccessibilityIndexAttribute]; + if ( !info.relations.empty() ) + [names addObject:NSAccessibilityLinkedUIElementsAttribute]; + if ( info.range.valid ) + [names addObjectsFromArray:@[ + NSAccessibilityMinValueAttribute, NSAccessibilityMaxValueAttribute + ]]; + if ( hasState( info.states, AccessibilityState::Protected ) ) + [names addObject:NSAccessibilityContainsProtectedContentAttribute]; + if ( info.text.valid ) { + [names addObjectsFromArray:@[ + NSAccessibilitySelectedTextAttribute, NSAccessibilitySelectedTextRangeAttribute, + NSAccessibilitySelectedTextRangesAttribute, NSAccessibilityNumberOfCharactersAttribute, + NSAccessibilityVisibleCharacterRangeAttribute, + NSAccessibilityInsertionPointLineNumberAttribute + ]]; + } + return EE_OBJC_AUTORELEASE( names ); +} + +- (NSUInteger)accessibilityIndexOfChild:(id)child { + auto state = [self accessibilityState]; + return state ? state->indexOfChild( _ref, child ) : NSNotFound; +} + +- (NSUInteger)accessibilityArrayAttributeCount:(NSAccessibilityAttributeName)attribute { + if ( ![attribute isEqualToString:NSAccessibilityChildrenAttribute] && + ![attribute isEqualToString:NSAccessibilityVisibleChildrenAttribute] && + ![attribute isEqualToString:NSAccessibilityChildrenInNavigationOrderAttribute] && + ![attribute isEqualToString:NSAccessibilityRowsAttribute] && + ![attribute isEqualToString:NSAccessibilityVisibleRowsAttribute] ) + return 0; + auto state = [self accessibilityState]; + return state ? state->childCountFor( _ref ) : 0; +} + +- (NSArray*)accessibilityArrayAttributeValues:(NSAccessibilityAttributeName)attribute + index:(NSUInteger)index + maxCount:(NSUInteger)maxCount { + if ( ![attribute isEqualToString:NSAccessibilityChildrenAttribute] && + ![attribute isEqualToString:NSAccessibilityVisibleChildrenAttribute] && + ![attribute isEqualToString:NSAccessibilityChildrenInNavigationOrderAttribute] && + ![attribute isEqualToString:NSAccessibilityRowsAttribute] && + ![attribute isEqualToString:NSAccessibilityVisibleRowsAttribute] ) + return @[]; + auto state = [self accessibilityState]; + return state ? state->childrenFor( _ref, index, maxCount ) : @[]; +} + +- (BOOL)accessibilityIsIgnored { + return !self.isAccessibilityElement; +} + +- (id)accessibilityHitTest:(NSPoint)point { + auto state = [self accessibilityState]; + if ( !state || !NSPointInRect( point, self.accessibilityFrame ) ) + return nil; + const auto ref = state->hitTest( point ); + return ref.isValid() ? state->elementFor( ref ) : self; +} + +- (NSString*)accessibilitySelectedText { + const auto info = [self nodeInfo]; + if ( !info.text.valid ) + return nil; + NSString* string = toNSString( info.value ); + const NSRange selected = textRange( info, info.value ); + return validRange( selected, string ) ? [string substringWithRange:selected] : nil; +} + +- (NSArray*)accessibilitySelectedTextRanges { + const auto info = [self nodeInfo]; + return info.text.valid ? @[ [NSValue valueWithRange:textRange( info, info.value )] ] : nil; +} + +- (NSInteger)accessibilityNumberOfCharacters { + const auto info = [self nodeInfo]; + return info.text.valid ? static_cast( toNSString( info.value ).length ) : 0; +} + +- (NSInteger)accessibilityInsertionPointLineNumber { + const auto info = [self nodeInfo]; + return info.text.valid + ? [self accessibilityLineForIndex:static_cast( codePointToUTF16( + info.value, info.text.caretOffset ) )] + : NSNotFound; +} + +- (NSArray*)accessibilityParameterizedAttributeNames { + if ( ![self nodeInfo].text.valid ) + return @[]; + return @[ + NSAccessibilityStringForRangeParameterizedAttribute, + NSAccessibilityRangeForIndexParameterizedAttribute, + NSAccessibilityLineForIndexParameterizedAttribute, + NSAccessibilityRangeForLineParameterizedAttribute, + NSAccessibilityBoundsForRangeParameterizedAttribute, + NSAccessibilityAttributedStringForRangeParameterizedAttribute + ]; +} + +- (id)accessibilityAttributeValue:(NSAccessibilityParameterizedAttributeName)attribute + forParameter:(id)parameter { + const auto info = [self nodeInfo]; + if ( !info.text.valid ) + return nil; + NSString* string = toNSString( info.value ); + if ( [attribute isEqualToString:NSAccessibilityStringForRangeParameterizedAttribute] || + [attribute + isEqualToString:NSAccessibilityAttributedStringForRangeParameterizedAttribute] || + [attribute isEqualToString:NSAccessibilityBoundsForRangeParameterizedAttribute] ) { + if ( ![parameter isKindOfClass:[NSValue class]] ) + return nil; + NSRange range; + [parameter getValue:&range]; + if ( !validRange( range, string ) ) + return nil; + if ( [attribute isEqualToString:NSAccessibilityStringForRangeParameterizedAttribute] ) + return [string substringWithRange:range]; + if ( [attribute + isEqualToString:NSAccessibilityAttributedStringForRangeParameterizedAttribute] ) + return EE_OBJC_AUTORELEASE( + [[NSAttributedString alloc] initWithString:[string substringWithRange:range]] ); + return [NSValue valueWithRect:self.accessibilityFrame]; + } + if ( [attribute isEqualToString:NSAccessibilityRangeForIndexParameterizedAttribute] && + [parameter respondsToSelector:@selector( unsignedIntegerValue )] ) { + const NSUInteger index = [parameter unsignedIntegerValue]; + if ( index >= string.length ) + return nil; + return [NSValue valueWithRange:[string rangeOfComposedCharacterSequenceAtIndex:index]]; + } + if ( [attribute isEqualToString:NSAccessibilityLineForIndexParameterizedAttribute] && + [parameter respondsToSelector:@selector( integerValue )] ) + return @( [self accessibilityLineForIndex:[parameter integerValue]] ); + if ( [attribute isEqualToString:NSAccessibilityRangeForLineParameterizedAttribute] && + [parameter respondsToSelector:@selector( integerValue )] ) + return [NSValue valueWithRange:[self accessibilityRangeForLine:[parameter integerValue]]]; + return nil; +} + +- (NSRange)accessibilitySelectedTextRange { + const auto info = [self nodeInfo]; + return textRange( info, info.value ); +} + +- (NSRange)accessibilityVisibleCharacterRange { + const auto info = [self nodeInfo]; + return info.text.valid ? NSMakeRange( 0, toNSString( info.value ).length ) + : NSMakeRange( 0, 0 ); +} + +- (NSRange)accessibilityRangeForIndex:(NSInteger)index { + const auto info = [self nodeInfo]; + NSString* string = toNSString( info.value ); + return index >= 0 && static_cast( index ) < string.length + ? [string rangeOfComposedCharacterSequenceAtIndex:static_cast( index )] + : NSMakeRange( NSNotFound, 0 ); +} + +- (NSString*)accessibilityStringForRange:(NSRange)range { + const auto info = [self nodeInfo]; + if ( !info.text.valid ) + return nil; + NSString* string = toNSString( info.value ); + return validRange( range, string ) ? [string substringWithRange:range] : nil; +} + +- (NSAttributedString*)accessibilityAttributedStringForRange:(NSRange)range { + NSString* string = [self accessibilityStringForRange:range]; + return string ? EE_OBJC_AUTORELEASE( [[NSAttributedString alloc] initWithString:string] ) : nil; +} + +- (NSInteger)accessibilityLineForIndex:(NSInteger)index { + const auto info = [self nodeInfo]; + NSString* string = toNSString( info.value ); + if ( !info.text.valid || index < 0 || static_cast( index ) > string.length ) + return NSNotFound; + NSInteger line = 0; + for ( NSInteger offset = 0; offset < index; ++offset ) { + if ( [string characterAtIndex:static_cast( offset )] == '\n' ) + ++line; + } + return line; +} + +- (NSRange)accessibilityRangeForLine:(NSInteger)wantedLine { + const auto info = [self nodeInfo]; + NSString* string = toNSString( info.value ); + if ( !info.text.valid || wantedLine < 0 ) + return NSMakeRange( NSNotFound, 0 ); + NSUInteger start = 0; + for ( NSInteger line = 0; start <= string.length; ++line ) { + NSUInteger lineStart = 0; + NSUInteger lineEnd = 0; + [string getLineStart:&lineStart + end:&lineEnd + contentsEnd:nil + forRange:NSMakeRange( start, 0 )]; + if ( line == wantedLine ) + return NSMakeRange( lineStart, lineEnd - lineStart ); + if ( lineEnd <= start ) + break; + start = lineEnd; + } + return NSMakeRange( NSNotFound, 0 ); +} + +- (NSRect)accessibilityFrameForRange:(NSRange)range { + (void)range; + // The shared query model exposes element geometry, but not per-glyph layout. Returning the + // element's screen frame is conservative and keeps range geometry in the correct coordinate + // space until glyph-level bounds become available in shared text semantics. + return self.accessibilityFrame; +} + +@end + +class MacAccessibilityRegistry { + public: + struct WindowChanges { + bool main{ false }; + bool focused{ false }; + }; + + static MacAccessibilityRegistry& instance() { + static MacAccessibilityRegistry registry; + return registry; + } + + std::shared_ptr registerManager( AccessibilityManager& manager ) { + if ( !isMainThread() ) + return {}; + UISceneNode* scene = manager.getSceneNode(); + EE::Window::Window* window = scene ? scene->getWindow() : nullptr; + NSWindow* nativeWindow = + window ? reinterpret_cast( window->getWindowHandler() ) : nil; + const std::string windowId = window ? std::to_string( window->getWindowID() ) : "0"; + auto state = std::make_shared( manager, nativeWindow, windowId ); + if ( gAccessibilityClientObserved ) + manager.onNativeClientObserved(); + if ( !nativeWindow || !NSApp ) + return state; + NSString* identifier = toNSString( "eepp.window." + windowId ); + NSView* contentView = [nativeWindow contentView]; + const NSRect contentFrame = [nativeWindow contentRectForFrameRect:[nativeWindow frame]]; + EEPPMacAccessibilityWindowElement* element = + [EEPPMacAccessibilityWindowElement accessibilityElementWithRole:NSAccessibilityGroupRole + frame:contentFrame + label:nil + parent:contentView]; + [element configureWithState:state identifier:identifier]; + state->setWindowElement( element ); + mStates.push_back( state ); + [mRetainedWindows addObject:element]; + // SDL owns the NSWindow and content NSView. Keep AppKit's native application/window + // hierarchy and add one lazy semantic root below the content view. + addAccessibilityChild( contentView, element ); + const auto changes = syncApplication( gAccessibilityClientObserved ); + if ( gAccessibilityClientObserved ) { + NSAccessibilityPostNotification( nativeWindow, + NSAccessibilityLayoutChangedNotification ); + postWindowChanges( changes ); + } + return state; + } + + void unregisterState( const std::shared_ptr& state ) { + if ( !state || !isMainThread() ) + return; + EEPPMacAccessibilityWindowElement* element = state->windowElement(); + NSWindow* nativeWindow = state->nativeWindow(); + const bool observed = gAccessibilityClientObserved; + if ( observed && element ) + NSAccessibilityPostNotification( element, + NSAccessibilityUIElementDestroyedNotification ); + if ( nativeWindow ) + removeAccessibilityChild( [nativeWindow contentView], element ); + for ( size_t i = 0; i < mStates.size(); ++i ) { + if ( mStates[i] == state ) { + mStates.erase( mStates.begin() + i ); + break; + } + } + state->invalidate(); + const auto changes = syncApplication( gAccessibilityClientObserved ); + if ( element ) + [mRetainedWindows removeObject:element]; + if ( observed && NSApp ) { + NSAccessibilityPostNotification( NSApp, NSAccessibilityLayoutChangedNotification ); + postWindowChanges( changes ); + } + } + + void onSourceInvalidated( const std::shared_ptr& state, + AccessibilitySourceId source ) { + if ( state ) + state->evictSource( source ); + } + + void onEvent( const std::shared_ptr& state, + const AccessibilityPendingEvent& event ) { + if ( !state || !state->hasActiveClient() || !isMainThread() ) + return; + if ( event.type == AccessibilityEvent::ChildrenChanged ) + state->evictInvalidElements(); + EEPPMacAccessibilityElement* target = state->cachedElementFor( event.ref ); + EEPPMacAccessibilityElement* related = state->cachedElementFor( event.related ); + const AccessibilityNodeRef relatedRef = event.related; + if ( !target && event.type != AccessibilityEvent::Destroyed ) + target = state->elementFor( event.ref ); + if ( event.type == AccessibilityEvent::FocusChanged ) { + postWindowChanges( syncApplication( true ) ); + const auto focusedRef = state->focused(); + id focusedElement = focusedRef.isValid() ? state->elementFor( focusedRef ) : nil; + if ( focusedElement ) + NSAccessibilityPostNotification( + focusedElement, NSAccessibilityFocusedUIElementChangedNotification ); + return; + } + if ( event.type == AccessibilityEvent::Destroyed ) { + EEPPMacAccessibilityElement* destroyed = related ? related : target; + if ( destroyed ) + NSAccessibilityPostNotification( destroyed, + NSAccessibilityUIElementDestroyedNotification ); + if ( relatedRef.isValid() ) + state->evict( relatedRef ); + else + state->evict( event.ref ); + if ( target && target != destroyed ) + NSAccessibilityPostNotification( target, NSAccessibilityLayoutChangedNotification ); + } else if ( event.type == AccessibilityEvent::StateChanged ) { + if ( target ) { + const auto info = state->infoFor( event.ref ); + if ( info.role == AccessibilityRole::TreeItem && + ( hasAction( info.actions, AccessibilityAction::Expand ) || + hasAction( info.actions, AccessibilityAction::Collapse ) ) ) + NSAccessibilityPostNotification( + target, hasState( info.states, AccessibilityState::Expanded ) + ? NSAccessibilityRowExpandedNotification + : NSAccessibilityRowCollapsedNotification ); + else + NSAccessibilityPostNotification( target, + NSAccessibilityValueChangedNotification ); + } + } else if ( event.type == AccessibilityEvent::ValueChanged || + event.type == AccessibilityEvent::EnabledChanged ) { + if ( target ) + NSAccessibilityPostNotification( target, NSAccessibilityValueChangedNotification ); + } else if ( event.type == AccessibilityEvent::NameChanged ) { + if ( target ) + NSAccessibilityPostNotification( target, NSAccessibilityTitleChangedNotification ); + } else if ( event.type == AccessibilityEvent::SelectionChanged ) { + if ( target ) { + const auto info = state->infoFor( event.ref ); + const auto notification = info.text.valid + ? NSAccessibilitySelectedTextChangedNotification + : ( info.role == AccessibilityRole::Table || + info.role == AccessibilityRole::List || + info.role == AccessibilityRole::Tree ) + ? NSAccessibilitySelectedRowsChangedNotification + : NSAccessibilitySelectedChildrenChangedNotification; + NSAccessibilityPostNotification( target, notification ); + } + } else if ( event.type == AccessibilityEvent::BoundsChanged ) { + if ( target ) + NSAccessibilityPostNotification( target, NSAccessibilityLayoutChangedNotification ); + } else if ( event.type == AccessibilityEvent::Created ) { + if ( !related && event.related.isValid() ) + related = state->elementFor( event.related ); + if ( related ) + NSAccessibilityPostNotification( related, NSAccessibilityCreatedNotification ); + if ( target ) { + NSDictionary* userInfo = related + ? @{NSAccessibilityUIElementsKey : @[ related ]} + : nil; + NSAccessibilityPostNotificationWithUserInfo( + target, NSAccessibilityLayoutChangedNotification, userInfo ); + } + } else if ( target ) { + NSAccessibilityPostNotification( target, NSAccessibilityLayoutChangedNotification ); + } + } + + void clientObserved() { + if ( gAccessibilityClientObserved || !isMainThread() ) + return; + gAccessibilityClientObserved = true; + for ( const auto& state : mStates ) { + if ( auto manager = state->manager() ) + manager->onNativeClientObserved(); + } + // macOS has no supported equivalent to UiaClientsAreListening() or AT-SPI listener + // registration. The first actual NSAccessibility query activates notifications and focus + // projection. There is no process polling or per-frame Cocoa work while dormant. + // Do not mutate NSApplication while AppKit is serializing that first query. Deferring the + // focused-element projection to the next scene update avoids re-entering AXWindows. + mNeedsFocusSync = true; + } + + void update() { + if ( !gAccessibilityClientObserved || !NSApp || !isMainThread() ) + return; + if ( mNeedsFocusSync || [NSApp keyWindow] != mLastKeyWindow || + [NSApp mainWindow] != mLastMainWindow ) { + postWindowChanges( syncApplication( true ) ); + mNeedsFocusSync = false; + } + } + + private: + MacAccessibilityRegistry() : mRetainedWindows( [[NSMutableArray alloc] init] ) { + // Secure text controls use AXSecureTextField and never vend their value or text ranges. + // Do not call NSAccessibilitySetMayContainProtectedContent() here: enabling that global + // mode made the native AXWindows attribute unavailable to ordinary trusted AX clients on + // macOS 15, preventing application discovery before any semantic element was queried. + } + + ~MacAccessibilityRegistry() { EE_OBJC_RELEASE( mRetainedWindows ); } + + WindowChanges syncApplication( bool resolveFocus ) { + if ( !NSApp || !isMainThread() ) + return {}; + NSWindow* nativeMainWindow = [NSApp mainWindow]; + NSWindow* nativeFocusedWindow = [NSApp keyWindow]; + const WindowChanges changes{ nativeMainWindow != mLastMainWindow, + nativeFocusedWindow != mLastKeyWindow }; + if ( resolveFocus ) { + id focusedElement = nil; + for ( const auto& state : mStates ) { + if ( nativeFocusedWindow && state->nativeWindow() != nativeFocusedWindow ) + continue; + const auto ref = state->focused(); + if ( ref.isValid() ) { + focusedElement = state->elementFor( ref ); + if ( focusedElement ) + break; + } + } + [NSApp setAccessibilityApplicationFocusedUIElement:focusedElement]; + } + mLastMainWindow = nativeMainWindow; + mLastKeyWindow = nativeFocusedWindow; + return changes; + } + + void postWindowChanges( const WindowChanges& changes ) { + if ( !NSApp ) + return; + if ( changes.main ) + NSAccessibilityPostNotification( NSApp, NSAccessibilityMainWindowChangedNotification ); + if ( changes.focused ) + NSAccessibilityPostNotification( NSApp, + NSAccessibilityFocusedWindowChangedNotification ); + } + + std::vector> mStates; + NSMutableArray* mRetainedWindows{ nil }; + NSWindow* mLastMainWindow{ nil }; + NSWindow* mLastKeyWindow{ nil }; + bool mNeedsFocusSync{ false }; +}; + +static void markAccessibilityClientObserved() { + MacAccessibilityRegistry::instance().clientObserved(); +} + +class MacAccessibilityBackend final : public AccessibilityBackend { + public: + explicit MacAccessibilityBackend( AccessibilityManager& manager ) : + mState( MacAccessibilityRegistry::instance().registerManager( manager ) ) {} + + ~MacAccessibilityBackend() override { + MacAccessibilityRegistry::instance().unregisterState( mState ); + } + + bool isAvailable() const override { return mState && mState->nativeWindow() != nil; } + + bool hasActiveClients() const override { return mState && mState->hasActiveClient(); } + + void update() override { + if ( !mState || !mState->hasActiveClient() ) + return; + for ( const auto& event : mPendingEvents ) + MacAccessibilityRegistry::instance().onEvent( mState, event ); + mPendingEvents.clear(); + MacAccessibilityRegistry::instance().update(); + } + + void onEvent( const AccessibilityPendingEvent& event ) override { + if ( !mState || !mState->hasActiveClient() ) + return; + // UI and document callbacks can hold eepp locks. Queue stable identifiers and enter AppKit + // only from the next main-thread scene update. Focus is process-global, so one final-state + // notification per update is sufficient even when both the old and new widget report it. + if ( event.type == AccessibilityEvent::FocusChanged ) { + for ( auto& pending : mPendingEvents ) { + if ( pending.type == AccessibilityEvent::FocusChanged ) { + pending = event; + return; + } + } + } + mPendingEvents.emplace_back( event ); + } + + void onSourceInvalidated( AccessibilitySourceId source ) override { + // AppKit drops UIElementDestroyed once the source no longer resolves. The manager calls + // this on the main thread immediately before reset(), outside model locks, so destruction + // is the one notification that must be delivered synchronously while wrappers are valid. + MacAccessibilityRegistry::instance().onSourceInvalidated( mState, source ); + } + + private: + std::shared_ptr mState; + std::vector mPendingEvents; +}; + +namespace EE { namespace UI { + +std::unique_ptr createAccessibilityBackend( AccessibilityManager& manager ) { + return std::make_unique( manager ); +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/accessibility/accessibilitymanager.cpp b/src/eepp/ui/accessibility/accessibilitymanager.cpp index 2a9c2934d..d2a55b49a 100644 --- a/src/eepp/ui/accessibility/accessibilitymanager.cpp +++ b/src/eepp/ui/accessibility/accessibilitymanager.cpp @@ -73,10 +73,12 @@ UIWidget* hitSemanticWidget( Node* parent, const Math::Vector2f& point ) { if ( widget && ( widget->isAccessibilityHidden() || !widget->hasVisibility() || !widget->getWorldBounds().contains( point ) ) ) continue; - if ( auto child = hitSemanticWidget( node, point ) ) - return child; + // An accessible widget is a semantic boundary: getChildren() does not expose its + // implementation children, so hit testing must not return one of those hidden descendants. if ( widget && widget->isAccessibilityElement() ) return widget; + if ( auto child = hitSemanticWidget( node, point ) ) + return child; } return nullptr; } @@ -263,6 +265,11 @@ bool AccessibilityManager::hasActiveNativeClients() const { return mBackend && mBackend->hasActiveClients(); } +void AccessibilityManager::onNativeClientObserved() { + if ( mScene ) + mScene->mHasActiveAccessibilityClients = true; +} + void AccessibilityManager::update() { if ( !mBackend ) { mBackend = std::getenv( "EEPP_DISABLE_ACCESSIBILITY" ) @@ -295,9 +302,11 @@ void AccessibilityManager::notify( AccessibilityNodeRef ref, AccessibilityEvent if ( source != mWidgetSources.end() ) { auto found = mSources.find( source->second ); if ( found != mSources.end() ) { - if ( event == AccessibilityEvent::ModelChanged ) + if ( event == AccessibilityEvent::ModelChanged ) { + if ( mBackend ) + mBackend->onSourceInvalidated( source->second ); found->second->reset(); - else + } else found->second->invalidate(); } } @@ -354,6 +363,8 @@ void AccessibilityManager::onWidgetRemovedFromParent( UIWidget* widget ) { void AccessibilityManager::onWidgetDelete( UIWidget* widget ) { auto source = mWidgetSources.find( widget ); if ( source != mWidgetSources.end() ) { + if ( mBackend ) + mBackend->onSourceInvalidated( source->second ); mSources.erase( source->second ); mWidgetSources.erase( source ); } diff --git a/src/eepp/ui/accessibility/accessibilitywidgetresolver.cpp b/src/eepp/ui/accessibility/accessibilitywidgetresolver.cpp index 570c857e2..87ac62af9 100644 --- a/src/eepp/ui/accessibility/accessibilitywidgetresolver.cpp +++ b/src/eepp/ui/accessibility/accessibilitywidgetresolver.cpp @@ -25,6 +25,8 @@ #include #include +#include + namespace EE { namespace UI { namespace { @@ -56,6 +58,43 @@ AccessibilityActions baseActions( const UIWidget* widget ) { return widget->isTabFocusable() ? accessibilityActionMask( AccessibilityAction::Focus ) : 0; } +Int32 documentOffset( const Doc::TextDocument& document, const Doc::TextPosition& position ) { + if ( !position.isValid() ) + return 0; + Int64 offset = 0; + for ( Int64 line = 0; + line < position.line() && line < static_cast( document.linesCount() ); ++line ) + offset += static_cast( document.line( static_cast( line ) ).size() ); + offset += position.column(); + return static_cast( std::max( 0, offset ) ); +} + +AccessibilityTextInfo getDocumentText( const Doc::TextDocument& document ) { + auto selection = document.getSelection( true ); + return { documentOffset( document, document.getSelection().end() ), + documentOffset( document, selection.start() ), + documentOffset( document, selection.end() ), true }; +} + +bool parseSelection( const String& value, Int32& start, Int32& end ) { + auto parts = String::split( value, ':' ); + return parts.size() == 2 && String::fromString( start, parts[0].toUtf8() ) && + String::fromString( end, parts[1].toUtf8() ); +} + +Doc::TextPosition documentPosition( const Doc::TextDocument& document, Int32 offset ) { + Int64 remaining = std::max( 0, offset ); + for ( size_t line = 0; line < document.linesCount(); ++line ) { + const Int64 length = static_cast( document.line( line ).size() ); + if ( line + 1 == document.linesCount() ) + return { static_cast( line ), std::min( remaining, length ) }; + if ( remaining < length ) + return { static_cast( line ), remaining }; + remaining -= length; + } + return { 0, 0 }; +} + } // namespace AccessibilityRole AccessibilityWidgetResolver::getRole( const UIWidget* widget ) { @@ -160,15 +199,15 @@ AccessibilityRangeInfo AccessibilityWidgetResolver::getRange( const UIWidget* wi } AccessibilityTextInfo AccessibilityWidgetResolver::getText( const UIWidget* widget ) { - if ( !widget->isType( UI_TYPE_TEXTINPUT ) || - static_cast( widget )->getMode() == - UITextInput::TextInputMode::Password ) - return {}; - const auto& document = static_cast( widget )->getDocument(); - auto selection = document.getSelection( true ); - return { static_cast( document.getSelection().end().column() ), - static_cast( selection.start().column() ), - static_cast( selection.end().column() ), true }; + if ( widget->isType( UI_TYPE_TEXTEDIT ) ) + return getDocumentText( static_cast( widget )->getDocument() ); + if ( widget->isType( UI_TYPE_TEXTINPUT ) ) { + const auto* input = static_cast( widget ); + return input->getMode() == UITextInput::TextInputMode::Password + ? AccessibilityTextInfo{} + : getDocumentText( input->getDocument() ); + } + return {}; } AccessibilityState AccessibilityWidgetResolver::getState( const UIWidget* widget ) { @@ -201,6 +240,9 @@ AccessibilityState AccessibilityWidgetResolver::getState( const UIWidget* widget state |= static_cast( widget )->isEditingAllowed() ? AccessibilityState::Editable : AccessibilityState::ReadOnly; + if ( static_cast( widget )->getMode() == + UITextInput::TextInputMode::Password ) + state |= AccessibilityState::Protected; } return state; } @@ -219,6 +261,8 @@ AccessibilityActions AccessibilityWidgetResolver::getActions( const UIWidget* wi actions |= accessibilityActionMask( AccessibilityAction::Select ); } else if ( widget->isType( UI_TYPE_PUSHBUTTON ) ) { actions |= accessibilityActionMask( AccessibilityAction::Press ); + } else if ( widget->isType( UI_TYPE_MENUITEM ) ) { + actions |= accessibilityActionMask( AccessibilityAction::Press ); } if ( widget->isType( UI_TYPE_CHECKBOX ) ) actions |= accessibilityActionMask( AccessibilityAction::Toggle ); @@ -230,6 +274,13 @@ AccessibilityActions AccessibilityWidgetResolver::getActions( const UIWidget* wi else if ( widget->isType( UI_TYPE_TEXTINPUT ) && static_cast( widget )->isEditingAllowed() ) actions |= accessibilityActionMask( AccessibilityAction::SetText ); + if ( ( widget->isType( UI_TYPE_TEXTINPUT ) && + static_cast( widget )->isEditingAllowed() && + static_cast( widget )->getMode() != + UITextInput::TextInputMode::Password ) || + ( widget->isType( UI_TYPE_TEXTEDIT ) && + !static_cast( widget )->isLocked() ) ) + actions |= accessibilityActionMask( AccessibilityAction::SetTextSelection ); if ( widget->isType( UI_TYPE_COMBOBOX ) ) { actions |= accessibilityActionMask( static_cast( widget )->getListBox()->isVisible() @@ -297,6 +348,19 @@ bool AccessibilityWidgetResolver::performAction( UIWidget* widget, static_cast( widget )->setText( request.value ); return true; } + if ( request.action == AccessibilityAction::SetTextSelection && + ( widget->isType( UI_TYPE_TEXTINPUT ) || widget->isType( UI_TYPE_TEXTEDIT ) ) ) { + Int32 start = 0; + Int32 end = 0; + if ( !parseSelection( request.value, start, end ) ) + return false; + auto& document = widget->isType( UI_TYPE_TEXTEDIT ) + ? static_cast( widget )->getDocument() + : static_cast( widget )->getDocument(); + document.setSelection( documentPosition( document, start ), + documentPosition( document, end ) ); + return true; + } if ( ( request.action == AccessibilityAction::Expand || request.action == AccessibilityAction::Collapse ) && widget->isType( UI_TYPE_COMBOBOX ) ) { diff --git a/src/eepp/ui/uitextedit.cpp b/src/eepp/ui/uitextedit.cpp index ca2b2a0b4..9c031a38b 100644 --- a/src/eepp/ui/uitextedit.cpp +++ b/src/eepp/ui/uitextedit.cpp @@ -86,6 +86,11 @@ void UITextEdit::setText( const String& text ) { invalidateLongestLineWidth(); } +void UITextEdit::onDocumentSelectionChange( const Doc::TextRange& range ) { + UICodeEditor::onDocumentSelectionChange( range ); + notifyAccessibilityEvent( AccessibilityEvent::SelectionChanged ); +} + void UITextEdit::setWordWrap( bool enabled ) { if ( enabled ) setFlags( UI_WORD_WRAP ); diff --git a/src/eepp/ui/uitextinput.cpp b/src/eepp/ui/uitextinput.cpp index 764f112cd..64cfdcf61 100644 --- a/src/eepp/ui/uitextinput.cpp +++ b/src/eepp/ui/uitextinput.cpp @@ -533,6 +533,7 @@ void UITextInput::onDocumentCursorChange( const TextPosition& ) { void UITextInput::onDocumentSelectionChange( const TextRange& ) { onSelectionChange(); + notifyAccessibilityEvent( AccessibilityEvent::SelectionChanged ); } void UITextInput::onDocumentLineCountChange( const size_t&, const size_t& ) {} diff --git a/src/examples/ui_accessibility/ui_accessibility.cpp b/src/examples/ui_accessibility/ui_accessibility.cpp index 44ca0f836..40ecd47cd 100644 --- a/src/examples/ui_accessibility/ui_accessibility.cpp +++ b/src/examples/ui_accessibility/ui_accessibility.cpp @@ -9,6 +9,7 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { bool multiWindow = false; bool closePrimary = false; bool benchmark = false; + bool benchmarkVisible = false; bool benchmarkInactive = false; size_t benchmarkItems = 1000; size_t benchmarkIterations = 100000; @@ -16,6 +17,7 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { multiWindow |= std::string_view( argv[i] ) == "--multi-window"; closePrimary |= std::string_view( argv[i] ) == "--close-primary"; benchmark |= std::string_view( argv[i] ) == "--benchmark"; + benchmarkVisible |= std::string_view( argv[i] ) == "--benchmark-visible"; benchmarkInactive |= std::string_view( argv[i] ) == "--benchmark-inactive"; if ( std::string_view( argv[i] ) == "--benchmark-items" && i + 1 < argc ) benchmarkItems = std::max( 1, std::strtoull( argv[++i], nullptr, 10 ) ); @@ -24,11 +26,12 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { } UIApplication::Settings settings; settings.threadPool = ThreadPool::createShared( 1 ); - UIApplication app( { 1280, 720, "eepp - Accessibility", - static_cast( benchmark || benchmarkInactive - ? WindowStyle::Default | WindowStyle::Hidden - : WindowStyle::Default ) }, - settings ); + UIApplication app( + { 1280, 720, "eepp - Accessibility", + static_cast( ( benchmark && !benchmarkVisible ) || benchmarkInactive + ? WindowStyle::Default | WindowStyle::Hidden + : WindowStyle::Default ) }, + settings ); multiWindow |= closePrimary; if ( closePrimary ) app.setQuitPolicy( UIApplication::QuitPolicy::OnLastWindowClosed ); @@ -37,6 +40,7 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { + @@ -62,6 +66,8 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { + + @@ -110,6 +116,18 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { content->find( "inspect" )->onClick( [scene]( auto ) { EE::UI::Tools::UIWidgetInspector::create( scene ); } ); + auto metadataButton = content->find( "update-metadata" ); + metadataButton->onClick( [metadataButton]( auto ) { + metadataButton->setAccessibilityLabel( "Updated metadata control" ); + metadataButton->setAccessibilityDescription( "Updated accessibility help" ); + } ); + content->find( "replace-projects" )->onClick( [table]( auto ) { + auto replacement = Models::ItemPairListOwnerModel::create( + { { "Gamma", "Updated" } } ); + replacement->setColumnName( 0, "Project" ); + replacement->setColumnName( 1, "Status" ); + table->setModel( replacement ); + } ); content->find( "open-window" )->onClick( [&app]( auto ) { auto* extra = app.createWindow( { 400, 180, "eepp - Accessibility Dynamic" } ); if ( !extra ) diff --git a/src/tests/unit_tests/accessibility_macos_tests.mm b/src/tests/unit_tests/accessibility_macos_tests.mm new file mode 100644 index 000000000..cbb28945c --- /dev/null +++ b/src/tests/unit_tests/accessibility_macos_tests.mm @@ -0,0 +1,177 @@ +#include "utest.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define FileInfo AppleFileInfo +#define Rect AppleRect +#define TextRange AppleTextRange +#import +#undef TextRange +#undef Rect +#undef FileInfo +#undef BSD + +using namespace EE; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::Window; + +namespace { + +class AccessibilityTestApplication : public UIApplication { + public: + using UIApplication::UIApplication; + + void destroyPendingWindows() { processPendingWindowDestruction(); } +}; + +NSObject* semanticRootForWindow( NSWindow* window ) { + for ( NSObject* child in [[window contentView] accessibilityChildren] ) { + if ( [[child accessibilityIdentifier] hasPrefix:@"eepp.window."] ) + return child; + } + return nil; +} + +} // namespace + +UTEST( AccessibilityMacOS, ApplicationWindowBridgeIdentityActionTextAndLifecycle ) { + AccessibilityTestApplication app( WindowSettings( 320, 240, "eepp - macOS Accessibility Test" ), + UIApplication::Settings( Sys::getProcessPath() ) ); + if ( !app.getUI() ) + UTEST_SKIP( "a logged-in macOS graphical session is required" ); + app.setQuitPolicy( UIApplication::QuitPolicy::OnLastWindowClosed ); + auto scene = app.getUI(); + auto manager = scene->getAccessibilityManager(); + bool pressed = false; + auto button = UIPushButton::New(); + button->setText( "Press me" ); + button->onClick( [&pressed]( auto ) { pressed = true; } ); + button->setParent( scene->getRoot() ); + auto checkbox = UICheckBox::New(); + checkbox->setText( "Enable feature" ); + checkbox->setParent( scene->getRoot() ); + auto textInput = UITextInput::New(); + textInput->setText( String::fromUtf8( std::string( "A😀é" ) ) ); + textInput->getDocument().setSelection( { 0, 1 }, { 0, 4 } ); + textInput->setParent( scene->getRoot() ); + auto textEdit = UITextEdit::New(); + textEdit->setText( String::fromUtf8( std::string( "one\n😀x" ) ) ); + textEdit->getDocument().setSelection( { 0, 1 }, { 1, 1 } ); + textEdit->setParent( scene->getRoot() ); + auto table = UITableView::New(); + table->setModel( Models::ItemPairListOwnerModel::create( + { { "Alpha", "One" }, { "Beta", "Two" } } ) ); + table->setParent( scene->getRoot() ); + manager->update(); + + auto secondary = + app.createWindow( WindowSettings( 240, 160, "eepp - Secondary Accessibility Test" ) ); + ASSERT_TRUE( secondary != nullptr ); + auto secondaryManager = secondary->getAccessibilityManager(); + UIPushButton* secondaryButton = nullptr; + { + auto context = secondary->makeCurrent(); + secondaryButton = UIPushButton::New(); + secondaryButton->setText( "Secondary" ); + secondaryButton->setParent( secondary->getRoot() ); + secondaryManager->update(); + } + + EXPECT_TRUE( NSApp != nil ); + NSWindow* window = reinterpret_cast( scene->getWindow()->getWindowHandler() ); + EXPECT_TRUE( window != nil ); + EXPECT_TRUE( [window.accessibilityRole isEqualToString:NSAccessibilityWindowRole] ); + NSObject* windowRoot = semanticRootForWindow( window ); + EXPECT_TRUE( windowRoot != nil ); + [windowRoot retain]; + + NSArray* children = [windowRoot accessibilityChildren]; + EXPECT_TRUE( children != nil ); + EXPECT_EQ( children.count, 5u ); + NSObject* element = children.firstObject; + NSObject* sameElement = [windowRoot.accessibilityChildren firstObject]; + EXPECT_TRUE( element == sameElement ); + EXPECT_TRUE( [element.accessibilityRole isEqualToString:NSAccessibilityButtonRole] ); + EXPECT_TRUE( element.accessibilityIdentifier.length > 0 ); + EXPECT_TRUE( [element accessibilityPerformPress] ); + EXPECT_TRUE( pressed ); + scene->update( Time::Zero ); + element.accessibilityFocused = YES; + EXPECT_TRUE( button->hasFocus() ); + scene->update( Time::Zero ); + EXPECT_TRUE( [NSApp accessibilityApplicationFocusedUIElement] == element ); + EXPECT_TRUE( [NSApp accessibilityFocusedWindow] == window ); + + NSObject* checkboxElement = children[1]; + EXPECT_TRUE( [checkboxElement.accessibilityRole isEqualToString:NSAccessibilityCheckBoxRole] ); + EXPECT_FALSE( checkbox->isChecked() ); + EXPECT_TRUE( [checkboxElement accessibilityPerformPress] ); + EXPECT_TRUE( checkbox->isChecked() ); + + NSObject* textElement = children[2]; + EXPECT_TRUE( [textElement.accessibilityRole isEqualToString:NSAccessibilityTextFieldRole] ); + EXPECT_EQ( textElement.accessibilitySelectedTextRange.location, 1u ); + EXPECT_EQ( textElement.accessibilitySelectedTextRange.length, 4u ); + textElement.accessibilitySelectedTextRange = NSMakeRange( 3, 2 ); + auto textInfo = manager->getNodeInfo( manager->getNodeRef( textInput ) ).text; + EXPECT_EQ( textInfo.selectionStart, 2 ); + EXPECT_EQ( textInfo.selectionEnd, 4 ); + textInput->setMode( UITextInput::TextInputMode::Password ); + EXPECT_TRUE( + [textElement.accessibilitySubrole isEqualToString:NSAccessibilitySecureTextFieldSubrole] ); + EXPECT_TRUE( textElement.isAccessibilityProtectedContent ); + + NSObject* multilineElement = children[3]; + EXPECT_TRUE( + [multilineElement.accessibilityRole isEqualToString:NSAccessibilityTextFieldRole] ); + EXPECT_EQ( multilineElement.accessibilityNumberOfCharacters, 7 ); + EXPECT_EQ( multilineElement.accessibilitySelectedTextRange.location, 1u ); + EXPECT_EQ( multilineElement.accessibilitySelectedTextRange.length, 5u ); + multilineElement.accessibilitySelectedTextRange = NSMakeRange( 4, 2 ); + auto multilineInfo = manager->getNodeInfo( manager->getNodeRef( textEdit ) ).text; + EXPECT_EQ( multilineInfo.selectionStart, 4 ); + EXPECT_EQ( multilineInfo.selectionEnd, 5 ); + + NSObject* tableElement = children[4]; + NSArray* rows = tableElement.accessibilityRows; + EXPECT_EQ( rows.count, 2u ); + NSObject* staleRow = rows.firstObject; + EXPECT_TRUE( [staleRow.accessibilityValue isEqualToString:@"Alpha"] ); + table->setModel( Models::ItemPairListOwnerModel::create( + { { "Gamma", "Three" } } ) ); + EXPECT_FALSE( staleRow.isAccessibilityElement ); + NSArray* replacementRows = tableElement.accessibilityRows; + EXPECT_EQ( replacementRows.count, 1u ); + EXPECT_TRUE( replacementRows.firstObject != staleRow ); + EXPECT_TRUE( [[replacementRows.firstObject accessibilityValue] isEqualToString:@"Gamma"] ); + + AccessibilityNodeRef buttonRef = manager->getNodeRef( button ); + eeDelete( button ); + EXPECT_FALSE( manager->isValid( buttonRef ) ); + EXPECT_FALSE( [element isAccessibilityElement] ); + + app.closeWindow( scene->getWindow() ); + app.destroyPendingWindows(); + EXPECT_EQ( app.getWindowCount(), 1u ); + EXPECT_TRUE( app.getUI() == nullptr ); + EXPECT_FALSE( [windowRoot isAccessibilityElement] ); + NSWindow* survivingWindow = + reinterpret_cast( secondary->getWindow()->getWindowHandler() ); + EXPECT_TRUE( survivingWindow != nil ); + EXPECT_TRUE( [survivingWindow.accessibilityTitle + isEqualToString:@"eepp - Secondary Accessibility Test"] ); + NSObject* survivingRoot = semanticRootForWindow( survivingWindow ); + EXPECT_TRUE( survivingRoot != nil ); + EXPECT_EQ( survivingRoot.accessibilityChildren.count, 1u ); + EXPECT_TRUE( secondaryManager->isValid( secondaryManager->getNodeRef( secondaryButton ) ) ); + [windowRoot release]; +} diff --git a/src/tests/unit_tests/accessibility_tests.cpp b/src/tests/unit_tests/accessibility_tests.cpp index aaf5efff0..131c9fe11 100644 --- a/src/tests/unit_tests/accessibility_tests.cpp +++ b/src/tests/unit_tests/accessibility_tests.cpp @@ -118,11 +118,30 @@ UTEST( Accessibility, LiveProjectionIdentityActionsAndInvalidation ) { textInput->setText( "Project" ); textInput->getDocument().setSelection( { 0, 3 } ); textInput->setParent( scene->getRoot() ); - auto textInputInfo = manager->getNodeInfo( manager->getNodeRef( textInput ) ); + auto textInputRef = manager->getNodeRef( textInput ); + auto textInputInfo = manager->getNodeInfo( textInputRef ); EXPECT_TRUE( textInputInfo.text.valid ); EXPECT_EQ( textInputInfo.text.caretOffset, 3 ); EXPECT_EQ( textInputInfo.text.selectionStart, 3 ); EXPECT_EQ( textInputInfo.text.selectionEnd, 3 ); + textInput->setText( String::fromUtf8( std::string( "A😀é" ) ) ); + textInput->getDocument().setSelection( { 0, 4 } ); + textInputInfo = manager->getNodeInfo( textInputRef ); + EXPECT_EQ( textInputInfo.text.caretOffset, 4 ); + EXPECT_TRUE( textInputInfo.actions & + accessibilityActionMask( AccessibilityAction::SetTextSelection ) ); + EXPECT_TRUE( manager->performAction( + textInputRef, { AccessibilityAction::SetTextSelection, String( "2:4" ) } ) ); + textInputInfo = manager->getNodeInfo( textInputRef ); + EXPECT_EQ( textInputInfo.text.selectionStart, 2 ); + EXPECT_EQ( textInputInfo.text.selectionEnd, 4 ); + textInput->setMode( UITextInput::TextInputMode::Password ); + textInputInfo = manager->getNodeInfo( textInputRef ); + EXPECT_TRUE( static_cast( textInputInfo.states ) & + static_cast( AccessibilityState::Protected ) ); + EXPECT_FALSE( textInputInfo.text.valid ); + EXPECT_FALSE( textInputInfo.actions & + accessibilityActionMask( AccessibilityAction::SetTextSelection ) ); UITextEdit* textEdit = UITextEdit::New(); textEdit->setParent( scene->getRoot() ); @@ -133,6 +152,24 @@ UTEST( Accessibility, LiveProjectionIdentityActionsAndInvalidation ) { EXPECT_TRUE( manager->getNodeInfo( textEditRef ).value == String( "Notes" ) ); EXPECT_TRUE( manager->getNodeInfo( textEditRef ).name.empty() ); EXPECT_TRUE( manager->getNodeInfo( textEditRef, false ).value.empty() ); + textEdit->setText( "first\nsecond" ); + textEdit->getDocument().setSelection( { 0, 2 }, { 1, 3 } ); + auto textEditInfo = manager->getNodeInfo( textEditRef ); + EXPECT_TRUE( textEditInfo.text.valid ); + EXPECT_EQ( textEditInfo.text.selectionStart, 2 ); + EXPECT_EQ( textEditInfo.text.selectionEnd, 9 ); + EXPECT_TRUE( manager->performAction( + textEditRef, { AccessibilityAction::SetTextSelection, String( "6:8" ) } ) ); + auto textEditSelection = textEdit->getDocument().getSelection( true ); + EXPECT_EQ( textEditSelection.start().line(), 1 ); + EXPECT_EQ( textEditSelection.start().column(), 0 ); + EXPECT_EQ( textEditSelection.end().line(), 1 ); + EXPECT_EQ( textEditSelection.end().column(), 2 ); + EXPECT_TRUE( manager->performAction( + textEditRef, { AccessibilityAction::SetTextSelection, String( "12:12" ) } ) ); + textEditSelection = textEdit->getDocument().getSelection( true ); + EXPECT_EQ( textEditSelection.start().line(), 1 ); + EXPECT_EQ( textEditSelection.start().column(), 6 ); textEdit->setLocked( true ); EXPECT_TRUE( static_cast( manager->getNodeInfo( textEditRef ).states ) & static_cast( AccessibilityState::ReadOnly ) );