diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index d57a556f177f7..0dd825faf3beb 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -3225,6 +3225,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_emoji_category5" = "Travel & Places"; "lng_emoji_category6" = "Objects"; "lng_emoji_category7" = "Symbols & Flags"; +"lng_emoji_categories" = "Categories"; +"lng_emoji_search_groups" = "Search groups"; "lng_emoji_manage_sets" = "Choose emoji set"; "lng_emoji_set_ready" = "Downloaded"; "lng_emoji_set_active" = "Current set"; diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp index 7185af0aedee2..d823fac14da02 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp @@ -29,6 +29,7 @@ For license and copyright information please follow this link: #include "ui/emoji_config.h" #include "ui/painter.h" #include "ui/power_saving.h" +#include "ui/screen_reader_mode.h" #include "ui/ui_utility.h" #include "ui/cached_round_corners.h" #include "boxes/share_box.h" @@ -84,6 +85,10 @@ class EmojiColorPicker final : public Ui::RpWidget { EmojiColorPicker(QWidget *parent, const style::EmojiPan &st); void showEmoji(EmojiPtr emoji, bool allLabel = false); + // Shown for an emoji from the keyboard: takes the focus, with the + // variant shown in the list selected, and gives it back to the list + // when hidden. + void focusFromKeyboard(EmojiPtr shown); void clearSelection(); void handleMouseMove(QPoint globalPos); @@ -97,27 +102,51 @@ class EmojiColorPicker final : public Ui::RpWidget { [[nodiscard]] rpl::producer chosen() const; [[nodiscard]] rpl::producer<> hidden() const; + // The variants as the items of a list for a screen reader. + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + QString accessibilityName() override; + int accessibilityChildCount() const override; + QAccessible::Role accessibilityChildRole() const override; + QString accessibilityChildName(int index) const override; + QRect accessibilityChildRect(int index) const override; + QAccessible::State accessibilityChildState(int index) 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; + protected: void paintEvent(QPaintEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; + void focusInEvent(QFocusEvent *e) override; private: void createAllLabel(); void animationCallback(); void updateSize(); [[nodiscard]] int topColorAllSkip() const; + [[nodiscard]] QRect variantRect(int variant) const; void drawVariant(QPainter &p, int variant); void updateSelected(); void setSelected(int newSelected); + void keyboardSelect(int index); + void chooseSelected(); + void returnFocus(); const style::EmojiPan &_st; bool _ignoreShow = false; + bool _keyboard = false; + base::weak_qptr _focusReturn; + EmojiPtr _emoji = nullptr; QVector _variants; int _selected = -1; @@ -175,6 +204,8 @@ void EmojiColorPicker::showEmoji(EmojiPtr emoji, bool allLabel) { createAllLabel(); } _ignoreShow = false; + _keyboard = false; + _emoji = emoji; _variants.resize(emoji->variantsCount() + 1); for (auto i = 0, size = int(_variants.size()); i != size; ++i) { @@ -189,6 +220,28 @@ void EmojiColorPicker::showEmoji(EmojiPtr emoji, bool allLabel) { showAnimated(); } +void EmojiColorPicker::focusFromKeyboard(EmojiPtr shown) { + if (_variants.isEmpty() || isHidden()) { + return; + } + // The focus arriving announces the selected variant. The picker is + // not a child of the list (it is raised above it in the container), + // so remember the list itself to give the focus back to. + _keyboard = true; + _focusReturn = QApplication::focusWidget(); + setSelected(std::max(int(_variants.indexOf(shown)), 0)); + setFocus(); +} + +void EmojiColorPicker::returnFocus() { + // Before hiding: a hidden widget holding the focus makes Qt move it + // wherever the focus chain leads, not back to the list. + const auto was = base::take(_focusReturn); + if (was && Ui::InFocusChain(this)) { + was->setFocus(); + } +} + void EmojiColorPicker::createAllLabel() { _allLabel = std::make_unique( this, @@ -317,9 +370,10 @@ void EmojiColorPicker::animationCallback() { _allLabel->show(); } if (_hiding) { + returnFocus(); hide(); _hidden.fire({}); - } else { + } else if (!_keyboard) { _lastMousePos = QCursor::pos(); updateSelected(); } @@ -330,6 +384,7 @@ void EmojiColorPicker::hideFast() { clearSelection(); _a_opacity.stop(); _cache = QPixmap(); + returnFocus(); hide(); _hidden.fire({}); } @@ -437,6 +492,172 @@ void EmojiColorPicker::setSelected(int newSelected) { setCursor((_selected >= 0) ? style::cur_pointer : style::cur_default); } +QRect EmojiColorPicker::variantRect(int variant) const { + const auto addedSkip = (variant > 0) + ? (2 * st::emojiColorsPadding + st::emojiColorsSep) + : 0; + const auto left = st::emojiPanMargins.left() + + st::emojiColorsPadding + + variant * _singleSize.width() + + addedSkip; + const auto top = st::emojiPanMargins.top() + + st::emojiColorsPadding + + topColorAllSkip(); + return myrtlrect(left, top, _singleSize.width(), _singleSize.height()); +} + +void EmojiColorPicker::keyboardSelect(int index) { + if (index < 0 || index >= _variants.size()) { + return; + } + _keyboard = true; + setSelected(index); + accessibilityChildFocused(index); +} + +void EmojiColorPicker::chooseSelected() { + if (_selected < 0 || _selected >= _variants.size()) { + return; + } + // As a click does: the choice, then away. + _chosen.fire_copy({ .emoji = _variants[_selected] }); + _ignoreShow = true; + hideAnimated(); +} + +void EmojiColorPicker::keyPressEvent(QKeyEvent *e) { + const auto key = e->key(); + const auto count = int(_variants.size()); + if (key == Qt::Key_Left || key == Qt::Key_Right) { + const auto forward = (key == Qt::Key_Right) != rtl(); + const auto index = (_selected < 0) + ? (forward ? 0 : count - 1) + : std::clamp(_selected + (forward ? 1 : -1), 0, count - 1); + keyboardSelect(index); + } else if (key == Qt::Key_Home) { + keyboardSelect(0); + } else if (key == Qt::Key_End) { + keyboardSelect(count - 1); + } else if (!e->isAutoRepeat() + && (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter)) { + chooseSelected(); + } else if (key == Qt::Key_Escape) { + _ignoreShow = true; + hideAnimated(); + } else { + RpWidget::keyPressEvent(e); + return; + } + e->accept(); +} + +void EmojiColorPicker::focusInEvent(QFocusEvent *e) { + RpWidget::focusInEvent(e); + if (_variants.isEmpty()) { + return; + } + const auto index = (_selected >= 0) ? _selected : 0; + InvokeQueued(this, [=] { + if (hasFocus() && index < _variants.size()) { + keyboardSelect(index); + } + }); +} + +QAccessible::Role EmojiColorPicker::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy EmojiColorPicker::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +QString EmojiColorPicker::accessibilityName() { + // The emoji the variants are of. + return _emoji ? _emoji->original()->text() : QString(); +} + +int EmojiColorPicker::accessibilityChildCount() const { + return int(_variants.size()); +} + +QAccessible::Role EmojiColorPicker::accessibilityChildRole() const { + return QAccessible::ListItem; +} + +QString EmojiColorPicker::accessibilityChildName(int index) const { + return (index >= 0 && index < _variants.size()) + ? _variants[index]->text() + : QString(); +} + +QRect EmojiColorPicker::accessibilityChildRect(int index) const { + return (index >= 0 && index < _variants.size()) + ? variantRect(index) + : QRect(); +} + +QAccessible::State EmojiColorPicker::accessibilityChildState( + int index) const { + auto state = QAccessible::State(); + if (Ui::ScreenReaderModeActive()) { + state.focusable = true; + state.selectable = true; + } + if (index == _selected) { + state.active = true; + // The item the keyboard is on is the selection of the list - or a + // screen reader reports every item as "not selected". + state.selected = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; +} + +bool EmojiColorPicker::accessibilityChildSupportsActions(int index) const { + return accessibilityChildIdentity(index) != 0; +} + +quintptr EmojiColorPicker::accessibilityChildIdentity(int index) const { + return (index >= 0 && index < _variants.size()) + ? quintptr(index + 1) + : quintptr(0); +} + +int EmojiColorPicker::accessibilityChildIndexByIdentity( + quintptr identity) const { + const auto index = int(identity) - 1; + return (identity && index < _variants.size()) ? index : -1; +} + +void EmojiColorPicker::accessibilityChildSetFocus(quintptr identity) { + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + if (index < 0) { + return; + } + keyboardSelect(index); + if (!hasFocus()) { + setFocus(); + } + }); +} + +void EmojiColorPicker::accessibilityChildActivate(quintptr identity) { + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + if (index < 0) { + return; + } + setSelected(index); + chooseSelected(); + }); +} + void EmojiColorPicker::drawVariant(QPainter &p, int variant) { const auto w = QPoint( st::emojiPanMargins.left(), @@ -516,8 +737,10 @@ EmojiListWidget::EmojiListWidget( , _searchRequestTimer([=] { sendSearchRequest(); }) , _picker(this, st()) , _showPickerTimer([=] { showPicker(); }) +, _keyPickerTimer([=] { keyPickerTimeout(); }) , _previewTimer([=] { showPreview(); }) { setMouseTracking(true); + setAccessibleName(tr::lng_switch_emoji(tr::now)); if (st().bg->c.alpha() > 0) { setAttribute(Qt::WA_OpaquePaintEvent); } @@ -648,6 +871,15 @@ void EmojiListWidget::setupSearch() { }); _searchQueries.fire_copy(_nextSearchQuery); }, session, type); + + // Enter in the field: on to the first of the results. + _search->submits( + ) | rpl::on_next([=] { + if (const auto first = accessibleChild(0)) { + keyboardSelect(*first, false); + setFocus(); + } + }, lifetime()); } void EmojiListWidget::setSearchRightReserved(int value) { @@ -1106,6 +1338,16 @@ void EmojiListWidget::searchSetsResultsDone( } void EmojiListWidget::showSearchResults() { + // The keyboard keeps its place through a refill of the results - they + // come in more than once, the cloud ones after the local - and a + // group chosen from the keyboard lands on the first of them. + const auto fromGroups = _search && _search->groupsHaveFocus(); + const auto keyboard = hasFocus(); + const auto selected = std::get_if(&_selected); + const auto wasIndex = (keyboard && selected) + ? accessibleIndex(*selected) + : -1; + clearSelection(); _searchResults.clear(); @@ -1146,6 +1388,21 @@ void EmojiListWidget::showSearchResults() { _recentShownCount = _searchResults.size(); update(); updateSelected(); + + const auto count = accessibilityChildCount(); + if (!count) { + return; + } else if (fromGroups) { + if (const auto first = accessibleChild(0)) { + keyboardSelect(*first, false); + setFocus(); + } + } else if (keyboard) { + const auto index = std::clamp(std::max(wasIndex, 0), 0, count - 1); + if (const auto over = accessibleChild(index)) { + keyboardSelect(*over, true); + } + } } void EmojiListWidget::fillCloudSearchResults() { @@ -1684,7 +1941,23 @@ object_ptr EmojiListWidget::createFooter() { _footer->setChosen( ) | rpl::on_next([=](uint64 setId) { + const auto keyboard = _footer->hasFocus(); showSet(setId); + if (keyboard) { + // Chosen from the keyboard: on to the first emoji of the + // section, as the mouse would go on to click one. + enumerateSections([&](const SectionInfo &info) { + if (setId != sectionSetId(info.section)) { + return true; + } else if (shownCount(info) > 0) { + keyboardSelect( + { .section = info.section, .index = 0 }, + false); + setFocus(); + } + return false; + }); + } }, _footer->lifetime()); return result; @@ -1694,8 +1967,16 @@ void EmojiListWidget::afterShown() { const auto steal = (_mode == Mode::EmojiStatus) || (_mode == Mode::FullReactions) || (_mode == Mode::UserpicBuilder); - if (_search && steal) { + if (keepsFocusOnShow()) { + return; + } else if (_search && steal) { _search->stealFocus(); + } else if (Ui::ScreenReaderModeActive() && !hasFocus()) { + // The panel is opened from the keyboard and has no one to talk + // to: take the focus into the list, so the emoji are announced + // and walked with the arrows, and remember where it came from. + _focusReturn = window()->focusWidget(); + setFocus(); } } @@ -1703,6 +1984,16 @@ void EmojiListWidget::beforeHiding() { if (_search) { _search->returnFocus(); } + returnFocus(); +} + +void EmojiListWidget::returnFocus() { + // The picker of variants is raised above the list, not inside it, + // but the focus in it is still ours to give back. + const auto was = base::take(_focusReturn); + if (was && (Ui::InFocusChain(this) || _picker->hasFocus())) { + was->setFocus(); + } } template @@ -3132,8 +3423,11 @@ void EmojiListWidget::pickerHidden() { disableScroll(false); setColorAllForceRippled(false); - _lastMousePos = QCursor::pos(); - updateSelected(); + // Opened from the keyboard, the list keeps the emoji it was on. + if (!_keyboardSelection) { + _lastMousePos = QCursor::pos(); + updateSelected(); + } } bool EmojiListWidget::hasColorButton(int index) const { @@ -3350,7 +3644,16 @@ void EmojiListWidget::colorChosen(EmojiChosen data) { _emoji[over->section][over->index] = emoji; rtlupdate(emojiRect(over->section, over->index)); } + // Chosen from the keyboard, as Enter on the emoji would: the + // focus goes back before the choice, and the panel is done. + const auto keyboard = _picker->hasFocus(); + if (keyboard) { + returnFocus(); + } selectEmoji(data); + if (keyboard) { + _hideRequests.fire({}); + } } _picker->hideAnimated(); } @@ -3401,15 +3704,21 @@ void EmojiListWidget::mouseMoveEvent(QMouseEvent *e) { _picker->clearSelection(); } } + _keyboardSelection = false; updateSelected(); } void EmojiListWidget::leaveEventHook(QEvent *e) { - clearSelection(); + // A selection made from the keyboard stays where the mouse is not. + if (!_keyboardSelection) { + clearSelection(); + } } void EmojiListWidget::leaveToChildEvent(QEvent *e, QWidget *child) { - clearSelection(); + if (!_keyboardSelection) { + clearSelection(); + } } void EmojiListWidget::enterFromChildEvent(QEvent *e, QWidget *child) { @@ -3420,9 +3729,497 @@ void EmojiListWidget::enterFromChildEvent(QEvent *e, QWidget *child) { void EmojiListWidget::clearSelection() { setPressed(v::null); setSelected(v::null); + _keyboardSelection = false; _lastMousePos = mapToGlobal(QPoint(-10, -10)); } +rpl::producer<> EmojiListWidget::hideRequests() const { + return _hideRequests.events(); +} + +int EmojiListWidget::shownCount(const SectionInfo &info) const { + return info.collapsed + ? std::min(info.count, _columnCount * kCollapsedRows) + : info.count; +} + +bool EmojiListWidget::isExpandCell( + const SectionInfo &info, + int index) const { + return info.collapsed && (index + 1 == _columnCount * kCollapsedRows); +} + +std::optional EmojiListWidget::accessibleChild( + int index) const { + if (index < 0 || _columnCount <= 0) { + return std::nullopt; + } + auto result = std::optional(); + enumerateSections([&](const SectionInfo &info) { + const auto count = shownCount(info); + if (index < count) { + result = OverEmoji{ .section = info.section, .index = index }; + return false; + } + index -= count; + return true; + }); + return result; +} + +int EmojiListWidget::accessibleIndex(const OverEmoji &over) const { + if (_columnCount <= 0) { + return -1; + } + auto result = -1; + auto before = 0; + enumerateSections([&](const SectionInfo &info) { + if (info.section == over.section) { + if (over.index >= 0 && over.index < shownCount(info)) { + result = before + over.index; + } + return false; + } + before += shownCount(info); + return true; + }); + return result; +} + +QString EmojiListWidget::accessibleEmojiText(const OverEmoji &over) const { + const auto info = sectionInfo(over.section); + if (isExpandCell(info, over.index)) { + // The last cell of a collapsed section shows how many more there + // are, and opens the section. + return u"+%1"_q.arg(info.count - _columnCount * kCollapsedRows + 1); + } else if (const auto emoji = lookupOverEmoji(&over)) { + // The emoji itself: the screen reader names it in its own words. + return emoji->text(); + } else if (const auto custom = lookupCustomEmoji(&over)) { + const auto sticker = custom.document->sticker(); + return sticker ? sticker->alt : QString(); + } + return QString(); +} + +QString EmojiListWidget::sectionTitle(int section) const { + if (_searchMode) { + return (section > 0) ? searchSetBySection(section).title : QString(); + } else if (section == int(Section::Recent)) { + return tr::lng_recent_stickers(tr::now); + } else if (section < _staticCount) { + return EmojiCategoryTitle(section)(tr::now); + } else if (section - _staticCount < int(_custom.size())) { + return _custom[section - _staticCount].title; + } + return QString(); +} + +QAccessible::Role EmojiListWidget::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy EmojiListWidget::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +int EmojiListWidget::accessibilityChildCount() const { + if (_columnCount <= 0) { + return 0; + } + auto result = 0; + enumerateSections([&](const SectionInfo &info) { + result += shownCount(info); + return true; + }); + return result; +} + +QAccessible::Role EmojiListWidget::accessibilityChildRole() const { + return QAccessible::ListItem; +} + +QString EmojiListWidget::accessibilityChildName(int index) const { + const auto over = accessibleChild(index); + return over ? accessibleEmojiText(*over) : QString(); +} + +QString EmojiListWidget::accessibilityChildDescription(int index) const { + // The section the emoji is in, heard on landing in it. + const auto over = accessibleChild(index); + return over ? sectionTitle(over->section) : QString(); +} + +QRect EmojiListWidget::accessibilityChildRect(int index) const { + const auto over = accessibleChild(index); + return over ? myrtlrect(emojiRect(over->section, over->index)) : QRect(); +} + +QAccessible::State EmojiListWidget::accessibilityChildState( + int index) const { + auto state = QAccessible::State(); + if (Ui::ScreenReaderModeActive()) { + state.focusable = true; + state.selectable = true; + } + const auto over = accessibleChild(index); + const auto selected = std::get_if(&_selected); + if (over && selected && *selected == *over) { + state.active = true; + // The item the keyboard is on is the selection of the list - or a + // screen reader reports every item as "not selected". + state.selected = true; + if (hasFocus()) { + state.focused = true; + } + } + // An emoji with variants opens the picker of its skin tones: a + // screen reader hears it as collapsed, and expanded while the picker + // is up for it. + if (over) { + const auto emoji = lookupOverEmoji(&*over); + if (emoji && emoji->hasVariants()) { + state.expandable = true; + const auto picked = std::get_if(&_pickerSelected); + state.expanded = picked + && (*picked == *over) + && !_picker->isHidden(); + } + } + return state; +} + +void EmojiListWidget::accessibilityChildShowMenu(quintptr identity) { + // Expand opens the picker of variants for the item, Collapse closes + // it - the bridge calls this for either, by the state it sees. + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + const auto over = accessibleChild(index); + if (!over) { + return; + } + const auto picked = std::get_if(&_pickerSelected); + if (picked && *picked == *over && !_picker->isHidden()) { + _picker->hideAnimated(); + return; + } + keyboardSelect(*over, false); + if (!hasFocus()) { + _focusReturn = window()->focusWidget(); + setFocus(); + } + [[maybe_unused]] const auto opened = openKeyboardPicker(); + }); +} + +bool EmojiListWidget::accessibilityChildSupportsActions(int index) const { + return accessibilityChildIdentity(index) != 0; +} + +quintptr EmojiListWidget::accessibilityChildIdentity(int index) const { + // The cell itself names the item: its section and its place in it. + // The tag bit keeps the token non-zero. + const auto over = accessibleChild(index); + return over + ? ((quintptr(over->section + 1) << 24) + | (quintptr(over->index + 1) << 1) + | quintptr(1)) + : quintptr(0); +} + +int EmojiListWidget::accessibilityChildIndexByIdentity( + quintptr identity) const { + if (!identity) { + return -1; + } + return accessibleIndex(OverEmoji{ + .section = int(identity >> 24) - 1, + .index = int((identity >> 1) & 0x7FFFFF) - 1, + }); +} + +void EmojiListWidget::accessibilityChildSetFocus(quintptr identity) { + // UIA invokes the action on a background thread: resolve and touch + // the widget on the main one, like the other painted lists do. + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + const auto over = accessibleChild(index); + if (!over) { + return; + } + keyboardSelect(*over, hasFocus()); + if (!hasFocus()) { + _focusReturn = window()->focusWidget(); + setFocus(); + } + }); +} + +void EmojiListWidget::accessibilityChildActivate(quintptr identity) { + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + const auto over = accessibleChild(index); + if (!over) { + return; + } + keyboardSelect(*over, false); + activateKeyboardSelected(); + }); +} + +void EmojiListWidget::keyboardSelect(const OverEmoji &over, bool announce) { + _keyboardSelection = true; + setSelected(over); + ensureCellVisible(over); + if (announce) { + const auto index = accessibleIndex(over); + if (index >= 0) { + accessibilityChildFocused(index); + } + } +} + +void EmojiListWidget::ensureCellVisible(const OverEmoji &over) { + const auto rect = emojiRect(over.section, over.index); + const auto top = getVisibleTop(); + const auto bottom = getVisibleBottom(); + if (bottom <= top) { + return; + } else if (rect.y() < top) { + // Show the header of the section the cell opens. + const auto info = sectionInfo(over.section); + scrollTo((rect.y() < info.rowsTop + _singleSize.height()) + ? info.top + : rect.y()); + } else if (rect.y() + rect.height() > bottom) { + scrollTo(rect.y() + rect.height() - (bottom - top)); + } +} + +void EmojiListWidget::keyboardMoveBy(int delta) { + const auto count = accessibilityChildCount(); + if (!count) { + return; + } + const auto selected = std::get_if(&_selected); + const auto current = selected ? accessibleIndex(*selected) : -1; + const auto index = (current < 0) + ? ((delta > 0) ? 0 : count - 1) + : std::clamp(current + delta, 0, count - 1); + if (const auto over = accessibleChild(index)) { + keyboardSelect(*over, true); + } +} + +std::optional EmojiListWidget::neighborRow( + const OverEmoji &over, + int step) const { + // The same column one row up or down, on into the next section + // that has anything to show - its first or its last row. + const auto info = sectionInfo(over.section); + const auto column = over.index % _columnCount; + const auto row = over.index / _columnCount; + const auto rows = (shownCount(info) + _columnCount - 1) / _columnCount; + if (row + step >= 0 && row + step < rows) { + const auto index = std::min( + (row + step) * _columnCount + column, + shownCount(info) - 1); + return OverEmoji{ .section = over.section, .index = index }; + } + const auto sections = sectionsCount(); + auto section = over.section + step; + while (section >= 0 && section < sections) { + const auto count = shownCount(sectionInfo(section)); + if (count > 0) { + const auto lastRow = (count + _columnCount - 1) / _columnCount - 1; + const auto index = std::min( + ((step > 0) ? 0 : lastRow) * _columnCount + column, + count - 1); + return OverEmoji{ .section = section, .index = index }; + } + section += step; + } + return std::nullopt; +} + +void EmojiListWidget::keyboardMoveRows(int rows) { + const auto selected = std::get_if(&_selected); + if (!selected || accessibleIndex(*selected) < 0) { + keyboardMoveBy(rows > 0 ? 1 : -1); + return; + } + auto over = *selected; + const auto step = (rows > 0) ? 1 : -1; + for (auto i = 0; i != std::abs(rows); ++i) { + const auto next = neighborRow(over, step); + if (!next) { + break; + } + over = *next; + } + keyboardSelect(over, true); +} + +void EmojiListWidget::expandSection(int section) { + if (_searchMode && section > 0) { + searchSetBySection(section).expanded = true; + } else if (section >= _staticCount) { + _custom[section - _staticCount].expanded = true; + } + resizeToWidth(width()); + update(); +} + +void EmojiListWidget::activateKeyboardSelected() { + const auto selected = std::get_if(&_selected); + if (!selected || accessibleIndex(*selected) < 0) { + return; + } + const auto over = *selected; + if (isExpandCell(sectionInfo(over.section), over.index)) { + expandSection(over.section); + keyboardSelect(over, true); + return; + } + // The field the emoji goes to tells itself apart by the focus, so + // the focus goes back before the choice is made - and the panel is + // done: hide it, as Escape would. + returnFocus(); + if (const auto emoji = lookupOverEmoji(&over)) { + selectEmoji(lookupChosen(emoji, &over)); + } else if (const auto custom = lookupCustomEmoji(&over)) { + selectCustom(lookupChosen(custom, &over)); + } else { + return; + } + _hideRequests.fire({}); +} + +void EmojiListWidget::focusInEvent(QFocusEvent *e) { + RpWidget::focusInEvent(e); + // Land on the emoji last walked to, or the first one there is. + const auto selected = std::get_if(&_selected); + auto over = (selected && accessibleIndex(*selected) >= 0) + ? std::optional(*selected) + : accessibleChild(0); + if (!over) { + return; + } + keyboardSelect(*over, false); + const auto index = accessibleIndex(*over); + InvokeQueued(this, [=] { + const auto now = std::get_if(&_selected); + if (hasFocus() && now && accessibleIndex(*now) == index) { + accessibilityChildFocused(index); + } + }); +} + +void EmojiListWidget::focusOutEvent(QFocusEvent *e) { + RpWidget::focusOutEvent(e); + // The focus in the picker of variants is still ours. + if (!_picker->hasFocus()) { + _keyboardSelection = false; + } + // A key held while the focus left is nothing to act on. + _keyPressPending = false; + _keyPickerTimer.cancel(); +} + +void EmojiListWidget::keyPressEvent(QKeyEvent *e) { + const auto key = e->key(); + const auto rowHeight = std::max(_singleSize.height(), 1); + const auto rowsOnPage = std::max( + (getVisibleBottom() - getVisibleTop()) / rowHeight, + 1); + if (key == Qt::Key_Left || key == Qt::Key_Right) { + const auto forward = (key == Qt::Key_Right) != rtl(); + keyboardMoveBy(forward ? 1 : -1); + } else if (key == Qt::Key_Up || key == Qt::Key_Down) { + keyboardMoveRows((key == Qt::Key_Down) ? 1 : -1); + } else if (key == Qt::Key_PageUp || key == Qt::Key_PageDown) { + keyboardMoveRows((key == Qt::Key_PageDown) + ? rowsOnPage + : -rowsOnPage); + } else if (key == Qt::Key_Home) { + keyboardMoveBy(-accessibilityChildCount()); + } else if (key == Qt::Key_End) { + keyboardMoveBy(accessibilityChildCount()); + } else if (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter) { + // As the mouse does on an emoji with variants: none of them ever + // chosen, the press asks for one right away; else the picker + // comes up under the key held for a long press, and the key let + // go before that chooses. + if (!e->isAutoRepeat() && !_keyPressPending) { + _keyPressPending = true; + const auto selected = std::get_if(&_selected); + const auto emoji = lookupOverEmoji(selected); + if (emoji && emoji->hasVariants()) { + if (!Core::App().settings().hasChosenEmojiVariant(emoji)) { + keyPickerTimeout(); + } else { + _keyPickerTimer.callOnce(kColorPickerDelay); + } + } + } + } else if (key == Qt::Key_Escape) { + returnFocus(); + _hideRequests.fire({}); + } else { + RpWidget::keyPressEvent(e); + return; + } + e->accept(); +} + +void EmojiListWidget::keyReleaseEvent(QKeyEvent *e) { + const auto key = e->key(); + if (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter) { + if (_keyPressPending && !e->isAutoRepeat()) { + _keyPressPending = false; + _keyPickerTimer.cancel(); + activateKeyboardSelected(); + } + e->accept(); + return; + } + RpWidget::keyReleaseEvent(e); +} + +void EmojiListWidget::keyPickerTimeout() { + if (!_keyPressPending) { + return; + } + // The picker takes the focus, and the key let go later is its own. + _keyPressPending = false; + [[maybe_unused]] const auto opened = openKeyboardPicker(); +} + +bool EmojiListWidget::openKeyboardPicker() { + // The variants of the emoji - its skin tones - in the picker a long + // press shows, with the focus in it. + const auto selected = std::get_if(&_selected); + if (!selected || accessibleIndex(*selected) < 0) { + return false; + } + const auto emoji = lookupOverEmoji(selected); + if (!emoji || !emoji->hasVariants()) { + return false; + } + _pickerSelected = _selected; + showPicker(); + if (_picker->isHidden()) { + _pickerSelected = v::null; + return false; + } + _picker->focusFromKeyboard(emoji); + return true; +} + uint64 EmojiListWidget::currentSet(int yOffset) const { return sectionSetId(sectionInfoByOffset(yOffset).section); } diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h index fafbee9f2afec..de68aa1ce1582 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h @@ -13,6 +13,7 @@ For license and copyright information please follow this link: #include "ui/widgets/tooltip.h" #include "ui/round_rect.h" #include "base/timer.h" +#include "base/weak_qptr.h" #include @@ -172,11 +173,36 @@ class EmojiListWidget final [[nodiscard]] rpl::producer> searchQueries() const; [[nodiscard]] rpl::producer recentShownCount() const; + // Fired when the keyboard is done with the list - an emoji chosen or + // Escape pressed - so the panel it is in can hide. + [[nodiscard]] rpl::producer<> hideRequests() const; + + // The emoji as the items of a list for a screen reader, walked with + // the keyboard in screen reader mode. + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + int accessibilityChildCount() const override; + QAccessible::Role accessibilityChildRole() const override; + QString accessibilityChildName(int index) const override; + QString accessibilityChildDescription(int index) const override; + QRect accessibilityChildRect(int index) const override; + QAccessible::State accessibilityChildState(int index) 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; + void accessibilityChildShowMenu(quintptr identity) override; + protected: void visibleTopBottomUpdated( int visibleTop, int visibleBottom) override; + void focusInEvent(QFocusEvent *e) override; + void focusOutEvent(QFocusEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; + void keyReleaseEvent(QKeyEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; @@ -374,6 +400,28 @@ class EmojiListWidget final void ensureLoaded(int section); void updateSelected(); void setSelected(OverState newSelected); + + // The list a screen reader sees: every cell shown, section by + // section - the collapsed sections contribute the rows they show, + // the last of those cells being the one that expands them. + [[nodiscard]] int shownCount(const SectionInfo &info) const; + [[nodiscard]] bool isExpandCell(const SectionInfo &info, int index) const; + [[nodiscard]] std::optional accessibleChild(int index) const; + [[nodiscard]] int accessibleIndex(const OverEmoji &over) const; + [[nodiscard]] QString accessibleEmojiText(const OverEmoji &over) const; + [[nodiscard]] QString sectionTitle(int section) const; + void keyboardSelect(const OverEmoji &over, bool announce); + void keyboardMoveBy(int delta); + void keyboardMoveRows(int rows); + [[nodiscard]] std::optional neighborRow( + const OverEmoji &over, + int step) const; + void activateKeyboardSelected(); + [[nodiscard]] bool openKeyboardPicker(); + void keyPickerTimeout(); + void expandSection(int section); + void ensureCellVisible(const OverEmoji &over); + void returnFocus(); void setPressed(OverState newPressed); void fillRecentMenu( @@ -593,12 +641,23 @@ class EmojiListWidget final OverState _selected; OverState _pressed; + // The selection was made from the keyboard: the mouse leaving the + // list does not clear it, and the widget that had the focus before + // the list took it gets it back when the list is done. + bool _keyboardSelection = false; + base::weak_qptr _focusReturn; + rpl::event_stream<> _hideRequests; OverState _pickerSelected; QPoint _lastMousePos; base::Timer _searchRequestTimer; object_ptr _picker; base::Timer _showPickerTimer; + // Enter or Space held on an emoji with variants brings up their picker + // after the delay of a long press, as the mouse does; let go before + // that, it chooses. + base::Timer _keyPickerTimer; + bool _keyPressPending = false; base::Timer _previewTimer; bool _previewShown = false; diff --git a/Telegram/SourceFiles/chat_helpers/gifs_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/gifs_list_widget.cpp index 9503a4e4eae92..0e9f9f5968006 100644 --- a/Telegram/SourceFiles/chat_helpers/gifs_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/gifs_list_widget.cpp @@ -9,6 +9,7 @@ For license and copyright information please follow this link: #include "api/api_toggling_media.h" // Api::ToggleSavedGif #include "base/const_string.h" +#include "base/invoke_queued.h" #include "base/qt/qt_key_modifiers.h" #include "chat_helpers/stickers_list_footer.h" #include "data/data_photo.h" @@ -24,6 +25,8 @@ For license and copyright information please follow this link: #include "mtproto/mtproto_config.h" #include "core/click_handler_types.h" #include "ui/controls/tabbed_search.h" +#include "ui/screen_reader_mode.h" +#include "ui/ui_utility.h" #include "ui/layers/generic_box.h" #include "ui/widgets/buttons.h" #include "ui/widgets/fields/input_field.h" @@ -113,6 +116,7 @@ GifsListWidget::GifsListWidget( , _mosaic(st::emojiPanWidth - st::inlineResultsLeft) , _previewTimer([=] { showPreview(); }) { setMouseTracking(true); + setAccessibleName(tr::lng_switch_gifs(tr::now)); setAttribute(Qt::WA_OpaquePaintEvent); setupSearch(); @@ -547,15 +551,21 @@ void GifsListWidget::selectInlineResult( void GifsListWidget::mouseMoveEvent(QMouseEvent *e) { _lastMousePos = e->globalPos(); + _keyboardSelection = false; updateSelected(); } void GifsListWidget::leaveEventHook(QEvent *e) { - clearSelection(); + // A selection made from the keyboard stays where the mouse is not. + if (!_keyboardSelection) { + clearSelection(); + } } void GifsListWidget::leaveToChildEvent(QEvent *e, QWidget *child) { - clearSelection(); + if (!_keyboardSelection) { + clearSelection(); + } } void GifsListWidget::enterFromChildEvent(QEvent *e, QWidget *child) { @@ -564,6 +574,7 @@ void GifsListWidget::enterFromChildEvent(QEvent *e, QWidget *child) { } void GifsListWidget::clearSelection() { + _keyboardSelection = false; if (_selected >= 0) { ClickHandler::clearActive(_mosaic.itemAt(_selected)); setCursor(style::cur_default); @@ -818,7 +829,7 @@ Data::FileOrigin GifsListWidget::inlineItemFileOrigin() { } void GifsListWidget::afterShown() { - if (_search) { + if (_search && !keepsFocusOnShow()) { _search->stealFocus(); } } @@ -855,6 +866,15 @@ void GifsListWidget::setupSearch() { refreshIcons(); searchForGifs(accumulated); }, session, TabbedSearchType::Emoji); + + // Enter or Down in the field: on to the first of the results. + _search->submits( + ) | rpl::on_next([=] { + if (_mosaic.maybeItemAt(0, 0)) { + keyboardSelect(Layout::PositionToIndex(0, 0), false); + setFocus(); + } + }, lifetime()); } int32 GifsListWidget::showInlineRows(bool newResults) { @@ -862,6 +882,15 @@ int32 GifsListWidget::showInlineRows(bool newResults) { refreshInlineRows(&added); if (newResults) { scrollTo(0); + // A section chosen in the footer or a group beside the search + // from the keyboard: on to the first of its GIFs, once they are + // in. + const auto fromFooter = _footer && _footer->hasFocus(); + const auto fromGroups = _search && _search->groupsHaveFocus(); + if ((fromFooter || fromGroups) && _mosaic.maybeItemAt(0, 0)) { + keyboardSelect(Layout::PositionToIndex(0, 0), false); + setFocus(); + } } return added; } @@ -912,7 +941,284 @@ void GifsListWidget::cancelled() { } rpl::producer<> GifsListWidget::cancelRequests() const { - return _cancelled.events(); + // The keyboard done with the list - a GIF sent or Escape pressed - + // hides the panel the same way. + return rpl::merge(_cancelled.events(), _hideRequests.events()); +} + +int GifsListWidget::accessibleCount() const { + auto result = 0; + for (auto row = 0, rows = _mosaic.rowsCount(); row != rows; ++row) { + for (auto column = 0; _mosaic.maybeItemAt(row, column); ++column) { + ++result; + } + } + return result; +} + +int GifsListWidget::accessibleIndex(int mosaicIndex) const { + if (mosaicIndex < 0 || !_mosaic.maybeItemAt(mosaicIndex)) { + return -1; + } + const auto position = Layout::IndexToPosition(mosaicIndex); + auto result = 0; + for (auto row = 0; row != position.row; ++row) { + for (auto column = 0; _mosaic.maybeItemAt(row, column); ++column) { + ++result; + } + } + return result + position.column; +} + +int GifsListWidget::mosaicIndexAt(int accessibleIndex) const { + if (accessibleIndex < 0) { + return -1; + } + for (auto row = 0, rows = _mosaic.rowsCount(); row != rows; ++row) { + for (auto column = 0; _mosaic.maybeItemAt(row, column); ++column) { + if (!accessibleIndex--) { + return Layout::PositionToIndex(row, column); + } + } + } + return -1; +} + +QAccessible::Role GifsListWidget::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy GifsListWidget::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +int GifsListWidget::accessibilityChildCount() const { + return accessibleCount(); +} + +QAccessible::Role GifsListWidget::accessibilityChildRole() const { + return QAccessible::ListItem; +} + +QString GifsListWidget::accessibilityChildName(int index) const { + // A GIF has no text of its own; the app calls one just that. + return (mosaicIndexAt(index) >= 0) ? u"GIF"_q : QString(); +} + +QRect GifsListWidget::accessibilityChildRect(int index) const { + const auto mosaicIndex = mosaicIndexAt(index); + return (mosaicIndex >= 0) + ? myrtlrect(_mosaic.findRect(mosaicIndex)) + : QRect(); +} + +QAccessible::State GifsListWidget::accessibilityChildState(int index) const { + auto state = QAccessible::State(); + if (Ui::ScreenReaderModeActive()) { + state.focusable = true; + state.selectable = true; + } + const auto mosaicIndex = mosaicIndexAt(index); + if (mosaicIndex >= 0 && mosaicIndex == _selected) { + state.active = true; + // The item the keyboard is on is the selection of the list - or a + // screen reader reports every item as "not selected". + state.selected = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; +} + +bool GifsListWidget::accessibilityChildSupportsActions(int index) const { + return accessibilityChildIdentity(index) != 0; +} + +quintptr GifsListWidget::accessibilityChildIdentity(int index) const { + // The place in the mosaic names the item; the tag bit keeps it + // non-zero. + const auto mosaicIndex = mosaicIndexAt(index); + return (mosaicIndex >= 0) + ? ((quintptr(mosaicIndex) << 1) | quintptr(1)) + : quintptr(0); +} + +int GifsListWidget::accessibilityChildIndexByIdentity( + quintptr identity) const { + return identity ? accessibleIndex(int(identity >> 1)) : -1; +} + +void GifsListWidget::accessibilityChildSetFocus(quintptr identity) { + crl::on_main(this, [=] { + const auto mosaicIndex = mosaicIndexAt( + accessibilityChildIndexByIdentity(identity)); + if (mosaicIndex < 0) { + return; + } + keyboardSelect(mosaicIndex, hasFocus()); + if (!hasFocus()) { + setFocus(); + } + }); +} + +void GifsListWidget::accessibilityChildActivate(quintptr identity) { + crl::on_main(this, [=] { + const auto mosaicIndex = mosaicIndexAt( + accessibilityChildIndexByIdentity(identity)); + if (mosaicIndex < 0) { + return; + } + keyboardSelect(mosaicIndex, false); + activateKeyboardSelected(); + }); +} + +void GifsListWidget::keyboardSelect(int mosaicIndex, bool announce) { + if (!_mosaic.maybeItemAt(mosaicIndex)) { + return; + } + _keyboardSelection = true; + if (_selected != mosaicIndex) { + if (const auto was = _mosaic.maybeItemAt(_selected)) { + was->update(); + } + _selected = mosaicIndex; + _mosaic.itemAt(mosaicIndex)->update(); + } + ensureItemVisible(mosaicIndex); + if (announce) { + const auto index = accessibleIndex(mosaicIndex); + if (index >= 0) { + accessibilityChildFocused(index); + } + } +} + +void GifsListWidget::ensureItemVisible(int mosaicIndex) { + const auto rect = _mosaic.findRect(mosaicIndex); + const auto top = getVisibleTop(); + const auto bottom = getVisibleBottom(); + if (bottom <= top || rect.isEmpty()) { + return; + } else if (rect.y() < top) { + scrollTo(rect.y()); + } else if (rect.y() + rect.height() > bottom) { + scrollTo(rect.y() + rect.height() - (bottom - top)); + } +} + +void GifsListWidget::keyboardMoveBy(int delta) { + const auto count = accessibleCount(); + if (!count) { + return; + } + const auto current = accessibleIndex(_selected); + const auto index = (current < 0) + ? ((delta > 0) ? 0 : count - 1) + : std::clamp(current + delta, 0, count - 1); + keyboardSelect(mosaicIndexAt(index), true); +} + +void GifsListWidget::keyboardMoveRows(int rows) { + if (!_mosaic.maybeItemAt(_selected)) { + keyboardMoveBy(rows > 0 ? 1 : -1); + return; + } + // The same column one row up or down, or the last of a shorter row. + const auto position = Layout::IndexToPosition(_selected); + const auto row = std::clamp( + position.row + rows, + 0, + std::max(_mosaic.rowsCount() - 1, 0)); + auto column = position.column; + while (column > 0 && !_mosaic.maybeItemAt(row, column)) { + --column; + } + keyboardSelect(Layout::PositionToIndex(row, column), true); +} + +void GifsListWidget::returnFocus() { + // The search took the focus when the panel came up, and gives it + // back from wherever in the panel it is now. + if (_search && Ui::InFocusChain(this)) { + _search->returnFocus(true); + } +} + +void GifsListWidget::activateKeyboardSelected() { + if (!_mosaic.maybeItemAt(_selected)) { + return; + } + // Sent right away, as with Ctrl held: the keyboard can't see whether + // the preview a click waits for has loaded. The focus goes back + // before, and the panel is done: hide it, as Escape would. + const auto index = _selected; + returnFocus(); + selectInlineResult(index, {}, true); + _hideRequests.fire({}); +} + +void GifsListWidget::focusInEvent(QFocusEvent *e) { + RpWidget::focusInEvent(e); + // Land on the GIF last walked to, or the first one there is. + const auto mosaicIndex = _mosaic.maybeItemAt(_selected) + ? _selected + : mosaicIndexAt(0); + if (mosaicIndex < 0) { + return; + } + keyboardSelect(mosaicIndex, false); + InvokeQueued(this, [=] { + if (hasFocus() && _selected == mosaicIndex) { + const auto index = accessibleIndex(mosaicIndex); + if (index >= 0) { + accessibilityChildFocused(index); + } + } + }); +} + +void GifsListWidget::focusOutEvent(QFocusEvent *e) { + RpWidget::focusOutEvent(e); + _keyboardSelection = false; +} + +void GifsListWidget::keyPressEvent(QKeyEvent *e) { + const auto key = e->key(); + if (key == Qt::Key_Left || key == Qt::Key_Right) { + const auto forward = (key == Qt::Key_Right) != rtl(); + keyboardMoveBy(forward ? 1 : -1); + } else if (key == Qt::Key_Up || key == Qt::Key_Down) { + keyboardMoveRows((key == Qt::Key_Down) ? 1 : -1); + } else if (key == Qt::Key_PageUp || key == Qt::Key_PageDown) { + const auto rowHeight = std::max( + _mosaic.rowsCount() ? _mosaic.rowHeightAt(0) : 0, + 1); + const auto rowsOnPage = std::max( + (getVisibleBottom() - getVisibleTop()) / rowHeight, + 1); + keyboardMoveRows((key == Qt::Key_PageDown) + ? rowsOnPage + : -rowsOnPage); + } else if (key == Qt::Key_Home) { + keyboardMoveBy(-accessibleCount()); + } else if (key == Qt::Key_End) { + keyboardMoveBy(accessibleCount()); + } else if (!e->isAutoRepeat() + && (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter)) { + activateKeyboardSelected(); + } else if (key == Qt::Key_Escape) { + returnFocus(); + _hideRequests.fire({}); + } else { + RpWidget::keyPressEvent(e); + return; + } + e->accept(); } void GifsListWidget::sendInlineRequest() { diff --git a/Telegram/SourceFiles/chat_helpers/gifs_list_widget.h b/Telegram/SourceFiles/chat_helpers/gifs_list_widget.h index 52fcae98ae4fe..01eeefd3e475f 100644 --- a/Telegram/SourceFiles/chat_helpers/gifs_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/gifs_list_widget.h @@ -101,6 +101,21 @@ class GifsListWidget final void cancelled(); rpl::producer<> cancelRequests() const; + // The GIFs as the items of a list for a screen reader, walked with the + // keyboard the same way as the emoji list; Enter or Space sends one. + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + int accessibilityChildCount() const override; + QAccessible::Role accessibilityChildRole() const override; + QString accessibilityChildName(int index) const override; + QRect accessibilityChildRect(int index) const override; + QAccessible::State accessibilityChildState(int index) 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; + base::unique_qptr fillContextMenu( const SendMenu::Details &details) override; @@ -111,6 +126,9 @@ class GifsListWidget final int visibleTop, int visibleBottom) override; + void focusInEvent(QFocusEvent *e) override; + void focusOutEvent(QFocusEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; @@ -152,6 +170,17 @@ class GifsListWidget final void inlineResultsDone(const MTPmessages_BotResults &result); void updateSelected(); + + // The list a screen reader sees: the items of the mosaic, row by row. + [[nodiscard]] int accessibleCount() const; + [[nodiscard]] int accessibleIndex(int mosaicIndex) const; + [[nodiscard]] int mosaicIndexAt(int accessibleIndex) const; + void keyboardSelect(int mosaicIndex, bool announce); + void keyboardMoveBy(int delta); + void keyboardMoveRows(int rows); + void activateKeyboardSelected(); + void ensureItemVisible(int mosaicIndex); + void returnFocus(); void paintInlineItems(Painter &p, QRect clip); void refreshIcons(); [[nodiscard]] std::vector fillIcons(); @@ -202,6 +231,10 @@ class GifsListWidget final Mosaic::Layout::MosaicLayout _mosaic; int _selected = -1; + // The selection was made from the keyboard: the mouse leaving the + // list does not clear it. + bool _keyboardSelection = false; + rpl::event_stream<> _hideRequests; int _pressed = -1; QPoint _lastMousePos; diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp index cad9834a6ddab..0eb801a0f5318 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp @@ -8,6 +8,7 @@ For license and copyright information please follow this link: #include "chat_helpers/stickers_list_footer.h" #include "chat_helpers/emoji_keywords.h" +#include "chat_helpers/emoji_list_widget.h" #include "chat_helpers/stickers_emoji_pack.h" #include "chat_helpers/stickers_lottie.h" #include "core/application.h" @@ -21,6 +22,7 @@ For license and copyright information please follow this link: #include "data/data_document_media.h" #include "main/main_app_config.h" #include "main/main_session.h" +#include "lang/lang_hardcoded.h" #include "lang/lang_keys.h" #include "lottie/lottie_single_player.h" #include "ui/dpr/dpr_icon.h" @@ -28,6 +30,7 @@ For license and copyright information please follow this link: #include "ui/widgets/fields/input_field.h" #include "ui/widgets/buttons.h" #include "ui/painter.h" +#include "ui/screen_reader_mode.h" #include "ui/rect_part.h" #include "styles/style_chat_helpers.h" @@ -315,6 +318,7 @@ StickersListFooter::StickersListFooter(Descriptor &&descriptor) , _subselectionBg(st().iconArea / 2, st().categoriesBgOver) , _forceFirstFrame(descriptor.forceFirstFrame) { setMouseTracking(true); + setAccessibleName(tr::lng_emoji_categories(tr::now)); _iconsLeft = st().iconSkip + (_features.stickersSettings ? st().iconWidth : 0); @@ -952,6 +956,294 @@ bool StickersListFooter::eventHook(QEvent *e) { return InnerFooter::eventHook(e); } +std::optional StickersListFooter::accessibleChild( + int index) const { + if (index < 0) { + return std::nullopt; + } + if (_features.stickersSettings && !_icons.empty()) { + if (!index) { + return AccessibleChild{ .settings = true }; + } + --index; + } + for (auto i = 0, count = int(_icons.size()); i != count; ++i) { + const auto all = (_icons[i].setId == AllEmojiSectionSetId()); + const auto sub = all + ? (int(EmojiSection::Symbols) - int(EmojiSection::People) + 1) + : 1; + if (index < sub) { + return AccessibleChild{ + .icon = { .index = i, .subindex = all ? index : 0 }, + }; + } + index -= sub; + } + return std::nullopt; +} + +int StickersListFooter::accessibleIndex(const OverState &over) const { + const auto count = accessibilityChildCount(); + for (auto i = 0; i != count; ++i) { + const auto child = accessibleChild(i); + if (!child) { + break; + } else if (child->settings) { + if (over == OverState(SpecialOver::Settings)) { + return i; + } + } else if (over == OverState(child->icon)) { + return i; + } + } + return -1; +} + +int StickersListFooter::accessibleActiveIndex() const { + // The icon of the section shown, with its category. + if (_iconState.selected < 0 || _iconState.selected >= _icons.size()) { + return -1; + } + const auto all = (_icons[_iconState.selected].setId + == AllEmojiSectionSetId()); + return accessibleIndex(IconId{ + .index = _iconState.selected, + .subindex = all ? std::max(_subiconState.selected, 0) : 0, + }); +} + +QString StickersListFooter::iconTitle(const StickerIcon &icon) const { + if (const auto section = SetIdEmojiSection(icon.setId)) { + return (*section == EmojiSection::Recent) + ? tr::lng_recent_stickers(tr::now) + : EmojiCategoryTitle(int(*section))(tr::now); + } else if (icon.setId == Data::Stickers::RecentSetId + || icon.setId == Data::Stickers::CloudRecentSetId) { + return tr::lng_recent_stickers(tr::now); + } else if (icon.setId == Data::Stickers::FavedSetId) { + return Lang::Hard::FavedSetTitle(); + } else if (icon.setId == Data::Stickers::FeaturedSetId) { + return tr::lng_stickers_featured_tab(tr::now); + } else if (icon.setId == Data::Stickers::CollectibleSetId) { + return tr::lng_collectible_emoji(tr::now); + } else if (icon.setId == Data::Stickers::MegagroupSetId) { + return icon.megagroup + ? icon.megagroup->name() + : tr::lng_stickers_group_set(tr::now); + } else if (icon.set && !icon.set->title.isEmpty()) { + return icon.set->title; + } else if (icon.sticker && icon.sticker->sticker()) { + // The sections of the GIFs tab stand for an emoji each. + return icon.sticker->sticker()->alt; + } + return QString(); +} + +QAccessible::Role StickersListFooter::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy StickersListFooter::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +int StickersListFooter::accessibilityChildCount() const { + if (_icons.empty()) { + return 0; + } + auto result = _features.stickersSettings ? 1 : 0; + for (const auto &icon : _icons) { + result += (icon.setId == AllEmojiSectionSetId()) + ? (int(EmojiSection::Symbols) - int(EmojiSection::People) + 1) + : 1; + } + return result; +} + +QAccessible::Role StickersListFooter::accessibilityChildRole() const { + return QAccessible::ListItem; +} + +QString StickersListFooter::accessibilityChildName(int index) const { + const auto child = accessibleChild(index); + if (!child) { + return QString(); + } else if (child->settings) { + return tr::lng_stickers_you_have(tr::now); + } + const auto &icon = _icons[child->icon.index]; + return (icon.setId == AllEmojiSectionSetId()) + ? EmojiCategoryTitle( + int(EmojiSection::People) + child->icon.subindex)(tr::now) + : iconTitle(icon); +} + +QRect StickersListFooter::accessibilityChildRect(int index) const { + const auto child = accessibleChild(index); + if (!child) { + return QRect(); + } else if (child->settings) { + return myrtlrect( + _iconsLeft - _singleWidth, + _iconsTop, + _singleWidth, + st().footer); + } + const auto info = iconInfo(child->icon.index); + const auto all = (_icons[child->icon.index].setId + == AllEmojiSectionSetId()); + if (all && _subiconsExpanded) { + const auto sub = subiconInfo(child->icon.subindex); + return myrtlrect( + info.adjustedLeft + sub.adjustedLeft, + _iconsTop, + sub.width, + st().footer); + } + return myrtlrect(info.adjustedLeft, _iconsTop, info.width, st().footer); +} + +QAccessible::State StickersListFooter::accessibilityChildState( + int index) const { + auto state = QAccessible::State(); + if (Ui::ScreenReaderModeActive()) { + state.focusable = true; + state.selectable = true; + } + const auto child = accessibleChild(index); + if (child && !child->settings && index == accessibleActiveIndex()) { + // The section shown in the list. + state.selected = true; + } + if (index == _keyboardSelected) { + state.active = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; +} + +bool StickersListFooter::accessibilityChildSupportsActions( + int index) const { + return accessibilityChildIdentity(index) != 0; +} + +quintptr StickersListFooter::accessibilityChildIdentity(int index) const { + // The place in the list names the item; the tag bit keeps it non-zero. + return (index >= 0 && index < accessibilityChildCount()) + ? ((quintptr(index) << 1) | quintptr(1)) + : quintptr(0); +} + +int StickersListFooter::accessibilityChildIndexByIdentity( + quintptr identity) const { + const auto index = int(identity >> 1); + return (identity && index < accessibilityChildCount()) ? index : -1; +} + +void StickersListFooter::accessibilityChildSetFocus(quintptr identity) { + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + if (index < 0) { + return; + } + keyboardSelect(index, hasFocus()); + if (!hasFocus()) { + setFocus(); + } + }); +} + +void StickersListFooter::accessibilityChildActivate(quintptr identity) { + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + if (const auto child = accessibleChild(index)) { + keyboardSelect(index, false); + activateChild(*child); + } + }); +} + +void StickersListFooter::keyboardSelect(int index, bool announce) { + if (index < 0 || index >= accessibilityChildCount()) { + return; + } + _keyboardSelected = index; + update(); + if (announce) { + accessibilityChildFocused(index); + } +} + +void StickersListFooter::activateChild(const AccessibleChild &child) { + if (child.settings) { + _openSettingsRequests.fire({}); + return; + } + // As a click on the icon does. + const auto &icon = _icons[child.icon.index]; + const auto info = iconInfo(child.icon.index); + _iconState.selectionX = anim::value(info.left, info.left); + _iconState.selectionWidth = anim::value(info.width, info.width); + _setChosen.fire_copy((icon.setId == AllEmojiSectionSetId()) + ? EmojiSectionSetId( + EmojiSection(int(EmojiSection::People) + child.icon.subindex)) + : icon.setId); +} + +void StickersListFooter::focusInEvent(QFocusEvent *e) { + RpWidget::focusInEvent(e); + // Land on the section shown, or the item last walked to. + const auto count = accessibilityChildCount(); + if (!count) { + return; + } + const auto active = accessibleActiveIndex(); + const auto index = (active >= 0) + ? active + : (_keyboardSelected >= 0 && _keyboardSelected < count) + ? _keyboardSelected + : 0; + keyboardSelect(index, false); + InvokeQueued(this, [=] { + if (hasFocus() && _keyboardSelected == index) { + accessibilityChildFocused(index); + } + }); +} + +void StickersListFooter::keyPressEvent(QKeyEvent *e) { + const auto key = e->key(); + const auto count = accessibilityChildCount(); + if (!count) { + RpWidget::keyPressEvent(e); + return; + } + const auto current = std::clamp(_keyboardSelected, 0, count - 1); + if (key == Qt::Key_Left || key == Qt::Key_Right) { + const auto forward = (key == Qt::Key_Right) != rtl(); + keyboardSelect( + std::clamp(current + (forward ? 1 : -1), 0, count - 1), + true); + } else if (key == Qt::Key_Home) { + keyboardSelect(0, true); + } else if (key == Qt::Key_End) { + keyboardSelect(count - 1, true); + } else if (!e->isAutoRepeat() + && (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter)) { + if (const auto child = accessibleChild(current)) { + activateChild(*child); + } + } else { + RpWidget::keyPressEvent(e); + return; + } + e->accept(); +} + void StickersListFooter::scrollByWheelEvent( not_null e) { auto horizontal = (e->angleDelta().x() != 0); diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h index 2da22cb57c669..661ca2d27a740 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h @@ -158,12 +158,31 @@ class StickersListFooter final : public TabbedSelector::InnerFooter { [[nodiscard]] static int IconFrameSize(); + // The icons as the items of a list for a screen reader - a category + // or a set each, the "all categories" icon as one per category, the + // settings button first where there is one - walked with the + // keyboard: Left and Right, Home and End, Enter or Space chooses. + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + int accessibilityChildCount() const override; + QAccessible::Role accessibilityChildRole() const override; + QString accessibilityChildName(int index) const override; + QRect accessibilityChildRect(int index) const override; + QAccessible::State accessibilityChildState(int index) 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; + protected: void paintEvent(QPaintEvent *e) override; void resizeEvent(QResizeEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; + void focusInEvent(QFocusEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; bool eventHook(QEvent *e) override; void processHideFinished() override; @@ -268,6 +287,20 @@ class StickersListFooter final : public TabbedSelector::InnerFooter { QPainter &p, const ExpandingContext &context) const; + // The list a screen reader sees: an item is the settings button, an + // icon, or one category of the "all categories" icon. + struct AccessibleChild { + bool settings = false; + IconId icon; + }; + [[nodiscard]] std::optional accessibleChild( + int index) const; + [[nodiscard]] int accessibleIndex(const OverState &over) const; + [[nodiscard]] int accessibleActiveIndex() const; + [[nodiscard]] QString iconTitle(const StickerIcon &icon) const; + void keyboardSelect(int index, bool announce); + void activateChild(const AccessibleChild &child); + void updateEmojiSectionWidth(); void updateEmojiWidthCallback(); @@ -314,6 +347,8 @@ class StickersListFooter final : public TabbedSelector::InnerFooter { Ui::Animations::Simple _subiconsWidthAnimation; int _subiconsWidth = 0; bool _subiconsExpanded = false; + // The item the keyboard is on, an index into the list above. + int _keyboardSelected = -1; bool _repaintScheduled = false; bool _forceFirstFrame = false; diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp index c0a702394c768..ac3c15ab23fc4 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp @@ -7,6 +7,7 @@ For license and copyright information please follow this link: */ #include "chat_helpers/stickers_list_widget.h" +#include "base/invoke_queued.h" #include "base/options.h" #include "base/timer_rpl.h" #include "core/application.h" @@ -22,6 +23,7 @@ For license and copyright information please follow this link: #include "chat_helpers/stickers_lottie.h" #include "chat_helpers/stickers_list_footer.h" #include "ui/controls/tabbed_search.h" +#include "ui/screen_reader_mode.h" #include "ui/toast/toast.h" #include "ui/widgets/buttons.h" #include "ui/widgets/popup_menu.h" @@ -251,6 +253,7 @@ StickersListWidget::StickersListWidget( &session(), st::stickersPremiumLock)) , _searchRequestTimer([=] { sendSearchRequest(); }) { + setAccessibleName(tr::lng_switch_stickers(tr::now)); setMouseTracking(true); if (st().bg->c.alpha() > 0) { setAttribute(Qt::WA_OpaquePaintEvent); @@ -345,7 +348,20 @@ object_ptr StickersListWidget::createFooter() { _footer->setChosen( ) | rpl::on_next([=](uint64 setId) { + const auto keyboard = _footer->hasFocus(); showStickerSet(setId); + if (keyboard) { + // Chosen from the keyboard: on to the first sticker of the + // set, as the mouse would go on to click one. + const auto &sets = shownSets(); + for (auto i = 0, count = int(sets.size()); i != count; ++i) { + if (sets[i].id == setId && !sets[i].stickers.empty()) { + keyboardSelect({ .section = i, .index = 0 }, false); + setFocus(); + break; + } + } + } }, _footer->lifetime()); _footer->openSettingsRequests( @@ -752,8 +768,32 @@ void StickersListWidget::cancelSetsSearch() { } void StickersListWidget::showSearchResults() { + // The keyboard keeps its place through a refill of the results, and + // a group chosen from the keyboard lands on the first of them. + const auto fromGroups = _search && _search->groupsHaveFocus(); + const auto keyboard = hasFocus(); + const auto selected = std::get_if(&_selected); + const auto wasIndex = (keyboard && selected) + ? accessibleIndex(*selected) + : -1; + refreshSearchRows(); scrollTo(0); + + const auto count = accessibilityChildCount(); + if (!count) { + return; + } else if (fromGroups) { + if (const auto first = accessibleChild(0)) { + keyboardSelect(*first, false); + setFocus(); + } + } else if (keyboard) { + const auto index = std::clamp(std::max(wasIndex, 0), 0, count - 1); + if (const auto over = accessibleChild(index)) { + keyboardSelect(*over, true); + } + } } void StickersListWidget::refreshSearchRows() { @@ -1342,7 +1382,7 @@ int StickersListWidget::stickersLeft() const { return _rowsLeft; } -QRect StickersListWidget::stickerRect(int section, int sel) { +QRect StickersListWidget::stickerRect(int section, int sel) const { const auto info = sectionInfo(section); if (sel >= shownSets()[section].stickers.size()) { sel -= shownSets()[section].stickers.size(); @@ -2933,6 +2973,7 @@ void StickersListWidget::mouseMoveEvent(QMouseEvent *e) { return; } } + _keyboardSelection = false; updateSelected(); } @@ -2946,11 +2987,16 @@ void StickersListWidget::resizeEvent(QResizeEvent *e) { } void StickersListWidget::leaveEventHook(QEvent *e) { - clearSelection(); + // A selection made from the keyboard stays where the mouse is not. + if (!_keyboardSelection) { + clearSelection(); + } } void StickersListWidget::leaveToChildEvent(QEvent *e, QWidget *child) { - clearSelection(); + if (!_keyboardSelection) { + clearSelection(); + } } void StickersListWidget::enterFromChildEvent(QEvent *e, QWidget *child) { @@ -2961,9 +3007,365 @@ void StickersListWidget::enterFromChildEvent(QEvent *e, QWidget *child) { void StickersListWidget::clearSelection() { setPressed(v::null); setSelected(v::null); + _keyboardSelection = false; repaintItems(); } +rpl::producer<> StickersListWidget::hideRequests() const { + return _hideRequests.events(); +} + +std::optional StickersListWidget::accessibleChild( + int index) const { + if (index < 0 || _columnCount <= 0) { + return std::nullopt; + } + auto result = std::optional(); + enumerateSections([&](const SectionInfo &info) { + if (index < info.count) { + result = OverSticker{ .section = info.section, .index = index }; + return false; + } + index -= info.count; + return true; + }); + return result; +} + +int StickersListWidget::accessibleIndex(const OverSticker &over) const { + if (_columnCount <= 0) { + return -1; + } + auto result = -1; + auto before = 0; + enumerateSections([&](const SectionInfo &info) { + if (info.section == over.section) { + if (over.index >= 0 && over.index < info.count) { + result = before + over.index; + } + return false; + } + before += info.count; + return true; + }); + return result; +} + +QAccessible::Role StickersListWidget::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy StickersListWidget::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +int StickersListWidget::accessibilityChildCount() const { + if (_columnCount <= 0) { + return 0; + } + auto result = 0; + enumerateSections([&](const SectionInfo &info) { + result += info.count; + return true; + }); + return result; +} + +QAccessible::Role StickersListWidget::accessibilityChildRole() const { + return QAccessible::ListItem; +} + +QString StickersListWidget::accessibilityChildName(int index) const { + // The emoji the sticker stands for: the screen reader names it in + // its own words. + const auto over = accessibleChild(index); + if (!over) { + return QString(); + } + const auto &sets = shownSets(); + const auto document = sets[over->section].stickers[over->index].document; + const auto sticker = document->sticker(); + return sticker ? sticker->alt : QString(); +} + +QString StickersListWidget::accessibilityChildDescription(int index) const { + // The set the sticker is in, heard on landing in it. + const auto over = accessibleChild(index); + return over ? shownSets()[over->section].title : QString(); +} + +QRect StickersListWidget::accessibilityChildRect(int index) const { + const auto over = accessibleChild(index); + return over + ? myrtlrect(stickerRect(over->section, over->index)) + : QRect(); +} + +QAccessible::State StickersListWidget::accessibilityChildState( + int index) const { + auto state = QAccessible::State(); + if (Ui::ScreenReaderModeActive()) { + state.focusable = true; + state.selectable = true; + } + const auto over = accessibleChild(index); + const auto selected = std::get_if(&_selected); + if (over + && selected + && selected->section == over->section + && selected->index == over->index) { + state.active = true; + // The item the keyboard is on is the selection of the list - or a + // screen reader reports every item as "not selected". + state.selected = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; +} + +bool StickersListWidget::accessibilityChildSupportsActions(int index) const { + return accessibilityChildIdentity(index) != 0; +} + +quintptr StickersListWidget::accessibilityChildIdentity(int index) const { + // The cell itself names the item: its section and its place in it. + // The tag bit keeps the token non-zero. + const auto over = accessibleChild(index); + return over + ? ((quintptr(over->section + 1) << 24) + | (quintptr(over->index + 1) << 1) + | quintptr(1)) + : quintptr(0); +} + +int StickersListWidget::accessibilityChildIndexByIdentity( + quintptr identity) const { + if (!identity) { + return -1; + } + return accessibleIndex(OverSticker{ + .section = int(identity >> 24) - 1, + .index = int((identity >> 1) & 0x7FFFFF) - 1, + }); +} + +void StickersListWidget::accessibilityChildSetFocus(quintptr identity) { + // UIA invokes the action on a background thread: resolve and touch + // the widget on the main one, like the other painted lists do. + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + const auto over = accessibleChild(index); + if (!over) { + return; + } + keyboardSelect(*over, hasFocus()); + if (!hasFocus()) { + _focusReturn = window()->focusWidget(); + setFocus(); + } + }); +} + +void StickersListWidget::accessibilityChildActivate(quintptr identity) { + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + const auto over = accessibleChild(index); + if (!over) { + return; + } + keyboardSelect(*over, false); + activateKeyboardSelected(); + }); +} + +void StickersListWidget::keyboardSelect( + const OverSticker &over, + bool announce) { + _keyboardSelection = true; + setSelected(over); + ensureCellVisible(over); + if (announce) { + const auto index = accessibleIndex(over); + if (index >= 0) { + accessibilityChildFocused(index); + } + } +} + +void StickersListWidget::ensureCellVisible(const OverSticker &over) { + const auto rect = stickerRect(over.section, over.index); + const auto top = getVisibleTop(); + const auto bottom = getVisibleBottom(); + if (bottom <= top) { + return; + } else if (rect.y() < top) { + // Show the header of the set the cell opens. + const auto info = sectionInfo(over.section); + scrollTo((rect.y() < info.rowsTop + _singleSize.height()) + ? info.top + : rect.y()); + } else if (rect.y() + rect.height() > bottom) { + scrollTo(rect.y() + rect.height() - (bottom - top)); + } +} + +void StickersListWidget::keyboardMoveBy(int delta) { + const auto count = accessibilityChildCount(); + if (!count) { + return; + } + const auto selected = std::get_if(&_selected); + const auto current = selected ? accessibleIndex(*selected) : -1; + const auto index = (current < 0) + ? ((delta > 0) ? 0 : count - 1) + : std::clamp(current + delta, 0, count - 1); + if (const auto over = accessibleChild(index)) { + keyboardSelect(*over, true); + } +} + +std::optional StickersListWidget::neighborRow( + const OverSticker &over, + int step) const { + // The same column one row up or down, on into the next set that has + // anything to show - its first or its last row. + const auto info = sectionInfo(over.section); + const auto column = over.index % _columnCount; + const auto row = over.index / _columnCount; + const auto rows = (info.count + _columnCount - 1) / _columnCount; + if (row + step >= 0 && row + step < rows) { + const auto index = std::min( + (row + step) * _columnCount + column, + info.count - 1); + return OverSticker{ .section = over.section, .index = index }; + } + const auto sections = int(shownSets().size()); + auto section = over.section + step; + while (section >= 0 && section < sections) { + const auto count = sectionInfo(section).count; + if (count > 0) { + const auto lastRow = (count + _columnCount - 1) / _columnCount - 1; + const auto index = std::min( + ((step > 0) ? 0 : lastRow) * _columnCount + column, + count - 1); + return OverSticker{ .section = section, .index = index }; + } + section += step; + } + return std::nullopt; +} + +void StickersListWidget::keyboardMoveRows(int rows) { + const auto selected = std::get_if(&_selected); + if (!selected || accessibleIndex(*selected) < 0) { + keyboardMoveBy(rows > 0 ? 1 : -1); + return; + } + auto over = *selected; + const auto step = (rows > 0) ? 1 : -1; + for (auto i = 0; i != std::abs(rows); ++i) { + const auto next = neighborRow(over, step); + if (!next) { + break; + } + over = *next; + } + keyboardSelect(over, true); +} + +void StickersListWidget::returnFocus() { + const auto was = base::take(_focusReturn); + if (was && Ui::InFocusChain(this)) { + was->setFocus(); + } else if (_search && Ui::InFocusChain(this)) { + // The search took the focus when the panel came up, and gives it + // back from wherever in the panel it is now. + _search->returnFocus(true); + } +} + +void StickersListWidget::activateKeyboardSelected() { + const auto selected = std::get_if(&_selected); + if (!selected || accessibleIndex(*selected) < 0) { + return; + } + const auto over = *selected; + const auto &sets = shownSets(); + const auto document = sets[over.section].stickers[over.index].document; + // The focus goes back before the choice is made, and the panel is + // done: hide it, as Escape would. + returnFocus(); + _chosen.fire({ + .document = document, + .messageSendingFrom = messageSentAnimationInfo( + over.section, + over.index, + document), + }); + _hideRequests.fire({}); +} + +void StickersListWidget::focusInEvent(QFocusEvent *e) { + RpWidget::focusInEvent(e); + // Land on the sticker last walked to, or the first one there is. + const auto selected = std::get_if(&_selected); + auto over = (selected && accessibleIndex(*selected) >= 0) + ? std::optional(*selected) + : accessibleChild(0); + if (!over) { + return; + } + keyboardSelect(*over, false); + const auto index = accessibleIndex(*over); + InvokeQueued(this, [=] { + const auto now = std::get_if(&_selected); + if (hasFocus() && now && accessibleIndex(*now) == index) { + accessibilityChildFocused(index); + } + }); +} + +void StickersListWidget::focusOutEvent(QFocusEvent *e) { + RpWidget::focusOutEvent(e); + _keyboardSelection = false; +} + +void StickersListWidget::keyPressEvent(QKeyEvent *e) { + const auto key = e->key(); + const auto rowHeight = std::max(_singleSize.height(), 1); + const auto rowsOnPage = std::max( + (getVisibleBottom() - getVisibleTop()) / rowHeight, + 1); + if (key == Qt::Key_Left || key == Qt::Key_Right) { + const auto forward = (key == Qt::Key_Right) != rtl(); + keyboardMoveBy(forward ? 1 : -1); + } else if (key == Qt::Key_Up || key == Qt::Key_Down) { + keyboardMoveRows((key == Qt::Key_Down) ? 1 : -1); + } else if (key == Qt::Key_PageUp || key == Qt::Key_PageDown) { + keyboardMoveRows((key == Qt::Key_PageDown) + ? rowsOnPage + : -rowsOnPage); + } else if (key == Qt::Key_Home) { + keyboardMoveBy(-accessibilityChildCount()); + } else if (key == Qt::Key_End) { + keyboardMoveBy(accessibilityChildCount()); + } else if (!e->isAutoRepeat() + && (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter)) { + activateKeyboardSelected(); + } else if (key == Qt::Key_Escape) { + returnFocus(); + _hideRequests.fire({}); + } else { + RpWidget::keyPressEvent(e); + return; + } + e->accept(); +} + TabbedSelector::InnerFooter *StickersListWidget::getFooter() const { return _footer; } @@ -3807,7 +4209,7 @@ void StickersListWidget::showMegagroupSet(ChannelData *megagroup) { } void StickersListWidget::afterShown() { - if (_search) { + if (_search && !keepsFocusOnShow()) { _search->stealFocus(); } } @@ -3828,6 +4230,15 @@ void StickersListWidget::setupSearch() { _search = MakeSearch(this, st(), [=](std::vector &&query) { applySearchQuery(std::move(query)); }, session, type); + + // Enter or Down in the field: on to the first of the results. + _search->submits( + ) | rpl::on_next([=] { + if (const auto first = accessibleChild(0)) { + keyboardSelect(*first, false); + setFocus(); + } + }, lifetime()); } void StickersListWidget::applySearchQuery(std::vector &&query) { diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h index 562c3945a81da..009aa14f007ac 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h @@ -14,6 +14,7 @@ For license and copyright information please follow this link: #include "ui/round_rect.h" #include "base/variant.h" #include "base/timer.h" +#include "base/weak_qptr.h" class StickerPremiumMark; @@ -140,6 +141,26 @@ class StickersListWidget final : public TabbedSelector::Inner { void applySearchQuery(std::vector &&query); [[nodiscard]] rpl::producer recentShownCount() const; + // Fired when the keyboard is done with the list - a sticker chosen or + // Escape pressed - so the panel it is in can hide. + [[nodiscard]] rpl::producer<> hideRequests() const; + + // The stickers as the items of a list for a screen reader, walked + // with the keyboard the same way as the emoji list. + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + int accessibilityChildCount() const override; + QAccessible::Role accessibilityChildRole() const override; + QString accessibilityChildName(int index) const override; + QString accessibilityChildDescription(int index) const override; + QRect accessibilityChildRect(int index) const override; + QAccessible::State accessibilityChildState(int index) 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; + ~StickersListWidget(); protected: @@ -147,6 +168,9 @@ class StickersListWidget final : public TabbedSelector::Inner { int visibleTop, int visibleBottom) override; + void focusInEvent(QFocusEvent *e) override; + void focusOutEvent(QFocusEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; @@ -296,6 +320,19 @@ class StickersListWidget final : public TabbedSelector::Inner { void updateSelected(); void setSelected(OverState newSelected); + + // The list a screen reader sees: every sticker, set by set. + [[nodiscard]] std::optional accessibleChild(int index) const; + [[nodiscard]] int accessibleIndex(const OverSticker &over) const; + void keyboardSelect(const OverSticker &over, bool announce); + void keyboardMoveBy(int delta); + void keyboardMoveRows(int rows); + [[nodiscard]] std::optional neighborRow( + const OverSticker &over, + int step) const; + void activateKeyboardSelected(); + void ensureCellVisible(const OverSticker &over); + void returnFocus(); void setPressed(OverState newPressed); [[nodiscard]] std::unique_ptr createButtonRipple( int section); @@ -376,7 +413,7 @@ class StickersListWidget final : public TabbedSelector::Inner { AppendSkip skip = AppendSkip::None); int stickersLeft() const; - QRect stickerRect(int section, int sel); + QRect stickerRect(int section, int sel) const; void removeRecentSticker(int section, int index); void removeFavedSticker(int section, int index); @@ -481,6 +518,12 @@ class StickersListWidget final : public TabbedSelector::Inner { OverState _selected; OverState _pressed; + // The selection was made from the keyboard: the mouse leaving the + // list does not clear it, and the widget that had the focus before + // the list took it gets it back when the list is done. + bool _keyboardSelection = false; + base::weak_qptr _focusReturn; + rpl::event_stream<> _hideRequests; QPoint _lastMousePosition; Ui::RoundRect _trendingAddBgOver, _trendingAddBg, _inactiveButtonBg; diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp index e00549b3a8f3a..46e51e4aa580d 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp @@ -13,6 +13,7 @@ For license and copyright information please follow this link: #include "menu/menu_send.h" #include "ui/controls/swipe_handler.h" #include "ui/controls/tabbed_search.h" +#include "ui/screen_reader_mode.h" #include "ui/text/text_utilities.h" #include "ui/widgets/buttons.h" #include "ui/widgets/labels.h" @@ -754,7 +755,23 @@ auto TabbedSelector::choosingStickerUpdated() const } rpl::producer<> TabbedSelector::cancelled() const { - return hasGifsTab() ? gifs()->cancelRequests() : nullptr; + // The emoji list asks to hide as well, once the keyboard is done with + // it - an emoji chosen or Escape pressed. + auto result = rpl::producer<>(); + if (hasGifsTab()) { + result = gifs()->cancelRequests(); + } + if (hasEmojiTab()) { + result = result + ? rpl::merge(std::move(result), emoji()->hideRequests()) + : emoji()->hideRequests(); + } + if (hasStickersTab()) { + result = result + ? rpl::merge(std::move(result), stickers()->hideRequests()) + : stickers()->hideRequests(); + } + return result; } rpl::producer<> TabbedSelector::checkForHide() const { @@ -1014,7 +1031,11 @@ QImage TabbedSelector::grabForAnimation() { if (_topShadow) { _topShadow->hide(); } - if (_tabsSlider) { + // The slide uses the part below the tab strip only, so the strip is + // hidden for the picture merely to be safe - and a strip holding the + // focus stays: hidden even for a moment, it would lose the focus + // wherever the focus chain leads. + if (_tabsSlider && !_tabsSlider->hasFocus()) { _tabsSlider->hide(); } Ui::SendPendingMoveResizeEvents(this); @@ -1075,7 +1096,10 @@ void TabbedSelector::beforeHiding() { _beforeHidingCallback(_currentTabType); } } - if (Ui::InFocusChain(this)) { + // Also called for a tab switch: the keyboard on the tab strip stays + // there, the strip is not going anywhere. + const auto onTabs = _tabsSlider && _tabsSlider->hasFocus(); + if (Ui::InFocusChain(this) && !onTabs) { window()->setFocus(); } } @@ -1083,7 +1107,15 @@ void TabbedSelector::beforeHiding() { void TabbedSelector::afterShown() { if (!_a_slide.animating()) { showAll(); - currentTab()->widget()->afterShown(); + // Switched to from the tab strip with a screen reader: the + // keyboard stays there, to go on by Tab when it wants. + const auto widget = currentTab()->widget(); + const auto keeps = Ui::ScreenReaderModeActive() + && _tabsSlider + && _tabsSlider->hasFocus(); + widget->setKeepsFocusOnShow(keeps); + widget->afterShown(); + widget->setKeepsFocusOnShow(false); if (_afterShownCallback) { _afterShownCallback(_currentTabType); } @@ -1211,7 +1243,20 @@ void TabbedSelector::showAll() { } void TabbedSelector::hideForSliding() { - hideChildren(); + // Everything but the tab strip and the shadow, which stay: hiding + // the strip even for a moment would send the focus on it wherever + // the focus chain leads, and it must still be there after the slide. + for (const auto child : children()) { + if (!child->isWidgetType()) { + continue; + } + const auto widget = static_cast(child); + if (!widget->isWindow() + && widget != _topShadow.data() + && widget != _tabsSlider.data()) { + widget->hide(); + } + } if (_topShadow) { _topShadow->show(); } @@ -1507,6 +1552,14 @@ rpl::producer TabbedSelector::Inner::scrollToRequests() const { return _scrollToRequests.events(); } +void TabbedSelector::Inner::setKeepsFocusOnShow(bool keeps) { + _keepsFocusOnShow = keeps; +} + +bool TabbedSelector::Inner::keepsFocusOnShow() const { + return _keepsFocusOnShow; +} + rpl::producer TabbedSelector::Inner::disableScrollRequests() const { return _disableScrollRequests.events(); } diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.h b/Telegram/SourceFiles/chat_helpers/tabbed_selector.h index 52bbe4e87a972..46b3e818e8b19 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.h +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.h @@ -412,6 +412,12 @@ class TabbedSelector::Inner : public Ui::RpWidget { virtual object_ptr createFooter() = 0; + // Set around afterShown() for a tab switched to from the keyboard, + // with a screen reader: the keyboard stays on the tab strip, so the + // tab shown must not take the focus into its search. + void setKeepsFocusOnShow(bool keeps); + [[nodiscard]] bool keepsFocusOnShow() const; + protected: void visibleTopBottomUpdated( int visibleTop, @@ -448,6 +454,7 @@ class TabbedSelector::Inner : public Ui::RpWidget { int _visibleTop = 0; int _visibleBottom = 0; std::optional _minimalHeight; + bool _keepsFocusOnShow = false; rpl::event_stream _scrollToRequests; rpl::event_stream _disableScrollRequests; diff --git a/Telegram/SourceFiles/data/data_emoji_statuses.cpp b/Telegram/SourceFiles/data/data_emoji_statuses.cpp index 1665dcb0dcf8d..8945af9e09553 100644 --- a/Telegram/SourceFiles/data/data_emoji_statuses.cpp +++ b/Telegram/SourceFiles/data/data_emoji_statuses.cpp @@ -238,6 +238,7 @@ void EmojiStatuses::requestProfilePhotoGroups() { group.match([&](const MTPDemojiGroupPremium &data) { result.push_back({ .iconId = QString::number(data.vicon_emoji_id().v), + .title = qs(data.vtitle()), .type = Ui::EmojiGroupType::Premium, }); }, [&](const auto &data) { @@ -248,6 +249,7 @@ void EmojiStatuses::requestProfilePhotoGroups() { }) | ranges::to_vector; result.push_back({ .iconId = QString::number(data.vicon_emoji_id().v), + .title = qs(data.vtitle()), .emoticons = std::move(emoticons), .type = (MTPDemojiGroupGreeting::Is() ? Ui::EmojiGroupType::Greeting diff --git a/Telegram/SourceFiles/ui/controls/emoji_button_factory.cpp b/Telegram/SourceFiles/ui/controls/emoji_button_factory.cpp index 857cfefedbfb1..5ab70d9b03224 100644 --- a/Telegram/SourceFiles/ui/controls/emoji_button_factory.cpp +++ b/Telegram/SourceFiles/ui/controls/emoji_button_factory.cpp @@ -107,6 +107,9 @@ namespace Ui { emojiToggle->installEventFilter(emojiPanel); emojiToggle->addClickHandler([=] { + // The field the chosen emoji goes to is told apart by the focus, + // which a toggle pressed from the keyboard holds itself. + field->setFocus(); updateEmojiPanelGeometry(); emojiPanel->toggleAnimated(); }); diff --git a/Telegram/SourceFiles/ui/controls/tabbed_search.cpp b/Telegram/SourceFiles/ui/controls/tabbed_search.cpp index a0d45b484ad8d..7d564742a08e0 100644 --- a/Telegram/SourceFiles/ui/controls/tabbed_search.cpp +++ b/Telegram/SourceFiles/ui/controls/tabbed_search.cpp @@ -7,6 +7,8 @@ For license and copyright information please follow this link: */ #include "ui/controls/tabbed_search.h" +#include "base/event_filter.h" +#include "base/invoke_queued.h" #include "base/qt_signal_producer.h" #include "lang/lang_keys.h" #include "ui/widgets/fields/input_field.h" @@ -14,6 +16,7 @@ For license and copyright information please follow this link: #include "ui/widgets/buttons.h" #include "ui/painter.h" #include "ui/rect.h" +#include "ui/screen_reader_mode.h" #include "ui/text/text_custom_emoji.h" #include "ui/ui_utility.h" #include "styles/style_chat_helpers.h" @@ -46,6 +49,21 @@ class GroupsStrip final : public RpWidget { [[nodiscard]] rpl::producer moveRequests() const; + // The groups as the items of a list for a screen reader, walked with + // the keyboard: Left and Right, Home and End, Enter or Space chooses. + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + int accessibilityChildCount() const override; + QAccessible::Role accessibilityChildRole() const override; + QString accessibilityChildName(int index) const override; + QRect accessibilityChildRect(int index) const override; + QAccessible::State accessibilityChildState(int index) 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; + private: struct Button { EmojiGroup group; @@ -60,8 +78,12 @@ class GroupsStrip final : public RpWidget { void mouseMoveEvent(QMouseEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; + void focusInEvent(QFocusEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; void fireChosenGroup(); + void keyboardSelect(int index, bool announce); + void chooseFromKeyboard(int index); static inline auto FindById(auto &&buttons, QStringView id) { return ranges::find(buttons, id, &Button::iconId); @@ -77,6 +99,7 @@ class GroupsStrip final : public RpWidget { bool _dragging = false; int _pressed = -1; int _chosen = -1; + int _keyboardSelected = -1; }; @@ -95,6 +118,7 @@ GroupsStrip::GroupsStrip( : RpWidget(parent) , _st(st) , _factory(std::move(factory)) { + setAccessibleName(tr::lng_emoji_search_groups(tr::now)); init(std::move(groups)); } @@ -273,6 +297,157 @@ void GroupsStrip::fireChosenGroup() { }); } +QAccessible::Role GroupsStrip::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy GroupsStrip::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +int GroupsStrip::accessibilityChildCount() const { + return int(_buttons.size()); +} + +QAccessible::Role GroupsStrip::accessibilityChildRole() const { + return QAccessible::ListItem; +} + +QString GroupsStrip::accessibilityChildName(int index) const { + return (index >= 0 && index < _buttons.size()) + ? _buttons[index].group.title + : QString(); +} + +QRect GroupsStrip::accessibilityChildRect(int index) const { + return (index >= 0 && index < _buttons.size()) + ? QRect(index * _st.groupWidth, 0, _st.groupWidth, height()) + : QRect(); +} + +QAccessible::State GroupsStrip::accessibilityChildState(int index) const { + auto state = QAccessible::State(); + if (ScreenReaderModeActive()) { + state.focusable = true; + state.selectable = true; + } + if (index == _chosen) { + state.selected = true; + } + if (index == _keyboardSelected) { + state.active = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; +} + +bool GroupsStrip::accessibilityChildSupportsActions(int index) const { + return accessibilityChildIdentity(index) != 0; +} + +quintptr GroupsStrip::accessibilityChildIdentity(int index) const { + // The place in the strip names the group; the tag bit keeps it + // non-zero. + return (index >= 0 && index < _buttons.size()) + ? ((quintptr(index) << 1) | quintptr(1)) + : quintptr(0); +} + +int GroupsStrip::accessibilityChildIndexByIdentity(quintptr identity) const { + const auto index = int(identity >> 1); + return (identity && index < _buttons.size()) ? index : -1; +} + +void GroupsStrip::accessibilityChildSetFocus(quintptr identity) { + crl::on_main(this, [=] { + const auto index = accessibilityChildIndexByIdentity(identity); + if (index < 0) { + return; + } + keyboardSelect(index, hasFocus()); + if (!hasFocus()) { + setFocus(); + } + }); +} + +void GroupsStrip::accessibilityChildActivate(quintptr identity) { + crl::on_main(this, [=] { + chooseFromKeyboard(accessibilityChildIndexByIdentity(identity)); + }); +} + +void GroupsStrip::keyboardSelect(int index, bool announce) { + if (index < 0 || index >= _buttons.size()) { + return; + } + _keyboardSelected = index; + if (announce) { + accessibilityChildFocused(index); + } +} + +void GroupsStrip::chooseFromKeyboard(int index) { + if (index < 0 || index >= _buttons.size()) { + return; + } + // As a click does. + keyboardSelect(index, false); + _chosen = index; + fireChosenGroup(); + update(); +} + +void GroupsStrip::focusInEvent(QFocusEvent *e) { + RpWidget::focusInEvent(e); + if (_buttons.empty()) { + return; + } + // Land on the group chosen, or the one last walked to. + const auto index = (_chosen >= 0) + ? _chosen + : (_keyboardSelected >= 0 && _keyboardSelected < _buttons.size()) + ? _keyboardSelected + : 0; + keyboardSelect(index, false); + InvokeQueued(this, [=] { + if (hasFocus() && _keyboardSelected == index) { + accessibilityChildFocused(index); + } + }); +} + +void GroupsStrip::keyPressEvent(QKeyEvent *e) { + const auto key = e->key(); + const auto count = int(_buttons.size()); + if (!count) { + RpWidget::keyPressEvent(e); + return; + } + const auto current = std::clamp(_keyboardSelected, 0, count - 1); + if (key == Qt::Key_Left || key == Qt::Key_Right) { + const auto forward = (key == Qt::Key_Right) != style::RightToLeft(); + keyboardSelect( + std::clamp(current + (forward ? 1 : -1), 0, count - 1), + true); + } else if (key == Qt::Key_Home) { + keyboardSelect(0, true); + } else if (key == Qt::Key_End) { + keyboardSelect(count - 1, true); + } else if (!e->isAutoRepeat() + && (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter)) { + chooseFromKeyboard(current); + } else { + RpWidget::keyPressEvent(e); + return; + } + e->accept(); +} + } // namespace const QString &PremiumGroupFakeEmoticon() { @@ -314,6 +489,18 @@ anim::type SearchWithGroups::animated() const { } void SearchWithGroups::initField() { + // Down in the field goes on to the results, as in a search box with + // suggestions; Enter does as well, through submits(). The field + // leaves the arrows to us, as the search of the chat list does. + _field->customUpDown(true); + base::install_event_filter(_field, [=](not_null e) { + if (e->type() == QEvent::KeyPress + && static_cast(e.get())->key() == Qt::Key_Down) { + _downs.fire({}); + return base::EventFilterResult::Cancel; + } + return base::EventFilterResult::Continue; + }); _field->changes( ) | rpl::on_next([=] { const auto last = FieldQuery(_field); @@ -500,6 +687,10 @@ void SearchWithGroups::initButtons() { _field->setFocus(); scrollGroupsToStart(); }); + // Named for a screen reader, which walks them with Tab. + _search->entity()->setAccessibleName(tr::lng_dlg_filter(tr::now)); + _back->entity()->setAccessibleName(tr::lng_create_group_back(tr::now)); + _cancel->setAccessibleName(tr::lng_call_box_clear_button(tr::now)); _field->focusedChanges( ) | rpl::filter(rpl::mappers::_1) | rpl::on_next([=] { scrollGroupsToStart(); @@ -527,6 +718,12 @@ void SearchWithGroups::ensureRounding(int size, float64 ratio) { _rounding.setDevicePixelRatio(ratio); } +rpl::producer<> SearchWithGroups::submits() const { + return rpl::merge( + _field->submits() | rpl::to_empty, + _downs.events()); +} + rpl::producer<> SearchWithGroups::escapes() const { return _field->cancelled(); } @@ -557,9 +754,15 @@ void SearchWithGroups::stealFocus() { _field->setFocus(); } -void SearchWithGroups::returnFocus() { +bool SearchWithGroups::groupsHaveFocus() const { + return _groups->entity()->hasFocus(); +} + +void SearchWithGroups::returnFocus(bool force) { if (_field && _focusTakenFrom) { - if (_field->hasFocus()) { + // Forced: the focus went on from the field into the panel, and + // the panel is done with it. + if (force || _field->hasFocus()) { _focusTakenFrom->setFocus(); } _focusTakenFrom = nullptr; @@ -680,8 +883,12 @@ void TabbedSearch::stealFocus() { _search.stealFocus(); } -void TabbedSearch::returnFocus() { - _search.returnFocus(); +bool TabbedSearch::groupsHaveFocus() const { + return _search.groupsHaveFocus(); +} + +void TabbedSearch::returnFocus(bool force) { + _search.returnFocus(force); } void TabbedSearch::setRightReserved(int value) { @@ -692,6 +899,10 @@ void TabbedSearch::setRightReserved(int value) { updateSearchGeometry(); } +rpl::producer<> TabbedSearch::submits() const { + return _search.submits(); +} + rpl::producer<> TabbedSearch::escapes() const { return _search.escapes(); } diff --git a/Telegram/SourceFiles/ui/controls/tabbed_search.h b/Telegram/SourceFiles/ui/controls/tabbed_search.h index 2a1843dfcc315..affcd39b8f141 100644 --- a/Telegram/SourceFiles/ui/controls/tabbed_search.h +++ b/Telegram/SourceFiles/ui/controls/tabbed_search.h @@ -39,6 +39,7 @@ enum class EmojiGroupType { struct EmojiGroup { QString iconId; + QString title; // What a screen reader calls the group. std::vector emoticons; EmojiGroupType type = EmojiGroupType::Normal; @@ -60,6 +61,8 @@ class SearchWithGroups final : public RpWidget { SearchWithGroups(QWidget *parent, SearchDescriptor descriptor); [[nodiscard]] rpl::producer<> escapes() const; + // Enter or Down in the field: the results are for the keyboard now. + [[nodiscard]] rpl::producer<> submits() const; [[nodiscard]] rpl::producer> queryValue() const; [[nodiscard]] auto debouncedQueryValue() const -> rpl::producer>; @@ -67,7 +70,8 @@ class SearchWithGroups final : public RpWidget { void cancel(); void setLoading(bool loading); void stealFocus(); - void returnFocus(); + [[nodiscard]] bool groupsHaveFocus() const; + void returnFocus(bool force = false); [[nodiscard]] static int IconSizeOverride(); @@ -114,6 +118,7 @@ class SearchWithGroups final : public RpWidget { rpl::variable _chosenGroup; base::Timer _debounceTimer; bool _inited = false; + rpl::event_stream<> _downs; }; @@ -128,6 +133,7 @@ class TabbedSearch final { [[nodiscard]] QImage grab(); [[nodiscard]] rpl::producer<> escapes() const; + [[nodiscard]] rpl::producer<> submits() const; [[nodiscard]] rpl::producer> queryValue() const; [[nodiscard]] auto debouncedQueryValue() const ->rpl::producer>; @@ -135,7 +141,8 @@ class TabbedSearch final { void cancel(); void setLoading(bool loading); void stealFocus(); - void returnFocus(); + [[nodiscard]] bool groupsHaveFocus() const; + void returnFocus(bool force = false); void setRightReserved(int value); private: