Support screen reader focus and activate on message history rows

This commit is contained in:
Reza Bakhshi Laktasaraei
2026-07-08 19:49:09 +03:30
committed by John Preston
parent 27f41580d2
commit aeb3e6be75
4 changed files with 304 additions and 0 deletions
@@ -2408,6 +2408,10 @@ void HistoryInner::itemRemoved(not_null<const HistoryItem*> 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);
});
}
@@ -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<Ui::ChatTheme*> 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<HistoryView::MessageSubItem> &;
@@ -551,6 +558,10 @@ private:
int _accessibilityFocusedIndex = -1;
HistoryItem *_accessibilityFocusedItem = nullptr;
mutable base::flat_map<
not_null<const HistoryItem*>,
quintptr> _accessibilityIdentities;
mutable quintptr _accessibilityIdentityCounter = 0;
mutable const HistoryView::Element *_activeColumnsView = nullptr;
mutable std::vector<HistoryView::MessageSubItem> _activeColumns;
@@ -4899,6 +4899,7 @@ void ListWidget::itemRemoved(not_null<const HistoryItem*> 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<ListWidget*> widget) {
const auto items = widget->getSelectedItems();
if (items.empty()) {
@@ -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<HistoryView::MessageSubItem> &;
@@ -931,6 +938,10 @@ private:
int _accessibilityFocusedIndex = -1;
HistoryItem *_accessibilityFocusedItem = nullptr;
mutable base::flat_map<
not_null<const HistoryItem*>,
quintptr> _accessibilityIdentities;
mutable quintptr _accessibilityIdentityCounter = 0;
mutable const HistoryView::Element *_activeColumnsView = nullptr;
mutable std::vector<HistoryView::MessageSubItem> _activeColumns;