From c59920c72cf0be2c018747fab9e2c201b06b6fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 12 Oct 2026 23:33:46 -0300 Subject: [PATCH] feat(a11y): expose basic HTML accessibility semantics - Expose source text, heading levels, links, lists/items and image alt text - Keep formatting wrappers transparent and nested controls accessible - Route link activation through existing navigation and interception - Project passive fragment focus to links while preserving control focus - Notify clients of text, href, alt text and semantic role changes - Map HTML semantics to AT-SPI, UIA and macOS accessibility APIs - Avoid advertising selection capabilities for static HTML lists - Preserve dormant allocation behavior without new per-widget caches - Fix MinGW harness compatibility with both TreeScope enum spellings - Add unit/native regression coverage and update accessibility docs --- docs/articles/ui_accessibility.md | 29 +- .../articles/ui_accessibility_architecture.md | 18 +- .../eepp/ui/accessibility/accessibility.hpp | 7 + .../ui/accessibility/accessibilitymanager.hpp | 2 + .../accessibilitywidgetresolver.hpp | 7 + include/eepp/ui/uihelper.hpp | 2 + include/eepp/ui/uitextspan.hpp | 7 + include/eepp/ui/uitextview.hpp | 7 + include/eepp/ui/uiwidget.hpp | 3 + projects/scripts/test_atspi.py | 31 +- .../accessibilitybackendatspievents.cpp | 15 +- .../accessibilitybackendatspimethods.cpp | 21 +- .../accessibilitybackendatspitree.cpp | 11 +- .../accessibilitybackendmacos.mm | 15 +- .../accessibility/accessibilitybackenduia.cpp | 5 + .../accessibilitybackenduiaprovider.cpp | 20 +- .../ui/accessibility/accessibilitymanager.cpp | 16 + .../accessibilitywidgetresolver.cpp | 154 +++++++++- src/eepp/ui/uihtmlimage.cpp | 5 + src/eepp/ui/uirichtext.cpp | 3 + src/eepp/ui/uitextnode.cpp | 7 +- src/eepp/ui/uitextspan.cpp | 40 ++- src/eepp/ui/uitextview.cpp | 27 +- src/eepp/ui/uiwidget.cpp | 8 +- .../ui_accessibility/ui_accessibility.cpp | 23 ++ .../unit_tests/accessibility_html_tests.cpp | 283 ++++++++++++++++++ .../unit_tests/accessibility_macos_tests.mm | 42 +++ src/tests/windows_accessibility/main.cpp | 18 +- .../windows_accessibility/provider_tests.cpp | 62 ++++ 29 files changed, 839 insertions(+), 49 deletions(-) create mode 100644 src/tests/unit_tests/accessibility_html_tests.cpp diff --git a/docs/articles/ui_accessibility.md b/docs/articles/ui_accessibility.md index ca9fa5580..a86623784 100644 --- a/docs/articles/ui_accessibility.md +++ b/docs/articles/ui_accessibility.md @@ -388,16 +388,37 @@ must not depend on rendering, and native queries or notifications must never sta ## Current scope +### Basic HTML semantics + +HTML and Markdown content rendered by eepp now exposes source text, `h1`–`h6` headings with their +levels, anchors with nonempty `href`, and `ul`/`ol` lists with their `li` items. Images with nonempty +alternative text expose that text. Anonymous form controls retain their native widget semantics. + +Ordinary block and formatting wrappers are transparent. Text is exposed through the text nodes +and spans that own it, rather than copying each enclosing paragraph's flattened rendering. Headings +and links have names derived from their visible textual contents; they remain containers so nested +links and controls are reachable. Input values, including passwords, never contribute to those +names. `aria-hidden` content is excluded from traversal and descendant-name calculation. + +Link Press/Invoke actions use the existing navigation path, including WebView/Markdown interception +and relative URI resolution. Merely focusing a link never activates it. Focus on a passive text or +image fragment inside a link is reported on the link; embedded interactive controls keep their own +focus. Runtime `setHref()` continues to preserve the application's explicit Tab-focus policy. + +This is basic object navigation, not a complete browser/document accessibility implementation. +Static HTML text is exposed by name/value; it does not advertise unimplemented Text/Hypertext +interfaces, document-wide cursor navigation, formatting ranges or cross-element text selection. +HTML lists are not selectable model views and do not advertise native Selection patterns. + The first implementation focuses on the semantics used by standard eepp desktop widgets. Native platform APIs contain additional advanced interfaces that are not yet exposed uniformly, including some rich-document formatting, complex table metadata, and container scroll patterns (scroll -position and paging, as opposed to scrolling an element into view). HTML content (`UIRichText`, -headings, links, lists) is not yet exposed as semantic elements. Relations are used to compute names -and descriptions but are not published as native relation sets. +position and paging, as opposed to scrolling an element into view). Relations are used to compute +names and descriptions but are not published as native relation sets. Do not advertise a native pattern or capability unless its required behavior is implemented. Assistive technologies can still navigate the semantic hierarchy and operate the supported controls when an optional advanced pattern is absent. -The implementation currently targets Linux, macOS, and Windows desktop applications. Mobile and +The implementation currently targets Linux/FreeBSD, macOS, and Windows desktop applications. Mobile and web accessibility bridges are outside the current scope. diff --git a/docs/articles/ui_accessibility_architecture.md b/docs/articles/ui_accessibility_architecture.md index 753bb25dd..dc671c656 100644 --- a/docs/articles/ui_accessibility_architecture.md +++ b/docs/articles/ui_accessibility_architecture.md @@ -503,7 +503,7 @@ widget where its semantic state changes (for example `StateChanged` when it is c contains implementation children, make sure they resolve to the widget (role `None`, hidden, or routed in `getEventTarget()`), and add a unit test in `accessibility_tests.cpp`. -**Expose a custom widget.** Selectively override `UIWidget`'s virtual role, name, description, +**Expose a custom widget.** Selectively override `UIWidget`'s virtual role, heading level, name, description, value, range, state, actions or action-execution methods. Their defaults still use the central resolver. Effective semantic consumers (including leaf ownership, focus projection and auditing) use the virtual methods; default resolver implementations must not call their own corresponding @@ -514,6 +514,19 @@ keeps the existing one-hop own-name rule when a target's override calls the base so default relation chains and cycles do not recurse. It retains no widget state between queries and allocates nothing. +HTML source text is resolved without building the layout's flattened `RichText` or a document cache. +Block and formatting wrappers are transparent; links/headings are non-leaf semantic containers. +Their names concatenate textual descendants while excluding hidden content and native input +values. Text, href and alt mutations notify queried elements and their named ancestors only after +the native-client activity gate. `RoleChanged` preserves a retained element's identity when its +implicit role changes. Heading depth uses existing `AccessibilityNodeInfo` tail padding and is also +available through the scalar `getNodeHeadingLevel()` query. + +Selection capability is explicit through `AccessibilityState::SelectionContainer`, not inferred +from a list role alone. Built-in model views and tab widgets provide it through the resolver; +custom selection containers should include it in their effective state. Non-interactive HTML lists +retain list semantics but do not advertise AT-SPI Selection or UIA SelectionProvider. + **Add a role.** Add it to `AccessibilityRole` (`accessibility.hpp`), map it in the resolver, then in every backend: `role()` and `roleName()` in `accessibilitybackendatspitree.cpp`, `controlType()` in `accessibilitybackenduiaprovider.cpp`, and `nativeRole()` / `nativeSubrole()` in @@ -694,5 +707,6 @@ need native-host evidence; queued UIA dispatch profiling is available for that i calls or their timeouts; whichever owner releases it last destroys it. - Text-index structural shifts and lazy rebuilds remain O(lines). Native UTF-16 snapshot cache misses remain O(document size), and the AT-SPI time budget cannot interrupt one expensive query. -- HTML content (`UIRichText`, headings, links, lists) is not exposed as semantic elements. +- HTML object semantics cover source text, headings/levels, links, lists/items and image alt text, + but not document-wide Text/Hypertext interfaces, rich formatting or cross-element text selection. - Only one cell editor (the view's active editor) is exposed inside a model view. diff --git a/include/eepp/ui/accessibility/accessibility.hpp b/include/eepp/ui/accessibility/accessibility.hpp index cd6125a87..7306f740a 100644 --- a/include/eepp/ui/accessibility/accessibility.hpp +++ b/include/eepp/ui/accessibility/accessibility.hpp @@ -67,6 +67,8 @@ enum class AccessibilityRole : Uint8 { Cell, Tree, TreeItem, + Link, + Heading, }; enum class AccessibilityState : Uint64 { @@ -84,6 +86,8 @@ enum class AccessibilityState : Uint64 { Active = 1ull << 10, Protected = 1ull << 11, MultiLine = 1ull << 12, + /** Offers selection of children, unlike a non-interactive HTML list. */ + SelectionContainer = 1ull << 13, }; inline AccessibilityState operator|( AccessibilityState left, AccessibilityState right ) { @@ -135,6 +139,7 @@ enum class AccessibilityEvent : Uint8 { VisibilityChanged, SelectionChanged, BoundsChanged, + RoleChanged, }; /** A keyboard shortcut, both as displayed and decomposed for native APIs that want their own @@ -221,6 +226,8 @@ struct AccessibilityNodeInfo { AccessibilityActions actions{ 0 }; AccessibilityRole role{ AccessibilityRole::None }; bool boundsValid{ false }; + /** 1..6 for HTML headings; zero when unspecified. Uses existing tail padding. */ + Uint8 headingLevel{ 0 }; }; struct AccessibilityAnnouncement { diff --git a/include/eepp/ui/accessibility/accessibilitymanager.hpp b/include/eepp/ui/accessibility/accessibilitymanager.hpp index 29a218d5d..b5de6089b 100644 --- a/include/eepp/ui/accessibility/accessibilitymanager.hpp +++ b/include/eepp/ui/accessibility/accessibilitymanager.hpp @@ -44,6 +44,8 @@ class EE_API AccessibilityManager { /** Scalar queries avoid constructing names, values, text offsets or relation caches. */ AccessibilityRole getNodeRole( AccessibilityNodeRef ref ) const; + Uint8 getNodeHeadingLevel( AccessibilityNodeRef ref ) const; + AccessibilityState getNodeState( AccessibilityNodeRef ref ) const; AccessibilityNodeInfo getNodeInfo( AccessibilityNodeRef ref, bool includeValue = true ) const; diff --git a/include/eepp/ui/accessibility/accessibilitywidgetresolver.hpp b/include/eepp/ui/accessibility/accessibilitywidgetresolver.hpp index a7340a383..1f1fe0909 100644 --- a/include/eepp/ui/accessibility/accessibilitywidgetresolver.hpp +++ b/include/eepp/ui/accessibility/accessibilitywidgetresolver.hpp @@ -2,6 +2,7 @@ #define EE_UI_ACCESSIBILITY_ACCESSIBILITYWIDGETRESOLVER_HPP #include +#include namespace EE { namespace Scene { class Node; @@ -27,6 +28,12 @@ class EE_API AccessibilityWidgetResolver { public: static AccessibilityRole getRole( const UIWidget* widget ); + static Uint8 getHeadingLevel( const UIWidget* widget ); + + /** Updates queried HTML text and named ancestors. Called only after the activity gate. */ + static void onHTMLTextChanged( UIWidget* widget, + std::optional previousRole = std::nullopt ); + /** A readable role name for tools and logs, for example "CheckBox". */ static const char* getRoleName( AccessibilityRole role ); diff --git a/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index 7d3d44cd2..2bee2d18b 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -149,6 +149,8 @@ enum UINodeType { UI_TYPE_DATEPICKER, UI_TYPE_TIMEPICKER, UI_TYPE_DATETIMEPICKER, + UI_TYPE_ANCHOR, + UI_TYPE_ANCHORSPAN, UI_TYPE_MODULES = 10000, UI_TYPE_TERMINAL = 10001, UI_TYPE_USER = 200000, diff --git a/include/eepp/ui/uitextspan.hpp b/include/eepp/ui/uitextspan.hpp index e162c7063..ccec0397f 100644 --- a/include/eepp/ui/uitextspan.hpp +++ b/include/eepp/ui/uitextspan.hpp @@ -178,6 +178,13 @@ class EE_API UIAnchorSpan : public UITextSpan { public: static UIAnchorSpan* New(); + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; + + /** Activates through the scene's navigation/interception path. */ + bool activateLink( Uint32 modifiers = 0, Uint32 mouseButtons = 0 ); + virtual void loadFromXmlNode( const pugi::xml_node& node ); virtual bool applyProperty( const StyleSheetProperty& attribute ); diff --git a/include/eepp/ui/uitextview.hpp b/include/eepp/ui/uitextview.hpp index 4f423cd27..48f092aeb 100644 --- a/include/eepp/ui/uitextview.hpp +++ b/include/eepp/ui/uitextview.hpp @@ -231,6 +231,13 @@ class EE_API UIAnchor : public UITextView { static UIAnchor* NewA(); + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; + + /** Uses the same URL-opening behavior as mouse/keyboard activation. */ + bool activateLink(); + virtual void loadFromXmlNode( const pugi::xml_node& node ); virtual bool applyProperty( const StyleSheetProperty& attribute ); diff --git a/include/eepp/ui/uiwidget.hpp b/include/eepp/ui/uiwidget.hpp index 6fe938ebb..2b18a9575 100644 --- a/include/eepp/ui/uiwidget.hpp +++ b/include/eepp/ui/uiwidget.hpp @@ -87,6 +87,9 @@ class EE_API UIWidget : public UINode { /** Override selectively for custom semantics; defaults use AccessibilityWidgetResolver. */ virtual AccessibilityRole getAccessibilityRole() const; + /** Heading depth, or zero when unspecified. Default resolves h1..h6 without stored metadata. */ + virtual Uint8 getAccessibilityHeadingLevel() const; + virtual String getAccessibilityName() const; virtual String getAccessibilityDescription() const; diff --git a/projects/scripts/test_atspi.py b/projects/scripts/test_atspi.py index 706a7c2b5..b59b8d27c 100644 --- a/projects/scripts/test_atspi.py +++ b/projects/scripts/test_atspi.py @@ -131,11 +131,37 @@ def wait_for_window_count(application, count, timeout=2): def validate(application, multi_window=False, close_primary=False, process_id=None, text_events=None, - selection_events=None, policy_reset=False, role_events=None): + selection_events=None, policy_reset=False, role_events=None, test_html=False): required_nodes = ["Project name", "Enable autosave"] + if test_html: + required_nodes.extend(["HTML heading", "HTML link"]) if multi_window: required_nodes.append("Secondary accessibility window") nodes = wait_for_nodes(application, required_nodes) + if any(node.get_name() == "HTML link" for node in nodes): + heading = next(node for node in nodes + if node.get_role() == Atspi.Role.HEADING and node.get_name() == "HTML heading") + check(heading.get_attributes().get("level") == "2", "HTML heading level was not exposed") + link = find_named(nodes, "HTML link") + check(link.get_role() == Atspi.Role.LINK, "HTML anchor must have link semantics") + item_text = find_named(nodes, "HTML first item") + item = item_text.get_parent() + check(item.get_role() == Atspi.Role.LIST_ITEM, "HTML li must have list-item semantics") + html_list = item.get_parent() + check(html_list.get_role() == Atspi.Role.LIST, "HTML ul must have list semantics") + check(html_list.get_child_count() == 2, "HTML list items were duplicated or missing") + check(not any("Selection" in interface for interface in html_list.get_interfaces()), + "non-interactive HTML list advertised Selection") + check(Atspi.Action.do_action(link, action_index(link, "click")), "HTML link activation failed") + wait_for_nodes(application, ["HTML link activated"]) + unlink = find_named(nodes, "HTML unlink") + relink = find_named(nodes, "HTML relink") + for button, expected_role in ((unlink, Atspi.Role.LABEL), (relink, Atspi.Role.LINK)): + check(button.do_action(action_index(button, "click")), "HTML href toggle failed") + deadline = time.monotonic() + 2 + while link.get_role() != expected_role and time.monotonic() < deadline: + pause(0.01) + check(link.get_role() == expected_role, "retained HTML link role cache stayed stale") check(len(nodes) >= 20, f"expected a complete accessibility tree, got {len(nodes)} nodes") check(application.get_role_name() == "application", "root must be an application") expected_windows = 2 if multi_window else 1 @@ -468,7 +494,7 @@ def main(): try: if not args.no_launch: discovery_listener, discovered_process_ids, text_events, selection_events, role_events = start_discovery_listener() - command = [args.executable, "--test-tree", "--test-password-mode"] + command = [args.executable, "--test-tree", "--test-password-mode", "--test-html"] if args.hidden: command.append("--hidden") if args.policy_reset: @@ -500,6 +526,7 @@ def main(): selection_events, args.policy_reset, role_events, + not args.no_launch, ) if process is not None and (args.close_primary or args.policy_reset): check(process.wait(timeout=3) == 0, "native backend teardown crashed on last-window closure") diff --git a/src/eepp/ui/accessibility/accessibilitybackendatspievents.cpp b/src/eepp/ui/accessibility/accessibilitybackendatspievents.cpp index 71d3444bd..7941a8e58 100644 --- a/src/eepp/ui/accessibility/accessibilitybackendatspievents.cpp +++ b/src/eepp/ui/accessibility/accessibilitybackendatspievents.cpp @@ -66,6 +66,11 @@ void AtSpiApplication::onEvent( AccessibilityManager& manager, } if ( !hasActiveClients() ) return; + if ( event.type == AccessibilityEvent::ChildrenChanged ) { + const auto role = manager.getNodeRole( event.ref ); + if ( role == AccessibilityRole::Heading || role == AccessibilityRole::Link ) + onEvent( manager, { event.ref, {}, -1, AccessibilityEvent::NameChanged } ); + } // Structural mutations can remove a selected child without a separate selection event. // Queries must then rebuild the snapshot from the new semantic membership. if ( !mSelections.empty() && ( event.type == AccessibilityEvent::ModelChanged || @@ -79,7 +84,8 @@ void AtSpiApplication::onEvent( AccessibilityManager& manager, return; } AccessibilityNodeInfo info; - if ( event.type == AccessibilityEvent::StateChanged ) { + if ( event.type == AccessibilityEvent::StateChanged || + event.type == AccessibilityEvent::RoleChanged ) { // State notifications need no names, values, or text offsets. info.role = manager.getNodeRole( event.ref ); info.states = manager.getNodeState( event.ref ); @@ -132,6 +138,8 @@ void AtSpiApplication::onEvent( AccessibilityManager& manager, detail = "accessible-description"; } else if ( event.type == AccessibilityEvent::ValueChanged ) { detail = "accessible-value"; + } else if ( event.type == AccessibilityEvent::RoleChanged ) { + detail = "accessible-role"; } else if ( event.type == AccessibilityEvent::StateChanged ) { signal = "StateChanged"; if ( info.role == AccessibilityRole::TextBox ) { @@ -203,8 +211,9 @@ void AtSpiApplication::onEvent( AccessibilityManager& manager, if ( childrenChanged ) { mDBus.messageIterOpenContainer( &iter, 'v', "(so)", &variant ); appendRef( variant, event.related ); - } else if ( event.type == AccessibilityEvent::StateChanged && - info.role == AccessibilityRole::TextBox ) { + } else if ( event.type == AccessibilityEvent::RoleChanged || + ( event.type == AccessibilityEvent::StateChanged && + info.role == AccessibilityRole::TextBox ) ) { Uint32 nativeRole = hasState( info.states, AccessibilityState::Protected ) ? 40 : role( info.role ); mDBus.messageIterOpenContainer( &iter, 'v', "u", &variant ); diff --git a/src/eepp/ui/accessibility/accessibilitybackendatspimethods.cpp b/src/eepp/ui/accessibility/accessibilitybackendatspimethods.cpp index befab809a..d2b3628e3 100644 --- a/src/eepp/ui/accessibility/accessibilitybackendatspimethods.cpp +++ b/src/eepp/ui/accessibility/accessibilitybackendatspimethods.cpp @@ -147,7 +147,8 @@ DBusHandlerResult AtSpiApplication::handleAccessible( DBusMessage* request, send( reply ); } else if ( std::strcmp( member, "GetState" ) == 0 ) { auto info = getNodeInfo( ref, false ); - if ( isSelectionContainerRole( info.role ) ) { + if ( isSelectionContainerRole( info.role ) && + hasState( info.states, AccessibilityState::SelectionContainer ) ) { rememberSelection( ref ); } else if ( hasState( info.states, AccessibilityState::Selected ) ) { const auto parent = getParent( ref ); @@ -165,6 +166,17 @@ DBusHandlerResult AtSpiApplication::handleAccessible( DBusMessage* request, DBusMessageIter attributes; mDBus.messageIterInitAppend( reply, &iter ); mDBus.messageIterOpenContainer( &iter, 'a', "{ss}", &attributes ); + const auto headingLevel = mManager ? mManager->getNodeHeadingLevel( ref ) : 0; + if ( headingLevel ) { + DBusMessageIter entry; + mDBus.messageIterOpenContainer( &attributes, 'e', nullptr, &entry ); + const char* key = "level"; + const std::string level = String::toString( headingLevel ); + const char* value = level.c_str(); + appendBasic( entry, 's', &key ); + appendBasic( entry, 's', &value ); + mDBus.messageIterCloseContainer( &attributes, &entry ); + } mDBus.messageIterCloseContainer( &iter, &attributes ); send( reply ); } else if ( std::strcmp( member, "GetRelationSet" ) == 0 ) { @@ -205,7 +217,8 @@ DBusHandlerResult AtSpiApplication::handleApplication( DBusMessage* request, DBusHandlerResult AtSpiApplication::handleSelection( DBusMessage* request, AccessibilityNodeRef ref, const char* member ) { - if ( !isSelectionContainerRole( mManager->getNodeRole( ref ) ) ) + if ( !isSelectionContainerRole( mManager->getNodeRole( ref ) ) || + !hasState( mManager->getNodeState( ref ), AccessibilityState::SelectionContainer ) ) return 1; Int32 index = -1; if ( std::strcmp( member, "GetSelectedChild" ) == 0 ) { @@ -598,7 +611,9 @@ DBusHandlerResult AtSpiApplication::handlePropertiesGet( DBusMessage* request, appendBasic( variant, 'i', &value ); } else if ( std::strcmp( requestedInterface, "org.a11y.atspi.Selection" ) == 0 && std::strcmp( property, "NSelectedChildren" ) == 0 && - isSelectionContainerRole( mManager->getNodeRole( ref ) ) ) { + isSelectionContainerRole( mManager->getNodeRole( ref ) ) && + hasState( mManager->getNodeState( ref ), + AccessibilityState::SelectionContainer ) ) { Int32 count = static_cast( rememberSelection( ref ).size() ); mDBus.messageIterOpenContainer( &iter, 'v', "i", &variant ); appendBasic( variant, 'i', &count ); diff --git a/src/eepp/ui/accessibility/accessibilitybackendatspitree.cpp b/src/eepp/ui/accessibility/accessibilitybackendatspitree.cpp index cd595e846..3f416b0b4 100644 --- a/src/eepp/ui/accessibility/accessibilitybackendatspitree.cpp +++ b/src/eepp/ui/accessibility/accessibilitybackendatspitree.cpp @@ -122,6 +122,10 @@ Uint32 AtSpiApplication::role( AccessibilityRole role ) const { return 29; case AccessibilityRole::TextBox: return 79; + case AccessibilityRole::Link: + return 88; + case AccessibilityRole::Heading: + return 83; case AccessibilityRole::Image: return 27; case AccessibilityRole::ComboBox: @@ -188,6 +192,10 @@ const char* AtSpiApplication::roleName( AccessibilityRole role ) const { return "text"; case AccessibilityRole::TextBox: return "entry"; + case AccessibilityRole::Link: + return "link"; + case AccessibilityRole::Heading: + return "heading"; case AccessibilityRole::Image: return "image"; case AccessibilityRole::ComboBox: @@ -351,7 +359,8 @@ void AtSpiApplication::appendInterfaces( DBusMessageIter& iter, AccessibilityNod const char* component = "org.a11y.atspi.Component"; appendBasic( array, 's', &accessible ); appendBasic( array, 's', &component ); - if ( isSelectionContainerRole( info.role ) ) { + if ( isSelectionContainerRole( info.role ) && + hasState( info.states, AccessibilityState::SelectionContainer ) ) { const char* selection = "org.a11y.atspi.Selection"; appendBasic( array, 's', &selection ); } diff --git a/src/eepp/ui/accessibility/accessibilitybackendmacos.mm b/src/eepp/ui/accessibility/accessibilitybackendmacos.mm index d598bead5..1f40de999 100644 --- a/src/eepp/ui/accessibility/accessibilitybackendmacos.mm +++ b/src/eepp/ui/accessibility/accessibilitybackendmacos.mm @@ -79,6 +79,10 @@ static NSAccessibilityRole nativeRole( AccessibilityRole role ) { return NSAccessibilityStaticTextRole; case AccessibilityRole::TextBox: return NSAccessibilityTextFieldRole; + case AccessibilityRole::Link: + return NSAccessibilityLinkRole; + case AccessibilityRole::Heading: + return @"AXHeading"; case AccessibilityRole::Image: return NSAccessibilityImageRole; case AccessibilityRole::ComboBox: @@ -984,6 +988,8 @@ bool MacAccessibilityState::perform( AccessibilityNodeRef ref, AccessibilityActi if ( auto text = [self textContents] ) return text->empty() ? nil : toNSString( *text ); const auto info = [self nodeInfo]; + if ( info.role == AccessibilityRole::Heading ) + return @( info.headingLevel ); 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 || @@ -1484,7 +1490,7 @@ bool MacAccessibilityState::perform( AccessibilityNodeRef ref, AccessibilityActi !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 ) + info.role == AccessibilityRole::Text || info.role == AccessibilityRole::Heading ) [names addObject:NSAccessibilityValueAttribute]; if ( hasAction( info.actions, AccessibilityAction::Select ) || info.role == AccessibilityRole::Row || info.role == AccessibilityRole::ListItem || @@ -1880,6 +1886,13 @@ class MacAccessibilityRegistry { } } else if ( target ) { NSAccessibilityPostNotification( target, NSAccessibilityLayoutChangedNotification ); + if ( event.type == AccessibilityEvent::ChildrenChanged ) { + const auto role = state->roleFor( event.ref ); + if ( role == AccessibilityRole::Heading || role == AccessibilityRole::Link ) { + NSAccessibilityPostNotification( target, + NSAccessibilityTitleChangedNotification ); + } + } } } diff --git a/src/eepp/ui/accessibility/accessibilitybackenduia.cpp b/src/eepp/ui/accessibility/accessibilitybackenduia.cpp index 781ce432c..7f4642bd4 100644 --- a/src/eepp/ui/accessibility/accessibilitybackenduia.cpp +++ b/src/eepp/ui/accessibility/accessibilitybackenduia.cpp @@ -379,6 +379,8 @@ void UIAutomationAccessibilityBackend::raiseEvent( const AccessibilityPendingEve if ( event.type == AccessibilityEvent::NameChanged ) { raisePropertyChanged( UIA_NamePropertyId ); + } else if ( event.type == AccessibilityEvent::RoleChanged ) { + raisePropertyChanged( UIA_ControlTypePropertyId ); } else if ( event.type == AccessibilityEvent::DescriptionChanged ) { raisePropertyChanged( UIA_HelpTextPropertyId ); } else if ( event.type == AccessibilityEvent::ValueChanged ) { @@ -438,6 +440,9 @@ void UIAutomationAccessibilityBackend::raiseEvent( const AccessibilityPendingEve event.type == AccessibilityEvent::ModelChanged ) { UiaRaiseStructureChangedEvent( static_cast( nativeProvider ), StructureChangeType_ChildrenInvalidated, nullptr, 0 ); + const auto role = mManager.getNodeRole( event.ref ); + if ( role == AccessibilityRole::Heading || role == AccessibilityRole::Link ) + raisePropertyChanged( UIA_NamePropertyId ); } else if ( event.type == AccessibilityEvent::FocusChanged ) { raisePropertyChanged( UIA_HasKeyboardFocusPropertyId ); UiaRaiseAutomationEvent( static_cast( nativeProvider ), diff --git a/src/eepp/ui/accessibility/accessibilitybackenduiaprovider.cpp b/src/eepp/ui/accessibility/accessibilitybackenduiaprovider.cpp index 3b69333d6..13e8ea75e 100644 --- a/src/eepp/ui/accessibility/accessibilitybackenduiaprovider.cpp +++ b/src/eepp/ui/accessibility/accessibilitybackenduiaprovider.cpp @@ -18,7 +18,10 @@ CONTROLTYPEID controlType( AccessibilityRole role ) { return UIA_RadioButtonControlTypeId; case AccessibilityRole::Label: case AccessibilityRole::Text: + case AccessibilityRole::Heading: return UIA_TextControlTypeId; + case AccessibilityRole::Link: + return UIA_HyperlinkControlTypeId; case AccessibilityRole::TextBox: return UIA_EditControlTypeId; case AccessibilityRole::Image: @@ -146,7 +149,8 @@ HRESULT STDMETHODCALLTYPE UIAutomationProvider::QueryInterface( REFIID interface hasAction( nodeInfo.actions, AccessibilityAction::Toggle ) ) *object = static_cast( this ); else if ( interfaceId == __uuidof( ISelectionProvider ) && - isSelectionContainerRole( nodeInfo.role ) ) + isSelectionContainerRole( nodeInfo.role ) && + hasState( nodeInfo.states, AccessibilityState::SelectionContainer ) ) *object = static_cast( this ); else if ( interfaceId == __uuidof( ISelectionItemProvider ) && ( hasAction( nodeInfo.actions, AccessibilityAction::Select ) || @@ -236,9 +240,12 @@ HRESULT STDMETHODCALLTYPE UIAutomationProvider::GetPropertyValue( PROPERTYID pro return UIA_E_ELEMENTNOTAVAILABLE; // Scalar/identity properties must not read model display data, copy text, or mark name // relations as queried. They still validate on the UI thread through the normal invoke path. - enum class Query { Metadata, Identity, Role, State }; + enum class Query { Metadata, Identity, Role, State, Heading }; Query query = Query::Metadata; switch ( propertyId ) { + case UIA_HeadingLevelPropertyId: + query = Query::Heading; + break; case UIA_AutomationIdPropertyId: case UIA_FrameworkIdPropertyId: case UIA_ClassNamePropertyId: @@ -276,6 +283,9 @@ HRESULT STDMETHODCALLTYPE UIAutomationProvider::GetPropertyValue( PROPERTYID pro output.role = manager.getNodeRole( mRef ); else if ( query == Query::State ) output.states = manager.getNodeState( mRef ); + else if ( query == Query::Heading ) + output.headingLevel = + manager.getNodeHeadingLevel( mRef ); return S_OK; } ); if ( FAILED( result ) ) @@ -289,7 +299,11 @@ HRESULT STDMETHODCALLTYPE UIAutomationProvider::GetPropertyValue( PROPERTYID pro ? S_OK : setBstr( value, String::fromUtf8( nodeInfo.shortcut.text ) ); } - if ( propertyId == UIA_ControlTypePropertyId ) { + if ( propertyId == UIA_HeadingLevelPropertyId ) { + // UIA heading IDs are HeadingLevel_None (80050), then HeadingLevel1..9 in order. + value->vt = VT_I4; + value->lVal = 80050 + nodeInfo.headingLevel; + } else if ( propertyId == UIA_ControlTypePropertyId ) { value->vt = VT_I4; value->lVal = controlType( nodeInfo.role ); } else if ( propertyId == UIA_IsEnabledPropertyId ) { diff --git a/src/eepp/ui/accessibility/accessibilitymanager.cpp b/src/eepp/ui/accessibility/accessibilitymanager.cpp index e01caaa2c..408df5d84 100644 --- a/src/eepp/ui/accessibility/accessibilitymanager.cpp +++ b/src/eepp/ui/accessibility/accessibilitymanager.cpp @@ -239,12 +239,28 @@ AccessibilityState AccessibilityManager::getNodeState( AccessibilityNodeRef ref return AccessibilityState::None; } +Uint8 AccessibilityManager::getNodeHeadingLevel( AccessibilityNodeRef ref ) const { + if ( auto* widget = resolve( ref ) ) { + return widget->getAccessibilityRole() == AccessibilityRole::Heading + ? widget->getAccessibilityHeadingLevel() + : 0; + } + if ( auto* source = resolveSource( ref ) ) { + return source->getRole( ref.id ) == AccessibilityRole::Heading + ? source->getInfo( ref.id ).headingLevel + : 0; + } + return 0; +} + AccessibilityNodeInfo AccessibilityManager::getNodeInfo( AccessibilityNodeRef ref, bool includeValue, bool includeTextOffsets ) const { AccessibilityNodeInfo info; if ( auto widget = resolve( ref ) ) { info.role = widget->getAccessibilityRole(); + if ( info.role == AccessibilityRole::Heading ) + info.headingLevel = widget->getAccessibilityHeadingLevel(); // A client now holds a name or description that depends on another widget. if ( !widget->getAccessibilityLabelledBy().empty() ) mRelationTargets.insert( String::hash( widget->getAccessibilityLabelledBy() ) ); diff --git a/src/eepp/ui/accessibility/accessibilitywidgetresolver.cpp b/src/eepp/ui/accessibility/accessibilitywidgetresolver.cpp index 668f1d15d..36b41be67 100644 --- a/src/eepp/ui/accessibility/accessibilitywidgetresolver.cpp +++ b/src/eepp/ui/accessibility/accessibilitywidgetresolver.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +27,8 @@ #include #include #include +#include +#include #include #include #include @@ -39,6 +43,41 @@ namespace EE { namespace UI { namespace { +// Source text, not the layout's flattened RichText (which can include the same descendants +// several times). Never read native input values while naming a heading or link. +void appendHTMLText( const Node* node, String& text, bool root = false ) { + if ( node->isDestroying() ) + return; + if ( node->isWidget() ) { + const auto* widget = static_cast( node ); + if ( !root && ( !widget->hasVisibility() || widget->isAccessibilityHidden() ) ) + return; + if ( widget->isType( UI_TYPE_TEXTNODE ) ) { + text += static_cast( widget )->getText(); + return; + } + if ( widget->isType( UI_TYPE_TEXTSPAN ) ) + text += static_cast( widget )->getText(); + else if ( widget->isType( UI_TYPE_HTML_IMAGE ) ) { + text += String::fromUtf8( static_cast( widget )->getAlt() ); + return; + } else if ( !widget->isType( UI_TYPE_RICHTEXT ) ) + return; + if ( widget->isType( UI_TYPE_BR ) ) + text += '\n'; + } + for ( auto* child = node->getFirstChild(); child; child = child->getNextNode() ) + appendHTMLText( child, text ); +} + +bool hasHref( const UIWidget* widget ) { + if ( widget->isType( UI_TYPE_ANCHORSPAN ) ) + return !static_cast( widget )->getHref().empty(); + if ( widget->isType( UI_TYPE_ANCHOR ) ) + return !static_cast( widget )->getHref().empty(); + return false; +} + bool isRoot( const UIWidget* widget ) { return widget->getUISceneNode() && !widget->getUISceneNode()->getParent() && widget == widget->getUISceneNode()->getRoot(); @@ -80,6 +119,8 @@ AccessibilityState baseState( const UIWidget* widget ) { AccessibilityState state = AccessibilityState::None; if ( widget->isEnabled() ) state |= AccessibilityState::Enabled; + if ( widget->isType( UI_TYPE_ABSTRACTTABLEVIEW ) || widget->isType( UI_TYPE_TABWIDGET ) ) + state |= AccessibilityState::SelectionContainer; if ( keyboardFocusable( widget ) ) state |= AccessibilityState::Focusable; auto* dispatcher = widget->getEventDispatcher(); @@ -224,9 +265,78 @@ AccessibilityRole AccessibilityWidgetResolver::getRole( const UIWidget* widget ) role = AccessibilityRole::Button; else if ( widget->isType( UI_TYPE_TEXTVIEW ) ) role = AccessibilityRole::Label; + else if ( widget->isType( UI_TYPE_TEXTNODE ) ) { + if ( !static_cast( widget )->isWhitespaceOnly() ) + role = AccessibilityRole::Text; + } else if ( widget->isType( UI_TYPE_RICHTEXT ) ) { + const auto& tag = widget->getStyleSheetTag(); + if ( getHeadingLevel( widget ) ) + role = AccessibilityRole::Heading; + else if ( tag == "ul" || tag == "ol" ) + role = AccessibilityRole::List; + else if ( widget->isType( UI_TYPE_HTML_LIST_ITEM ) ) + role = AccessibilityRole::ListItem; + else if ( widget->isType( UI_TYPE_TEXTSPAN ) && + !static_cast( widget )->getText().empty() ) + role = widget->getFirstChild() ? AccessibilityRole::Group : AccessibilityRole::Text; + } else if ( widget->isType( UI_TYPE_HTML_IMAGE ) && + !static_cast( widget )->getAlt().empty() ) + role = AccessibilityRole::Image; + if ( hasHref( widget ) ) + role = AccessibilityRole::Link; return widget->resolveAccessibilityRole( role ); } +Uint8 AccessibilityWidgetResolver::getHeadingLevel( const UIWidget* widget ) { + if ( !widget->isType( UI_TYPE_RICHTEXT ) ) + return 0; + const auto& tag = widget->getStyleSheetTag(); + return tag.size() == 2 && tag[0] == 'h' && tag[1] >= '1' && tag[1] <= '6' ? tag[1] - '0' : 0; +} + +void AccessibilityWidgetResolver::onHTMLTextChanged( + UIWidget* widget, std::optional previousRole ) { + const auto* scene = widget->getUISceneNode(); + if ( !scene || !scene->hasActiveAccessibilityClients() ) + return; + if ( widget->isDestroying() ) + return; + const auto role = widget->getAccessibilityRole(); + const bool structural = !previousRole; + if ( structural && widget->isType( UI_TYPE_TEXTSPAN ) && + ( role == AccessibilityRole::Text || role == AccessibilityRole::Group ) && + widget->getChildCount() <= 1 ) { + // An own-text span changes from a leaf to a container at its first child, and back + // at its last removal. Explicit author roles still take precedence. + previousRole = widget->resolveAccessibilityRole( + widget->getFirstChild() ? AccessibilityRole::Text : AccessibilityRole::Group ); + } + if ( previousRole && *previousRole != role ) + widget->notifyAccessibilityEvent( AccessibilityEvent::RoleChanged ); + widget->notifyAccessibilityEvent( AccessibilityEvent::NameChanged ); + // Empty/whitespace text and spans are transparent. Refresh the flattened semantic parent + // when their content changes; retained identities of still-exposed text stay unchanged. + if ( auto* parent = widget->getParent(); + parent && ( structural || ( previousRole && *previousRole != role ) ) ) { + while ( parent && !parent->isWidget() ) + parent = parent->getParent(); + if ( parent ) { + parent->asType()->notifyAccessibilityEvent( + AccessibilityEvent::ChildrenChanged ); + } + } + for ( auto* parent = widget->getParent(); parent; parent = parent->getParent() ) { + if ( parent->isDestroying() ) + break; + if ( !parent->isWidget() ) + continue; + auto* ancestor = parent->asType(); + const auto role = ancestor->getAccessibilityRole(); + if ( role == AccessibilityRole::Heading || role == AccessibilityRole::Link ) + ancestor->notifyAccessibilityEvent( AccessibilityEvent::NameChanged ); + } +} + bool AccessibilityWidgetResolver::isLeafRole( AccessibilityRole role ) { switch ( role ) { case AccessibilityRole::Button: @@ -279,6 +389,16 @@ String AccessibilityWidgetResolver::getOwnName( const UIWidget* widget ) { static_cast( widget )->getDocument().hasFilepath() ) { name = String::fromUtf8( static_cast( widget )->getDocument().getFilename() ); + } else if ( widget->isType( UI_TYPE_TEXTNODE ) ) + name = static_cast( widget )->getText(); + else if ( widget->isType( UI_TYPE_HTML_IMAGE ) ) + name = String::fromUtf8( static_cast( widget )->getAlt() ); + else if ( widget->isType( UI_TYPE_RICHTEXT ) ) { + const auto role = widget->getAccessibilityRole(); + if ( role == AccessibilityRole::Link || role == AccessibilityRole::Heading ) + appendHTMLText( widget, name, true ); + else if ( widget->isType( UI_TYPE_TEXTSPAN ) ) + name = static_cast( widget )->getText(); } name = widget->resolveAccessibilityName( name ); // Icon-only buttons usually carry their meaning in the tooltip. @@ -375,6 +495,10 @@ const char* AccessibilityWidgetResolver::getRoleName( AccessibilityRole role ) { return "Tree"; case AccessibilityRole::TreeItem: return "TreeItem"; + case AccessibilityRole::Link: + return "Link"; + case AccessibilityRole::Heading: + return "Heading"; } return "Unknown"; } @@ -608,9 +732,11 @@ AccessibilityActions AccessibilityWidgetResolver::getActions( const UIWidget* wi if ( isRoot( widget ) || ( widget->isType( UI_TYPE_TEXTVIEW ) && !widget->isType( UI_TYPE_CHECKBOX ) && !widget->isType( UI_TYPE_RADIOBUTTON ) && !widget->isType( UI_TYPE_TEXTINPUT ) && - !widget->isType( UI_TYPE_LISTBOXITEM ) ) ) + !widget->isType( UI_TYPE_LISTBOXITEM ) && !widget->isType( UI_TYPE_ANCHOR ) ) ) return scroll; auto actions = baseActions( widget ) | scroll; + if ( hasHref( widget ) ) + actions |= accessibilityActionMask( AccessibilityAction::Press ); if ( widget->isType( UI_TYPE_MENUCHECKBOX ) ) { actions |= accessibilityActionMask( AccessibilityAction::Toggle ); } else if ( widget->isType( UI_TYPE_MENURADIOBUTTON ) ) { @@ -685,9 +811,20 @@ UIWidget* AccessibilityWidgetResolver::getFocusOwner( Node* focused, const UIWid element = widget; } else if ( !element && widget != sceneRoot && widget->isAccessibilityElement() ) element = widget; - else if ( element && widget != element && isLeafRole( widget->getAccessibilityRole() ) ) { - // A leaf control does not expose its children; it owns their focus. - element = widget; + else if ( element && widget != element ) { + const auto role = widget->getAccessibilityRole(); + if ( isLeafRole( role ) ) { + // A leaf control does not expose its children; it owns their focus. + element = widget; + } else if ( role == AccessibilityRole::Link && !keyboardFocusable( element ) ) { + // Clicking a passive fragment focuses the link, without hiding its semantic + // children or stealing focus from an embedded interactive control. + const auto childRole = element->getAccessibilityRole(); + if ( childRole == AccessibilityRole::Text || + childRole == AccessibilityRole::Label || + childRole == AccessibilityRole::Image ) + element = widget; + } } } return element; @@ -707,6 +844,11 @@ UIWidget* AccessibilityWidgetResolver::getEventTarget( UIWidget* widget, // any accessible tree yet; notifying them would only register orphan identities. if ( !top->isSceneNode() || widget->isAccessibilityHidden() ) return nullptr; + if ( event == AccessibilityEvent::FocusChanged ) { + auto* owner = getFocusOwner( widget, widget->getUISceneNode()->getRoot() ); + if ( owner && owner->getAccessibilityRole() == AccessibilityRole::Link ) + return owner; + } if ( auto* view = getOwningModelView( widget ) ) return event == AccessibilityEvent::FocusChanged ? view : nullptr; if ( auto* leaf = getLeafOwner( widget ) ) { @@ -782,6 +924,10 @@ bool AccessibilityWidgetResolver::performAction( UIWidget* widget, scrolled |= view->scrollIntoView( widget ); return scrolled; } + if ( request.action == AccessibilityAction::Press && widget->isType( UI_TYPE_ANCHORSPAN ) ) + return static_cast( widget )->activateLink(); + if ( request.action == AccessibilityAction::Press && widget->isType( UI_TYPE_ANCHOR ) ) + return static_cast( widget )->activateLink(); if ( request.action == AccessibilityAction::Toggle && widget->isType( UI_TYPE_MENUCHECKBOX ) ) { static_cast( widget )->activate(); return true; diff --git a/src/eepp/ui/uihtmlimage.cpp b/src/eepp/ui/uihtmlimage.cpp index 003428ac8..8f6f58b12 100644 --- a/src/eepp/ui/uihtmlimage.cpp +++ b/src/eepp/ui/uihtmlimage.cpp @@ -1,3 +1,4 @@ +#include #include #define PUGIXML_HEADER_ONLY #include @@ -569,7 +570,11 @@ const std::string& UIHTMLImage::getAlt() const { UIHTMLImage* UIHTMLImage::setAlt( const std::string& alt ) { if ( mAlt != alt ) { + const auto previousRole = + Accessibility::isActive() ? getAccessibilityRole() : AccessibilityRole::None; mAlt = alt; + if ( Accessibility::isActive() ) + AccessibilityWidgetResolver::onHTMLTextChanged( this, previousRole ); invalidateDraw(); } return this; diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 96c98c747..df015c8fc 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -1268,6 +1269,8 @@ void UIRichText::onChildCountChange( Node* child, const bool& removed ) { notifyLayoutAttrChange( LayoutInvalidation::TextFormatting ); notifyLayoutAttrChangeParent( LayoutInvalidation::ParentReplacedFormatting ); + if ( Accessibility::isActive() && !isDestroying() ) + AccessibilityWidgetResolver::onHTMLTextChanged( this ); } void UIRichText::onFontChanged() { diff --git a/src/eepp/ui/uitextnode.cpp b/src/eepp/ui/uitextnode.cpp index 6d9273da7..895467960 100644 --- a/src/eepp/ui/uitextnode.cpp +++ b/src/eepp/ui/uitextnode.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -107,13 +108,17 @@ const String& UITextNode::getText() const { void UITextNode::setText( const String& text ) { if ( mText != text ) { + const auto previousRole = + Accessibility::isActive() ? getAccessibilityRole() : AccessibilityRole::None; mText = text; + if ( Accessibility::isActive() ) + AccessibilityWidgetResolver::onHTMLTextChanged( this, previousRole ); notifyLayoutAttrChange( LayoutInvalidation::TextFormatting ); } } bool UITextNode::isWhitespaceOnly() const { - for ( char c : mText ) { + for ( auto c : mText ) { if ( c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\v' ) return false; } diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp index 365087278..b4a083c75 100644 --- a/src/eepp/ui/uitextspan.cpp +++ b/src/eepp/ui/uitextspan.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -274,8 +275,12 @@ const String& UITextSpan::getText() const { UITextSpan* UITextSpan::setText( const String& text ) { if ( mText != text ) { + const auto previousRole = + Accessibility::isActive() ? getAccessibilityRole() : AccessibilityRole::None; mText = text; onTextChanged(); + if ( Accessibility::isActive() ) + AccessibilityWidgetResolver::onHTMLTextChanged( this, previousRole ); notifyLayoutAttrChange( LayoutInvalidation::TextFormatting ); invalidateDraw(); } @@ -770,6 +775,25 @@ UIAnchorSpan::UIAnchorSpan( const std::string& tag ) : UITextSpan( tag ) { mState |= UIState::StateFlagLink; } +Uint32 UIAnchorSpan::getType() const { + return UI_TYPE_ANCHORSPAN; +} + +bool UIAnchorSpan::isType( const Uint32& type ) const { + return type == UI_TYPE_ANCHORSPAN || UITextSpan::isType( type ); +} + +bool UIAnchorSpan::activateLink( Uint32 modifiers, Uint32 mouseButtons ) { + if ( mHref.empty() || !getUISceneNode() ) + return false; + NavigationRequest request{ URI( mHref ) }; + request.source = this; + request.modifiers = modifiers; + request.mouseButtons = mouseButtons; + getUISceneNode()->navigate( request ); + return true; +} + Uint32 UIAnchorSpan::onMessage( const NodeMessage* Msg ) { switch ( Msg->getMsg() ) { case NodeMessage::MouseClick: { @@ -779,11 +803,7 @@ Uint32 UIAnchorSpan::onMessage( const NodeMessage* Msg ) { return 1; } if ( !mHref.empty() && ( Msg->getFlags() & ( EE_BUTTON_LMASK | EE_BUTTON_MMASK ) ) ) { - NavigationRequest request{ URI( mHref ) }; - request.source = this; - request.mouseButtons = Msg->getFlags(); - request.modifiers = getInput() ? getInput()->getModState() : 0; - getUISceneNode()->navigate( request ); + activateLink( getInput() ? getInput()->getModState() : 0, Msg->getFlags() ); } return 1; } @@ -820,7 +840,11 @@ bool UIAnchorSpan::applyProperty( const StyleSheetProperty& attribute ) { void UIAnchorSpan::setHref( const std::string& href ) { if ( href != mHref ) { + const auto previousRole = + Accessibility::isActive() ? getAccessibilityRole() : AccessibilityRole::None; mHref = href; + if ( Accessibility::isActive() ) + AccessibilityWidgetResolver::onHTMLTextChanged( this, previousRole ); } } @@ -830,11 +854,7 @@ const std::string& UIAnchorSpan::getHref() const { Uint32 UIAnchorSpan::onKeyDown( const KeyEvent& event ) { if ( event.getKeyCode() == KEY_KP_ENTER || event.getKeyCode() == KEY_RETURN ) { - if ( !mHref.empty() ) { - NavigationRequest request{ URI( mHref ) }; - request.source = this; - request.modifiers = event.getMod(); - getUISceneNode()->navigate( request ); + if ( activateLink( event.getMod() ) ) { return 1; } } diff --git a/src/eepp/ui/uitextview.cpp b/src/eepp/ui/uitextview.cpp index 607bb9087..1c385fc35 100644 --- a/src/eepp/ui/uitextview.cpp +++ b/src/eepp/ui/uitextview.cpp @@ -1099,12 +1099,22 @@ UIAnchor* UIAnchor::NewA() { } UIAnchor::UIAnchor( const std::string& tag ) : UITextView( tag ) { - onClick( - [this]( const MouseEvent* ) { - if ( !mHref.empty() ) - getUISceneNode()->openURL( mHref ); - }, - EE_BUTTON_LEFT ); + onClick( [this]( const MouseEvent* ) { activateLink(); }, EE_BUTTON_LEFT ); +} + +Uint32 UIAnchor::getType() const { + return UI_TYPE_ANCHOR; +} + +bool UIAnchor::isType( const Uint32& type ) const { + return type == UI_TYPE_ANCHOR || UITextView::isType( type ); +} + +bool UIAnchor::activateLink() { + if ( mHref.empty() || !getUISceneNode() ) + return false; + getUISceneNode()->openURL( mHref ); + return true; } void UIAnchor::loadFromXmlNode( const pugi::xml_node& node ) { @@ -1134,6 +1144,8 @@ bool UIAnchor::applyProperty( const StyleSheetProperty& attribute ) { void UIAnchor::setHref( const std::string& href ) { if ( href != mHref ) { mHref = href; + notifyAccessibilityEvent( AccessibilityEvent::RoleChanged ); + notifyAccessibilityEvent( AccessibilityEvent::ChildrenChanged ); } } @@ -1143,8 +1155,7 @@ const std::string& UIAnchor::getHref() const { Uint32 UIAnchor::onKeyDown( const KeyEvent& event ) { if ( event.getKeyCode() == KEY_KP_ENTER || event.getKeyCode() == KEY_RETURN ) { - if ( !mHref.empty() ) { - getUISceneNode()->openURL( mHref ); + if ( activateLink() ) { return 1; } } diff --git a/src/eepp/ui/uiwidget.cpp b/src/eepp/ui/uiwidget.cpp index db0ff65e3..f4b942e98 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -159,6 +159,10 @@ AccessibilityRole UIWidget::getAccessibilityRole() const { return AccessibilityWidgetResolver::getRole( this ); } +Uint8 UIWidget::getAccessibilityHeadingLevel() const { + return AccessibilityWidgetResolver::getHeadingLevel( this ); +} + AccessibilityRole UIWidget::resolveAccessibilityRole( AccessibilityRole defaultRole ) const { if ( mAccessibilityProperties ) { if ( mAccessibilityProperties->hidden ) @@ -335,7 +339,8 @@ void UIWidget::deliverAccessibilityEvent( AccessibilityEvent event ) { !AccessibilityWidgetResolver::isHiddenFromAccessibility( this ) ) ) return; if ( target ) { - while ( target && !target->isAccessibilityElement() ) { + while ( target && !target->isAccessibilityElement() && + event != AccessibilityEvent::RoleChanged ) { // Plain nodes are transparent, as in the manager's semantic parent lookup: keep // walking to the nearest exposed widget instead of falling back to the scene root. Node* parent = target->getParent(); @@ -352,6 +357,7 @@ void UIWidget::deliverAccessibilityEvent( AccessibilityEvent event ) { event == AccessibilityEvent::DescriptionChanged || event == AccessibilityEvent::ValueChanged || event == AccessibilityEvent::StateChanged || + event == AccessibilityEvent::RoleChanged || event == AccessibilityEvent::EnabledChanged || event == AccessibilityEvent::BoundsChanged; const auto ref = diff --git a/src/examples/ui_accessibility/ui_accessibility.cpp b/src/examples/ui_accessibility/ui_accessibility.cpp index 65082c58c..22dcd67db 100644 --- a/src/examples/ui_accessibility/ui_accessibility.cpp +++ b/src/examples/ui_accessibility/ui_accessibility.cpp @@ -17,6 +17,7 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { bool testPolicyReset = false; bool testTree = false; bool testPasswordMode = false; + bool testHTML = false; size_t benchmarkItems = 1000; size_t benchmarkIterations = 100000; for ( int i = 1; i < argc; ++i ) { @@ -29,6 +30,7 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { testPolicyReset |= std::string_view( argv[i] ) == "--test-policy-reset"; testTree |= std::string_view( argv[i] ) == "--test-tree"; testPasswordMode |= std::string_view( argv[i] ) == "--test-password-mode"; + testHTML |= std::string_view( argv[i] ) == "--test-html"; if ( std::string_view( argv[i] ) == "--benchmark-items" && i + 1 < argc ) benchmarkItems = std::max( 1, std::strtoull( argv[++i], nullptr, 10 ) ); if ( std::string_view( argv[i] ) == "--benchmark-iterations" && i + 1 < argc ) @@ -126,6 +128,27 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { } auto status = content->find( "status" ); + if ( testHTML ) { + auto* html = scene->loadLayoutFromString( R"xml( +

