From 846f2921b4e1dc74fb2b04919df45798bab75452 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Wed, 8 Jul 2026 19:49:09 +0330 Subject: [PATCH 1/2] Support screen reader focus and activate on message history rows --- .../history/history_inner_widget.cpp | 143 ++++++++++++++++++ .../history/history_inner_widget.h | 11 ++ .../history/view/history_view_list_widget.cpp | 139 +++++++++++++++++ .../history/view/history_view_list_widget.h | 11 ++ 4 files changed, 304 insertions(+) diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index 2babbaf6b1e8c..b240ed1502299 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 47fa95a0937a4..eae4140416675 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.h +++ b/Telegram/SourceFiles/history/history_inner_widget.h @@ -133,6 +133,12 @@ class HistoryInner 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 @@ class HistoryInner 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 @@ class HistoryInner 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 11b19dd91423e..3632e3336616f 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 b90bba08673c4..ac09106881e10 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 @@ class ListWidget final 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 @@ class ListWidget final 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 @@ class ListWidget final 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; From 7a3e1bb66bf64d3cfc5025708cbae06b46b31119 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Wed, 8 Jul 2026 19:50:54 +0330 Subject: [PATCH 2/2] Add keyboard message selection for screen reader users --- .../history/history_inner_widget.cpp | 209 ++++++++++++++---- .../history/history_inner_widget.h | 3 + .../SourceFiles/history/history_widget.cpp | 6 +- .../view/history_view_chat_section.cpp | 4 +- .../history/view/history_view_list_widget.cpp | 205 +++++++++++++---- .../history/view/history_view_list_widget.h | 3 + .../view/history_view_scheduled_section.cpp | 4 +- 7 files changed, 348 insertions(+), 86 deletions(-) diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index b240ed1502299..8436ef9d6c08c 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.cpp +++ b/Telegram/SourceFiles/history/history_inner_widget.cpp @@ -2411,6 +2411,9 @@ void HistoryInner::itemRemoved(not_null item) { if (_accessibilityFocusedItem == item) { _accessibilityFocusedItem = nullptr; } + if (_accessibilitySelectionAnchor == item) { + _accessibilitySelectionAnchor = nullptr; + } _accessibilityIdentities.remove(item); if ((_dragSelFrom && _dragSelFrom->data() == item) @@ -4065,47 +4068,64 @@ void HistoryInner::keyPressEvent(QKeyEvent *e) { } } } + const auto modifiers = e->modifiers() + & ~(Qt::KeypadModifier | Qt::GroupSwitchModifier); + const auto plainKey = (modifiers == Qt::NoModifier); + const auto shiftRange = (modifiers == Qt::ShiftModifier) + && (e->key() == Qt::Key_Up || e->key() == Qt::Key_Down); auto newIndex = _accessibilityFocusedIndex; switch (e->key()) { case Qt::Key_Down: - newIndex = std::min( - (newIndex < 0) ? (count - 1) : (newIndex + 1), - count - 1); + if (plainKey || shiftRange) { + newIndex = std::min( + (newIndex < 0) ? (count - 1) : (newIndex + 1), + count - 1); + } break; case Qt::Key_Up: - newIndex = std::max( - (newIndex < 0) ? (count - 1) : (newIndex - 1), - 0); + if (plainKey || shiftRange) { + newIndex = std::max( + (newIndex < 0) ? (count - 1) : (newIndex - 1), + 0); + } break; case Qt::Key_PageDown: { - const auto pageHeight = _visibleAreaBottom - - _visibleAreaTop; - auto remaining = pageHeight; - while (newIndex + 1 < count && remaining > 0) { - ++newIndex; - const auto rect = accessibilityChildRect( - newIndex); - remaining -= rect.height(); + if (plainKey) { + const auto pageHeight = _visibleAreaBottom + - _visibleAreaTop; + auto remaining = pageHeight; + while (newIndex + 1 < count && remaining > 0) { + ++newIndex; + const auto rect = accessibilityChildRect( + newIndex); + remaining -= rect.height(); + } } break; } case Qt::Key_PageUp: { - const auto pageHeight = _visibleAreaBottom - - _visibleAreaTop; - auto remaining = pageHeight; - while (newIndex - 1 >= 0 && remaining > 0) { - --newIndex; - const auto rect = accessibilityChildRect( - newIndex); - remaining -= rect.height(); + if (plainKey) { + const auto pageHeight = _visibleAreaBottom + - _visibleAreaTop; + auto remaining = pageHeight; + while (newIndex - 1 >= 0 && remaining > 0) { + --newIndex; + const auto rect = accessibilityChildRect( + newIndex); + remaining -= rect.height(); + } } break; } case Qt::Key_Home: - newIndex = 0; + if (plainKey) { + newIndex = 0; + } break; case Qt::Key_End: - newIndex = count - 1; + if (plainKey) { + newIndex = count - 1; + } break; default: break; @@ -4124,6 +4144,13 @@ void HistoryInner::keyPressEvent(QKeyEvent *e) { && elementIndex < int(elements.size())) ? elements[elementIndex]->data().get() : nullptr; + if (shiftRange) { + extendAccessibilitySelection( + _accessibilityFocusedIndex, + newIndex); + } else { + _accessibilitySelectionAnchor = nullptr; + } setAccessibilityFocusedItem(newIndex, item); const auto rect = accessibilityChildRect(newIndex); @@ -4150,14 +4177,32 @@ void HistoryInner::keyPressEvent(QKeyEvent *e) { return; } + if (shiftRange) { + e->accept(); + return; + } + if (e->key() == Qt::Key_Space) { - if (hasSelectedItems()) { + // Ctrl+Space toggles selection of the focused message and + // thereby enters "selection mode". While any message stays + // selected, plain Space keeps toggling (mirroring how mouse + // clicks work for sighted users once selection is active); + // with nothing selected it plays/pauses the focused media. + if (modifiers == Qt::ControlModifier) { + _accessibilitySelectionAnchor = nullptr; toggleMessageSelection(); - } else { - playPauseFocusedMedia(); + e->accept(); + return; + } else if (modifiers == Qt::NoModifier) { + if (hasSelectedItems()) { + _accessibilitySelectionAnchor = nullptr; + toggleMessageSelection(); + } else { + playPauseFocusedMedia(); + } + e->accept(); + return; } - e->accept(); - return; } } @@ -5813,30 +5858,109 @@ void HistoryInner::announceAccessibilityFocus(int index) { } void HistoryInner::toggleMessageSelection() { - if (!hasSelectedItems() || _accessibilityFocusedIndex < 0) { - return; - } + changeAccessibilitySelection( + _accessibilityFocusedIndex, + SelectAction::Invert); +} + +void HistoryInner::changeAccessibilitySelection( + int index, + SelectAction action) { const auto barIndex = accessibilityUnreadBarIndex(); - if (barIndex >= 0 && _accessibilityFocusedIndex == barIndex) { + if (index < 0 || (barIndex >= 0 && index == barIndex)) { return; } const auto elements = accessibleElements(); - const auto elementIndex = (barIndex >= 0 - && _accessibilityFocusedIndex > barIndex) - ? (_accessibilityFocusedIndex - 1) - : _accessibilityFocusedIndex; + const auto elementIndex = (barIndex >= 0 && index > barIndex) + ? (index - 1) + : index; if (elementIndex < 0 || elementIndex >= int(elements.size())) { return; } const auto item = elements[elementIndex]->data(); + // Detect the change by container size, not by the group membership + // flip: deselecting a partially selected album really mutates the + // selection while isSelectedAsGroup() stays false both before and + // after. Selection changes here are pure adds or removes, so an + // unchanged size means nothing changed and no repaint, top bar + // update or announcement is due. + const auto sizeBefore = _selected.size(); + changeSelectionAsGroup(&_selected, item, action); + if (_selected.size() == sizeBefore) { + return; + } clearTextSelection(); - changeSelectionAsGroup(&_selected, item, SelectAction::Invert); repaintItem(item); _widget->updateTopBarSelection(); - accessibilityChildStateChanged( - _accessibilityFocusedIndex, - { .selected = true }); - accessibilityChildNameChanged(_accessibilityFocusedIndex); + accessibilityChildStateChanged(index, { .selected = true }); + accessibilityChildNameChanged(index); +} + +void HistoryInner::extendAccessibilitySelection( + int oldIndex, + int newIndex) { + // Windows-Explorer-style Shift+arrow range selection. The anchor is + // the row the current range grows from: re-established here whenever + // it is missing, no longer listed or the selection is empty, so an + // interrupted or cancelled range simply starts a fresh one at the + // focused row. Growing away from the anchor selects the row being + // entered (and the origin row on the first step), stepping back + // towards it deselects the row being left. + const auto elements = accessibleElements(); + const auto barIndex = accessibilityUnreadBarIndex(); + const auto itemAt = [&](int index) -> HistoryItem* { + if (barIndex >= 0 && index == barIndex) { + return nullptr; + } + const auto elementIndex = (barIndex >= 0 && index > barIndex) + ? (index - 1) + : index; + return (elementIndex >= 0 + && elementIndex < int(elements.size())) + ? elements[elementIndex]->data().get() + : nullptr; + }; + if (oldIndex < 0) { + _accessibilitySelectionAnchor = itemAt(newIndex); + changeAccessibilitySelection(newIndex, SelectAction::Select); + return; + } + auto anchorIndex = -1; + if (_accessibilitySelectionAnchor && hasSelectedItems()) { + for (auto i = 0, n = int(elements.size()); i != n; ++i) { + if (elements[i]->data().get() + == _accessibilitySelectionAnchor) { + anchorIndex = (barIndex >= 0 && i >= barIndex) + ? (i + 1) + : i; + break; + } + } + } + if (anchorIndex < 0) { + _accessibilitySelectionAnchor = itemAt(oldIndex); + if (!_accessibilitySelectionAnchor) { + // Re-anchoring on the unread-bar row (it has no item): anchor + // to the row being entered instead, so the next Shift+arrow + // can resolve the anchor and shrink the range. Leaving the + // anchor null here would make every following step re-anchor + // at its own old index and always read as growing. + _accessibilitySelectionAnchor = itemAt(newIndex); + } + anchorIndex = oldIndex; + } + const auto growing = std::abs(newIndex - anchorIndex) + > std::abs(oldIndex - anchorIndex); + if (growing) { + if (oldIndex == anchorIndex) { + changeAccessibilitySelection( + oldIndex, + SelectAction::Select); + } + changeAccessibilitySelection(newIndex, SelectAction::Select); + } else { + changeAccessibilitySelection(oldIndex, SelectAction::Deselect); + } } void HistoryInner::playPauseFocusedMedia() { @@ -6521,6 +6645,7 @@ void HistoryInner::applyAccessibilityFocus( : nullptr; const auto changed = (_accessibilityFocusedIndex != index) || (_accessibilityFocusedItem != item); + _accessibilitySelectionAnchor = nullptr; _accessibilityFocusedIndex = index; _accessibilityFocusedItem = item; // Exactly one announcement: directly when the widget already has diff --git a/Telegram/SourceFiles/history/history_inner_widget.h b/Telegram/SourceFiles/history/history_inner_widget.h index eae4140416675..8533f91ab2dfc 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.h +++ b/Telegram/SourceFiles/history/history_inner_widget.h @@ -515,6 +515,8 @@ class HistoryInner not_null toItems, not_null item, SelectAction action) const; + void changeAccessibilitySelection(int index, SelectAction action); + void extendAccessibilitySelection(int oldIndex, int newIndex); void forwardItem(FullMsgId itemId); void forwardAsGroup(FullMsgId itemId); void deleteItem(not_null item); @@ -558,6 +560,7 @@ class HistoryInner int _accessibilityFocusedIndex = -1; HistoryItem *_accessibilityFocusedItem = nullptr; + HistoryItem *_accessibilitySelectionAnchor = nullptr; mutable base::flat_map< not_null, quintptr> _accessibilityIdentities; diff --git a/Telegram/SourceFiles/history/history_widget.cpp b/Telegram/SourceFiles/history/history_widget.cpp index 841f92b4a6b03..8eefc73ec9fa9 100644 --- a/Telegram/SourceFiles/history/history_widget.cpp +++ b/Telegram/SourceFiles/history/history_widget.cpp @@ -61,6 +61,7 @@ For license and copyright information please follow this link: #include "ui/controls/send_button.h" #include "ui/controls/send_as_button.h" #include "ui/controls/silent_toggle.h" +#include "ui/screen_reader_mode.h" #include "ui/ui_utility.h" #include "inline_bots/inline_bot_result.h" #include "base/event_filter.h" @@ -10462,7 +10463,10 @@ void HistoryWidget::updateTopBarSelection() { || isBotStart() || isBlocked() || !_richDraftPreview->isHidden() - || (!_canSendTexts && !_editMsgId)) { + || (!_canSendTexts && !_editMsgId) + || (_list + && _list->hasFocus() + && Ui::ScreenReaderModeActive())) { _list->setFocus(); } else { _field->setFocus(); diff --git a/Telegram/SourceFiles/history/view/history_view_chat_section.cpp b/Telegram/SourceFiles/history/view/history_view_chat_section.cpp index 8a39cc0e186c3..fbf86bc48c986 100644 --- a/Telegram/SourceFiles/history/view/history_view_chat_section.cpp +++ b/Telegram/SourceFiles/history/view/history_view_chat_section.cpp @@ -41,6 +41,7 @@ For license and copyright information please follow this link: #include "ui/text/text_utilities.h" #include "ui/effects/message_sending_animation_controller.h" #include "ui/rect.h" +#include "ui/screen_reader_mode.h" #include "ui/ui_utility.h" #include "base/timer_rpl.h" #include "api/api_bot.h" @@ -3328,7 +3329,8 @@ void ChatWidget::listSelectionChanged(SelectedItems &&items) { if ((state.count > 0) && _composeSearch) { _composeSearch->hideAnimated(); } - if (items.empty()) { + if (items.empty() + && !(_inner->hasFocus() && Ui::ScreenReaderModeActive())) { doSetInnerFocus(); } } diff --git a/Telegram/SourceFiles/history/view/history_view_list_widget.cpp b/Telegram/SourceFiles/history/view/history_view_list_widget.cpp index 3632e3336616f..a3fc1b6ff677f 100644 --- a/Telegram/SourceFiles/history/view/history_view_list_widget.cpp +++ b/Telegram/SourceFiles/history/view/history_view_list_widget.cpp @@ -3000,9 +3000,9 @@ auto ListWidget::countScrollState() const -> ScrollTopState { void ListWidget::keyPressEvent(QKeyEvent *e) { const auto key = e->key(); - const auto hasModifiers = (Qt::NoModifier != - (e->modifiers() - & ~(Qt::KeypadModifier | Qt::GroupSwitchModifier))); + const auto modifiers = e->modifiers() + & ~(Qt::KeypadModifier | Qt::GroupSwitchModifier); + const auto hasModifiers = (modifiers != Qt::NoModifier); if (_middleClickAutoscroll.active() && key == Qt::Key_Escape) { _middleClickAutoscroll.stop(); return; @@ -3043,43 +3043,58 @@ void ListWidget::keyPressEvent(QKeyEvent *e) { } } } + const auto plainKey = (modifiers == Qt::NoModifier); + const auto shiftRange = (modifiers == Qt::ShiftModifier) + && (key == Qt::Key_Up || key == Qt::Key_Down); auto newIndex = _accessibilityFocusedIndex; switch (key) { case Qt::Key_Down: - newIndex = std::min( - (newIndex < 0) ? (count - 1) : (newIndex + 1), - count - 1); + if (plainKey || shiftRange) { + newIndex = std::min( + (newIndex < 0) ? (count - 1) : (newIndex + 1), + count - 1); + } break; case Qt::Key_Up: - newIndex = std::max( - (newIndex < 0) ? (count - 1) : (newIndex - 1), - 0); + if (plainKey || shiftRange) { + newIndex = std::max( + (newIndex < 0) ? (count - 1) : (newIndex - 1), + 0); + } break; case Qt::Key_PageDown: { - const auto pageHeight = _visibleBottom - _visibleTop; - auto remaining = pageHeight; - while (newIndex + 1 < count && remaining > 0) { - ++newIndex; - const auto rect = accessibilityChildRect(newIndex); - remaining -= rect.height(); + if (plainKey) { + const auto pageHeight = _visibleBottom - _visibleTop; + auto remaining = pageHeight; + while (newIndex + 1 < count && remaining > 0) { + ++newIndex; + const auto rect = accessibilityChildRect(newIndex); + remaining -= rect.height(); + } } break; } case Qt::Key_PageUp: { - const auto pageHeight = _visibleBottom - _visibleTop; - auto remaining = pageHeight; - while (newIndex - 1 >= 0 && remaining > 0) { - --newIndex; - const auto rect = accessibilityChildRect(newIndex); - remaining -= rect.height(); + if (plainKey) { + const auto pageHeight = _visibleBottom - _visibleTop; + auto remaining = pageHeight; + while (newIndex - 1 >= 0 && remaining > 0) { + --newIndex; + const auto rect = accessibilityChildRect(newIndex); + remaining -= rect.height(); + } } break; } case Qt::Key_Home: - newIndex = 0; + if (plainKey) { + newIndex = 0; + } break; case Qt::Key_End: - newIndex = count - 1; + if (plainKey) { + newIndex = count - 1; + } break; default: break; @@ -3098,6 +3113,13 @@ void ListWidget::keyPressEvent(QKeyEvent *e) { && elementIndex < int(elements.size())) ? elements[elementIndex]->data().get() : nullptr; + if (shiftRange) { + extendAccessibilitySelection( + _accessibilityFocusedIndex, + newIndex); + } else { + _accessibilitySelectionAnchor = nullptr; + } setAccessibilityFocusedItem(newIndex, item); const auto rect = accessibilityChildRect(newIndex); @@ -3123,14 +3145,32 @@ void ListWidget::keyPressEvent(QKeyEvent *e) { return; } + if (shiftRange) { + e->accept(); + return; + } + if (key == Qt::Key_Space) { - if (hasSelectedItems()) { + // Ctrl+Space toggles selection of the focused message and + // thereby enters "selection mode". While any message stays + // selected, plain Space keeps toggling (mirroring how mouse + // clicks work for sighted users once selection is active); + // with nothing selected it plays/pauses the focused media. + if (modifiers == Qt::ControlModifier) { + _accessibilitySelectionAnchor = nullptr; toggleMessageSelection(); - } else { - playPauseFocusedMedia(); + e->accept(); + return; + } else if (modifiers == Qt::NoModifier) { + if (hasSelectedItems()) { + _accessibilitySelectionAnchor = nullptr; + toggleMessageSelection(); + } else { + playPauseFocusedMedia(); + } + e->accept(); + return; } - e->accept(); - return; } } @@ -4899,6 +4939,9 @@ void ListWidget::itemRemoved(not_null item) { if (_accessibilityFocusedItem == item) { _accessibilityFocusedItem = nullptr; } + if (_accessibilitySelectionAnchor == item) { + _accessibilitySelectionAnchor = nullptr; + } _accessibilityIdentities.remove(item); const auto i = _views.find(item); if (i == end(_views)) { @@ -5133,31 +5176,110 @@ void ListWidget::announceAccessibilityFocus(int index) { } void ListWidget::toggleMessageSelection() { - if (!hasSelectedItems() || _accessibilityFocusedIndex < 0) { - return; - } + changeAccessibilitySelection( + _accessibilityFocusedIndex, + SelectAction::Invert); +} + +void ListWidget::changeAccessibilitySelection( + int index, + SelectAction action) { const auto barIndex = accessibilityUnreadBarIndex(); - if (barIndex >= 0 && _accessibilityFocusedIndex == barIndex) { + if (index < 0 || (barIndex >= 0 && index == barIndex)) { return; } const auto elements = accessibleElements(); - const auto elementIndex = (barIndex >= 0 - && _accessibilityFocusedIndex > barIndex) - ? (_accessibilityFocusedIndex - 1) - : _accessibilityFocusedIndex; + const auto elementIndex = (barIndex >= 0 && index > barIndex) + ? (index - 1) + : index; if (elementIndex < 0 || elementIndex >= int(elements.size())) { return; } const auto view = elements[elementIndex]; const auto item = view->data(); + // Detect the change by container size, not by the group membership + // flip: deselecting a partially selected album really mutates the + // selection while isSelectedAsGroup() stays false both before and + // after. Selection changes here are pure adds or removes, so an + // unchanged size means nothing changed and no repaint, top bar + // update or announcement is due. + const auto sizeBefore = _selected.size(); + changeSelectionAsGroup(_selected, item, action); + if (_selected.size() == sizeBefore) { + return; + } clearTextSelection(); - changeSelectionAsGroup(_selected, item, SelectAction::Invert); repaintItem(view); pushSelectedItems(); - accessibilityChildStateChanged( - _accessibilityFocusedIndex, - { .selected = true }); - accessibilityChildNameChanged(_accessibilityFocusedIndex); + accessibilityChildStateChanged(index, { .selected = true }); + accessibilityChildNameChanged(index); +} + +void ListWidget::extendAccessibilitySelection( + int oldIndex, + int newIndex) { + // Windows-Explorer-style Shift+arrow range selection. The anchor is + // the row the current range grows from: re-established here whenever + // it is missing, no longer listed or the selection is empty, so an + // interrupted or cancelled range simply starts a fresh one at the + // focused row. Growing away from the anchor selects the row being + // entered (and the origin row on the first step), stepping back + // towards it deselects the row being left. + const auto elements = accessibleElements(); + const auto barIndex = accessibilityUnreadBarIndex(); + const auto itemAt = [&](int index) -> HistoryItem* { + if (barIndex >= 0 && index == barIndex) { + return nullptr; + } + const auto elementIndex = (barIndex >= 0 && index > barIndex) + ? (index - 1) + : index; + return (elementIndex >= 0 + && elementIndex < int(elements.size())) + ? elements[elementIndex]->data().get() + : nullptr; + }; + if (oldIndex < 0) { + _accessibilitySelectionAnchor = itemAt(newIndex); + changeAccessibilitySelection(newIndex, SelectAction::Select); + return; + } + auto anchorIndex = -1; + if (_accessibilitySelectionAnchor && hasSelectedItems()) { + for (auto i = 0, n = int(elements.size()); i != n; ++i) { + if (elements[i]->data().get() + == _accessibilitySelectionAnchor) { + anchorIndex = (barIndex >= 0 && i >= barIndex) + ? (i + 1) + : i; + break; + } + } + } + if (anchorIndex < 0) { + _accessibilitySelectionAnchor = itemAt(oldIndex); + if (!_accessibilitySelectionAnchor) { + // Re-anchoring on the unread-bar row (it has no item): anchor + // to the row being entered instead, so the next Shift+arrow + // can resolve the anchor and shrink the range. Leaving the + // anchor null here would make every following step re-anchor + // at its own old index and always read as growing. + _accessibilitySelectionAnchor = itemAt(newIndex); + } + anchorIndex = oldIndex; + } + const auto growing = std::abs(newIndex - anchorIndex) + > std::abs(oldIndex - anchorIndex); + if (growing) { + if (oldIndex == anchorIndex) { + changeAccessibilitySelection( + oldIndex, + SelectAction::Select); + } + changeAccessibilitySelection(newIndex, SelectAction::Select); + } else { + changeAccessibilitySelection(oldIndex, SelectAction::Deselect); + } } void ListWidget::playPauseFocusedMedia() { @@ -5468,6 +5590,7 @@ void ListWidget::applyAccessibilityFocus( : nullptr; const auto changed = (_accessibilityFocusedIndex != index) || (_accessibilityFocusedItem != item); + _accessibilitySelectionAnchor = nullptr; _accessibilityFocusedIndex = index; _accessibilityFocusedItem = item; // Exactly one announcement: directly when the widget already has diff --git a/Telegram/SourceFiles/history/view/history_view_list_widget.h b/Telegram/SourceFiles/history/view/history_view_list_widget.h index ac09106881e10..6731c866abb63 100644 --- a/Telegram/SourceFiles/history/view/history_view_list_widget.h +++ b/Telegram/SourceFiles/history/view/history_view_list_widget.h @@ -749,6 +749,8 @@ class ListWidget final SelectedMap &applyTo, not_null item, SelectAction action) const; + void changeAccessibilitySelection(int index, SelectAction action); + void extendAccessibilitySelection(int oldIndex, int newIndex); SelectedMap::iterator itemUnderPressSelection(); SelectedMap::const_iterator itemUnderPressSelection() const; @@ -938,6 +940,7 @@ class ListWidget final int _accessibilityFocusedIndex = -1; HistoryItem *_accessibilityFocusedItem = nullptr; + HistoryItem *_accessibilitySelectionAnchor = nullptr; mutable base::flat_map< not_null, quintptr> _accessibilityIdentities; diff --git a/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp b/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp index cc6c7c952d332..abf4f822e5492 100644 --- a/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp +++ b/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp @@ -27,6 +27,7 @@ For license and copyright information please follow this link: #include "ui/toast/toast.h" #include "ui/dynamic_image.h" #include "ui/dynamic_thumbnails.h" +#include "ui/screen_reader_mode.h" #include "ui/ui_utility.h" #include "api/api_editing.h" #include "api/api_sending.h" @@ -1454,7 +1455,8 @@ void ScheduledWidget::listSelectionChanged(SelectedItems &&items) { } } _topBar->showSelected(state); - if (items.empty()) { + if (items.empty() + && !(_inner->hasFocus() && Ui::ScreenReaderModeActive())) { doSetInnerFocus(); } }