From 0d8065fc1f4911548c08d217d06e38aa65486b15 Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 2 May 2025 17:34:10 +0400 Subject: [PATCH] First attempt of Ctrl+Tab/Ctrl+Shift+Tab UI. --- Telegram/CMakeLists.txt | 2 + Telegram/SourceFiles/core/application.cpp | 14 +- Telegram/SourceFiles/core/shortcuts.cpp | 79 +++++- Telegram/SourceFiles/core/shortcuts.h | 15 +- .../data/components/recent_peers.cpp | 27 +++ .../data/components/recent_peers.h | 8 + Telegram/SourceFiles/window/window.style | 8 + .../window/window_chat_switch_process.cpp | 225 ++++++++++++++++++ .../window/window_chat_switch_process.h | 82 +++++++ .../window/window_session_controller.cpp | 46 +++- .../window/window_session_controller.h | 3 + 11 files changed, 498 insertions(+), 11 deletions(-) create mode 100644 Telegram/SourceFiles/window/window_chat_switch_process.cpp create mode 100644 Telegram/SourceFiles/window/window_chat_switch_process.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 4de746002d..76ea20879b 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -1663,6 +1663,8 @@ PRIVATE window/window_adaptive.h window/window_chat_preview.cpp window/window_chat_preview.h + window/window_chat_switch_process.cpp + window/window_chat_switch_process.h window/window_connecting_widget.cpp window/window_connecting_widget.h window/window_controller.cpp diff --git a/Telegram/SourceFiles/core/application.cpp b/Telegram/SourceFiles/core/application.cpp index 51fa20fcc6..190f4431b4 100644 --- a/Telegram/SourceFiles/core/application.cpp +++ b/Telegram/SourceFiles/core/application.cpp @@ -656,8 +656,20 @@ bool Application::eventFilter(QObject *object, QEvent *e) { updateNonIdle(); } break; + case QEvent::KeyRelease: { + const auto event = static_cast(e); + if (Shortcuts::HandlePossibleChatSwitch(event)) { + return true; + } + } break; + case QEvent::ShortcutOverride: { - // handle shortcuts ourselves + // Ctrl+Tab/Ctrl+Shift+Tab chat switch is a special shortcut case, + // because it not only does an action on the shortcut activation, + // but also keeps the UI visible until you release the Ctrl key. + Shortcuts::HandlePossibleChatSwitch(static_cast(e)); + + // Handle all the shortcut management manually. return true; } break; diff --git a/Telegram/SourceFiles/core/shortcuts.cpp b/Telegram/SourceFiles/core/shortcuts.cpp index 061120af0d..87a186b48d 100644 --- a/Telegram/SourceFiles/core/shortcuts.cpp +++ b/Telegram/SourceFiles/core/shortcuts.cpp @@ -30,6 +30,10 @@ constexpr auto kCountLimit = 256; // How many shortcuts can be in json file. rpl::event_stream> RequestsStream; bool Paused/* = false*/; +Qt::Key ChatSwitchModifier/* = Qt::Key()*/; +bool ChatSwitchStarted/* = false*/; +rpl::event_stream ChatSwitchStream; + const auto AutoRepeatCommands = base::flat_set{ Command::MediaPrevious, Command::MediaNext, @@ -156,6 +160,7 @@ public: void toggleMedia(bool toggled); void toggleSupport(bool toggled); void listen(not_null widget); + [[nodiscard]] bool handles(const QKeySequence &sequence) const; [[nodiscard]] const QStringList &errors() const; @@ -361,6 +366,10 @@ void Manager::listen(not_null widget) { } } +bool Manager::handles(const QKeySequence &sequence) const { + return _shortcuts.contains(sequence); +} + void Manager::pruneListened() { for (auto i = begin(_listened); i != end(_listened);) { if (i->data()) { @@ -470,10 +479,6 @@ void Manager::fillDefaults() { set(u"ctrl+pgup"_q, Command::ChatPrevious); set(u"alt+up"_q, Command::ChatPrevious); - set(u"%1+tab"_q.arg(ctrl), Command::ChatNext); - set(u"%1+shift+tab"_q.arg(ctrl), Command::ChatPrevious); - set(u"%1+backtab"_q.arg(ctrl), Command::ChatPrevious); - set(u"ctrl+alt+home"_q, Command::ChatFirst); set(u"ctrl+alt+end"_q, Command::ChatLast); @@ -800,6 +805,72 @@ bool HandleEvent( return Launch(Data.lookup(object)); } +void CancelChatSwitch(Qt::Key result) { + ChatSwitchModifier = Qt::Key(); + if (ChatSwitchStarted) { + ChatSwitchStarted = false; + ChatSwitchStream.fire({ .action = result }); + } +} + +rpl::producer ChatSwitchRequests() { + return ChatSwitchStream.events(); +} + +bool HandlePossibleChatSwitch(not_null event) { + const auto type = event->type(); + if (Paused) { + return false; + } else if (type == QEvent::ShortcutOverride) { + const auto key = Qt::Key(event->key()); + if (key == Qt::Key_Escape) { + CancelChatSwitch(Qt::Key_Escape); + return false; + } else if (key == Qt::Key_Return || key == Qt::Key_Enter) { + CancelChatSwitch(Qt::Key_Enter); + return false; + } + const auto ctrl = Platform::IsMac() + ? Qt::MetaModifier + : Qt::ControlModifier; + + if (Data.handles(ctrl | Qt::ShiftModifier | Qt::Key_Tab) + && Data.handles(QKeySequence(ctrl | Qt::Key_Tab)) + && Data.handles(QKeySequence(ctrl | Qt::Key_Backtab))) { + return false; + } else if (key == Qt::Key_Control || key == Qt::Key_Meta) { + ChatSwitchModifier = key; + } else if (key == Qt::Key_Tab || key == Qt::Key_Backtab) { + const auto modifiers = event->modifiers(); + if (modifiers & ctrl) { + if (Data.handles(modifiers | key)) { + return false; + } + if (ChatSwitchModifier == Qt::Key()) { + ChatSwitchModifier = Platform::IsMac() + ? Qt::Key_Meta + : Qt::Key_Control; + } + const auto action = (modifiers & Qt::ShiftModifier) + ? Qt::Key_Backtab + : key; + const auto started = !std::exchange(ChatSwitchStarted, true); + ChatSwitchStream.fire({ + .action = action, + .started = started, + }); + return true; + } + } + } else if (type == QEvent::KeyRelease) { + const auto key = Qt::Key(event->key()); + if (key == ChatSwitchModifier) { + CancelChatSwitch(Qt::Key_Enter); + } + } + return false; +} + void ToggleMediaShortcuts(bool toggled) { Data.toggleMedia(toggled); } diff --git a/Telegram/SourceFiles/core/shortcuts.h b/Telegram/SourceFiles/core/shortcuts.h index ee832915b0..dffb31ec63 100644 --- a/Telegram/SourceFiles/core/shortcuts.h +++ b/Telegram/SourceFiles/core/shortcuts.h @@ -7,6 +7,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once +class QKeyEvent; +class QShortcutEvent; + namespace Shortcuts { enum class Command { @@ -122,7 +125,7 @@ private: }; -rpl::producer> Requests(); +[[nodiscard]] rpl::producer> Requests(); void Start(); void Finish(); @@ -132,7 +135,15 @@ void Listen(not_null widget); bool Launch(Command command); bool HandleEvent(not_null object, not_null event); -const QStringList &Errors(); +bool HandlePossibleChatSwitch(not_null event); + +struct ChatSwitchRequest { + Qt::Key action = Qt::Key_Tab; // Key_Tab, Key_Backtab or Key_Escape. + bool started = false; +}; +[[nodiscard]] rpl::producer ChatSwitchRequests(); + +[[nodiscard]] const QStringList &Errors(); // Media shortcuts are not enabled by default, because other // applications also use them. They are enabled only when diff --git a/Telegram/SourceFiles/data/components/recent_peers.cpp b/Telegram/SourceFiles/data/components/recent_peers.cpp index 0997cc1507..e397bcbaf1 100644 --- a/Telegram/SourceFiles/data/components/recent_peers.cpp +++ b/Telegram/SourceFiles/data/components/recent_peers.cpp @@ -7,6 +7,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "data/components/recent_peers.h" +#include "data/data_peer.h" +#include "data/data_session.h" +#include "history/history.h" #include "main/main_session.h" #include "storage/serialize_common.h" #include "storage/serialize_peer.h" @@ -133,4 +136,28 @@ void RecentPeers::applyLocal(QByteArray serialized) { ("Suggestions: RecentPeers read OK, count: %1").arg(_list.size())); } +std::vector> RecentPeers::collectChatOpenHistory() const { + _session->local().readSearchSuggestions(); + + auto result = _opens; + result.reserve(result.size() + _list.size()); + for (const auto &peer : _list) { + const auto history = peer->owner().history(peer); + const auto thread = not_null(history); + if (!ranges::contains(result, thread)) { + result.push_back(thread); + } + } + return result; +} + +void RecentPeers::chatOpenPush(not_null thread) { + const auto i = ranges::find(_opens, thread); + if (i == end(_opens)) { + _opens.insert(begin(_opens), thread); + } else if (i != begin(_opens)) { + ranges::rotate(begin(_opens), i, i + 1); + } +} + } // namespace Data diff --git a/Telegram/SourceFiles/data/components/recent_peers.h b/Telegram/SourceFiles/data/components/recent_peers.h index e30942720e..4d0fa5dc6a 100644 --- a/Telegram/SourceFiles/data/components/recent_peers.h +++ b/Telegram/SourceFiles/data/components/recent_peers.h @@ -13,6 +13,8 @@ class Session; namespace Data { +class Thread; + class RecentPeers final { public: explicit RecentPeers(not_null session); @@ -28,10 +30,16 @@ public: [[nodiscard]] QByteArray serialize() const; void applyLocal(QByteArray serialized); + [[nodiscard]] auto collectChatOpenHistory() const + -> std::vector>; + void chatOpenPush(not_null thread); + private: const not_null _session; std::vector> _list; + std::vector> _opens; + rpl::event_stream<> _updates; }; diff --git a/Telegram/SourceFiles/window/window.style b/Telegram/SourceFiles/window/window.style index 26e77ab1f1..63c98b936f 100644 --- a/Telegram/SourceFiles/window/window.style +++ b/Telegram/SourceFiles/window/window.style @@ -331,6 +331,14 @@ ivHeightDefault: 800px; maxWidthSharedMediaWindow: 419px; +chatSwitchMargins: margins(16px, 16px, 16px, 16px); +chatSwitchPadding: margins(12px, 12px, 12px, 12px); +chatSwitchSize: size(72px, 72px); +chatSwitchUserpic: UserpicButton(defaultUserpicButton) { + size: size(56px, 56px); + photoSize: 56px; +} + // Windows specific winQuitIcon: icon {{ "win_quit", windowFg }}; diff --git a/Telegram/SourceFiles/window/window_chat_switch_process.cpp b/Telegram/SourceFiles/window/window_chat_switch_process.cpp new file mode 100644 index 0000000000..2cdc13dc09 --- /dev/null +++ b/Telegram/SourceFiles/window/window_chat_switch_process.cpp @@ -0,0 +1,225 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "window/window_chat_switch_process.h" + +#include "core/shortcuts.h" +#include "data/components/recent_peers.h" +#include "data/data_thread.h" +#include "main/main_session.h" +#include "ui/widgets/shadow.h" +#include "ui/controls/userpic_button.h" +#include "ui/rp_widget.h" +#include "styles/style_layers.h" +#include "styles/style_window.h" + +namespace Window { + +ChatSwitchProcess::ChatSwitchProcess( + not_null geometry, + not_null session, + Data::Thread *opened) +: _session(session) +, _widget(std::make_unique( + geometry->parentWidget() ? geometry->parentWidget() : geometry)) +, _view(Ui::CreateChild(_widget.get()))\ +, _bg(st::boxRadius, st::boxBg) +, _over(st::boxRadius, st::windowBgOver) { + setupWidget(geometry); + setupContent(opened); + setupView(); +} + +ChatSwitchProcess::~ChatSwitchProcess() = default; + +rpl::producer> ChatSwitchProcess::chosen() const { + return _chosen.events(); +} + +rpl::producer<> ChatSwitchProcess::closeRequests() const { + return _closeRequests.events(); +} + +void ChatSwitchProcess::process(const Request &request) { + Expects(_selected < int(_list.size())); + + const auto count = int(_list.size()); + if (request.action == Qt::Key_Escape) { + _closeRequests.fire({}); + } else if (request.action == Qt::Key_Enter) { + if (_selected >= 0) { + _chosen.fire_copy(_list[_selected]); + } else { + _closeRequests.fire({}); + } + } else if (request.action == Qt::Key_Tab) { + if (_selected < 0 || _selected + 1 >= count) { + setSelected(0); + } else { + setSelected(_selected + 1); + } + } else if (request.action == Qt::Key_Backtab) { + if (_selected <= 0) { + setSelected(count - 1); + } else { + setSelected(_selected - 1); + } + } +} + +void ChatSwitchProcess::setSelected(int index) { + if (_selected == index || _list.empty()) { + return; + } + if (_selected >= 0) { + _entries[_selected].button->update(); + } + _selected = index; + if (_selected >= 0) { + _entries[_selected].button->update(); + } +} + +void ChatSwitchProcess::setupWidget(not_null geometry) { + geometry->geometryValue( + ) | rpl::start_with_next([=](QRect value) { + const auto parent = geometry->parentWidget(); + _widget->setGeometry((parent == _widget->parentWidget()) + ? value + : QRect(QPoint(), value.size())); + }, _widget->lifetime()); + + _widget->events() | rpl::start_with_next([=](not_null e) { + if (e->type() == QEvent::MouseButtonPress) { + _closeRequests.fire({}); + } + }, _widget->lifetime()); + + _widget->show(); +} + +void ChatSwitchProcess::setupContent(Data::Thread *opened) { + _list = _session->recentPeers().collectChatOpenHistory(); + if (opened) { + const auto i = ranges::find(_list, not_null(opened)); + if (i == end(_list)) { + _list.insert(begin(_list), opened); + } else if (i != begin(_list)) { + ranges::rotate(begin(_list), i, i + 1); + } + _selected = 0; + } + + auto index = 0; + for (const auto &thread : _list) { + const auto button = Ui::CreateChild(_view.get()); + button->resize(st::chatSwitchSize); + button->paintRequest() | rpl::start_with_next([=] { + if (index == _selected) { + auto p = QPainter(button); + _over.paint(p, button->rect()); + } + }, button->lifetime()); + button->setClickedCallback([=] { + _chosen.fire_copy(thread); + }); + button->events() | rpl::start_with_next([=](not_null e) { + if (e->type() == QEvent::MouseMove) { + setSelected(index); + } + }, button->lifetime()); + button->show(); + + const auto userpic = Ui::CreateChild( + button, + thread->peer(), + st::chatSwitchUserpic); + userpic->show(); + userpic->move( + ((button->width() - userpic->width()) / 2), + ((button->height() - userpic->height()) / 2)); + userpic->setAttribute(Qt::WA_TransparentForMouseEvents); + + _entries.push_back({ .button = button }); + + ++index; + } +} + +void ChatSwitchProcess::setupView() { + _widget->sizeValue() | rpl::start_with_next([=](QSize size) { + layout(size); + }, _view->lifetime()); + _view->show(); + + _view->paintRequest() | rpl::start_with_next([=](QRect clip) { + if (_outer.isEmpty()) { + return; + } + auto p = QPainter(_view); + p.translate(-_shadowed.topLeft()); + Ui::Shadow::paint(p, _outer, _view->width(), st::boxRoundShadow); + _bg.paint(p, _outer); + }, _view->lifetime()); + + _view->events() | rpl::start_with_next([=](not_null e) { + if (e->type() == QEvent::MouseButtonPress) { + e->accept(); + } + }, _view->lifetime()); +} + +void ChatSwitchProcess::layout(QSize size) { + const auto full = QRect(QPoint(), size); + const auto outer = full.marginsRemoved(st::chatSwitchMargins); + auto inner = outer.marginsRemoved(st::chatSwitchPadding); + const auto available = inner.width(); + const auto canPerRow = (available / st::chatSwitchSize.width()); + if (canPerRow < 1 || _list.empty()) { + return; + } + const auto count = int(_list.size()); + const auto rows = (count + canPerRow - 1) / canPerRow; + const auto minPerRow = count / rows; + const auto wideRows = (count - (minPerRow * rows)); + const auto maxPerRow = wideRows ? (minPerRow + 1) : minPerRow; + const auto narrowShift = wideRows ? (st::chatSwitchSize.width() / 2) : 0; + const auto width = maxPerRow * st::chatSwitchSize.width(); + const auto height = rows * st::chatSwitchSize.height(); + + size = QSize(width, height); + _inner = QRect( + (full.width() - width) / 2, + (full.height() - height) / 2, + width, + height); + _outer = _inner.marginsAdded(st::chatSwitchPadding); + + const auto padding = st::boxRoundShadow.extend + st::chatSwitchPadding; + + auto index = 0; + auto top = padding.top(); + for (auto row = 0; row != rows; ++row) { + const auto columns = (row < wideRows) ? maxPerRow : minPerRow; + auto left = padding.left() + ((row < wideRows) ? 0 : narrowShift); + for (auto column = 0; column != columns; ++column) { + auto &entry = _entries[index++]; + entry.button->moveToLeft(left, top, _inner.width()); + left += st::chatSwitchSize.width(); + } + top += st::chatSwitchSize.height(); + } + + _shadowed = _outer.marginsAdded(st::boxRoundShadow.extend); + _view->setGeometry(_shadowed); +} + +rpl::lifetime &ChatSwitchProcess::lifetime() { + return _lifetime; +} + +} // namespace Window diff --git a/Telegram/SourceFiles/window/window_chat_switch_process.h b/Telegram/SourceFiles/window/window_chat_switch_process.h new file mode 100644 index 0000000000..632bf8137c --- /dev/null +++ b/Telegram/SourceFiles/window/window_chat_switch_process.h @@ -0,0 +1,82 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "ui/round_rect.h" + +namespace Data { +class Thread; +} // namespace Data + +namespace Main { +class Session; +} // namespace Main + +namespace Shortcuts { +struct ChatSwitchRequest; +} // namespace Shortcuts + +namespace Ui { +class AbstractButton; +class RpWidget; +} // namespace Ui + +namespace Window { + +class ChatSwitchProcess final { +public: + // Create widget in geometry->parentWidget() and geometry->geometry(). + ChatSwitchProcess( + not_null geometry, + not_null session, + Data::Thread *opened); + ~ChatSwitchProcess(); + + [[nodiscard]] rpl::producer> chosen() const; + [[nodiscard]] rpl::producer<> closeRequests() const; + + using Request = Shortcuts::ChatSwitchRequest; + void process(const Request &request); + + [[nodiscard]] rpl::lifetime &lifetime(); + +private: + struct Entry { + not_null button; + }; + void setupWidget(not_null geometry); + void setupContent(Data::Thread *opened); + void setupView(); + + void layout(QSize size); + + void setSelected(int index); + + const not_null _session; + const std::unique_ptr _widget; + const not_null _view; + + QRect _shadowed; + QRect _outer; + QRect _inner; + Ui::RoundRect _bg; + Ui::RoundRect _over; + + std::vector> _list; + std::vector _entries; + + int _selected = -1; + + rpl::event_stream> _chosen; + rpl::event_stream<> _closeRequests; + + rpl::lifetime _lifetime; + +}; + +} // namespace Window diff --git a/Telegram/SourceFiles/window/window_session_controller.cpp b/Telegram/SourceFiles/window/window_session_controller.cpp index 292ceb1fbb..237f39f65b 100644 --- a/Telegram/SourceFiles/window/window_session_controller.cpp +++ b/Telegram/SourceFiles/window/window_session_controller.cpp @@ -14,6 +14,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/peers/replace_boost_box.h" #include "boxes/delete_messages_box.h" #include "window/window_chat_preview.h" +#include "window/window_chat_switch_process.h" #include "window/window_controller.h" #include "window/window_filters_menu.h" #include "window/window_separate_id.h" @@ -32,6 +33,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/history_view_subsection_tabs.h" #include "media/player/media_player_instance.h" #include "media/view/media_view_open_common.h" +#include "data/components/recent_peers.h" #include "data/stickers/data_custom_emoji.h" #include "data/data_document_resolver.h" #include "data/data_download_manager.h" @@ -1735,18 +1737,50 @@ void SessionController::init() { } void SessionController::setupShortcuts() { - Shortcuts::Requests( + using namespace Shortcuts; + + ChatSwitchRequests( + ) | rpl::filter([=](const ChatSwitchRequest &request) { + return !window().locked() + && (_chatSwitchProcess + || (request.started + && (Core::App().activeWindow() == &window()))); + }) | rpl::start_with_next([=](const ChatSwitchRequest &request) { + if (!_chatSwitchProcess) { + _chatSwitchProcess = std::make_unique( + widget()->bodyWidget(), + &session(), + activeChatCurrent().thread()); + const auto close = [this, raw = _chatSwitchProcess.get()] { + if (_chatSwitchProcess.get() == raw) { + base::take(_chatSwitchProcess); + } + }; + + _chatSwitchProcess->chosen( + ) | rpl::start_with_next([=](not_null thread) { + close(); + jumpToChatListEntry({ Dialogs::Key(thread), FullMsgId() }); + }, _chatSwitchProcess->lifetime()); + + _chatSwitchProcess->closeRequests( + ) | rpl::start_with_next(close, _chatSwitchProcess->lifetime()); + } + _chatSwitchProcess->process(request); + }, _lifetime); + + Requests( ) | rpl::filter([=] { return (Core::App().activeWindow() == &window()) && !isLayerShown() && !window().locked(); - }) | rpl::start_with_next([=](not_null request) { - using C = Shortcuts::Command; + }) | rpl::start_with_next([=](not_null request) { + using C = Command; const auto app = &Core::App(); const auto accountsCount = int(app->domain().accounts().size()); auto &&accounts = ranges::views::zip( - Shortcuts::kShowAccount, + kShowAccount, ranges::views::ints(0, accountsCount)); for (const auto &[command, index] : accounts) { request->check(command) && request->handle([=] { @@ -2033,6 +2067,10 @@ void SessionController::setActiveChatEntry(Dialogs::RowDescriptor row) { { anim::type::normal, anim::activation::background }); }, _activeHistoryLifetime); } + + if (const auto thread = row.key.thread()) { + session().recentPeers().chatOpenPush(thread); + } } if (session().supportMode()) { pushToChatEntryHistory(row); diff --git a/Telegram/SourceFiles/window/window_session_controller.h b/Telegram/SourceFiles/window/window_session_controller.h index 8ca4819def..7f46a54ffd 100644 --- a/Telegram/SourceFiles/window/window_session_controller.h +++ b/Telegram/SourceFiles/window/window_session_controller.h @@ -99,6 +99,7 @@ class SectionMemento; class Controller; class FiltersMenu; class ChatPreviewManager; +class ChatSwitchProcess; struct PeerByLinkInfo; struct SeparateId; @@ -784,6 +785,8 @@ private: std::deque> _lastUsedCustomChatThemes; rpl::variable _peerThemeOverride; + std::unique_ptr _chatSwitchProcess; + base::has_weak_ptr _storyOpenGuard; QString _premiumRef;