Skip to content

Commit 846f292

Browse files
Support screen reader focus and activate on message history rows
1 parent 27f4158 commit 846f292

4 files changed

Lines changed: 304 additions & 0 deletions

File tree

Telegram/SourceFiles/history/history_inner_widget.cpp

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2408,6 +2408,10 @@ void HistoryInner::itemRemoved(not_null<const HistoryItem*> item) {
24082408
if (_dragStateItem == item) {
24092409
_dragStateItem = nullptr;
24102410
}
2411+
if (_accessibilityFocusedItem == item) {
2412+
_accessibilityFocusedItem = nullptr;
2413+
}
2414+
_accessibilityIdentities.remove(item);
24112415

24122416
if ((_dragSelFrom && _dragSelFrom->data() == item)
24132417
|| (_dragSelTo && _dragSelTo->data() == item)) {
@@ -6440,3 +6444,142 @@ void HistoryInner::focusInEvent(QFocusEvent *e) {
64406444
setAccessibilityFocusedItem(index, item);
64416445
});
64426446
}
6447+
6448+
bool HistoryInner::accessibilityChildSupportsActions(int index) const {
6449+
// Every message row can be focused and activated and has a stable
6450+
// identity below. Tying the opt-in to a valid identity keeps the
6451+
// action interface off invalid indices and off the unread bar row,
6452+
// which has no meaningful press action.
6453+
return accessibilityChildIdentity(index) != 0;
6454+
}
6455+
6456+
quintptr HistoryInner::accessibilityChildIdentity(int index) const {
6457+
// Child indices shift whenever messages are inserted or removed and
6458+
// the unread bar appears or goes away, so a queued action must not
6459+
// be dispatched by index. A raw HistoryItem pointer is not a safe
6460+
// token either: items are destroyed all the time and a new message
6461+
// can be allocated at the same address, silently rebinding a stale
6462+
// provider to an unrelated row (ABA). So the first request issues
6463+
// the item a token from a monotonic counter; itemRemoved() erases
6464+
// the pointer->token entry, and an item reusing the address gets a
6465+
// fresh token, so stale identities resolve to nothing. The unread
6466+
// bar row deliberately has no identity (and no action interface).
6467+
const auto barIndex = accessibilityUnreadBarIndex();
6468+
if (barIndex >= 0 && index == barIndex) {
6469+
return 0;
6470+
}
6471+
const auto elements = accessibleElements();
6472+
const auto elementIndex = (barIndex >= 0 && index > barIndex)
6473+
? (index - 1)
6474+
: index;
6475+
if (elementIndex < 0 || elementIndex >= int(elements.size())) {
6476+
return 0;
6477+
}
6478+
const auto item = elements[elementIndex]->data();
6479+
const auto i = _accessibilityIdentities.find(item);
6480+
if (i != _accessibilityIdentities.end()) {
6481+
return i->second;
6482+
}
6483+
const auto token = ++_accessibilityIdentityCounter;
6484+
_accessibilityIdentities.emplace(item, token);
6485+
return token;
6486+
}
6487+
6488+
int HistoryInner::accessibilityChildIndexByIdentity(
6489+
quintptr identity) const {
6490+
// One pass over the elements looking each item up in the issued
6491+
// tokens map: only an item that was already handed out a token can
6492+
// match, so rows never seen by the accessibility layer just do not
6493+
// compare equal.
6494+
if (!identity) {
6495+
return -1;
6496+
}
6497+
const auto elements = accessibleElements();
6498+
const auto barIndex = accessibilityUnreadBarIndex();
6499+
for (auto i = 0, n = int(elements.size()); i != n; ++i) {
6500+
const auto j = _accessibilityIdentities.find(
6501+
elements[i]->data());
6502+
if (j != _accessibilityIdentities.end()
6503+
&& j->second == identity) {
6504+
return (barIndex >= 0 && i >= barIndex) ? (i + 1) : i;
6505+
}
6506+
}
6507+
return -1;
6508+
}
6509+
6510+
void HistoryInner::applyAccessibilityFocus(
6511+
int index,
6512+
bool announceAlways) {
6513+
const auto elements = accessibleElements();
6514+
const auto barIndex = accessibilityUnreadBarIndex();
6515+
const auto elementIndex = (barIndex >= 0 && index > barIndex)
6516+
? (index - 1)
6517+
: index;
6518+
const auto item = (elementIndex >= 0
6519+
&& elementIndex < int(elements.size()))
6520+
? elements[elementIndex]->data().get()
6521+
: nullptr;
6522+
const auto changed = (_accessibilityFocusedIndex != index)
6523+
|| (_accessibilityFocusedItem != item);
6524+
_accessibilityFocusedIndex = index;
6525+
_accessibilityFocusedItem = item;
6526+
// Exactly one announcement: directly when the widget already has
6527+
// focus, via focusInEvent when keyboard focus is being taken.
6528+
if (hasFocus()) {
6529+
if (changed || announceAlways) {
6530+
announceAccessibilityFocus(index);
6531+
}
6532+
} else {
6533+
setFocus();
6534+
}
6535+
const auto rect = accessibilityChildRect(index);
6536+
if (!rect.isEmpty()) {
6537+
if (rect.top() < _visibleAreaTop) {
6538+
_scroll->scrollToY(rect.top());
6539+
} else if (rect.bottom() > _visibleAreaBottom) {
6540+
_scroll->scrollToY(rect.bottom()
6541+
- (_visibleAreaBottom - _visibleAreaTop));
6542+
}
6543+
}
6544+
if (_widget->markingMessagesRead()
6545+
&& (barIndex < 0 || index != barIndex)
6546+
&& elementIndex >= 0
6547+
&& elementIndex < int(elements.size())) {
6548+
session().data().histories().readInboxTill(
6549+
elements[elementIndex]->data());
6550+
}
6551+
}
6552+
6553+
void HistoryInner::accessibilityChildSetFocus(quintptr identity) {
6554+
// UIA invokes provider actions (SetFocus) on a background thread, so
6555+
// hop to the main thread before touching any widget state. Resolve
6556+
// the stable identity to its current index here (not on the
6557+
// background thread) so a list mutation does not move focus to
6558+
// another row.
6559+
crl::on_main(this, [=] {
6560+
// An explicit accessibility SetFocus is itself sufficient
6561+
// authorization, so we do not gate it on the screen-reader-mode
6562+
// detector: the UIA provider already reported success to the
6563+
// caller, and the detector may still be false during startup or
6564+
// for valid clients that are not on its allowlist.
6565+
const auto index = accessibilityChildIndexByIdentity(identity);
6566+
if (index < 0) {
6567+
return;
6568+
}
6569+
applyAccessibilityFocus(index, true);
6570+
});
6571+
}
6572+
6573+
void HistoryInner::accessibilityChildActivate(quintptr identity) {
6574+
// A mouse click on a message body performs no action, so Invoke
6575+
// mirrors the click and only takes the accessibility focus onto the
6576+
// row. Same background-thread hop and identity resolution as
6577+
// SetFocus above.
6578+
crl::on_main(this, [=] {
6579+
const auto index = accessibilityChildIndexByIdentity(identity);
6580+
if (index < 0) {
6581+
return;
6582+
}
6583+
applyAccessibilityFocus(index, true);
6584+
});
6585+
}