HTML heading

+

HTML paragraph HTML link

+
  • HTML first item
  • HTML second item
+
)xml", + content ); + scene->setNavigationInterceptorCb( html, [status]( const NavigationRequest& ) { + status->setText( "HTML link activated" ); + return true; + } ); + auto* link = html->find( "html-link" ); + auto* unlink = UIPushButton::New(); + unlink->setParent( content ); + unlink->setText( "HTML unlink" ); + unlink->onClick( [link]( const MouseEvent* ) { link->setHref( "" ); } ); + auto* relink = UIPushButton::New(); + relink->setParent( content ); + relink->setText( "HTML relink" ); + relink->onClick( [link]( const MouseEvent* ) { link->setHref( "relative.html" ); } ); + } if ( benchmarkInactive ) { auto manager = scene->getAccessibilityManager(); Clock clock; diff --git a/src/tests/unit_tests/accessibility_html_tests.cpp b/src/tests/unit_tests/accessibility_html_tests.cpp new file mode 100644 index 000000000..c525a7d54 --- /dev/null +++ b/src/tests/unit_tests/accessibility_html_tests.cpp @@ -0,0 +1,283 @@ +#include "../../eepp/ui/accessibility/accessibilitybackend.hpp" +#include "utest.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::Window; + +namespace { + +UIApplication htmlApp() { + UIApplication::Settings settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ); + settings.accessibilityPolicy = AccessibilityPolicy::Disabled; + return UIApplication( WindowSettings( 640, 480, "HTML accessibility tests", + WindowStyle::Default | WindowStyle::Hidden, + WindowBackend::Default, 32, {}, 1, false, true ), + settings ); +} + +void collect( AccessibilityManager* manager, AccessibilityNodeRef ref, + std::vector& nodes ) { + nodes.push_back( ref ); + const auto count = manager->getChildCount( ref ); + for ( size_t i = 0; i < count; ++i ) + collect( manager, manager->getChild( ref, i ), nodes ); +} + +bool notified( AccessibilityManager* manager, AccessibilityNodeRef ref, AccessibilityEvent type ) { + for ( const auto& event : manager->getPendingEvents() ) { + if ( event.ref == ref && event.type == type ) + return true; + } + return false; +} + +} // namespace + +UTEST( AccessibilityHTML, DocumentRolesKeepFormattingTransparentAndControlsReachable ) { + auto app = htmlApp(); + auto* scene = app.getUI(); + scene->loadLayoutFromString( R"xml( +

