From 2818a1d4e3ca4accc08f916014d1128bf1405892 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Thu, 3 Sep 2026 18:59:44 +0330 Subject: [PATCH 1/3] Walk the emoji panel with the keyboard and a screen reader The emoji panel is a painted grid with no keyboard handling and nothing for a screen reader to read: opened from the keyboard, it appeared with no one to talk to, and pressing the "Emoji" button of a field seemed to do nothing. Expose every cell shown as an item of a list - named by the emoji itself, which the screen reader speaks in its own words, described by the section it is in (the category, or the title of a custom set); the last cell of a collapsed set is the "+N" that opens it. In screen reader mode the list takes the focus once the panel is shown and remembers where it came from; the arrows walk the cells (Up and Down by column, on into the next section), Home, End and the page keys too. Enter or Space chooses the emoji, Escape gives up - both return the focus to where it was and ask the panel to hide, which TabbedSelector::cancelled() passes on. A selection made from the keyboard survives the mouse leaving the list. SetFocus and Invoke from the screen reader work the same way. The "Emoji" toggle of a field gives the focus to its field before opening the panel: the fields of a box tell the target of a chosen emoji apart by the focus, which a toggle pressed from the keyboard held itself. With the mouse the field has it already. --- .../chat_helpers/emoji_list_widget.cpp | 411 +++++++++++++++++- .../chat_helpers/emoji_list_widget.h | 50 +++ .../chat_helpers/tabbed_selector.cpp | 13 +- .../ui/controls/emoji_button_factory.cpp | 3 + 4 files changed, 474 insertions(+), 3 deletions(-) diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp index 7185af0aedee2a..15236c89b1833b 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" @@ -1696,6 +1697,12 @@ void EmojiListWidget::afterShown() { || (_mode == Mode::UserpicBuilder); 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 +1710,14 @@ void EmojiListWidget::beforeHiding() { if (_search) { _search->returnFocus(); } + returnFocus(); +} + +void EmojiListWidget::returnFocus() { + const auto was = base::take(_focusReturn); + if (was && hasFocus()) { + was->setFocus(); + } } template @@ -3401,15 +3416,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 +3441,395 @@ 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; + } + } + return state; +} + +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); + _keyboardSelection = false; +} + +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 (!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(); +} + 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 fafbee9f2afec3..62043215603dd8 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,34 @@ 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; + protected: void visibleTopBottomUpdated( 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; @@ -374,6 +398,26 @@ 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(); + void expandSection(int section); + void ensureCellVisible(const OverEmoji &over); + void returnFocus(); void setPressed(OverState newPressed); void fillRecentMenu( @@ -593,6 +637,12 @@ 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; diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp index e00549b3a8f3aa..3df02beb95e69e 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp @@ -754,7 +754,18 @@ 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(); + } + return result; } rpl::producer<> TabbedSelector::checkForHide() const { diff --git a/Telegram/SourceFiles/ui/controls/emoji_button_factory.cpp b/Telegram/SourceFiles/ui/controls/emoji_button_factory.cpp index 857cfefedbfb1c..5ab70d9b03224e 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(); }); From e70ae39a0785c78374d9b7a32fabd5d6a4c1ca31 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Fri, 4 Sep 2026 08:31:03 +0330 Subject: [PATCH 2/3] Choose the skin tone of an emoji from the keyboard The variants of an emoji - its skin tones - are picked from a small panel the mouse brings up over the emoji: right away on the first press of an emoji none of whose variants was ever chosen, and under the button held for a long press after that, while a click puts the emoji in with the tone chosen before. From the keyboard there was no way to it: the emoji went in with whatever tone it had. Enter and Space now do on an emoji with variants what the mouse does, with the same delay: the picker comes up right away for one never chosen from, under the key held for a long press otherwise, and the key let go before that chooses. The picker takes the focus, with the variant shown in the list selected; it is a list for a screen reader, named by the emoji, its items the variants. Left and Right, Home and End walk them, Enter or Space chooses - the tone is saved and the emoji goes in, as a click does, with the focus back where it came from and the panel done - and Escape backs out to the same emoji in the list. The picker is raised above the list rather than inside it, so the list gives the focus in it back the same way as its own, and keeps the emoji it was on when the picker hides. --- .../chat_helpers/emoji_list_widget.cpp | 321 +++++++++++++++++- .../chat_helpers/emoji_list_widget.h | 8 + 2 files changed, 319 insertions(+), 10 deletions(-) diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp index 15236c89b1833b..068e2f4aca8a94 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp @@ -85,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); @@ -98,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; @@ -176,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) { @@ -190,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, @@ -318,9 +370,10 @@ void EmojiColorPicker::animationCallback() { _allLabel->show(); } if (_hiding) { + returnFocus(); hide(); _hidden.fire({}); - } else { + } else if (!_keyboard) { _lastMousePos = QCursor::pos(); updateSelected(); } @@ -331,6 +384,7 @@ void EmojiColorPicker::hideFast() { clearSelection(); _a_opacity.stop(); _cache = QPixmap(); + returnFocus(); hide(); _hidden.fire({}); } @@ -438,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(), @@ -517,6 +737,7 @@ EmojiListWidget::EmojiListWidget( , _searchRequestTimer([=] { sendSearchRequest(); }) , _picker(this, st()) , _showPickerTimer([=] { showPicker(); }) +, _keyPickerTimer([=] { keyPickerTimeout(); }) , _previewTimer([=] { showPreview(); }) { setMouseTracking(true); if (st().bg->c.alpha() > 0) { @@ -1714,8 +1935,10 @@ void EmojiListWidget::beforeHiding() { } 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 && hasFocus()) { + if (was && (Ui::InFocusChain(this) || _picker->hasFocus())) { was->setFocus(); } } @@ -3147,8 +3370,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 { @@ -3365,7 +3591,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(); } @@ -3793,7 +4028,13 @@ void EmojiListWidget::focusInEvent(QFocusEvent *e) { void EmojiListWidget::focusOutEvent(QFocusEvent *e) { RpWidget::focusOutEvent(e); - _keyboardSelection = false; + // 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) { @@ -3815,11 +4056,25 @@ void EmojiListWidget::keyPressEvent(QKeyEvent *e) { 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_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({}); @@ -3830,6 +4085,52 @@ void EmojiListWidget::keyPressEvent(QKeyEvent *e) { 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 62043215603dd8..6c559b0cd75574 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h @@ -201,6 +201,7 @@ class EmojiListWidget final 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; @@ -415,6 +416,8 @@ class EmojiListWidget final const OverEmoji &over, int step) const; void activateKeyboardSelected(); + [[nodiscard]] bool openKeyboardPicker(); + void keyPickerTimeout(); void expandSection(int section); void ensureCellVisible(const OverEmoji &over); void returnFocus(); @@ -649,6 +652,11 @@ class EmojiListWidget final 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; From c9e8099c0d68a9590008a41555d11215d24d5178 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Fri, 4 Sep 2026 08:48:40 +0330 Subject: [PATCH 3/3] Tell a screen reader an emoji has variants to open An emoji with skin tones and one without sounded the same in the list; nothing said there was a picker to open. Report such an emoji as expandable - expanded while its picker is up - so the screen reader hears it as collapsed and offers to expand it, which opens the picker as Shift+Enter does; collapsing closes it. On Windows this needs the UIA ExpandCollapse pattern offered to expandable elements (Qt patch 0043). --- .../chat_helpers/emoji_list_widget.cpp | 36 +++++++++++++++++++ .../chat_helpers/emoji_list_widget.h | 1 + 2 files changed, 37 insertions(+) diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp index 068e2f4aca8a94..22ab16980dddf4 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp @@ -3820,9 +3820,45 @@ QAccessible::State EmojiListWidget::accessibilityChildState( 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; } diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h index 6c559b0cd75574..de68aa1ce15829 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h @@ -192,6 +192,7 @@ class EmojiListWidget final int accessibilityChildIndexByIdentity(quintptr identity) const override; void accessibilityChildSetFocus(quintptr identity) override; void accessibilityChildActivate(quintptr identity) override; + void accessibilityChildShowMenu(quintptr identity) override; protected: void visibleTopBottomUpdated(