mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-27 00:28:34 +03:00
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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -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 )
|
||||
|
||||
|
||||
+6
-1
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<T>(
|
||||
_ 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.traversals {
|
||||
let enumerationStart = DispatchTime.now().uptimeNanoseconds
|
||||
let rows = elementsAttribute(list, kAXRowsAttribute as CFString)
|
||||
rowEnumerationMilliseconds.append(elapsedMilliseconds(since: enumerationStart))
|
||||
guard rows.count == arguments.items else {
|
||||
try fail("expected \(arguments.items) rows, received \(rows.count)")
|
||||
}
|
||||
for row in rows {
|
||||
let queryStart = DispatchTime.now().uptimeNanoseconds
|
||||
guard stringAttribute(row, kAXValueAttribute as CFString) != nil else {
|
||||
try fail("benchmark row did not expose a value")
|
||||
}
|
||||
valueQueryMicroseconds.append(elapsedMilliseconds(since: queryStart) * 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
let traversalStart = DispatchTime.now().uptimeNanoseconds
|
||||
let allElements = descendants(window, limit: searchLimit)
|
||||
let completeTraversalMilliseconds = elapsedMilliseconds(since: traversalStart)
|
||||
guard allElements.count >= 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)
|
||||
}
|
||||
@@ -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<NotificationRecorder>.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<T>(
|
||||
_ 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)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ std::unique_ptr<AccessibilityBackend> 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<AccessibilityBackend> createAccessibilityBackend( AccessibilityManager& ) {
|
||||
return createNullAccessibilityBackend();
|
||||
}
|
||||
|
||||
@@ -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<AccessibilityBackend> createAccessibilityBackend( AccessibilityManager& manager );
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
#include <eepp/ui/uiwindow.hpp>
|
||||
#include <eepp/window/window.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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<Int64>( document.linesCount() ); ++line )
|
||||
offset += static_cast<Int64>( document.line( static_cast<size_t>( line ) ).size() );
|
||||
offset += position.column();
|
||||
return static_cast<Int32>( std::max<Int64>( 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<Int32>( 0, offset );
|
||||
for ( size_t line = 0; line < document.linesCount(); ++line ) {
|
||||
const Int64 length = static_cast<Int64>( document.line( line ).size() );
|
||||
if ( line + 1 == document.linesCount() )
|
||||
return { static_cast<Int64>( line ), std::min( remaining, length ) };
|
||||
if ( remaining < length )
|
||||
return { static_cast<Int64>( 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<const UITextInput*>( widget )->getMode() ==
|
||||
UITextInput::TextInputMode::Password )
|
||||
return {};
|
||||
const auto& document = static_cast<const UITextInput*>( widget )->getDocument();
|
||||
auto selection = document.getSelection( true );
|
||||
return { static_cast<Int32>( document.getSelection().end().column() ),
|
||||
static_cast<Int32>( selection.start().column() ),
|
||||
static_cast<Int32>( selection.end().column() ), true };
|
||||
if ( widget->isType( UI_TYPE_TEXTEDIT ) )
|
||||
return getDocumentText( static_cast<const UITextEdit*>( widget )->getDocument() );
|
||||
if ( widget->isType( UI_TYPE_TEXTINPUT ) ) {
|
||||
const auto* input = static_cast<const UITextInput*>( 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<const UITextInput*>( widget )->isEditingAllowed()
|
||||
? AccessibilityState::Editable
|
||||
: AccessibilityState::ReadOnly;
|
||||
if ( static_cast<const UITextInput*>( 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<const UITextInput*>( widget )->isEditingAllowed() )
|
||||
actions |= accessibilityActionMask( AccessibilityAction::SetText );
|
||||
if ( ( widget->isType( UI_TYPE_TEXTINPUT ) &&
|
||||
static_cast<const UITextInput*>( widget )->isEditingAllowed() &&
|
||||
static_cast<const UITextInput*>( widget )->getMode() !=
|
||||
UITextInput::TextInputMode::Password ) ||
|
||||
( widget->isType( UI_TYPE_TEXTEDIT ) &&
|
||||
!static_cast<const UITextEdit*>( widget )->isLocked() ) )
|
||||
actions |= accessibilityActionMask( AccessibilityAction::SetTextSelection );
|
||||
if ( widget->isType( UI_TYPE_COMBOBOX ) ) {
|
||||
actions |= accessibilityActionMask(
|
||||
static_cast<const UIComboBox*>( widget )->getListBox()->isVisible()
|
||||
@@ -297,6 +348,19 @@ bool AccessibilityWidgetResolver::performAction( UIWidget* widget,
|
||||
static_cast<UITextEdit*>( 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<UITextEdit*>( widget )->getDocument()
|
||||
: static_cast<UITextInput*>( 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 ) ) {
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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& ) {}
|
||||
|
||||
@@ -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<size_t>( 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<Uint32>( benchmark || benchmarkInactive
|
||||
? WindowStyle::Default | WindowStyle::Hidden
|
||||
: WindowStyle::Default ) },
|
||||
settings );
|
||||
UIApplication app(
|
||||
{ 1280, 720, "eepp - Accessibility",
|
||||
static_cast<Uint32>( ( 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 ) {
|
||||
<vbox lw="mp" lh="mp" padding="16dp" padding="8dp">
|
||||
<TextView text="Accessibility settings" font-size="22dp" />
|
||||
<TextInput id="project-name" aria-label="Project name" text="eepp" lw="mp" />
|
||||
<TextInput id="account-password" aria-label="Account password" text="secret" input-mode="password" lw="mp" />
|
||||
<TextEdit id="description" aria-label="Description" aria-description="Multiline editor. Press Mod Tab to move to the next control." text="Accessible UI" lw="mp" lh="80dp" />
|
||||
<CheckBox id="autosave" aria-label="Enable autosave" text="Enable autosave" />
|
||||
<RadioButton id="light-theme" aria-label="Light Theme" text="Light theme" />
|
||||
@@ -62,6 +66,8 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
|
||||
<hbox lw="mp" lh="wc" margin-top="8dp">
|
||||
<PushButton id="save" text="Save settings" />
|
||||
<PushButton id="inspect" text="Open inspector" />
|
||||
<PushButton id="update-metadata" text="Update accessible metadata" />
|
||||
<PushButton id="replace-projects" text="Replace project model" />
|
||||
<PushButton id="open-window" text="Open accessibility window" />
|
||||
</hbox>
|
||||
<TextView id="status" text="Ready" />
|
||||
@@ -110,6 +116,18 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
|
||||
content->find<UIPushButton>( "inspect" )->onClick( [scene]( auto ) {
|
||||
EE::UI::Tools::UIWidgetInspector::create( scene );
|
||||
} );
|
||||
auto metadataButton = content->find<UIPushButton>( "update-metadata" );
|
||||
metadataButton->onClick( [metadataButton]( auto ) {
|
||||
metadataButton->setAccessibilityLabel( "Updated metadata control" );
|
||||
metadataButton->setAccessibilityDescription( "Updated accessibility help" );
|
||||
} );
|
||||
content->find<UIPushButton>( "replace-projects" )->onClick( [table]( auto ) {
|
||||
auto replacement = Models::ItemPairListOwnerModel<std::string, std::string>::create(
|
||||
{ { "Gamma", "Updated" } } );
|
||||
replacement->setColumnName( 0, "Project" );
|
||||
replacement->setColumnName( 1, "Status" );
|
||||
table->setModel( replacement );
|
||||
} );
|
||||
content->find<UIPushButton>( "open-window" )->onClick( [&app]( auto ) {
|
||||
auto* extra = app.createWindow( { 400, 180, "eepp - Accessibility Dynamic" } );
|
||||
if ( !extra )
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#include "utest.h"
|
||||
|
||||
#include <eepp/ui/accessibility/accessibilitymanager.hpp>
|
||||
#include <eepp/ui/models/itemlistmodel.hpp>
|
||||
#include <eepp/ui/uiapplication.hpp>
|
||||
#include <eepp/ui/uicheckbox.hpp>
|
||||
#include <eepp/ui/uipushbutton.hpp>
|
||||
#include <eepp/ui/uiscenenode.hpp>
|
||||
#include <eepp/ui/uitableview.hpp>
|
||||
#include <eepp/ui/uitextedit.hpp>
|
||||
#include <eepp/ui/uitextinput.hpp>
|
||||
|
||||
#define FileInfo AppleFileInfo
|
||||
#define Rect AppleRect
|
||||
#define TextRange AppleTextRange
|
||||
#import <AppKit/AppKit.h>
|
||||
#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<NSAccessibility>* semanticRootForWindow( NSWindow* window ) {
|
||||
for ( NSObject<NSAccessibility>* 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<std::string, std::string>::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<NSWindow*>( scene->getWindow()->getWindowHandler() );
|
||||
EXPECT_TRUE( window != nil );
|
||||
EXPECT_TRUE( [window.accessibilityRole isEqualToString:NSAccessibilityWindowRole] );
|
||||
NSObject<NSAccessibility>* windowRoot = semanticRootForWindow( window );
|
||||
EXPECT_TRUE( windowRoot != nil );
|
||||
[windowRoot retain];
|
||||
|
||||
NSArray* children = [windowRoot accessibilityChildren];
|
||||
EXPECT_TRUE( children != nil );
|
||||
EXPECT_EQ( children.count, 5u );
|
||||
NSObject<NSAccessibility>* element = children.firstObject;
|
||||
NSObject<NSAccessibility>* 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<NSAccessibility>* checkboxElement = children[1];
|
||||
EXPECT_TRUE( [checkboxElement.accessibilityRole isEqualToString:NSAccessibilityCheckBoxRole] );
|
||||
EXPECT_FALSE( checkbox->isChecked() );
|
||||
EXPECT_TRUE( [checkboxElement accessibilityPerformPress] );
|
||||
EXPECT_TRUE( checkbox->isChecked() );
|
||||
|
||||
NSObject<NSAccessibility>* 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<NSAccessibility>* 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<NSAccessibility>* tableElement = children[4];
|
||||
NSArray* rows = tableElement.accessibilityRows;
|
||||
EXPECT_EQ( rows.count, 2u );
|
||||
NSObject<NSAccessibility>* staleRow = rows.firstObject;
|
||||
EXPECT_TRUE( [staleRow.accessibilityValue isEqualToString:@"Alpha"] );
|
||||
table->setModel( Models::ItemPairListOwnerModel<std::string, std::string>::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<NSWindow*>( secondary->getWindow()->getWindowHandler() );
|
||||
EXPECT_TRUE( survivingWindow != nil );
|
||||
EXPECT_TRUE( [survivingWindow.accessibilityTitle
|
||||
isEqualToString:@"eepp - Secondary Accessibility Test"] );
|
||||
NSObject<NSAccessibility>* survivingRoot = semanticRootForWindow( survivingWindow );
|
||||
EXPECT_TRUE( survivingRoot != nil );
|
||||
EXPECT_EQ( survivingRoot.accessibilityChildren.count, 1u );
|
||||
EXPECT_TRUE( secondaryManager->isValid( secondaryManager->getNodeRef( secondaryButton ) ) );
|
||||
[windowRoot release];
|
||||
}
|
||||
@@ -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<Uint64>( textInputInfo.states ) &
|
||||
static_cast<Uint64>( 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<Uint64>( manager->getNodeInfo( textEditRef ).states ) &
|
||||
static_cast<Uint64>( AccessibilityState::ReadOnly ) );
|
||||
|
||||
Reference in New Issue
Block a user