Heading bold link

+

Before bold text after

+ + +
)xml" ); + scene->update( Time::Zero ); + auto* manager = scene->getAccessibilityManager(); + auto* heading = scene->find( "heading" ); + auto* link = scene->find( "heading-link" ); + auto* list = scene->find( "list" ); + auto* item = scene->find( "item" ); + ASSERT_TRUE( heading && link && list && item ); + const auto headingRef = manager->getNodeRef( heading ); + const auto linkRef = manager->getNodeRef( link ); + const auto listRef = manager->getNodeRef( list ); + const auto itemRef = manager->getNodeRef( item ); + EXPECT_EQ( manager->getNodeRole( headingRef ), AccessibilityRole::Heading ); + EXPECT_EQ( manager->getNodeInfo( headingRef ).headingLevel, 2 ); + EXPECT_TRUE( manager->getNodeInfo( headingRef ).name == "Heading bold link" ); + EXPECT_EQ( manager->getNodeRole( linkRef ), AccessibilityRole::Link ); + EXPECT_TRUE( manager->getNodeInfo( linkRef ).name == "link" ); + EXPECT_TRUE( manager->getParent( linkRef ) == headingRef ); + EXPECT_EQ( manager->getNodeRole( listRef ), AccessibilityRole::List ); + EXPECT_EQ( manager->getNodeRole( itemRef ), AccessibilityRole::ListItem ); + EXPECT_TRUE( manager->getParent( itemRef ) == listRef ); + EXPECT_TRUE( manager->getParent( manager->getNodeRef( scene->find( "nested" ) ) ) == + itemRef ); + EXPECT_FALSE( + hasState( manager->getNodeState( listRef ), AccessibilityState::SelectionContainer ) ); + EXPECT_FALSE( scene->find( "paragraph" )->isAccessibilityElement() ); + EXPECT_FALSE( scene->find( "format" )->isAccessibilityElement() ); + EXPECT_FALSE( AccessibilityWidgetResolver::isLeafRole( AccessibilityRole::Heading ) ); + EXPECT_FALSE( AccessibilityWidgetResolver::isLeafRole( AccessibilityRole::Link ) ); + + std::vector nodes; + collect( manager, manager->getRoot(), nodes ); + size_t boldOccurrences = 0; + for ( size_t i = 0; i < nodes.size(); ++i ) { + for ( size_t j = 0; j < i; ++j ) + EXPECT_FALSE( nodes[i] == nodes[j] ); + const auto info = manager->getNodeInfo( nodes[i] ); + EXPECT_FALSE( info.name == "Hidden paragraph" ); + if ( info.role == AccessibilityRole::Text && info.name == "bold text" ) + ++boldOccurrences; + } + EXPECT_EQ( boldOccurrences, 1u ); + auto* password = scene->find( "protected" )->getChildWidget(); + const auto passwordRef = manager->getNodeRef( password ); + EXPECT_TRUE( manager->getParent( passwordRef ) == headingRef ); + EXPECT_TRUE( hasState( manager->getNodeState( passwordRef ), AccessibilityState::Protected ) ); + EXPECT_TRUE( manager->getNodeInfo( passwordRef ).value.empty() ); + auto* modelList = UIListView::New(); + EXPECT_TRUE( + hasState( modelList->getAccessibilityState(), AccessibilityState::SelectionContainer ) ); +} + +UTEST( AccessibilityHTML, LinksActivateThroughNavigationWithoutOpeningOnFocus ) { + auto app = htmlApp(); + auto* scene = app.getUI(); + scene->loadLayoutFromString( + R"xml(Read morePlain anchor)xml" ); + auto* link = scene->find( "link" ); + ASSERT_TRUE( link != nullptr ); + auto* manager = scene->getAccessibilityManager(); + const auto ref = manager->getNodeRef( link ); + EXPECT_TRUE( manager->getNodeInfo( ref ).name == "Read more" ); + EXPECT_TRUE( hasAction( manager->getNodeInfo( ref ).actions, AccessibilityAction::Press ) ); + EXPECT_FALSE( manager->getNodeInfo( ref ).text.valid ); + int activations = 0; + const Node* activatedSource = nullptr; + std::string activatedURI; + scene->setNavigationInterceptorCb( [&]( const NavigationRequest& request ) { + ++activations; + activatedSource = request.source; + activatedURI = request.uri.toString(); + return true; + } ); + EXPECT_TRUE( manager->performAction( ref, { AccessibilityAction::Focus, {} } ) ); + EXPECT_EQ( activations, 0 ); + EXPECT_TRUE( manager->getKeyboardFocusedNode() == ref ); + manager->onNativeClientObserved(); + manager->clearPendingEvents(); + auto* fragment = scene->find( "fragment" ); + fragment->setFocus( NodeFocusReason::Click ); + EXPECT_TRUE( manager->getKeyboardFocusedNode() == ref ); + EXPECT_TRUE( hasState( manager->getNodeState( ref ), AccessibilityState::Focused ) ); + EXPECT_TRUE( notified( manager, ref, AccessibilityEvent::FocusChanged ) ); + EXPECT_FALSE( + notified( manager, manager->getNodeRef( fragment ), AccessibilityEvent::FocusChanged ) ); + auto* embedded = scene->find( "embedded" )->getChildWidget(); + embedded->setFocus(); + EXPECT_TRUE( manager->getKeyboardFocusedNode() == manager->getNodeRef( embedded ) ); + EXPECT_TRUE( manager->performAction( ref, { AccessibilityAction::Press, {} } ) ); + EXPECT_EQ( activations, 1 ); + EXPECT_TRUE( activatedSource == link ); + EXPECT_TRUE( activatedURI == "relative.html" ); + link->setEnabled( false ); + EXPECT_FALSE( manager->performAction( ref, { AccessibilityAction::Press, {} } ) ); + EXPECT_EQ( activations, 1 ); + auto* plain = scene->find( "plain" ); + EXPECT_EQ( plain->getAccessibilityRole(), AccessibilityRole::Text ); + EXPECT_FALSE( hasAction( plain->getAccessibilityActions(), AccessibilityAction::Press ) ); +} + +UTEST( AccessibilityHTML, TextAndHrefChangesUpdateQueriedAncestorsAndKeepIdentity ) { + auto app = htmlApp(); + auto* scene = app.getUI(); + scene->loadLayoutFromString( + R"xml(

Before

)xml" ); + auto* manager = scene->getAccessibilityManager(); + auto* heading = scene->find( "heading" ); + auto* link = scene->find( "link" ); + auto* text = scene->find( "text" ); + const auto headingRef = manager->getNodeRef( heading ); + const auto linkRef = manager->getNodeRef( link ); + const auto textRef = manager->getNodeRef( text ); + manager->onNativeClientObserved(); + manager->clearPendingEvents(); + text->setText( "After" ); + EXPECT_TRUE( manager->getNodeInfo( headingRef ).name == "After" ); + EXPECT_TRUE( manager->getNodeInfo( linkRef ).name == "After" ); + EXPECT_TRUE( notified( manager, textRef, AccessibilityEvent::NameChanged ) ); + EXPECT_TRUE( notified( manager, linkRef, AccessibilityEvent::NameChanged ) ); + EXPECT_TRUE( notified( manager, headingRef, AccessibilityEvent::NameChanged ) ); + link->setHref( "" ); + EXPECT_EQ( manager->getNodeRole( linkRef ), AccessibilityRole::None ); + EXPECT_TRUE( notified( manager, linkRef, AccessibilityEvent::RoleChanged ) ); + EXPECT_TRUE( manager->getParent( textRef ) == headingRef ); + link->setHref( "again.html" ); + EXPECT_TRUE( manager->getNodeRef( link ) == linkRef ); + EXPECT_EQ( manager->getNodeRole( linkRef ), AccessibilityRole::Link ); + EXPECT_TRUE( manager->getParent( textRef ) == linkRef ); +} + +UTEST( AccessibilityHTML, WhitespaceAndNonASCIITextExposureUpdatesTheCachedHierarchy ) { + auto app = htmlApp(); + auto* scene = app.getUI(); + auto* heading = UIRichText::NewH3(); + auto* text = UITextNode::New(); + text->setParent( heading ); + text->setText( " \n\t" ); + auto* manager = scene->getAccessibilityManager(); + const auto ref = manager->getNodeRef( heading ); + manager->onNativeClientObserved(); + EXPECT_EQ( manager->getChildCount( ref ), 0u ); + // U+10020 has a low byte of ASCII space; whitespace checks must not narrow code points. + text->setText( String::fromUtf8( std::string( "\xf0\x90\x80\xa0" ) ) ); + EXPECT_EQ( manager->getChildCount( ref ), 1u ); + const auto textRef = manager->getChild( ref, 0 ); + EXPECT_EQ( manager->getNodeRole( textRef ), AccessibilityRole::Text ); + EXPECT_EQ( manager->getNodeInfo( textRef ).name.size(), 1u ); + text->setText( "Replacement" ); + EXPECT_TRUE( manager->getChild( ref, 0 ) == textRef ); + text->setText( "" ); + EXPECT_EQ( manager->getChildCount( ref ), 0u ); +} + +UTEST( AccessibilityHTML, HeadingLevelsAreScalarAndExtensible ) { + auto app = htmlApp(); + auto* manager = app.getUI()->getAccessibilityManager(); + for ( int level = 1; level <= 6; ++level ) { + auto* heading = UIRichText::NewWithTag( "h" + std::to_string( level ) ); + const auto ref = manager->getNodeRef( heading ); + EXPECT_EQ( manager->getNodeRole( ref ), AccessibilityRole::Heading ); + EXPECT_EQ( manager->getNodeHeadingLevel( ref ), level ); + EXPECT_EQ( manager->getNodeInfo( ref ).headingLevel, level ); + } + class CustomHeading : public UIWidget { + public: + CustomHeading() : UIWidget( "custom-heading" ) {} + + AccessibilityRole getAccessibilityRole() const override { + return AccessibilityRole::Heading; + } + + Uint8 getAccessibilityHeadingLevel() const override { return 4; } + }; + auto* custom = eeNew( CustomHeading, () ); + const auto ref = manager->getNodeRef( custom ); + EXPECT_EQ( manager->getNodeHeadingLevel( ref ), 4 ); + EXPECT_EQ( manager->getNodeInfo( ref ).headingLevel, 4 ); +} + +UTEST( AccessibilityHTML, OwnTextDoesNotHideInteractiveChildrenAndAltNamesUpdateLinks ) { + auto app = htmlApp(); + auto* scene = app.getUI(); + auto* span = UITextSpan::New(); + span->setText( "Prefix" ); + auto* input = UIHTMLInput::New(); + input->setParent( span ); + auto* manager = scene->getAccessibilityManager(); + const auto spanRef = manager->getNodeRef( span ); + EXPECT_EQ( manager->getNodeRole( spanRef ), AccessibilityRole::Group ); + EXPECT_EQ( manager->getChildCount( spanRef ), 1u ); + EXPECT_TRUE( manager->getParent( manager->getNodeRef( input->getChildWidget() ) ) == spanRef ); + auto* link = UIAnchorSpan::New(); + link->setHref( "relative.html" ); + auto* image = UIHTMLImage::New(); + image->setParent( link ); + image->setAlt( "Image link" ); + const auto linkRef = manager->getNodeRef( link ); + const auto imageRef = manager->getNodeRef( image ); + EXPECT_TRUE( manager->getNodeInfo( linkRef ).name == "Image link" ); + EXPECT_EQ( manager->getNodeRole( imageRef ), AccessibilityRole::Image ); + manager->onNativeClientObserved(); + manager->clearPendingEvents(); + image->setAlt( "Updated image" ); + EXPECT_TRUE( manager->getNodeInfo( linkRef ).name == "Updated image" ); + EXPECT_TRUE( notified( manager, linkRef, AccessibilityEvent::NameChanged ) ); + image->setAlt( "" ); + EXPECT_EQ( manager->getNodeRole( imageRef ), AccessibilityRole::None ); + EXPECT_TRUE( notified( manager, imageRef, AccessibilityEvent::RoleChanged ) ); + EXPECT_EQ( manager->getChildCount( linkRef ), 0u ); +} + +UTEST( AccessibilityHTML, DormantConstructionAndEditsDoNotCreateAManager ) { + auto app = htmlApp(); + auto* scene = app.getUI(); + const auto* constScene = static_cast( scene ); + ASSERT_TRUE( constScene->getAccessibilityManager() == nullptr ); + scene->loadLayoutFromString( + R"xml(

Heading

Text Link

  • Item
)xml" ); + auto* link = scene->find( "link" ); + link->setText( "Updated" ); + link->setHref( "second.html" ); + scene->update( Time::Zero ); + EXPECT_TRUE( constScene->getAccessibilityManager() == nullptr ); + EXPECT_FALSE( scene->hasActiveAccessibilityClients() ); +} diff --git a/src/tests/unit_tests/accessibility_macos_tests.mm b/src/tests/unit_tests/accessibility_macos_tests.mm index 1b402bd30..d72f828d4 100644 --- a/src/tests/unit_tests/accessibility_macos_tests.mm +++ b/src/tests/unit_tests/accessibility_macos_tests.mm @@ -5,10 +5,12 @@ #include #include #include +#include #include #include #include #include +#include #define FileInfo AppleFileInfo #define Rect AppleRect @@ -43,6 +45,46 @@ NSObject* semanticRootForWindow( NSWindow* window ) { } // namespace +UTEST( AccessibilityMacOS, HTMLHeadingsLinksAndListsExposeNativeSemantics ) { + UIApplication app( WindowSettings( 320, 240, "eepp - HTML Accessibility Test", + WindowStyle::Default | WindowStyle::Hidden ), + UIApplication::Settings( Sys::getProcessPath() ) ); + if ( !app.getUI() ) + UTEST_SKIP( "a logged-in macOS graphical session is required" ); + auto* scene = app.getUI(); + scene->loadLayoutFromString( + R"xml(

HTML heading

HTML link
  • HTML item
)xml" ); + scene->getAccessibilityManager()->update(); + NSWindow* window = reinterpret_cast( scene->getWindow()->getWindowHandler() ); + NSObject* root = semanticRootForWindow( window ); + ASSERT_TRUE( root != nil ); + NSArray* children = root.accessibilityChildren; + ASSERT_EQ( children.count, 3u ); + NSObject* heading = children[0]; + EXPECT_TRUE( [heading.accessibilityRole isEqualToString:@"AXHeading"] ); + EXPECT_TRUE( [heading.accessibilityLabel isEqualToString:@"HTML heading"] ); + EXPECT_TRUE( [heading.accessibilityValue isEqual:@2] ); + EXPECT_TRUE( + [[heading accessibilityAttributeNames] containsObject:NSAccessibilityValueAttribute] ); + NSObject* link = children[1]; + EXPECT_TRUE( [link.accessibilityRole isEqualToString:NSAccessibilityLinkRole] ); + EXPECT_TRUE( [link.accessibilityLabel isEqualToString:@"HTML link"] ); + int activations = 0; + scene->setNavigationInterceptorCb( [&]( const NavigationRequest& ) { + ++activations; + return true; + } ); + EXPECT_TRUE( [link accessibilityPerformPress] ); + EXPECT_EQ( activations, 1 ); + scene->find( "link" )->setHref( "" ); + EXPECT_TRUE( [link.accessibilityRole isEqualToString:NSAccessibilityStaticTextRole] ); + EXPECT_FALSE( [link accessibilityPerformPress] ); + EXPECT_EQ( activations, 1 ); + NSObject* list = children[2]; + EXPECT_TRUE( [list.accessibilityRole isEqualToString:NSAccessibilityListRole] ); + EXPECT_EQ( list.accessibilityChildren.count, 1u ); +} + UTEST( AccessibilityMacOS, PasswordModeTransitionsRevalidateRetainedTextElements ) { UIApplication app( WindowSettings( 320, 240, "eepp - Password Accessibility Test", WindowStyle::Default | WindowStyle::Hidden ), diff --git a/src/tests/windows_accessibility/main.cpp b/src/tests/windows_accessibility/main.cpp index f5c7defcd..8313e08f0 100644 --- a/src/tests/windows_accessibility/main.cpp +++ b/src/tests/windows_accessibility/main.cpp @@ -32,6 +32,8 @@ void testProviderScalarQueries(); void testProviderEventAdviceABI(); +void testProviderHTMLSemantics(); + namespace { using Clock = std::chrono::steady_clock; @@ -257,6 +259,9 @@ class EventSubscriptions { } void subscribe() { + // MinGW SDKs disagree on Subtree/SubTree spelling; compose the standard scope flags. + constexpr auto subtreeScope = static_cast( + TreeScope_Element | TreeScope_Children | TreeScope_Descendants ); PROPERTYID properties[] = { UIA_NamePropertyId, UIA_HelpTextPropertyId, @@ -271,7 +276,7 @@ class EventSubscriptions { UIA_ExpandCollapseExpandCollapseStatePropertyId, }; requireResult( mAutomation->AddPropertyChangedEventHandlerNativeArray( - mRoot, TreeScope_Subtree, nullptr, mRecorder, properties, + mRoot, subtreeScope, nullptr, mRecorder, properties, static_cast( std::size( properties ) ) ), "AddPropertyChangedEventHandler" ); mProperties = true; @@ -279,14 +284,14 @@ class EventSubscriptions { UIA_Text_TextChangedEventId, UIA_Text_TextSelectionChangedEventId, UIA_SelectionItem_ElementSelectedEventId, UIA_LayoutInvalidatedEventId }; for ( EVENTID eventId : events ) { - requireResult( mAutomation->AddAutomationEventHandler( - eventId, mRoot, TreeScope_Subtree, nullptr, mRecorder ), + requireResult( mAutomation->AddAutomationEventHandler( eventId, mRoot, subtreeScope, + nullptr, mRecorder ), "AddAutomationEventHandler" ); mAutomationEvents.emplace_back( eventId ); } - requireResult( mAutomation->AddStructureChangedEventHandler( mRoot, TreeScope_Subtree, - nullptr, mRecorder ), - "AddStructureChangedEventHandler" ); + requireResult( + mAutomation->AddStructureChangedEventHandler( mRoot, subtreeScope, nullptr, mRecorder ), + "AddStructureChangedEventHandler" ); mStructure = true; requireResult( mAutomation->AddFocusChangedEventHandler( nullptr, mRecorder ), "AddFocusChangedEventHandler" ); @@ -633,6 +638,7 @@ int wmain( int argc, wchar_t** argv ) { try { record( "provider event advice ABI", testProviderEventAdviceABI ); + record( "provider HTML semantics", testProviderHTMLSemantics ); record( "provider selection teardown", testProviderSelectionTeardown ); record( "provider text range window identity", testProviderTextRangeIdentity ); record( "provider policy change during dispatch", testProviderPolicyChangeDuringDispatch ); diff --git a/src/tests/windows_accessibility/provider_tests.cpp b/src/tests/windows_accessibility/provider_tests.cpp index 7e65b7590..2ddc7ce05 100644 --- a/src/tests/windows_accessibility/provider_tests.cpp +++ b/src/tests/windows_accessibility/provider_tests.cpp @@ -7,7 +7,9 @@ #include #include #include +#include #include +#include #include #include @@ -228,6 +230,66 @@ void testProviderPolicyChangeDuringDispatch() { scene->setAccessibilityPolicy( AccessibilityPolicy::Disabled ); } +void testProviderHTMLSemantics() { + UIApplication app( testWindow(), testSettings() ); + require( app.getUI() != nullptr, "hidden test window could not be created" ); + auto* scene = app.getUI(); + scene->loadLayoutFromString( + R"xml(

Heading

Link
  • Item
)xml" ); + auto* manager = scene->getAccessibilityManager(); + auto context = std::make_shared( *manager, nullptr, 1 ); + std::unique_ptr heading( + context->provider( manager->getNodeRef( scene->find( "heading" ) ) ) ); + require( heading != nullptr, "HTML heading provider missing" ); + VARIANT level; + VariantInit( &level ); + const HRESULT levelResult = heading->GetPropertyValue( UIA_HeadingLevelPropertyId, &level ); + const bool validLevel = level.vt == VT_I4 && level.lVal == 80052; + VariantClear( &level ); + require( levelResult == S_OK && validLevel, "HTML UIA heading level is wrong" ); + auto* anchor = scene->find( "link" ); + std::unique_ptr link( + context->provider( manager->getNodeRef( anchor ) ) ); + require( link != nullptr, "HTML link provider missing" ); + VARIANT role; + VariantInit( &role ); + const HRESULT roleResult = link->GetPropertyValue( UIA_ControlTypePropertyId, &role ); + const bool validRole = role.vt == VT_I4 && role.lVal == UIA_HyperlinkControlTypeId; + VariantClear( &role ); + require( roleResult == S_OK && validRole, "HTML UIA hyperlink role is wrong" ); + IInvokeProvider* rawInvoke{}; + const HRESULT invokeResult = + link->QueryInterface( __uuidof( IInvokeProvider ), reinterpret_cast( &rawInvoke ) ); + std::unique_ptr invoke( rawInvoke ); + require( invokeResult == S_OK && invoke != nullptr, "HTML link did not expose Invoke" ); + int activations = 0; + scene->setNavigationInterceptorCb( [&]( const NavigationRequest& request ) { + ++activations; + require( request.source == anchor && request.uri.toString() == "relative.html", + "HTML link did not use the navigation path" ); + return true; + } ); + require( invoke->Invoke() == S_OK && activations == 1, "HTML link Invoke failed" ); + anchor->setHref( "" ); + VariantInit( &role ); + const HRESULT plainResult = link->GetPropertyValue( UIA_ControlTypePropertyId, &role ); + const bool plainRole = role.vt == VT_I4 && role.lVal == UIA_TextControlTypeId; + VariantClear( &role ); + require( plainResult == S_OK && plainRole, "retained HTML link did not change its role" ); + require( invoke->Invoke() != S_OK && activations == 1, + "retained Invoke activated an anchor without href" ); + std::unique_ptr list( + context->provider( manager->getNodeRef( scene->find( "list" ) ) ) ); + require( list != nullptr, "HTML list provider missing" ); + ISelectionProvider* selection{}; + const HRESULT selectionResult = list->QueryInterface( __uuidof( ISelectionProvider ), + reinterpret_cast( &selection ) ); + if ( selection ) + selection->Release(); + require( selectionResult == E_NOINTERFACE && selection == nullptr, + "non-interactive HTML list advertised Selection" ); +} + void testProviderScalarQueries() { UIApplication app( testWindow(), testSettings() ); require( app.getUI() != nullptr, "hidden test window could not be created" );