Skip to content

Commit 7a3e1bb

Browse files
Add keyboard message selection for screen reader users
1 parent 846f292 commit 7a3e1bb

7 files changed

Lines changed: 348 additions & 86 deletions

Telegram/SourceFiles/history/history_inner_widget.cpp

Lines changed: 167 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2411,6 +2411,9 @@ void HistoryInner::itemRemoved(not_null<const HistoryItem*> item) {
24112411
if (_accessibilityFocusedItem == item) {
24122412
_accessibilityFocusedItem = nullptr;
24132413
}
2414+
if (_accessibilitySelectionAnchor == item) {
2415+
_accessibilitySelectionAnchor = nullptr;
2416+
}
24142417
_accessibilityIdentities.remove(item);
24152418

24162419
if ((_dragSelFrom && _dragSelFrom->data() == item)
@@ -4065,47 +4068,64 @@ void HistoryInner::keyPressEvent(QKeyEvent *e) {
40654068
}
40664069
}
40674070
}
4071+
const auto modifiers = e->modifiers()
4072+
& ~(Qt::KeypadModifier | Qt::GroupSwitchModifier);
4073+
const auto plainKey = (modifiers == Qt::NoModifier);
4074+
const auto shiftRange = (modifiers == Qt::ShiftModifier)
4075+
&& (e->key() == Qt::Key_Up || e->key() == Qt::Key_Down);
40684076
auto newIndex = _accessibilityFocusedIndex;
40694077
switch (e->key()) {
40704078
case Qt::Key_Down:
4071-
newIndex = std::min(
4072-
(newIndex < 0) ? (count - 1) : (newIndex + 1),
4073-
count - 1);
4079+
if (plainKey || shiftRange) {
4080+
newIndex = std::min(
4081+
(newIndex < 0) ? (count - 1) : (newIndex + 1),
4082+
count - 1);
4083+
}
40744084
break;
40754085
case Qt::Key_Up:
4076-
newIndex = std::max(
4077-
(newIndex < 0) ? (count - 1) : (newIndex - 1),
4078-
0);
4086+
if (plainKey || shiftRange) {
4087+
newIndex = std::max(
4088+
(newIndex < 0) ? (count - 1) : (newIndex - 1),
4089+
0);
4090+
}
40794091
break;
40804092
case Qt::Key_PageDown: {
4081-
const auto pageHeight = _visibleAreaBottom
4082-
- _visibleAreaTop;
4083-
auto remaining = pageHeight;
4084-
while (newIndex + 1 < count && remaining > 0) {
4085-
++newIndex;
4086-
const auto rect = accessibilityChildRect(
4087-
newIndex);
4088-
remaining -= rect.height();
4093+
if (plainKey) {
4094+
const auto pageHeight = _visibleAreaBottom
4095+
- _visibleAreaTop;
4096+
auto remaining = pageHeight;
4097+
while (newIndex + 1 < count && remaining > 0) {
4098+
++newIndex;
4099+
const auto rect = accessibilityChildRect(
4100+
newIndex);
4101+
remaining -= rect.height();
4102+
}
40894103
}
40904104
break;
40914105
}
40924106
case Qt::Key_PageUp: {
4093-
const auto pageHeight = _visibleAreaBottom
4094-
- _visibleAreaTop;
4095-
auto remaining = pageHeight;
4096-
while (newIndex - 1 >= 0 && remaining > 0) {
4097-
--newIndex;
4098-
const auto rect = accessibilityChildRect(
4099-
newIndex);
4100-
remaining -= rect.height();
4107+
if (plainKey) {
4108+
const auto pageHeight = _visibleAreaBottom
4109+
- _visibleAreaTop;
4110+
auto remaining = pageHeight;
4111+
while (newIndex - 1 >= 0 && remaining > 0) {
4112+
--newIndex;
4113+
const auto rect = accessibilityChildRect(
4114+
newIndex);
4115+
remaining -= rect.height();
4116+
}
41014117
}
41024118
break;
41034119
}
41044120
case Qt::Key_Home:
4105-
newIndex = 0;
4121+
if (plainKey) {
4122+
newIndex = 0;
4123+
}
41064124
break;
41074125
case Qt::Key_End:
4108-
newIndex = count - 1;
4126+
if (plainKey) {
4127+
newIndex = count - 1;
4128+
}
41094129
break;
41104130
default:
41114131
break;
@@ -4124,6 +4144,13 @@ void HistoryInner::keyPressEvent(QKeyEvent *e) {
41244144
&& elementIndex < int(elements.size()))
41254145
? elements[elementIndex]->data().get()
41264146
: nullptr;
4147+
if (shiftRange) {
4148+
extendAccessibilitySelection(
4149+
_accessibilityFocusedIndex,
4150+
newIndex);
4151+
} else {
4152+
_accessibilitySelectionAnchor = nullptr;
4153+
}
41274154
setAccessibilityFocusedItem(newIndex, item);
41284155

41294156
const auto rect = accessibilityChildRect(newIndex);
@@ -4150,14 +4177,32 @@ void HistoryInner::keyPressEvent(QKeyEvent *e) {
41504177
return;
41514178
}
41524179

4180+
if (shiftRange) {
4181+
e->accept();
4182+
return;
4183+
}
4184+
41534185
if (e->key() == Qt::Key_Space) {
4154-
if (hasSelectedItems()) {
4186+
// Ctrl+Space toggles selection of the focused message and
4187+
// thereby enters "selection mode". While any message stays
4188+
// selected, plain Space keeps toggling (mirroring how mouse
4189+
// clicks work for sighted users once selection is active);
4190+
// with nothing selected it plays/pauses the focused media.
4191+
if (modifiers == Qt::ControlModifier) {
4192+
_accessibilitySelectionAnchor = nullptr;
41554193
toggleMessageSelection();
4156-
} else {
4157-
playPauseFocusedMedia();
4194+
e->accept();
4195+
return;
4196+
} else if (modifiers == Qt::NoModifier) {
4197+
if (hasSelectedItems()) {
4198+
_accessibilitySelectionAnchor = nullptr;
4199+
toggleMessageSelection();
4200+
} else {
4201+
playPauseFocusedMedia();
4202+
}
4203+
e->accept();
4204+
return;
41584205
}
4159-
e->accept();
4160-
return;
41614206
}
41624207
}
41634208

@@ -5813,30 +5858,109 @@ void HistoryInner::announceAccessibilityFocus(int index) {
58135858
}
58145859

58155860
void HistoryInner::toggleMessageSelection() {
5816-
if (!hasSelectedItems() || _accessibilityFocusedIndex < 0) {
5817-
return;
5818-
}
5861+
changeAccessibilitySelection(
5862+
_accessibilityFocusedIndex,
5863+
SelectAction::Invert);
5864+
}
5865+
5866+
void HistoryInner::changeAccessibilitySelection(
5867+
int index,
5868+
SelectAction action) {
58195869
const auto barIndex = accessibilityUnreadBarIndex();
5820-
if (barIndex >= 0 && _accessibilityFocusedIndex == barIndex) {
5870+
if (index < 0 || (barIndex >= 0 && index == barIndex)) {
58215871
return;
58225872
}
58235873
const auto elements = accessibleElements();
5824-
const auto elementIndex = (barIndex >= 0
5825-
&& _accessibilityFocusedIndex > barIndex)
5826-
? (_accessibilityFocusedIndex - 1)
5827-
: _accessibilityFocusedIndex;
5874+
const auto elementIndex = (barIndex >= 0 && index > barIndex)
5875+
? (index - 1)
5876+
: index;
58285877
if (elementIndex < 0 || elementIndex >= int(elements.size())) {
58295878
return;
58305879
}
58315880
const auto item = elements[elementIndex]->data();
5881+
// Detect the change by container size, not by the group membership
5882+
// flip: deselecting a partially selected album really mutates the
5883+
// selection while isSelectedAsGroup() stays false both before and
5884+
// after. Selection changes here are pure adds or removes, so an
5885+
// unchanged size means nothing changed and no repaint, top bar
5886+
// update or announcement is due.
5887+
const auto sizeBefore = _selected.size();
5888+
changeSelectionAsGroup(&_selected, item, action);
5889+
if (_selected.size() == sizeBefore) {
5890+
return;
5891+
}
58325892
clearTextSelection();
5833-
changeSelectionAsGroup(&_selected, item, SelectAction::Invert);
58345893
repaintItem(item);
58355894
_widget->updateTopBarSelection();
5836-
accessibilityChildStateChanged(
5837-
_accessibilityFocusedIndex,
5838-
{ .selected = true });
5839-
accessibilityChildNameChanged(_accessibilityFocusedIndex);
5895+
accessibilityChildStateChanged(index, { .selected = true });
5896+
accessibilityChildNameChanged(index);
5897+
}
5898+
5899+
void HistoryInner::extendAccessibilitySelection(
5900+
int oldIndex,
5901+
int newIndex) {
5902+
// Windows-Explorer-style Shift+arrow range selection. The anchor is
5903+
// the row the current range grows from: re-established here whenever
5904+
// it is missing, no longer listed or the selection is empty, so an
5905+
// interrupted or cancelled range simply starts a fresh one at the
5906+
// focused row. Growing away from the anchor selects the row being
5907+
// entered (and the origin row on the first step), stepping back
5908+
// towards it deselects the row being left.
5909+
const auto elements = accessibleElements();
5910+
const auto barIndex = accessibilityUnreadBarIndex();
5911+
const auto itemAt = [&](int index) -> HistoryItem* {
5912+
if (barIndex >= 0 && index == barIndex) {
5913+
return nullptr;
5914+
}
5915+
const auto elementIndex = (barIndex >= 0 && index > barIndex)
5916+
? (index - 1)
5917+
: index;
5918+
return (elementIndex >= 0
5919+
&& elementIndex < int(elements.size()))
5920+
? elements[elementIndex]->data().get()
5921+
: nullptr;
5922+
};
5923+
if (oldIndex < 0) {
5924+
_accessibilitySelectionAnchor = itemAt(newIndex);
5925+
changeAccessibilitySelection(newIndex, SelectAction::Select);
5926+
return;
5927+
}
5928+
auto anchorIndex = -1;
5929+
if (_accessibilitySelectionAnchor && hasSelectedItems()) {
5930+
for (auto i = 0, n = int(elements.size()); i != n; ++i) {
5931+
if (elements[i]->data().get()
5932+
== _accessibilitySelectionAnchor) {
5933+
anchorIndex = (barIndex >= 0 && i >= barIndex)
5934+
? (i + 1)
5935+
: i;
5936+
break;
5937+
}
5938+
}
5939+
}
5940+
if (anchorIndex < 0) {
5941+
_accessibilitySelectionAnchor = itemAt(oldIndex);
5942+
if (!_accessibilitySelectionAnchor) {
5943+
// Re-anchoring on the unread-bar row (it has no item): anchor
5944+
// to the row being entered instead, so the next Shift+arrow
5945+
// can resolve the anchor and shrink the range. Leaving the
5946+
// anchor null here would make every following step re-anchor
5947+
// at its own old index and always read as growing.
5948+
_accessibilitySelectionAnchor = itemAt(newIndex);
5949+
}
5950+
anchorIndex = oldIndex;
5951+
}
5952+
const auto growing = std::abs(newIndex - anchorIndex)
5953+
> std::abs(oldIndex - anchorIndex);
5954+
if (growing) {
5955+
if (oldIndex == anchorIndex) {
5956+
changeAccessibilitySelection(
5957+
oldIndex,
5958+
SelectAction::Select);
5959+
}
5960+
changeAccessibilitySelection(newIndex, SelectAction::Select);
5961+
} else {
5962+
changeAccessibilitySelection(oldIndex, SelectAction::Deselect);
5963+
}
58405964
}
58415965

58425966
void HistoryInner::playPauseFocusedMedia() {
@@ -6521,6 +6645,7 @@ void HistoryInner::applyAccessibilityFocus(
65216645
: nullptr;
65226646
const auto changed = (_accessibilityFocusedIndex != index)
65236647
|| (_accessibilityFocusedItem != item);
6648+
_accessibilitySelectionAnchor = nullptr;
65246649
_accessibilityFocusedIndex = index;
65256650
_accessibilityFocusedItem = item;
65266651
// Exactly one announcement: directly when the widget already has

Telegram/SourceFiles/history/history_inner_widget.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,8 @@ class HistoryInner
515515
not_null<SelectedItems*> toItems,
516516
not_null<HistoryItem*> item,
517517
SelectAction action) const;
518+
void changeAccessibilitySelection(int index, SelectAction action);
519+
void extendAccessibilitySelection(int oldIndex, int newIndex);
518520
void forwardItem(FullMsgId itemId);
519521
void forwardAsGroup(FullMsgId itemId);
520522
void deleteItem(not_null<HistoryItem*> item);
@@ -558,6 +560,7 @@ class HistoryInner
558560

559561
int _accessibilityFocusedIndex = -1;
560562
HistoryItem *_accessibilityFocusedItem = nullptr;
563+
HistoryItem *_accessibilitySelectionAnchor = nullptr;
561564
mutable base::flat_map<
562565
not_null<const HistoryItem*>,
563566
quintptr> _accessibilityIdentities;

Telegram/SourceFiles/history/history_widget.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ For license and copyright information please follow this link:
6161
#include "ui/controls/send_button.h"
6262
#include "ui/controls/send_as_button.h"
6363
#include "ui/controls/silent_toggle.h"
64+
#include "ui/screen_reader_mode.h"
6465
#include "ui/ui_utility.h"
6566
#include "inline_bots/inline_bot_result.h"
6667
#include "base/event_filter.h"
@@ -10462,7 +10463,10 @@ void HistoryWidget::updateTopBarSelection() {
1046210463
|| isBotStart()
1046310464
|| isBlocked()
1046410465
|| !_richDraftPreview->isHidden()
10465-
|| (!_canSendTexts && !_editMsgId)) {
10466+
|| (!_canSendTexts && !_editMsgId)
10467+
|| (_list
10468+
&& _list->hasFocus()
10469+
&& Ui::ScreenReaderModeActive())) {
1046610470
_list->setFocus();
1046710471
} else {
1046810472
_field->setFocus();

Telegram/SourceFiles/history/view/history_view_chat_section.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ For license and copyright information please follow this link:
4141
#include "ui/text/text_utilities.h"
4242
#include "ui/effects/message_sending_animation_controller.h"
4343
#include "ui/rect.h"
44+
#include "ui/screen_reader_mode.h"
4445
#include "ui/ui_utility.h"
4546
#include "base/timer_rpl.h"
4647
#include "api/api_bot.h"
@@ -3328,7 +3329,8 @@ void ChatWidget::listSelectionChanged(SelectedItems &&items) {
33283329
if ((state.count > 0) && _composeSearch) {
33293330
_composeSearch->hideAnimated();
33303331
}
3331-
if (items.empty()) {
3332+
if (items.empty()
3333+
&& !(_inner->hasFocus() && Ui::ScreenReaderModeActive())) {
33323334
doSetInnerFocus();
33333335
}
33343336
}

0 commit comments

Comments
 (0)