diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index 2babbaf6b1..b240ed1502 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.cpp +++ b/Telegram/SourceFiles/history/history_inner_widget.cpp @@ -2408,6 +2408,10 @@ void HistoryInner::itemRemoved(not_null item) { if (_dragStateItem == item) { _dragStateItem = nullptr; } + if (_accessibilityFocusedItem == item) { + _accessibilityFocusedItem = nullptr; + } + _accessibilityIdentities.remove(item); if ((_dragSelFrom && _dragSelFrom->data() == item) || (_dragSelTo && _dragSelTo->data() == item)) { @@ -6440,3 +6444,142 @@ void HistoryInner::focusInEvent(QFocusEvent *e) { setAccessibilityFocusedItem(index, item); }); } + +bool HistoryInner::accessibilityChildSupportsActions(int index) const { + // Every message row can be focused and activated and has a stable + // identity below. Tying the opt-in to a valid identity keeps the + // action interface off invalid indices and off the unread bar row, + // which has no meaningful press action. + return accessibilityChildIdentity(index) != 0; +} + +quintptr HistoryInner::accessibilityChildIdentity(int index) const { + // Child indices shift whenever messages are inserted or removed and + // the unread bar appears or goes away, so a queued action must not + // be dispatched by index. A raw HistoryItem pointer is not a safe + // token either: items are destroyed all the time and a new message + // can be allocated at the same address, silently rebinding a stale + // provider to an unrelated row (ABA). So the first request issues + // the item a token from a monotonic counter; itemRemoved() erases + // the pointer->token entry, and an item reusing the address gets a + // fresh token, so stale identities resolve to nothing. The unread + // bar row deliberately has no identity (and no action interface). + const auto barIndex = accessibilityUnreadBarIndex(); + if (barIndex >= 0 && index == barIndex) { + return 0; + } + const auto elements = accessibleElements(); + const auto elementIndex = (barIndex >= 0 && index > barIndex) + ? (index - 1) + : index; + if (elementIndex < 0 || elementIndex >= int(elements.size())) { + return 0; + } + const auto item = elements[elementIndex]->data(); + const auto i = _accessibilityIdentities.find(item); + if (i != _accessibilityIdentities.end()) { + return i->second; + } + const auto token = ++_accessibilityIdentityCounter; + _accessibilityIdentities.emplace(item, token); + return token; +} + +int HistoryInner::accessibilityChildIndexByIdentity( + quintptr identity) const { + // One pass over the elements looking each item up in the issued + // tokens map: only an item that was already handed out a token can + // match, so rows never seen by the accessibility layer just do not + // compare equal. + if (!identity) { + return -1; + } + const auto elements = accessibleElements(); + const auto barIndex = accessibilityUnreadBarIndex(); + for (auto i = 0, n = int(elements.size()); i != n; ++i) { + const auto j = _accessibilityIdentities.find( + elements[i]->data()); + if (j != _accessibilityIdentities.end() + && j->second == identity) { + return (barIndex >= 0 && i >= barIndex) ? (i + 1) : i; + } + } + return -1; +} + +void HistoryInner::applyAccessibilityFocus( + int index, + bool announceAlways) { + const auto elements = accessibleElements(); + const auto barIndex = accessibilityUnreadBarIndex(); + const auto elementIndex = (barIndex >= 0 && index > barIndex) + ? (index - 1) + : index; + const auto item = (elementIndex >= 0 + && elementIndex < int(elements.size())) + ? elements[elementIndex]->data().get() + : nullptr; + const auto changed = (_accessibilityFocusedIndex != index) + || (_accessibilityFocusedItem != item); + _accessibilityFocusedIndex = index; + _accessibilityFocusedItem = item; + // Exactly one announcement: directly when the widget already has + // focus, via focusInEvent when keyboard focus is being taken. + if (hasFocus()) { + if (changed || announceAlways) { + announceAccessibilityFocus(index); + } + } else { + setFocus(); + } + const auto rect = accessibilityChildRect(index); + if (!rect.isEmpty()) { + if (rect.top() < _visibleAreaTop) { + _scroll->scrollToY(rect.top()); + } else if (rect.bottom() > _visibleAreaBottom) { + _scroll->scrollToY(rect.bottom() + - (_visibleAreaBottom - _visibleAreaTop)); + } + } + if (_widget->markingMessagesRead() + && (barIndex < 0 || index != barIndex) + && elementIndex >= 0 + && elementIndex < int(elements.size())) { + session().data().histories().readInboxTill( + elements[elementIndex]->data()); + } +} + +void HistoryInner::accessibilityChildSetFocus(quintptr identity) { + // UIA invokes provider actions (SetFocus) on a background thread, so + // hop to the main thread before touching any widget state. Resolve + // the stable identity to its current index here (not on the + // background thread) so a list mutation does not move focus to + // another row. + crl::on_main(this, [=] { + // An explicit accessibility SetFocus is itself sufficient + // authorization, so we do not gate it on the screen-reader-mode + // detector: the UIA provider already reported success to the + // caller, and the detector may still be false during startup or + // for valid clients that are not on its allowlist. + const auto index = accessibilityChildIndexByIdentity(identity); + if (index < 0) { + return; + } + applyAccessibilityFocus(index, true); + }); +} + +void HistoryInner::accessibilityChildActivate(quintptr identity) { + // A mouse click on a message body performs no action, so Invoke + // mirrors the click and only takes the accessibility focus onto the + // row. Same background-thread hop and identity resolution as + // SetFocus above. + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + if (index < 0) { + return; + } + applyAccessibilityFocus(index, true); + }); +} diff --git a/Telegram/SourceFiles/history/history_inner_widget.h b/Telegram/SourceFiles/history/history_inner_widget.h index 47fa95a093..eae4140416 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.h +++ b/Telegram/SourceFiles/history/history_inner_widget.h @@ -133,6 +133,12 @@ public: int row, int column) const override; QString accessibilityChildSubItemValue( int row, int column) const override; + bool accessibilityChildSupportsActions(int index) const override; + quintptr accessibilityChildIdentity(int index) const override; + int accessibilityChildIndexByIdentity( + quintptr identity) const override; + void accessibilityChildSetFocus(quintptr identity) override; + void accessibilityChildActivate(quintptr identity) override; [[nodiscard]] Main::Session &session() const; [[nodiscard]] not_null theme() const { @@ -307,6 +313,7 @@ private: void playPauseFocusedMedia(); void setAccessibilityFocusedItem(int index, HistoryItem *item); void announceAccessibilityFocus(int index); + void applyAccessibilityFocus(int index, bool announceAlways); [[nodiscard]] auto computeActiveColumns(int row) const -> const std::vector &; @@ -551,6 +558,10 @@ private: int _accessibilityFocusedIndex = -1; HistoryItem *_accessibilityFocusedItem = nullptr; + mutable base::flat_map< + not_null, + quintptr> _accessibilityIdentities; + mutable quintptr _accessibilityIdentityCounter = 0; mutable const HistoryView::Element *_activeColumnsView = nullptr; mutable std::vector _activeColumns; diff --git a/Telegram/SourceFiles/history/view/history_view_list_widget.cpp b/Telegram/SourceFiles/history/view/history_view_list_widget.cpp index 11b19dd914..3632e33366 100644 --- a/Telegram/SourceFiles/history/view/history_view_list_widget.cpp +++ b/Telegram/SourceFiles/history/view/history_view_list_widget.cpp @@ -4899,6 +4899,7 @@ void ListWidget::itemRemoved(not_null item) { if (_accessibilityFocusedItem == item) { _accessibilityFocusedItem = nullptr; } + _accessibilityIdentities.remove(item); const auto i = _views.find(item); if (i == end(_views)) { return; @@ -5391,6 +5392,144 @@ void ListWidget::focusInEvent(QFocusEvent *e) { }); } +bool ListWidget::accessibilityChildSupportsActions(int index) const { + // Every message row can be focused and activated and has a stable + // identity below. Tying the opt-in to a valid identity keeps the + // action interface off invalid indices and off the unread bar row, + // which has no meaningful press action. + return accessibilityChildIdentity(index) != 0; +} + +quintptr ListWidget::accessibilityChildIdentity(int index) const { + // Child indices shift whenever messages are inserted or removed and + // the unread bar appears or goes away, so a queued action must not + // be dispatched by index. A raw HistoryItem pointer is not a safe + // token either: items are destroyed all the time and a new message + // can be allocated at the same address, silently rebinding a stale + // provider to an unrelated row (ABA). So the first request issues + // the item a token from a monotonic counter; itemRemoved() erases + // the pointer->token entry, and an item reusing the address gets a + // fresh token, so stale identities resolve to nothing. The unread + // bar row deliberately has no identity (and no action interface). + const auto barIndex = accessibilityUnreadBarIndex(); + if (barIndex >= 0 && index == barIndex) { + return 0; + } + const auto elements = accessibleElements(); + const auto elementIndex = (barIndex >= 0 && index > barIndex) + ? (index - 1) + : index; + if (elementIndex < 0 || elementIndex >= int(elements.size())) { + return 0; + } + const auto item = elements[elementIndex]->data(); + const auto i = _accessibilityIdentities.find(item); + if (i != _accessibilityIdentities.end()) { + return i->second; + } + const auto token = ++_accessibilityIdentityCounter; + _accessibilityIdentities.emplace(item, token); + return token; +} + +int ListWidget::accessibilityChildIndexByIdentity( + quintptr identity) const { + // One pass over the elements looking each item up in the issued + // tokens map: only an item that was already handed out a token can + // match, so rows never seen by the accessibility layer just do not + // compare equal. + if (!identity) { + return -1; + } + const auto elements = accessibleElements(); + const auto barIndex = accessibilityUnreadBarIndex(); + for (auto i = 0, n = int(elements.size()); i != n; ++i) { + const auto j = _accessibilityIdentities.find( + elements[i]->data()); + if (j != _accessibilityIdentities.end() + && j->second == identity) { + return (barIndex >= 0 && i >= barIndex) ? (i + 1) : i; + } + } + return -1; +} + +void ListWidget::applyAccessibilityFocus( + int index, + bool announceAlways) { + const auto elements = accessibleElements(); + const auto barIndex = accessibilityUnreadBarIndex(); + const auto elementIndex = (barIndex >= 0 && index > barIndex) + ? (index - 1) + : index; + const auto item = (elementIndex >= 0 + && elementIndex < int(elements.size())) + ? elements[elementIndex]->data().get() + : nullptr; + const auto changed = (_accessibilityFocusedIndex != index) + || (_accessibilityFocusedItem != item); + _accessibilityFocusedIndex = index; + _accessibilityFocusedItem = item; + // Exactly one announcement: directly when the widget already has + // focus, via focusInEvent when keyboard focus is being taken. + if (hasFocus()) { + if (changed || announceAlways) { + announceAccessibilityFocus(index); + } + } else { + setFocus(); + } + const auto rect = accessibilityChildRect(index); + if (!rect.isEmpty()) { + if (rect.top() < _visibleTop) { + _delegate->listScrollTo(rect.top()); + } else if (rect.bottom() > _visibleBottom) { + _delegate->listScrollTo(rect.bottom() + - (_visibleBottom - _visibleTop)); + } + } + if (markingMessagesRead() + && (barIndex < 0 || index != barIndex) + && elementIndex >= 0 + && elementIndex < int(elements.size())) { + _delegate->listMarkReadTill(elements[elementIndex]->data()); + } +} + +void ListWidget::accessibilityChildSetFocus(quintptr identity) { + // UIA invokes provider actions (SetFocus) on a background thread, so + // hop to the main thread before touching any widget state. Resolve + // the stable identity to its current index here (not on the + // background thread) so a list mutation does not move focus to + // another row. + crl::on_main(this, [=] { + // An explicit accessibility SetFocus is itself sufficient + // authorization, so we do not gate it on the screen-reader-mode + // detector: the UIA provider already reported success to the + // caller, and the detector may still be false during startup or + // for valid clients that are not on its allowlist. + const auto index = accessibilityChildIndexByIdentity(identity); + if (index < 0) { + return; + } + applyAccessibilityFocus(index, true); + }); +} + +void ListWidget::accessibilityChildActivate(quintptr identity) { + // A mouse click on a message body performs no action, so Invoke + // mirrors the click and only takes the accessibility focus onto the + // row. Same background-thread hop and identity resolution as + // SetFocus above. + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + if (index < 0) { + return; + } + applyAccessibilityFocus(index, true); + }); +} + void ConfirmDeleteSelectedItems(not_null widget) { const auto items = widget->getSelectedItems(); if (items.empty()) { diff --git a/Telegram/SourceFiles/history/view/history_view_list_widget.h b/Telegram/SourceFiles/history/view/history_view_list_widget.h index b90bba0867..ac09106881 100644 --- a/Telegram/SourceFiles/history/view/history_view_list_widget.h +++ b/Telegram/SourceFiles/history/view/history_view_list_widget.h @@ -507,6 +507,12 @@ public: int row, int column) const override; QString accessibilityChildSubItemValue( int row, int column) const override; + bool accessibilityChildSupportsActions(int index) const override; + quintptr accessibilityChildIdentity(int index) const override; + int accessibilityChildIndexByIdentity( + quintptr identity) const override; + void accessibilityChildSetFocus(quintptr identity) override; + void accessibilityChildActivate(quintptr identity) override; ~ListWidget(); @@ -550,6 +556,7 @@ private: void playPauseFocusedMedia(); void setAccessibilityFocusedItem(int index, HistoryItem *item); void announceAccessibilityFocus(int index); + void applyAccessibilityFocus(int index, bool announceAlways); [[nodiscard]] auto computeActiveColumns(int row) const -> const std::vector &; @@ -931,6 +938,10 @@ private: int _accessibilityFocusedIndex = -1; HistoryItem *_accessibilityFocusedItem = nullptr; + mutable base::flat_map< + not_null, + quintptr> _accessibilityIdentities; + mutable quintptr _accessibilityIdentityCounter = 0; mutable const HistoryView::Element *_activeColumnsView = nullptr; mutable std::vector _activeColumns;