Telegram/SourceFiles/history/history_inner_widget.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,12 @@ class HistoryInner
133133
int row, int column) const override;
134134
QString accessibilityChildSubItemValue(
135135
int row, int column) const override;
136+
bool accessibilityChildSupportsActions(int index) const override;
137+
quintptr accessibilityChildIdentity(int index) const override;
138+
int accessibilityChildIndexByIdentity(
139+
quintptr identity) const override;
140+
void accessibilityChildSetFocus(quintptr identity) override;
141+
void accessibilityChildActivate(quintptr identity) override;
136142

137143
[[nodiscard]] Main::Session &session() const;
138144
[[nodiscard]] not_null<Ui::ChatTheme*> theme() const {
@@ -307,6 +313,7 @@ class HistoryInner
307313
void playPauseFocusedMedia();
308314
void setAccessibilityFocusedItem(int index, HistoryItem *item);
309315
void announceAccessibilityFocus(int index);
316+
void applyAccessibilityFocus(int index, bool announceAlways);
310317
[[nodiscard]] auto computeActiveColumns(int row) const
311318
-> const std::vector<HistoryView::MessageSubItem> &;
312319

@@ -551,6 +558,10 @@ class HistoryInner
551558

552559
int _accessibilityFocusedIndex = -1;
553560
HistoryItem *_accessibilityFocusedItem = nullptr;
561+
mutable base::flat_map<
562+
not_null<const HistoryItem*>,
563+
quintptr> _accessibilityIdentities;
564+
mutable quintptr _accessibilityIdentityCounter = 0;
554565
mutable const HistoryView::Element *_activeColumnsView = nullptr;
555566
mutable std::vector<HistoryView::MessageSubItem> _activeColumns;
556567

Telegram/SourceFiles/history/view/history_view_list_widget.cpp

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4899,6 +4899,7 @@ void ListWidget::itemRemoved(not_null<const HistoryItem*> item) {
48994899
if (_accessibilityFocusedItem == item) {
49004900
_accessibilityFocusedItem = nullptr;
49014901
}
4902+
_accessibilityIdentities.remove(item);
49024903
const auto i = _views.find(item);
49034904
if (i == end(_views)) {
49044905
return;
@@ -5391,6 +5392,144 @@ void ListWidget::focusInEvent(QFocusEvent *e) {
53915392
});
53925393
}
53935394

5395+
bool ListWidget::accessibilityChildSupportsActions(int index) const {
5396+
// Every message row can be focused and activated and has a stable
5397+
// identity below. Tying the opt-in to a valid identity keeps the
5398+
// action interface off invalid indices and off the unread bar row,
5399+
// which has no meaningful press action.
5400+
return accessibilityChildIdentity(index) != 0;
5401+
}
5402+
5403+
quintptr ListWidget::accessibilityChildIdentity(int index) const {
5404+
// Child indices shift whenever messages are inserted or removed and
5405+
// the unread bar appears or goes away, so a queued action must not
5406+
// be dispatched by index. A raw HistoryItem pointer is not a safe
5407+
// token either: items are destroyed all the time and a new message
5408+
// can be allocated at the same address, silently rebinding a stale
5409+
// provider to an unrelated row (ABA). So the first request issues
5410+
// the item a token from a monotonic counter; itemRemoved() erases
5411+
// the pointer->token entry, and an item reusing the address gets a
5412+
// fresh token, so stale identities resolve to nothing. The unread
5413+
// bar row deliberately has no identity (and no action interface).
5414+
const auto barIndex = accessibilityUnreadBarIndex();
5415+
if (barIndex >= 0 && index == barIndex) {
5416+
return 0;
5417+
}
5418+
const auto elements = accessibleElements();
5419+
const auto elementIndex = (barIndex >= 0 && index > barIndex)
5420+
? (index - 1)
5421+
: index;
5422+
if (elementIndex < 0 || elementIndex >= int(elements.size())) {
5423+
return 0;
5424+
}
5425+
const auto item = elements[elementIndex]->data();
5426+
const auto i = _accessibilityIdentities.find(item);
5427+
if (i != _accessibilityIdentities.end()) {
5428+
return i->second;
5429+
}
5430+
const auto token = ++_accessibilityIdentityCounter;
5431+
_accessibilityIdentities.emplace(item, token);
5432+
return token;
5433+
}
5434+
5435+
int ListWidget::accessibilityChildIndexByIdentity(
5436+
quintptr identity) const {
5437+
// One pass over the elements looking each item up in the issued
5438+
// tokens map: only an item that was already handed out a token can
5439+
// match, so rows never seen by the accessibility layer just do not
5440+
// compare equal.
5441+
if (!identity) {
5442+
return -1;
5443+
}
5444+
const auto elements = accessibleElements();
5445+
const auto barIndex = accessibilityUnreadBarIndex();
5446+
for (auto i = 0, n = int(elements.size()); i != n; ++i) {
5447+
const auto j = _accessibilityIdentities.find(
5448+
elements[i]->data());
5449+
if (j != _accessibilityIdentities.end()
5450+
&& j->second == identity) {
5451+
return (barIndex >= 0 && i >= barIndex) ? (i + 1) : i;
5452+
}
5453+
}
5454+
return -1;
5455+
}
5456+
5457+
void ListWidget::applyAccessibilityFocus(
5458+
int index,
5459+
bool announceAlways) {
5460+
const auto elements = accessibleElements();
5461+
const auto barIndex = accessibilityUnreadBarIndex();
5462+
const auto elementIndex = (barIndex >= 0 && index > barIndex)
5463+
? (index - 1)
5464+
: index;
5465+
const auto item = (elementIndex >= 0
5466+
&& elementIndex < int(elements.size()))
5467+
? elements[elementIndex]->data().get()
5468+
: nullptr;
5469+
const auto changed = (_accessibilityFocusedIndex != index)
5470+
|| (_accessibilityFocusedItem != item);
5471+
_accessibilityFocusedIndex = index;
5472+
_accessibilityFocusedItem = item;
5473+
// Exactly one announcement: directly when the widget already has
5474+
// focus, via focusInEvent when keyboard focus is being taken.
5475+
if (hasFocus()) {
5476+
if (changed || announceAlways) {
5477+
announceAccessibilityFocus(index);
5478+
}
5479+
} else {
5480+
setFocus();
5481+
}
5482+
const auto rect = accessibilityChildRect(index);
5483+
if (!rect.isEmpty()) {
5484+
if (rect.top() < _visibleTop) {
5485+
_delegate->listScrollTo(rect.top());
5486+
} else if (rect.bottom() > _visibleBottom) {
5487+
_delegate->listScrollTo(rect.bottom()
5488+
- (_visibleBottom - _visibleTop));
5489+
}
5490+
}
5491+
if (markingMessagesRead()
5492+
&& (barIndex < 0 || index != barIndex)
5493+
&& elementIndex >= 0
5494+
&& elementIndex < int(elements.size())) {
5495+
_delegate->listMarkReadTill(elements[elementIndex]->data());
5496+
}
5497+
}
5498+
5499+
void ListWidget::accessibilityChildSetFocus(quintptr identity) {
5500+
// UIA invokes provider actions (SetFocus) on a background thread, so
5501+
// hop to the main thread before touching any widget state. Resolve
5502+
// the stable identity to its current index here (not on the
5503+
// background thread) so a list mutation does not move focus to
5504+
// another row.
5505+
crl::on_main(this, [=] {
5506+
// An explicit accessibility SetFocus is itself sufficient
5507+
// authorization, so we do not gate it on the screen-reader-mode
5508+
// detector: the UIA provider already reported success to the
5509+
// caller, and the detector may still be false during startup or
5510+
// for valid clients that are not on its allowlist.
5511+
const auto index = accessibilityChildIndexByIdentity(identity);
5512+
if (index < 0) {
5513+
return;
5514+
}
5515+
applyAccessibilityFocus(index, true);
5516+
});
5517+
}
5518+
5519+
void ListWidget::accessibilityChildActivate(quintptr identity) {
5520+
// A mouse click on a message body performs no action, so Invoke
5521+
// mirrors the click and only takes the accessibility focus onto the
5522+
// row. Same background-thread hop and identity resolution as
5523+
// SetFocus above.
5524+
crl::on_main(this, [=] {
5525+
const auto index = accessibilityChildIndexByIdentity(identity);
5526+
if (index < 0) {
5527+
return;
5528+
}
5529+
applyAccessibilityFocus(index, true);
5530+
});
5531+
}
5532+
53945533
void ConfirmDeleteSelectedItems(not_null<ListWidget*> widget) {
53955534
const auto items = widget->getSelectedItems();
53965535
if (items.empty()) {

Telegram/SourceFiles/history/view/history_view_list_widget.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,12 @@ class ListWidget final
507507
int row, int column) const override;
508508
QString accessibilityChildSubItemValue(
509509
int row, int column) const override;
510+
bool accessibilityChildSupportsActions(int index) const override;
511+
quintptr accessibilityChildIdentity(int index) const override;
512+
int accessibilityChildIndexByIdentity(
513+
quintptr identity) const override;
514+
void accessibilityChildSetFocus(quintptr identity) override;
515+
void accessibilityChildActivate(quintptr identity) override;
510516

511517
~ListWidget();
512518

@@ -550,6 +556,7 @@ class ListWidget final
550556
void playPauseFocusedMedia();
551557
void setAccessibilityFocusedItem(int index, HistoryItem *item);
552558
void announceAccessibilityFocus(int index);
559+
void applyAccessibilityFocus(int index, bool announceAlways);
553560
[[nodiscard]] auto computeActiveColumns(int row) const
554561
-> const std::vector<HistoryView::MessageSubItem> &;
555562

@@ -931,6 +938,10 @@ class ListWidget final
931938

932939
int _accessibilityFocusedIndex = -1;
933940
HistoryItem *_accessibilityFocusedItem = nullptr;
941+
mutable base::flat_map<
942+
not_null<const HistoryItem*>,
943+
quintptr> _accessibilityIdentities;
944+
mutable quintptr _accessibilityIdentityCounter = 0;
934945
mutable const HistoryView::Element *_activeColumnsView = nullptr;
935946
mutable std::vector<HistoryView::MessageSubItem> _activeColumns;
936947

0 commit comments

Comments
 (0)