From 0e7a483c7ba4817e663d1531e3488304db3fd04b Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 13 Apr 2026 11:21:21 +0700 Subject: [PATCH 01/78] Fix muted icon outline clipping. --- Telegram/SourceFiles/calls/calls.style | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Telegram/SourceFiles/calls/calls.style b/Telegram/SourceFiles/calls/calls.style index 2c79e422d0..2b76011fdc 100644 --- a/Telegram/SourceFiles/calls/calls.style +++ b/Telegram/SourceFiles/calls/calls.style @@ -52,7 +52,7 @@ callBodyLayout: CallBodyLayout { participantsTop: 294px; muteStroke: 3px; muteSize: 36px; - mutePosition: point(142px, 135px); + mutePosition: point(140px, 135px); } callBodyWithPreview: CallBodyLayout { height: 185px; From b87df709df26be0580d6318c4965f27d95b24010 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 13 Apr 2026 22:28:57 +0700 Subject: [PATCH 02/78] Fix microphone testing in Settings. --- .../settings/sections/settings_calls.cpp | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Telegram/SourceFiles/settings/sections/settings_calls.cpp b/Telegram/SourceFiles/settings/sections/settings_calls.cpp index 88856cef81..99ce45c03b 100644 --- a/Telegram/SourceFiles/settings/sections/settings_calls.cpp +++ b/Telegram/SourceFiles/settings/sections/settings_calls.cpp @@ -427,11 +427,9 @@ void BuildOtherSection(SectionBuilder &builder) { builder.addSkip(); } -void BuildCallsSectionContent(SectionBuilder &builder) { - auto *testingMicrophone = builder.container() - ? builder.container()->lifetime().make_state>() - : nullptr; - +void BuildCallsSectionContent( + SectionBuilder &builder, + rpl::variable *testingMicrophone = nullptr) { BuildOutputSection(builder); BuildInputSection(builder, testingMicrophone); BuildCallDevicesSection(builder); @@ -672,7 +670,8 @@ void Calls::sectionSaveChanges(FnMut done) { void Calls::setupContent() { const auto content = Ui::CreateChild(this); - const SectionBuildMethod buildMethod = []( + const auto testingMicrophone = &_testingMicrophone; + const SectionBuildMethod buildMethod = [testingMicrophone]( not_null container, not_null controller, Fn showOther, @@ -686,7 +685,7 @@ void Calls::setupContent() { .showOther = std::move(showOther), .isPaused = isPaused, }); - BuildCallsSectionContent(builder); + BuildCallsSectionContent(builder, testingMicrophone); }; build(content, buildMethod); From d39af8edf3b70f1b0958d6083855f8306eb84334 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 13 Apr 2026 22:51:24 +0700 Subject: [PATCH 03/78] [ai] Fix custom command for icon creation. --- .claude/commands/icon.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/commands/icon.md b/.claude/commands/icon.md index 7e7bde46c3..479e23eceb 100644 --- a/.claude/commands/icon.md +++ b/.claude/commands/icon.md @@ -127,7 +127,7 @@ Locate the render tool (`codegen_style` with `--render-svg` mode): ```bash if [[ "$OSTYPE" == darwin* ]]; then - ls out/Debug/codegen_style + ls out/Telegram/codegen/codegen/style/Debug/codegen_style else ls out/Telegram/codegen/codegen/style/Debug/codegen_style.exe fi @@ -137,7 +137,7 @@ If missing, build it: `cmake --build out --config Debug --target codegen_style` Test on a known good SVG (use the appropriate binary path for the OS): ```bash -CODEGEN=$(if [[ "$OSTYPE" == darwin* ]]; then echo out/Debug/codegen_style; else echo out/Telegram/codegen/codegen/style/Debug/codegen_style.exe; fi) +CODEGEN=$(if [[ "$OSTYPE" == darwin* ]]; then echo out/Telegram/codegen/codegen/style/Debug/codegen_style; else echo out/Telegram/codegen/codegen/style/Debug/codegen_style.exe; fi) $CODEGEN --render-svg Telegram/Resources/icons/menu/tag_add.svg .ai/icon_{name}/test_render.png 512 ``` From ce10b71122aa2d00a4e3279be55938b1a61385fb Mon Sep 17 00:00:00 2001 From: John Preston Date: Wed, 15 Apr 2026 15:05:08 +0700 Subject: [PATCH 04/78] Fix geometry counting around collapsed blockquote. --- Telegram/lib_ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Telegram/lib_ui b/Telegram/lib_ui index 1644611aec..787445cdc6 160000 --- a/Telegram/lib_ui +++ b/Telegram/lib_ui @@ -1 +1 @@ -Subproject commit 1644611aeceb7702c6fdf3cc2f2fec6fccc9703c +Subproject commit 787445cdc68e06d370ca81af2f42028abc7f503b From e3a34eae292b365c004a75317a7bc065a0de73c1 Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 16 Apr 2026 00:56:22 +0700 Subject: [PATCH 05/78] Cache date string in dialog rows. --- .../SourceFiles/dialogs/dialogs_entry.cpp | 29 +++++++++++ Telegram/SourceFiles/dialogs/dialogs_entry.h | 21 ++++++++ Telegram/SourceFiles/dialogs/dialogs_row.cpp | 6 +++ Telegram/SourceFiles/dialogs/dialogs_row.h | 5 ++ .../SourceFiles/dialogs/ui/dialogs_layout.cpp | 50 +++++++++++-------- 5 files changed, 90 insertions(+), 21 deletions(-) diff --git a/Telegram/SourceFiles/dialogs/dialogs_entry.cpp b/Telegram/SourceFiles/dialogs/dialogs_entry.cpp index 9b77e31814..5a6ab2d037 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_entry.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_entry.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "dialogs/dialogs_key.h" #include "dialogs/dialogs_indexed_list.h" +#include "base/unixtime.h" #include "data/data_changes.h" #include "data/data_session.h" #include "data/data_folder.h" @@ -20,6 +21,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "mainwidget.h" #include "main/main_session.h" #include "main/main_session_settings.h" +#include "ui/text/format_values.h" #include "ui/text/text_options.h" #include "ui/ui_utility.h" #include "history/history.h" @@ -309,6 +311,33 @@ const Ui::Text::String &Entry::chatListNameText() const { return _chatListNameText; } +DateText ResolveDateText( + DateTextCache &cache, + TimeId date, + crl::time now) { + static crl::time LastNow = 0; + static int LastTodaySerial = 0; + if (!now || LastNow != now) { + LastNow = now; + LastTodaySerial = int(QDate::currentDate().toJulianDay()); + } + if (cache.messageTimeId != date + || cache.todaySerial != LastTodaySerial) { + const auto qdt = base::unixtime::parse(date); + cache.text = Ui::FormatDialogsDate(qdt); + cache.width = st::dialogsDateFont->width(cache.text); + cache.messageTimeId = date; + cache.todaySerial = LastTodaySerial; + } + return { cache.text, cache.width }; +} + +DateText Entry::chatListTimestampText( + TimeId date, + crl::time now) const { + return ResolveDateText(_chatListDateCache, date, now); +} + void Entry::setChatListExistence(bool exists) { if (exists && _sortKeyInChatList) { owner().refreshChatListEntry(this); diff --git a/Telegram/SourceFiles/dialogs/dialogs_entry.h b/Telegram/SourceFiles/dialogs/dialogs_entry.h index 8838bd05fb..2026f8f879 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_entry.h +++ b/Telegram/SourceFiles/dialogs/dialogs_entry.h @@ -52,6 +52,23 @@ class MainList; CountInBadge count = CountInBadge::Default, IncludeInBadge include = IncludeInBadge::Default); +struct DateTextCache { + QString text; + TimeId messageTimeId = 0; + int todaySerial = 0; + int width = 0; +}; + +struct DateText { + const QString &text; + int width = 0; +}; + +[[nodiscard]] DateText ResolveDateText( + DateTextCache &cache, + TimeId date, + crl::time now); + class Entry : public base::has_weak_ptr { public: enum class Type : uchar { @@ -154,6 +171,9 @@ public: } [[nodiscard]] const Ui::Text::String &chatListNameText() const; + [[nodiscard]] DateText chatListTimestampText( + TimeId date, + crl::time now) const; [[nodiscard]] Ui::PeerBadge &chatListPeerBadge() const { return _chatListPeerBadge; } @@ -194,6 +214,7 @@ private: mutable Ui::PeerBadge _chatListPeerBadge; mutable Ui::Text::String _chatListNameText; mutable int _chatListNameVersion = 0; + mutable DateTextCache _chatListDateCache; TimeId _timeId = 0; Flags _flags; diff --git a/Telegram/SourceFiles/dialogs/dialogs_row.cpp b/Telegram/SourceFiles/dialogs/dialogs_row.cpp index 87ba11d6bb..acd8c8cb1c 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_row.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_row.cpp @@ -760,4 +760,10 @@ const Ui::Text::String &FakeRow::name() const { return _name; } +DateText FakeRow::dateText( + TimeId date, + crl::time now) const { + return ResolveDateText(_dateCache, date, now); +} + } // namespace Dialogs diff --git a/Telegram/SourceFiles/dialogs/dialogs_row.h b/Telegram/SourceFiles/dialogs/dialogs_row.h index 7bf981d17d..7e2f9a6708 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_row.h +++ b/Telegram/SourceFiles/dialogs/dialogs_row.h @@ -11,6 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/text/text.h" #include "ui/unread_badge.h" #include "ui/userpic_view.h" +#include "dialogs/dialogs_entry.h" #include "dialogs/dialogs_key.h" #include "dialogs/ui/dialogs_message_view.h" @@ -239,6 +240,9 @@ public: return _badge; } [[nodiscard]] const Ui::Text::String &name() const; + [[nodiscard]] DateText dateText( + TimeId date, + crl::time now) const; void invalidateTopic(); @@ -252,6 +256,7 @@ private: mutable Ui::MessageView _itemView; mutable Ui::PeerBadge _badge; mutable Ui::Text::String _name; + mutable DateTextCache _dateCache; }; diff --git a/Telegram/SourceFiles/dialogs/ui/dialogs_layout.cpp b/Telegram/SourceFiles/dialogs/ui/dialogs_layout.cpp index 390d370077..ac29683c5b 100644 --- a/Telegram/SourceFiles/dialogs/ui/dialogs_layout.cpp +++ b/Telegram/SourceFiles/dialogs/ui/dialogs_layout.cpp @@ -31,7 +31,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_unread_things.h" #include "history/view/history_view_item_preview.h" #include "history/view/history_view_send_action.h" -#include "lang/lang_instance.h" #include "lang/lang_keys.h" #include "lottie/lottie_icon.h" #include "main/main_session.h" @@ -87,8 +86,11 @@ void PaintRowTopRight( QPainter &p, const QString &text, QRect &rectForName, - const PaintContext &context) { - const auto width = st::dialogsDateFont->width(text); + const PaintContext &context, + int precomputedWidth = -1) { + const auto width = (precomputedWidth >= 0) + ? precomputedWidth + : st::dialogsDateFont->width(text); rectForName.setWidth(rectForName.width() - width - st::dialogsDateSkip); p.setFont(st::dialogsDateFont); p.setPen(context.active @@ -380,6 +382,19 @@ enum class Flag { }; inline constexpr bool is_flag_type(Flag) { return true; } +void PaintDialogDate( + QPainter &p, + not_null entry, + const FakeRow *fakeRow, + TimeId date, + QRect &rectForName, + const PaintContext &context) { + const auto resolved = fakeRow + ? fakeRow->dateText(date, context.now) + : entry->chatListTimestampText(date, context.now); + PaintRowTopRight(p, resolved.text, rectForName, context, resolved.width); +} + template void PaintRow( Painter &p, @@ -394,8 +409,9 @@ void PaintRow( const HiddenSenderInfo *hiddenSenderInfo, HistoryItem *item, const Data::Draft *draft, - QDateTime date, + TimeId date, const PaintContext &context, + const FakeRow *fakeRow, BadgesState badgesState, base::flags flags, PaintItemCallback &&paintItemCallback) { @@ -589,8 +605,7 @@ void PaintRow( || (supportMode && entry->session().supportHelper().isOccupiedBySomeone(history))) { if (!promoted) { - const auto dateString = Ui::FormatDialogsDate(date); - PaintRowTopRight(p, dateString, rectForName, context); + PaintDialogDate(p, entry, fakeRow, date, rectForName, context); } auto availableWidth = namewidth; @@ -721,8 +736,7 @@ void PaintRow( } } else if (!item->isEmpty()) { if ((thread || sublist) && !promoted) { - const auto dateString = Ui::FormatDialogsDate(date); - PaintRowTopRight(p, dateString, rectForName, context); + PaintDialogDate(p, entry, fakeRow, date, rectForName, context); } paintItemCallback(nameleft, namewidth); @@ -1061,18 +1075,10 @@ void RowPainter::Paint( } return nullptr; }(); - const auto displayDate = [&] { - if (item) { - if (cloudDraft) { - return (item->date() > cloudDraft->date) - ? ItemDateTime(item) - : base::unixtime::parse(cloudDraft->date); - } - return ItemDateTime(item); - } - return cloudDraft - ? base::unixtime::parse(cloudDraft->date) - : QDateTime(); + const auto displayDate = [&]() -> TimeId { + const auto itemDate = item ? item->date() : TimeId(0); + const auto draftDate = cloudDraft ? cloudDraft->date : TimeId(0); + return std::max(itemDate, draftDate); }(); const auto displayPinnedIcon = badgesState.empty() && entry->isPinnedDialog(context.filter) @@ -1172,6 +1178,7 @@ void RowPainter::Paint( cloudDraft, displayDate, context, + nullptr, badgesState, flags, paintItemCallback); @@ -1281,8 +1288,9 @@ void RowPainter::Paint( hiddenSenderInfo, item, cloudDraft, - ItemDateTime(item), + item->date(), context, + row, badgesState, flags, paintItemCallback); From f31421a3114feb691c79f32568a4014d4cd83eaa Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 16 Apr 2026 12:58:06 +0700 Subject: [PATCH 06/78] Fix unread media indicator display. --- .../SourceFiles/history/view/media/history_view_document.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Telegram/SourceFiles/history/view/media/history_view_document.cpp b/Telegram/SourceFiles/history/view/media/history_view_document.cpp index c97db4250f..cc8ad9f416 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_document.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_document.cpp @@ -599,7 +599,7 @@ QSize Document::countCurrentSize(int newWidth) { + st::mediaUnreadSkip) + (thumbedWidth + statusWidth) + st.thumbSkip - + (_realParent->hasUnreadMediaFlag() + + (_realParent->isUnreadMedia() ? st::mediaUnreadSkip + st::mediaUnreadSize : 0) + _parent->bottomInfoFirstLineWidth() @@ -947,7 +947,7 @@ void Document::draw( p.setPen(stm->mediaFg); p.drawTextLeft(nameleft, statustop, width, statusText); - if (_realParent->hasUnreadMediaFlag()) { + if (_realParent->isUnreadMedia()) { auto w = st::normalFont->width(statusText); if (w + st::mediaUnreadSkip + st::mediaUnreadSize <= statuswidth) { p.setPen(Qt::NoPen); From 1b96772126bd09be4ed4c0d5d51baaa40327d043 Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 16 Apr 2026 01:02:44 +0700 Subject: [PATCH 07/78] Allow continue macOS build after partial. --- Telegram/build/build.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Telegram/build/build.sh b/Telegram/build/build.sh index b5cf854c39..eb1ed0fa10 100755 --- a/Telegram/build/build.sh +++ b/Telegram/build/build.sh @@ -280,7 +280,7 @@ if [ "$BuildTarget" == "mac" ] || [ "$BuildTarget" == "macstore" ]; then fi cd $ReleasePath fi - if [ "$NotarizeRequestId" == "" ]; then + if [ "$NotarizeRequestId" == "" ] || [ "$NotarizeRequestId" == "go" ]; then if [ "$BuildTarget" == "mac" ]; then if [ ! -f "$ReleasePath/$BundleName/Contents/Frameworks/Updater" ]; then Error "Updater not found!" @@ -358,7 +358,7 @@ if [ "$BuildTarget" == "mac" ] || [ "$BuildTarget" == "macstore" ]; then if [ "$BuildTarget" == "mac" ]; then cd "$ReleasePath" - if [ "$NotarizeRequestId" == "" ]; then + if [ "$NotarizeRequestId" == "" ] || [ "$NotarizeRequestId" == "go" ]; then if [ "$AlphaVersion" == "0" ]; then cp -f tsetup_template.dmg tsetup.temp.dmg TempDiskPath=`hdiutil attach -nobrowse -noautoopenrw -readwrite tsetup.temp.dmg | awk -F "\t" 'END {print $3}'` @@ -391,7 +391,7 @@ if [ "$BuildTarget" == "mac" ] || [ "$BuildTarget" == "macstore" ]; then SetupFile="talpha${AlphaVersion}_${AlphaSignature}.zip" fi - if [ "$NotarizeRequestId" == "" ]; then + if [ "$NotarizeRequestId" == "" ] || [ "$NotarizeRequestId" == "go" ]; then rm -rf "$ReleasePath/AlphaTemp" mkdir "$ReleasePath/AlphaTemp" mkdir "$ReleasePath/AlphaTemp/$BinaryName" From 2cd90eb3ce0acfe9a46b293a67ba2d944f17c80e Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 16 Apr 2026 14:50:06 +0700 Subject: [PATCH 08/78] Add proxy auto-rotation option. --- Telegram/CMakeLists.txt | 4 + Telegram/Resources/langs/lang.strings | 4 + Telegram/SourceFiles/boxes/connection_box.cpp | 263 +++++++------ Telegram/SourceFiles/boxes/connection_box.h | 2 + Telegram/SourceFiles/core/application.cpp | 15 + Telegram/SourceFiles/core/application.h | 2 + .../SourceFiles/core/core_settings_proxy.cpp | 164 +++++++- .../SourceFiles/core/core_settings_proxy.h | 33 +- .../core/proxy_rotation_manager.cpp | 359 ++++++++++++++++++ .../SourceFiles/core/proxy_rotation_manager.h | 80 ++++ Telegram/SourceFiles/main/main_account.cpp | 1 + Telegram/SourceFiles/mtproto/proxy_check.cpp | 111 ++++++ Telegram/SourceFiles/mtproto/proxy_check.h | 35 ++ .../details/storage_settings_scheme.cpp | 18 +- 14 files changed, 958 insertions(+), 133 deletions(-) create mode 100644 Telegram/SourceFiles/core/proxy_rotation_manager.cpp create mode 100644 Telegram/SourceFiles/core/proxy_rotation_manager.h create mode 100644 Telegram/SourceFiles/mtproto/proxy_check.cpp create mode 100644 Telegram/SourceFiles/mtproto/proxy_check.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index a1fae40a00..f5c3c1499c 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -503,6 +503,8 @@ PRIVATE chat_helpers/ttl_media_layer_widget.h core/application.cpp core/application.h + core/proxy_rotation_manager.cpp + core/proxy_rotation_manager.h core/cached_webview_availability.h core/bank_card_click_handler.cpp core/bank_card_click_handler.h @@ -1436,6 +1438,8 @@ PRIVATE mtproto/facade.h mtproto/mtp_instance.cpp mtproto/mtp_instance.h + mtproto/proxy_check.cpp + mtproto/proxy_check.h mtproto/sender.h mtproto/session.cpp mtproto/session.h diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 226614677b..0ee8c1f13d 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -1239,6 +1239,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_proxy_use_system_settings" = "Use system proxy settings"; "lng_proxy_use_custom" = "Use custom proxy"; "lng_proxy_use_for_calls" = "Use proxy for calls"; +"lng_proxy_auto_switch" = "Auto-switch proxies"; +"lng_proxy_auto_switch_about" = "You can choose how quickly the app should auto-connect to the nearest active proxy if the current one stops working."; +"lng_proxy_auto_switch_timeout#one" = "{count} s"; +"lng_proxy_auto_switch_timeout#other" = "{count} s"; "lng_proxy_about" = "Proxy servers may be helpful in accessing Telegram if there is no connection in a specific region."; "lng_proxy_add" = "Add proxy"; "lng_proxy_share" = "Share"; diff --git a/Telegram/SourceFiles/boxes/connection_box.cpp b/Telegram/SourceFiles/boxes/connection_box.cpp index 0a172d31d4..7ca091ebf2 100644 --- a/Telegram/SourceFiles/boxes/connection_box.cpp +++ b/Telegram/SourceFiles/boxes/connection_box.cpp @@ -18,6 +18,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "main/main_account.h" #include "main/main_session.h" #include "mtproto/facade.h" +#include "mtproto/proxy_check.h" #include "settings/settings_common.h" #include "storage/localstorage.h" #include "ui/basic_click_handlers.h" @@ -32,6 +33,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/buttons.h" #include "ui/widgets/checkbox.h" #include "ui/widgets/dropdown_menu.h" +#include "ui/widgets/discrete_sliders.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/fields/number_input.h" #include "ui/widgets/fields/password_input.h" @@ -62,6 +64,20 @@ constexpr auto kSaveSettingsDelayedTimeout = crl::time(1000); using ProxyData = MTP::ProxyData; +[[nodiscard]] int ClosestProxyRotationTimeoutSection(int value) { + auto result = 0; + auto bestDistance = 0; + for (auto i = 0; i != int(Core::SettingsProxy::kProxyRotationTimeouts.size()); ++i) { + const auto current = Core::SettingsProxy::kProxyRotationTimeouts[i]; + const auto distance = (current > value) ? (current - value) : (value - current); + if ((i == 0) || (distance < bestDistance)) { + result = i; + bestDistance = distance; + } + } + return result; +} + [[nodiscard]] std::vector ExtractUrlsSimple(const QString &input) { auto urls = std::vector(); static auto urlRegex = QRegularExpression(R"((https?:\/\/[^\s]+))"); @@ -376,12 +392,16 @@ private: void setupButtons(int id, not_null button); int rowHeight() const; void refreshProxyForCalls(); + void refreshProxyRotation(); not_null _controller; Core::SettingsProxy &_settings; QPointer _tryIPv6; std::shared_ptr> _proxySettings; QPointer> _proxyForCalls; + QPointer> _proxyRotation; + QPointer> _proxyRotationOptions; + QPointer _proxyRotationTimeout; QPointer _about; base::unique_qptr _noRows; object_ptr _initialWrap; @@ -900,6 +920,47 @@ void ProxiesBox::setupContent() { 0, st::proxyTryIPv6Padding.right(), st::proxyTryIPv6Padding.top())); + _proxyRotation = inner->add( + object_ptr>( + inner, + object_ptr( + inner, + tr::lng_proxy_auto_switch(tr::now), + _settings.proxyRotationEnabled()), + style::margins( + 0, + st::proxyUsePadding.top(), + 0, + st::proxyUsePadding.bottom())), + style::margins( + st::proxyTryIPv6Padding.left(), + 0, + st::proxyTryIPv6Padding.right(), + st::proxyTryIPv6Padding.top())); + _proxyRotationOptions = inner->add( + object_ptr>( + inner, + object_ptr(inner))); + _proxyRotationTimeout = _proxyRotationOptions->entity()->add( + object_ptr( + _proxyRotationOptions->entity(), + st::settingsSlider), + st::settingsBigScalePadding); + for (const auto seconds : Core::SettingsProxy::kProxyRotationTimeouts) { + _proxyRotationTimeout->addSection( + tr::lng_proxy_auto_switch_timeout( + tr::now, + lt_count, + seconds)); + } + _proxyRotationTimeout->setActiveSectionFast( + ClosestProxyRotationTimeoutSection(_settings.proxyRotationTimeout())); + _proxyRotationOptions->entity()->add( + object_ptr( + _proxyRotationOptions->entity(), + tr::lng_proxy_auto_switch_about(tr::now), + st::boxDividerLabel), + st::proxyAboutPadding); _about = inner->add( object_ptr( @@ -922,6 +983,7 @@ void ProxiesBox::setupContent() { addNewProxy(); } refreshProxyForCalls(); + refreshProxyRotation(); }); _tryIPv6->checkedChanges( ) | rpl::on_next([=](bool checked) { @@ -931,18 +993,33 @@ void ProxiesBox::setupContent() { _controller->proxySettingsValue( ) | rpl::on_next([=](ProxyData::Settings value) { _proxySettings->setValue(value); + refreshProxyForCalls(); + refreshProxyRotation(); }, inner->lifetime()); _proxyForCalls->entity()->checkedChanges( ) | rpl::on_next([=](bool checked) { _controller->setProxyForCalls(checked); }, _proxyForCalls->lifetime()); + _proxyRotation->entity()->checkedChanges( + ) | rpl::on_next([=](bool checked) { + _controller->setProxyRotationEnabled(checked); + refreshProxyRotation(); + }, _proxyRotation->lifetime()); + _proxyRotationTimeout->sectionActivated( + ) | rpl::on_next([=](int section) { + _controller->setProxyRotationTimeout( + Core::SettingsProxy::kProxyRotationTimeouts[section]); + }, _proxyRotationTimeout->lifetime()); if (_rows.empty()) { createNoRowsLabel(); } refreshProxyForCalls(); + refreshProxyRotation(); _proxyForCalls->finishAnimating(); + _proxyRotation->finishAnimating(); + _proxyRotationOptions->finishAnimating(); { const auto wrap = inner->add( @@ -987,6 +1064,20 @@ void ProxiesBox::refreshProxyForCalls() { anim::type::normal); } +void ProxiesBox::refreshProxyRotation() { + if (!_proxyRotation || !_proxyRotationOptions) { + return; + } + const auto visible = (_proxySettings->current() + == ProxyData::Settings::Enabled) + && _settings.selected() + && (_settings.list().size() > 1); + _proxyRotation->toggle(visible, anim::type::normal); + _proxyRotationOptions->toggle( + visible && _proxyRotation->entity()->checked(), + anim::type::normal); +} + int ProxiesBox::rowHeight() const { return st::proxyRowPadding.top() + st::semiboldFont->height @@ -1029,6 +1120,7 @@ void ProxiesBox::applyView(View &&view) { } else { i->second->updateFields(std::move(view)); } + refreshProxyRotation(); } void ProxiesBox::createNoRowsLabel() { @@ -1359,95 +1451,7 @@ void ProxyBox::addLabel( } using Connection = MTP::details::AbstractConnection; -using Checker = MTP::details::ConnectionPointer; - -void ResetProxyCheckers(Checker &v4, Checker &v6) { - v4 = nullptr; - v6 = nullptr; -} - -void DropProxyChecker(Checker &v4, Checker &v6, not_null raw) { - if (v4.get() == raw) { - v4 = nullptr; - } else if (v6.get() == raw) { - v6 = nullptr; - } -} - -[[nodiscard]] bool HasProxyCheckers(const Checker &v4, const Checker &v6) { - return v4 || v6; -} - -void StartProxyCheck( - not_null mtproto, - const ProxyData &proxy, - Checker &v4, - Checker &v6, - Fn done, - Fn fail) { - using Variants = MTP::DcOptions::Variants; - - ResetProxyCheckers(v4, v6); - const auto connType = (proxy.type == ProxyData::Type::Http) - ? Variants::Http - : Variants::Tcp; - const auto dcId = mtproto->mainDcId(); - const auto setup = [&](Checker &checker, const bytes::vector &secret) { - checker = Connection::Create( - mtproto, - connType, - QThread::currentThread(), - secret, - proxy); - const auto raw = checker.get(); - raw->connect(raw, &Connection::connected, [=] { - if (done) { - done(raw, raw->pingTime()); - } - }); - const auto failed = [=] { - if (fail) { - fail(raw); - } - }; - raw->connect(raw, &Connection::disconnected, failed); - raw->connect(raw, &Connection::error, failed); - }; - if (proxy.type == ProxyData::Type::Mtproto) { - const auto secret = proxy.secretFromMtprotoPassword(); - setup(v4, secret); - v4->connectToServer( - proxy.host, - proxy.port, - secret, - dcId, - false); - return; - } - const auto options = mtproto->dcOptions().lookup( - dcId, - MTP::DcType::Regular, - true); - const auto tryConnect = [&](Checker &checker, Variants::Address address) { - const auto &list = options.data[address][connType]; - if (list.empty() - || ((address == Variants::IPv6) - && !Core::App().settings().proxy().tryIPv6())) { - checker = nullptr; - return; - } - const auto &endpoint = list.front(); - setup(checker, endpoint.secret); - checker->connectToServer( - QString::fromStdString(endpoint.ip), - endpoint.port, - endpoint.secret, - dcId, - false); - }; - tryConnect(v4, Variants::IPv4); - tryConnect(v6, Variants::IPv6); -} +using Checker = MTP::ProxyCheckConnection; } // namespace @@ -1609,18 +1613,19 @@ void ProxiesBoxController::ShowApplyConfirmation( }; statusLabel->setTextColorOverride(st::proxyRowStatusFg->c); relayout(); - StartProxyCheck( + MTP::StartProxyCheck( &account->mtp(), proxy, + Core::App().settings().proxy().tryIPv6(), state->v4, state->v6, [=](Connection *raw, int ping) { if (!weak || state->finished) { return; } - DropProxyChecker(state->v4, state->v6, raw); + MTP::DropProxyChecker(state->v4, state->v6, raw); state->finished = true; - ResetProxyCheckers(state->v4, state->v6); + MTP::ResetProxyCheckers(state->v4, state->v6); state->statusValue = TextWithEntities{ tr::lng_proxy_box_table_available( tr::now, @@ -1635,13 +1640,13 @@ void ProxiesBoxController::ShowApplyConfirmation( if (!weak || state->finished) { return; } - DropProxyChecker(state->v4, state->v6, raw); - if (!HasProxyCheckers(state->v4, state->v6)) { + MTP::DropProxyChecker(state->v4, state->v6, raw); + if (!MTP::HasProxyCheckers(state->v4, state->v6)) { state->finished = true; setUnavailable(); } }); - if (!HasProxyCheckers(state->v4, state->v6)) { + if (!MTP::HasProxyCheckers(state->v4, state->v6)) { state->finished = true; setUnavailable(); } @@ -1681,9 +1686,9 @@ void ProxiesBoxController::ShowApplyConfirmation( const auto enableButton = box->addButton( tr::lng_proxy_box_table_button(), [=] { - auto &proxies = Core::App().settings().proxy().list(); - if (!ranges::contains(proxies, proxy)) { - proxies.push_back(proxy); + auto &settings = Core::App().settings().proxy(); + if (settings.indexInList(proxy) < 0) { + settings.addToList(proxy); } Core::App().setCurrentProxy( proxy, @@ -1719,9 +1724,10 @@ auto ProxiesBoxController::proxySettingsValue() const void ProxiesBoxController::refreshChecker(Item &item) { item.state = ItemState::Checking; const auto id = item.id; - StartProxyCheck( + MTP::StartProxyCheck( &_account->mtp(), item.data, + Core::App().settings().proxy().tryIPv6(), item.checker, item.checkerv6, [=](Connection *raw, int pingTime) { @@ -1732,8 +1738,8 @@ void ProxiesBoxController::refreshChecker(Item &item) { if (item == end(_list)) { return; } - DropProxyChecker(item->checker, item->checkerv6, raw); - ResetProxyCheckers(item->checker, item->checkerv6); + MTP::DropProxyChecker(item->checker, item->checkerv6, raw); + MTP::ResetProxyCheckers(item->checker, item->checkerv6); if (item->state == ItemState::Checking) { item->state = ItemState::Available; item->ping = pingTime; @@ -1748,14 +1754,14 @@ void ProxiesBoxController::refreshChecker(Item &item) { if (item == end(_list)) { return; } - DropProxyChecker(item->checker, item->checkerv6, raw); - if (!HasProxyCheckers(item->checker, item->checkerv6) + MTP::DropProxyChecker(item->checker, item->checkerv6, raw); + if (!MTP::HasProxyCheckers(item->checker, item->checkerv6) && item->state == ItemState::Checking) { item->state = ItemState::Unavailable; updateView(*item); } }); - if (!HasProxyCheckers(item.checker, item.checkerv6)) { + if (!MTP::HasProxyCheckers(item.checker, item.checkerv6)) { item.state = ItemState::Unavailable; } } @@ -1854,8 +1860,8 @@ void ProxiesBoxController::setDeleted(int id, bool deleted) { item->deleted = deleted; if (deleted) { - auto &proxies = _settings.list(); - proxies.erase(ranges::remove(proxies, item->data), end(proxies)); + const auto removed = _settings.removeFromList(item->data); + Assert(removed); if (item->data == _settings.selected()) { _lastSelectedProxy = _settings.selected(); @@ -1871,16 +1877,19 @@ void ProxiesBoxController::setDeleted(int id, bool deleted) { } } } else { - auto &proxies = _settings.list(); - if (ranges::find(proxies, item->data) == end(proxies)) { + if (_settings.indexInList(item->data) < 0) { + const auto &proxies = _settings.list(); auto insertBefore = item + 1; while (insertBefore != end(_list) && insertBefore->deleted) { ++insertBefore; } - auto insertBeforeIt = (insertBefore == end(_list)) - ? end(proxies) - : ranges::find(proxies, insertBefore->data); - proxies.insert(insertBeforeIt, item->data); + const auto foundIndex = (insertBefore == end(_list)) + ? int(proxies.size()) + : _settings.indexInList(insertBefore->data); + const auto insertIndex = (foundIndex >= 0) + ? foundIndex + : int(proxies.size()); + _settings.insertToList(insertIndex, item->data); } if (!_settings.selected() && _lastSelectedProxy == item->data) { @@ -1919,8 +1928,8 @@ object_ptr ProxiesBoxController::editItemBox(int id) { void ProxiesBoxController::replaceItemWith( std::vector::iterator which, std::vector::iterator with) { - auto &proxies = _settings.list(); - proxies.erase(ranges::remove(proxies, which->data), end(proxies)); + const auto removed = _settings.removeFromList(which->data); + Assert(removed); _views.fire({ which->id }); _list.erase(which); @@ -1939,10 +1948,8 @@ void ProxiesBoxController::replaceItemValue( restoreItem(which->id); } - auto &proxies = _settings.list(); - const auto i = ranges::find(proxies, which->data); - Assert(i != end(proxies)); - *i = proxy; + const auto replaced = _settings.replaceInList(which->data, proxy); + Assert(replaced); which->data = proxy; refreshChecker(*which); @@ -1978,8 +1985,7 @@ bool ProxiesBoxController::contains(const ProxyData &proxy) const { } void ProxiesBoxController::addNewItem(const ProxyData &proxy) { - auto &proxies = _settings.list(); - proxies.push_back(proxy); + _settings.addToList(proxy); _list.push_back({ ++_idCounter, proxy }); refreshChecker(_list.back()); @@ -2016,6 +2022,22 @@ void ProxiesBoxController::setProxyForCalls(bool enabled) { saveDelayed(); } +void ProxiesBoxController::setProxyRotationEnabled(bool enabled) { + if (_settings.proxyRotationEnabled() == enabled) { + return; + } + _settings.setProxyRotationEnabled(enabled); + saveDelayed(); +} + +void ProxiesBoxController::setProxyRotationTimeout(int value) { + if (_settings.proxyRotationTimeout() == value) { + return; + } + _settings.setProxyRotationTimeout(value); + saveDelayed(); +} + void ProxiesBoxController::setTryIPv6(bool enabled) { if (Core::App().settings().proxy().tryIPv6() == enabled) { return; @@ -2027,6 +2049,7 @@ void ProxiesBoxController::setTryIPv6(bool enabled) { } void ProxiesBoxController::saveDelayed() { + Core::App().proxyRotationSettingsChanged(); _saveTimer.callOnce(kSaveSettingsDelayedTimeout); } diff --git a/Telegram/SourceFiles/boxes/connection_box.h b/Telegram/SourceFiles/boxes/connection_box.h index c33f8ea0f4..5de98bc663 100644 --- a/Telegram/SourceFiles/boxes/connection_box.h +++ b/Telegram/SourceFiles/boxes/connection_box.h @@ -85,6 +85,8 @@ public: object_ptr addNewItemBox(); bool setProxySettings(ProxyData::Settings value); void setProxyForCalls(bool enabled); + void setProxyRotationEnabled(bool enabled); + void setProxyRotationTimeout(int value); void setTryIPv6(bool enabled); rpl::producer proxySettingsValue() const; diff --git a/Telegram/SourceFiles/core/application.cpp b/Telegram/SourceFiles/core/application.cpp index de4b20765a..6840b9eb35 100644 --- a/Telegram/SourceFiles/core/application.cpp +++ b/Telegram/SourceFiles/core/application.cpp @@ -27,6 +27,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "core/sandbox.h" #include "core/local_url_handlers.h" #include "core/launcher.h" +#include "core/proxy_rotation_manager.h" #include "core/ui_integration.h" #include "chat_helpers/emoji_keywords.h" #include "chat_helpers/stickers_emoji_image_loader.h" @@ -146,6 +147,7 @@ struct Application::Private { base::Timer quitTimer; UiIntegration uiIntegration; Settings settings; + std::unique_ptr proxyRotation; }; Application::Application() @@ -173,6 +175,7 @@ Application::Application() , _setupEmailLock(false) , _autoLockTimer([=] { checkAutoLock(); }) { Ui::Integration::Set(&_private->uiIntegration); + _private->proxyRotation = std::make_unique(); _platformIntegration->init(); @@ -234,6 +237,7 @@ Application::~Application() { // Domain::finish() and there is a violation on Ensures(started()). closeAdditionalWindows(); + _private->proxyRotation = nullptr; _domain->finish(); Local::finish(); @@ -833,6 +837,17 @@ void Application::setCurrentProxy( refreshGlobalProxy(); _proxyChanges.fire({ was, now }); my.connectionTypeChangesNotify(); + proxyRotationSettingsChanged(); +} + +void Application::proxyRotationSettingsChanged() { + _private->proxyRotation->settingsChanged(); +} + +void Application::checkProxyRotation( + not_null account, + int32 state) { + _private->proxyRotation->handleConnectionStateChanged(account, state); } auto Application::proxyChanges() const -> rpl::producer { diff --git a/Telegram/SourceFiles/core/application.h b/Telegram/SourceFiles/core/application.h index 104f194d3d..6c0bfecbd6 100644 --- a/Telegram/SourceFiles/core/application.h +++ b/Telegram/SourceFiles/core/application.h @@ -219,6 +219,8 @@ public: void setCurrentProxy( const MTP::ProxyData &proxy, MTP::ProxyData::Settings settings); + void proxyRotationSettingsChanged(); + void checkProxyRotation(not_null account, int32 state); [[nodiscard]] rpl::producer proxyChanges() const; void badMtprotoConfigurationError(); diff --git a/Telegram/SourceFiles/core/core_settings_proxy.cpp b/Telegram/SourceFiles/core/core_settings_proxy.cpp index 6f338de6aa..495bb0b846 100644 --- a/Telegram/SourceFiles/core/core_settings_proxy.cpp +++ b/Telegram/SourceFiles/core/core_settings_proxy.cpp @@ -10,6 +10,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/platform/base_platform_info.h" #include "storage/serialize_common.h" +#include + namespace Core { namespace { @@ -88,6 +90,22 @@ namespace { return result; } +std::vector NormalizeProxyRotationPreferredIndices( + std::vector indices, + int listSize) { + auto filtered = std::vector(); + filtered.reserve(indices.size()); + for (const auto index : indices) { + if (index < 0 + || index >= listSize + || ranges::contains(filtered, index)) { + continue; + } + filtered.push_back(index); + } + return filtered; +} + } // namespace SettingsProxy::SettingsProxy() @@ -108,7 +126,7 @@ QByteArray SettingsProxy::serialize() const { 0, ranges::plus(), &Serialize::bytearraySize) - + 1 * sizeof(qint32); // _checkIpWarningShown + + (4 + int(_proxyRotationPreferredIndices.size())) * sizeof(qint32); auto stream = Serialize::ByteArrayWriter(size); stream << qint32(_tryIPv6 ? 1 : 0) @@ -119,7 +137,14 @@ QByteArray SettingsProxy::serialize() const { for (const auto &i : serializedList) { stream << i; } - stream << qint32(_checkIpWarningShown ? 1 : 0); + stream + << qint32(_checkIpWarningShown ? 1 : 0) + << qint32(_proxyRotationEnabled ? 1 : 0) + << qint32(_proxyRotationTimeout) + << qint32(_proxyRotationPreferredIndices.size()); + for (const auto index : _proxyRotationPreferredIndices) { + stream << qint32(index); + } return std::move(stream).result(); } @@ -135,6 +160,7 @@ bool SettingsProxy::setFromSerialized(const QByteArray &serialized) { auto settings = ProxySettingsToInt(_settings); auto listCount = qint32(_list.size()); auto selectedProxy = QByteArray(); + auto list = std::vector(); if (!stream.atEnd()) { stream @@ -144,10 +170,14 @@ bool SettingsProxy::setFromSerialized(const QByteArray &serialized) { >> selectedProxy >> listCount; if (stream.ok()) { + if (listCount < 0) { + return false; + } + list.reserve(listCount); for (auto i = 0; i != listCount; ++i) { QByteArray data; stream >> data; - _list.push_back(DeserializeProxyData(data)); + list.push_back(DeserializeProxyData(data)); } } } @@ -156,6 +186,30 @@ bool SettingsProxy::setFromSerialized(const QByteArray &serialized) { if (!stream.atEnd()) { stream >> checkIpWarningShown; } + auto proxyRotationEnabled = qint32(_proxyRotationEnabled ? 1 : 0); + if (!stream.atEnd()) { + stream >> proxyRotationEnabled; + } + auto proxyRotationTimeout = qint32(_proxyRotationTimeout); + if (!stream.atEnd()) { + stream >> proxyRotationTimeout; + } + auto preferredCount = qint32(0); + auto preferredIndices = std::vector(); + if (!stream.atEnd()) { + stream >> preferredCount; + if (stream.ok()) { + if (preferredCount < 0) { + return false; + } + preferredIndices.reserve(preferredCount); + for (auto i = 0; i != preferredCount; ++i) { + auto index = qint32(0); + stream >> index; + preferredIndices.push_back(index); + } + } + } if (!stream.ok()) { LOG(("App Error: " @@ -166,8 +220,12 @@ bool SettingsProxy::setFromSerialized(const QByteArray &serialized) { _tryIPv6 = (tryIPv6 == 1); _useProxyForCalls = (useProxyForCalls == 1); _checkIpWarningShown = (checkIpWarningShown == 1); + _proxyRotationEnabled = (proxyRotationEnabled == 1); + setProxyRotationTimeout(proxyRotationTimeout); _settings = IntToProxySettings(settings); _selected = DeserializeProxyData(selectedProxy); + _list = std::move(list); + setProxyRotationPreferredIndices(std::move(preferredIndices)); return true; } @@ -192,6 +250,32 @@ void SettingsProxy::setCheckIpWarningShown(bool value) { _checkIpWarningShown = value; } +const std::vector &SettingsProxy::proxyRotationPreferredIndices() const { + return _proxyRotationPreferredIndices; +} + +void SettingsProxy::setProxyRotationPreferredIndices(std::vector value) { + _proxyRotationPreferredIndices = NormalizeProxyRotationPreferredIndices( + std::move(value), + int(_list.size())); +} + +bool SettingsProxy::promoteProxyRotationPreferredIndex(int index) { + if (index < 0 || index >= int(_list.size())) { + return false; + } + auto &indices = _proxyRotationPreferredIndices; + const auto i = ranges::find(indices, index); + if (i == begin(indices)) { + return false; + } else if (i != end(indices)) { + std::rotate(begin(indices), i, std::next(i)); + } else { + indices.insert(begin(indices), index); + } + return true; +} + bool SettingsProxy::tryIPv6() const { return _tryIPv6; } @@ -208,6 +292,24 @@ void SettingsProxy::setUseProxyForCalls(bool value) { _useProxyForCalls = value; } +bool SettingsProxy::proxyRotationEnabled() const { + return _proxyRotationEnabled; +} + +void SettingsProxy::setProxyRotationEnabled(bool value) { + _proxyRotationEnabled = value; +} + +int SettingsProxy::proxyRotationTimeout() const { + return _proxyRotationTimeout; +} + +void SettingsProxy::setProxyRotationTimeout(int value) { + _proxyRotationTimeout = (value > 0) + ? value + : kDefaultProxyRotationTimeout; +} + MTP::ProxyData::Settings SettingsProxy::settings() const { return _settings; } @@ -232,6 +334,62 @@ std::vector &SettingsProxy::list() { return _list; } +void SettingsProxy::setList(std::vector value) { + _list = std::move(value); + _proxyRotationPreferredIndices.clear(); +} + +void SettingsProxy::addToList(MTP::ProxyData value) { + _list.push_back(std::move(value)); +} + +void SettingsProxy::insertToList(int index, MTP::ProxyData value) { + index = std::clamp(index, 0, int(_list.size())); + for (auto &existing : _proxyRotationPreferredIndices) { + if (existing >= index) { + ++existing; + } + } + _list.insert(begin(_list) + index, std::move(value)); +} + +bool SettingsProxy::removeFromList(const MTP::ProxyData &value) { + const auto i = ranges::find(_list, value); + if (i == end(_list)) { + return false; + } + const auto index = int(i - begin(_list)); + _list.erase(i); + for (auto &existing : _proxyRotationPreferredIndices) { + if (existing > index) { + --existing; + } + } + _proxyRotationPreferredIndices.erase( + std::remove( + begin(_proxyRotationPreferredIndices), + end(_proxyRotationPreferredIndices), + index), + end(_proxyRotationPreferredIndices)); + return true; +} + +bool SettingsProxy::replaceInList( + const MTP::ProxyData &was, + MTP::ProxyData value) { + const auto i = ranges::find(_list, was); + if (i == end(_list)) { + return false; + } + *i = std::move(value); + return true; +} + +int SettingsProxy::indexInList(const MTP::ProxyData &value) const { + const auto i = ranges::find(_list, value); + return (i == end(_list)) ? -1 : int(i - begin(_list)); +} + rpl::producer<> SettingsProxy::connectionTypeValue() const { return _connectionTypeChanges.events_starting_with({}); } diff --git a/Telegram/SourceFiles/core/core_settings_proxy.h b/Telegram/SourceFiles/core/core_settings_proxy.h index 340dd050d8..1f2ec5c516 100644 --- a/Telegram/SourceFiles/core/core_settings_proxy.h +++ b/Telegram/SourceFiles/core/core_settings_proxy.h @@ -9,10 +9,21 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "mtproto/mtproto_proxy_data.h" +#include + namespace Core { class SettingsProxy final { public: + static constexpr auto kProxyRotationTimeouts = std::array{ + 5, + 10, + 15, + 30, + 60, + }; + static constexpr auto kDefaultProxyRotationTimeout = 10; + SettingsProxy(); [[nodiscard]] bool isEnabled() const; @@ -29,6 +40,12 @@ public: [[nodiscard]] bool useProxyForCalls() const; void setUseProxyForCalls(bool value); + [[nodiscard]] bool proxyRotationEnabled() const; + void setProxyRotationEnabled(bool value); + + [[nodiscard]] int proxyRotationTimeout() const; + void setProxyRotationTimeout(int value); + [[nodiscard]] MTP::ProxyData::Settings settings() const; void setSettings(MTP::ProxyData::Settings value); @@ -38,8 +55,20 @@ public: [[nodiscard]] bool checkIpWarningShown() const; void setCheckIpWarningShown(bool value); + [[nodiscard]] const std::vector &proxyRotationPreferredIndices() const; + void setProxyRotationPreferredIndices(std::vector value); + [[nodiscard]] bool promoteProxyRotationPreferredIndex(int index); + [[nodiscard]] const std::vector &list() const; [[nodiscard]] std::vector &list(); + void setList(std::vector value); + void addToList(MTP::ProxyData value); + void insertToList(int index, MTP::ProxyData value); + [[nodiscard]] bool removeFromList(const MTP::ProxyData &value); + [[nodiscard]] bool replaceInList( + const MTP::ProxyData &was, + MTP::ProxyData value); + [[nodiscard]] int indexInList(const MTP::ProxyData &value) const; [[nodiscard]] QByteArray serialize() const; bool setFromSerialized(const QByteArray &serialized); @@ -47,14 +76,16 @@ public: private: bool _tryIPv6 = false; bool _useProxyForCalls = false; + bool _proxyRotationEnabled = false; bool _checkIpWarningShown = false; + int _proxyRotationTimeout = kDefaultProxyRotationTimeout; MTP::ProxyData::Settings _settings = MTP::ProxyData::Settings::System; MTP::ProxyData _selected; std::vector _list; + std::vector _proxyRotationPreferredIndices; rpl::event_stream<> _connectionTypeChanges; }; } // namespace Core - diff --git a/Telegram/SourceFiles/core/proxy_rotation_manager.cpp b/Telegram/SourceFiles/core/proxy_rotation_manager.cpp new file mode 100644 index 0000000000..a643f0aee6 --- /dev/null +++ b/Telegram/SourceFiles/core/proxy_rotation_manager.cpp @@ -0,0 +1,359 @@ +/* +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 "core/proxy_rotation_manager.h" + +#include "core/application.h" +#include "core/core_settings.h" +#include "main/main_account.h" +#include "main/main_domain.h" +#include "mtproto/facade.h" + +#include + +namespace Core { +namespace { + +constexpr auto kProxyRotationCheckInterval = 2 * crl::time(1000); +constexpr auto kProxyRotationCheckLifetime = 20 * crl::time(1000); +constexpr auto kProxyRotationMaxActiveChecks = 10; + +} // namespace + +ProxyRotationManager::ProxyRotationManager() +: _checkTimer([=] { runChecks(); }) +, _switchTimer([=] { switchTimerDone(); }) { + App().domain().accountsChanges( + ) | rpl::on_next([=] { + stopChecking(); + reevaluate(); + }, _lifetime); +} + +void ProxyRotationManager::settingsChanged() { + stopChecking(); + pruneRemovedEntries(); + reevaluate(); +} + +void ProxyRotationManager::handleConnectionStateChanged( + not_null account, + int32 state) { + (void)account; + (void)state; + reevaluate(); +} + +bool ProxyRotationManager::shouldObserve() const { + const auto &settings = App().settings().proxy(); + return settings.isEnabled() + && settings.selected() + && settings.proxyRotationEnabled() + && (settings.list().size() > 1); +} + +std::vector> ProxyRotationManager::productionAccounts() const { + auto result = std::vector>(); + for (const auto &entry : App().domain().accounts()) { + const auto account = entry.account.get(); + if (!account->sessionExists() || account->mtp().isTestMode()) { + continue; + } + result.push_back(account); + } + return result; +} + +not_null ProxyRotationManager::accountForChecks() const { + if (App().someSessionExists() + && App().activeAccount().sessionExists() + && !App().activeAccount().mtp().isTestMode()) { + return &App().activeAccount(); + } + const auto accounts = productionAccounts(); + Expects(!accounts.empty()); + return accounts.front(); +} + +auto ProxyRotationManager::find( + const MTP::ProxyData &proxy) -> Entry* { + const auto i = ranges::find( + _entries, + proxy, + [](const Entry &entry) { return entry.proxy; }); + return (i == end(_entries)) ? nullptr : &*i; +} + +auto ProxyRotationManager::ensure( + const MTP::ProxyData &proxy) -> Entry& { + if (const auto entry = find(proxy)) { + return *entry; + } + _entries.push_back({ .proxy = proxy }); + return _entries.back(); +} + +void ProxyRotationManager::reevaluate() { + if (!shouldObserve()) { + stopChecking(); + return; + } + const auto accounts = productionAccounts(); + if (accounts.empty()) { + stopChecking(); + return; + } + const auto stateProj = [](not_null account) { + return account->mtp().dcstate(); + }; + if (ranges::contains(accounts, MTP::ConnectedState, stateProj)) { + stopChecking(); + return; + } + startChecking(); +} + +void ProxyRotationManager::startChecking() { + if (_checking) { + return; + } + _checking = true; + _waitingToSwitch = false; + _switchStartedAt = crl::now(); + updateProbeOrder(); + runChecks(); + const auto timeout = App().settings().proxy().proxyRotationTimeout(); + _switchTimer.callOnce(timeout * crl::time(1000)); +} + +void ProxyRotationManager::stopChecking() { + _checkTimer.cancel(); + _switchTimer.cancel(); + _checking = false; + _waitingToSwitch = false; + _switchStartedAt = 0; + _probeOrder.clear(); + _nextCheckIndex = 0; + clearPendingChecks(); +} + +void ProxyRotationManager::pruneRemovedEntries() { + const auto &settings = App().settings().proxy(); + _entries.erase( + std::remove_if(begin(_entries), end(_entries), [&](const Entry &entry) { + return (settings.indexInList(entry.proxy) < 0); + }), + end(_entries)); +} + +void ProxyRotationManager::updateProbeOrder() { + const auto &settings = App().settings().proxy(); + const auto currentIndex = settings.indexInList(settings.selected()); + _probeOrder.clear(); + _probeOrder.reserve(settings.list().size()); + for (const auto index : settings.proxyRotationPreferredIndices()) { + if (index == currentIndex) { + continue; + } + _probeOrder.push_back(index); + } + for (auto i = 0, count = int(settings.list().size()); i != count; ++i) { + if (i == currentIndex || ranges::contains(_probeOrder, i)) { + continue; + } + _probeOrder.push_back(i); + } + _nextCheckIndex = 0; +} + +void ProxyRotationManager::continueChecking(crl::time delay) { + if (!_checking) { + return; + } + if (_checkTimer.isActive()) { + _checkTimer.cancel(); + } + _checkTimer.callOnce(delay); +} + +void ProxyRotationManager::runChecks() { + if (!_checking) { + return; + } + if (!shouldObserve()) { + stopChecking(); + return; + } + const auto accounts = productionAccounts(); + if (accounts.empty() + || ranges::contains( + accounts, + MTP::ConnectedState, + [](not_null account) { + return account->mtp().dcstate(); + })) { + stopChecking(); + return; + } + pruneExpiredChecks(); + startNextCheck(); + continueChecking(kProxyRotationCheckInterval); +} + +void ProxyRotationManager::pruneExpiredChecks() { + const auto now = crl::now(); + for (auto &entry : _entries) { + if (!entry.checking + || (now - entry.startedAt < kProxyRotationCheckLifetime)) { + continue; + } + MTP::ResetProxyCheckers(entry.v4, entry.v6); + entry.checking = false; + entry.startedAt = 0; + } +} + +void ProxyRotationManager::startNextCheck() { + if (_probeOrder.empty()) { + return; + } + if (ranges::count(_entries, true, &Entry::checking) + >= kProxyRotationMaxActiveChecks) { + return; + } + const auto &settings = App().settings().proxy(); + auto attemptsLeft = int(_probeOrder.size()); + while (attemptsLeft-- > 0) { + if (_nextCheckIndex >= int(_probeOrder.size())) { + _nextCheckIndex = 0; + } + const auto listIndex = _probeOrder[_nextCheckIndex++]; + if (listIndex < 0 || listIndex >= int(settings.list().size())) { + continue; + } + const auto &proxy = settings.list()[listIndex]; + auto &entry = ensure(proxy); + if (entry.checking) { + continue; + } + entry.checking = true; + entry.startedAt = crl::now(); + MTP::StartProxyCheck( + &accountForChecks()->mtp(), + proxy, + settings.tryIPv6(), + entry.v4, + entry.v6, + [=](MTP::details::AbstractConnection *raw, int ping) { + checkDone(proxy, raw, ping); + }, + [=](MTP::details::AbstractConnection *raw) { + checkFailed(proxy, raw); + }); + break; + } +} + +void ProxyRotationManager::switchTimerDone() { + if (!_checking || !shouldSwitchToAvailable()) { + return; + } + _waitingToSwitch = !switchToAvailable(); +} + +void ProxyRotationManager::clearPendingChecks() { + for (auto &entry : _entries) { + MTP::ResetProxyCheckers(entry.v4, entry.v6); + entry.checking = false; + entry.startedAt = 0; + } +} + +void ProxyRotationManager::checkDone( + const MTP::ProxyData &proxy, + not_null raw, + int ping) { + const auto entry = find(proxy); + if (!entry + || !entry->checking + || ((entry->v4.get() != raw) && (entry->v6.get() != raw))) { + return; + } + MTP::DropProxyChecker(entry->v4, entry->v6, raw); + MTP::ResetProxyCheckers(entry->v4, entry->v6); + entry->checking = false; + entry->startedAt = 0; + entry->availableAt = crl::now(); + const auto proxySettings = &App().settings().proxy(); + if (const auto index = proxySettings->indexInList(proxy); index >= 0) { + if (proxySettings->promoteProxyRotationPreferredIndex(index)) { + App().saveSettingsDelayed(); + } + } + updateProbeOrder(); + if (_waitingToSwitch && shouldSwitchToAvailable()) { + _waitingToSwitch = !switchToAvailable(); + } +} + +void ProxyRotationManager::checkFailed( + const MTP::ProxyData &proxy, + not_null raw) { + const auto entry = find(proxy); + if (!entry + || !entry->checking + || ((entry->v4.get() != raw) && (entry->v6.get() != raw))) { + return; + } + MTP::DropProxyChecker(entry->v4, entry->v6, raw); + if (MTP::HasProxyCheckers(entry->v4, entry->v6)) { + return; + } + entry->checking = false; + entry->startedAt = 0; +} + +bool ProxyRotationManager::switchToAvailable() { + if (!_checking) { + return false; + } + const auto &settings = App().settings().proxy(); + for (const auto index : _probeOrder) { + if (index < 0 || index >= int(settings.list().size())) { + continue; + } + const auto &proxy = settings.list()[index]; + const auto entry = find(proxy); + if (!entry || entry->checking || !entry->availableAt) { + continue; + } + if (entry->availableAt < _switchStartedAt) { + continue; + } + _waitingToSwitch = false; + App().setCurrentProxy(proxy, MTP::ProxyData::Settings::Enabled); + App().saveSettingsDelayed(); + return true; + } + return false; +} + +bool ProxyRotationManager::shouldSwitchToAvailable() const { + if (!_checking || !shouldObserve()) { + return false; + } + const auto accounts = productionAccounts(); + return !accounts.empty() + && !ranges::contains( + accounts, + MTP::ConnectedState, + [](not_null account) { + return account->mtp().dcstate(); + }); +} + +} // namespace Core diff --git a/Telegram/SourceFiles/core/proxy_rotation_manager.h b/Telegram/SourceFiles/core/proxy_rotation_manager.h new file mode 100644 index 0000000000..c1fee27696 --- /dev/null +++ b/Telegram/SourceFiles/core/proxy_rotation_manager.h @@ -0,0 +1,80 @@ +/* +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 "base/timer.h" +#include "mtproto/proxy_check.h" + +#include +#include + +namespace Main { +class Account; +} // namespace Main + +namespace Core { + +class ProxyRotationManager final { +public: + ProxyRotationManager(); + + void settingsChanged(); + void handleConnectionStateChanged( + not_null account, + int32 state); + +private: + struct Entry { + MTP::ProxyData proxy; + MTP::ProxyCheckConnection v4; + MTP::ProxyCheckConnection v6; + bool checking = false; + crl::time startedAt = 0; + crl::time availableAt = 0; + }; + + [[nodiscard]] bool shouldObserve() const; + [[nodiscard]] std::vector> productionAccounts() const; + [[nodiscard]] not_null accountForChecks() const; + [[nodiscard]] Entry *find(const MTP::ProxyData &proxy); + [[nodiscard]] Entry &ensure(const MTP::ProxyData &proxy); + + void reevaluate(); + void startChecking(); + void stopChecking(); + void pruneRemovedEntries(); + void updateProbeOrder(); + void continueChecking(crl::time delay); + void runChecks(); + void pruneExpiredChecks(); + void startNextCheck(); + void switchTimerDone(); + void clearPendingChecks(); + void checkDone( + const MTP::ProxyData &proxy, + not_null raw, + int ping); + void checkFailed( + const MTP::ProxyData &proxy, + not_null raw); + [[nodiscard]] bool switchToAvailable(); + [[nodiscard]] bool shouldSwitchToAvailable() const; + + base::Timer _checkTimer; + base::Timer _switchTimer; + std::vector _entries; + std::vector _probeOrder; + int _nextCheckIndex = 0; + bool _checking = false; + bool _waitingToSwitch = false; + crl::time _switchStartedAt = 0; + rpl::lifetime _lifetime; + +}; + +} // namespace Core diff --git a/Telegram/SourceFiles/main/main_account.cpp b/Telegram/SourceFiles/main/main_account.cpp index 4a2fe29ea0..2db2533187 100644 --- a/Telegram/SourceFiles/main/main_account.cpp +++ b/Telegram/SourceFiles/main/main_account.cpp @@ -462,6 +462,7 @@ void Account::startMtp(std::unique_ptr config) { _mtp->setStateChangedHandler([=](MTP::ShiftedDcId dc, int32 state) { if (dc == _mtp->mainDcId()) { Core::App().settings().proxy().connectionTypeChangesNotify(); + Core::App().checkProxyRotation(this, state); } }); _mtp->setSessionResetHandler([=](MTP::ShiftedDcId shiftedDcId) { diff --git a/Telegram/SourceFiles/mtproto/proxy_check.cpp b/Telegram/SourceFiles/mtproto/proxy_check.cpp new file mode 100644 index 0000000000..831c8453cf --- /dev/null +++ b/Telegram/SourceFiles/mtproto/proxy_check.cpp @@ -0,0 +1,111 @@ +/* +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 "mtproto/proxy_check.h" + +#include "mtproto/facade.h" +#include "mtproto/mtproto_dc_options.h" + +namespace MTP { + +using Connection = details::AbstractConnection; + +void ResetProxyCheckers( + ProxyCheckConnection &v4, + ProxyCheckConnection &v6) { + v4 = nullptr; + v6 = nullptr; +} + +void DropProxyChecker( + ProxyCheckConnection &v4, + ProxyCheckConnection &v6, + not_null raw) { + if (v4.get() == raw) { + v4 = nullptr; + } else if (v6.get() == raw) { + v6 = nullptr; + } +} + +bool HasProxyCheckers( + const ProxyCheckConnection &v4, + const ProxyCheckConnection &v6) { + return v4 || v6; +} + +void StartProxyCheck( + not_null mtproto, + const ProxyData &proxy, + bool tryIPv6, + ProxyCheckConnection &v4, + ProxyCheckConnection &v6, + Fn done, + Fn fail) { + using Variants = DcOptions::Variants; + + ResetProxyCheckers(v4, v6); + const auto connType = (proxy.type == ProxyData::Type::Http) + ? Variants::Http + : Variants::Tcp; + const auto dcId = mtproto->mainDcId(); + const auto setup = [&](ProxyCheckConnection &checker, const bytes::vector &secret) { + checker = Connection::Create( + mtproto, + connType, + QThread::currentThread(), + secret, + proxy); + const auto raw = checker.get(); + raw->connect(raw, &Connection::connected, [=] { + if (done) { + done(raw, raw->pingTime()); + } + }); + const auto failed = [=] { + if (fail) { + fail(raw); + } + }; + raw->connect(raw, &Connection::disconnected, failed); + raw->connect(raw, &Connection::error, failed); + }; + if (proxy.type == ProxyData::Type::Mtproto) { + const auto secret = proxy.secretFromMtprotoPassword(); + setup(v4, secret); + v4->connectToServer( + proxy.host, + proxy.port, + secret, + dcId, + false); + return; + } + const auto options = mtproto->dcOptions().lookup( + dcId, + DcType::Regular, + true); + const auto tryConnect = [&](ProxyCheckConnection &checker, Variants::Address address) { + const auto &list = options.data[address][connType]; + if (list.empty() || ((address == Variants::IPv6) && !tryIPv6)) { + checker = nullptr; + return; + } + const auto &endpoint = list.front(); + setup(checker, endpoint.secret); + checker->connectToServer( + QString::fromStdString(endpoint.ip), + endpoint.port, + endpoint.secret, + dcId, + false); + }; + tryConnect(v4, Variants::IPv4); + tryConnect(v6, Variants::IPv6); +} + +} // namespace MTP diff --git a/Telegram/SourceFiles/mtproto/proxy_check.h b/Telegram/SourceFiles/mtproto/proxy_check.h new file mode 100644 index 0000000000..1cbfb15fd2 --- /dev/null +++ b/Telegram/SourceFiles/mtproto/proxy_check.h @@ -0,0 +1,35 @@ +/* +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 "mtproto/connection_abstract.h" + +namespace MTP { + +using ProxyCheckConnection = details::ConnectionPointer; + +void ResetProxyCheckers( + ProxyCheckConnection &v4, + ProxyCheckConnection &v6); +void DropProxyChecker( + ProxyCheckConnection &v4, + ProxyCheckConnection &v6, + not_null raw); +[[nodiscard]] bool HasProxyCheckers( + const ProxyCheckConnection &v4, + const ProxyCheckConnection &v6); +void StartProxyCheck( + not_null mtproto, + const ProxyData &proxy, + bool tryIPv6, + ProxyCheckConnection &v4, + ProxyCheckConnection &v6, + Fn done, + Fn fail); + +} // namespace MTP diff --git a/Telegram/SourceFiles/storage/details/storage_settings_scheme.cpp b/Telegram/SourceFiles/storage/details/storage_settings_scheme.cpp index 051436ad47..1d6796c20c 100644 --- a/Telegram/SourceFiles/storage/details/storage_settings_scheme.cpp +++ b/Telegram/SourceFiles/storage/details/storage_settings_scheme.cpp @@ -487,9 +487,9 @@ bool ReadSetting( proxySettings.setSettings(proxy ? MTP::ProxyData::Settings::Enabled : MTP::ProxyData::Settings::System); - proxySettings.list() = proxy - ? std::vector{ 1, proxy } - : std::vector{}; + proxySettings.setList(proxy + ? std::vector{ proxy } + : std::vector{}); Core::App().refreshGlobalProxy(); context.legacyRead = true; } break; @@ -562,16 +562,16 @@ bool ReadSetting( if (!CheckStreamStatus(stream)) { return false; } - proxySettings.list() = list; + proxySettings.setList(std::move(list)); if (connectionType == dbictProxiesListOld) { settings = static_cast( - (index > 0 && index <= list.size() + (index > 0 && index <= proxySettings.list().size() ? MTP::ProxyData::Settings::Enabled : MTP::ProxyData::Settings::System)); index = std::abs(index); } - proxySettings.setSelected((index > 0 && index <= list.size()) - ? list[index - 1] + proxySettings.setSelected((index > 0 && index <= proxySettings.list().size()) + ? proxySettings.list()[index - 1] : MTP::ProxyData()); const auto unchecked = static_cast(settings); @@ -596,14 +596,14 @@ bool ReadSetting( return false; } if (proxy) { - proxySettings.list() = { 1, proxy }; + proxySettings.setList({ proxy }); proxySettings.setSelected(proxy); proxySettings.setSettings((connectionType == dbictTcpProxy || connectionType == dbictHttpProxy) ? MTP::ProxyData::Settings::Enabled : MTP::ProxyData::Settings::System); } else { - proxySettings.list() = {}; + proxySettings.setList({}); proxySettings.setSelected(MTP::ProxyData()); proxySettings.setSettings(MTP::ProxyData::Settings::System); } From 6cc58e3701216044890bb2059d2597afc9c5de66 Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Thu, 16 Apr 2026 12:48:59 +0000 Subject: [PATCH 09/78] Use git -C with xargs --- Telegram/build/docker/centos_env/Dockerfile | 7 ++----- Telegram/build/prepare/prepare.py | 11 ++++------- snap/snapcraft.yaml | 7 ++----- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/Telegram/build/docker/centos_env/Dockerfile b/Telegram/build/docker/centos_env/Dockerfile index 83053377f9..69f05ce7a5 100644 --- a/Telegram/build/docker/centos_env/Dockerfile +++ b/Telegram/build/docker/centos_env/Dockerfile @@ -747,11 +747,8 @@ ENV QT=6.11.0 RUN git clone -b v$QT --depth=1 https://github.com/qt/qt5.git \ && cd qt5 \ && git submodule update --init --recursive --depth=1 qtbase qtdeclarative qtwayland qtimageformats qtsvg qtshadertools \ - && cd qtbase \ - && find ../../patches/qtbase_$QT -type f -print0 | sort -z | xargs -r0 git apply \ - && cd ../qtwayland \ - && find ../../patches/qtwayland_$QT -type f -print0 | sort -z | xargs -r0 git apply \ - && cd .. \ + && find $PWD/../patches/qtbase_$QT -type f -print0 | sort -z | xargs -r0 git -C qtbase apply \ + && find $PWD/../patches/qtwayland_$QT -type f -print0 | sort -z | xargs -r0 git -C qtwayland apply \ && cmake -B build . \ -DCMAKE_INSTALL_PREFIX=/usr/local \ -DBUILD_SHARED_LIBS=OFF \ diff --git a/Telegram/build/prepare/prepare.py b/Telegram/build/prepare/prepare.py index f1810e80c1..d32ba029bd 100644 --- a/Telegram/build/prepare/prepare.py +++ b/Telegram/build/prepare/prepare.py @@ -1495,8 +1495,8 @@ release: cd qt_$QT git submodule update --init --recursive --progress qtbase qtimageformats qtsvg depends:patches/qtbase_""" + qt + """/*.patch - cd qtbase win: + cd qtbase setlocal enabledelayedexpansion for /r %%i in (..\\..\\patches\\qtbase_%QT%\\*) do ( git apply %%i -v @@ -1505,7 +1505,6 @@ win: exit /b 1 ) ) - cd .. SET CONFIGURATIONS=-debug @@ -1555,8 +1554,7 @@ win: jom -j%NUMBER_OF_PROCESSORS% jom -j%NUMBER_OF_PROCESSORS% install mac: - find ../../patches/qtbase_$QT -type f -print0 | sort -z | xargs -0 git apply - cd .. + find ../../patches/qtbase_$QT -type f -print0 | sort -z | xargs -0 git -C qtbase apply CONFIGURATIONS=-debug release: @@ -1588,10 +1586,8 @@ else: # qt > '6' cd qt_$QT git submodule update --init --recursive --progress qtbase qtimageformats qtsvg depends:patches/qtbase_""" + qt + """/*.patch - cd qtbase mac: - find ../../patches/qtbase_$QT -type f -print0 | sort -z | xargs -0 git apply -v - cd .. + find $PWD/../patches/qtbase_$QT -type f -print0 | sort -z | xargs -0 git -C qtbase apply -v sed -i.bak 's/tqtc-//' {qtimageformats,qtsvg}/dependencies.yaml CONFIGURATIONS=-debug @@ -1618,6 +1614,7 @@ mac: cmake --build . cmake --install . win: + cd qtbase for /r %%i in (..\\..\\patches\\qtbase_%QT%\\*) do git apply %%i -v cd .. diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 90422fd6ef..aae2b552f4 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -411,11 +411,8 @@ parts: override-pull: | craftctl default QT="$(grep 'set(QT_REPO_MODULE_VERSION' qtbase/.cmake.conf | sed -r 's/.*"(.*)".*/\1/')" - cd qtbase - find $CRAFT_STAGE/patches/qtbase_${QT} -type f -print0 | sort -z | xargs -r0 git apply - cd ../qtwayland - find $CRAFT_STAGE/patches/qtwayland_${QT} -type f -print0 | sort -z | xargs -r0 git apply - cd .. + find $CRAFT_STAGE/patches/qtbase_${QT} -type f -print0 | sort -z | xargs -r0 git -C qtbase apply + find $CRAFT_STAGE/patches/qtwayland_${QT} -type f -print0 | sort -z | xargs -r0 git -C qtwayland apply prime: [-./*] after: - mozjpeg From aae5631f1954477f613701954b94f77eecae8d00 Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 17 Apr 2026 00:23:22 +0700 Subject: [PATCH 10/78] Fix bio cut-off in calls peer info. --- Telegram/SourceFiles/calls/calls.style | 1 - .../calls/group/calls_cover_item.cpp | 23 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Telegram/SourceFiles/calls/calls.style b/Telegram/SourceFiles/calls/calls.style index 2b76011fdc..b15fbd6167 100644 --- a/Telegram/SourceFiles/calls/calls.style +++ b/Telegram/SourceFiles/calls/calls.style @@ -631,7 +631,6 @@ groupCallMenuAbout: FlatLabel(defaultFlatLabel) { textFg: groupCallMemberNotJoinedStatus; palette: groupCallTextPalette; minWidth: 200px; - maxHeight: 92px; } callDeviceSelectionLabel: FlatLabel(defaultSubsectionTitle) { textFg: groupCallActiveFg; diff --git a/Telegram/SourceFiles/calls/group/calls_cover_item.cpp b/Telegram/SourceFiles/calls/group/calls_cover_item.cpp index 4d8ddbb9ab..8f8a44a292 100644 --- a/Telegram/SourceFiles/calls/group/calls_cover_item.cpp +++ b/Telegram/SourceFiles/calls/group/calls_cover_item.cpp @@ -66,13 +66,28 @@ AboutItem::AboutItem( , _dummyAction(new QAction(parent)) { setPointerCursor(false); + _text->setSelectable(true); + + const auto added = st.itemPadding.left() + st.itemPadding.right(); + + sizeValue( + ) | rpl::on_next([=](const QSize &s) { + if (s.width() <= added) { + return; + } + _text->resizeToWidth(s.width() - added); + _text->moveToLeft(st.itemPadding.left(), st.itemPadding.top()); + }, lifetime()); + + _text->heightValue( + ) | rpl::on_next([=] { + resize(width(), contentHeight()); + }, lifetime()); + + _text->resizeToWidth(parent->width() - added); fitToMenuWidth(); enableMouseSelecting(); enableMouseSelecting(_text.get()); - - _text->setSelectable(true); - _text->resizeToWidth(st::groupCallMenuAbout.minWidth); - _text->moveToLeft(st.itemPadding.left(), st.itemPadding.top()); } not_null AboutItem::action() const { From 66b38ccb5fe73f5f1d021cc559f86e60dade4f90 Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 16 Apr 2026 23:26:47 +0700 Subject: [PATCH 11/78] Pause animations only if video message isn't paused. --- .../media/player/media_player_instance.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Telegram/SourceFiles/media/player/media_player_instance.cpp b/Telegram/SourceFiles/media/player/media_player_instance.cpp index 7aa0f9036c..9cb6df004c 100644 --- a/Telegram/SourceFiles/media/player/media_player_instance.cpp +++ b/Telegram/SourceFiles/media/player/media_player_instance.cpp @@ -1282,6 +1282,15 @@ void Instance::emitUpdate(AudioMsgId::Type type, CheckCallback check) { } } updatePowerSaveBlocker(data, state); + const auto activelyPlayingRound = (type == AudioMsgId::Type::Voice) + && !IsPausedOrPausing(state.state) + && !IsStoppedOrStopping(state.state) + && data->current.audio() + && data->current.audio()->isVideoMessage(); + if (_roundPlaying != activelyPlayingRound) { + _roundPlaying = activelyPlayingRound; + Core::App().floatPlayerToggleGifsPaused(activelyPlayingRound); + } if (type == AudioMsgId::Type::Song) { _listenTracker->update(state); } @@ -1357,8 +1366,6 @@ void Instance::handleStreamingUpdate( float64) { requestRoundVideoRepaint(); }); - _roundPlaying = true; - Core::App().floatPlayerToggleGifsPaused(true); requestRoundVideoResize(); } emitUpdate(data->type); From 5eed8136ddb86cff43e795fdb874b66949bf366b Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 17 Apr 2026 10:53:20 +0700 Subject: [PATCH 12/78] Fix layout for RTL messages. --- Telegram/SourceFiles/history/view/history_view_message.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Telegram/SourceFiles/history/view/history_view_message.cpp b/Telegram/SourceFiles/history/view/history_view_message.cpp index f975a839c0..5dadd24ea2 100644 --- a/Telegram/SourceFiles/history/view/history_view_message.cpp +++ b/Telegram/SourceFiles/history/view/history_view_message.cpp @@ -2321,11 +2321,10 @@ void Message::paintText( }); } - const auto realWidth = textRealWidth(); auto highlightRequest = context.computeHighlightCache(); text().draw(p, { .position = trect.topLeft(), - .availableWidth = realWidth ? realWidth : trect.width(), + .availableWidth = std::max(textRealWidth(), trect.width()), .palette = &stm->textPalette, .pre = stm->preCache.get(), .blockquote = context.quoteCache( @@ -3644,7 +3643,7 @@ bool Message::getStateText( if (base::in_range(point.y(), trect.y(), trect.y() + trect.height())) { *outResult = TextState(item, text().getState( point - trect.topLeft(), - trect.width(), + std::max(textRealWidth(), trect.width()), request.forText())); if (outResult->link && IsRippleLink(outResult->link) From 4319245dd3d300b1c1d512700b035e625d1fb2ba Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 17 Apr 2026 12:29:40 +0700 Subject: [PATCH 13/78] Allow seeking in video messages. --- .../history/view/media/history_view_gif.cpp | 219 +++++++++++++++--- .../history/view/media/history_view_gif.h | 14 ++ Telegram/SourceFiles/ui/chat/chat.style | 2 + 3 files changed, 204 insertions(+), 31 deletions(-) diff --git a/Telegram/SourceFiles/history/view/media/history_view_gif.cpp b/Telegram/SourceFiles/history/view/media/history_view_gif.cpp index 86ad412de2..00ca1fb633 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_gif.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_gif.cpp @@ -61,6 +61,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_chat.h" #include +#include namespace HistoryView { namespace { @@ -68,6 +69,8 @@ namespace { constexpr auto kMaxGifForwardedBarLines = 4; constexpr auto kUseNonBlurredThreshold = 240; constexpr auto kMaxInlineArea = 1920 * 1080; +constexpr auto kSeekAnimationDuration = crl::time(200); +constexpr auto kSeekTrackOpacity = 0.2; [[nodiscard]] int GifMaxStatusWidth(not_null document) { auto result = st::normalFont->width( @@ -197,6 +200,12 @@ Gif::Gif( setStatusSize(Ui::FileStatusSizeReady); + if (_data->isVideoMessage() && !_parent->data()->media()->ttlSeconds()) { + _seekl = std::make_shared( + _data, + [](FullMsgId) {}); + } + if (_spoiler) { createSpoilerLink(_spoiler.get()); } @@ -539,6 +548,9 @@ void Gif::draw(Painter &p, const PaintContext &context) const { == PaintContext::SkipDrawingParts::Content; const auto drawStreamed = streamed && (shouldBePlaying || !_videoCover); if (drawStreamed && !skipDrawingContent && !fullHiddenBySpoiler) { + if (!_seekLastFrame.isNull()) { + _seekLastFrame = QImage(); + } auto paused = context.paused || !shouldBePlaying; auto request = ::Media::Streaming::FrameRequest{ .outer = QSize(usew, painth) * style::DevicePixelRatio(), @@ -574,39 +586,25 @@ void Gif::draw(Painter &p, const PaintContext &context) const { const auto frame = streamed->frameWithInfo(request); p.drawImage(rthumb, frame.image); + if (_seeking) { + _seekLastFrame = frame.image; + } if (!paused) { streamed->markFrameShown(); } } - - if (const auto playback = videoPlayback()) { - const auto value = playback->value(); - if (value > 0.) { - auto pen = st->historyVideoMessageProgressFg()->p; - const auto was = p.pen(); - pen.setWidth(st::radialLine); - pen.setCapStyle(Qt::RoundCap); - p.setPen(pen); - p.setOpacity(st::historyVideoMessageProgressOpacity); - - const auto from = arc::kQuarterLength; - const auto len = std::round(arc::kFullLength - * (inTTLViewer ? (1. - value) : -value)); - const auto stepInside = st::radialLine / 2; - { - auto hq = PainterHighQualityEnabler(p); - p.drawArc(rthumb - Margins(stepInside), from, len); - } - - p.setPen(was); - p.setOpacity(1.); - } - } + } else if (!_seekLastFrame.isNull() + && !skipDrawingContent + && !fullHiddenBySpoiler) { + p.drawImage(rthumb, _seekLastFrame); } else if (!skipDrawingContent && !fullHiddenBySpoiler) { ensureDataMediaCreated(); validateThumbCache({ usew, painth }, isRound, rounding); p.drawImage(rthumb, _thumbCache); } + if (isRound) { + paintRoundPlaybackProgress(p, context, rthumb, inTTLViewer); + } if (!isRound) { paintTimestampMark(p, rthumb, rounding); } @@ -736,7 +734,11 @@ void Gif::draw(Painter &p, const PaintContext &context) const { } else if (!skipDrawingSurrounding) { if (isRound) { const auto mediaUnread = item->hasUnreadMediaFlag(); - auto statusW = st::normalFont->width(_statusText) + 2 * st::msgDateImgPadding.x(); + const auto statusText = _seeking + ? Ui::FormatDurationText(1 + int64(base::SafeRound( + (1. - _seekingCurrent) * _data->duration() / 1000.))) + : _statusText; + auto statusW = st::normalFont->width(statusText) + 2 * st::msgDateImgPadding.x(); auto statusH = st::normalFont->height + 2 * st::msgDateImgPadding.y(); auto statusX = usex + paintx + st::msgDateImgDelta + st::msgDateImgPadding.x(); auto statusY = painty + painth - st::msgDateImgDelta - statusH + st::msgDateImgPadding.y(); @@ -746,7 +748,7 @@ void Gif::draw(Painter &p, const PaintContext &context) const { Ui::FillRoundRect(p, style::rtlrect(statusX - st::msgDateImgPadding.x(), statusY - st::msgDateImgPadding.y(), statusW, statusH, width()), sti->msgServiceBg, sti->msgServiceBgCornersSmall); p.setFont(st::normalFont); p.setPen(st->msgServiceFg()); - p.drawTextLeft(statusX, statusY, width(), _statusText, statusW - 2 * st::msgDateImgPadding.x()); + p.drawTextLeft(statusX, statusY, width(), statusText, statusW - 2 * st::msgDateImgPadding.x()); if (mediaUnread) { p.setPen(Qt::NoPen); p.setBrush(st->msgServiceFg()); @@ -959,6 +961,62 @@ void Gif::paintTimestampMark( p.restore(); } +void Gif::paintRoundPlaybackProgress( + Painter &p, + const PaintContext &context, + QRect rthumb, + bool inTTLViewer) const { + const auto st = context.st; + const auto playback = videoPlayback(); + const auto seekAmount = _seekAnimation.value(_seeking ? 1. : 0.); + const auto value = _seeking + ? _seekingCurrent + : playback + ? playback->value() + : (seekAmount > 0.) + ? _seekingCurrent + : 0.; + if (value <= 0. && seekAmount <= 0.) { + return; + } + auto pen = st->historyVideoMessageProgressFg()->p; + const auto was = p.pen(); + pen.setWidth(st::radialLine); + pen.setCapStyle(Qt::RoundCap); + p.setPen(pen); + + const auto from = arc::kQuarterLength; + const auto normalInset = 1.5 * st::radialLine; + const auto seekInset = st::historyVideoMessageSeekInset; + const auto stepInside = normalInset + + (seekInset - normalInset) * seekAmount; + const auto arcRect = QRectF(rthumb) - Margins(stepInside); + auto hq = PainterHighQualityEnabler(p); + if (seekAmount > 0.) { + p.setOpacity(kSeekTrackOpacity * seekAmount); + p.drawArc(arcRect, 0, arc::kFullLength); + } + p.setOpacity(st::historyVideoMessageProgressOpacity); + const auto len = std::round(arc::kFullLength + * (inTTLViewer ? (1. - value) : -value)); + p.drawArc(arcRect, from, len); + if (seekAmount > 0.) { + const auto dotSize = float64(st::historyVideoMessageSeekDotSize); + const auto angle = M_PI / 2. - value * 2. * M_PI; + const auto radius = arcRect.width() / 2.; + const auto center = arcRect.center(); + const auto cx = center.x() + radius * cos(angle); + const auto cy = center.y() - radius * sin(angle); + p.setOpacity(seekAmount); + p.setPen(Qt::NoPen); + p.setBrush(st->historyVideoMessageProgressFg()); + p.drawEllipse(QPointF(cx, cy), dotSize / 2., dotSize / 2.); + } + p.setBrush(Qt::NoBrush); + p.setPen(was); + p.setOpacity(1.); +} + void Gif::drawSpoilerTag( Painter &p, QRect rthumb, @@ -1289,13 +1347,17 @@ TextState Gif::textState(QPoint point, StateRequest request) const { } if (QRect(usex + paintx, painty, usew, painth).contains(point)) { ensureDataMediaCreated(); - result.link = (_spoiler && !_spoiler->revealed) - ? (_sensitiveSpoiler + if (_spoiler && !_spoiler->revealed) { + result.link = _sensitiveSpoiler ? spoilerTagLink() : (isRound && _parent->data()->media()->ttlSeconds()) - ? _openl // Overriden. - : _spoiler->link) - : currentVideoLink(); + ? _openl + : _spoiler->link; + } else if (_seekl && isRoundSeekable()) { + result.link = _seekl; + } else { + result.link = currentVideoLink(); + } } const auto checkBottomInfo = !inWebPage && (unwrapped || !bubble || isBubbleBottom()); @@ -1362,6 +1424,32 @@ TextState Gif::textState(QPoint point, StateRequest request) const { void Gif::clickHandlerPressedChanged( const ClickHandlerPtr &handler, bool pressed) { + if (_seekl && handler == _seekl) { + if (pressed && !_seeking) { + _seekPressPoint = QPoint(-1, -1); + if (const auto playback = videoPlayback()) { + _seekingCurrent = playback->value(); + } + } else if (!pressed) { + if (_seeking) { + if (isRoundSeekable()) { + ::Media::Player::instance()->finishSeeking( + AudioMsgId::Type::Voice, + _seekingCurrent); + } + _seeking = false; + _seekAnimation.start( + [=] { repaint(); }, + 1., + 0., + kSeekAnimationDuration); + } else if (_seekPressPoint != QPoint()) { + _seekPressPoint = QPoint(); + ::Media::Player::instance()->playPauseCancelClicked( + AudioMsgId::Type::Voice); + } + } + } File::clickHandlerPressedChanged(handler, pressed); if (!handler) { return; @@ -1374,6 +1462,61 @@ void Gif::clickHandlerPressedChanged( } } +void Gif::updatePressed(QPoint point) { + if (!_seeking && _seekPressPoint == QPoint()) { + return; + } + const auto item = _parent->data(); + auto paintx = 0, painty = 0, paintw = width(), painth = height(); + const auto unwrapped = isUnwrapped(); + auto usew = paintw, usex = 0; + const auto via = unwrapped ? item->Get() : nullptr; + const auto reply = unwrapped ? _parent->Get() : nullptr; + const auto forwarded = unwrapped + ? item->Get() + : nullptr; + if (via || reply || forwarded) { + usew = maxWidth() - additionalWidth(reply, via, forwarded); + if (unwrapped && _parent->hasRightLayout()) { + usex = width() - usew; + } + } + accumulate_min(usew, painth); + if (rtl()) usex = width() - usex - usew; + const auto rthumb = QRect( + style::rtlrect(usex + paintx, painty, usew, painth, width())); + + if (!_seeking) { + if (_seekPressPoint == QPoint(-1, -1)) { + _seekPressPoint = point; + return; + } + if ((point - _seekPressPoint).manhattanLength() + <= QApplication::startDragDistance()) { + return; + } + _seeking = true; + _seekPressPoint = QPoint(); + ::Media::Player::instance()->startSeeking( + AudioMsgId::Type::Voice); + _seekAnimation.start( + [=] { repaint(); }, + 0., + 1., + kSeekAnimationDuration); + } + + const auto center = rthumb.center(); + const auto dx = float64(point.x() - center.x()); + const auto dy = float64(point.y() - center.y()); + const auto angle = atan2(-dy, dx); + _seekingCurrent = std::clamp( + fmod((M_PI / 2. - angle) / (2. * M_PI) + 1., 1.), + 0., + 1.); + repaint(); +} + bool Gif::fullFeaturedGrouped(RectParts sides) const { return (sides & RectPart::Left) && (sides & RectPart::Right); } @@ -1941,6 +2084,7 @@ void Gif::unloadHeavyPart() { _spoiler->animation = nullptr; } _thumbCache = QImage(); + _seekLastFrame = QImage(); _videoThumbnailFrame = nullptr; togglePollingStory(false); } @@ -1969,6 +2113,19 @@ int Gif::additionalWidth( return ::Media::Player::instance()->roundVideoStreamed(_parent->data()); } +bool Gif::isRoundSeekable() const { + if (!activeRoundStreamed()) { + return false; + } + const auto state = ::Media::Player::instance()->getState( + AudioMsgId::Type::Voice); + return (state.id == AudioMsgId( + _data, + _realParent->fullId(), + state.id.externalPlayId())) + && !::Media::Player::IsStoppedOrStopping(state.state); +} + Gif::Streamed *Gif::activeOwnStreamed() const { return (_streamed && _streamed->instance.player().ready() diff --git a/Telegram/SourceFiles/history/view/media/history_view_gif.h b/Telegram/SourceFiles/history/view/media/history_view_gif.h index f663d00d24..0e85b3fcd4 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_gif.h +++ b/Telegram/SourceFiles/history/view/media/history_view_gif.h @@ -16,6 +16,7 @@ struct HistoryMessageReply; struct HistoryMessageForwarded; class Painter; class PhotoData; +class VoiceSeekClickHandler; namespace Data { class DocumentMedia; @@ -65,6 +66,7 @@ public: void clickHandlerPressedChanged( const ClickHandlerPtr &p, bool pressed) override; + void updatePressed(QPoint point) override; bool uploading() const override; @@ -151,6 +153,7 @@ private: Streamed *activeOwnStreamed() const; ::Media::Streaming::Instance *activeCurrentStreamed() const; ::Media::View::PlaybackProgress *videoPlayback() const; + bool isRoundSeekable() const; void createStreamedPlayer(); void checkStreamedIsStarted() const; @@ -172,6 +175,11 @@ private: Painter &p, QRect rthumb, std::optional rounding) const; + void paintRoundPlaybackProgress( + Painter &p, + const PaintContext &context, + QRect rthumb, + bool inTTLViewer) const; [[nodiscard]] bool needInfoDisplay() const; [[nodiscard]] bool needCornerStatusDisplay() const; @@ -231,12 +239,18 @@ private: mutable QImage _thumbCache; mutable QImage _roundingMask; mutable crl::time _videoPosition = 0; + std::shared_ptr _seekl; + mutable Ui::Animations::Simple _seekAnimation; + float64 _seekingCurrent = 0.; + QPoint _seekPressPoint; + mutable QImage _seekLastFrame; mutable TimeId _videoTimestamp = 0; mutable std::optional _thumbCacheRounding; mutable bool _thumbCacheBlurred : 1 = false; mutable bool _thumbIsEllipse : 1 = false; mutable bool _pollingStory : 1 = false; mutable bool _purchasedPriceTag : 1 = false; + mutable bool _seeking : 1 = false; mutable bool _smallGroupPart : 1 = false; const bool _sensitiveSpoiler : 1 = false; const bool _hasVideoCover : 1 = false; diff --git a/Telegram/SourceFiles/ui/chat/chat.style b/Telegram/SourceFiles/ui/chat/chat.style index 56fe6cbc44..724aca8104 100644 --- a/Telegram/SourceFiles/ui/chat/chat.style +++ b/Telegram/SourceFiles/ui/chat/chat.style @@ -595,6 +595,8 @@ historyVideoMessageMute: icon {{ "volume_mute", historyFileThumbIconFg }}; historyVideoMessageMuteSelected: icon {{ "volume_mute", historyFileThumbIconFgSelected }}; historyVideoMessageMuteSize: 25px; historyVideoMessageProgressOpacity: 0.72; +historyVideoMessageSeekInset: 12px; +historyVideoMessageSeekDotSize: 13px; historyVideoMessageTtlIcon: icon {{ "chat/audio_once", historyFileThumbIconFg }}; historyVideoMessageTtlIconSelected: icon {{ "chat/audio_once", historyFileThumbIconFgSelected }}; From ef848e0a1bb2344da5aeb9f73f6cf0c6367dc537 Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Thu, 16 Apr 2026 20:15:19 +0000 Subject: [PATCH 14/78] Port Linux notifications to std::get_if There's no sense to use abstrations calling std::holds_alternative --- .../linux/notifications_manager_linux.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp b/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp index 409659fcb1..116fc7227a 100644 --- a/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp +++ b/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp @@ -432,8 +432,8 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { Core::Sandbox::Instance().customEnterFromEventLoop([&] { for (const auto &[key, notifications] : _notifications) { for (const auto &[msgId, notification] : notifications) { - const auto &nid = notification->id; - if (v::is(nid) && v::get(nid) == id) { + const auto nid = std::get_if(¬ification->id); + if (nid && id == *nid) { if (actionName == "default") { _manager->notificationActivated({ key, msgId }); } else if (actionName == "mail-mark-read") { @@ -457,8 +457,8 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { Core::Sandbox::Instance().customEnterFromEventLoop([&] { for (const auto &[key, notifications] : _notifications) { for (const auto &[msgId, notification] : notifications) { - const auto &nid = notification->id; - if (v::is(nid) && v::get(nid) == id) { + const auto nid = std::get_if(¬ification->id); + if (nid && id == *nid) { _manager->notificationReplied( { key, msgId }, { QString::fromStdString(text), {} }); @@ -479,8 +479,8 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { std::string token) { for (const auto &[key, notifications] : _notifications) { for (const auto &[msgId, notification] : notifications) { - const auto &nid = notification->id; - if (v::is(nid) && v::get(nid) == id) { + const auto nid = std::get_if(¬ification->id); + if (nid && id == *nid) { GLib::setenv("XDG_ACTIVATION_TOKEN", token, true); return; } @@ -513,8 +513,8 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { * In all other cases we keep the notification reference so that we may clear the notification later from history, * if the message for that notification is read (e.g. chat is opened or read from another device). */ - const auto &nid = notification->id; - if (v::is(nid) && v::get(nid) == id && reason == 2) { + const auto nid = std::get_if(¬ification->id); + if (nid && id == *nid && reason == 2) { clearNotification({ key, msgId }); return; } From 92488b29d5f81cd7d49955a059fd65c1d1f38dc5 Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Fri, 17 Apr 2026 00:40:44 +0400 Subject: [PATCH 15/78] Move GNotification action creation to the rest of notification code --- .../platform/linux/integration_linux.cpp | 14 --------- .../linux/notifications_manager_linux.cpp | 31 ++++++++++++------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/Telegram/SourceFiles/platform/linux/integration_linux.cpp b/Telegram/SourceFiles/platform/linux/integration_linux.cpp index f5d9473a3f..d0a12b1d01 100644 --- a/Telegram/SourceFiles/platform/linux/integration_linux.cpp +++ b/Telegram/SourceFiles/platform/linux/integration_linux.cpp @@ -104,20 +104,6 @@ Application::Application() }); }); actionMap.add_action(quitAction); - - const auto notificationIdVariantType = GLib::VariantType::new_("a{sv}"); - - auto notificationActivateAction = Gio::SimpleAction::new_( - "notification-activate", - notificationIdVariantType); - - actionMap.add_action(notificationActivateAction); - - auto notificationMarkAsReadAction = Gio::SimpleAction::new_( - "notification-mark-as-read", - notificationIdVariantType); - - actionMap.add_action(notificationMarkAsReadAction); } gi::ref_ptr MakeApplication() { diff --git a/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp b/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp index 116fc7227a..a3cb117793 100644 --- a/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp +++ b/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp @@ -382,10 +382,19 @@ Manager::Private::Private(not_null manager) }; }; - auto activate = gi::object_cast( - actionMap.lookup_action("notification-activate")); + const auto notificationIdVariantType = GLib::VariantType::new_( + "a{sv}"); - const auto activateSig = activate.signal_activate().connect([=]( + auto activate = Gio::SimpleAction::new_( + "notification-activate", + notificationIdVariantType); + + actionMap.add_action(activate); + _lifetime.add([=]() mutable { + actionMap.remove_action("notification-activate"); + }); + + activate.signal_activate().connect([=]( Gio::SimpleAction, GLib::Variant parameter) { Core::Sandbox::Instance().customEnterFromEventLoop([&] { @@ -394,14 +403,16 @@ Manager::Private::Private(not_null manager) }); }); + auto markAsRead = Gio::SimpleAction::new_( + "notification-mark-as-read", + notificationIdVariantType); + + actionMap.add_action(markAsRead); _lifetime.add([=]() mutable { - activate.disconnect(activateSig); + actionMap.remove_action("notification-mark-as-read"); }); - auto markAsRead = gi::object_cast( - actionMap.lookup_action("notification-mark-as-read")); - - const auto markAsReadSig = markAsRead.signal_activate().connect([=]( + markAsRead.signal_activate().connect([=]( Gio::SimpleAction, GLib::Variant parameter) { Core::Sandbox::Instance().customEnterFromEventLoop([&] { @@ -410,10 +421,6 @@ Manager::Private::Private(not_null manager) {}); }); }); - - _lifetime.add([=]() mutable { - markAsRead.disconnect(markAsReadSig); - }); } } From bc2eb3df826f31d254c464c65b8d3e10610f1309 Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Fri, 17 Apr 2026 11:46:53 +0400 Subject: [PATCH 16/78] Remove explicit disconnections where object lifetime is the same --- .../linux/notifications_manager_linux.cpp | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp b/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp index a3cb117793..b60df5a859 100644 --- a/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp +++ b/Telegram/SourceFiles/platform/linux/notifications_manager_linux.cpp @@ -432,7 +432,7 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { return; } - const auto actionInvoked = _interface.signal_action_invoked().connect([=]( + _interface.signal_action_invoked().connect([=]( XdgNotifications::Notifications, uint id, std::string actionName) { @@ -453,11 +453,7 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { }); }); - _lifetime.add([=] { - _interface.disconnect(actionInvoked); - }); - - const auto replied = _interface.signal_notification_replied().connect([=]( + _interface.signal_notification_replied().connect([=]( XdgNotifications::Notifications, uint id, std::string text) { @@ -476,11 +472,7 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { }); }); - _lifetime.add([=] { - _interface.disconnect(replied); - }); - - const auto tokenSignal = _interface.signal_activation_token().connect([=]( + _interface.signal_activation_token().connect([=]( XdgNotifications::Notifications, uint id, std::string token) { @@ -495,11 +487,7 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { } }); - _lifetime.add([=] { - _interface.disconnect(tokenSignal); - }); - - const auto closed = _interface.signal_notification_closed().connect([=]( + _interface.signal_notification_closed().connect([=]( XdgNotifications::Notifications, uint id, uint reason) { @@ -529,10 +517,6 @@ void Manager::Private::init(XdgNotifications::NotificationsProxy proxy) { } }); }); - - _lifetime.add([=] { - _interface.disconnect(closed); - }); } void Manager::Private::showNotification( From b5f3ea60b7d5af3775cadc85f5f5cb0be7233353 Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Thu, 16 Apr 2026 21:26:04 +0000 Subject: [PATCH 17/78] Try to use KSandbox::startHostProcess --- .../SourceFiles/platform/linux/specific_linux.cpp | 15 +++++++++++++++ .../platform/linux/translate_provider_linux.cpp | 12 +++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Telegram/SourceFiles/platform/linux/specific_linux.cpp b/Telegram/SourceFiles/platform/linux/specific_linux.cpp index cc9e032979..630ac06b52 100644 --- a/Telegram/SourceFiles/platform/linux/specific_linux.cpp +++ b/Telegram/SourceFiles/platform/linux/specific_linux.cpp @@ -807,6 +807,21 @@ bool OpenSystemSettings(SystemSettingsType type) { add("pavucontrol"); add("alsamixergui"); return ranges::any_of(options, [](const Command &command) { + if (KSandbox::isInside()) { + QProcess process; + process.setProgram("which"); + process.setArguments({command.command}); + KSandbox::startHostProcess(process); + process.waitForFinished(); + if (process.exitStatus() != QProcess::NormalExit + || process.exitCode() != 0) { + return false; + } + process.setProgram(command.command); + process.setArguments(command.arguments); + KSandbox::startHostProcess(process); + return true; + } return QProcess::startDetached( command.command, command.arguments); diff --git a/Telegram/SourceFiles/platform/linux/translate_provider_linux.cpp b/Telegram/SourceFiles/platform/linux/translate_provider_linux.cpp index c3df46fc6c..acf8279100 100644 --- a/Telegram/SourceFiles/platform/linux/translate_provider_linux.cpp +++ b/Telegram/SourceFiles/platform/linux/translate_provider_linux.cpp @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include #include +#include namespace Platform { namespace { @@ -22,6 +23,15 @@ namespace { u"org.kde.CrowTranslate"_q, }; const auto it = ranges::find_if(commands, [](const auto &command) { + if (KSandbox::isInside()) { + QProcess process; + process.setProgram("which"); + process.setArguments({command}); + KSandbox::startHostProcess(process); + process.waitForFinished(); + return process.exitStatus() == QProcess::NormalExit + && process.exitCode() == 0; + } return !QStandardPaths::findExecutable(command).isEmpty(); }); return it != end(commands) ? *it : QString(); @@ -58,7 +68,7 @@ public: ); delete process; }); - process->start(); + KSandbox::startHostProcess(*process); process->write(request.text.text.toUtf8()); process->closeWriteChannel(); } From 3fbe30231e2d5d306357d0ba90b434b21ee8e11f Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Sun, 15 Feb 2026 16:48:06 +0330 Subject: [PATCH 18/78] feat(accessibility): add screen reader support for country select box --- Telegram/Resources/langs/lang.strings | 2 + .../ui/boxes/country_select_box.cpp | 165 ++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 0ee8c1f13d..0147c99bb3 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -7917,4 +7917,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_mac_hold_to_quit" = "Hold {text} to Quit"; +"lng_sr_country_column_name" = "Country name"; + // Keys finished diff --git a/Telegram/SourceFiles/ui/boxes/country_select_box.cpp b/Telegram/SourceFiles/ui/boxes/country_select_box.cpp index a7c1d81092..192580e7c6 100644 --- a/Telegram/SourceFiles/ui/boxes/country_select_box.cpp +++ b/Telegram/SourceFiles/ui/boxes/country_select_box.cpp @@ -8,16 +8,22 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/boxes/country_select_box.h" #include "lang/lang_keys.h" +#include "base/screen_reader_state.h" +#include "ui/accessible/ui_accessible_item.h" #include "ui/widgets/scroll_area.h" #include "ui/widgets/multi_select.h" #include "ui/effects/ripple_animation.h" #include "ui/painter.h" +#include "base/invoke_queued.h" #include "countries/countries_instance.h" #include "styles/style_layers.h" #include "styles/style_boxes.h" #include "styles/style_intro.h" +#include + #include +#include namespace Ui { namespace { @@ -48,8 +54,87 @@ public: return _mustScrollTo.events(); } + QAccessible::Role accessibilityRole() override { + return QAccessible::List; + } + + QAccessible::Role accessibilityChildRole() const override { + return QAccessible::ListItem; + } + + QAccessible::State accessibilityChildState(int index) const override { + QAccessible::State state; + state.selectable = true; + if (base::ScreenReaderState::Instance()->active()) { + state.focusable = true; + } + if (index == _selected) { + state.selected = true; + state.active = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; + } + + int accessibilityChildCount() const override { + return int(current().size()); + } + + QString accessibilityChildName(int index) const override { + const auto &list = current(); + if (index < 0 || index >= int(list.size())) { + return {}; + } + if (_type == Type::Phones) { + return list[index].country + u", +"_q + list[index].code; + } + return list[index].country; + } + + QRect accessibilityChildRect(int index) const override { + const auto &list = current(); + if (index < 0 || index >= int(list.size())) { + return QRect(); + } + return QRect(0, st::countriesSkip + index * _rowHeight, width(), _rowHeight); + } + + int accessibilityChildColumnCount(int row) const override { + return (_type == Type::Phones) ? 2 : 1; + } + + QAccessible::Role accessibilityChildSubItemRole() const override { + return QAccessible::Cell; + } + + QString accessibilityChildSubItemName(int row, int column) const override { + if (column == 0) { + return tr::lng_sr_country_column_name(tr::now); + } else if (column == 1 && _type == Type::Phones) { + return tr::lng_country_code(tr::now); + } + return {}; + } + + QString accessibilityChildSubItemValue(int row, int column) const override { + const auto &list = current(); + if (row < 0 || row >= int(list.size())) { + return {}; + } + if (column == 0) { + return list[row].country; + } else if (column == 1 && _type == Type::Phones) { + return u"+"_q + list[row].code; + } + return {}; + } + protected: + void focusInEvent(QFocusEvent *e) override; void paintEvent(QPaintEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; void enterEventHook(QEnterEvent *e) override; void leaveEventHook(QEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; @@ -203,6 +288,13 @@ CountrySelectBox::Inner::Inner( _filter = u"a"_q; updateFilter(filter); }, lifetime()); + + setAccessibleName(tr::lng_country_select(tr::now)); + + base::ScreenReaderState::Instance()->activeValue( + ) | rpl::on_next([=](bool active) { + setFocusPolicy(active ? Qt::TabFocus : Qt::NoFocus); + }, lifetime()); } void CountrySelectBox::Inner::init() { @@ -262,6 +354,27 @@ void CountrySelectBox::Inner::init() { } } +void CountrySelectBox::Inner::focusInEvent(QFocusEvent *e) { + // Select first item when focus enters. + const auto &list = current(); + if (_selected < 0 && !list.empty()) { + _selected = 0; + updateSelectedRow(); + } + + RpWidget::focusInEvent(e); + + if (_selected >= 0 && base::ScreenReaderState::Instance()->active()) { + const auto index = _selected; + InvokeQueued(this, [=] { + if (_selected != index || !hasFocus()) { + return; + } + accessibilityChildFocused(index); + }); + } +} + void CountrySelectBox::Inner::paintEvent(QPaintEvent *e) { Painter p(this); QRect r(e->rect()); @@ -316,6 +429,50 @@ void CountrySelectBox::Inner::paintEvent(QPaintEvent *e) { } } +void CountrySelectBox::Inner::keyPressEvent(QKeyEvent *e) { + if (e->key() == Qt::Key_Down) { + selectSkip(1); + } else if (e->key() == Qt::Key_Up) { + selectSkip(-1); + } else if (e->key() == Qt::Key_PageDown || e->key() == Qt::Key_PageUp) { + const auto visibleHeight = visibleRegion().boundingRect().height(); + const auto rowsPerPage = std::max(visibleHeight / _rowHeight, 1); + selectSkip(e->key() == Qt::Key_PageDown ? rowsPerPage : -rowsPerPage); + } else if (e->key() == Qt::Key_Home) { + const auto &list = current(); + if (!list.empty()) { + _selected = 0; + _mustScrollTo.fire(ScrollToRequest( + st::countriesSkip, + st::countriesSkip + _rowHeight)); + update(); + if (base::ScreenReaderState::Instance()->active()) { + accessibilityChildNameChanged(_selected); + accessibilityChildFocused(_selected); + } + } + } else if (e->key() == Qt::Key_End) { + const auto &list = current(); + if (!list.empty()) { + _selected = int(list.size()) - 1; + _mustScrollTo.fire(ScrollToRequest( + st::countriesSkip + _selected * _rowHeight, + st::countriesSkip + (_selected + 1) * _rowHeight)); + update(); + if (base::ScreenReaderState::Instance()->active()) { + accessibilityChildNameChanged(_selected); + accessibilityChildFocused(_selected); + } + } + } else if (!e->isAutoRepeat() + && (e->key() == Qt::Key_Return + || e->key() == Qt::Key_Enter)) { + chooseCountry(); + } else { + RpWidget::keyPressEvent(e); + } +} + void CountrySelectBox::Inner::enterEventHook(QEnterEvent *e) { setMouseTracking(true); } @@ -426,6 +583,10 @@ void CountrySelectBox::Inner::selectSkip(int32 dir) { st::countriesSkip + (_selected + 1) * _rowHeight)); } update(); + if (_selected >= 0 && base::ScreenReaderState::Instance()->active()) { + accessibilityChildNameChanged(_selected); + accessibilityChildFocused(_selected); + } } void CountrySelectBox::Inner::selectSkipPage(int32 h, int32 dir) { @@ -457,6 +618,10 @@ void CountrySelectBox::Inner::updateSelected(QPoint localPos) { updateSelectedRow(); _selected = selected; updateSelectedRow(); + if (_selected >= 0 && base::ScreenReaderState::Instance()->active()) { + accessibilityChildNameChanged(_selected); + accessibilityChildFocused(_selected); + } } } From c354ed78145c02be678669c114851090714fcfd2 Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 17 Apr 2026 15:07:53 +0700 Subject: [PATCH 19/78] Simplify, reuse stuff in country_select_box. --- .../ui/boxes/country_select_box.cpp | 373 ++++++++++-------- .../SourceFiles/ui/boxes/country_select_box.h | 2 +- 2 files changed, 204 insertions(+), 171 deletions(-) diff --git a/Telegram/SourceFiles/ui/boxes/country_select_box.cpp b/Telegram/SourceFiles/ui/boxes/country_select_box.cpp index 192580e7c6..064b3d64f9 100644 --- a/Telegram/SourceFiles/ui/boxes/country_select_box.cpp +++ b/Telegram/SourceFiles/ui/boxes/country_select_box.cpp @@ -7,23 +7,21 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "ui/boxes/country_select_box.h" -#include "lang/lang_keys.h" -#include "base/screen_reader_state.h" -#include "ui/accessible/ui_accessible_item.h" -#include "ui/widgets/scroll_area.h" -#include "ui/widgets/multi_select.h" -#include "ui/effects/ripple_animation.h" -#include "ui/painter.h" +#include "base/event_filter.h" #include "base/invoke_queued.h" #include "countries/countries_instance.h" -#include "styles/style_layers.h" +#include "lang/lang_keys.h" +#include "ui/accessible/ui_accessible_item.h" +#include "ui/effects/ripple_animation.h" +#include "ui/widgets/multi_select.h" +#include "ui/widgets/scroll_area.h" +#include "ui/painter.h" +#include "ui/screen_reader_mode.h" #include "styles/style_boxes.h" #include "styles/style_intro.h" - -#include +#include "styles/style_layers.h" #include -#include namespace Ui { namespace { @@ -54,82 +52,17 @@ public: return _mustScrollTo.events(); } - QAccessible::Role accessibilityRole() override { - return QAccessible::List; - } - - QAccessible::Role accessibilityChildRole() const override { - return QAccessible::ListItem; - } - - QAccessible::State accessibilityChildState(int index) const override { - QAccessible::State state; - state.selectable = true; - if (base::ScreenReaderState::Instance()->active()) { - state.focusable = true; - } - if (index == _selected) { - state.selected = true; - state.active = true; - if (hasFocus()) { - state.focused = true; - } - } - return state; - } - - int accessibilityChildCount() const override { - return int(current().size()); - } - - QString accessibilityChildName(int index) const override { - const auto &list = current(); - if (index < 0 || index >= int(list.size())) { - return {}; - } - if (_type == Type::Phones) { - return list[index].country + u", +"_q + list[index].code; - } - return list[index].country; - } - - QRect accessibilityChildRect(int index) const override { - const auto &list = current(); - if (index < 0 || index >= int(list.size())) { - return QRect(); - } - return QRect(0, st::countriesSkip + index * _rowHeight, width(), _rowHeight); - } - - int accessibilityChildColumnCount(int row) const override { - return (_type == Type::Phones) ? 2 : 1; - } - - QAccessible::Role accessibilityChildSubItemRole() const override { - return QAccessible::Cell; - } - - QString accessibilityChildSubItemName(int row, int column) const override { - if (column == 0) { - return tr::lng_sr_country_column_name(tr::now); - } else if (column == 1 && _type == Type::Phones) { - return tr::lng_country_code(tr::now); - } - return {}; - } - - QString accessibilityChildSubItemValue(int row, int column) const override { - const auto &list = current(); - if (row < 0 || row >= int(list.size())) { - return {}; - } - if (column == 0) { - return list[row].country; - } else if (column == 1 && _type == Type::Phones) { - return u"+"_q + list[row].code; - } - return {}; - } + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + QAccessible::Role accessibilityChildRole() const override; + QAccessible::State accessibilityChildState(int index) const override; + int accessibilityChildCount() const override; + QString accessibilityChildName(int index) const override; + QRect accessibilityChildRect(int index) const override; + int accessibilityChildColumnCount(int row) const override; + QAccessible::Role accessibilityChildSubItemRole() const override; + QString accessibilityChildSubItemName(int row, int column) const override; + QString accessibilityChildSubItemValue(int row, int column) const override; protected: void focusInEvent(QFocusEvent *e) override; @@ -150,6 +83,12 @@ private: void updateSelectedRow(); void updateRow(int index); void setPressed(int pressed); + enum class Announce { + No, + OnChange, + Always, + }; + void setSelected(int index, Announce announce); const std::vector ¤t() const; Type _type = Type::Phones; @@ -172,6 +111,28 @@ private: }; +namespace { + +[[nodiscard]] bool ForwardListNavigation( + not_null e, + not_null inner, + int pageHeight) { + if (e->key() == Qt::Key_Down) { + inner->selectSkip(1); + } else if (e->key() == Qt::Key_Up) { + inner->selectSkip(-1); + } else if (e->key() == Qt::Key_PageDown) { + inner->selectSkipPage(pageHeight, 1); + } else if (e->key() == Qt::Key_PageUp) { + inner->selectSkipPage(pageHeight, -1); + } else { + return false; + } + return true; +} + +} // namespace + CountrySelectBox::CountrySelectBox(QWidget*) : CountrySelectBox(nullptr, QString(), Type::Phones) { } @@ -223,6 +184,17 @@ void CountrySelectBox::prepare() { ) | rpl::on_next([=](ScrollToRequest request) { scrollToY(request.ymin, request.ymax); }, lifetime()); + + base::install_event_filter(_select.data(), [=](not_null e) { + if (e->type() != QEvent::KeyPress) { + return base::EventFilterResult::Continue; + } + const auto key = static_cast(e.get()); + const auto pageHeight = height() - _select->height(); + return ForwardListNavigation(key, _inner.data(), pageHeight) + ? base::EventFilterResult::Cancel + : base::EventFilterResult::Continue; + }); } void CountrySelectBox::submit() { @@ -230,15 +202,8 @@ void CountrySelectBox::submit() { } void CountrySelectBox::keyPressEvent(QKeyEvent *e) { - if (e->key() == Qt::Key_Down) { - _inner->selectSkip(1); - } else if (e->key() == Qt::Key_Up) { - _inner->selectSkip(-1); - } else if (e->key() == Qt::Key_PageDown) { - _inner->selectSkipPage(height() - _select->height(), 1); - } else if (e->key() == Qt::Key_PageUp) { - _inner->selectSkipPage(height() - _select->height(), -1); - } else { + const auto pageHeight = height() - _select->height(); + if (!ForwardListNavigation(e, _inner.data(), pageHeight)) { BoxContent::keyPressEvent(e); } } @@ -290,11 +255,6 @@ CountrySelectBox::Inner::Inner( }, lifetime()); setAccessibleName(tr::lng_country_select(tr::now)); - - base::ScreenReaderState::Instance()->activeValue( - ) | rpl::on_next([=](bool active) { - setFocusPolicy(active ? Qt::TabFocus : Qt::NoFocus); - }, lifetime()); } void CountrySelectBox::Inner::init() { @@ -354,23 +314,32 @@ void CountrySelectBox::Inner::init() { } } -void CountrySelectBox::Inner::focusInEvent(QFocusEvent *e) { - // Select first item when focus enters. - const auto &list = current(); - if (_selected < 0 && !list.empty()) { - _selected = 0; +void CountrySelectBox::Inner::setSelected(int index, Announce announce) { + const auto changed = (_selected != index); + if (changed) { + updateSelectedRow(); + _selected = index; updateSelectedRow(); } + const auto shouldAnnounce = (announce == Announce::Always) + || (announce == Announce::OnChange && changed); + if (shouldAnnounce && _selected >= 0) { + accessibilityChildNameChanged(_selected); + accessibilityChildFocused(_selected); + } +} +void CountrySelectBox::Inner::focusInEvent(QFocusEvent *e) { + if (_selected < 0 && !current().empty()) { + setSelected(0, Announce::No); + } RpWidget::focusInEvent(e); - - if (_selected >= 0 && base::ScreenReaderState::Instance()->active()) { + if (_selected >= 0) { const auto index = _selected; InvokeQueued(this, [=] { - if (_selected != index || !hasFocus()) { - return; + if (_selected == index && hasFocus()) { + accessibilityChildFocused(index); } - accessibilityChildFocused(index); }); } } @@ -430,40 +399,22 @@ void CountrySelectBox::Inner::paintEvent(QPaintEvent *e) { } void CountrySelectBox::Inner::keyPressEvent(QKeyEvent *e) { - if (e->key() == Qt::Key_Down) { - selectSkip(1); - } else if (e->key() == Qt::Key_Up) { - selectSkip(-1); - } else if (e->key() == Qt::Key_PageDown || e->key() == Qt::Key_PageUp) { - const auto visibleHeight = visibleRegion().boundingRect().height(); - const auto rowsPerPage = std::max(visibleHeight / _rowHeight, 1); - selectSkip(e->key() == Qt::Key_PageDown ? rowsPerPage : -rowsPerPage); - } else if (e->key() == Qt::Key_Home) { - const auto &list = current(); - if (!list.empty()) { - _selected = 0; - _mustScrollTo.fire(ScrollToRequest( - st::countriesSkip, - st::countriesSkip + _rowHeight)); - update(); - if (base::ScreenReaderState::Instance()->active()) { - accessibilityChildNameChanged(_selected); - accessibilityChildFocused(_selected); - } - } - } else if (e->key() == Qt::Key_End) { - const auto &list = current(); - if (!list.empty()) { - _selected = int(list.size()) - 1; - _mustScrollTo.fire(ScrollToRequest( - st::countriesSkip + _selected * _rowHeight, - st::countriesSkip + (_selected + 1) * _rowHeight)); - update(); - if (base::ScreenReaderState::Instance()->active()) { - accessibilityChildNameChanged(_selected); - accessibilityChildFocused(_selected); - } - } + const auto pageHeight = parentWidget()->height(); + if (ForwardListNavigation(e, this, pageHeight)) { + return; + } + const auto &list = current(); + if (e->key() == Qt::Key_Home && !list.empty()) { + setSelected(0, Announce::Always); + _mustScrollTo.fire(ScrollToRequest( + st::countriesSkip, + st::countriesSkip + _rowHeight)); + } else if (e->key() == Qt::Key_End && !list.empty()) { + const auto last = int(list.size()) - 1; + setSelected(last, Announce::Always); + _mustScrollTo.fire(ScrollToRequest( + st::countriesSkip + last * _rowHeight, + st::countriesSkip + (last + 1) * _rowHeight)); } else if (!e->isAutoRepeat() && (e->key() == Qt::Key_Return || e->key() == Qt::Key_Enter)) { @@ -570,23 +521,18 @@ void CountrySelectBox::Inner::selectSkip(int32 dir) { const auto &list = current(); int cur = (_selected >= 0) ? _selected : -1; cur += dir; - if (cur <= 0) { - _selected = list.empty() ? -1 : 0; - } else if (cur >= list.size()) { - _selected = -1; - } else { - _selected = cur; - } + const auto next = (cur <= 0) + ? (list.empty() ? -1 : 0) + : (cur >= int(list.size())) + ? -1 + : cur; + setSelected(next, Announce::Always); if (_selected >= 0) { _mustScrollTo.fire(ScrollToRequest( st::countriesSkip + _selected * _rowHeight, st::countriesSkip + (_selected + 1) * _rowHeight)); } update(); - if (_selected >= 0 && base::ScreenReaderState::Instance()->active()) { - accessibilityChildNameChanged(_selected); - accessibilityChildFocused(_selected); - } } void CountrySelectBox::Inner::selectSkipPage(int32 h, int32 dir) { @@ -608,21 +554,18 @@ void CountrySelectBox::Inner::refresh() { } void CountrySelectBox::Inner::updateSelected(QPoint localPos) { - if (!_mouseSelection) return; - - auto in = parentWidget()->rect().contains(parentWidget()->mapFromGlobal(QCursor::pos())); - - const auto &list = current(); - auto selected = (in && localPos.y() >= st::countriesSkip && localPos.y() < st::countriesSkip + list.size() * _rowHeight) ? ((localPos.y() - st::countriesSkip) / _rowHeight) : -1; - if (_selected != selected) { - updateSelectedRow(); - _selected = selected; - updateSelectedRow(); - if (_selected >= 0 && base::ScreenReaderState::Instance()->active()) { - accessibilityChildNameChanged(_selected); - accessibilityChildFocused(_selected); - } + if (!_mouseSelection) { + return; } + const auto in = parentWidget()->rect().contains( + parentWidget()->mapFromGlobal(QCursor::pos())); + const auto &list = current(); + const auto selected = (in + && localPos.y() >= st::countriesSkip + && localPos.y() < st::countriesSkip + int(list.size()) * _rowHeight) + ? ((localPos.y() - st::countriesSkip) / _rowHeight) + : -1; + setSelected(selected, Announce::OnChange); } auto CountrySelectBox::Inner::current() const @@ -649,4 +592,94 @@ void CountrySelectBox::Inner::setPressed(int pressed) { CountrySelectBox::Inner::~Inner() = default; +QAccessible::Role CountrySelectBox::Inner::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy CountrySelectBox::Inner::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +QAccessible::Role CountrySelectBox::Inner::accessibilityChildRole() const { + return QAccessible::ListItem; +} + +QAccessible::State CountrySelectBox::Inner::accessibilityChildState( + int index) const { + QAccessible::State state; + state.selectable = true; + if (Ui::ScreenReaderModeActive()) { + state.focusable = true; + } + if (index == _selected) { + state.selected = true; + state.active = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; +} + +int CountrySelectBox::Inner::accessibilityChildCount() const { + return int(current().size()); +} + +QString CountrySelectBox::Inner::accessibilityChildName(int index) const { + const auto &list = current(); + if (index < 0 || index >= int(list.size())) { + return {}; + } + if (_type == Type::Phones) { + return list[index].country + u", +"_q + list[index].code; + } + return list[index].country; +} + +QRect CountrySelectBox::Inner::accessibilityChildRect(int index) const { + const auto &list = current(); + if (index < 0 || index >= int(list.size())) { + return {}; + } + return QRect( + 0, + st::countriesSkip + index * _rowHeight, + width(), + _rowHeight); +} + +int CountrySelectBox::Inner::accessibilityChildColumnCount(int row) const { + return (_type == Type::Phones) ? 2 : 1; +} + +QAccessible::Role CountrySelectBox::Inner::accessibilityChildSubItemRole() const { + return QAccessible::Cell; +} + +QString CountrySelectBox::Inner::accessibilityChildSubItemName( + int row, + int column) const { + if (column == 0) { + return tr::lng_sr_country_column_name(tr::now); + } else if (column == 1 && _type == Type::Phones) { + return tr::lng_country_code(tr::now); + } + return {}; +} + +QString CountrySelectBox::Inner::accessibilityChildSubItemValue( + int row, + int column) const { + const auto &list = current(); + if (row < 0 || row >= int(list.size())) { + return {}; + } + if (column == 0) { + return list[row].country; + } else if (column == 1 && _type == Type::Phones) { + return u"+"_q + list[row].code; + } + return {}; +} + } // namespace Ui diff --git a/Telegram/SourceFiles/ui/boxes/country_select_box.h b/Telegram/SourceFiles/ui/boxes/country_select_box.h index 553fd39e27..779a9f970b 100644 --- a/Telegram/SourceFiles/ui/boxes/country_select_box.h +++ b/Telegram/SourceFiles/ui/boxes/country_select_box.h @@ -20,6 +20,7 @@ class RippleAnimation; class CountrySelectBox : public BoxContent { public: + class Inner; enum class Type { Phones, Countries, @@ -50,7 +51,6 @@ private: object_ptr _select; - class Inner; object_ptr _ownedInner; QPointer _inner; From 5bb03928a16991a0c1ea57820d7c7ec0e131118b Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Fri, 17 Apr 2026 11:57:37 +0400 Subject: [PATCH 20/78] Fix sticker size option with scaling --- .../SourceFiles/history/view/media/history_view_sticker.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Telegram/SourceFiles/history/view/media/history_view_sticker.cpp b/Telegram/SourceFiles/history/view/media/history_view_sticker.cpp index a5871d4157..4ef4cc7be1 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_sticker.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_sticker.cpp @@ -201,7 +201,7 @@ QSize Sticker::Size() { const auto side = std::min(st::maxStickerSize, kMaxSizeFixed); if (OptionStickerSize.value() > 0) [[unlikely]] { const auto scaled = std::clamp( - OptionStickerSize.value(), + style::ConvertScale(OptionStickerSize.value()), style::ConvertScale(50), side); return { scaled, scaled }; From d53ee9c241beea1d46ec9eb8e0de72527a7cadb9 Mon Sep 17 00:00:00 2001 From: "Sergey A. Osokin" Date: Sun, 12 Apr 2026 12:25:44 -0400 Subject: [PATCH 21/78] Fix warnings. --- Telegram/SourceFiles/boxes/edit_todo_list_box.cpp | 1 - Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp | 1 - Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp | 2 -- Telegram/SourceFiles/data/data_todo_list.cpp | 2 -- Telegram/SourceFiles/history/view/history_view_message.cpp | 1 - .../history/view/history_view_paid_reaction_toast.cpp | 2 -- .../info/global_media/info_global_media_provider.cpp | 1 - Telegram/SourceFiles/media/audio/media_audio_local_cache.cpp | 1 - .../SourceFiles/settings/business/settings_working_hours.cpp | 1 - Telegram/SourceFiles/ui/boxes/collectible_info_box.cpp | 2 -- Telegram/SourceFiles/ui/chat/attach/attach_bot_webview.cpp | 1 - Telegram/SourceFiles/ui/controls/stars_rating.cpp | 2 -- Telegram/SourceFiles/ui/peer/color_sample.cpp | 1 - 13 files changed, 18 deletions(-) diff --git a/Telegram/SourceFiles/boxes/edit_todo_list_box.cpp b/Telegram/SourceFiles/boxes/edit_todo_list_box.cpp index 96712d2293..c47cf00040 100644 --- a/Telegram/SourceFiles/boxes/edit_todo_list_box.cpp +++ b/Telegram/SourceFiles/boxes/edit_todo_list_box.cpp @@ -53,7 +53,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace { -constexpr auto kMaxOptionsCount = TodoListData::kMaxOptions; constexpr auto kWarnTitleLimit = 12; constexpr auto kWarnTaskLimit = 24; constexpr auto kErrorLimit = 99; diff --git a/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp b/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp index e901ec135c..9f0aa64bd0 100644 --- a/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp +++ b/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp @@ -55,7 +55,6 @@ constexpr auto kSuccessFadeInDuration = crl::time(300); constexpr auto kSuccessExpandDuration = crl::time(400); constexpr auto kSuccessExpandStart = crl::time(100); constexpr auto kProgressFadeInDuration = crl::time(300); -constexpr auto kFailureFadeInDuration = crl::time(300); [[nodiscard]] QString FormatPercent(int permille) { const auto rounded = (permille + 5) / 10; diff --git a/Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp b/Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp index 7fd579b465..e398598a80 100644 --- a/Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp +++ b/Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp @@ -37,8 +37,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Calls::Group { namespace { -constexpr auto kPasswordCharAmount = 24; - void StartWithBox( not_null box, Fn done, diff --git a/Telegram/SourceFiles/data/data_todo_list.cpp b/Telegram/SourceFiles/data/data_todo_list.cpp index ca98523b61..1496490e18 100644 --- a/Telegram/SourceFiles/data/data_todo_list.cpp +++ b/Telegram/SourceFiles/data/data_todo_list.cpp @@ -18,8 +18,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace { -constexpr auto kShortPollTimeout = 30 * crl::time(1000); - const TodoListItem *ItemById(const std::vector &list, int id) { const auto i = ranges::find(list, id, &TodoListItem::id); return (i != end(list)) ? &*i : nullptr; diff --git a/Telegram/SourceFiles/history/view/history_view_message.cpp b/Telegram/SourceFiles/history/view/history_view_message.cpp index 5dadd24ea2..e34e9c179e 100644 --- a/Telegram/SourceFiles/history/view/history_view_message.cpp +++ b/Telegram/SourceFiles/history/view/history_view_message.cpp @@ -63,7 +63,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace HistoryView { namespace { -constexpr auto kSummarizeThreshold = 512; constexpr auto kPlayStatusLimit = 2; constexpr auto kMaxWidth = (1 << 16) - 1; constexpr auto kMaxNiceToReadLines = 6; diff --git a/Telegram/SourceFiles/history/view/history_view_paid_reaction_toast.cpp b/Telegram/SourceFiles/history/view/history_view_paid_reaction_toast.cpp index 0d1abb053e..1a9f03d41a 100644 --- a/Telegram/SourceFiles/history/view/history_view_paid_reaction_toast.cpp +++ b/Telegram/SourceFiles/history/view/history_view_paid_reaction_toast.cpp @@ -32,8 +32,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace HistoryView { namespace { -constexpr auto kPremiumToastDuration = 5 * crl::time(1000); - [[nodiscard]] not_null MakeUndoButton( not_null parent, int width, diff --git a/Telegram/SourceFiles/info/global_media/info_global_media_provider.cpp b/Telegram/SourceFiles/info/global_media/info_global_media_provider.cpp index b260f7531b..0877859131 100644 --- a/Telegram/SourceFiles/info/global_media/info_global_media_provider.cpp +++ b/Telegram/SourceFiles/info/global_media/info_global_media_provider.cpp @@ -30,7 +30,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Info::GlobalMedia { namespace { -constexpr auto kPerPage = 50; constexpr auto kPreloadedScreensCount = 4; constexpr auto kPreloadedScreensCountFull = kPreloadedScreensCount + 1 + kPreloadedScreensCount; diff --git a/Telegram/SourceFiles/media/audio/media_audio_local_cache.cpp b/Telegram/SourceFiles/media/audio/media_audio_local_cache.cpp index 73c7d7d693..195b018bf1 100644 --- a/Telegram/SourceFiles/media/audio/media_audio_local_cache.cpp +++ b/Telegram/SourceFiles/media/audio/media_audio_local_cache.cpp @@ -14,7 +14,6 @@ namespace Media::Audio { namespace { constexpr auto kMaxDuration = 3 * crl::time(1000); -constexpr auto kMaxStreams = 2; constexpr auto kFrameSize = 4096; [[nodiscard]] QByteArray ConvertAndCut(const QByteArray &bytes) { diff --git a/Telegram/SourceFiles/settings/business/settings_working_hours.cpp b/Telegram/SourceFiles/settings/business/settings_working_hours.cpp index a47391e90b..c3e6dbfa28 100644 --- a/Telegram/SourceFiles/settings/business/settings_working_hours.cpp +++ b/Telegram/SourceFiles/settings/business/settings_working_hours.cpp @@ -35,7 +35,6 @@ namespace Settings { namespace { constexpr auto kDay = Data::WorkingInterval::kDay; -constexpr auto kWeek = Data::WorkingInterval::kWeek; constexpr auto kInNextDayMax = Data::WorkingInterval::kInNextDayMax; class WorkingHours : public Section { diff --git a/Telegram/SourceFiles/ui/boxes/collectible_info_box.cpp b/Telegram/SourceFiles/ui/boxes/collectible_info_box.cpp index 9f9ec3ed5f..93f2911bff 100644 --- a/Telegram/SourceFiles/ui/boxes/collectible_info_box.cpp +++ b/Telegram/SourceFiles/ui/boxes/collectible_info_box.cpp @@ -30,8 +30,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Ui { namespace { -constexpr auto kTonMultiplier = uint64(1000000000); - [[nodiscard]] QString FormatEntity(CollectibleType type, QString entity) { switch (type) { case CollectibleType::Phone: { diff --git a/Telegram/SourceFiles/ui/chat/attach/attach_bot_webview.cpp b/Telegram/SourceFiles/ui/chat/attach/attach_bot_webview.cpp index 70daf5c353..c92f1f0337 100644 --- a/Telegram/SourceFiles/ui/chat/attach/attach_bot_webview.cpp +++ b/Telegram/SourceFiles/ui/chat/attach/attach_bot_webview.cpp @@ -48,7 +48,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Ui::BotWebView { namespace { -constexpr auto kProcessClickTimeout = crl::time(1000); constexpr auto kClipboardReadTimeout = crl::time(10000); constexpr auto kProgressDuration = crl::time(200); constexpr auto kProgressOpacity = 0.3; diff --git a/Telegram/SourceFiles/ui/controls/stars_rating.cpp b/Telegram/SourceFiles/ui/controls/stars_rating.cpp index 7fa17b8ba1..4850b0d34a 100644 --- a/Telegram/SourceFiles/ui/controls/stars_rating.cpp +++ b/Telegram/SourceFiles/ui/controls/stars_rating.cpp @@ -37,8 +37,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Ui { namespace { -constexpr auto kAutoCollapseTimeout = 4 * crl::time(1000); - using Counters = Data::StarsRating; [[nodiscard]] Counters AdjustByReached(Counters data) { diff --git a/Telegram/SourceFiles/ui/peer/color_sample.cpp b/Telegram/SourceFiles/ui/peer/color_sample.cpp index 490555385a..d11ac016c6 100644 --- a/Telegram/SourceFiles/ui/peer/color_sample.cpp +++ b/Telegram/SourceFiles/ui/peer/color_sample.cpp @@ -22,7 +22,6 @@ namespace Ui { namespace { constexpr auto kSelectAnimationDuration = crl::time(150); -constexpr auto kUnsetColorIndex = uint8(0xFF); constexpr auto kProfileColorIndexCount = uint8(8); } // namespace From 6419bb15fbc8181f1c81c30b31886f555fc7c3ec Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 3 Apr 2026 14:29:47 +0000 Subject: [PATCH 22/78] Update User-Agent for DNS to Chrome 146.0.0.0. --- .../SourceFiles/mtproto/details/mtproto_domain_resolver.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Telegram/SourceFiles/mtproto/details/mtproto_domain_resolver.cpp b/Telegram/SourceFiles/mtproto/details/mtproto_domain_resolver.cpp index 94cfbc3954..056d22aac6 100644 --- a/Telegram/SourceFiles/mtproto/details/mtproto_domain_resolver.cpp +++ b/Telegram/SourceFiles/mtproto/details/mtproto_domain_resolver.cpp @@ -65,7 +65,7 @@ QByteArray DnsUserAgent() { static const auto kResult = QByteArray( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/142.0.0.0 Safari/537.36"); + "Chrome/146.0.0.0 Safari/537.36"); return kResult; } From ed1e3cabc3a5c870e72da0eb5cf4a50f0ccc9e7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:17:51 +0000 Subject: [PATCH 23/78] Bump actions/deploy-pages from 4 to 5 Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/changelog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index a94c13542b..294f1ad3ef 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -44,4 +44,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deploy - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From c9ca79ab471bcac14f0e4516c2f197af151ee015 Mon Sep 17 00:00:00 2001 From: John Preston Date: Sun, 19 Apr 2026 16:33:25 +0700 Subject: [PATCH 24/78] Don't remember incorrect value in textRealWidth. --- Telegram/SourceFiles/history/view/history_view_message.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Telegram/SourceFiles/history/view/history_view_message.cpp b/Telegram/SourceFiles/history/view/history_view_message.cpp index e34e9c179e..57f8e60f87 100644 --- a/Telegram/SourceFiles/history/view/history_view_message.cpp +++ b/Telegram/SourceFiles/history/view/history_view_message.cpp @@ -4217,6 +4217,8 @@ int Message::bubbleTextualWidth() const { } } _bubbleTextualWidthCache = right; + [[maybe_unused]] const auto ensureRightCache + = textHeightFor(bubbleTextWidth(right)); } } } From 827ec824d1cd543b9ba2dcd8d6cc823d8727b63d Mon Sep 17 00:00:00 2001 From: cumdev1337 Date: Wed, 4 Mar 2026 01:48:48 +0200 Subject: [PATCH 25/78] Bring back original video quality option --- Telegram/SourceFiles/core/core_settings.cpp | 8 +- Telegram/SourceFiles/data/data_document.cpp | 89 ++++++++++++++++--- Telegram/SourceFiles/data/data_document.h | 1 + Telegram/SourceFiles/media/media_common.h | 2 + .../media/player/media_player_button.cpp | 11 ++- .../media/player/media_player_dropdown.cpp | 28 ++++-- .../media/streaming/media_streaming_common.h | 1 + .../streaming/media_streaming_player.cpp | 2 + .../streaming/media_streaming_video_track.cpp | 10 ++- .../media/view/media_view_overlay_widget.cpp | 52 ++++++++--- .../view/media_view_playback_controls.cpp | 4 +- .../media/view/media_view_playback_controls.h | 4 +- 12 files changed, 168 insertions(+), 44 deletions(-) diff --git a/Telegram/SourceFiles/core/core_settings.cpp b/Telegram/SourceFiles/core/core_settings.cpp index 3c156814f2..6ce4b5b745 100644 --- a/Telegram/SourceFiles/core/core_settings.cpp +++ b/Telegram/SourceFiles/core/core_settings.cpp @@ -115,7 +115,13 @@ void LogPosition(const WindowPosition &position, const QString &name) { auto result = Media::VideoQuality(); const auto data = static_cast(&result); memcpy(data, &value, sizeof(result)); - return (result.height <= 4320) ? result : Media::VideoQuality(); + + const auto height = result.height; + const auto offset = Media::kVideoQualityOriginalOffset; + const auto max = 4320; + return (height <= max || (height >= offset && height <= offset + max)) + ? result + : Media::VideoQuality(); } } // namespace diff --git a/Telegram/SourceFiles/data/data_document.cpp b/Telegram/SourceFiles/data/data_document.cpp index f5e88a0dc3..7522dc1bc0 100644 --- a/Telegram/SourceFiles/data/data_document.cpp +++ b/Telegram/SourceFiles/data/data_document.cpp @@ -544,11 +544,12 @@ void DocumentData::setVideoQualities( return; } const auto good = [&](not_null document) { + // Transcodes in alt_documents are always streamable, + // even if the supports_streaming flag is missing return document->isVideoFile() && !document->dimensions.isEmpty() && !document->inappPlaybackFailed() - && document->useStreamingLoader() - && document->canBeStreamed(); + && document->useStreamingLoader(); }; ranges::sort( qualities, @@ -578,18 +579,67 @@ void DocumentData::setVideoQualities( } qualities.erase(qualities.begin() + count, qualities.end()); if (!qualities.empty()) { - if (const auto mine = resolveVideoQuality()) { - if (mine > qualities.front()->resolveVideoQuality()) { - qualities.insert(begin(qualities), this); + auto transcodeMax = 0; + for (const auto &quality : qualities) { + const auto qres = quality->resolveVideoQuality(); + if (qres > transcodeMax) { + transcodeMax = qres; } } + const auto attributesSize = isVideoFile() ? dimensions : QSize(); + const auto attributesQuality = attributesSize.isEmpty() + ? 0 + : std::min(attributesSize.width(), attributesSize.height()); + + // Heuristic: Trust attributes resolution unless it blatantly + // contradicts server-side transcodes (>1.5x delta) + auto mine = (transcodeMax > 0 + && (attributesQuality < transcodeMax + || attributesQuality > transcodeMax * 1.5)) + ? transcodeMax + : attributesQuality; + if (mine) { + qualities.insert(begin(qualities), this); + } } data->qualities = std::move(qualities); } int DocumentData::resolveVideoQuality() const { - const auto size = isVideoFile() ? dimensions : QSize(); - return size.isEmpty() ? 0 : std::min(size.width(), size.height()); + if (const auto data = video()) { + if (!data->realVideoSize.isEmpty()) { + // Always trust FFmpeg-parsed physical resolution + const auto size = data->realVideoSize; + return std::min(size.width(), size.height()); + } + const auto attributesSize = isVideoFile() ? dimensions : QSize(); + const auto attributesQuality = attributesSize.isEmpty() + ? 0 + : std::min(attributesSize.width(), attributesSize.height()); + if (!data->qualities.empty()) { + auto transcodeMax = 0; + for (const auto &quality : data->qualities) { + if (quality != this) { + const auto qres = quality->resolveVideoQuality(); + if (qres > transcodeMax) { + transcodeMax = qres; + } + } + } + if (transcodeMax > 0) { + // Trust transcodes if attributes appear fake + if (attributesQuality < transcodeMax + || attributesQuality > transcodeMax * 1.5) { + return transcodeMax; + } + return attributesQuality; + } + } + } + const auto attributesSize = isVideoFile() ? dimensions : QSize(); + return attributesSize.isEmpty() + ? 0 + : std::min(attributesSize.width(), attributesSize.height()); } auto DocumentData::resolveQualities(HistoryItem *context) const @@ -611,19 +661,30 @@ not_null DocumentData::chooseQuality( return this; } const auto height = int(request.height); - auto closest = this; - auto closestAbs = std::abs(height - resolveVideoQuality()); - auto closestSize = size; + if (height >= Media::kVideoQualityOriginalOffset) { + return this; + } + + auto closest = (DocumentData*)nullptr; + auto closestAbs = -1; + auto closestSize = -1; + for (const auto &quality : list) { - const auto abs = std::abs(height - quality->resolveVideoQuality()); - if (abs < closestAbs - || (abs == closestAbs && quality->size < closestSize)) { + const auto qres = quality->resolveVideoQuality(); + const auto abs = std::abs(height - qres); + // Prefer Original if it fits target resolution best, + // falling back to transcode only on exact matches + if (!closest + || abs < closestAbs + || (abs == closestAbs && (quality->size < closestSize + || (closest == this && quality != this)))) { closest = quality; closestAbs = abs; closestSize = quality->size; } } - return closest; + + return closest ? closest : this; } void DocumentData::validateLottieSticker() { diff --git a/Telegram/SourceFiles/data/data_document.h b/Telegram/SourceFiles/data/data_document.h index ec0a47921d..47c9bccfd2 100644 --- a/Telegram/SourceFiles/data/data_document.h +++ b/Telegram/SourceFiles/data/data_document.h @@ -99,6 +99,7 @@ struct VoiceData : public DocumentAdditionalData { struct VideoData : public DocumentAdditionalData { QString codec; std::vector> qualities; + QSize realVideoSize; }; using RoundData = VoiceData; diff --git a/Telegram/SourceFiles/media/media_common.h b/Telegram/SourceFiles/media/media_common.h index 1f757e581f..42d6e4e4d8 100644 --- a/Telegram/SourceFiles/media/media_common.h +++ b/Telegram/SourceFiles/media/media_common.h @@ -41,6 +41,8 @@ inline constexpr auto kSpeedMin = 0.5; inline constexpr auto kSpeedMax = 2.5; inline constexpr auto kSpedUpDefault = 1.7; +inline constexpr auto kVideoQualityOriginalOffset = 1000000; + [[nodiscard]] inline bool EqualSpeeds(float64 a, float64 b) { return int(base::SafeRound(a * 10.)) == int(base::SafeRound(b * 10.)); } diff --git a/Telegram/SourceFiles/media/player/media_player_button.cpp b/Telegram/SourceFiles/media/player/media_player_button.cpp index 2fc6b8877f..8e043be964 100644 --- a/Telegram/SourceFiles/media/player/media_player_button.cpp +++ b/Telegram/SourceFiles/media/player/media_player_button.cpp @@ -437,13 +437,16 @@ void SettingsButton::prepareFrame() { : u"%1X"_q.arg(rounded / 10); paintBadge(p, text, RectPart::TopLeft, color); } - const auto text = (!_quality) + const auto displayQuality = (_quality >= Media::kVideoQualityOriginalOffset) + ? (_quality - Media::kVideoQualityOriginalOffset) + : _quality; + const auto text = (!displayQuality) ? QString() - : (_quality > 2000) + : (displayQuality > 2000) ? u"4K"_q - : (_quality > 1000) + : (displayQuality > 1000) ? u"FHD"_q - : (_quality > 700) + : (displayQuality > 700) ? u"HD"_q : u"SD"_q; if (!text.isEmpty()) { diff --git a/Telegram/SourceFiles/media/player/media_player_dropdown.cpp b/Telegram/SourceFiles/media/player/media_player_dropdown.cpp index d8086faf8b..00950631e2 100644 --- a/Telegram/SourceFiles/media/player/media_player_dropdown.cpp +++ b/Telegram/SourceFiles/media/player/media_player_dropdown.cpp @@ -826,7 +826,13 @@ void SpeedController::fillMenu(not_null menu) { const auto add = [&](int quality) { const auto automatic = tr::lng_mediaview_quality_auto(tr::now); - const auto text = quality ? u"%1p"_q.arg(quality) : automatic; + const auto offset = Media::kVideoQualityOriginalOffset; + // Quality is height-based, except for Original which uses offset + const auto text = !quality + ? automatic + : (quality >= offset) + ? u"Original (%1p)"_q.arg(std::clamp(quality - offset, 0, 4320)) + : u"%1p"_q.arg(quality); auto action = base::make_unique_q( raw, st.qualityMenu, @@ -836,15 +842,15 @@ void SpeedController::fillMenu(not_null menu) { [=] { _changeQuality(quality); }), nullptr, nullptr); - const auto raw = action.get(); - const auto check = Ui::CreateChild(raw); + const auto rawAction = action.get(); + const auto check = Ui::CreateChild(rawAction); check->resize(st.activeCheck.size()); check->paintRequest( ) | rpl::on_next([check, icon = &st.activeCheck] { auto p = QPainter(check); icon->paint(p, 0, 0, check->width()); }, check->lifetime()); - raw->sizeValue( + rawAction->sizeValue( ) | rpl::on_next([=, skip = st.activeCheckSkip](QSize size) { check->moveToRight( skip, @@ -857,13 +863,19 @@ void SpeedController::fillMenu(not_null menu) { const auto chosen = now.manual ? (now.height == quality) : !quality; - raw->action()->setEnabled(!chosen); + rawAction->action()->setEnabled(!chosen); if (!quality) { - raw->action()->setText(automatic - + (now.manual ? QString() : u"\t%1p"_q.arg(now.height))); + const auto offset = Media::kVideoQualityOriginalOffset; + const auto displayHeight = (now.height >= offset) + ? std::clamp(int(now.height - offset), 0, 4320) + : now.height; + const auto suffix = now.manual + ? QString() + : u"\t%1p"_q.arg(displayHeight); + rawAction->action()->setText(automatic + suffix); } check->setVisible(chosen); - }, raw->lifetime()); + }, rawAction->lifetime()); menu->addAction(std::move(action)); }; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_common.h b/Telegram/SourceFiles/media/streaming/media_streaming_common.h index 79af0e4e0e..505f665a02 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_common.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_common.h @@ -59,6 +59,7 @@ struct TrackState { struct VideoInformation { TrackState state; QSize size; + QSize realSize; QImage cover; int rotation = 0; float64 fps = 0.; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp index 4e7979fc2e..7a61e74aaf 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp @@ -62,6 +62,8 @@ void SaveValidVideoInformation( SaveValidStateInformation(to.state, std::move(from.state)); to.size = from.size; + // Propagate physical resolution parsed from the stream + to.realSize = from.realSize; to.cover = std::move(from.cover); to.rotation = from.rotation; to.fps = from.fps; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp index 5e63d08182..7c554a7a1c 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp @@ -721,6 +721,10 @@ void VideoTrackObject::callReady() { const auto frame = _shared->frameForPaint(); ++_frameIndex; + const auto frameSize = frame->original.isNull() + ? frame->yuv.size + : frame->original.size(); + base::take(_ready)({ VideoInformation{ .state = { .position = _syncTimePoint.trackTime, @@ -730,7 +734,11 @@ void VideoTrackObject::callReady() { .duration = _stream.duration, }, .size = FFmpeg::TransposeSizeByRotation( - FFmpeg::CorrectByAspect(frame->original.size(), _stream.aspect), + FFmpeg::CorrectByAspect(frameSize, _stream.aspect), + _stream.rotation), + // realSize captures physical resolution before SAR correction + .realSize = FFmpeg::TransposeSizeByRotation( + frameSize, _stream.rotation), .cover = frame->original, .rotation = _stream.rotation, diff --git a/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp b/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp index 38827b4963..1c18cbfb37 100644 --- a/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp +++ b/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp @@ -1246,7 +1246,8 @@ QSize OverlayWidget::videoSize() const { Expects(videoShown()); const auto use = (_document && _chosenQuality != _document) - ? _document->dimensions + // Use chosen quality dimensions instead of original + ? _chosenQuality->dimensions : _streamed->instance.info().video.size; return flipSizeByRotation(use); } @@ -4714,8 +4715,21 @@ void OverlayWidget::initStreamingThumbnail() { void OverlayWidget::streamingReady(Streaming::Information &&info) { markStreamedReady(); if (videoShown()) { + if (_document && _streamed && _streamed->ready) { + const auto targetDocument = _chosenQuality ? _chosenQuality : _document; + if (const auto video = targetDocument->video()) { + video->realVideoSize = info.video.realSize; + } + } applyVideoSize(); _streamedQualityChangeFrame = QImage(); + if (_streamed && _streamed->controls) { + crl::on_main(_widget, [=] { + if (_streamed && _streamed->controls) { + _streamed->controls->updateSpeedToggleQuality(); + } + }); + } } else { updateContentRect(); } @@ -5121,12 +5135,16 @@ void OverlayWidget::restartAtSeekPosition(crl::time position) { } const auto overrideDuration = _stories || (_chosenQuality && _chosenQuality != _document); + const auto durationDocument = (_chosenQuality && _chosenQuality != _document) + ? _chosenQuality + : _document; + auto options = Streaming::PlaybackOptions{ .position = position, .durationOverride = ((overrideDuration - && _document - && _document->hasDuration()) - ? _document->duration() + && durationDocument + && durationDocument->hasDuration()) + ? durationDocument->duration() : crl::time(0)), .hwAllowed = Core::App().settings().hardwareAcceleratedVideo(), .seekable = !_stories, @@ -5231,21 +5249,30 @@ std::vector OverlayWidget::playbackControlsQualities() { } auto result = std::vector(); result.reserve(list.size()); + auto seen = std::vector(); for (const auto &quality : list) { - result.push_back(quality->resolveVideoQuality()); + const auto res = quality->resolveVideoQuality(); + const auto value = (quality == _document) + ? (res + Media::kVideoQualityOriginalOffset) + : res; + if (!ranges::contains(seen, value)) { + result.push_back(value); + seen.push_back(value); + } } return result; } VideoQuality OverlayWidget::playbackControlsCurrentQuality() { - return _chosenQuality - ? VideoQuality{ - .manual = _quality.manual, - .height = uint32(_chosenQuality->resolveVideoQuality()), - } - : _quality; + if (!_chosenQuality) { + return _quality; + } + auto height = uint32(_chosenQuality->resolveVideoQuality()); + if (_chosenQuality == _document) { + height += Media::kVideoQualityOriginalOffset; + } + return { .manual = _quality.manual, .height = height }; } - void OverlayWidget::playbackControlsQualityChanged(int quality) { applyVideoQuality({ .manual = (quality > 0), @@ -5556,6 +5583,7 @@ void OverlayWidget::updatePlaybackState() { _streamedPosition = state.position; if (_streamed->controls) { _streamed->controls->updatePlayback(state); + _streamed->controls->updateSpeedToggleQuality(); _touchbarTrackState.fire_copy(state); updatePowerSaveBlocker(state); } diff --git a/Telegram/SourceFiles/media/view/media_view_playback_controls.cpp b/Telegram/SourceFiles/media/view/media_view_playback_controls.cpp index e680d3a909..6c2e83bd23 100644 --- a/Telegram/SourceFiles/media/view/media_view_playback_controls.cpp +++ b/Telegram/SourceFiles/media/view/media_view_playback_controls.cpp @@ -225,13 +225,13 @@ void PlaybackControls::saveSpeed(float64 speed) { } void PlaybackControls::saveQuality(int quality) { - _speedToggle->setQuality(_qualitiesList.empty() ? 0 : quality); + _speedToggle->setQuality(quality); _delegate->playbackControlsQualityChanged(quality); } void PlaybackControls::updateSpeedToggleQuality() { const auto quality = _delegate->playbackControlsCurrentQuality(); - _speedToggle->setQuality(_qualitiesList.empty() ? 0 : quality.height); + _speedToggle->setQuality(quality.height); } void PlaybackControls::updatePlaybackSpeed(float64 speed) { diff --git a/Telegram/SourceFiles/media/view/media_view_playback_controls.h b/Telegram/SourceFiles/media/view/media_view_playback_controls.h index 881c109a4e..0cd4f3b551 100644 --- a/Telegram/SourceFiles/media/view/media_view_playback_controls.h +++ b/Telegram/SourceFiles/media/view/media_view_playback_controls.h @@ -71,6 +71,8 @@ public: void setLoadingProgress(int64 ready, int64 total); void setTimestamps(std::vector timestamps); void setInFullScreen(bool inFullScreen); + void updatePlaybackSpeed(float64 speed); + void updateSpeedToggleQuality(); [[nodiscard]] bool hasTimestamps() const; [[nodiscard]] std::optional nextTimestamp( float64 progress) const; @@ -96,7 +98,6 @@ private: [[nodiscard]] float64 countDownloadedTillPercent( const Player::TrackState &state) const; - void updatePlaybackSpeed(float64 speed); void updateVolumeToggleIcon(); void updateDownloadProgressPosition(); @@ -108,7 +109,6 @@ private: void saveSpeed(float64 speed); void saveQuality(int quality); - void updateSpeedToggleQuality(); void updateTimestampLabel(); const not_null _delegate; From 9878ee6fa70122ac9d3a73efb597ae47a8c86dc0 Mon Sep 17 00:00:00 2001 From: John Preston Date: Sun, 19 Apr 2026 22:19:47 +0700 Subject: [PATCH 26/78] Improve work on original video quality. --- Telegram/Resources/langs/lang.strings | 1 + Telegram/SourceFiles/core/core_settings.cpp | 8 +--- Telegram/SourceFiles/data/data_document.cpp | 37 +++++++--------- Telegram/SourceFiles/media/media_common.h | 5 +-- .../media/player/media_player_button.cpp | 14 +++--- .../media/player/media_player_button.h | 5 ++- .../media/player/media_player_dropdown.cpp | 37 +++++++--------- .../media/player/media_player_dropdown.h | 8 ++-- .../streaming/media_streaming_player.cpp | 1 - .../streaming/media_streaming_video_track.cpp | 1 - .../media/view/media_view_overlay_widget.cpp | 44 ++++++++++--------- .../media/view/media_view_overlay_widget.h | 4 +- .../view/media_view_playback_controls.cpp | 8 ++-- .../media/view/media_view_playback_controls.h | 9 ++-- 14 files changed, 84 insertions(+), 98 deletions(-) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 0147c99bb3..cf59430033 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -5608,6 +5608,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_mediaview_playback_speed" = "Playback speed: {speed}"; "lng_mediaview_rotate_video" = "Rotate video"; "lng_mediaview_quality_auto" = "Auto"; +"lng_mediaview_quality_original" = "Original ({quality}p)"; "lng_theme_preview_title" = "Theme Preview"; "lng_theme_preview_generating" = "Generating color theme preview..."; diff --git a/Telegram/SourceFiles/core/core_settings.cpp b/Telegram/SourceFiles/core/core_settings.cpp index 6ce4b5b745..3c156814f2 100644 --- a/Telegram/SourceFiles/core/core_settings.cpp +++ b/Telegram/SourceFiles/core/core_settings.cpp @@ -115,13 +115,7 @@ void LogPosition(const WindowPosition &position, const QString &name) { auto result = Media::VideoQuality(); const auto data = static_cast(&result); memcpy(data, &value, sizeof(result)); - - const auto height = result.height; - const auto offset = Media::kVideoQualityOriginalOffset; - const auto max = 4320; - return (height <= max || (height >= offset && height <= offset + max)) - ? result - : Media::VideoQuality(); + return (result.height <= 4320) ? result : Media::VideoQuality(); } } // namespace diff --git a/Telegram/SourceFiles/data/data_document.cpp b/Telegram/SourceFiles/data/data_document.cpp index 7522dc1bc0..d36b6d94a0 100644 --- a/Telegram/SourceFiles/data/data_document.cpp +++ b/Telegram/SourceFiles/data/data_document.cpp @@ -88,6 +88,16 @@ void UpdateStickerSetIdentifier( }); } +[[nodiscard]] int ResolveAttributeVsTranscodeQuality( + int attributesQuality, + int transcodeMax) { + return (transcodeMax > 0 + && (attributesQuality < transcodeMax + || attributesQuality > transcodeMax * 1.5)) + ? transcodeMax + : attributesQuality; +} + } // namespace QString FileNameUnsafe( @@ -544,8 +554,6 @@ void DocumentData::setVideoQualities( return; } const auto good = [&](not_null document) { - // Transcodes in alt_documents are always streamable, - // even if the supports_streaming flag is missing return document->isVideoFile() && !document->dimensions.isEmpty() && !document->inappPlaybackFailed() @@ -590,14 +598,9 @@ void DocumentData::setVideoQualities( const auto attributesQuality = attributesSize.isEmpty() ? 0 : std::min(attributesSize.width(), attributesSize.height()); - - // Heuristic: Trust attributes resolution unless it blatantly - // contradicts server-side transcodes (>1.5x delta) - auto mine = (transcodeMax > 0 - && (attributesQuality < transcodeMax - || attributesQuality > transcodeMax * 1.5)) - ? transcodeMax - : attributesQuality; + auto mine = ResolveAttributeVsTranscodeQuality( + attributesQuality, + transcodeMax); if (mine) { qualities.insert(begin(qualities), this); } @@ -608,7 +611,6 @@ void DocumentData::setVideoQualities( int DocumentData::resolveVideoQuality() const { if (const auto data = video()) { if (!data->realVideoSize.isEmpty()) { - // Always trust FFmpeg-parsed physical resolution const auto size = data->realVideoSize; return std::min(size.width(), size.height()); } @@ -627,12 +629,9 @@ int DocumentData::resolveVideoQuality() const { } } if (transcodeMax > 0) { - // Trust transcodes if attributes appear fake - if (attributesQuality < transcodeMax - || attributesQuality > transcodeMax * 1.5) { - return transcodeMax; - } - return attributesQuality; + return ResolveAttributeVsTranscodeQuality( + attributesQuality, + transcodeMax); } } } @@ -661,7 +660,7 @@ not_null DocumentData::chooseQuality( return this; } const auto height = int(request.height); - if (height >= Media::kVideoQualityOriginalOffset) { + if (request.original) { return this; } @@ -672,8 +671,6 @@ not_null DocumentData::chooseQuality( for (const auto &quality : list) { const auto qres = quality->resolveVideoQuality(); const auto abs = std::abs(height - qres); - // Prefer Original if it fits target resolution best, - // falling back to transcode only on exact matches if (!closest || abs < closestAbs || (abs == closestAbs && (quality->size < closestSize diff --git a/Telegram/SourceFiles/media/media_common.h b/Telegram/SourceFiles/media/media_common.h index 42d6e4e4d8..b3049900d7 100644 --- a/Telegram/SourceFiles/media/media_common.h +++ b/Telegram/SourceFiles/media/media_common.h @@ -27,7 +27,8 @@ enum class OrderMode { struct VideoQuality { uint32 manual : 1 = 0; - uint32 height : 31 = 0; + uint32 height : 30 = 0; + uint32 original : 1 = 0; friend inline constexpr auto operator<=>( VideoQuality, @@ -41,8 +42,6 @@ inline constexpr auto kSpeedMin = 0.5; inline constexpr auto kSpeedMax = 2.5; inline constexpr auto kSpedUpDefault = 1.7; -inline constexpr auto kVideoQualityOriginalOffset = 1000000; - [[nodiscard]] inline bool EqualSpeeds(float64 a, float64 b) { return int(base::SafeRound(a * 10.)) == int(base::SafeRound(b * 10.)); } diff --git a/Telegram/SourceFiles/media/player/media_player_button.cpp b/Telegram/SourceFiles/media/player/media_player_button.cpp index 8e043be964..59fd6c8743 100644 --- a/Telegram/SourceFiles/media/player/media_player_button.cpp +++ b/Telegram/SourceFiles/media/player/media_player_button.cpp @@ -355,7 +355,7 @@ void SettingsButton::setSpeed(float64 speed) { } } -void SettingsButton::setQuality(int quality) { +void SettingsButton::setQuality(Media::VideoQuality quality) { if (_quality != quality) { _quality = quality; update(); @@ -437,16 +437,14 @@ void SettingsButton::prepareFrame() { : u"%1X"_q.arg(rounded / 10); paintBadge(p, text, RectPart::TopLeft, color); } - const auto displayQuality = (_quality >= Media::kVideoQualityOriginalOffset) - ? (_quality - Media::kVideoQualityOriginalOffset) - : _quality; - const auto text = (!displayQuality) + const auto height = _quality.height; + const auto text = !height ? QString() - : (displayQuality > 2000) + : (height > 2000) ? u"4K"_q - : (displayQuality > 1000) + : (height > 1000) ? u"FHD"_q - : (displayQuality > 700) + : (height > 700) ? u"HD"_q : u"SD"_q; if (!text.isEmpty()) { diff --git a/Telegram/SourceFiles/media/player/media_player_button.h b/Telegram/SourceFiles/media/player/media_player_button.h index 0c667b099f..998e23452a 100644 --- a/Telegram/SourceFiles/media/player/media_player_button.h +++ b/Telegram/SourceFiles/media/player/media_player_button.h @@ -7,6 +7,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once +#include "media/media_common.h" #include "ui/effects/animations.h" #include "ui/widgets/buttons.h" #include "ui/rect_part.h" @@ -111,7 +112,7 @@ public: } void setSpeed(float64 speed); - void setQuality(int quality); + void setQuality(Media::VideoQuality quality); void setActive(bool active); private: @@ -134,7 +135,7 @@ private: Ui::Animations::Simple _overAnimation; QImage _frameCache; float _speed = 1.; - int _quality = 0; + Media::VideoQuality _quality; bool _isDefaultSpeed = false; bool _active = false; diff --git a/Telegram/SourceFiles/media/player/media_player_dropdown.cpp b/Telegram/SourceFiles/media/player/media_player_dropdown.cpp index 00950631e2..8ac53b8fbc 100644 --- a/Telegram/SourceFiles/media/player/media_player_dropdown.cpp +++ b/Telegram/SourceFiles/media/player/media_player_dropdown.cpp @@ -719,9 +719,9 @@ SpeedController::SpeedController( Fn menuOverCallback, Fn value, Fn change, - std::vector qualities, + std::vector qualities, Fn quality, - Fn changeQuality) + Fn changeQuality) : WithDropdownController( button, menuParent, @@ -802,7 +802,7 @@ void SpeedController::save() { void SpeedController::setQuality(VideoQuality quality) { _quality = quality; - _changeQuality(quality.manual ? quality.height : 0); + _changeQuality(quality); } void SpeedController::fillMenu(not_null menu) { @@ -824,15 +824,16 @@ void SpeedController::fillMenu(not_null menu) { raw->addSeparator(&st.dropdown.menu.separator); } - const auto add = [&](int quality) { + const auto add = [&](VideoQuality quality) { const auto automatic = tr::lng_mediaview_quality_auto(tr::now); - const auto offset = Media::kVideoQualityOriginalOffset; - // Quality is height-based, except for Original which uses offset - const auto text = !quality + const auto text = (!quality.height && !quality.original) ? automatic - : (quality >= offset) - ? u"Original (%1p)"_q.arg(std::clamp(quality - offset, 0, 4320)) - : u"%1p"_q.arg(quality); + : quality.original + ? tr::lng_mediaview_quality_original( + tr::now, + lt_quality, + QString::number(quality.height)) + : u"%1p"_q.arg(quality.height); auto action = base::make_unique_q( raw, st.qualityMenu, @@ -861,17 +862,13 @@ void SpeedController::fillMenu(not_null menu) { _quality.value( ) | rpl::on_next([=](VideoQuality now) { const auto chosen = now.manual - ? (now.height == quality) - : !quality; + ? (now == quality) + : (!quality.height && !quality.original); rawAction->action()->setEnabled(!chosen); - if (!quality) { - const auto offset = Media::kVideoQualityOriginalOffset; - const auto displayHeight = (now.height >= offset) - ? std::clamp(int(now.height - offset), 0, 4320) - : now.height; + if (!quality.height && !quality.original) { const auto suffix = now.manual ? QString() - : u"\t%1p"_q.arg(displayHeight); + : u"\t%1p"_q.arg(now.height); rawAction->action()->setText(automatic + suffix); } check->setVisible(chosen); @@ -879,8 +876,8 @@ void SpeedController::fillMenu(not_null menu) { menu->addAction(std::move(action)); }; - add(0); - for (const auto quality : _qualities) { + add(VideoQuality()); + for (const auto &quality : _qualities) { add(quality); } } diff --git a/Telegram/SourceFiles/media/player/media_player_dropdown.h b/Telegram/SourceFiles/media/player/media_player_dropdown.h index 6e047eb73e..45851da7a2 100644 --- a/Telegram/SourceFiles/media/player/media_player_dropdown.h +++ b/Telegram/SourceFiles/media/player/media_player_dropdown.h @@ -133,9 +133,9 @@ public: Fn menuOverCallback, Fn value, Fn change, - std::vector qualities = {}, + std::vector qualities = {}, Fn quality = nullptr, - Fn changeQuality = nullptr); + Fn changeQuality = nullptr); [[nodiscard]] rpl::producer<> saved() const; [[nodiscard]] rpl::producer realtimeValue() const; @@ -160,9 +160,9 @@ private: rpl::event_stream _speedChanged; rpl::event_stream<> _saved; - std::vector _qualities; + std::vector _qualities; Fn _lookupQuality; - Fn _changeQuality; + Fn _changeQuality; rpl::variable _quality; }; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp index 7a61e74aaf..950c0e2f1d 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp @@ -62,7 +62,6 @@ void SaveValidVideoInformation( SaveValidStateInformation(to.state, std::move(from.state)); to.size = from.size; - // Propagate physical resolution parsed from the stream to.realSize = from.realSize; to.cover = std::move(from.cover); to.rotation = from.rotation; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp index 7c554a7a1c..f352f00484 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp @@ -736,7 +736,6 @@ void VideoTrackObject::callReady() { .size = FFmpeg::TransposeSizeByRotation( FFmpeg::CorrectByAspect(frameSize, _stream.aspect), _stream.rotation), - // realSize captures physical resolution before SAR correction .realSize = FFmpeg::TransposeSizeByRotation( frameSize, _stream.rotation), diff --git a/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp b/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp index 1c18cbfb37..83b2430ca6 100644 --- a/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp +++ b/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp @@ -1246,7 +1246,6 @@ QSize OverlayWidget::videoSize() const { Expects(videoShown()); const auto use = (_document && _chosenQuality != _document) - // Use chosen quality dimensions instead of original ? _chosenQuality->dimensions : _streamed->instance.info().video.size; return flipSizeByRotation(use); @@ -4715,9 +4714,11 @@ void OverlayWidget::initStreamingThumbnail() { void OverlayWidget::streamingReady(Streaming::Information &&info) { markStreamedReady(); if (videoShown()) { - if (_document && _streamed && _streamed->ready) { - const auto targetDocument = _chosenQuality ? _chosenQuality : _document; - if (const auto video = targetDocument->video()) { + if (_document + && _streamed + && _streamed->ready + && (!_chosenQuality || _chosenQuality == _document)) { + if (const auto video = _document->video()) { video->realVideoSize = info.video.realSize; } } @@ -5239,7 +5240,7 @@ float64 OverlayWidget::playbackControlsCurrentSpeed(bool lastNonDefault) { return Core::App().settings().videoPlaybackSpeed(lastNonDefault); } -std::vector OverlayWidget::playbackControlsQualities() { +std::vector OverlayWidget::playbackControlsQualities() { if (!_document) { return {}; } @@ -5247,17 +5248,16 @@ std::vector OverlayWidget::playbackControlsQualities() { if (list.empty()) { return {}; } - auto result = std::vector(); + auto result = std::vector(); result.reserve(list.size()); - auto seen = std::vector(); for (const auto &quality : list) { - const auto res = quality->resolveVideoQuality(); - const auto value = (quality == _document) - ? (res + Media::kVideoQualityOriginalOffset) - : res; - if (!ranges::contains(seen, value)) { + const auto value = VideoQuality{ + .manual = 1u, + .height = uint32(quality->resolveVideoQuality()), + .original = (quality == _document) ? 1u : 0u, + }; + if (!ranges::contains(result, value)) { result.push_back(value); - seen.push_back(value); } } return result; @@ -5267,16 +5267,18 @@ VideoQuality OverlayWidget::playbackControlsCurrentQuality() { if (!_chosenQuality) { return _quality; } - auto height = uint32(_chosenQuality->resolveVideoQuality()); - if (_chosenQuality == _document) { - height += Media::kVideoQualityOriginalOffset; - } - return { .manual = _quality.manual, .height = height }; + return { + .manual = _quality.manual, + .height = uint32(_chosenQuality->resolveVideoQuality()), + .original = (_chosenQuality == _document) ? 1u : 0u, + }; } -void OverlayWidget::playbackControlsQualityChanged(int quality) { + +void OverlayWidget::playbackControlsQualityChanged(VideoQuality quality) { applyVideoQuality({ - .manual = (quality > 0), - .height = quality ? uint32(quality) : _quality.height, + .manual = uint32(quality.height ? 1 : 0), + .height = quality.height, + .original = quality.original, }); } diff --git a/Telegram/SourceFiles/media/view/media_view_overlay_widget.h b/Telegram/SourceFiles/media/view/media_view_overlay_widget.h index 6b55cba4e9..5f1fb6f7a5 100644 --- a/Telegram/SourceFiles/media/view/media_view_overlay_widget.h +++ b/Telegram/SourceFiles/media/view/media_view_overlay_widget.h @@ -241,9 +241,9 @@ private: void playbackControlsVolumeChangeFinished() override; void playbackControlsSpeedChanged(float64 speed) override; float64 playbackControlsCurrentSpeed(bool lastNonDefault) override; - std::vector playbackControlsQualities() override; + std::vector playbackControlsQualities() override; VideoQuality playbackControlsCurrentQuality() override; - void playbackControlsQualityChanged(int quality) override; + void playbackControlsQualityChanged(VideoQuality quality) override; void playbackControlsToFullScreen() override; void playbackControlsFromFullScreen() override; void playbackControlsToPictureInPicture() override; diff --git a/Telegram/SourceFiles/media/view/media_view_playback_controls.cpp b/Telegram/SourceFiles/media/view/media_view_playback_controls.cpp index 6c2e83bd23..7cd01b2ea6 100644 --- a/Telegram/SourceFiles/media/view/media_view_playback_controls.cpp +++ b/Telegram/SourceFiles/media/view/media_view_playback_controls.cpp @@ -65,7 +65,7 @@ PlaybackControls::PlaybackControls( : Fn()), _qualitiesList, [=] { return _delegate->playbackControlsCurrentQuality(); }, - [=](int quality) { saveQuality(quality); }) + [=](Media::VideoQuality quality) { saveQuality(quality); }) : nullptr) , _fadeAnimation(std::make_unique(this)) { _fadeAnimation->show(); @@ -214,7 +214,6 @@ void PlaybackControls::fadeUpdated(float64 opacity) { _volumeController->setFadeOpacity(opacity); } - float64 PlaybackControls::speedLookup(bool lastNonDefault) const { return _delegate->playbackControlsCurrentSpeed(lastNonDefault); } @@ -224,14 +223,13 @@ void PlaybackControls::saveSpeed(float64 speed) { _delegate->playbackControlsSpeedChanged(speed); } -void PlaybackControls::saveQuality(int quality) { +void PlaybackControls::saveQuality(Media::VideoQuality quality) { _speedToggle->setQuality(quality); _delegate->playbackControlsQualityChanged(quality); } void PlaybackControls::updateSpeedToggleQuality() { - const auto quality = _delegate->playbackControlsCurrentQuality(); - _speedToggle->setQuality(quality.height); + _speedToggle->setQuality(_delegate->playbackControlsCurrentQuality()); } void PlaybackControls::updatePlaybackSpeed(float64 speed) { diff --git a/Telegram/SourceFiles/media/view/media_view_playback_controls.h b/Telegram/SourceFiles/media/view/media_view_playback_controls.h index 0cd4f3b551..1c06dfb6b6 100644 --- a/Telegram/SourceFiles/media/view/media_view_playback_controls.h +++ b/Telegram/SourceFiles/media/view/media_view_playback_controls.h @@ -46,10 +46,11 @@ public: [[nodiscard]] virtual float64 playbackControlsCurrentSpeed( bool lastNonDefault) = 0; [[nodiscard]] virtual auto playbackControlsQualities() - -> std::vector = 0; + -> std::vector = 0; [[nodiscard]] virtual auto playbackControlsCurrentQuality() -> VideoQuality = 0; - virtual void playbackControlsQualityChanged(int quality) = 0; + virtual void playbackControlsQualityChanged( + Media::VideoQuality quality) = 0; virtual void playbackControlsToFullScreen() = 0; virtual void playbackControlsFromFullScreen() = 0; virtual void playbackControlsToPictureInPicture() = 0; @@ -108,13 +109,13 @@ private: [[nodiscard]] float64 speedLookup(bool lastNonDefault) const; void saveSpeed(float64 speed); - void saveQuality(int quality); + void saveQuality(Media::VideoQuality quality); void updateTimestampLabel(); const not_null _delegate; bool _speedControllable = false; - std::vector _qualitiesList; + std::vector _qualitiesList; bool _inFullScreen = false; bool _showPause = false; From f543c2569e0177db540fe221f312865b20a67bde Mon Sep 17 00:00:00 2001 From: John Preston Date: Sun, 19 Apr 2026 23:06:28 +0700 Subject: [PATCH 27/78] Don't offer sending text as file if hasImage. --- Telegram/SourceFiles/ui/controls/compose_ai_button_factory.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Telegram/SourceFiles/ui/controls/compose_ai_button_factory.cpp b/Telegram/SourceFiles/ui/controls/compose_ai_button_factory.cpp index ed640d242b..b4b7b28189 100644 --- a/Telegram/SourceFiles/ui/controls/compose_ai_button_factory.cpp +++ b/Telegram/SourceFiles/ui/controls/compose_ai_button_factory.cpp @@ -84,6 +84,9 @@ int SendAsFilePasteThreshold() { LargeTextPasteResult CheckLargeTextPaste( not_null field, not_null data) { + if (data->hasImage()) { + return {}; + } const auto pasteText = Core::ReadMimeText(data); if (pasteText.isEmpty()) { return {}; From 02f0d7bb8a8e993b8bf1632566db587908e2dc83 Mon Sep 17 00:00:00 2001 From: John Preston Date: Sun, 19 Apr 2026 23:06:41 +0700 Subject: [PATCH 28/78] Don't set empty good thumbnail. Image(QImage&&) for isNull() sets a fully transparent QImage. This leads to uninitialized memory QImages in prepared thumbnails, because we allocate QImage and start painting thumbnail parts in it. --- Telegram/SourceFiles/data/data_document_media.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Telegram/SourceFiles/data/data_document_media.cpp b/Telegram/SourceFiles/data/data_document_media.cpp index 478b2fc79d..ef6266aeb2 100644 --- a/Telegram/SourceFiles/data/data_document_media.cpp +++ b/Telegram/SourceFiles/data/data_document_media.cpp @@ -181,7 +181,7 @@ Image *DocumentMedia::goodThumbnail() const { } void DocumentMedia::setGoodThumbnail(QImage thumbnail) { - if (!(_flags & Flag::GoodThumbnailWanted)) { + if (!(_flags & Flag::GoodThumbnailWanted) || thumbnail.isNull()) { return; } _goodThumbnail = std::make_unique(std::move(thumbnail)); From 786e8999e18fc44baaf96162c82f2cfddfd766d2 Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Sun, 19 Apr 2026 15:50:56 +0000 Subject: [PATCH 29/78] Synchronize system accent color avilability between toggle and setting --- Telegram/SourceFiles/settings/sections/settings_chat.cpp | 6 +----- .../SourceFiles/window/themes/window_themes_embedded.cpp | 5 +++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Telegram/SourceFiles/settings/sections/settings_chat.cpp b/Telegram/SourceFiles/settings/sections/settings_chat.cpp index 2cac29431a..c4c4cfe15a 100644 --- a/Telegram/SourceFiles/settings/sections/settings_chat.cpp +++ b/Telegram/SourceFiles/settings/sections/settings_chat.cpp @@ -99,11 +99,7 @@ const auto kSchemesList = Window::Theme::EmbeddedThemes(); constexpr auto kCustomColorButtonParts = 7; [[nodiscard]] bool IsSystemAccentColorSupported() { -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - return true; -#else - return !Platform::IsWindows() || !Platform::IsWindows8OrGreater(); -#endif + return Window::Theme::SystemAccentColor().has_value(); } class ColorsPalette final { diff --git a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp index 58ebea3240..48511057eb 100644 --- a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp +++ b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp @@ -179,6 +179,11 @@ style::colorizer ColorizerFrom( } std::optional SystemAccentColor() { +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + if (Platform::IsWindows() && !Platform::IsWindows8OrGreater()) { + return std::nullopt; + } +#endif // Qt < 6.0.0 #if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) constexpr auto kAccentRole = QPalette::ColorRole::Accent; #else From 92192715bb58ee5f726aef71f623e8e1d0c6db5b Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Sun, 19 Apr 2026 15:55:12 +0000 Subject: [PATCH 30/78] Use shorter QPalette API --- .../SourceFiles/window/themes/window_themes_embedded.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp index 48511057eb..6b33b3cd09 100644 --- a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp +++ b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp @@ -185,11 +185,11 @@ std::optional SystemAccentColor() { } #endif // Qt < 6.0.0 #if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) - constexpr auto kAccentRole = QPalette::ColorRole::Accent; + constexpr auto kAccentRole = QPalette::Accent; #else - constexpr auto kAccentRole = QPalette::ColorRole::Highlight; + constexpr auto kAccentRole = QPalette::Highlight; #endif - const auto accent = QGuiApplication::palette().color(kAccentRole); + const auto accent = QPalette().color(kAccentRole); return accent.isValid() ? std::make_optional(accent) : std::nullopt; } From 6a1212de82ea9386edd647ba598e58e7199dfb26 Mon Sep 17 00:00:00 2001 From: Ilya Fedin Date: Sun, 19 Apr 2026 15:59:42 +0000 Subject: [PATCH 31/78] Remove QPalette::Accent support Looks like even Qt's internal plugins don't always set it and when it's set, it's similar to highlight anyway --- .../SourceFiles/window/themes/window_themes_embedded.cpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp index 6b33b3cd09..f2861a2ccc 100644 --- a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp +++ b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp @@ -184,12 +184,7 @@ std::optional SystemAccentColor() { return std::nullopt; } #endif // Qt < 6.0.0 -#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) - constexpr auto kAccentRole = QPalette::Accent; -#else - constexpr auto kAccentRole = QPalette::Highlight; -#endif - const auto accent = QPalette().color(kAccentRole); + const auto accent = QPalette().color(QPalette::Highlight); return accent.isValid() ? std::make_optional(accent) : std::nullopt; } From 11e84ffbefe7c5c6a57bcbad57882fcaec18c7fc Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Sun, 15 Feb 2026 16:48:15 +0330 Subject: [PATCH 32/78] feat(accessibility): add screen reader support for language list --- Telegram/Resources/langs/lang.strings | 2 + Telegram/SourceFiles/boxes/language_box.cpp | 189 ++++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index cf59430033..56e4a3014f 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -7919,5 +7919,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_mac_hold_to_quit" = "Hold {text} to Quit"; "lng_sr_country_column_name" = "Country name"; +"lng_sr_languages_column_native" = "Native name"; +"lng_sr_languages_column_name" = "Language name"; // Keys finished diff --git a/Telegram/SourceFiles/boxes/language_box.cpp b/Telegram/SourceFiles/boxes/language_box.cpp index c9b9b22a52..fb6ff36cca 100644 --- a/Telegram/SourceFiles/boxes/language_box.cpp +++ b/Telegram/SourceFiles/boxes/language_box.cpp @@ -9,6 +9,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_peer_values.h" #include "lang/lang_keys.h" +#include "base/screen_reader_state.h" +#include "ui/accessible/ui_accessible_item.h" #include "ui/boxes/choose_language_box.h" #include "ui/widgets/checkbox.h" #include "ui/widgets/buttons.h" @@ -51,6 +53,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_menu_icons.h" #include "styles/style_settings.h" +#include + #include #include @@ -71,11 +75,13 @@ public: int count() const; int selected() const; + int chosenIndex() const; void setSelected(int selected); rpl::producer hasSelection() const; rpl::producer isEmpty() const; void activateSelected(); + void selectSkip(int dir); rpl::producer activations() const; void changeChosen(const QString &chosen); @@ -83,10 +89,87 @@ public: static int DefaultRowHeight(); + QAccessible::Role accessibilityRole() override { + return QAccessible::List; + } + + QAccessible::Role accessibilityChildRole() const override { + return QAccessible::RadioButton; + } + + QAccessible::State accessibilityChildState(int index) const override { + QAccessible::State state; + if (base::ScreenReaderState::Instance()->active()) { + state.focusable = true; + } + state.checkable = true; + state.checked = (index == chosenIndex()); + if (index == selected()) { + state.active = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; + } + + int accessibilityChildCount() const override { + return count(); + } + + QString accessibilityChildName(int index) const override { + if (index < 0 || index >= count()) { + return {}; + } + const auto &row = rowByIndex(index); + // Announce native name followed by English name. + return row.data.nativeName + u", "_q + row.data.name; + } + + QRect accessibilityChildRect(int index) const override { + if (index < 0 || index >= count()) { + return QRect(); + } + const auto &row = rowByIndex(index); + return QRect(0, row.top, width(), row.height); + } + + int accessibilityChildColumnCount(int row) const override { + return 2; + } + + QAccessible::Role accessibilityChildSubItemRole() const override { + return QAccessible::Cell; + } + + QString accessibilityChildSubItemName(int row, int column) const override { + if (column == 0) { + return tr::lng_sr_languages_column_native(tr::now); + } else if (column == 1) { + return tr::lng_sr_languages_column_name(tr::now); + } + return {}; + } + + QString accessibilityChildSubItemValue(int row, int column) const override { + if (row < 0 || row >= count()) { + return {}; + } + const auto &data = rowByIndex(row).data; + if (column == 0) { + return data.nativeName; + } else if (column == 1) { + return data.name; + } + return {}; + } + protected: int resizeGetHeight(int newWidth) override; + void focusInEvent(QFocusEvent *e) override; void paintEvent(QPaintEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; @@ -288,6 +371,63 @@ Rows::Rows( resizeToWidth(width()); setAttribute(Qt::WA_MouseTracking); update(); + + setAccessibleName(tr::lng_languages(tr::now)); + + base::ScreenReaderState::Instance()->activeValue( + ) | rpl::on_next([=](bool active) { + setFocusPolicy(active ? Qt::TabFocus : Qt::NoFocus); + }, lifetime()); +} + +void Rows::focusInEvent(QFocusEvent *e) { + // Select first item or chosen item when focus enters. + if (selected() < 0 && count() > 0) { + const auto chosen = chosenIndex(); + setSelected(chosen >= 0 ? chosen : 0); + } + + RpWidget::focusInEvent(e); + + if (base::ScreenReaderState::Instance()->active()) { + const auto index = selected(); + if (index >= 0) { + InvokeQueued(this, [=] { + if (selected() != index || !hasFocus()) { + return; + } + accessibilityChildFocused(index); + }); + } + } +} + +void Rows::keyPressEvent(QKeyEvent *e) { + const auto key = e->key(); + if (key == Qt::Key_Down) { + selectSkip(1); + } else if (key == Qt::Key_Up) { + selectSkip(-1); + } else if (key == Qt::Key_PageDown || key == Qt::Key_PageUp) { + const auto visibleHeight = visibleRegion().boundingRect().height(); + const auto rowsPerPage = std::max(visibleHeight / DefaultRowHeight(), 1); + selectSkip(key == Qt::Key_PageDown ? rowsPerPage : -rowsPerPage); + } else if (key == Qt::Key_Home) { + if (count() > 0) { + setSelected(0); + } + } else if (key == Qt::Key_End) { + if (count() > 0) { + setSelected(count() - 1); + } + } else if (!e->isAutoRepeat() + && (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter)) { + activateSelected(); + } else { + RpWidget::keyPressEvent(e); + } } void Rows::mouseMoveEvent(QMouseEvent *e) { @@ -555,7 +695,12 @@ void Rows::setForceRippled(not_null row, bool rippled) { } void Rows::activateByIndex(int index) { + _chosen = rowByIndex(index).data.id; _activations.fire_copy(rowByIndex(index).data); + if (base::ScreenReaderState::Instance()->active()) { + accessibilityChildStateChanged(index, { .checked = true }); + accessibilityChildNameChanged(index); + } } void Rows::leaveEventHook(QEvent *e) { @@ -631,6 +776,15 @@ int Rows::selected() const { return indexFromSelection(_selected); } +int Rows::chosenIndex() const { + for (auto i = 0, n = count(); i < n; ++i) { + if (rowByIndex(i).data.id == _chosen) { + return i; + } + } + return -1; +} + void Rows::activateSelected() { const auto index = selected(); if (index >= 0) { @@ -638,14 +792,44 @@ void Rows::activateSelected() { } } +void Rows::selectSkip(int dir) { + const auto limit = count(); + auto now = selected(); + // If no keyboard selection, start from the checked item. + if (now < 0) { + now = chosenIndex(); + } + if (now >= 0) { + const auto changed = now + dir; + if (changed < 0) { + setSelected(0); + } else if (changed >= limit) { + setSelected(limit - 1); + } else { + setSelected(changed); + } + } else if (dir > 0) { + setSelected(0); + } +} + rpl::producer Rows::activations() const { return _activations.events(); } void Rows::changeChosen(const QString &chosen) { + const auto oldIndex = chosenIndex(); + _chosen = chosen; for (const auto &row : _rows) { row.check->setChecked(row.data.id == chosen, anim::type::normal); } + if (base::ScreenReaderState::Instance()->active()) { + const auto newIndex = chosenIndex(); + if (newIndex != oldIndex && newIndex >= 0) { + accessibilityChildStateChanged(newIndex, { .checked = true }); + accessibilityChildNameChanged(newIndex); + } + } } void Rows::setSelected(int selected) { @@ -656,6 +840,11 @@ void Rows::setSelected(int selected) { } else { updateSelected({}); } + if (selected >= 0 && selected < limit + && base::ScreenReaderState::Instance()->active()) { + accessibilityChildNameChanged(selected); + accessibilityChildFocused(selected); + } } rpl::producer Rows::hasSelection() const { From 6468b8ab19a437359c8a906a9c08759020411592 Mon Sep 17 00:00:00 2001 From: John Preston Date: Sun, 19 Apr 2026 23:24:23 +0700 Subject: [PATCH 33/78] Proxy share t.me, QR with tg://, accept both. --- Telegram/SourceFiles/boxes/connection_box.cpp | 75 ++++++++++++++----- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/Telegram/SourceFiles/boxes/connection_box.cpp b/Telegram/SourceFiles/boxes/connection_box.cpp index 7ca091ebf2..2ec2e938fd 100644 --- a/Telegram/SourceFiles/boxes/connection_box.cpp +++ b/Telegram/SourceFiles/boxes/connection_box.cpp @@ -78,22 +78,41 @@ using ProxyData = MTP::ProxyData; return result; } -[[nodiscard]] std::vector ExtractUrlsSimple(const QString &input) { +[[nodiscard]] std::vector ExtractLinkCandidates(const QString &input) { auto urls = std::vector(); - static auto urlRegex = QRegularExpression(R"((https?:\/\/[^\s]+))"); + static const auto urlRegex = QRegularExpression( + R"((?:https?:\/\/[^\s]+|tg:\/\/[^\s]+|(?:www\.)?(?:t\.me|telegram\.me|telegram\.dog)\/[^\s]+))", + QRegularExpression::CaseInsensitiveOption); auto it = urlRegex.globalMatch(input); while (it.hasNext()) { - urls.push_back(it.next().captured(1)); + urls.push_back(it.next().captured(0)); } return urls; } -[[nodiscard]] QString ProxyDataToString(const ProxyData &proxy) { +[[nodiscard]] bool ProxyDataIsShareable(const ProxyData &proxy) { using Type = ProxyData::Type; - return u"tg://"_q - + (proxy.type == Type::Socks5 ? "socks" : "proxy") + return (proxy.type == Type::Socks5) + || (proxy.type == Type::Mtproto); +} + +[[nodiscard]] QString ProxyDataToQueryPath(const ProxyData &proxy) { + using Type = ProxyData::Type; + const auto path = [&] { + switch (proxy.type) { + case Type::Socks5: return u"socks"_q; + case Type::Mtproto: return u"proxy"_q; + case Type::None: + case Type::Http: return QString(); + } + Unexpected("Proxy type in ProxyDataToQueryPath."); + }(); + if (path.isEmpty()) { + return QString(); + } + return path + "?server=" + proxy.host + "&port=" + QString::number(proxy.port) + ((proxy.type == Type::Socks5 && !proxy.user.isEmpty()) ? "&user=" + qthelp::url_encode(proxy.user) : "") @@ -103,6 +122,20 @@ using ProxyData = MTP::ProxyData; ? "&secret=" + proxy.password : ""); } +[[nodiscard]] QString ProxyDataToLocalLink(const ProxyData &proxy) { + const auto queryPath = ProxyDataToQueryPath(proxy); + return queryPath.isEmpty() ? QString() : (u"tg://"_q + queryPath); +} + +[[nodiscard]] QString ProxyDataToPublicLink( + const Main::Session &session, + const ProxyData &proxy) { + const auto queryPath = ProxyDataToQueryPath(proxy); + return queryPath.isEmpty() + ? QString() + : session.createInternalLinkFull(queryPath); +} + [[nodiscard]] ProxyData ProxyDataFromFields( ProxyData::Type type, const QMap &fields) { @@ -126,7 +159,7 @@ void AddProxyFromClipboard( const auto socksString = u"socks"_q; const auto protocol = u"tg://"_q; - const auto maybeUrls = ExtractUrlsSimple( + const auto maybeUrls = ExtractLinkCandidates( QGuiApplication::clipboard()->text()); const auto isSingle = maybeUrls.size() == 1; @@ -144,8 +177,8 @@ void AddProxyFromClipboard( protocol.size(), 8192); - if (local.startsWith(protocol + proxyString) - || local.startsWith(protocol + socksString)) { + if (local.startsWith(protocol + proxyString, Qt::CaseInsensitive) + || local.startsWith(protocol + socksString, Qt::CaseInsensitive)) { using namespace qthelp; const auto options = RegExOption::CaseInsensitive; @@ -204,7 +237,10 @@ void AddProxyFromClipboard( auto success = Result::Failed; for (const auto &maybeUrl : maybeUrls) { - const auto result = proceedUrl(Core::TryConvertUrlToLocal(maybeUrl)); + const auto trimmed = maybeUrl.trimmed(); + const auto local = Core::TryConvertUrlToLocal(trimmed); + const auto check = local.isEmpty() ? trimmed : local; + const auto result = proceedUrl(check); if (success != Result::Success) { success = result; } @@ -1823,8 +1859,9 @@ void ProxiesBoxController::shareItem(int id, bool qr) { void ProxiesBoxController::shareItems() { auto result = QString(); for (const auto &item : _list) { - if (!item.deleted) { - result += ProxyDataToString(item.data) + '\n' + '\n'; + if (!item.deleted && ProxyDataIsShareable(item.data)) { + result += ProxyDataToPublicLink(_account->session(), item.data) + + '\n' + '\n'; } } if (result.isEmpty()) { @@ -2060,7 +2097,7 @@ auto ProxiesBoxController::views() const -> rpl::producer { rpl::producer ProxiesBoxController::listShareableChanges() const { return _views.events_starting_with(ItemView()) | rpl::map([=] { for (const auto &item : _list) { - if (!item.deleted) { + if (!item.deleted && ProxyDataIsShareable(item.data)) { return true; } } @@ -2087,8 +2124,7 @@ void ProxiesBoxController::updateView(const Item &item) { } return ItemState::Connecting; }(); - const auto supportsShare = (item.data.type == Type::Socks5) - || (item.data.type == Type::Mtproto); + const auto supportsShare = ProxyDataIsShareable(item.data); const auto supportsCalls = item.data.supportsCalls(); _views.fire({ item.id, @@ -2105,18 +2141,19 @@ void ProxiesBoxController::updateView(const Item &item) { } void ProxiesBoxController::share(const ProxyData &proxy, bool qr) { - if (proxy.type == Type::Http) { + if (!ProxyDataIsShareable(proxy)) { return; } - const auto link = ProxyDataToString(proxy); + const auto qrLink = ProxyDataToLocalLink(proxy); + const auto shareLink = ProxyDataToPublicLink(_account->session(), proxy); if (qr) { _show->showBox(Box([=](not_null box) { - Ui::FillPeerQrBox(box, nullptr, link, rpl::single(QString())); + Ui::FillPeerQrBox(box, nullptr, qrLink, rpl::single(QString())); box->setTitle(tr::lng_proxy_edit_share_qr_box_title()); })); return; } - QGuiApplication::clipboard()->setText(link); + QGuiApplication::clipboard()->setText(shareLink); _show->showToast(tr::lng_username_copied(tr::now)); } From eac7141ce7c43e8365e55ba97c929985ec773ff6 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 20 Apr 2026 10:53:30 +0700 Subject: [PATCH 34/78] Use photo as video thumbnail in webpage. --- Telegram/SourceFiles/data/data_session.cpp | 29 ++++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/Telegram/SourceFiles/data/data_session.cpp b/Telegram/SourceFiles/data/data_session.cpp index 2163ec9554..3822e7c187 100644 --- a/Telegram/SourceFiles/data/data_session.cpp +++ b/Telegram/SourceFiles/data/data_session.cpp @@ -4154,6 +4154,21 @@ void Session::webpageApplyFields( auto iv = (data.vcached_page() && !IgnoreIv(type)) ? std::make_unique(data, *data.vcached_page()) : nullptr; + const auto resolvedPhoto = story + ? story->photo() + : photo + ? processPhoto(*photo).get() + : nullptr; + const auto resolvedDocument = story + ? story->document() + : document + ? processDocument(*document).get() + : lookupThemeDocument(); + const auto photoIsVideoCover = data.is_video_cover_photo() + || (resolvedDocument + && resolvedPhoto + && resolvedDocument->isVideoFile() + && !resolvedDocument->hasThumbnail()); webpageApplyFields( page, type, @@ -4163,16 +4178,8 @@ void Session::webpageApplyFields( qs(data.vtitle().value_or_empty()), (story ? story->caption() : description), storyId, - (story - ? story->photo() - : photo - ? processPhoto(*photo).get() - : nullptr), - (story - ? story->document() - : document - ? processDocument(*document).get() - : lookupThemeDocument()), + resolvedPhoto, + resolvedDocument, WebPageCollage(this, data), std::move(iv), lookupStickerSet(), @@ -4181,7 +4188,7 @@ void Session::webpageApplyFields( data.vduration().value_or_empty(), qs(data.vauthor().value_or_empty()), data.is_has_large_media(), - data.is_video_cover_photo(), + photoIsVideoCover, pendingTill); } From 5ef60debc2da6af305c8181455403a852d01a1c0 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 20 Apr 2026 10:53:54 +0700 Subject: [PATCH 35/78] Fix build with MSVC. --- Telegram/SourceFiles/window/themes/window_themes_embedded.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp index f2861a2ccc..829cef7d2c 100644 --- a/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp +++ b/Telegram/SourceFiles/window/themes/window_themes_embedded.cpp @@ -7,12 +7,13 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "window/themes/window_themes_embedded.h" -#include "window/themes/window_theme.h" +#include "base/platform/base_platform_info.h" #include "lang/lang_keys.h" #include "storage/serialize_common.h" #include "core/application.h" #include "core/core_settings.h" #include "ui/style/style_palette_colorizer.h" +#include "window/themes/window_theme.h" #include #include From 22449a0913c063ad52b15f008c927368cd95ca4b Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 07:37:21 +0300 Subject: [PATCH 36/78] Fixed opening of stories in albums with reorder enabled. Regression was introduced: c5ea86b474. --- Telegram/SourceFiles/info/media/info_media_list_widget.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Telegram/SourceFiles/info/media/info_media_list_widget.cpp b/Telegram/SourceFiles/info/media/info_media_list_widget.cpp index d2a9441128..dcee596edc 100644 --- a/Telegram/SourceFiles/info/media/info_media_list_widget.cpp +++ b/Telegram/SourceFiles/info/media/info_media_list_widget.cpp @@ -2221,11 +2221,14 @@ void ListWidget::mouseActionFinish( && (button != Qt::RightButton) && (_mouseAction == MouseAction::PrepareDrag || _mouseAction == MouseAction::PrepareSelect); - if (_mouseAction == MouseAction::Reordering - || _mouseAction == MouseAction::PrepareReorder) { + if (_mouseAction == MouseAction::Reordering) { finishReorder(); return; } + if (_mouseAction == MouseAction::PrepareReorder) { + _reorderState = {}; + _mouseAction = MouseAction::PrepareDrag; + } const auto needSelectionToggle = simpleSelectionChange && selectionMode; const auto needSelectionClear = simpleSelectionChange && hasSelectedText(); From 252bf3e363f54860f2ca23d7dec71a9db050c7a7 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 08:31:47 +0300 Subject: [PATCH 37/78] Improved search of emoji from emoji list widget. --- .../SourceFiles/chat_helpers/emoji_list_widget.cpp | 11 ++++++----- .../SourceFiles/chat_helpers/stickers_list_footer.cpp | 5 +++-- .../SourceFiles/chat_helpers/stickers_list_footer.h | 3 ++- Telegram/SourceFiles/ui/controls/tabbed_search.cpp | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp index 3957bd2e75..a777157679 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp @@ -670,12 +670,13 @@ void EmojiListWidget::applyNextSearchQuery() { } const auto guard = gsl::finally([&] { finish(); }); auto plain = collectPlainSearchResults(); - if (_searchEmoji == _searchEmojiPrevious) { - return; - } _searchEmoticon = QString(); - for (const auto emoji : plain) { - _searchEmoticon += emoji->text(); + { + auto exactSet = base::flat_set(); + const auto exact = SearchEmoji(_searchQuery, exactSet, true); + for (const auto emoji : exact) { + _searchEmoticon += emoji->text(); + } } _searchResults.clear(); _searchCustomIds.clear(); diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp index a0394d1ade..8971ceec58 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp @@ -146,7 +146,8 @@ rpl::producer> GifSectionsValue( [[nodiscard]] std::vector SearchEmoji( const std::vector &query, - base::flat_set &outResultSet) { + base::flat_set &outResultSet, + bool exact) { auto result = std::vector(); const auto pushPlain = [&](EmojiPtr emoji) { if (result.size() < kEmojiSearchLimit @@ -170,7 +171,7 @@ rpl::producer> GifSectionsValue( refreshed = true; keywords.refresh(); } - const auto list = keywords.queryMine(entry); + const auto list = keywords.queryMine(entry, exact); for (const auto &entry : list) { pushPlain(entry.emoji); if (result.size() >= kEmojiSearchLimit) { diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h index a76b1ee21a..e5910d60c4 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h @@ -70,7 +70,8 @@ struct GifSection { [[nodiscard]] std::vector SearchEmoji( const std::vector &query, - base::flat_set &outResultSet); + base::flat_set &outResultSet, + bool exact = false); struct StickerIcon { explicit StickerIcon(uint64 setId); diff --git a/Telegram/SourceFiles/ui/controls/tabbed_search.cpp b/Telegram/SourceFiles/ui/controls/tabbed_search.cpp index 55b9edd481..d806e7a390 100644 --- a/Telegram/SourceFiles/ui/controls/tabbed_search.cpp +++ b/Telegram/SourceFiles/ui/controls/tabbed_search.cpp @@ -80,7 +80,7 @@ private: }; [[nodiscard]] std::vector FieldQuery(not_null field) { - if (const auto last = field->getLastText(); !last.isEmpty()) { + if (const auto last = field->getLastText().trimmed(); !last.isEmpty()) { return { last }; } return {}; From 91145dc8680cd6b2f99ed9bfd421fe5d4cf04d7b Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 09:29:56 +0300 Subject: [PATCH 38/78] Removed matching of installed emoji pack titles from emoji search. --- .../chat_helpers/emoji_list_widget.cpp | 62 ------------------- .../chat_helpers/emoji_list_widget.h | 1 - 2 files changed, 63 deletions(-) diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp index a777157679..3fc24d1fd7 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp @@ -691,9 +691,6 @@ void EmojiListWidget::applyNextSearchQuery() { if (_mode != Mode::Full || session().premium()) { appendPremiumSearchResults(); } - if (_mode == Mode::Full) { - appendLocalPackSearchResults(); - } _searchQueryText = ranges::accumulate( _searchQuery, @@ -823,62 +820,6 @@ void EmojiListWidget::appendPremiumSearchResults() { } } -void EmojiListWidget::appendLocalPackSearchResults() { - const auto text = _searchQueryText.toLower(); - if (text.isEmpty()) { - return; - } - const auto test = session().isTestMode(); - const auto &sets = session().data().stickers().sets(); - const auto processSet = [&](uint64 setId) { - const auto it = sets.find(setId); - if (it == sets.end()) { - return; - } - const auto set = it->second.get(); - if (!(set->flags & Data::StickersSetFlag::Emoji)) { - return; - } - const auto title = set->title.toLower(); - if (!title.startsWith(text) - && !title.contains(u' ' + text)) { - return; - } - const auto &list = set->stickers.empty() - ? set->covers - : set->stickers; - for (const auto document : list) { - if (_searchResults.size() >= kCustomSearchLimit) { - return; - } - const auto sticker = document->sticker(); - if (!sticker) { - continue; - } - const auto id = document->id; - if (!_searchCustomIds.emplace(id).second) { - continue; - } - const auto statusId = EmojiStatusId{ id }; - _searchResults.push_back({ - .custom = resolveCustomEmoji( - statusId, - document, - SearchEmojiSectionSetId()), - .id = { RecentEmojiDocument{ .id = id, .test = test } }, - }); - } - }; - for (const auto setId - : session().data().stickers().emojiSetsOrder()) { - processSet(setId); - } - for (const auto setId - : session().data().stickers().featuredEmojiSetsOrder()) { - processSet(setId); - } -} - void EmojiListWidget::toggleSearchLoading(bool loading) { if (_search) { _search->setLoading(loading); @@ -1132,9 +1073,6 @@ void EmojiListWidget::showSearchResults() { appendPremiumSearchResults(); } fillCloudSearchResults(); - if (_mode == Mode::Full) { - appendLocalPackSearchResults(); - } fillCloudSearchSets(); resizeToWidth(width()); diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h index 0d0ad370da..afa38c9fbe 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h @@ -299,7 +299,6 @@ private: void setupSearch(); [[nodiscard]] std::vector collectPlainSearchResults(); void appendPremiumSearchResults(); - void appendLocalPackSearchResults(); void sendSearchRequest(); void sendSearchSetsRequest(const QString &query); void requestSearchCloud( From 04aec11daabe98b7e1219f7543ef395c969233fb Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 11:06:47 +0300 Subject: [PATCH 39/78] Added reply header with preview to send files box. --- Telegram/CMakeLists.txt | 2 + Telegram/SourceFiles/boxes/boxes.style | 1 + Telegram/SourceFiles/boxes/send_files_box.cpp | 71 +++- Telegram/SourceFiles/boxes/send_files_box.h | 15 +- .../boxes/send_files_box_reply_header.cpp | 372 ++++++++++++++++++ .../boxes/send_files_box_reply_header.h | 77 ++++ .../chat_helpers/chat_helpers.style | 17 +- .../SourceFiles/history/history_widget.cpp | 14 +- .../history_view_compose_controls.cpp | 13 +- .../view/history_view_chat_section.cpp | 8 +- .../view/history_view_scheduled_section.cpp | 3 +- .../media/stories/media_stories_reply.cpp | 7 +- .../business/settings_shortcut_messages.cpp | 3 +- .../attach_abstract_single_media_preview.cpp | 11 + .../window/session/window_session_media.cpp | 5 +- 15 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 Telegram/SourceFiles/boxes/send_files_box_reply_header.cpp create mode 100644 Telegram/SourceFiles/boxes/send_files_box_reply_header.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index f5c3c1499c..b6499165b8 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -356,6 +356,8 @@ PRIVATE boxes/send_gif_with_caption_box.h boxes/send_files_box.cpp boxes/send_files_box.h + boxes/send_files_box_reply_header.cpp + boxes/send_files_box_reply_header.h boxes/share_box.cpp boxes/share_box.h boxes/star_gift_auction_box.cpp diff --git a/Telegram/SourceFiles/boxes/boxes.style b/Telegram/SourceFiles/boxes/boxes.style index 0698689800..bf1caf551f 100644 --- a/Telegram/SourceFiles/boxes/boxes.style +++ b/Telegram/SourceFiles/boxes/boxes.style @@ -59,6 +59,7 @@ boxPhotoTitlePosition: point(28px, 20px); boxPhotoPadding: margins(28px, 28px, 28px, 18px); boxPhotoCompressedSkip: 20px; boxPhotoCaptionSkip: 8px; +boxPhotoCaptionReplyOverlap: 5px; defaultChangeUserpicIcon: icon {{ "new_chat_photo", activeButtonFg }}; defaultUploadUserpicIcon: icon {{ "upload_chat_photo", msgDateImgFg }}; diff --git a/Telegram/SourceFiles/boxes/send_files_box.cpp b/Telegram/SourceFiles/boxes/send_files_box.cpp index 2b1eb7085f..a7535d837d 100644 --- a/Telegram/SourceFiles/boxes/send_files_box.cpp +++ b/Telegram/SourceFiles/boxes/send_files_box.cpp @@ -37,6 +37,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/premium_preview_box.h" #include "boxes/send_gif_with_caption_box.h" #include "boxes/send_credits_box.h" +#include "boxes/send_files_box_reply_header.h" #include "ui/effects/scroll_content_shadow.h" #include "ui/widgets/fields/number_input.h" #include "ui/widgets/checkbox.h" @@ -429,6 +430,10 @@ int SendFilesBox::Block::fromIndex() const { return _from; } +bool SendFilesBox::Block::isSingleFile() const { + return !_isAlbum && !_isSingleMedia; +} + int SendFilesBox::Block::tillIndex() const { return _till; } @@ -646,9 +651,58 @@ SendFilesBox::SendFilesBox(QWidget*, SendFilesBoxDescriptor &&descriptor) , _inner( _scroll->setOwnedWidget( object_ptr(_scroll.data()))) { + setReplyTo(descriptor.replyTo); enqueueNextPrepare(); } +void SendFilesBox::setReplyTo(FullReplyTo replyTo) { + if (_replyTo == replyTo) { + return; + } else if (!replyTo.messageId || !replyTo.messageId.peer) { + _replyTo = {}; + if (_replyHeader) { + _replyHeader->hideAnimated(); + } + return; + } + _replyTo = replyTo; + if (_replyHeader) { + _replyHeader = nullptr; + _replyHeaderHeight = 0; + } + _replyHeader = std::make_unique( + this, + _show, + std::move(replyTo)); + _replyHeader->setRoundedShapeBelow( + !_blocks.empty() && !_blocks.front().isSingleFile()); + _replyHeader->show(); + _replyHeader->desiredHeight( + ) | rpl::on_next([=](int height) { + if (_replyHeaderHeight.current() != height) { + _replyHeaderHeight = height; + updateBoxSize(); + updateControlsGeometry(); + } + }, _replyHeader->lifetime()); + _replyHeader->closeRequests( + ) | rpl::on_next([=] { + _replyTo = {}; + if (_replyHeader) { + _replyHeader->hideAnimated(); + } + }, _replyHeader->lifetime()); + _replyHeader->hideFinished( + ) | rpl::on_next([=] { + InvokeQueued(this, [=] { + _replyHeader = nullptr; + _replyHeaderHeight = 0; + updateBoxSize(); + updateControlsGeometry(); + }); + }, _replyHeader->lifetime()); +} + Fn SendFilesBox::prepareSendMenuDetails( const SendFilesBoxDescriptor &descriptor) { auto initial = descriptor.sendMenuDetails; @@ -1219,6 +1273,10 @@ void SendFilesBox::generatePreviewFrom(int fromBlock) { if (albumStart >= 0) { pushBlock(albumStart, _list.files.size()); } + if (_replyHeader) { + _replyHeader->setRoundedShapeBelow( + !_blocks.empty() && !_blocks.front().isSingleFile()); + } } void SendFilesBox::pushBlock(int from, int till) { @@ -2083,6 +2141,7 @@ void SendFilesBox::updateBoxSize() { if (!_caption->isHidden()) { footerHeight += st::boxPhotoCaptionSkip + _caption->height(); } + footerHeight += _replyHeaderHeight.current(); const auto pairs = std::array, 4>{ { { _groupFiles.data(), st::boxPhotoCompressedSkip }, { _sendImagesAsPhotos.data(), st::boxPhotoCompressedSkip }, @@ -2170,8 +2229,14 @@ void SendFilesBox::updateControlsGeometry() { bottom -= pair.second + pointer->heightNoMargins(); } } - _scroll->resize(width(), bottom - _titleHeight.current()); - _scroll->move(0, _titleHeight.current()); + const auto replyH = _replyHeaderHeight.current(); + const auto replyTopOverlap = std::min(st::boxPhotoCaptionSkip, replyH); + const auto replyTop = _titleHeight.current() - replyTopOverlap; + if (_replyHeader) { + _replyHeader->setGeometry(0, replyTop, width(), replyH); + } + _scroll->resize(width(), bottom - replyTop - replyH); + _scroll->move(0, replyTop + replyH); } void SendFilesBox::showFinished() { @@ -2316,7 +2381,7 @@ void SendFilesBox::send( } } - _confirmedCallback(std::move(bundle), options); + _confirmedCallback(std::move(bundle), options, _replyTo); } closeBox(); } diff --git a/Telegram/SourceFiles/boxes/send_files_box.h b/Telegram/SourceFiles/boxes/send_files_box.h index 3f37e872e8..ca9e1d64fb 100644 --- a/Telegram/SourceFiles/boxes/send_files_box.h +++ b/Telegram/SourceFiles/boxes/send_files_box.h @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "base/flags.h" +#include "data/data_msg_id.h" #include "ui/layers/box_content.h" #include "ui/chat/attach/attach_prepare.h" #include "ui/chat/attach/attach_send_files_way.h" @@ -62,6 +63,10 @@ class CharactersLimitLabel; class ComposeAiButton; } // namespace HistoryView::Controls +namespace SendFiles { +class ReplyPillHeader; +} // namespace SendFiles + enum class SendFilesAllow { OnlyOne = (1 << 0), Photos = (1 << 1), @@ -91,7 +96,8 @@ using SendFilesCheck = Fn, - Api::SendOptions)>; + Api::SendOptions, + FullReplyTo)>; struct SendFilesBoxDescriptor { std::shared_ptr show; @@ -105,6 +111,7 @@ struct SendFilesBoxDescriptor { const style::ComposeControls *stOverride = nullptr; SendFilesConfirmed confirmed; Fn cancelled; + FullReplyTo replyTo; }; class SendFilesBox : public Ui::BoxContent { @@ -129,6 +136,7 @@ public: void setCancelledCallback(Fn callback) { _cancelledCallback = std::move(callback); } + void setReplyTo(FullReplyTo replyTo); [[nodiscard]] rpl::producer takeTextWithTagsRequests() const; @@ -164,6 +172,7 @@ private: [[nodiscard]] int fromIndex() const; [[nodiscard]] int tillIndex() const; + [[nodiscard]] bool isSingleFile() const; [[nodiscard]] object_ptr takeWidget(); [[nodiscard]] rpl::producer itemDeleteRequest() const; @@ -315,6 +324,10 @@ private: rpl::variable _footerHeight = 0; rpl::lifetime _dimensionsLifetime; + std::unique_ptr _replyHeader; + rpl::variable _replyHeaderHeight = 0; + FullReplyTo _replyTo; + object_ptr _scroll; QPointer _inner; std::deque _blocks; diff --git a/Telegram/SourceFiles/boxes/send_files_box_reply_header.cpp b/Telegram/SourceFiles/boxes/send_files_box_reply_header.cpp new file mode 100644 index 0000000000..881ca1fb7f --- /dev/null +++ b/Telegram/SourceFiles/boxes/send_files_box_reply_header.cpp @@ -0,0 +1,372 @@ +/* +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 "boxes/send_files_box_reply_header.h" + +#include "chat_helpers/compose/compose_show.h" +#include "core/ui_integration.h" +#include "data/data_changes.h" +#include "data/data_media_types.h" +#include "data/data_session.h" +#include "history/history.h" +#include "history/history_item.h" +#include "history/view/history_view_reply.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "ui/chat/chat_style.h" +#include "ui/effects/spoiler_mess.h" +#include "ui/image/image.h" +#include "ui/painter.h" +#include "ui/text/text_options.h" +#include "ui/power_saving.h" +#include "ui/widgets/buttons.h" +#include "window/window_session_controller.h" +#include "apiwrap.h" +#include "styles/style_boxes.h" +#include "styles/style_chat.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_dialogs.h" + +namespace SendFiles { +namespace { + +constexpr auto kAnimationDuration = crl::time(180); + +} // namespace + +ReplyPillHeader::ReplyPillHeader( + QWidget *parent, + std::shared_ptr show, + FullReplyTo replyTo) +: RpWidget(parent) +, _show(std::move(show)) +, _data(&_show->session().data()) +, _replyTo(std::move(replyTo)) +, _cancel(Ui::CreateChild(this, st::sendFilesReplyCancel)) { + resize( + parent->width(), + st::boxPhotoCaptionSkip + st::historyReplyHeight); + + _cancel->setAccessibleName(tr::lng_cancel(tr::now)); + _cancel->setClickedCallback([=] { + hideAnimated(); + }); + + setShownMessage(_data->message(_replyTo.messageId)); + + _data->session().changes().messageUpdates( + Data::MessageUpdate::Flag::Edited + | Data::MessageUpdate::Flag::Destroyed + ) | rpl::filter([=](const Data::MessageUpdate &update) { + return (update.item == _shownMessage); + }) | rpl::on_next([=](const Data::MessageUpdate &update) { + if (update.flags & Data::MessageUpdate::Flag::Destroyed) { + _shownMessage = nullptr; + _shownMessageName.clear(); + _shownMessageText.clear(); + hideAnimated(); + } else { + updateShownMessageText(); + RpWidget::update(); + } + }, lifetime()); + + animationCallback(); +} + +ReplyPillHeader::~ReplyPillHeader() = default; + +rpl::producer<> ReplyPillHeader::closeRequests() const { + return _closeRequests.events(); +} + +rpl::producer<> ReplyPillHeader::hideFinished() const { + return _hideFinished.value() + | rpl::filter(rpl::mappers::_1) + | rpl::to_empty; +} + +rpl::producer ReplyPillHeader::desiredHeight() const { + return _desiredHeight.value(); +} + +void ReplyPillHeader::setRoundedShapeBelow(bool value) { + if (_roundedShapeBelow == value) { + return; + } + _roundedShapeBelow = value; + update(); +} + +void ReplyPillHeader::hideAnimated() { + if (_hiding) { + return; + } + _hiding = true; + _closeRequests.fire({}); + _showAnimation.start( + [=] { animationCallback(); }, + 1., + 0., + kAnimationDuration); +} + +void ReplyPillHeader::animationCallback() { + const auto full = st::boxPhotoCaptionSkip + st::historyReplyHeight; + const auto value = _showAnimation.value(_hiding ? 0. : 1.); + _desiredHeight = int(base::SafeRound(full * value)); + update(); + if (_hiding && !_showAnimation.animating()) { + _hideFinished = true; + } +} + +void ReplyPillHeader::resolveMessageData() { + const auto id = _replyTo.messageId; + if (!id || !id.peer) { + return; + } + const auto peer = _data->peer(id.peer); + const auto itemId = id.msg; + const auto callback = crl::guard(this, [=] { + if (!_shownMessage) { + if (const auto message = _data->message(peer, itemId)) { + setShownMessage(message); + } else { + hideAnimated(); + } + } + }); + _data->session().api().requestMessageData(peer, itemId, callback); +} + +void ReplyPillHeader::setShownMessage(HistoryItem *item) { + _shownMessage = item; + if (item) { + updateShownMessageText(); + const auto context = Core::TextContext({ + .session = &item->history()->session(), + .customEmojiLoopLimit = 1, + }); + _shownMessageName.setMarkedText( + st::fwdTextStyle, + HistoryView::Reply::ComposePreviewName( + item->history(), + item, + _replyTo), + Ui::NameTextOptions(), + context); + } else { + _shownMessageName.clear(); + _shownMessageText.clear(); + resolveMessageData(); + } + update(); +} + +void ReplyPillHeader::updateShownMessageText() { + Expects(_shownMessage != nullptr); + + const auto context = Core::TextContext({ + .session = &_data->session(), + .repaint = [=] { customEmojiRepaint(); }, + }); + _shownMessageText.setMarkedText( + st::messageTextStyle, + (_replyTo.quote.empty() + ? _shownMessage->inReplyText() + : _replyTo.quote), + Ui::DialogTextOptions(), + context); +} + +void ReplyPillHeader::customEmojiRepaint() { + if (_repaintScheduled) { + return; + } + _repaintScheduled = true; + update(); +} + +void ReplyPillHeader::resizeEvent(QResizeEvent *e) { + _cancel->moveToRight( + st::boxPhotoPadding.right() + st::sendBoxAlbumGroupSkipRight, + (st::historyReplyHeight - _cancel->height()) / 2); +} + +void ReplyPillHeader::paintEvent(QPaintEvent *e) { + _repaintScheduled = false; + + Painter p(this); + p.setInactive(_show->paused(Window::GifPauseReason::Layer)); + + const auto left = st::boxPhotoPadding.left(); + const auto right = st::boxPhotoPadding.right(); + const auto bottomSkip = st::boxPhotoCaptionSkip; + const auto pillHeight = height() - bottomSkip; + if (pillHeight <= 0) { + return; + } + const auto pillRect = QRect( + left, + 0, + width() - left - right, + pillHeight); + if (pillRect.isEmpty()) { + return; + } + + { + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(st::windowBgOver); + const auto topRadius = st::bubbleRadiusLarge; + const auto bottomRadius = _roundedShapeBelow + ? st::bubbleRadiusSmall + : st::bubbleRadiusLarge; + const auto rectF = QRectF(pillRect); + auto path = QPainterPath(); + path.moveTo(rectF.left() + topRadius, rectF.top()); + path.lineTo(rectF.right() - topRadius, rectF.top()); + path.arcTo( + rectF.right() - 2 * topRadius, + rectF.top(), + 2 * topRadius, + 2 * topRadius, + 90, -90); + path.lineTo(rectF.right(), rectF.bottom() - bottomRadius); + path.arcTo( + rectF.right() - 2 * bottomRadius, + rectF.bottom() - 2 * bottomRadius, + 2 * bottomRadius, + 2 * bottomRadius, + 0, -90); + path.lineTo(rectF.left() + bottomRadius, rectF.bottom()); + path.arcTo( + rectF.left(), + rectF.bottom() - 2 * bottomRadius, + 2 * bottomRadius, + 2 * bottomRadius, + 270, -90); + path.lineTo(rectF.left(), rectF.top() + topRadius); + path.arcTo( + rectF.left(), + rectF.top(), + 2 * topRadius, + 2 * topRadius, + 180, -90); + path.closeSubpath(); + p.fillPath(path, st::windowBgOver); + } + + const auto iconPos = st::sendFilesReplyIconPosition + + QPoint(pillRect.left(), pillRect.top()); + if (!_replyTo.quote.empty()) { + st::historyQuoteIcon.paint(p, iconPos, width()); + } else { + st::historyReplyIcon.paint(p, iconPos, width()); + // Remove 'settings' mini-icon. + p.fillRect( + QRect( + QPoint(style::ConvertScale(16), style::ConvertScale(5)) + + iconPos, + QSize(style::ConvertScale(11), style::ConvertScale(8))), + st::windowBgOver); + p.fillRect( + QRect( + QPoint(style::ConvertScale(22), style::ConvertScale(13)) + + iconPos, + QSize(style::ConvertScale(5), style::ConvertScale(2))), + st::windowBgOver); + } + + const auto replySkip = st::historyReplySkip; + const auto textLeft = pillRect.left() + replySkip; + const auto availableWidth = _cancel->x() - textLeft; + if (availableWidth <= 0) { + return; + } + + const auto pillCenterY = pillRect.top() + + st::historyReplyHeight / 2; + + if (!_shownMessage) { + p.setFont(st::msgDateFont); + p.setPen(st::historyComposeAreaFgService); + const auto top = pillCenterY - st::msgDateFont->height / 2; + p.drawText( + textLeft, + top + st::msgDateFont->ascent, + st::msgDateFont->elided( + tr::lng_profile_loading(tr::now), + availableWidth)); + return; + } + + const auto media = _shownMessage->media(); + const auto hasPreview = media && media->hasReplyPreview(); + const auto preview = hasPreview ? media->replyPreview() : nullptr; + const auto spoilered = media && media->hasSpoiler(); + if (!spoilered) { + _previewSpoiler = nullptr; + } else if (!_previewSpoiler) { + _previewSpoiler = std::make_unique([=] { + update(); + }); + } + const auto previewSkipValue = st::historyReplyPreview + st::msgReplyBarSkip; + const auto previewSkip = (hasPreview && preview) ? previewSkipValue : 0; + const auto contentLeft = textLeft + previewSkip; + const auto contentAvailable = availableWidth - previewSkip; + + if (preview) { + const auto to = QRect( + textLeft, + pillCenterY - st::historyReplyPreview / 2, + st::historyReplyPreview, + st::historyReplyPreview); + p.drawPixmap(to.x(), to.y(), preview->pixSingle( + preview->size() / style::DevicePixelRatio(), + { + .options = Images::Option::RoundSmall, + .outer = to.size(), + })); + if (_previewSpoiler) { + Ui::FillSpoilerRect( + p, + to, + Ui::DefaultImageSpoiler().frame( + _previewSpoiler->index(crl::now(), p.inactive()))); + } + } + + p.setPen(st::historyReplyNameFg); + p.setFont(st::msgServiceNameFont); + _shownMessageName.drawElided( + p, + contentLeft, + pillRect.top() + st::msgReplyPadding.top(), + contentAvailable); + + p.setPen(st::historyComposeAreaFg); + _shownMessageText.draw(p, { + .position = QPoint( + contentLeft, + pillRect.top() + + st::msgReplyPadding.top() + + st::msgServiceNameFont->height), + .availableWidth = contentAvailable, + .palette = &st::historyComposeAreaPalette, + .spoiler = Ui::Text::DefaultSpoilerCache(), + .now = crl::now(), + .pausedEmoji = p.inactive() || On(PowerSaving::kEmojiChat), + .pausedSpoiler = p.inactive() || On(PowerSaving::kChatSpoiler), + .elisionLines = 1, + }); +} + +} // namespace SendFiles diff --git a/Telegram/SourceFiles/boxes/send_files_box_reply_header.h b/Telegram/SourceFiles/boxes/send_files_box_reply_header.h new file mode 100644 index 0000000000..9515c896fd --- /dev/null +++ b/Telegram/SourceFiles/boxes/send_files_box_reply_header.h @@ -0,0 +1,77 @@ +/* +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/effects/animations.h" +#include "ui/rp_widget.h" +#include "ui/text/text.h" + +class HistoryItem; + +namespace ChatHelpers { +class Show; +} // namespace ChatHelpers + +namespace Data { +class Session; +} // namespace Data + +namespace Ui { +class IconButton; +class SpoilerAnimation; +} // namespace Ui + +namespace SendFiles { + +class ReplyPillHeader final : public Ui::RpWidget { +public: + ReplyPillHeader( + QWidget *parent, + std::shared_ptr show, + FullReplyTo replyTo); + ~ReplyPillHeader(); + + [[nodiscard]] rpl::producer<> closeRequests() const; + [[nodiscard]] rpl::producer<> hideFinished() const; + [[nodiscard]] rpl::producer desiredHeight() const; + + void setRoundedShapeBelow(bool value); + void hideAnimated(); + +protected: + void paintEvent(QPaintEvent *e) override; + void resizeEvent(QResizeEvent *e) override; + +private: + void resolveMessageData(); + void setShownMessage(HistoryItem *item); + void updateShownMessageText(); + void customEmojiRepaint(); + void animationCallback(); + + const std::shared_ptr _show; + const not_null _data; + const FullReplyTo _replyTo; + const not_null _cancel; + + HistoryItem *_shownMessage = nullptr; + Ui::Text::String _shownMessageName; + Ui::Text::String _shownMessageText; + std::unique_ptr _previewSpoiler; + bool _repaintScheduled = false; + + Ui::Animations::Simple _showAnimation; + rpl::variable _desiredHeight = 0; + rpl::event_stream<> _closeRequests; + rpl::variable _hideFinished = false; + bool _hiding = false; + bool _roundedShapeBelow = true; + +}; + +} // namespace SendFiles diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index 4bc15a36ab..bffe23d89b 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -609,7 +609,7 @@ sendBoxAlbumSmallGroupSize: size(30px, 25px); sendBoxAlbumSmallGroupCircleSize: 27px; sendBoxFileGroupSkipTop: 2px; -sendBoxFileGroupSkipRight: 5px; +sendBoxFileGroupSkipRight: 1px; sendBoxFileGroupEditInternalSkip: -1px; sendBoxAlbumGroupButtonFile: IconButton(editMediaButton) { @@ -622,7 +622,7 @@ sendBoxAlbumGroupDeleteButtonIconFile: icon {{ "send_media/send_media_cross", me sendBoxAlbumButtonMediaMore: icon {{ "send_media/send_media_more", roundedFg }}; sendBoxAlbumGroupButtonMediaMore: icon {{ "send_media/send_media_more", roundedFg, point(4px, 1px) }}; -sendBoxAlbumGroupButtonMediaDelete: icon {{ "send_media/send_media_cross", roundedFg, point(-2px, 1px) }}; +sendBoxAlbumGroupButtonMediaDelete: icon {{ "send_media/send_media_cross", roundedFg }}; defaultComposeIcons: ComposeIcons { settings: icon {{ "emoji/emoji_settings", emojiIconFg }}; @@ -1033,6 +1033,19 @@ historyPinnedShowAll: IconButton(historyReplyCancel) { icon: icon {{ "pinned_show_all", historyReplyCancelFg }}; iconOver: icon {{ "pinned_show_all", historyReplyCancelFgOver }}; } +sendFilesReplyIconPosition: point(11px, 7px); +sendFilesReplyCancelSize: 24px; +sendFilesReplyCancel: IconButton(editMediaButton) { + width: sendFilesReplyCancelSize; + height: sendFilesReplyCancelSize; + icon: icon {{ "send_media/send_media_cross", historyReplyCancelFg }}; + iconOver: icon {{ "send_media/send_media_cross", historyReplyCancelFgOver }}; + ripple: RippleAnimation(defaultRippleAnimation) { + color: windowBgRipple; + } + + rippleAreaSize: sendFilesReplyCancelSize; +} historyPinnedBotButton: RoundButton(defaultActiveButton) { width: -34px; height: 30px; diff --git a/Telegram/SourceFiles/history/history_widget.cpp b/Telegram/SourceFiles/history/history_widget.cpp index fe7a9944a6..0f3a21055b 100644 --- a/Telegram/SourceFiles/history/history_widget.cpp +++ b/Telegram/SourceFiles/history/history_widget.cpp @@ -1368,9 +1368,14 @@ void HistoryWidget::sendTextAsFile( _peer, Api::SendType::Normal, sendMenuDetails()); + box->setReplyTo(replyTo()); box->setConfirmedCallback(crl::guard(this, [=]( std::shared_ptr bundle, - Api::SendOptions options) { + Api::SendOptions options, + FullReplyTo currentReplyTo) { + if (!currentReplyTo.messageId && replyTo().messageId) { + cancelReply(); + } sendingFilesConfirmed(std::move(bundle), options); })); box->setCancelledCallback(crl::guard(this, [=] { @@ -6782,10 +6787,15 @@ bool HistoryWidget::confirmSendingFiles( _peer, Api::SendType::Normal, sendMenuDetails()); + box->setReplyTo(replyTo()); _field->setTextWithTags({}); box->setConfirmedCallback(crl::guard(this, [=]( std::shared_ptr bundle, - Api::SendOptions options) { + Api::SendOptions options, + FullReplyTo currentReplyTo) { + if (!currentReplyTo.messageId && replyTo().messageId) { + cancelReply(); + } sendingFilesConfirmed(std::move(bundle), options); })); box->setCancelledCallback(crl::guard(this, [=] { diff --git a/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp b/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp index ff637d9966..e376e72623 100644 --- a/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp +++ b/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp @@ -3384,6 +3384,16 @@ void ComposeControls::fireSendTextAsFile( ? Api::SendType::ScheduledToUser : Api::SendType::Scheduled) : Api::SendType::Normal; + auto confirmed = [=, callback = _sendAsFileConfirmed]( + std::shared_ptr bundle, + Api::SendOptions options, + FullReplyTo replyTo) { + if (!replyTo.messageId + && replyingToMessage().messageId) { + cancelReplyMessage(); + } + callback(std::move(bundle), options); + }; _show->show(Box(SendFilesBoxDescriptor{ .show = _show, .list = Ui::PrepareTextAsFile(fileText), @@ -3394,8 +3404,9 @@ void ComposeControls::fireSendTextAsFile( .sendType = sendType, .sendMenuDetails = _sendMenuDetails, .stOverride = &_st, - .confirmed = _sendAsFileConfirmed, + .confirmed = std::move(confirmed), .cancelled = std::move(restoreText), + .replyTo = replyingToMessage(), })); } diff --git a/Telegram/SourceFiles/history/view/history_view_chat_section.cpp b/Telegram/SourceFiles/history/view/history_view_chat_section.cpp index 8d09db7458..138dd8f110 100644 --- a/Telegram/SourceFiles/history/view/history_view_chat_section.cpp +++ b/Telegram/SourceFiles/history/view/history_view_chat_section.cpp @@ -1192,10 +1192,16 @@ bool ChatWidget::confirmSendingFiles( _peer, Api::SendType::Normal, sendMenuDetails()); + box->setReplyTo(_composeControls->replyingToMessage()); box->setConfirmedCallback(crl::guard(this, [=]( std::shared_ptr bundle, - Api::SendOptions options) { + Api::SendOptions options, + FullReplyTo currentReplyTo) { + if (!currentReplyTo.messageId + && _composeControls->replyingToMessage().messageId) { + _composeControls->cancelReplyMessage(); + } sendingFilesConfirmed(std::move(bundle), options); })); box->setCancelledCallback(_composeControls->restoreTextCallback( diff --git a/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp b/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp index 904dddc0e6..cae8dc159e 100644 --- a/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp +++ b/Telegram/SourceFiles/history/view/history_view_scheduled_section.cpp @@ -575,7 +575,8 @@ bool ScheduledWidget::confirmSendingFiles( box->setConfirmedCallback(crl::guard(this, [=]( std::shared_ptr bundle, - Api::SendOptions options) { + Api::SendOptions options, + FullReplyTo) { sendingFilesConfirmed(std::move(bundle), options); })); box->setCancelledCallback(_composeControls->restoreTextCallback( diff --git a/Telegram/SourceFiles/media/stories/media_stories_reply.cpp b/Telegram/SourceFiles/media/stories/media_stories_reply.cpp index 1164fc3398..b745473553 100644 --- a/Telegram/SourceFiles/media/stories/media_stories_reply.cpp +++ b/Telegram/SourceFiles/media/stories/media_stories_reply.cpp @@ -668,8 +668,11 @@ bool ReplyArea::confirmSendingFiles( } const auto show = _controller->uiShow(); - auto confirmed = [=](auto &&...args) { - sendingFilesConfirmed(std::forward(args)...); + auto confirmed = [=]( + std::shared_ptr bundle, + Api::SendOptions options, + FullReplyTo) { + sendingFilesConfirmed(std::move(bundle), options); }; show->show(Box(SendFilesBoxDescriptor{ .show = show, diff --git a/Telegram/SourceFiles/settings/business/settings_shortcut_messages.cpp b/Telegram/SourceFiles/settings/business/settings_shortcut_messages.cpp index 908bd3eee2..bfb96335f6 100644 --- a/Telegram/SourceFiles/settings/business/settings_shortcut_messages.cpp +++ b/Telegram/SourceFiles/settings/business/settings_shortcut_messages.cpp @@ -1344,7 +1344,8 @@ bool ShortcutMessages::confirmSendingFiles( box->setConfirmedCallback(crl::guard(this, [=]( std::shared_ptr bundle, - Api::SendOptions options) { + Api::SendOptions options, + FullReplyTo) { sendingFilesConfirmed(std::move(bundle), options); })); box->setCancelledCallback(_composeControls->restoreTextCallback( diff --git a/Telegram/SourceFiles/ui/chat/attach/attach_abstract_single_media_preview.cpp b/Telegram/SourceFiles/ui/chat/attach/attach_abstract_single_media_preview.cpp index d312bbc458..5a08892251 100644 --- a/Telegram/SourceFiles/ui/chat/attach/attach_abstract_single_media_preview.cpp +++ b/Telegram/SourceFiles/ui/chat/attach/attach_abstract_single_media_preview.cpp @@ -175,8 +175,19 @@ void AbstractSingleMediaPreview::paintEvent(QPaintEvent *e) { } }); + auto hq = std::optional(); if (drawBackground()) { const auto &padding = st::boxPhotoPadding; + const auto bgRect = QRect( + padding.left(), + 0, + width() - padding.left() - padding.right(), + height()); + const auto radius = st::bubbleRadiusSmall; + auto clipPath = QPainterPath(); + clipPath.addRoundedRect(bgRect, radius, radius); + hq.emplace(p); + p.setClipPath(clipPath); if (_previewLeft > padding.left()) { p.fillRect( padding.left(), diff --git a/Telegram/SourceFiles/window/session/window_session_media.cpp b/Telegram/SourceFiles/window/session/window_session_media.cpp index 3e9f54c99a..dbb6cc43fb 100644 --- a/Telegram/SourceFiles/window/session/window_session_media.cpp +++ b/Telegram/SourceFiles/window/session/window_session_media.cpp @@ -100,11 +100,12 @@ void SessionController::showDrawToReplyFilesBox( .sendType = Api::SendType::Normal, .confirmed = crl::guard(this, [=]( std::shared_ptr bundle, - Api::SendOptions options) { + Api::SendOptions options, + FullReplyTo currentReplyTo) { if (const auto thread = weak.get()) { sendDrawToReplyFiles( thread, - replyTo, + currentReplyTo.messageId, std::move(bundle), options); } From 0b70be9a9bc48d4fe0099744d90de685c0c80ce7 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sun, 12 Apr 2026 13:46:10 +0300 Subject: [PATCH 40/78] [img-editor] Added styled text items. --- Telegram/SourceFiles/editor/editor_paint.cpp | 13 + Telegram/SourceFiles/editor/editor_paint.h | 2 + Telegram/SourceFiles/editor/photo_editor.cpp | 5 + .../editor/photo_editor_content.cpp | 4 + .../SourceFiles/editor/photo_editor_content.h | 1 + .../editor/photo_editor_controls.cpp | 36 ++ .../editor/photo_editor_controls.h | 3 + Telegram/SourceFiles/editor/scene/scene.cpp | 252 ++++++++++- Telegram/SourceFiles/editor/scene/scene.h | 18 + .../editor/scene/scene_item_text.cpp | 391 ++++++++++++++++++ .../editor/scene/scene_item_text.h | 93 +++++ Telegram/cmake/td_ui.cmake | 2 + 12 files changed, 818 insertions(+), 2 deletions(-) create mode 100644 Telegram/SourceFiles/editor/scene/scene_item_text.cpp create mode 100644 Telegram/SourceFiles/editor/scene/scene_item_text.h diff --git a/Telegram/SourceFiles/editor/editor_paint.cpp b/Telegram/SourceFiles/editor/editor_paint.cpp index a90cc8af63..7553983a4d 100644 --- a/Telegram/SourceFiles/editor/editor_paint.cpp +++ b/Telegram/SourceFiles/editor/editor_paint.cpp @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene_item_canvas.h" #include "editor/scene/scene_item_image.h" #include "editor/scene/scene_item_sticker.h" +#include "editor/scene/scene_item_text.h" #include "editor/scene/scene.h" #include "lang/lang_keys.h" #include "lottie/lottie_single_player.h" @@ -65,6 +66,14 @@ Paint::Paint( _scene->setBlurSource(std::move(blurSource)); + { + const auto shortSide = std::min(imageSize.width(), imageSize.height()); + _scene->setTextDefaults( + QColor(255, 255, 255), + shortSide / 15.f, + int(TextStyle::Plain)); + } + keepResult(); _view->show(); @@ -234,6 +243,10 @@ void Paint::applyBrush(const Brush &brush) { brush.tool); } +void Paint::createTextItem() { + _scene->createTextAtCenter(); +} + void Paint::handleMimeData(const QMimeData *data) { const auto add = [&](QImage image) { if (image.isNull()) { diff --git a/Telegram/SourceFiles/editor/editor_paint.h b/Telegram/SourceFiles/editor/editor_paint.h index b1bfb68c5b..e2d586aaba 100644 --- a/Telegram/SourceFiles/editor/editor_paint.h +++ b/Telegram/SourceFiles/editor/editor_paint.h @@ -41,6 +41,8 @@ public: void keepResult(); void updateUndoState(); + void createTextItem(); + void handleMimeData(const QMimeData *data); void paintImage(QPainter &p, const QPixmap &image) const; void resetView(); diff --git a/Telegram/SourceFiles/editor/photo_editor.cpp b/Telegram/SourceFiles/editor/photo_editor.cpp index 544ccfcc8a..27d864dc4f 100644 --- a/Telegram/SourceFiles/editor/photo_editor.cpp +++ b/Telegram/SourceFiles/editor/photo_editor.cpp @@ -302,6 +302,11 @@ PhotoEditor::PhotoEditor( }; }, lifetime()); + _controls->textRequests( + ) | rpl::on_next([=] { + _content->createTextItem(); + }, lifetime()); + _controls->doneRequests( ) | rpl::on_next([=] { const auto mode = _mode.current().mode; diff --git a/Telegram/SourceFiles/editor/photo_editor_content.cpp b/Telegram/SourceFiles/editor/photo_editor_content.cpp index fb9102b114..f9197cde6e 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_content.cpp @@ -162,6 +162,10 @@ void PhotoEditorContent::applyBrush(const Brush &brush) { _paint->applyBrush(brush); } +void PhotoEditorContent::createTextItem() { + _paint->createTextItem(); +} + bool PhotoEditorContent::handleKeyPress(not_null e) const { return false; } diff --git a/Telegram/SourceFiles/editor/photo_editor_content.h b/Telegram/SourceFiles/editor/photo_editor_content.h index cf1cc3226f..39c411358c 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.h +++ b/Telegram/SourceFiles/editor/photo_editor_content.h @@ -31,6 +31,7 @@ public: void applyModifications(PhotoModifications modifications); void applyMode(const PhotoEditorMode &mode); void applyBrush(const Brush &brush); + void createTextItem(); void applyAspectRatio(float64 ratio); void save(PhotoModifications &modifications); diff --git a/Telegram/SourceFiles/editor/photo_editor_controls.cpp b/Telegram/SourceFiles/editor/photo_editor_controls.cpp index f090b3c5d2..21b6b01b38 100644 --- a/Telegram/SourceFiles/editor/photo_editor_controls.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_controls.cpp @@ -202,6 +202,37 @@ ButtonBar::ButtonBar( }, lifetime()); } +class TextToolButton final : public Ui::AbstractButton { +public: + TextToolButton(not_null parent) + : AbstractButton(parent) { + resize( + st::photoEditorStickersButton.width, + st::photoEditorStickersButton.height); + events( + ) | rpl::on_next([=](not_null event) { + if (event->type() == QEvent::Enter + || event->type() == QEvent::Leave) { + update(); + } + }, lifetime()); + } + +private: + void paintEvent(QPaintEvent *) override { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + auto font = st::semiboldFont->f; + font.setPixelSize(QWidget::rect().height() / 2); + p.setFont(font); + p.setPen(isOver() + ? st::photoEditorButtonIconFgOver + : st::photoEditorButtonIconFg); + p.translate(0, st::lineWidth * 3); + p.drawText(QWidget::rect(), style::al_center, u"A"_q); + } +}; + PhotoEditorControls::PhotoEditorControls( not_null parent, std::shared_ptr controllers, @@ -272,6 +303,7 @@ PhotoEditorControls::PhotoEditorControls( _paintBottomButtons, st::photoEditorStickersButton) : nullptr) +, _textButton(base::make_unique_q(_paintBottomButtons)) , _paintDone(base::make_unique_q( _paintBottomButtons, tr::lng_box_done(tr::now), @@ -499,6 +531,10 @@ rpl::producer<> PhotoEditorControls::paintModeRequests() const { return _paintModeButton->clicks() | rpl::to_empty; } +rpl::producer<> PhotoEditorControls::textRequests() const { + return _textButton->clicks() | rpl::to_empty; +} + rpl::producer<> PhotoEditorControls::doneRequests() const { return rpl::merge( _transformDone->clicks() | rpl::to_empty, diff --git a/Telegram/SourceFiles/editor/photo_editor_controls.h b/Telegram/SourceFiles/editor/photo_editor_controls.h index 24d95fa799..2505b5c387 100644 --- a/Telegram/SourceFiles/editor/photo_editor_controls.h +++ b/Telegram/SourceFiles/editor/photo_editor_controls.h @@ -15,6 +15,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/photo_editor_inner_common.h" namespace Ui { +class AbstractButton; class IconButton; class FlatLabel; class PopupMenu; @@ -41,6 +42,7 @@ public: [[nodiscard]] rpl::producer rotateRequests() const; [[nodiscard]] rpl::producer<> flipRequests() const; [[nodiscard]] rpl::producer<> paintModeRequests() const; + [[nodiscard]] rpl::producer<> textRequests() const; [[nodiscard]] rpl::producer<> doneRequests() const; [[nodiscard]] rpl::producer<> cancelRequests() const; [[nodiscard]] rpl::producer colorLinePositionValue() const; @@ -82,6 +84,7 @@ private: const base::unique_qptr _redoButton; const base::unique_qptr _paintModeButtonActive; const base::unique_qptr _stickersButton; + const base::unique_qptr _textButton; const base::unique_qptr _paintDone; base::unique_qptr _ratioMenu; diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index d4d8463ff9..b35bec3204 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -10,11 +10,17 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene_item_canvas.h" #include "editor/scene/scene_item_line.h" #include "editor/scene/scene_item_sticker.h" +#include "editor/scene/scene_item_text.h" #include "ui/image/image_prepare.h" #include "ui/rp_widget.h" #include "styles/style_editor.h" +#include #include +#include +#include +#include +#include namespace Editor { namespace { @@ -100,6 +106,43 @@ bool SkipMouseEvent(not_null event) { return event->isAccepted() || (event->button() == Qt::RightButton); } +class TextEditProxy final : public QGraphicsTextItem { +public: + using QGraphicsTextItem::QGraphicsTextItem; + + Fn onFinish; + Fn onCancel; + +protected: + void keyPressEvent(QKeyEvent *event) override { + if (event->key() == Qt::Key_Escape) { + fire(onCancel); + return; + } + QGraphicsTextItem::keyPressEvent(event); + } + + void focusOutEvent(QFocusEvent *event) override { + QGraphicsTextItem::focusOutEvent(event); + fire(onFinish); + } + + void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override { + event->accept(); + } + +private: + void fire(Fn &callback) { + if (!callback) { + return; + } + const auto cb = std::exchange(callback, nullptr); + onFinish = nullptr; + onCancel = nullptr; + QTimer::singleShot(0, cb); + } +}; + } // namespace Scene::Scene(const QRectF &rect) @@ -240,6 +283,9 @@ Scene::Scene(const QRectF &rect) } void Scene::cancelDrawing() { + if (_textEdit.proxy) { + finishTextEditing(false); + } _canvas->cancelDrawing(); } @@ -271,6 +317,16 @@ void Scene::removeItem(const ItemPtr &item) { } void Scene::mousePressEvent(QGraphicsSceneMouseEvent *event) { + if (_textEdit.proxy) { + const auto clickOnProxy = _textEdit.proxy->contains( + _textEdit.proxy->mapFromScene(event->scenePos())); + if (!clickOnProxy) { + finishTextEditing(true); + QGraphicsScene::mousePressEvent(event); + return; + } + } + QGraphicsScene::mousePressEvent(event); if (SkipMouseEvent(event) || !sceneRect().contains(event->scenePos())) { return; @@ -280,7 +336,7 @@ void Scene::mousePressEvent(QGraphicsSceneMouseEvent *event) { void Scene::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { QGraphicsScene::mouseReleaseEvent(event); - if (SkipMouseEvent(event)) { + if (SkipMouseEvent(event) || _textEdit.proxy) { return; } _canvas->handleMouseReleaseEvent(event); @@ -288,7 +344,7 @@ void Scene::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { void Scene::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { QGraphicsScene::mouseMoveEvent(event); - if (SkipMouseEvent(event)) { + if (SkipMouseEvent(event) || _textEdit.proxy) { return; } _canvas->handleMouseMoveEvent(event); @@ -296,6 +352,17 @@ void Scene::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { void Scene::applyBrush(const QColor &color, float size, Brush::Tool tool) { _canvas->applyBrush(color, size, tool); + for (auto *item : selectedItems()) { + if (item->type() == ItemText::Type) { + static_cast(item)->setColor(color); + } + } +} + +void Scene::setTextDefaults(const QColor &color, float fontSize, int style) { + _textColor = color; + _textFontSize = fontSize; + _textStyle = style; } void Scene::setBlurSource(Fn source) { @@ -328,6 +395,7 @@ std::shared_ptr Scene::lastZ() const { } void Scene::updateZoom(float64 zoom) { + _currentZoom = zoom; _canvas->updateZoom(zoom); for (const auto &item : items()) { if (item->type() >= ItemBase::Type) { @@ -396,6 +464,10 @@ void Scene::clearRedoList() { } void Scene::save(SaveState state) { + if (_textEdit.proxy) { + finishTextEditing(true); + } + removeIf([](const ItemPtr &item) { return item->isRemovedStatus() && !item->hasState(SaveState::Keep) @@ -421,7 +493,183 @@ void Scene::restore(SaveState state) { cancelDrawing(); } +void Scene::createTextAtCenter() { + if (_textEdit.proxy) { + return; + } + + clearSelection(); + cancelDrawing(); + + auto *proxy = new TextEditProxy(); + proxy->setTextInteractionFlags(Qt::TextEditorInteraction); + proxy->setDefaultTextColor(_textColor); + + auto font = QFont(); + font.setPixelSize(int(_textFontSize)); + font.setWeight(QFont::DemiBold); + proxy->setFont(font); + + { + auto option = proxy->document()->defaultTextOption(); + option.setAlignment(Qt::AlignCenter); + proxy->document()->setDefaultTextOption(option); + } + + const auto shortSide = std::min( + sceneRect().width(), + sceneRect().height()); + const auto padding = int(_textFontSize * 0.4); + const auto textWidth = int(shortSide * 0.8) - 2 * padding; + proxy->setTextWidth(textWidth); + const auto center = sceneRect().center(); + proxy->setPos(center.x() - textWidth / 2., center.y()); + + QGraphicsScene::addItem(proxy); + proxy->setZValue((*_lastZ)++); + proxy->setFocus(); + + proxy->onFinish = crl::guard(this, [=] { + finishTextEditing(true); + }); + proxy->onCancel = crl::guard(this, [=] { + finishTextEditing(false); + }); + + _textEdit = { .item = nullptr, .proxy = proxy }; +} + +void Scene::startTextEditing(ItemText *item) { + if (_textEdit.proxy) { + finishTextEditing(true); + } + if (!item) { + return; + } + + cancelDrawing(); + + auto *proxy = new TextEditProxy(); + proxy->setTextInteractionFlags(Qt::TextEditorInteraction); + proxy->setDefaultTextColor(item->color()); + + auto font = QFont(); + font.setPixelSize(int(item->fontSize())); + font.setWeight(QFont::DemiBold); + proxy->setFont(font); + + { + auto option = proxy->document()->defaultTextOption(); + option.setAlignment(Qt::AlignCenter); + proxy->document()->setDefaultTextOption(option); + } + + proxy->setPlainText(item->text()); + + const auto shortSide = std::min( + sceneRect().width(), + sceneRect().height()); + const auto padding = int(item->fontSize() * 0.4); + const auto textWidth = int(shortSide * 0.8) - 2 * padding; + proxy->setTextWidth(textWidth); + + const auto scale = item->editScale(); + const auto proxyRect = proxy->boundingRect(); + const auto center = proxyRect.center(); + proxy->setTransformOriginPoint(center); + proxy->setPos(item->scenePos() - center); + proxy->setRotation(item->rotation()); + if (std::abs(scale - 1.) > 0.01) { + proxy->setScale(scale); + } + + QGraphicsScene::addItem(proxy); + proxy->setZValue((*_lastZ)++); + proxy->setFocus(); + + auto cursor = proxy->textCursor(); + cursor.select(QTextCursor::Document); + proxy->setTextCursor(cursor); + + item->setVisible(false); + + proxy->onFinish = crl::guard(this, [=] { + finishTextEditing(true); + }); + proxy->onCancel = crl::guard(this, [=] { + finishTextEditing(false); + }); + + _textEdit = { .item = item, .proxy = proxy }; +} + +void Scene::finishTextEditing(bool save) { + if (!_textEdit.proxy) { + return; + } + + const auto text = save + ? _textEdit.proxy->toPlainText().trimmed() + : QString(); + const auto proxyRect = _textEdit.proxy->boundingRect(); + const auto proxyCenter = _textEdit.proxy->pos() + + QPointF(proxyRect.width() / 2., proxyRect.height() / 2.); + auto *existingItem = _textEdit.item; + + const auto proxy = static_cast(_textEdit.proxy); + proxy->onFinish = nullptr; + proxy->onCancel = nullptr; + QGraphicsScene::removeItem(proxy); + delete proxy; + _textEdit.proxy = nullptr; + _textEdit.item = nullptr; + + if (!text.isEmpty()) { + if (existingItem) { + existingItem->setText(text); + existingItem->setVisible(true); + } else { + const auto imageSize = sceneRect().size().toSize(); + const auto contentSize = ItemText::computeContentSize( + text, + _textFontSize, + imageSize); + const auto size = std::max(contentSize.width(), 1); + auto data = ItemBase::Data{ + .initialZoom = zoom, + .zPtr = _lastZ, + .size = size, + .x = int(proxyCenter.x()), + .y = int(proxyCenter.y()), + .imageSize = imageSize, + }; + auto item = std::make_shared( + text, + _textColor, + _textFontSize, + TextStyle::Plain, + imageSize, + std::move(data)); + addItem(item); + } + } else if (existingItem) { + if (save) { + removeItem(existingItem); + } else { + existingItem->setVisible(true); + } + } +} + Scene::~Scene() { + if (_textEdit.proxy) { + const auto proxy = static_cast(_textEdit.proxy); + proxy->onFinish = nullptr; + proxy->onCancel = nullptr; + QGraphicsScene::removeItem(proxy); + delete proxy; + _textEdit.proxy = nullptr; + } // Prevent destroying by scene of all items. QGraphicsScene::removeItem(_canvas.get()); for (const auto &item : items()) { diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index 5b37b2d794..6024c0d9aa 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -13,6 +13,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include class QGraphicsSceneMouseEvent; +class QGraphicsTextItem; namespace Ui { class RpWidget; @@ -21,6 +22,7 @@ class RpWidget; namespace Editor { class ItemCanvas; +class ItemText; class NumberedItem; class Scene final : public QGraphicsScene { @@ -31,6 +33,7 @@ public: ~Scene(); void applyBrush(const QColor &color, float size, Brush::Tool tool); void setBlurSource(Fn source); + void setTextDefaults(const QColor &color, float fontSize, int style); [[nodiscard]] std::vector items( Qt::SortOrder order = Qt::DescendingOrder) const; @@ -46,6 +49,9 @@ public: void cancelDrawing(); + void startTextEditing(ItemText *item); + void createTextAtCenter(); + [[nodiscard]] bool hasUndo() const; [[nodiscard]] bool hasRedo() const; @@ -62,6 +68,8 @@ protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; private: void removeIf(Fn proj); + void finishTextEditing(bool save); + const std::shared_ptr _canvas; const std::shared_ptr _lastZ; Fn _blurSource; @@ -70,8 +78,18 @@ private: std::unordered_map _itemsByPointer; float64 _lastLineZ = 0.; + float64 _currentZoom = 1.; int _itemNumber = 0; + QColor _textColor; + float _textFontSize = 0.f; + int _textStyle = 3; + + struct { + ItemText *item = nullptr; + QGraphicsTextItem *proxy = nullptr; + } _textEdit; + rpl::event_stream<> _addsItem, _removesItem; rpl::lifetime _lifetime; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp new file mode 100644 index 0000000000..38fb590b01 --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -0,0 +1,391 @@ +/* +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 "editor/scene/scene_item_text.h" + +#include "editor/scene/scene.h" +#include "lang/lang_keys.h" +#include "ui/painter.h" +#include "ui/widgets/popup_menu.h" +#include "styles/style_editor.h" +#include "styles/style_menu_icons.h" + +#include +#include +#include +#include + +namespace Editor { +namespace { + +constexpr auto kPaddingFactor = 0.4f; +constexpr auto kMaxWidthFactor = 0.8f; +constexpr auto kMinContentWidth = 20; + +struct LayoutMetrics { + int contentWidth = 0; + int contentHeight = 0; + int padding = 0; + int textMaxWidth = 0; +}; + +QFont TextFont(float fontSize) { + auto font = QFont(); + font.setPixelSize(std::max(int(fontSize), 1)); + font.setWeight(QFont::DemiBold); + return font; +} + +float ComputeBrightness(const QColor &color) { + return (color.red() * 0.2126f + + color.green() * 0.7152f + + color.blue() * 0.0722f) / 255.f; +} + +LayoutMetrics ComputeMetrics( + const QString &text, + float fontSize, + const QSize &imageSize) { + const auto padding = int(fontSize * kPaddingFactor); + const auto shortSide = std::min(imageSize.width(), imageSize.height()); + const auto textMaxWidth = int(shortSide * kMaxWidthFactor) - 2 * padding; + + const auto font = TextFont(fontSize); + + auto processedText = text; + processedText.replace('\n', QChar::LineSeparator); + + auto option = QTextOption(); + option.setWrapMode(QTextOption::WordWrap); + + auto layout = QTextLayout(processedText, font); + layout.setTextOption(option); + layout.beginLayout(); + + auto totalHeight = 0.; + auto maxWidth = 0.; + while (true) { + auto line = layout.createLine(); + if (!line.isValid()) { + break; + } + line.setLineWidth(textMaxWidth); + line.setPosition(QPointF(0, totalHeight)); + totalHeight += line.height(); + maxWidth = std::max(maxWidth, double(line.naturalTextWidth())); + } + layout.endLayout(); + + return { + .contentWidth = std::max(int(std::ceil(maxWidth)), kMinContentWidth), + .contentHeight = int(std::ceil(totalHeight)), + .padding = padding, + .textMaxWidth = textMaxWidth, + }; +} + +} // namespace + +ItemText::ItemText( + const QString &text, + const QColor &color, + float fontSize, + TextStyle style, + const QSize &imageSize, + ItemBase::Data data) +: ItemBase(std::move(data)) +, _text(text) +, _color(color) +, _fontSize(fontSize) +, _textStyle(style) +, _imageSize(imageSize) { + renderContent(); +} + +void ItemText::renderContent() { + if (_text.isEmpty()) { + _pixmap = QPixmap(); + setAspectRatio(1.); + return; + } + + const auto m = ComputeMetrics(_text, _fontSize, _imageSize); + const auto pixWidth = m.contentWidth + 2 * m.padding; + const auto pixHeight = m.contentHeight + 2 * m.padding; + + const auto font = TextFont(_fontSize); + + auto processedText = _text; + processedText.replace('\n', QChar::LineSeparator); + + auto option = QTextOption(); + option.setWrapMode(QTextOption::WordWrap); + + auto layout = QTextLayout(processedText, font); + layout.setTextOption(option); + layout.beginLayout(); + auto y = 0.; + while (true) { + auto line = layout.createLine(); + if (!line.isValid()) { + break; + } + line.setLineWidth(m.textMaxWidth); + line.setPosition(QPointF(0, y)); + y += line.height(); + } + layout.endLayout(); + + auto textColor = _color; + auto bgColor = QColor(Qt::transparent); + const auto brightness = ComputeBrightness(_color); + const auto cornerRadius = _fontSize / 3.f; + const auto hasPerLineBackground = + (_textStyle == TextStyle::Framed) + || (_textStyle == TextStyle::SemiTransparent); + + switch (_textStyle) { + case TextStyle::Framed: + bgColor = _color; + textColor = (brightness >= 0.721f) + ? QColor(0, 0, 0) + : QColor(255, 255, 255); + break; + case TextStyle::SemiTransparent: + bgColor = (brightness >= 0.25f) + ? QColor(0, 0, 0, 0x99) + : QColor(255, 255, 255, 0x99); + break; + case TextStyle::Plain: + break; + } + + const auto dpr = style::DevicePixelRatio(); + auto pixmap = QPixmap(QSize(pixWidth, pixHeight) * dpr); + pixmap.setDevicePixelRatio(dpr); + pixmap.fill(Qt::transparent); + + { + auto p = QPainter(&pixmap); + auto hq = PainterHighQualityEnabler(p); + + if (hasPerLineBackground) { + const auto linePadH = _fontSize / 3.f; + const auto linePadV = _fontSize / 8.f; + + p.setPen(Qt::NoPen); + p.setBrush(bgColor); + + for (auto i = 0; i < layout.lineCount(); ++i) { + const auto line = layout.lineAt(i); + const auto natWidth = line.naturalTextWidth(); + const auto xOffset = + (m.contentWidth - natWidth) / 2.; + const auto rect = QRectF( + m.padding + xOffset - linePadH, + m.padding + line.y() - linePadV, + natWidth + 2. * linePadH, + line.height() + 2. * linePadV); + p.drawRoundedRect(rect, cornerRadius, cornerRadius); + } + } + + p.setPen(textColor); + for (auto i = 0; i < layout.lineCount(); ++i) { + const auto line = layout.lineAt(i); + const auto xOffset = + (m.contentWidth - line.naturalTextWidth()) / 2.; + line.draw(&p, QPointF(m.padding + xOffset, m.padding)); + } + } + + _pixmap = std::move(pixmap); + const auto handleMargin = std::max( + innerRect().width() - contentRect().width(), + 0.); + setAspectRatio( + (pixHeight + handleMargin) / float64(pixWidth + handleMargin)); +} + +QSize ItemText::computeContentSize( + const QString &text, + float fontSize, + const QSize &imageSize) { + if (text.isEmpty()) { + return {}; + } + auto processedText = text; + processedText.replace('\n', QChar::LineSeparator); + const auto m = ComputeMetrics(processedText, fontSize, imageSize); + return QSize( + m.contentWidth + 2 * m.padding, + m.contentHeight + 2 * m.padding); +} + +void ItemText::paint( + QPainter *p, + const QStyleOptionGraphicsItem *option, + QWidget *w) { + if (!_pixmap.isNull()) { + const auto rect = contentRect(); + const auto pixmapSize = QSizeF( + _pixmap.size() / style::DevicePixelRatio() + ).scaled(rect.size(), Qt::KeepAspectRatio); + const auto resultRect = QRectF( + rect.topLeft(), + pixmapSize + ).translated( + (rect.width() - pixmapSize.width()) / 2., + (rect.height() - pixmapSize.height()) / 2.); + if (flipped()) { + p->save(); + const auto center = resultRect.center(); + p->translate(center); + p->scale(-1, 1); + p->translate(-center); + p->drawPixmap(resultRect.toRect(), _pixmap); + p->restore(); + } else { + p->drawPixmap(resultRect.toRect(), _pixmap); + } + } + ItemBase::paint(p, option, w); +} + +int ItemText::type() const { + return Type; +} + +const QString &ItemText::text() const { + return _text; +} + +void ItemText::setText(const QString &text) { + _text = text; + renderContent(); + update(); +} + +const QColor &ItemText::color() const { + return _color; +} + +void ItemText::setColor(const QColor &color) { + _color = color; + renderContent(); + update(); +} + +float ItemText::fontSize() const { + return _fontSize; +} + +float64 ItemText::editScale() const { + const auto natural = computeContentSize(_text, _fontSize, _imageSize); + if (natural.width() <= 0) { + return 1.; + } + return size() / natural.width(); +} + +TextStyle ItemText::textStyle() const { + return _textStyle; +} + +void ItemText::setTextStyle(TextStyle style) { + _textStyle = style; + renderContent(); + update(); +} + +void ItemText::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { + if (const auto s = static_cast(scene())) { + s->startTextEditing(this); + } +} + +void ItemText::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { + if (scene()) { + scene()->clearSelection(); + setSelected(true); + } + + _contextMenu = base::make_unique_q( + nullptr, + st::popupMenuWithIcons); + const auto add = [&](const QString &text, TextStyle style) { + const auto checked = (_textStyle == style); + auto action = _contextMenu->addAction(text, [=] { + setTextStyle(style); + }); + if (checked) { + action->setChecked(true); + } + }; + add(u"Plain"_q, TextStyle::Plain); + add(u"Framed"_q, TextStyle::Framed); + add(u"Semi-Transparent"_q, TextStyle::SemiTransparent); + + _contextMenu->addSeparator(); + + _contextMenu->addAction( + tr::lng_photo_editor_menu_delete(tr::now), + [=] { actionDelete(); }, + &st::menuIconDelete); + _contextMenu->addAction( + tr::lng_photo_editor_menu_duplicate(tr::now), + [=] { actionDuplicate(); }, + &st::menuIconCopy); + + _contextMenu->popup(event->screenPos()); +} + +void ItemText::performFlip() { + update(); +} + +std::shared_ptr ItemText::duplicate(ItemBase::Data data) const { + return std::make_shared( + _text, + _color, + _fontSize, + _textStyle, + _imageSize, + std::move(data)); +} + +void ItemText::save(SaveState state) { + ItemBase::save(state); + auto &saved = (state == SaveState::Keep) ? _keepedState : _savedState; + saved = { + .saved = true, + .status = status(), + .text = _text, + .color = _color, + .fontSize = _fontSize, + .textStyle = _textStyle, + }; +} + +void ItemText::restore(SaveState state) { + if (!hasState(state)) { + return; + } + const auto &saved = (state == SaveState::Keep) ? _keepedState : _savedState; + _text = saved.text; + _color = saved.color; + _fontSize = saved.fontSize; + _textStyle = saved.textStyle; + renderContent(); + ItemBase::restore(state); +} + +bool ItemText::hasState(SaveState state) const { + return ItemBase::hasState(state); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.h b/Telegram/SourceFiles/editor/scene/scene_item_text.h new file mode 100644 index 0000000000..07ec5b85a3 --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.h @@ -0,0 +1,93 @@ +/* +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 "base/unique_qptr.h" +#include "editor/scene/scene_item_base.h" + +namespace Ui { +class PopupMenu; +} // namespace Ui + +namespace Editor { + +enum class TextStyle : uchar { + Framed, + SemiTransparent, + Plain, +}; + +class ItemText : public ItemBase { +public: + enum { Type = ItemBase::Type + 2 }; + + ItemText( + const QString &text, + const QColor &color, + float fontSize, + TextStyle style, + const QSize &imageSize, + ItemBase::Data data); + + void paint( + QPainter *p, + const QStyleOptionGraphicsItem *option, + QWidget *widget) override; + int type() const override; + + [[nodiscard]] const QString &text() const; + void setText(const QString &text); + + [[nodiscard]] const QColor &color() const; + void setColor(const QColor &color); + + [[nodiscard]] float fontSize() const; + + [[nodiscard]] TextStyle textStyle() const; + void setTextStyle(TextStyle style); + + [[nodiscard]] float64 editScale() const; + + [[nodiscard]] static QSize computeContentSize( + const QString &text, + float fontSize, + const QSize &imageSize); + + void save(SaveState state) override; + void restore(SaveState state) override; + bool hasState(SaveState state) const override; + +protected: + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override; + void performFlip() override; + std::shared_ptr duplicate(ItemBase::Data data) const override; + +private: + void renderContent(); + + QString _text; + QColor _color; + float _fontSize; + TextStyle _textStyle = TextStyle::Plain; + QSize _imageSize; + QPixmap _pixmap; + base::unique_qptr _contextMenu; + + struct SavedText { + bool saved = false; + NumberedItem::Status status = NumberedItem::Status::Normal; + QString text; + QColor color; + float fontSize = 0; + TextStyle textStyle = TextStyle::Plain; + }; + SavedText _savedState, _keepedState; +}; + +} // namespace Editor diff --git a/Telegram/cmake/td_ui.cmake b/Telegram/cmake/td_ui.cmake index 51828b9eb2..9baeaea9c9 100644 --- a/Telegram/cmake/td_ui.cmake +++ b/Telegram/cmake/td_ui.cmake @@ -147,6 +147,8 @@ PRIVATE editor/scene/scene_item_image.h editor/scene/scene_item_line.cpp editor/scene/scene_item_line.h + editor/scene/scene_item_text.cpp + editor/scene/scene_item_text.h ui/boxes/about_cocoon_box.h ui/boxes/about_cocoon_box.cpp From 0fff162c6552b390907c048a5684949ff1e1215e Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sat, 11 Apr 2026 20:22:45 +0300 Subject: [PATCH 41/78] [img-editor] Added emoji support in photo editor text items. --- Telegram/SourceFiles/editor/scene/scene.cpp | 30 +- .../editor/scene/scene_item_canvas.cpp | 2 +- .../editor/scene/scene_item_text.cpp | 386 +++++++++++++++++- .../editor/scene/scene_item_text.h | 14 + 4 files changed, 405 insertions(+), 27 deletions(-) diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index b35bec3204..11a862c151 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -11,6 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene_item_line.h" #include "editor/scene/scene_item_sticker.h" #include "editor/scene/scene_item_text.h" +#include "ui/emoji_config.h" #include "ui/image/image_prepare.h" #include "ui/rp_widget.h" #include "styles/style_editor.h" @@ -18,6 +19,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include #include +#include #include #include #include @@ -505,17 +507,24 @@ void Scene::createTextAtCenter() { proxy->setTextInteractionFlags(Qt::TextEditorInteraction); proxy->setDefaultTextColor(_textColor); + auto *emojiDoc = new EmojiDocument(proxy); + proxy->setDocument(emojiDoc); + auto font = QFont(); font.setPixelSize(int(_textFontSize)); font.setWeight(QFont::DemiBold); proxy->setFont(font); { - auto option = proxy->document()->defaultTextOption(); + auto option = emojiDoc->defaultTextOption(); option.setAlignment(Qt::AlignCenter); - proxy->document()->setDefaultTextOption(option); + emojiDoc->setDefaultTextOption(option); } + QObject::connect(emojiDoc, &QTextDocument::contentsChanged, [emojiDoc] { + ReplaceEmoji(emojiDoc); + }); + const auto shortSide = std::min( sceneRect().width(), sceneRect().height()); @@ -528,6 +537,9 @@ void Scene::createTextAtCenter() { QGraphicsScene::addItem(proxy); proxy->setZValue((*_lastZ)++); proxy->setFocus(); + if (!views().isEmpty()) { + views().first()->setFocus(); + } proxy->onFinish = crl::guard(this, [=] { finishTextEditing(true); @@ -553,18 +565,26 @@ void Scene::startTextEditing(ItemText *item) { proxy->setTextInteractionFlags(Qt::TextEditorInteraction); proxy->setDefaultTextColor(item->color()); + auto *emojiDoc = new EmojiDocument(proxy); + proxy->setDocument(emojiDoc); + auto font = QFont(); font.setPixelSize(int(item->fontSize())); font.setWeight(QFont::DemiBold); proxy->setFont(font); { - auto option = proxy->document()->defaultTextOption(); + auto option = emojiDoc->defaultTextOption(); option.setAlignment(Qt::AlignCenter); - proxy->document()->setDefaultTextOption(option); + emojiDoc->setDefaultTextOption(option); } proxy->setPlainText(item->text()); + ReplaceEmoji(emojiDoc); + + QObject::connect(emojiDoc, &QTextDocument::contentsChanged, [emojiDoc] { + ReplaceEmoji(emojiDoc); + }); const auto shortSide = std::min( sceneRect().width(), @@ -609,7 +629,7 @@ void Scene::finishTextEditing(bool save) { } const auto text = save - ? _textEdit.proxy->toPlainText().trimmed() + ? RecoverTextFromDocument(_textEdit.proxy->document()).trimmed() : QString(); const auto proxyRect = _textEdit.proxy->boundingRect(); const auto proxyCenter = _textEdit.proxy->pos() diff --git a/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp b/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp index 5e7cbb91b5..27a4186dee 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp @@ -257,7 +257,7 @@ void ItemCanvas::drawArrowHead() { } direction /= length; const auto angle = qDegreesToRadians( - double(st::photoEditorArrowHeadAngleDegrees)); + float64(st::photoEditorArrowHeadAngleDegrees)); const auto sinA = std::sin(angle); const auto cosA = std::cos(angle); const auto rotate = [&](const QPointF &v, float64 s, float64 c) { diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index 38fb590b01..1e0a759337 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene.h" #include "lang/lang_keys.h" +#include "ui/emoji_config.h" #include "ui/painter.h" #include "ui/widgets/popup_menu.h" #include "styles/style_editor.h" @@ -16,6 +17,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include +#include +#include +#include #include #include @@ -76,7 +80,7 @@ LayoutMetrics ComputeMetrics( line.setLineWidth(textMaxWidth); line.setPosition(QPointF(0, totalHeight)); totalHeight += line.height(); - maxWidth = std::max(maxWidth, double(line.naturalTextWidth())); + maxWidth = std::max(maxWidth, float64(line.naturalTextWidth())); } layout.endLayout(); @@ -88,8 +92,272 @@ LayoutMetrics ComputeMetrics( }; } +struct LineRect { + float left = 0; + float top = 0; + float right = 0; + float bottom = 0; + [[nodiscard]] float width() const { return right - left; } +}; + +QPainterPath BuildConnectedBackground( + const QTextLayout &layout, + int contentWidth, + int padding, + float fontSize) { + const auto linePadH = fontSize / 3.f; + const auto linePadV = fontSize / 8.f; + const auto cornerRadius = fontSize / 3.f; + const auto mergeRadius = cornerRadius * 1.5f; + const auto centerX = padding + contentWidth / 2.f; + + auto rects = std::vector(); + for (auto i = 0; i < layout.lineCount(); ++i) { + const auto line = layout.lineAt(i); + const auto hw = float(line.naturalTextWidth()) / 2.f + linePadH; + rects.push_back({ + .left = centerX - hw, + .top = padding + float(line.y()) - linePadV, + .right = centerX + hw, + .bottom = padding + float(line.y() + line.height()) + linePadV, + }); + } + + if (rects.empty()) { + return {}; + } + if (rects.size() == 1) { + auto path = QPainterPath(); + const auto &r = rects[0]; + path.addRoundedRect( + QRectF(r.left, r.top, r.width(), r.bottom - r.top), + cornerRadius, + cornerRadius); + return path; + } + + for (auto i = 1; i < int(rects.size()); ++i) { + rects[i - 1].bottom = rects[i].top; + } + + for (auto i = 1; i < int(rects.size()); ++i) { + auto traceback = false; + if (std::abs(rects[i - 1].left - rects[i].left) < mergeRadius) { + const auto v = std::min(rects[i - 1].left, rects[i].left); + rects[i - 1].left = rects[i].left = v; + traceback = true; + } + if (std::abs(rects[i - 1].right - rects[i].right) < mergeRadius) { + const auto v = std::max(rects[i - 1].right, rects[i].right); + rects[i - 1].right = rects[i].right = v; + traceback = true; + } + if (traceback) { + for (auto j = i; j >= 1; --j) { + if (std::abs(rects[j - 1].left - rects[j].left) < mergeRadius) { + const auto v = std::min( + rects[j - 1].left, + rects[j].left); + rects[j - 1].left = rects[j].left = v; + } + if (std::abs(rects[j - 1].right - rects[j].right) + < mergeRadius) { + const auto v = std::max( + rects[j - 1].right, + rects[j].right); + rects[j - 1].right = rects[j].right = v; + } + } + } + } + + struct V { float x, y; }; + auto verts = std::vector(); + + verts.push_back({ rects[0].left, rects[0].top }); + verts.push_back({ rects[0].right, rects[0].top }); + + for (auto i = 1; i < int(rects.size()); ++i) { + if (std::abs(rects[i].right - rects[i - 1].right) > 0.5f) { + verts.push_back({ rects[i - 1].right, rects[i].top }); + verts.push_back({ rects[i].right, rects[i].top }); + } + } + + const auto last = int(rects.size()) - 1; + verts.push_back({ rects[last].right, rects[last].bottom }); + verts.push_back({ rects[last].left, rects[last].bottom }); + + for (auto i = last - 1; i >= 0; --i) { + if (std::abs(rects[i].left - rects[i + 1].left) > 0.5f) { + verts.push_back({ rects[i + 1].left, rects[i + 1].top }); + verts.push_back({ rects[i].left, rects[i + 1].top }); + } + } + + auto path = QPainterPath(); + const auto n = int(verts.size()); + for (auto i = 0; i < n; ++i) { + const auto &prev = verts[(i + n - 1) % n]; + const auto &curr = verts[i]; + const auto &next = verts[(i + 1) % n]; + + const auto dx1 = curr.x - prev.x; + const auto dy1 = curr.y - prev.y; + const auto len1 = std::sqrt(dx1 * dx1 + dy1 * dy1); + + const auto dx2 = next.x - curr.x; + const auto dy2 = next.y - curr.y; + const auto len2 = std::sqrt(dx2 * dx2 + dy2 * dy2); + + if (len1 < 0.1f || len2 < 0.1f) { + if (i == 0) { + path.moveTo(curr.x, curr.y); + } else { + path.lineTo(curr.x, curr.y); + } + continue; + } + + const auto r = std::min({ + cornerRadius, + len1 / 2.f, + len2 / 2.f, + }); + const auto bx = curr.x - dx1 / len1 * r; + const auto by = curr.y - dy1 / len1 * r; + const auto ax = curr.x + dx2 / len2 * r; + const auto ay = curr.y + dy2 / len2 * r; + + if (i == 0) { + path.moveTo(bx, by); + } else { + path.lineTo(bx, by); + } + path.quadTo(curr.x, curr.y, ax, ay); + } + path.closeSubpath(); + return path; +} + } // namespace +EmojiDocument::EmojiDocument(QObject *parent) +: QTextDocument(parent) { +} + +QVariant EmojiDocument::loadResource(int type, const QUrl &name) { + if (type != QTextDocument::ImageResource + || name.scheme() != u"emoji"_q) { + return QTextDocument::loadResource(type, name); + } + const auto i = _cache.find(name); + if (i != _cache.end()) { + return i->second; + } + auto result = QVariant(); + if (const auto emoji = Ui::Emoji::FromUrl(name.toDisplayString())) { + const auto factor = style::DevicePixelRatio(); + const auto logical = QFontMetrics(defaultFont()).height(); + const auto source = Ui::Emoji::GetSizeLarge(); + auto image = QImage( + QSize(logical, logical) * factor, + QImage::Format_ARGB32_Premultiplied); + image.setDevicePixelRatio(factor); + image.fill(Qt::transparent); + { + auto p = QPainter(&image); + auto hq = PainterHighQualityEnabler(p); + const auto enlarged = logical * 1.0; + const auto sourceLogical = source / float64(factor); + const auto scale = enlarged / sourceLogical; + const auto offset = (logical - enlarged) / 2.; + p.translate(offset, offset); + p.scale(scale, scale); + Ui::Emoji::Draw(p, emoji, source, 0, 0); + } + result = QVariant(QPixmap::fromImage(std::move(image))); + } + _cache.emplace(name, result); + return result; +} + +void ReplaceEmoji(QTextDocument *doc) { + const auto fontHeight = QFontMetrics(doc->defaultFont()).height(); + auto cursor = QTextCursor(doc); + auto block = doc->begin(); + while (block.isValid()) { + auto text = block.text(); + auto start = text.constData(); + auto end = start + text.size(); + auto ch = start; + while (ch < end) { + auto emojiLength = 0; + const auto emoji = Ui::Emoji::Find(ch, end, &emojiLength); + if (!emoji) { + ++ch; + continue; + } + const auto pos = block.position() + int(ch - start); + cursor.setPosition(pos); + cursor.setPosition( + pos + emojiLength, + QTextCursor::KeepAnchor); + + auto format = QTextImageFormat(); + format.setName(emoji->toUrl()); + format.setWidth(fontHeight); + format.setHeight(fontHeight); + format.setVerticalAlignment( + QTextCharFormat::AlignBaseline); + cursor.insertImage(format); + + block = doc->findBlock(pos); + text = block.text(); + start = text.constData(); + end = start + text.size(); + ch = start + (pos - block.position()) + 1; + continue; + } + block = block.next(); + } +} + +QString RecoverTextFromDocument(QTextDocument *doc) { + auto result = QString(); + auto block = doc->begin(); + while (block.isValid()) { + if (block != doc->begin()) { + result += '\n'; + } + auto it = block.begin(); + while (!it.atEnd()) { + const auto fragment = it.fragment(); + if (!fragment.isValid()) { + ++it; + continue; + } + const auto text = fragment.text(); + const auto format = fragment.charFormat(); + for (const auto &ch : text) { + if (ch == QChar::ObjectReplacementCharacter) { + if (format.isImageFormat()) { + const auto name = format.toImageFormat().name(); + if (const auto emoji = Ui::Emoji::FromUrl(name)) { + result += emoji->text(); + continue; + } + } + } + result += ch; + } + ++it; + } + block = block.next(); + } + return result; +} + ItemText::ItemText( const QString &text, const QColor &color, @@ -127,6 +395,30 @@ void ItemText::renderContent() { auto layout = QTextLayout(processedText, font); layout.setTextOption(option); + + auto emojiFormats = QList(); + { + auto pos = 0; + const auto begin = processedText.constData(); + const auto end = begin + processedText.size(); + while (pos < processedText.size()) { + auto emojiLen = 0; + const auto emoji = Ui::Emoji::Find( + begin + pos, + end, + &emojiLen); + if (emoji) { + auto fmt = QTextCharFormat(); + fmt.setForeground(QColor(0, 0, 0, 0)); + emojiFormats.append({ pos, emojiLen, fmt }); + pos += emojiLen; + } else { + ++pos; + } + } + } + layout.setFormats(emojiFormats); + layout.beginLayout(); auto y = 0.; while (true) { @@ -143,8 +435,7 @@ void ItemText::renderContent() { auto textColor = _color; auto bgColor = QColor(Qt::transparent); const auto brightness = ComputeBrightness(_color); - const auto cornerRadius = _fontSize / 3.f; - const auto hasPerLineBackground = + const auto hasBackground = (_textStyle == TextStyle::Framed) || (_textStyle == TextStyle::SemiTransparent); @@ -173,24 +464,32 @@ void ItemText::renderContent() { auto p = QPainter(&pixmap); auto hq = PainterHighQualityEnabler(p); - if (hasPerLineBackground) { - const auto linePadH = _fontSize / 3.f; - const auto linePadV = _fontSize / 8.f; - - p.setPen(Qt::NoPen); - p.setBrush(bgColor); - - for (auto i = 0; i < layout.lineCount(); ++i) { - const auto line = layout.lineAt(i); - const auto natWidth = line.naturalTextWidth(); - const auto xOffset = - (m.contentWidth - natWidth) / 2.; - const auto rect = QRectF( - m.padding + xOffset - linePadH, - m.padding + line.y() - linePadV, - natWidth + 2. * linePadH, - line.height() + 2. * linePadV); - p.drawRoundedRect(rect, cornerRadius, cornerRadius); + if (hasBackground) { + const auto bgPath = BuildConnectedBackground( + layout, + m.contentWidth, + m.padding, + _fontSize); + if (_textStyle == TextStyle::SemiTransparent) { + auto opaque = bgColor; + opaque.setAlpha(255); + auto mask = QPixmap(pixmap.size()); + mask.setDevicePixelRatio(dpr); + mask.fill(Qt::transparent); + { + auto mp = QPainter(&mask); + auto mhq = PainterHighQualityEnabler(mp); + mp.setPen(Qt::NoPen); + mp.setBrush(opaque); + mp.drawPath(bgPath); + } + p.setOpacity(bgColor.alphaF()); + p.drawPixmap(0, 0, mask); + p.setOpacity(1.0); + } else { + p.setPen(Qt::NoPen); + p.setBrush(bgColor); + p.drawPath(bgPath); } } @@ -201,6 +500,51 @@ void ItemText::renderContent() { (m.contentWidth - line.naturalTextWidth()) / 2.; line.draw(&p, QPointF(m.padding + xOffset, m.padding)); } + + p.setRenderHint(QPainter::SmoothPixmapTransform, true); + const auto factor = style::DevicePixelRatio(); + const auto source = Ui::Emoji::GetSizeLarge(); + const auto sourceLogical = source / float64(factor); + const auto emojiSize = float64(QFontMetrics(font).height()); + const auto emojiScale = emojiSize / sourceLogical; + for (auto i = 0; i < layout.lineCount(); ++i) { + const auto line = layout.lineAt(i); + const auto xOffset = + (m.contentWidth - line.naturalTextWidth()) / 2.; + const auto lineStart = line.textStart(); + const auto lineText = processedText.mid( + lineStart, + line.textLength()); + auto pos = 0; + while (pos < lineText.size()) { + auto emojiLen = 0; + const auto emoji = Ui::Emoji::Find( + lineText.constData() + pos, + lineText.constData() + lineText.size(), + &emojiLen); + if (!emoji) { + ++pos; + continue; + } + const auto x = line.cursorToX(lineStart + pos); + const auto nextX = line.cursorToX( + lineStart + pos + emojiLen); + const auto glyphWidth = float64(nextX - x); + const auto drawX = m.padding + + xOffset + + x + + (glyphWidth - emojiSize) / 2.; + const auto drawY = m.padding + + line.y() + + (line.height() - emojiSize) / 2.; + p.save(); + p.translate(drawX, drawY); + p.scale(emojiScale, emojiScale); + Ui::Emoji::Draw(p, emoji, source, 0, 0); + p.restore(); + pos += emojiLen; + } + } } _pixmap = std::move(pixmap); diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.h b/Telegram/SourceFiles/editor/scene/scene_item_text.h index 07ec5b85a3..e4d136b3b1 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.h @@ -10,6 +10,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/unique_qptr.h" #include "editor/scene/scene_item_base.h" +#include + namespace Ui { class PopupMenu; } // namespace Ui @@ -22,6 +24,18 @@ enum class TextStyle : uchar { Plain, }; +class EmojiDocument final : public QTextDocument { +public: + explicit EmojiDocument(QObject *parent = nullptr); + QVariant loadResource(int type, const QUrl &name) override; + +private: + std::map _cache; +}; + +void ReplaceEmoji(QTextDocument *doc); +[[nodiscard]] QString RecoverTextFromDocument(QTextDocument *doc); + class ItemText : public ItemBase { public: enum { Type = ItemBase::Type + 2 }; From e1789d40d4c45b6d8bb5513a97e7ff622e7d12a6 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sun, 12 Apr 2026 10:57:05 +0300 Subject: [PATCH 42/78] [img-editor] Added auto-fitting width for text editing overlay. --- Telegram/SourceFiles/editor/scene/scene.cpp | 58 ++++++++++++++------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index 11a862c151..0655a4782b 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -521,18 +521,29 @@ void Scene::createTextAtCenter() { emojiDoc->setDefaultTextOption(option); } - QObject::connect(emojiDoc, &QTextDocument::contentsChanged, [emojiDoc] { - ReplaceEmoji(emojiDoc); - }); - const auto shortSide = std::min( sceneRect().width(), sceneRect().height()); const auto padding = int(_textFontSize * 0.4); - const auto textWidth = int(shortSide * 0.8) - 2 * padding; - proxy->setTextWidth(textWidth); - const auto center = sceneRect().center(); - proxy->setPos(center.x() - textWidth / 2., center.y()); + const auto maxTextWidth = int(shortSide * 0.8) - 2 * padding; + const auto minTextWidth = int(shortSide * 0.16) - 2 * padding; + const auto sceneCenter = sceneRect().center(); + const auto adjustWidth = [=] { + emojiDoc->setTextWidth(maxTextWidth); + const auto ideal = int(std::ceil(emojiDoc->idealWidth())); + const auto width = std::clamp( + ideal + 2, + minTextWidth, + maxTextWidth); + proxy->setTextWidth(width); + proxy->setPos(sceneCenter.x() - width / 2., sceneCenter.y()); + }; + adjustWidth(); + + QObject::connect(emojiDoc, &QTextDocument::contentsChanged, [=] { + ReplaceEmoji(emojiDoc); + adjustWidth(); + }); QGraphicsScene::addItem(proxy); proxy->setZValue((*_lastZ)++); @@ -582,22 +593,33 @@ void Scene::startTextEditing(ItemText *item) { proxy->setPlainText(item->text()); ReplaceEmoji(emojiDoc); - QObject::connect(emojiDoc, &QTextDocument::contentsChanged, [emojiDoc] { - ReplaceEmoji(emojiDoc); - }); - const auto shortSide = std::min( sceneRect().width(), sceneRect().height()); const auto padding = int(item->fontSize() * 0.4); - const auto textWidth = int(shortSide * 0.8) - 2 * padding; - proxy->setTextWidth(textWidth); + const auto maxTextWidth = int(shortSide * 0.8) - 2 * padding; + const auto minTextWidth = int(shortSide * 0.16) - 2 * padding; + const auto anchor = item->scenePos(); + const auto adjustWidth = [=] { + emojiDoc->setTextWidth(maxTextWidth); + const auto ideal = int(std::ceil(emojiDoc->idealWidth())); + const auto width = std::clamp( + ideal + 2, + minTextWidth, + maxTextWidth); + proxy->setTextWidth(width); + const auto center = proxy->boundingRect().center(); + proxy->setTransformOriginPoint(center); + proxy->setPos(anchor - center); + }; + adjustWidth(); + + QObject::connect(emojiDoc, &QTextDocument::contentsChanged, [=] { + ReplaceEmoji(emojiDoc); + adjustWidth(); + }); const auto scale = item->editScale(); - const auto proxyRect = proxy->boundingRect(); - const auto center = proxyRect.center(); - proxy->setTransformOriginPoint(center); - proxy->setPos(item->scenePos() - center); proxy->setRotation(item->rotation()); if (std::abs(scale - 1.) > 0.01) { proxy->setScale(scale); From 6153480e43efc1b9d1d4d9400e7feeb645345092 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sun, 12 Apr 2026 11:01:52 +0300 Subject: [PATCH 43/78] [img-editor] Fixed text wrapping, spacing, and geometry in editor items. --- Telegram/SourceFiles/editor/scene/scene.cpp | 12 +++++- .../editor/scene/scene_item_base.cpp | 1 + .../editor/scene/scene_item_text.cpp | 37 +++++++++++++------ .../editor/scene/scene_item_text.h | 3 +- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index 0655a4782b..306c63e8b7 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -508,6 +508,7 @@ void Scene::createTextAtCenter() { proxy->setDefaultTextColor(_textColor); auto *emojiDoc = new EmojiDocument(proxy); + emojiDoc->setDocumentMargin(0); proxy->setDocument(emojiDoc); auto font = QFont(); @@ -577,6 +578,7 @@ void Scene::startTextEditing(ItemText *item) { proxy->setDefaultTextColor(item->color()); auto *emojiDoc = new EmojiDocument(proxy); + emojiDoc->setDocumentMargin(0); proxy->setDocument(emojiDoc); auto font = QFont(); @@ -675,8 +677,14 @@ void Scene::finishTextEditing(bool save) { const auto contentSize = ItemText::computeContentSize( text, _textFontSize, - imageSize); - const auto size = std::max(contentSize.width(), 1); + imageSize, + TextStyle::Plain); + const auto zoom = (_currentZoom > 0.) ? _currentZoom : 1.; + const auto handleInflate = int( + std::ceil(st::photoEditorItemHandleSize / zoom)); + const auto size = std::max( + contentSize.width() + handleInflate, + 1); auto data = ItemBase::Data{ .initialZoom = zoom, .zPtr = _lastZ, diff --git a/Telegram/SourceFiles/editor/scene/scene_item_base.cpp b/Telegram/SourceFiles/editor/scene/scene_item_base.cpp index 8c69702584..be84c2773b 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_base.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_base.cpp @@ -329,6 +329,7 @@ void ItemBase::updateVerticalSize() { } void ItemBase::setAspectRatio(float64 aspectRatio) { + prepareGeometryChange(); _aspectRatio = aspectRatio; updateVerticalSize(); } diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index 1e0a759337..b9a1b7ae0c 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -53,8 +53,11 @@ float ComputeBrightness(const QColor &color) { LayoutMetrics ComputeMetrics( const QString &text, float fontSize, - const QSize &imageSize) { - const auto padding = int(fontSize * kPaddingFactor); + const QSize &imageSize, + TextStyle style) { + const auto hasBackground = (style == TextStyle::Framed) + || (style == TextStyle::SemiTransparent); + const auto padding = hasBackground ? int(fontSize * kPaddingFactor) : 0; const auto shortSide = std::min(imageSize.width(), imageSize.height()); const auto textMaxWidth = int(shortSide * kMaxWidthFactor) - 2 * padding; @@ -64,7 +67,7 @@ LayoutMetrics ComputeMetrics( processedText.replace('\n', QChar::LineSeparator); auto option = QTextOption(); - option.setWrapMode(QTextOption::WordWrap); + option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); auto layout = QTextLayout(processedText, font); layout.setTextOption(option); @@ -381,7 +384,7 @@ void ItemText::renderContent() { return; } - const auto m = ComputeMetrics(_text, _fontSize, _imageSize); + const auto m = ComputeMetrics(_text, _fontSize, _imageSize, _textStyle); const auto pixWidth = m.contentWidth + 2 * m.padding; const auto pixHeight = m.contentHeight + 2 * m.padding; @@ -391,7 +394,7 @@ void ItemText::renderContent() { processedText.replace('\n', QChar::LineSeparator); auto option = QTextOption(); - option.setWrapMode(QTextOption::WordWrap); + option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); auto layout = QTextLayout(processedText, font); layout.setTextOption(option); @@ -493,12 +496,17 @@ void ItemText::renderContent() { } } + const auto lineShift = _fontSize / 7.f; + const auto lineCount = layout.lineCount(); p.setPen(textColor); - for (auto i = 0; i < layout.lineCount(); ++i) { + for (auto i = 0; i < lineCount; ++i) { const auto line = layout.lineAt(i); const auto xOffset = (m.contentWidth - line.naturalTextWidth()) / 2.; - line.draw(&p, QPointF(m.padding + xOffset, m.padding)); + const auto yShift = (i < lineCount - 1) ? -lineShift : 0.f; + line.draw( + &p, + QPointF(m.padding + xOffset, m.padding + yShift)); } p.setRenderHint(QPainter::SmoothPixmapTransform, true); @@ -507,10 +515,11 @@ void ItemText::renderContent() { const auto sourceLogical = source / float64(factor); const auto emojiSize = float64(QFontMetrics(font).height()); const auto emojiScale = emojiSize / sourceLogical; - for (auto i = 0; i < layout.lineCount(); ++i) { + for (auto i = 0; i < lineCount; ++i) { const auto line = layout.lineAt(i); const auto xOffset = (m.contentWidth - line.naturalTextWidth()) / 2.; + const auto yShift = (i < lineCount - 1) ? -lineShift : 0.f; const auto lineStart = line.textStart(); const auto lineText = processedText.mid( lineStart, @@ -535,6 +544,7 @@ void ItemText::renderContent() { + x + (glyphWidth - emojiSize) / 2.; const auto drawY = m.padding + + yShift + line.y() + (line.height() - emojiSize) / 2.; p.save(); @@ -558,13 +568,14 @@ void ItemText::renderContent() { QSize ItemText::computeContentSize( const QString &text, float fontSize, - const QSize &imageSize) { + const QSize &imageSize, + TextStyle style) { if (text.isEmpty()) { return {}; } auto processedText = text; processedText.replace('\n', QChar::LineSeparator); - const auto m = ComputeMetrics(processedText, fontSize, imageSize); + const auto m = ComputeMetrics(processedText, fontSize, imageSize, style); return QSize( m.contentWidth + 2 * m.padding, m.contentHeight + 2 * m.padding); @@ -629,7 +640,11 @@ float ItemText::fontSize() const { } float64 ItemText::editScale() const { - const auto natural = computeContentSize(_text, _fontSize, _imageSize); + const auto natural = computeContentSize( + _text, + _fontSize, + _imageSize, + _textStyle); if (natural.width() <= 0) { return 1.; } diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.h b/Telegram/SourceFiles/editor/scene/scene_item_text.h index e4d136b3b1..a35bf4cecc 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.h @@ -70,7 +70,8 @@ public: [[nodiscard]] static QSize computeContentSize( const QString &text, float fontSize, - const QSize &imageSize); + const QSize &imageSize, + TextStyle style); void save(SaveState state) override; void restore(SaveState state) override; From 631fe4f3624acc55d527dd2d83b0954c68cbe9e4 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sun, 12 Apr 2026 14:45:01 +0300 Subject: [PATCH 44/78] [img-editor] Improved color handling in text items. --- Telegram/Resources/langs/lang.strings | 4 + Telegram/SourceFiles/editor/color_picker.cpp | 11 + Telegram/SourceFiles/editor/color_picker.h | 1 + Telegram/SourceFiles/editor/editor_paint.cpp | 15 +- Telegram/SourceFiles/editor/editor_paint.h | 3 + Telegram/SourceFiles/editor/photo_editor.cpp | 8 +- .../editor/photo_editor_content.cpp | 8 + .../SourceFiles/editor/photo_editor_content.h | 3 + .../editor/photo_editor_controls.cpp | 5 +- Telegram/SourceFiles/editor/scene/scene.cpp | 161 +++++++------ Telegram/SourceFiles/editor/scene/scene.h | 20 +- .../editor/scene/scene_emoji_document.cpp | 132 +++++++++++ .../editor/scene/scene_emoji_document.h | 26 +++ .../editor/scene/scene_item_text.cpp | 214 +++++------------- .../editor/scene/scene_item_text.h | 24 +- Telegram/cmake/td_ui.cmake | 2 + 16 files changed, 378 insertions(+), 259 deletions(-) create mode 100644 Telegram/SourceFiles/editor/scene/scene_emoji_document.cpp create mode 100644 Telegram/SourceFiles/editor/scene/scene_emoji_document.h diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 56e4a3014f..a5fd302dde 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -7182,6 +7182,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_photo_editor_menu_flip" = "Flip"; "lng_photo_editor_menu_duplicate" = "Duplicate"; +"lng_photo_editor_text_style_plain" = "Plain"; +"lng_photo_editor_text_style_framed" = "Framed"; +"lng_photo_editor_text_style_semi_transparent" = "Semi-Transparent"; + "lng_photo_editor_crop_original" = "Original"; "lng_photo_editor_crop_square" = "Square"; "lng_photo_editor_crop_free" = "Free"; diff --git a/Telegram/SourceFiles/editor/color_picker.cpp b/Telegram/SourceFiles/editor/color_picker.cpp index cd19a75a06..411169079a 100644 --- a/Telegram/SourceFiles/editor/color_picker.cpp +++ b/Telegram/SourceFiles/editor/color_picker.cpp @@ -570,6 +570,17 @@ void ColorPicker::storeCurrentBrush() { _toolBrushes[ToolIndex(_brush.tool)] = _brush; } +void ColorPicker::setColor(const QColor &color) { + _brush.color = color; + storeCurrentBrush(); + updateColorButtonColor(color, true); + if (_paletteVisible) { + rebuildPalette(); + } else { + _colorButton->update(); + } +} + void ColorPicker::updateColorButtonColor(const QColor &color, bool animated) { const auto hasValid = _colorButtonFrom.isValid() && _colorButtonTo.isValid(); const auto from = hasValid ? colorButtonColor() : color; diff --git a/Telegram/SourceFiles/editor/color_picker.h b/Telegram/SourceFiles/editor/color_picker.h index 63b0211b2f..d7e80654fa 100644 --- a/Telegram/SourceFiles/editor/color_picker.h +++ b/Telegram/SourceFiles/editor/color_picker.h @@ -30,6 +30,7 @@ public: void moveLine(const QPoint &position); void setCanvasRect(const QRect &rect); void setVisible(bool visible); + void setColor(const QColor &color); bool preventHandleKeyPress() const; rpl::producer saveBrushRequests() const; diff --git a/Telegram/SourceFiles/editor/editor_paint.cpp b/Telegram/SourceFiles/editor/editor_paint.cpp index 7553983a4d..836e511b36 100644 --- a/Telegram/SourceFiles/editor/editor_paint.cpp +++ b/Telegram/SourceFiles/editor/editor_paint.cpp @@ -67,10 +67,13 @@ Paint::Paint( _scene->setBlurSource(std::move(blurSource)); { - const auto shortSide = std::min(imageSize.width(), imageSize.height()); + constexpr auto kDefaultFontSizeDivisor = 15.; + const auto shortSide = std::min( + imageSize.width(), + imageSize.height()); _scene->setTextDefaults( QColor(255, 255, 255), - shortSide / 15.f, + shortSide / kDefaultFontSizeDivisor, int(TextStyle::Plain)); } @@ -247,6 +250,14 @@ void Paint::createTextItem() { _scene->createTextAtCenter(); } +void Paint::setTextColor(const QColor &color) { + _scene->setTextColor(color); +} + +rpl::producer Paint::textColorRequests() const { + return _scene->textColorRequests(); +} + void Paint::handleMimeData(const QMimeData *data) { const auto add = [&](QImage image) { if (image.isNull()) { diff --git a/Telegram/SourceFiles/editor/editor_paint.h b/Telegram/SourceFiles/editor/editor_paint.h index e2d586aaba..5540b60abc 100644 --- a/Telegram/SourceFiles/editor/editor_paint.h +++ b/Telegram/SourceFiles/editor/editor_paint.h @@ -42,6 +42,9 @@ public: void updateUndoState(); void createTextItem(); + void setTextColor(const QColor &color); + + [[nodiscard]] rpl::producer textColorRequests() const; void handleMimeData(const QMimeData *data); void paintImage(QPainter &p, const QPixmap &image) const; diff --git a/Telegram/SourceFiles/editor/photo_editor.cpp b/Telegram/SourceFiles/editor/photo_editor.cpp index 27d864dc4f..786e6794e5 100644 --- a/Telegram/SourceFiles/editor/photo_editor.cpp +++ b/Telegram/SourceFiles/editor/photo_editor.cpp @@ -151,7 +151,7 @@ struct BrushState { const auto tool = ToolFromSerialized(entryTool); const auto index = ToolIndex(tool); if (version == kBrushesVersion && size > 0) { - result.brushes[index].sizeRatio = size / float(kPrecision); + result.brushes[index].sizeRatio = size / float64(kPrecision); } if (color.isValid()) { result.brushes[index].color = color; @@ -344,6 +344,7 @@ PhotoEditor::PhotoEditor( _colorPicker->saveBrushRequests( ) | rpl::on_next([=](const Brush &brush) { _content->applyBrush(brush); + _content->setTextColor(brush.color); _brushTool = brush.tool; _brushes[ToolIndex(brush.tool)] = brush; @@ -353,6 +354,11 @@ PhotoEditor::PhotoEditor( Core::App().saveSettingsDelayed(); } }, lifetime()); + + _content->textColorRequests( + ) | rpl::on_next([=](const QColor &color) { + _colorPicker->setColor(color); + }, lifetime()); } void PhotoEditor::keyPressEvent(QKeyEvent *e) { diff --git a/Telegram/SourceFiles/editor/photo_editor_content.cpp b/Telegram/SourceFiles/editor/photo_editor_content.cpp index f9197cde6e..38b0b16c86 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_content.cpp @@ -166,6 +166,14 @@ void PhotoEditorContent::createTextItem() { _paint->createTextItem(); } +void PhotoEditorContent::setTextColor(const QColor &color) { + _paint->setTextColor(color); +} + +rpl::producer PhotoEditorContent::textColorRequests() const { + return _paint->textColorRequests(); +} + bool PhotoEditorContent::handleKeyPress(not_null e) const { return false; } diff --git a/Telegram/SourceFiles/editor/photo_editor_content.h b/Telegram/SourceFiles/editor/photo_editor_content.h index 39c411358c..95bb3ee439 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.h +++ b/Telegram/SourceFiles/editor/photo_editor_content.h @@ -32,6 +32,9 @@ public: void applyMode(const PhotoEditorMode &mode); void applyBrush(const Brush &brush); void createTextItem(); + void setTextColor(const QColor &color); + + [[nodiscard]] rpl::producer textColorRequests() const; void applyAspectRatio(float64 ratio); void save(PhotoModifications &modifications); diff --git a/Telegram/SourceFiles/editor/photo_editor_controls.cpp b/Telegram/SourceFiles/editor/photo_editor_controls.cpp index 21b6b01b38..e0985021a4 100644 --- a/Telegram/SourceFiles/editor/photo_editor_controls.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_controls.cpp @@ -206,9 +206,10 @@ class TextToolButton final : public Ui::AbstractButton { public: TextToolButton(not_null parent) : AbstractButton(parent) { + constexpr auto kSizeShrink = 6; resize( - st::photoEditorStickersButton.width, - st::photoEditorStickersButton.height); + st::photoEditorStickersButton.width - kSizeShrink, + st::photoEditorStickersButton.height - kSizeShrink); events( ) | rpl::on_next([=](not_null event) { if (event->type() == QEvent::Enter diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index 306c63e8b7..f0ac32d829 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -11,7 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene_item_line.h" #include "editor/scene/scene_item_sticker.h" #include "editor/scene/scene_item_text.h" -#include "ui/emoji_config.h" +#include "editor/scene/scene_emoji_document.h" #include "ui/image/image_prepare.h" #include "ui/rp_widget.h" #include "styles/style_editor.h" @@ -22,7 +22,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include #include -#include namespace Editor { namespace { @@ -108,6 +107,13 @@ bool SkipMouseEvent(not_null event) { return event->isAccepted() || (event->button() == Qt::RightButton); } +constexpr auto kPaddingFactor = 0.4; +constexpr auto kMaxWidthFactor = 0.8; +constexpr auto kMinWidthFactor = 0.16; +constexpr auto kIdealWidthExtra = 2; +constexpr auto kDefaultFontSizeDivisor = 15.; +constexpr auto kScaleThreshold = 0.01; + class TextEditProxy final : public QGraphicsTextItem { public: using QGraphicsTextItem::QGraphicsTextItem; @@ -141,7 +147,7 @@ private: const auto cb = std::exchange(callback, nullptr); onFinish = nullptr; onCancel = nullptr; - QTimer::singleShot(0, cb); + crl::on_main(cb); } }; @@ -352,21 +358,30 @@ void Scene::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { _canvas->handleMouseMoveEvent(event); } -void Scene::applyBrush(const QColor &color, float size, Brush::Tool tool) { +void Scene::applyBrush(const QColor &color, float64 size, Brush::Tool tool) { _canvas->applyBrush(color, size, tool); - for (auto *item : selectedItems()) { - if (item->type() == ItemText::Type) { - static_cast(item)->setColor(color); - } - } } -void Scene::setTextDefaults(const QColor &color, float fontSize, int style) { +void Scene::setTextDefaults( + const QColor &color, + float64 fontSize, + int style) { _textColor = color; _textFontSize = fontSize; _textStyle = style; } +void Scene::setTextColor(const QColor &color) { + _textColor = color; + if (_textEdit.proxy) { + _textEdit.proxy->setDefaultTextColor(color); + } +} + +rpl::producer Scene::textColorRequests() const { + return _textColorRequests.events(); +} + void Scene::setBlurSource(Fn source) { _blurSource = std::move(source); } @@ -495,24 +510,19 @@ void Scene::restore(SaveState state) { cancelDrawing(); } -void Scene::createTextAtCenter() { - if (_textEdit.proxy) { - return; - } - - clearSelection(); - cancelDrawing(); - - auto *proxy = new TextEditProxy(); +void Scene::setupTextProxy( + QGraphicsTextItem *proxy, + const QColor &color, + float64 fontSize) { proxy->setTextInteractionFlags(Qt::TextEditorInteraction); - proxy->setDefaultTextColor(_textColor); + proxy->setDefaultTextColor(color); auto *emojiDoc = new EmojiDocument(proxy); emojiDoc->setDocumentMargin(0); proxy->setDocument(emojiDoc); auto font = QFont(); - font.setPixelSize(int(_textFontSize)); + font.setPixelSize(int(fontSize)); font.setWeight(QFont::DemiBold); proxy->setFont(font); @@ -521,19 +531,33 @@ void Scene::createTextAtCenter() { option.setAlignment(Qt::AlignCenter); emojiDoc->setDefaultTextOption(option); } +} +void Scene::createTextAtCenter() { + if (_textEdit.proxy) { + return; + } + + clearSelection(); + cancelDrawing(); + + _textEdit.proxy.reset(new TextEditProxy()); + const auto proxy = _textEdit.proxy.get(); + setupTextProxy(proxy, _textColor, _textFontSize); + + const auto emojiDoc = proxy->document(); const auto shortSide = std::min( sceneRect().width(), sceneRect().height()); - const auto padding = int(_textFontSize * 0.4); - const auto maxTextWidth = int(shortSide * 0.8) - 2 * padding; - const auto minTextWidth = int(shortSide * 0.16) - 2 * padding; + const auto padding = int(_textFontSize * kPaddingFactor); + const auto maxTextWidth = int(shortSide * kMaxWidthFactor) - 2 * padding; + const auto minTextWidth = int(shortSide * kMinWidthFactor) - 2 * padding; const auto sceneCenter = sceneRect().center(); const auto adjustWidth = [=] { emojiDoc->setTextWidth(maxTextWidth); const auto ideal = int(std::ceil(emojiDoc->idealWidth())); const auto width = std::clamp( - ideal + 2, + ideal + kIdealWidthExtra, minTextWidth, maxTextWidth); proxy->setTextWidth(width); @@ -553,14 +577,16 @@ void Scene::createTextAtCenter() { views().first()->setFocus(); } - proxy->onFinish = crl::guard(this, [=] { + const auto raw = static_cast(proxy); + raw->onFinish = crl::guard(this, [=] { finishTextEditing(true); }); - proxy->onCancel = crl::guard(this, [=] { + raw->onCancel = crl::guard(this, [=] { finishTextEditing(false); }); - _textEdit = { .item = nullptr, .proxy = proxy }; + _textEdit.item.reset(); + _textColorRequests.fire_copy(_textColor); } void Scene::startTextEditing(ItemText *item) { @@ -573,40 +599,26 @@ void Scene::startTextEditing(ItemText *item) { cancelDrawing(); - auto *proxy = new TextEditProxy(); - proxy->setTextInteractionFlags(Qt::TextEditorInteraction); - proxy->setDefaultTextColor(item->color()); - - auto *emojiDoc = new EmojiDocument(proxy); - emojiDoc->setDocumentMargin(0); - proxy->setDocument(emojiDoc); - - auto font = QFont(); - font.setPixelSize(int(item->fontSize())); - font.setWeight(QFont::DemiBold); - proxy->setFont(font); - - { - auto option = emojiDoc->defaultTextOption(); - option.setAlignment(Qt::AlignCenter); - emojiDoc->setDefaultTextOption(option); - } + _textEdit.proxy.reset(new TextEditProxy()); + const auto proxy = _textEdit.proxy.get(); + setupTextProxy(proxy, item->color(), item->fontSize()); proxy->setPlainText(item->text()); - ReplaceEmoji(emojiDoc); + ReplaceEmoji(proxy->document()); + const auto emojiDoc = proxy->document(); const auto shortSide = std::min( sceneRect().width(), sceneRect().height()); - const auto padding = int(item->fontSize() * 0.4); - const auto maxTextWidth = int(shortSide * 0.8) - 2 * padding; - const auto minTextWidth = int(shortSide * 0.16) - 2 * padding; + const auto padding = int(item->fontSize() * kPaddingFactor); + const auto maxTextWidth = int(shortSide * kMaxWidthFactor) - 2 * padding; + const auto minTextWidth = int(shortSide * kMinWidthFactor) - 2 * padding; const auto anchor = item->scenePos(); const auto adjustWidth = [=] { emojiDoc->setTextWidth(maxTextWidth); const auto ideal = int(std::ceil(emojiDoc->idealWidth())); const auto width = std::clamp( - ideal + 2, + ideal + kIdealWidthExtra, minTextWidth, maxTextWidth); proxy->setTextWidth(width); @@ -623,7 +635,7 @@ void Scene::startTextEditing(ItemText *item) { const auto scale = item->editScale(); proxy->setRotation(item->rotation()); - if (std::abs(scale - 1.) > 0.01) { + if (std::abs(scale - 1.) > kScaleThreshold) { proxy->setScale(scale); } @@ -637,14 +649,19 @@ void Scene::startTextEditing(ItemText *item) { item->setVisible(false); - proxy->onFinish = crl::guard(this, [=] { + const auto raw = static_cast(proxy); + raw->onFinish = crl::guard(this, [=] { finishTextEditing(true); }); - proxy->onCancel = crl::guard(this, [=] { + raw->onCancel = crl::guard(this, [=] { finishTextEditing(false); }); - _textEdit = { .item = item, .proxy = proxy }; + const auto it = _itemsByPointer.find(item); + _textEdit.item = (it != end(_itemsByPointer)) + ? it->second + : std::weak_ptr(); + _textColorRequests.fire_copy(item->color()); } void Scene::finishTextEditing(bool save) { @@ -658,15 +675,19 @@ void Scene::finishTextEditing(bool save) { const auto proxyRect = _textEdit.proxy->boundingRect(); const auto proxyCenter = _textEdit.proxy->pos() + QPointF(proxyRect.width() / 2., proxyRect.height() / 2.); - auto *existingItem = _textEdit.item; + const auto lockedItem = _textEdit.item.lock(); + auto *existingItem = lockedItem + ? static_cast(lockedItem.get()) + : (ItemText*)(nullptr); - const auto proxy = static_cast(_textEdit.proxy); - proxy->onFinish = nullptr; - proxy->onCancel = nullptr; - QGraphicsScene::removeItem(proxy); - delete proxy; + const auto raw = static_cast(_textEdit.proxy.get()); + raw->onFinish = nullptr; + raw->onCancel = nullptr; + QGraphicsScene::removeItem(_textEdit.proxy.get()); _textEdit.proxy = nullptr; - _textEdit.item = nullptr; + _textEdit.item.reset(); + + const auto defaultStyle = static_cast(_textStyle); if (!text.isEmpty()) { if (existingItem) { @@ -678,7 +699,7 @@ void Scene::finishTextEditing(bool save) { text, _textFontSize, imageSize, - TextStyle::Plain); + defaultStyle); const auto zoom = (_currentZoom > 0.) ? _currentZoom : 1.; const auto handleInflate = int( std::ceil(st::photoEditorItemHandleSize / zoom)); @@ -697,7 +718,7 @@ void Scene::finishTextEditing(bool save) { text, _textColor, _textFontSize, - TextStyle::Plain, + defaultStyle, imageSize, std::move(data)); addItem(item); @@ -713,17 +734,15 @@ void Scene::finishTextEditing(bool save) { Scene::~Scene() { if (_textEdit.proxy) { - const auto proxy = static_cast(_textEdit.proxy); - proxy->onFinish = nullptr; - proxy->onCancel = nullptr; - QGraphicsScene::removeItem(proxy); - delete proxy; + const auto raw = static_cast( + _textEdit.proxy.get()); + raw->onFinish = nullptr; + raw->onCancel = nullptr; + QGraphicsScene::removeItem(_textEdit.proxy.get()); _textEdit.proxy = nullptr; } - // Prevent destroying by scene of all items. QGraphicsScene::removeItem(_canvas.get()); for (const auto &item : items()) { - // Scene loses ownership of an item. QGraphicsScene::removeItem(item.get()); } } diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index 6024c0d9aa..c069e8de7e 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -31,9 +31,9 @@ public: Scene(const QRectF &rect); ~Scene(); - void applyBrush(const QColor &color, float size, Brush::Tool tool); + void applyBrush(const QColor &color, float64 size, Brush::Tool tool); void setBlurSource(Fn source); - void setTextDefaults(const QColor &color, float fontSize, int style); + void setTextDefaults(const QColor &color, float64 fontSize, int style); [[nodiscard]] std::vector items( Qt::SortOrder order = Qt::DescendingOrder) const; @@ -51,6 +51,9 @@ public: void startTextEditing(ItemText *item); void createTextAtCenter(); + void setTextColor(const QColor &color); + + [[nodiscard]] rpl::producer textColorRequests() const; [[nodiscard]] bool hasUndo() const; [[nodiscard]] bool hasRedo() const; @@ -69,6 +72,10 @@ protected: private: void removeIf(Fn proj); void finishTextEditing(bool save); + void setupTextProxy( + QGraphicsTextItem *proxy, + const QColor &color, + float64 fontSize); const std::shared_ptr _canvas; const std::shared_ptr _lastZ; @@ -82,15 +89,16 @@ private: int _itemNumber = 0; QColor _textColor; - float _textFontSize = 0.f; - int _textStyle = 3; + float64 _textFontSize = 0.; + int _textStyle = 0; struct { - ItemText *item = nullptr; - QGraphicsTextItem *proxy = nullptr; + std::weak_ptr item; + base::unique_qptr proxy; } _textEdit; rpl::event_stream<> _addsItem, _removesItem; + rpl::event_stream _textColorRequests; rpl::lifetime _lifetime; }; diff --git a/Telegram/SourceFiles/editor/scene/scene_emoji_document.cpp b/Telegram/SourceFiles/editor/scene/scene_emoji_document.cpp new file mode 100644 index 0000000000..df04598343 --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_emoji_document.cpp @@ -0,0 +1,132 @@ +/* +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 "editor/scene/scene_emoji_document.h" + +#include "ui/emoji_config.h" +#include "ui/painter.h" + +#include +#include + +namespace Editor { + +EmojiDocument::EmojiDocument(QObject *parent) +: QTextDocument(parent) { +} + +QVariant EmojiDocument::loadResource(int type, const QUrl &name) { + if (type != QTextDocument::ImageResource + || name.scheme() != u"emoji"_q) { + return QTextDocument::loadResource(type, name); + } + const auto i = _cache.find(name); + if (i != _cache.end()) { + return i->second; + } + auto result = QVariant(); + if (const auto emoji = Ui::Emoji::FromUrl(name.toDisplayString())) { + const auto factor = style::DevicePixelRatio(); + const auto logical = QFontMetrics(defaultFont()).height(); + const auto source = Ui::Emoji::GetSizeLarge(); + auto image = QImage( + QSize(logical, logical) * factor, + QImage::Format_ARGB32_Premultiplied); + image.setDevicePixelRatio(factor); + image.fill(Qt::transparent); + { + auto p = QPainter(&image); + auto hq = PainterHighQualityEnabler(p); + const auto sourceLogical = source / float64(factor); + const auto scale = logical / sourceLogical; + p.scale(scale, scale); + Ui::Emoji::Draw(p, emoji, source, 0, 0); + } + result = QVariant(QPixmap::fromImage(std::move(image))); + } + _cache.emplace(name, result); + return result; +} + +void ReplaceEmoji(QTextDocument *doc) { + QSignalBlocker blocker(doc); + const auto fontHeight = QFontMetrics(doc->defaultFont()).height(); + auto cursor = QTextCursor(doc); + auto block = doc->begin(); + while (block.isValid()) { + auto text = block.text(); + auto start = text.constData(); + auto end = start + text.size(); + auto ch = start; + while (ch < end) { + auto emojiLength = 0; + const auto emoji = Ui::Emoji::Find(ch, end, &emojiLength); + if (!emoji || emojiLength <= 0) { + ++ch; + continue; + } + const auto pos = block.position() + int(ch - start); + cursor.setPosition(pos); + cursor.setPosition( + pos + emojiLength, + QTextCursor::KeepAnchor); + + auto format = QTextImageFormat(); + format.setName(emoji->toUrl()); + format.setWidth(fontHeight); + format.setHeight(fontHeight); + format.setVerticalAlignment( + QTextCharFormat::AlignBaseline); + cursor.insertImage(format); + + block = doc->findBlock(pos); + text = block.text(); + start = text.constData(); + end = start + text.size(); + ch = start + (pos - block.position()) + 1; + continue; + } + block = block.next(); + } +} + +QString RecoverTextFromDocument(QTextDocument *doc) { + auto result = QString(); + auto block = doc->begin(); + while (block.isValid()) { + if (block != doc->begin()) { + result += '\n'; + } + auto it = block.begin(); + while (!it.atEnd()) { + const auto fragment = it.fragment(); + if (!fragment.isValid()) { + ++it; + continue; + } + const auto text = fragment.text(); + const auto format = fragment.charFormat(); + for (const auto &ch : text) { + if (ch == QChar::ObjectReplacementCharacter) { + if (format.isImageFormat()) { + const auto name = format.toImageFormat().name(); + if (const auto emoji = Ui::Emoji::FromUrl(name)) { + result += emoji->text(); + continue; + } + } + } + result += ch; + } + ++it; + } + block = block.next(); + } + return result; +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_emoji_document.h b/Telegram/SourceFiles/editor/scene/scene_emoji_document.h new file mode 100644 index 0000000000..eff28e4907 --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_emoji_document.h @@ -0,0 +1,26 @@ +/* +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 + +namespace Editor { + +class EmojiDocument final : public QTextDocument { +public: + explicit EmojiDocument(QObject *parent = nullptr); + QVariant loadResource(int type, const QUrl &name) override; + +private: + std::map _cache; +}; + +void ReplaceEmoji(QTextDocument *doc); +[[nodiscard]] QString RecoverTextFromDocument(QTextDocument *doc); + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index b9a1b7ae0c..a0dc900105 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene_item_text.h" #include "editor/scene/scene.h" +#include "editor/scene/scene_emoji_document.h" #include "lang/lang_keys.h" #include "ui/emoji_config.h" #include "ui/painter.h" @@ -26,9 +27,17 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Editor { namespace { -constexpr auto kPaddingFactor = 0.4f; -constexpr auto kMaxWidthFactor = 0.8f; +constexpr auto kPaddingFactor = 0.4; +constexpr auto kMaxWidthFactor = 0.8; constexpr auto kMinContentWidth = 20; +constexpr auto kBrightnessFramedThreshold = 0.721; +constexpr auto kBrightnessSemiTransparentThreshold = 0.25; +constexpr auto kSemiTransparentAlpha = 0x99; +constexpr auto kCornerRadiusFactor = 1. / 3.; +constexpr auto kLinePadHFactor = 1. / 3.; +constexpr auto kLinePadVFactor = 1. / 8.; +constexpr auto kMergeRadiusFactor = 1.5; +constexpr auto kLineShiftFactor = 1. / 7.; struct LayoutMetrics { int contentWidth = 0; @@ -37,22 +46,22 @@ struct LayoutMetrics { int textMaxWidth = 0; }; -QFont TextFont(float fontSize) { +QFont TextFont(float64 fontSize) { auto font = QFont(); font.setPixelSize(std::max(int(fontSize), 1)); font.setWeight(QFont::DemiBold); return font; } -float ComputeBrightness(const QColor &color) { - return (color.red() * 0.2126f - + color.green() * 0.7152f - + color.blue() * 0.0722f) / 255.f; +float64 ComputeBrightness(const QColor &color) { + return (color.red() * 0.2126 + + color.green() * 0.7152 + + color.blue() * 0.0722) / 255.; } LayoutMetrics ComputeMetrics( const QString &text, - float fontSize, + float64 fontSize, const QSize &imageSize, TextStyle style) { const auto hasBackground = (style == TextStyle::Framed) @@ -96,33 +105,33 @@ LayoutMetrics ComputeMetrics( } struct LineRect { - float left = 0; - float top = 0; - float right = 0; - float bottom = 0; - [[nodiscard]] float width() const { return right - left; } + float64 left = 0; + float64 top = 0; + float64 right = 0; + float64 bottom = 0; + [[nodiscard]] float64 width() const { return right - left; } }; QPainterPath BuildConnectedBackground( const QTextLayout &layout, int contentWidth, int padding, - float fontSize) { - const auto linePadH = fontSize / 3.f; - const auto linePadV = fontSize / 8.f; - const auto cornerRadius = fontSize / 3.f; - const auto mergeRadius = cornerRadius * 1.5f; - const auto centerX = padding + contentWidth / 2.f; + float64 fontSize) { + const auto linePadH = fontSize * kLinePadHFactor; + const auto linePadV = fontSize * kLinePadVFactor; + const auto cornerRadius = fontSize * kCornerRadiusFactor; + const auto mergeRadius = cornerRadius * kMergeRadiusFactor; + const auto centerX = padding + contentWidth / 2.; auto rects = std::vector(); for (auto i = 0; i < layout.lineCount(); ++i) { const auto line = layout.lineAt(i); - const auto hw = float(line.naturalTextWidth()) / 2.f + linePadH; + const auto hw = float64(line.naturalTextWidth()) / 2. + linePadH; rects.push_back({ .left = centerX - hw, - .top = padding + float(line.y()) - linePadV, + .top = padding + float64(line.y()) - linePadV, .right = centerX + hw, - .bottom = padding + float(line.y() + line.height()) + linePadV, + .bottom = padding + float64(line.y() + line.height()) + linePadV, }); } @@ -157,7 +166,8 @@ QPainterPath BuildConnectedBackground( } if (traceback) { for (auto j = i; j >= 1; --j) { - if (std::abs(rects[j - 1].left - rects[j].left) < mergeRadius) { + if (std::abs(rects[j - 1].left - rects[j].left) + < mergeRadius) { const auto v = std::min( rects[j - 1].left, rects[j].left); @@ -174,14 +184,14 @@ QPainterPath BuildConnectedBackground( } } - struct V { float x, y; }; + struct V { float64 x, y; }; auto verts = std::vector(); verts.push_back({ rects[0].left, rects[0].top }); verts.push_back({ rects[0].right, rects[0].top }); for (auto i = 1; i < int(rects.size()); ++i) { - if (std::abs(rects[i].right - rects[i - 1].right) > 0.5f) { + if (std::abs(rects[i].right - rects[i - 1].right) > 0.5) { verts.push_back({ rects[i - 1].right, rects[i].top }); verts.push_back({ rects[i].right, rects[i].top }); } @@ -192,7 +202,7 @@ QPainterPath BuildConnectedBackground( verts.push_back({ rects[last].left, rects[last].bottom }); for (auto i = last - 1; i >= 0; --i) { - if (std::abs(rects[i].left - rects[i + 1].left) > 0.5f) { + if (std::abs(rects[i].left - rects[i + 1].left) > 0.5) { verts.push_back({ rects[i + 1].left, rects[i + 1].top }); verts.push_back({ rects[i].left, rects[i + 1].top }); } @@ -213,7 +223,7 @@ QPainterPath BuildConnectedBackground( const auto dy2 = next.y - curr.y; const auto len2 = std::sqrt(dx2 * dx2 + dy2 * dy2); - if (len1 < 0.1f || len2 < 0.1f) { + if (len1 < 0.1 || len2 < 0.1) { if (i == 0) { path.moveTo(curr.x, curr.y); } else { @@ -224,8 +234,8 @@ QPainterPath BuildConnectedBackground( const auto r = std::min({ cornerRadius, - len1 / 2.f, - len2 / 2.f, + len1 / 2., + len2 / 2., }); const auto bx = curr.x - dx1 / len1 * r; const auto by = curr.y - dy1 / len1 * r; @@ -245,126 +255,10 @@ QPainterPath BuildConnectedBackground( } // namespace -EmojiDocument::EmojiDocument(QObject *parent) -: QTextDocument(parent) { -} - -QVariant EmojiDocument::loadResource(int type, const QUrl &name) { - if (type != QTextDocument::ImageResource - || name.scheme() != u"emoji"_q) { - return QTextDocument::loadResource(type, name); - } - const auto i = _cache.find(name); - if (i != _cache.end()) { - return i->second; - } - auto result = QVariant(); - if (const auto emoji = Ui::Emoji::FromUrl(name.toDisplayString())) { - const auto factor = style::DevicePixelRatio(); - const auto logical = QFontMetrics(defaultFont()).height(); - const auto source = Ui::Emoji::GetSizeLarge(); - auto image = QImage( - QSize(logical, logical) * factor, - QImage::Format_ARGB32_Premultiplied); - image.setDevicePixelRatio(factor); - image.fill(Qt::transparent); - { - auto p = QPainter(&image); - auto hq = PainterHighQualityEnabler(p); - const auto enlarged = logical * 1.0; - const auto sourceLogical = source / float64(factor); - const auto scale = enlarged / sourceLogical; - const auto offset = (logical - enlarged) / 2.; - p.translate(offset, offset); - p.scale(scale, scale); - Ui::Emoji::Draw(p, emoji, source, 0, 0); - } - result = QVariant(QPixmap::fromImage(std::move(image))); - } - _cache.emplace(name, result); - return result; -} - -void ReplaceEmoji(QTextDocument *doc) { - const auto fontHeight = QFontMetrics(doc->defaultFont()).height(); - auto cursor = QTextCursor(doc); - auto block = doc->begin(); - while (block.isValid()) { - auto text = block.text(); - auto start = text.constData(); - auto end = start + text.size(); - auto ch = start; - while (ch < end) { - auto emojiLength = 0; - const auto emoji = Ui::Emoji::Find(ch, end, &emojiLength); - if (!emoji) { - ++ch; - continue; - } - const auto pos = block.position() + int(ch - start); - cursor.setPosition(pos); - cursor.setPosition( - pos + emojiLength, - QTextCursor::KeepAnchor); - - auto format = QTextImageFormat(); - format.setName(emoji->toUrl()); - format.setWidth(fontHeight); - format.setHeight(fontHeight); - format.setVerticalAlignment( - QTextCharFormat::AlignBaseline); - cursor.insertImage(format); - - block = doc->findBlock(pos); - text = block.text(); - start = text.constData(); - end = start + text.size(); - ch = start + (pos - block.position()) + 1; - continue; - } - block = block.next(); - } -} - -QString RecoverTextFromDocument(QTextDocument *doc) { - auto result = QString(); - auto block = doc->begin(); - while (block.isValid()) { - if (block != doc->begin()) { - result += '\n'; - } - auto it = block.begin(); - while (!it.atEnd()) { - const auto fragment = it.fragment(); - if (!fragment.isValid()) { - ++it; - continue; - } - const auto text = fragment.text(); - const auto format = fragment.charFormat(); - for (const auto &ch : text) { - if (ch == QChar::ObjectReplacementCharacter) { - if (format.isImageFormat()) { - const auto name = format.toImageFormat().name(); - if (const auto emoji = Ui::Emoji::FromUrl(name)) { - result += emoji->text(); - continue; - } - } - } - result += ch; - } - ++it; - } - block = block.next(); - } - return result; -} - ItemText::ItemText( const QString &text, const QColor &color, - float fontSize, + float64 fontSize, TextStyle style, const QSize &imageSize, ItemBase::Data data) @@ -445,14 +339,14 @@ void ItemText::renderContent() { switch (_textStyle) { case TextStyle::Framed: bgColor = _color; - textColor = (brightness >= 0.721f) + textColor = (brightness >= kBrightnessFramedThreshold) ? QColor(0, 0, 0) : QColor(255, 255, 255); break; case TextStyle::SemiTransparent: - bgColor = (brightness >= 0.25f) - ? QColor(0, 0, 0, 0x99) - : QColor(255, 255, 255, 0x99); + bgColor = (brightness >= kBrightnessSemiTransparentThreshold) + ? QColor(0, 0, 0, kSemiTransparentAlpha) + : QColor(255, 255, 255, kSemiTransparentAlpha); break; case TextStyle::Plain: break; @@ -496,14 +390,14 @@ void ItemText::renderContent() { } } - const auto lineShift = _fontSize / 7.f; + const auto lineShift = _fontSize * kLineShiftFactor; const auto lineCount = layout.lineCount(); p.setPen(textColor); for (auto i = 0; i < lineCount; ++i) { const auto line = layout.lineAt(i); const auto xOffset = (m.contentWidth - line.naturalTextWidth()) / 2.; - const auto yShift = (i < lineCount - 1) ? -lineShift : 0.f; + const auto yShift = (i < lineCount - 1) ? -lineShift : 0.; line.draw( &p, QPointF(m.padding + xOffset, m.padding + yShift)); @@ -519,7 +413,7 @@ void ItemText::renderContent() { const auto line = layout.lineAt(i); const auto xOffset = (m.contentWidth - line.naturalTextWidth()) / 2.; - const auto yShift = (i < lineCount - 1) ? -lineShift : 0.f; + const auto yShift = (i < lineCount - 1) ? -lineShift : 0.; const auto lineStart = line.textStart(); const auto lineText = processedText.mid( lineStart, @@ -567,7 +461,7 @@ void ItemText::renderContent() { QSize ItemText::computeContentSize( const QString &text, - float fontSize, + float64 fontSize, const QSize &imageSize, TextStyle style) { if (text.isEmpty()) { @@ -635,7 +529,7 @@ void ItemText::setColor(const QColor &color) { update(); } -float ItemText::fontSize() const { +float64 ItemText::fontSize() const { return _fontSize; } @@ -685,9 +579,13 @@ void ItemText::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { action->setChecked(true); } }; - add(u"Plain"_q, TextStyle::Plain); - add(u"Framed"_q, TextStyle::Framed); - add(u"Semi-Transparent"_q, TextStyle::SemiTransparent); + add(tr::lng_photo_editor_text_style_plain(tr::now), TextStyle::Plain); + add( + tr::lng_photo_editor_text_style_framed(tr::now), + TextStyle::Framed); + add( + tr::lng_photo_editor_text_style_semi_transparent(tr::now), + TextStyle::SemiTransparent); _contextMenu->addSeparator(); diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.h b/Telegram/SourceFiles/editor/scene/scene_item_text.h index a35bf4cecc..09d9128bd5 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.h @@ -10,8 +10,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/unique_qptr.h" #include "editor/scene/scene_item_base.h" -#include - namespace Ui { class PopupMenu; } // namespace Ui @@ -24,18 +22,6 @@ enum class TextStyle : uchar { Plain, }; -class EmojiDocument final : public QTextDocument { -public: - explicit EmojiDocument(QObject *parent = nullptr); - QVariant loadResource(int type, const QUrl &name) override; - -private: - std::map _cache; -}; - -void ReplaceEmoji(QTextDocument *doc); -[[nodiscard]] QString RecoverTextFromDocument(QTextDocument *doc); - class ItemText : public ItemBase { public: enum { Type = ItemBase::Type + 2 }; @@ -43,7 +29,7 @@ public: ItemText( const QString &text, const QColor &color, - float fontSize, + float64 fontSize, TextStyle style, const QSize &imageSize, ItemBase::Data data); @@ -60,7 +46,7 @@ public: [[nodiscard]] const QColor &color() const; void setColor(const QColor &color); - [[nodiscard]] float fontSize() const; + [[nodiscard]] float64 fontSize() const; [[nodiscard]] TextStyle textStyle() const; void setTextStyle(TextStyle style); @@ -69,7 +55,7 @@ public: [[nodiscard]] static QSize computeContentSize( const QString &text, - float fontSize, + float64 fontSize, const QSize &imageSize, TextStyle style); @@ -88,7 +74,7 @@ private: QString _text; QColor _color; - float _fontSize; + float64 _fontSize; TextStyle _textStyle = TextStyle::Plain; QSize _imageSize; QPixmap _pixmap; @@ -99,7 +85,7 @@ private: NumberedItem::Status status = NumberedItem::Status::Normal; QString text; QColor color; - float fontSize = 0; + float64 fontSize = 0.; TextStyle textStyle = TextStyle::Plain; }; SavedText _savedState, _keepedState; diff --git a/Telegram/cmake/td_ui.cmake b/Telegram/cmake/td_ui.cmake index 9baeaea9c9..840ab71395 100644 --- a/Telegram/cmake/td_ui.cmake +++ b/Telegram/cmake/td_ui.cmake @@ -149,6 +149,8 @@ PRIVATE editor/scene/scene_item_line.h editor/scene/scene_item_text.cpp editor/scene/scene_item_text.h + editor/scene/scene_emoji_document.cpp + editor/scene/scene_emoji_document.h ui/boxes/about_cocoon_box.h ui/boxes/about_cocoon_box.cpp From 3ec1ff8c7e479c2169f9e38f2fd59e7cf73b8e5c Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sun, 12 Apr 2026 17:37:11 +0300 Subject: [PATCH 45/78] [img-editor] Synced palette color and tool selection with text focus. --- Telegram/SourceFiles/editor/color_picker.cpp | 22 ++++++++-- Telegram/SourceFiles/editor/color_picker.h | 4 ++ Telegram/SourceFiles/editor/editor_paint.cpp | 16 ++++++++ Telegram/SourceFiles/editor/editor_paint.h | 4 ++ Telegram/SourceFiles/editor/photo_editor.cpp | 41 +++++++++++++++---- Telegram/SourceFiles/editor/photo_editor.h | 1 + .../editor/photo_editor_content.cpp | 16 ++++++++ .../SourceFiles/editor/photo_editor_content.h | 4 ++ Telegram/SourceFiles/editor/scene/scene.cpp | 37 +++++++++++++++++ Telegram/SourceFiles/editor/scene/scene.h | 6 +++ 10 files changed, 139 insertions(+), 12 deletions(-) diff --git a/Telegram/SourceFiles/editor/color_picker.cpp b/Telegram/SourceFiles/editor/color_picker.cpp index 411169079a..b703650fe2 100644 --- a/Telegram/SourceFiles/editor/color_picker.cpp +++ b/Telegram/SourceFiles/editor/color_picker.cpp @@ -351,6 +351,7 @@ ColorPicker::ColorPicker( button->show(); } const auto setToolRequest = [=](Brush::Tool tool) { + _toolClicks.fire({}); setTool(tool); }; if (_toolButtons.size() >= 5) { @@ -581,6 +582,11 @@ void ColorPicker::setColor(const QColor &color) { } } +void ColorPicker::setToolSelectionVisible(bool visible) { + _toolSelectionSuppressed = !visible; + _toolSelection->setVisible(visible); +} + void ColorPicker::updateColorButtonColor(const QColor &color, bool animated) { const auto hasValid = _colorButtonFrom.isValid() && _colorButtonTo.isValid(); const auto from = hasValid ? colorButtonColor() : color; @@ -643,11 +649,14 @@ void ColorPicker::setVisible(bool visible) { _paletteWrap->setVisible(visible && _paletteVisible); _sizeControlHoverArea->setVisible(visible); _sizeControl->setVisible(visible); - _toolSelection->setVisible(visible && !_paletteVisible); + const auto showTools = visible + && !_paletteVisible + && !_toolSelectionSuppressed; + _toolSelection->setVisible(showTools); for (const auto &button : _toolButtons) { button->setVisible(visible && !_paletteVisible); } - if (visible && !_paletteVisible) { + if (showTools) { updateToolSelection(false); } } @@ -656,6 +665,10 @@ rpl::producer ColorPicker::saveBrushRequests() const { return _saveBrushRequests.events_starting_with_copy(_brush); } +rpl::producer<> ColorPicker::toolClicks() const { + return _toolClicks.events(); +} + bool ColorPicker::preventHandleKeyPress() const { return _sizeControl->isVisible() && (_sizeControlAnimation.animating() || _sizeDown.pressed); @@ -796,13 +809,14 @@ void ColorPicker::setPaletteVisible(bool visible) { _paletteVisible = visible; _paletteWrap->setVisible(visible); _colorButton->setVisible(!visible); - _toolSelection->setVisible(!visible); + const auto showTools = !visible && !_toolSelectionSuppressed; + _toolSelection->setVisible(showTools); for (const auto &button : _toolButtons) { button->setVisible(!visible); } if (visible) { rebuildPalette(); - } else { + } else if (showTools) { updateToolSelection(false); } } diff --git a/Telegram/SourceFiles/editor/color_picker.h b/Telegram/SourceFiles/editor/color_picker.h index d7e80654fa..3d64431ce6 100644 --- a/Telegram/SourceFiles/editor/color_picker.h +++ b/Telegram/SourceFiles/editor/color_picker.h @@ -31,9 +31,11 @@ public: void setCanvasRect(const QRect &rect); void setVisible(bool visible); void setColor(const QColor &color); + void setToolSelectionVisible(bool visible); bool preventHandleKeyPress() const; rpl::producer saveBrushRequests() const; + rpl::producer<> toolClicks() const; private: void paintSizeControl(QPainter &p); @@ -76,6 +78,7 @@ private: int y = 0; bool pressed = false; } _sizeDown; + bool _toolSelectionSuppressed = false; bool _sizeHoverAreaHovered = false; bool _sizeControlHovered = false; bool _sizeControlExpanded = false; @@ -95,6 +98,7 @@ private: Ui::Animations::Simple _toolSelectionAnimation; rpl::event_stream _saveBrushRequests; + rpl::event_stream<> _toolClicks; std::vector> _paletteButtons; base::unique_qptr _palettePlus; diff --git a/Telegram/SourceFiles/editor/editor_paint.cpp b/Telegram/SourceFiles/editor/editor_paint.cpp index 836e511b36..3f633707b6 100644 --- a/Telegram/SourceFiles/editor/editor_paint.cpp +++ b/Telegram/SourceFiles/editor/editor_paint.cpp @@ -250,14 +250,30 @@ void Paint::createTextItem() { _scene->createTextAtCenter(); } +void Paint::clearSelection() { + _scene->clearSelection(); +} + void Paint::setTextColor(const QColor &color) { _scene->setTextColor(color); } +void Paint::setSelectedTextColor(const QColor &color) { + _scene->setSelectedTextColor(color); +} + rpl::producer Paint::textColorRequests() const { return _scene->textColorRequests(); } +rpl::producer Paint::textItemSelections() const { + return _scene->textItemSelections(); +} + +rpl::producer<> Paint::textItemDeselections() const { + return _scene->textItemDeselections(); +} + void Paint::handleMimeData(const QMimeData *data) { const auto add = [&](QImage image) { if (image.isNull()) { diff --git a/Telegram/SourceFiles/editor/editor_paint.h b/Telegram/SourceFiles/editor/editor_paint.h index 5540b60abc..b7856a5cfe 100644 --- a/Telegram/SourceFiles/editor/editor_paint.h +++ b/Telegram/SourceFiles/editor/editor_paint.h @@ -42,9 +42,13 @@ public: void updateUndoState(); void createTextItem(); + void clearSelection(); void setTextColor(const QColor &color); + void setSelectedTextColor(const QColor &color); [[nodiscard]] rpl::producer textColorRequests() const; + [[nodiscard]] rpl::producer textItemSelections() const; + [[nodiscard]] rpl::producer<> textItemDeselections() const; void handleMimeData(const QMimeData *data); void paintImage(QPainter &p, const QPixmap &image) const; diff --git a/Telegram/SourceFiles/editor/photo_editor.cpp b/Telegram/SourceFiles/editor/photo_editor.cpp index 786e6794e5..315317fa5a 100644 --- a/Telegram/SourceFiles/editor/photo_editor.cpp +++ b/Telegram/SourceFiles/editor/photo_editor.cpp @@ -341,17 +341,27 @@ PhotoEditor::PhotoEditor( } }, lifetime()); + _colorPicker->toolClicks( + ) | rpl::on_next([=] { + _content->clearSelection(); + }, lifetime()); + _colorPicker->saveBrushRequests( ) | rpl::on_next([=](const Brush &brush) { - _content->applyBrush(brush); - _content->setTextColor(brush.color); + if (_textItemSelected) { + _content->setSelectedTextColor(brush.color); + _content->setTextColor(brush.color); + } else { + _content->applyBrush(brush); + _content->setTextColor(brush.color); - _brushTool = brush.tool; - _brushes[ToolIndex(brush.tool)] = brush; - const auto serialized = Serialize(_brushes, _brushTool); - if (Core::App().settings().photoEditorBrush() != serialized) { - Core::App().settings().setPhotoEditorBrush(serialized); - Core::App().saveSettingsDelayed(); + _brushTool = brush.tool; + _brushes[ToolIndex(brush.tool)] = brush; + const auto serialized = Serialize(_brushes, _brushTool); + if (Core::App().settings().photoEditorBrush() != serialized) { + Core::App().settings().setPhotoEditorBrush(serialized); + Core::App().saveSettingsDelayed(); + } } }, lifetime()); @@ -359,6 +369,21 @@ PhotoEditor::PhotoEditor( ) | rpl::on_next([=](const QColor &color) { _colorPicker->setColor(color); }, lifetime()); + + _content->textItemSelections( + ) | rpl::on_next([=](const QColor &color) { + _textItemSelected = true; + _colorPicker->setColor(color); + _colorPicker->setToolSelectionVisible(false); + }, lifetime()); + + _content->textItemDeselections( + ) | rpl::on_next([=] { + _textItemSelected = false; + const auto &brush = _brushes[ToolIndex(_brushTool)]; + _colorPicker->setColor(brush.color); + _colorPicker->setToolSelectionVisible(true); + }, lifetime()); } void PhotoEditor::keyPressEvent(QKeyEvent *e) { diff --git a/Telegram/SourceFiles/editor/photo_editor.h b/Telegram/SourceFiles/editor/photo_editor.h index 00351e5b04..e29edae765 100644 --- a/Telegram/SourceFiles/editor/photo_editor.h +++ b/Telegram/SourceFiles/editor/photo_editor.h @@ -72,6 +72,7 @@ private: .mode = PhotoEditorMode::Mode::Transform, .action = PhotoEditorMode::Action::None, }; + bool _textItemSelected = false; rpl::event_stream _done; rpl::event_stream<> _cancel; diff --git a/Telegram/SourceFiles/editor/photo_editor_content.cpp b/Telegram/SourceFiles/editor/photo_editor_content.cpp index 38b0b16c86..735775e2da 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_content.cpp @@ -166,14 +166,30 @@ void PhotoEditorContent::createTextItem() { _paint->createTextItem(); } +void PhotoEditorContent::clearSelection() { + _paint->clearSelection(); +} + void PhotoEditorContent::setTextColor(const QColor &color) { _paint->setTextColor(color); } +void PhotoEditorContent::setSelectedTextColor(const QColor &color) { + _paint->setSelectedTextColor(color); +} + rpl::producer PhotoEditorContent::textColorRequests() const { return _paint->textColorRequests(); } +rpl::producer PhotoEditorContent::textItemSelections() const { + return _paint->textItemSelections(); +} + +rpl::producer<> PhotoEditorContent::textItemDeselections() const { + return _paint->textItemDeselections(); +} + bool PhotoEditorContent::handleKeyPress(not_null e) const { return false; } diff --git a/Telegram/SourceFiles/editor/photo_editor_content.h b/Telegram/SourceFiles/editor/photo_editor_content.h index 95bb3ee439..1b83437c5b 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.h +++ b/Telegram/SourceFiles/editor/photo_editor_content.h @@ -32,9 +32,13 @@ public: void applyMode(const PhotoEditorMode &mode); void applyBrush(const Brush &brush); void createTextItem(); + void clearSelection(); void setTextColor(const QColor &color); + void setSelectedTextColor(const QColor &color); [[nodiscard]] rpl::producer textColorRequests() const; + [[nodiscard]] rpl::producer textItemSelections() const; + [[nodiscard]] rpl::producer<> textItemDeselections() const; void applyAspectRatio(float64 ratio); void save(PhotoModifications &modifications); diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index f0ac32d829..8d9dc65b1c 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -288,6 +288,27 @@ Scene::Scene(const QRectF &rect) addItem(item); _canvas->setZValue(++_lastLineZ); }, _lifetime); + + QObject::connect( + this, + &QGraphicsScene::selectionChanged, + [=] { + const auto selected = selectedItems(); + auto *textItem = (ItemText*)(nullptr); + if (selected.size() == 1 + && selected.front()->type() == ItemText::Type) { + textItem = static_cast(selected.front()); + } + if (textItem) { + if (!_textItemWasSelected) { + _textItemWasSelected = true; + _textItemSelections.fire_copy(textItem->color()); + } + } else if (_textItemWasSelected) { + _textItemWasSelected = false; + _textItemDeselections.fire({}); + } + }); } void Scene::cancelDrawing() { @@ -378,10 +399,26 @@ void Scene::setTextColor(const QColor &color) { } } +void Scene::setSelectedTextColor(const QColor &color) { + for (auto *item : selectedItems()) { + if (item->type() == ItemText::Type) { + static_cast(item)->setColor(color); + } + } +} + rpl::producer Scene::textColorRequests() const { return _textColorRequests.events(); } +rpl::producer Scene::textItemSelections() const { + return _textItemSelections.events(); +} + +rpl::producer<> Scene::textItemDeselections() const { + return _textItemDeselections.events(); +} + void Scene::setBlurSource(Fn source) { _blurSource = std::move(source); } diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index c069e8de7e..fccbd67c76 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -52,8 +52,11 @@ public: void startTextEditing(ItemText *item); void createTextAtCenter(); void setTextColor(const QColor &color); + void setSelectedTextColor(const QColor &color); [[nodiscard]] rpl::producer textColorRequests() const; + [[nodiscard]] rpl::producer textItemSelections() const; + [[nodiscard]] rpl::producer<> textItemDeselections() const; [[nodiscard]] bool hasUndo() const; [[nodiscard]] bool hasRedo() const; @@ -99,6 +102,9 @@ private: rpl::event_stream<> _addsItem, _removesItem; rpl::event_stream _textColorRequests; + rpl::event_stream _textItemSelections; + rpl::event_stream<> _textItemDeselections; + bool _textItemWasSelected = false; rpl::lifetime _lifetime; }; From a2f228e74d1d5cf9e38fea6170ba77711a25ca4d Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sun, 12 Apr 2026 19:04:26 +0300 Subject: [PATCH 46/78] [img-editor] Fixed text selection color sync and isolated from brush. --- Telegram/SourceFiles/editor/color_picker.cpp | 4 +++- Telegram/SourceFiles/editor/photo_editor.cpp | 2 +- Telegram/SourceFiles/editor/scene/scene.cpp | 13 +++++++------ Telegram/SourceFiles/editor/scene/scene.h | 2 +- .../SourceFiles/editor/scene/scene_item_text.cpp | 6 ------ Telegram/SourceFiles/editor/scene/scene_item_text.h | 3 --- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/Telegram/SourceFiles/editor/color_picker.cpp b/Telegram/SourceFiles/editor/color_picker.cpp index b703650fe2..c4f74fe1ba 100644 --- a/Telegram/SourceFiles/editor/color_picker.cpp +++ b/Telegram/SourceFiles/editor/color_picker.cpp @@ -567,13 +567,15 @@ void ColorPicker::setTool(Brush::Tool tool) { } void ColorPicker::storeCurrentBrush() { + if (_toolSelectionSuppressed) { + return; + } NormalizeBrushColor(_brush); _toolBrushes[ToolIndex(_brush.tool)] = _brush; } void ColorPicker::setColor(const QColor &color) { _brush.color = color; - storeCurrentBrush(); updateColorButtonColor(color, true); if (_paletteVisible) { rebuildPalette(); diff --git a/Telegram/SourceFiles/editor/photo_editor.cpp b/Telegram/SourceFiles/editor/photo_editor.cpp index 315317fa5a..8a374e51d2 100644 --- a/Telegram/SourceFiles/editor/photo_editor.cpp +++ b/Telegram/SourceFiles/editor/photo_editor.cpp @@ -373,8 +373,8 @@ PhotoEditor::PhotoEditor( _content->textItemSelections( ) | rpl::on_next([=](const QColor &color) { _textItemSelected = true; - _colorPicker->setColor(color); _colorPicker->setToolSelectionVisible(false); + _colorPicker->setColor(color); }, lifetime()); _content->textItemDeselections( diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index 8d9dc65b1c..4e278c9898 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -299,13 +299,14 @@ Scene::Scene(const QRectF &rect) && selected.front()->type() == ItemText::Type) { textItem = static_cast(selected.front()); } + const auto changed = (textItem != _selectedTextItem); + if (!changed) { + return; + } + _selectedTextItem = textItem; if (textItem) { - if (!_textItemWasSelected) { - _textItemWasSelected = true; - _textItemSelections.fire_copy(textItem->color()); - } - } else if (_textItemWasSelected) { - _textItemWasSelected = false; + _textItemSelections.fire_copy(textItem->color()); + } else { _textItemDeselections.fire({}); } }); diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index fccbd67c76..683380d5f0 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -104,7 +104,7 @@ private: rpl::event_stream _textColorRequests; rpl::event_stream _textItemSelections; rpl::event_stream<> _textItemDeselections; - bool _textItemWasSelected = false; + ItemText *_selectedTextItem = nullptr; rpl::lifetime _lifetime; }; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index a0dc900105..4a785a6175 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -619,8 +619,6 @@ void ItemText::save(SaveState state) { ItemBase::save(state); auto &saved = (state == SaveState::Keep) ? _keepedState : _savedState; saved = { - .saved = true, - .status = status(), .text = _text, .color = _color, .fontSize = _fontSize, @@ -641,8 +639,4 @@ void ItemText::restore(SaveState state) { ItemBase::restore(state); } -bool ItemText::hasState(SaveState state) const { - return ItemBase::hasState(state); -} - } // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.h b/Telegram/SourceFiles/editor/scene/scene_item_text.h index 09d9128bd5..fa2c128186 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.h @@ -61,7 +61,6 @@ public: void save(SaveState state) override; void restore(SaveState state) override; - bool hasState(SaveState state) const override; protected: void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; @@ -81,8 +80,6 @@ private: base::unique_qptr _contextMenu; struct SavedText { - bool saved = false; - NumberedItem::Status status = NumberedItem::Status::Normal; QString text; QColor color; float64 fontSize = 0.; From f94ec5a8a96b402b2e7f8b2039f3d993f969a4b6 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sun, 12 Apr 2026 19:28:59 +0300 Subject: [PATCH 47/78] [img-editor] Added text editing state flow to keep palette consistent. --- Telegram/SourceFiles/editor/editor_paint.cpp | 26 ++++++++++++++++--- Telegram/SourceFiles/editor/editor_paint.h | 2 ++ Telegram/SourceFiles/editor/photo_editor.cpp | 17 +++++++++++- Telegram/SourceFiles/editor/photo_editor.h | 1 + .../editor/photo_editor_content.cpp | 4 +++ .../SourceFiles/editor/photo_editor_content.h | 1 + Telegram/SourceFiles/editor/scene/scene.cpp | 16 ++++++++++++ Telegram/SourceFiles/editor/scene/scene.h | 4 +++ 8 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Telegram/SourceFiles/editor/editor_paint.cpp b/Telegram/SourceFiles/editor/editor_paint.cpp index 3f633707b6..d0cd23f2ab 100644 --- a/Telegram/SourceFiles/editor/editor_paint.cpp +++ b/Telegram/SourceFiles/editor/editor_paint.cpp @@ -89,9 +89,17 @@ Paint::Paint( _viewport->setAttribute(Qt::WA_TranslucentBackground, true); _viewport->installEventFilter(this); + _scene->textEditStates( + ) | rpl::on_next([=](bool editing) { + _textEditing = editing; + }, lifetime()); + // Undo / Redo. controllers->undoController->performRequestChanges( ) | rpl::on_next([=](const Undo &command) { + if (_textEditing.current()) { + return; + } if (command == Undo::Undo) { _scene->performUndo(); } else { @@ -103,16 +111,22 @@ Paint::Paint( }, lifetime()); controllers->undoController->setCanPerformChanges(rpl::merge( - _hasUndo.value() | rpl::map([](bool enable) { + rpl::combine( + _hasUndo.value(), + _textEditing.value() + ) | rpl::map([](bool enable, bool editing) { return UndoController::EnableRequest{ .command = Undo::Undo, - .enable = enable, + .enable = enable && !editing, }; }), - _hasRedo.value() | rpl::map([](bool enable) { + rpl::combine( + _hasRedo.value(), + _textEditing.value() + ) | rpl::map([](bool enable, bool editing) { return UndoController::EnableRequest{ .command = Undo::Redo, - .enable = enable, + .enable = enable && !editing, }; }))); @@ -274,6 +288,10 @@ rpl::producer<> Paint::textItemDeselections() const { return _scene->textItemDeselections(); } +rpl::producer Paint::textEditStates() const { + return _scene->textEditStates(); +} + void Paint::handleMimeData(const QMimeData *data) { const auto add = [&](QImage image) { if (image.isNull()) { diff --git a/Telegram/SourceFiles/editor/editor_paint.h b/Telegram/SourceFiles/editor/editor_paint.h index b7856a5cfe..a1dcf00c72 100644 --- a/Telegram/SourceFiles/editor/editor_paint.h +++ b/Telegram/SourceFiles/editor/editor_paint.h @@ -49,6 +49,7 @@ public: [[nodiscard]] rpl::producer textColorRequests() const; [[nodiscard]] rpl::producer textItemSelections() const; [[nodiscard]] rpl::producer<> textItemDeselections() const; + [[nodiscard]] rpl::producer textEditStates() const; void handleMimeData(const QMimeData *data); void paintImage(QPainter &p, const QPixmap &image) const; @@ -93,6 +94,7 @@ private: rpl::variable _hasUndo = true; rpl::variable _hasRedo = true; + rpl::variable _textEditing = false; }; diff --git a/Telegram/SourceFiles/editor/photo_editor.cpp b/Telegram/SourceFiles/editor/photo_editor.cpp index 8a374e51d2..989ff8c40b 100644 --- a/Telegram/SourceFiles/editor/photo_editor.cpp +++ b/Telegram/SourceFiles/editor/photo_editor.cpp @@ -348,7 +348,7 @@ PhotoEditor::PhotoEditor( _colorPicker->saveBrushRequests( ) | rpl::on_next([=](const Brush &brush) { - if (_textItemSelected) { + if (_textItemSelected || _textEditing) { _content->setSelectedTextColor(brush.color); _content->setTextColor(brush.color); } else { @@ -365,6 +365,18 @@ PhotoEditor::PhotoEditor( } }, lifetime()); + _content->textEditStates( + ) | rpl::on_next([=](bool editing) { + _textEditing = editing; + if (_textEditing) { + _colorPicker->setToolSelectionVisible(false); + } else if (!_textItemSelected) { + const auto &brush = _brushes[ToolIndex(_brushTool)]; + _colorPicker->setColor(brush.color); + _colorPicker->setToolSelectionVisible(true); + } + }, lifetime()); + _content->textColorRequests( ) | rpl::on_next([=](const QColor &color) { _colorPicker->setColor(color); @@ -380,6 +392,9 @@ PhotoEditor::PhotoEditor( _content->textItemDeselections( ) | rpl::on_next([=] { _textItemSelected = false; + if (_textEditing) { + return; + } const auto &brush = _brushes[ToolIndex(_brushTool)]; _colorPicker->setColor(brush.color); _colorPicker->setToolSelectionVisible(true); diff --git a/Telegram/SourceFiles/editor/photo_editor.h b/Telegram/SourceFiles/editor/photo_editor.h index e29edae765..2c23a82cf3 100644 --- a/Telegram/SourceFiles/editor/photo_editor.h +++ b/Telegram/SourceFiles/editor/photo_editor.h @@ -73,6 +73,7 @@ private: .action = PhotoEditorMode::Action::None, }; bool _textItemSelected = false; + bool _textEditing = false; rpl::event_stream _done; rpl::event_stream<> _cancel; diff --git a/Telegram/SourceFiles/editor/photo_editor_content.cpp b/Telegram/SourceFiles/editor/photo_editor_content.cpp index 735775e2da..6b0be6f9fc 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_content.cpp @@ -190,6 +190,10 @@ rpl::producer<> PhotoEditorContent::textItemDeselections() const { return _paint->textItemDeselections(); } +rpl::producer PhotoEditorContent::textEditStates() const { + return _paint->textEditStates(); +} + bool PhotoEditorContent::handleKeyPress(not_null e) const { return false; } diff --git a/Telegram/SourceFiles/editor/photo_editor_content.h b/Telegram/SourceFiles/editor/photo_editor_content.h index 1b83437c5b..085ace2344 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.h +++ b/Telegram/SourceFiles/editor/photo_editor_content.h @@ -39,6 +39,7 @@ public: [[nodiscard]] rpl::producer textColorRequests() const; [[nodiscard]] rpl::producer textItemSelections() const; [[nodiscard]] rpl::producer<> textItemDeselections() const; + [[nodiscard]] rpl::producer textEditStates() const; void applyAspectRatio(float64 ratio); void save(PhotoModifications &modifications); diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index 4e278c9898..8a986c3cb8 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -420,6 +420,10 @@ rpl::producer<> Scene::textItemDeselections() const { return _textItemDeselections.events(); } +rpl::producer Scene::textEditStates() const { + return _textEditStates.events(); +} + void Scene::setBlurSource(Fn source) { _blurSource = std::move(source); } @@ -548,6 +552,14 @@ void Scene::restore(SaveState state) { cancelDrawing(); } +void Scene::setTextEditing(bool editing) { + if (_textEditing == editing) { + return; + } + _textEditing = editing; + _textEditStates.fire_copy(editing); +} + void Scene::setupTextProxy( QGraphicsTextItem *proxy, const QColor &color, @@ -578,6 +590,7 @@ void Scene::createTextAtCenter() { clearSelection(); cancelDrawing(); + setTextEditing(true); _textEdit.proxy.reset(new TextEditProxy()); const auto proxy = _textEdit.proxy.get(); @@ -636,6 +649,7 @@ void Scene::startTextEditing(ItemText *item) { } cancelDrawing(); + setTextEditing(true); _textEdit.proxy.reset(new TextEditProxy()); const auto proxy = _textEdit.proxy.get(); @@ -724,6 +738,7 @@ void Scene::finishTextEditing(bool save) { QGraphicsScene::removeItem(_textEdit.proxy.get()); _textEdit.proxy = nullptr; _textEdit.item.reset(); + setTextEditing(false); const auto defaultStyle = static_cast(_textStyle); @@ -772,6 +787,7 @@ void Scene::finishTextEditing(bool save) { Scene::~Scene() { if (_textEdit.proxy) { + setTextEditing(false); const auto raw = static_cast( _textEdit.proxy.get()); raw->onFinish = nullptr; diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index 683380d5f0..62456c042f 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -57,6 +57,7 @@ public: [[nodiscard]] rpl::producer textColorRequests() const; [[nodiscard]] rpl::producer textItemSelections() const; [[nodiscard]] rpl::producer<> textItemDeselections() const; + [[nodiscard]] rpl::producer textEditStates() const; [[nodiscard]] bool hasUndo() const; [[nodiscard]] bool hasRedo() const; @@ -75,6 +76,7 @@ protected: private: void removeIf(Fn proj); void finishTextEditing(bool save); + void setTextEditing(bool editing); void setupTextProxy( QGraphicsTextItem *proxy, const QColor &color, @@ -104,7 +106,9 @@ private: rpl::event_stream _textColorRequests; rpl::event_stream _textItemSelections; rpl::event_stream<> _textItemDeselections; + rpl::event_stream _textEditStates; ItemText *_selectedTextItem = nullptr; + bool _textEditing = false; rpl::lifetime _lifetime; }; From 61cf3a960c8731b03efac2af77f956ed018ed20c Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 10:32:03 +0300 Subject: [PATCH 48/78] [img-editor] Ignored stale deferred text edit callbacks from prior edit. --- Telegram/SourceFiles/editor/scene/scene.cpp | 20 ++++++++++++++++---- Telegram/SourceFiles/editor/scene/scene.h | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index 8a986c3cb8..fe611a5818 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -588,6 +588,8 @@ void Scene::createTextAtCenter() { return; } + const auto generation = ++_textEditGeneration; + clearSelection(); cancelDrawing(); setTextEditing(true); @@ -630,10 +632,14 @@ void Scene::createTextAtCenter() { const auto raw = static_cast(proxy); raw->onFinish = crl::guard(this, [=] { - finishTextEditing(true); + if (generation == _textEditGeneration) { + finishTextEditing(true); + } }); raw->onCancel = crl::guard(this, [=] { - finishTextEditing(false); + if (generation == _textEditGeneration) { + finishTextEditing(false); + } }); _textEdit.item.reset(); @@ -648,6 +654,8 @@ void Scene::startTextEditing(ItemText *item) { return; } + const auto generation = ++_textEditGeneration; + cancelDrawing(); setTextEditing(true); @@ -703,10 +711,14 @@ void Scene::startTextEditing(ItemText *item) { const auto raw = static_cast(proxy); raw->onFinish = crl::guard(this, [=] { - finishTextEditing(true); + if (generation == _textEditGeneration) { + finishTextEditing(true); + } }); raw->onCancel = crl::guard(this, [=] { - finishTextEditing(false); + if (generation == _textEditGeneration) { + finishTextEditing(false); + } }); const auto it = _itemsByPointer.find(item); diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index 62456c042f..ff487e7fba 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -109,6 +109,7 @@ private: rpl::event_stream _textEditStates; ItemText *_selectedTextItem = nullptr; bool _textEditing = false; + int _textEditGeneration = 0; rpl::lifetime _lifetime; }; From f616d19a4e6e895f90a1903da68d030b6fce15a8 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 10:32:38 +0300 Subject: [PATCH 49/78] [img-editor] Fixed emoji drawing in text wrapped across line boundaries. --- .../editor/scene/scene_item_text.cpp | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index 4a785a6175..45a5bd395b 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -293,7 +293,13 @@ void ItemText::renderContent() { auto layout = QTextLayout(processedText, font); layout.setTextOption(option); + struct EmojiPos { + int start = 0; + int length = 0; + EmojiPtr emoji = nullptr; + }; auto emojiFormats = QList(); + auto emojiPositions = std::vector(); { auto pos = 0; const auto begin = processedText.constData(); @@ -304,10 +310,11 @@ void ItemText::renderContent() { begin + pos, end, &emojiLen); - if (emoji) { + if (emoji && emojiLen > 0) { auto fmt = QTextCharFormat(); fmt.setForeground(QColor(0, 0, 0, 0)); emojiFormats.append({ pos, emojiLen, fmt }); + emojiPositions.push_back({ pos, emojiLen, emoji }); pos += emojiLen; } else { ++pos; @@ -409,45 +416,45 @@ void ItemText::renderContent() { const auto sourceLogical = source / float64(factor); const auto emojiSize = float64(QFontMetrics(font).height()); const auto emojiScale = emojiSize / sourceLogical; - for (auto i = 0; i < lineCount; ++i) { - const auto line = layout.lineAt(i); + for (const auto &ep : emojiPositions) { + auto lineIndex = -1; + for (auto i = 0; i < lineCount; ++i) { + const auto line = layout.lineAt(i); + const auto lineStart = line.textStart(); + const auto lineEnd = lineStart + line.textLength(); + if (ep.start >= lineStart && ep.start < lineEnd) { + lineIndex = i; + break; + } + } + if (lineIndex < 0) { + continue; + } + const auto line = layout.lineAt(lineIndex); + const auto lineStart = line.textStart(); + const auto lineEnd = lineStart + line.textLength(); + const auto drawEnd = std::min(ep.start + ep.length, lineEnd); const auto xOffset = (m.contentWidth - line.naturalTextWidth()) / 2.; - const auto yShift = (i < lineCount - 1) ? -lineShift : 0.; - const auto lineStart = line.textStart(); - const auto lineText = processedText.mid( - lineStart, - line.textLength()); - auto pos = 0; - while (pos < lineText.size()) { - auto emojiLen = 0; - const auto emoji = Ui::Emoji::Find( - lineText.constData() + pos, - lineText.constData() + lineText.size(), - &emojiLen); - if (!emoji) { - ++pos; - continue; - } - const auto x = line.cursorToX(lineStart + pos); - const auto nextX = line.cursorToX( - lineStart + pos + emojiLen); - const auto glyphWidth = float64(nextX - x); - const auto drawX = m.padding - + xOffset - + x - + (glyphWidth - emojiSize) / 2.; - const auto drawY = m.padding - + yShift - + line.y() - + (line.height() - emojiSize) / 2.; - p.save(); - p.translate(drawX, drawY); - p.scale(emojiScale, emojiScale); - Ui::Emoji::Draw(p, emoji, source, 0, 0); - p.restore(); - pos += emojiLen; - } + const auto yShift = (lineIndex < lineCount - 1) + ? -lineShift + : 0.; + const auto x = line.cursorToX(ep.start); + const auto nextX = line.cursorToX(drawEnd); + const auto glyphWidth = float64(nextX - x); + const auto drawX = m.padding + + xOffset + + x + + (glyphWidth - emojiSize) / 2.; + const auto drawY = m.padding + + yShift + + line.y() + + (line.height() - emojiSize) / 2.; + p.save(); + p.translate(drawX, drawY); + p.scale(emojiScale, emojiScale); + Ui::Emoji::Draw(p, ep.emoji, source, 0, 0); + p.restore(); } } From 085f139abdb15f15e51a7f09b3abf7c91794348c Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 10:33:07 +0300 Subject: [PATCH 50/78] [img-editor] Clamped text width to prevent negative layout constraints. --- Telegram/SourceFiles/editor/scene/scene.cpp | 18 ++++++++++++++---- .../editor/scene/scene_item_text.cpp | 4 +++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index fe611a5818..c743c1a902 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -603,8 +603,13 @@ void Scene::createTextAtCenter() { sceneRect().width(), sceneRect().height()); const auto padding = int(_textFontSize * kPaddingFactor); - const auto maxTextWidth = int(shortSide * kMaxWidthFactor) - 2 * padding; - const auto minTextWidth = int(shortSide * kMinWidthFactor) - 2 * padding; + const auto maxTextWidth = std::max( + int(shortSide * kMaxWidthFactor) - 2 * padding, + 1); + const auto minTextWidth = std::clamp( + int(shortSide * kMinWidthFactor) - 2 * padding, + 1, + maxTextWidth); const auto sceneCenter = sceneRect().center(); const auto adjustWidth = [=] { emojiDoc->setTextWidth(maxTextWidth); @@ -671,8 +676,13 @@ void Scene::startTextEditing(ItemText *item) { sceneRect().width(), sceneRect().height()); const auto padding = int(item->fontSize() * kPaddingFactor); - const auto maxTextWidth = int(shortSide * kMaxWidthFactor) - 2 * padding; - const auto minTextWidth = int(shortSide * kMinWidthFactor) - 2 * padding; + const auto maxTextWidth = std::max( + int(shortSide * kMaxWidthFactor) - 2 * padding, + 1); + const auto minTextWidth = std::clamp( + int(shortSide * kMinWidthFactor) - 2 * padding, + 1, + maxTextWidth); const auto anchor = item->scenePos(); const auto adjustWidth = [=] { emojiDoc->setTextWidth(maxTextWidth); diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index 45a5bd395b..a68f98c6db 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -68,7 +68,9 @@ LayoutMetrics ComputeMetrics( || (style == TextStyle::SemiTransparent); const auto padding = hasBackground ? int(fontSize * kPaddingFactor) : 0; const auto shortSide = std::min(imageSize.width(), imageSize.height()); - const auto textMaxWidth = int(shortSide * kMaxWidthFactor) - 2 * padding; + const auto textMaxWidth = std::max( + int(shortSide * kMaxWidthFactor) - 2 * padding, + kMinContentWidth); const auto font = TextFont(fontSize); From e99442cb1ccd4fe6642746444b3cfbcf07ffd149 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 10:34:34 +0300 Subject: [PATCH 51/78] [img-editor] Fixed contrast text color in edit proxy for framed type. --- Telegram/SourceFiles/editor/scene/scene.cpp | 18 +++++++++++++++--- Telegram/SourceFiles/editor/scene/scene.h | 1 + .../editor/scene/scene_item_text.cpp | 9 +++++++++ .../SourceFiles/editor/scene/scene_item_text.h | 2 ++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index c743c1a902..06d1568e75 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -396,7 +396,9 @@ void Scene::setTextDefaults( void Scene::setTextColor(const QColor &color) { _textColor = color; if (_textEdit.proxy) { - _textEdit.proxy->setDefaultTextColor(color); + _textEdit.proxy->setDefaultTextColor(EffectiveTextColor( + color, + static_cast(_textEditStyle))); } } @@ -593,10 +595,16 @@ void Scene::createTextAtCenter() { clearSelection(); cancelDrawing(); setTextEditing(true); + _textEditStyle = _textStyle; _textEdit.proxy.reset(new TextEditProxy()); const auto proxy = _textEdit.proxy.get(); - setupTextProxy(proxy, _textColor, _textFontSize); + setupTextProxy( + proxy, + EffectiveTextColor( + _textColor, + static_cast(_textEditStyle)), + _textFontSize); const auto emojiDoc = proxy->document(); const auto shortSide = std::min( @@ -663,10 +671,14 @@ void Scene::startTextEditing(ItemText *item) { cancelDrawing(); setTextEditing(true); + _textEditStyle = int(item->textStyle()); _textEdit.proxy.reset(new TextEditProxy()); const auto proxy = _textEdit.proxy.get(); - setupTextProxy(proxy, item->color(), item->fontSize()); + setupTextProxy( + proxy, + EffectiveTextColor(item->color(), item->textStyle()), + item->fontSize()); proxy->setPlainText(item->text()); ReplaceEmoji(proxy->document()); diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index ff487e7fba..5b4a31657e 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -96,6 +96,7 @@ private: QColor _textColor; float64 _textFontSize = 0.; int _textStyle = 0; + int _textEditStyle = 0; struct { std::weak_ptr item; diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index a68f98c6db..538515bc61 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -257,6 +257,15 @@ QPainterPath BuildConnectedBackground( } // namespace +QColor EffectiveTextColor(const QColor &color, TextStyle style) { + if (style != TextStyle::Framed) { + return color; + } + return (ComputeBrightness(color) >= kBrightnessFramedThreshold) + ? QColor(0, 0, 0) + : QColor(255, 255, 255); +} + ItemText::ItemText( const QString &text, const QColor &color, diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.h b/Telegram/SourceFiles/editor/scene/scene_item_text.h index fa2c128186..a1898b2b3e 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.h +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.h @@ -22,6 +22,8 @@ enum class TextStyle : uchar { Plain, }; +[[nodiscard]] QColor EffectiveTextColor(const QColor &color, TextStyle style); + class ItemText : public ItemBase { public: enum { Type = ItemBase::Type + 2 }; From 17b72ef1bd7f3af84d3ace6d4b38e3989504bab3 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 15:21:45 +0300 Subject: [PATCH 52/78] Added assets and lang keys for sticker creation menu. --- Telegram/Resources/icons/menu/sticker_add.png | Bin 0 -> 685 bytes .../Resources/icons/menu/sticker_add@2x.png | Bin 0 -> 1157 bytes .../Resources/icons/menu/sticker_add@3x.png | Bin 0 -> 1604 bytes .../Resources/icons/menu/sticker_select.png | Bin 0 -> 769 bytes .../Resources/icons/menu/sticker_select@2x.png | Bin 0 -> 1359 bytes .../Resources/icons/menu/sticker_select@3x.png | Bin 0 -> 1933 bytes Telegram/Resources/langs/lang.strings | 17 +++++++++++++++++ .../chat_helpers/chat_helpers.style | 4 ++++ Telegram/SourceFiles/ui/menu_icons.style | 2 ++ 9 files changed, 23 insertions(+) create mode 100644 Telegram/Resources/icons/menu/sticker_add.png create mode 100644 Telegram/Resources/icons/menu/sticker_add@2x.png create mode 100644 Telegram/Resources/icons/menu/sticker_add@3x.png create mode 100644 Telegram/Resources/icons/menu/sticker_select.png create mode 100644 Telegram/Resources/icons/menu/sticker_select@2x.png create mode 100644 Telegram/Resources/icons/menu/sticker_select@3x.png diff --git a/Telegram/Resources/icons/menu/sticker_add.png b/Telegram/Resources/icons/menu/sticker_add.png new file mode 100644 index 0000000000000000000000000000000000000000..0a4ee1dbc7bf8586493b1d835272300fe23ad967 GIT binary patch literal 685 zcmeAS@N?(olHy`uVBq!ia0vp^5+KY0Bp8m$B&h%?g=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(G&@^*J&U|7|wYy{-7mw5WRvcF>CW0n>Dx42Xi zD5P5A8c~vxSdwa$T$Bo=7>o>z%ybQmb&U){3@ogSO{@$ov<(cb3=BRzD$GUEkei>9 znO2EgL*xFF7l9fy;5L+G=B5^xB<2=C^_b`ynuHh_TNzne85u$>k+;5U&A`Ao(bL5- zMC1I@iT)mw86}R@zyGyt!Mbh!(TC3;a_)-tI(VkzO`Jr<1gj?toej7oS8&K$u{4$l zzK`28t;{Ew`(>K=|DeSB0|{x_k7u8*O}6^JSN4wG-jY|gwjU0B-eWKK<+Zb89@ zQ?DmlG%z}R*dr=%qy9g)@weKG#!-vR|36oWbjN%(Y?K#S>3ftvzw?`nlf+ zx80d#{E3sf>EemW*OxBKd6Zy3{lqiIjQ^i*{B~WXf2Q|(D5pfi&v0Akxg}OJt#qb4 zTx;v9`pw_scQp9C$&}m7@*P^2el6p#Fu5NSb6<8R(+@Anwzo{{-i99vmtM-#p~sug zAj~bjeCx>)+oB1n>;+c8oQtj%^)IR^kGsPyvGqLr-}%?=)5=760tD~0O8YOq9rpOZ z?Ha{v^H_LFwjXPe-{>7%DsI2WTX5f=O-BohCZ0Hx!8_G?cDd!7A7*!}xdYDbKI*jN z|HCUenJfN0+j{%i$*9%)7Vj>2=Xieg#`_FKTiR!ZsqOt#WY(KLMJ%LTedpKBJjV(b d{yF}aHR1n6^|glIQh^c5;OXk;vd$@?2>@+Z64?L% literal 0 HcmV?d00001 diff --git a/Telegram/Resources/icons/menu/sticker_add@2x.png b/Telegram/Resources/icons/menu/sticker_add@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..72c830ca7419b08c762eef4e3277136559f732bf GIT binary patch literal 1157 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZA21szhdEImKFWCxKt7- zq*~${QIe8al4_M)lnSI6j0}v-bPbGkjSNBzEUb)8tPCu)4GgRd3_d(6%tg_Vo1c=I zR*73fEamT zas2Jn*bI?yk>mf%)ql?{eZ5QPiDOcRP?+xqv1YcCr+(Glx!E^Nd+IJ4-0JC)W4c+|tdPjm!PypS^qYCPnN` zw7iY~^Lf>E-{(}Hn`?aFzu%JQA3lct-2DILg|qu(J7#oq{m#3fEwb!{Q}uS$yN2F= z4e6o5z7_!t&vw^6YTdHePjJaI+1E*o-0$~)Oi8SBJ@EhHzAEvRU)BgH{!N!U!&<=R zA;c)dFyrO_oAWf}ci*x#j6X3=-T2Zm-@FfJdrz73A6&M7)~0FY*X^fRiGJSY^~jyw zG&Iz5;<8m){}K*N3i`b7#pOO>$>>A3_Ij{tJXySj?a90kN)K)Zp4Zp?;Qs3JRmKeK z$2)_Qk4)OHeRS@!I;q~N*>^I%OlNaXEDr8j*EgLxCP8R3qbcLs=%b%I7+DT4;M_aM zzpZG~P6NHSybo^H+;l!4es8DuQU(T&;DWc_d%wGWxfL4xSL?$5*%bwamx7dC&Rx!# z`|wMlm$kB7@mo=;>ccy7Upy#(e^9WXT=?(ZtcdOEo_{ZSJbapIwfjVrYPY})10yz} zrzJlhe6(Wv^SWoAheXwT2G#X5wkmImy(RGXkRr2$E z`a8)g?`rM|=NJ^$&N>^yPS>k-y zo_A*QjOT4%NIwuLZM?FLu{KtNvC94CZBy0l=|>f}r`Bh@yOW}6P!PNRu5`;}kN5Ta zeBS=KmKSI5wDNFR(#686KYR@z?iF6r(~qs_s|{(hdT}uN(j;Abo27rAK4R9i?+KUb z_npJH4|$%~-rS~P^-@aR>;EmWO`?a-ocia?Sd_fIdY4)to4|gZ z`P>XM+yhf8(+m>jFPz!tcHuBX!_KQUVTZXH8UAo@I-GpqU19ZhU`A!|boFyt=akR{ E05&@I-2eap literal 0 HcmV?d00001 diff --git a/Telegram/Resources/icons/menu/sticker_add@3x.png b/Telegram/Resources/icons/menu/sticker_add@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..2bfe690b77290c4cf59242241e312076d0fe8163 GIT binary patch literal 1604 zcmZ{ke>~H99LGQNBP>Ez+r(`%^P|=J`c+Kb(0AGFc=%bm&ip7UKS~{CacokwOiTK8 zDj|w1sr(4nDCvh`eiyUWAvDZ6*HoNt_qco9<8go7AMf|;@p`=<&&TV(_bFc=PXnYW z5&&SpWWfGvW&Aa|>ILFYPUWiwznkIj4Ztxg0Js?dd{#%?2>=oy02E;WP%i<1j=xgx zw^@DAh+uib0F(=e_G+h-!1Si;D702+ny*?aPQM8kK+74z-xTjr3P&NZAjQ37x{fccwGEH zjN^Jc$3Vz|YVSg|xBI&U=PGjrK--21y9Wk0PhSj)MFpXre>wVEHZc84SyfWS)sFVq zImqV<^StP+N^i?D55_mygp)>oqdfG}TXrpIoKOqZ$i?Up2q!(<3Rj-C4Hj-#9@iAr z!_Vu!a3OWN#`VF2hUb}2QY1=^*~b}km6wy1b&bg#b+}~8ukaG_5L6u1T^#mM+o@-U ze`aPy{+zYJLFnVtEy!C%m^b{`t(E;X2MDx)o0!k6?e17Gl;STW-i@e}pBwA8Z81qC zl(?@B$ahQWI%!%UX`5eM-1Q_$Kt%ReB)5LjZ)Vq>s|a=E@%!s| zHCkphw3rhY5idHTFz>BFS5wZe(hpKBLBSTl!?Swn_|cznR#e42GX1SMDPFPaOfXwu z;=IW6@O9P)L(YibvQq2vwz4D(+?=T;~yjc22bozE+NSKiD~PT+7f% z;T31}I7QRu`l4glZEL3vb1Vi^jfkx0G9Z%g&-a%4>plzFWwbx2X&je|+QgP(6WrD{ zQ8{niEri=PlgA7SL|+B0>Y$5T#^lzeNIBqc9;(I!WAVtqwce`jxUMq)JMLsZ)`=*? zJJk5Fpu?S2Vi?BNuc9m27DdOL(y{GuyF=T{jf|HGz-HE#Z@U~^|0W3Kv&;%@rQI?D zn#yEXdV~Q!w4oKhON>C3A({X92$(PnMBP}l)JjxhH9E}K>_EwA37VT4vj$&FDQQ!# zs-cO;VpH$tXM-QhOn$G#Yt=fGN`=E`ciYJrrIr-Gec(mqJ>#Abm{iAQJ!&>j(hcuO zrw3Rk>w=*mw(OS_U89Rjtv}k|6}uQ~jb|ul`d5s;XvfU-yvrcV@2`^y^}IxjVpXVf z@SUqI)nk+AFK>(4`G_^O`P*8v^PGT5ojkoz=~+X!=ovjRXd2x)$H|&+BN}9?jzh)< z%De3i@``<=qK}u`-)Unh*Ty+t6=cBz#cxth)vNgfrWu>oN-6AV<@o*5#sDN^K~z_c zNn&w)3$CDg=LgzrSn!;a66TvgwP@@3!e)hr~hCXzK_9=dAa!@&kp^kk01 zT$O6l^HhDSlX&qh5rg<{&}-x2k)CEJCpz0u$4GOupd$L%3DV{3Zl|?x9W`TY=yb8I z{!Z$Y_I}DNb=}dzHEE&Usz<>Fa1N%G2DlBU7`Gq1e4Rg^<%8aj`b1mV^&vvAZ6IP; zVDoWrY_!Ns2cJ_GH?A;sTPklxW`t()x?B43n{?p5lCkoQHxc7!wyk&Ho5{PmW1#CW0n>Dx42Xi zD5P5A8c~vxSdwa$T$Bo=7>o>z%ybQmb&U){3@ogSO{@$ov<(cb3=BRzD$GUEkei>9 znO2EgL*xFF7l9fy;5L+G=B5^xB<2=C^_b`ynuHh_TNzne85u$>k+;5U&A`C;)YHW= zMC1J2iRZm17c#WV?|=W>>VEm+@1Ui+R6eyuZ!YeHwOk-Ma_3cFP7_SZ8nP_3iwGok`!1*=^mE zC1_qYU6sjpqWme72)CS~3Zt|L-kA$)oY<#MethES6tz^{d41QHTnTb9^J91s8KvK6 z&hFNweRQJ3=ZeDGmCr-s?q{7`D_^G}rj`G1$x7di`@X?z4p9W>jw!r)3X;7Z4@0^ueP_@6wGdT z%G(~v|Ma}p)lEyD|7zI$T=RGAO1_89T(SPrTl)JCJdZj)`@z}2cO!1HPfgf9*Yj@n z?a5zz?n-%_y}Qzef44$FllA9#+oL!0%=f3}KNr`YZY`b@P21szhdEImKFWCxKt7- zq*~${QIe8al4_M)lnSI6j0}v-bPbGkjSNBzEUb)8tPCu)4GgRd3_d(6%tg_Vo1c=I zR*73fEamT zas27j*c_2iiMIJOZ{|*C-DaP??aT39yPfKL%x+GI@ZfUwVs@IvC9$|uoXu462!mnV zj0K&Hc|Il|y^>cwWYRw~i%HZcXJPM;$w3C!8V+7c%GAz%dExE5Z|S>z%D;V1dijoL z@8_WVvuEa?+w=VIx$1qNWq-&!RsQ&LZJn#6d@3o9*Tq!|9U51~87^C{r1bV_SiE`pG8dJ*yKgi8==1OD@R9q@*mrkt zrl4nO-nRE!>i32lCzrPcGq8p}T^%7K-@|Ot)aZ1!=Z@S)VTY4Cy3#k9vcKqe_cLvG zGMY8}#N)8d?_VByv$$o^vo@j7MYZBzd}3ndKUr?J*M0f7c6*N1I^~&F-ZJ_O7ov9Y zY&`fP>*9u-n5)|NtgrK~eYVp4d^~emeZ=Vx0eZaW+Y*XdpX+j2t3R20YSW}DoBuk!=|jGarGD&HG1jvO?;1)(7OW3y`ITGqsqo6IPbUnHZ@YSbQ^;!3c@v6% zeMJjYms;qkryMxaPvhC+$V>*y@JE81c*W}su7JFU(oqiu%Q*NXF^@Givc^NUw zy|%n}va#NjH+%iRJ3Otky`*ZiIgXq!S$`}{s^|ZX*Vir1#N4e9O=o3%RQP6wP0Ww& zoZidz&feJ^l09w1rncnoED3ohUWZ>^n*E05s{9ri1%{=0|F5oNcSt?m-M?GJePQhW zjq2qtrw<9~DZk&)v-fYy<)3eMPLbIB(b9RJ=4s6-@^yVPr#!p)<=@-X_}J@TVh8I~3%~T*)|8)3dL47+y4R#-NybVSzB_*oJmBL9QtlP1O z^TqOQQCv!k6ZeYb=DHa!c4of*&fY7cF-CbZ`$m)hntzz5&EVNOt!e!UV3EV%>FVdQ I&MBb@0EYlqumAu6 literal 0 HcmV?d00001 diff --git a/Telegram/Resources/icons/menu/sticker_select@3x.png b/Telegram/Resources/icons/menu/sticker_select@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..04238df9887f75a9990f611eb848ab0d3a22428c GIT binary patch literal 1933 zcmZ{ldpy&P9>;%Lcv{J`HNqjvB`M5?7(!Val51`5m&~EsTsG!zgf7$6iPFr2vs`mc z$aTpjy0|?dWXYI&^F->w?CiYGd7anm{PFzp{eE7b@Avh7eZK#FZ#!MY$bgl=0079? zVr^W+iv4%}B5puf`bw@?)UIG%8~`9z3jk=T0I($<(G~z85)J@MegJ^V0f56{tY&9( z@nTPaJ;nwA%I~Tp#7>%G>wuPC+ItYBBDc$qR1}XiDQG+;(4Rs;U5Oxw1wgEjrwEvet2_X{56n|pae;Gqv z14B1B5`{2DAq@T#qG~o}1^`mwwl>yo_|DZw-q)3Ja<7x9L1UW}e@t*S?U#Em66-Xw zJ@*=iD`VB551Hk1rIpN7EHOp3+{5_<6biMC-goxaAnbsKwY8m{X3f>Nsj_Ky+R*NQ zUiw?$)3eWJJ{t8+bTjfye5%K`jq1qJIaA*g-=XeLOOy#MSGa!;G>PK)zmWwNhtKq2 z6w0RyeGX?}3(;){7sHOuN3w_gPpHGpf4x5Vvem-4HD`{w*|;5v)xRs-AJp^`sgmpM zr|Z~eFgH7SMLoP0awK>lZ0ZqPQ7^X1tg~J=73mc}YapFBeXD|3AxrD|VorJi0X~nL zlQi%;lw*3A1GQk_1cgKHAEm7Xcd!&=82J5p^yOHIkBw^3ZMfLkoVuOg`<+Ud*TJKo zC-v?X44v~RP^4YfFQZp?caUe(z@aCB_YxIfkdQEo?Fix}C)@i@382)QyZeIgN*nHx zJ5joME+F`V)rh%oG6y9kCxP{CzAB+yRaBU0s$M|Yjg;xy`x2l?2BU8IlLK-KHZml0 z0&-#Cxj7r_t5YZce1c!#oR4B0I7F$IdeI7Yk@r(77+I7Lj${&3viHnIKBnOs9l&YF z8at@Zhn%A0B6EWr%XyE$uOUE`;~o5Yoou@z^s9uT&k727$B#b^WC3m>^7Uh(4`BX!W;q>{>?QL0 zIjZ$xRH?=Ed)RMN<9mC28_>mzXT5Zf!D z!k&c6M3oFr9`4)vrAhAi%nw*3U4Kd;uZESde8i{k$+%b2+bt{N@z3s1=~HdwXYa%F zgPU7PfxD%~%l!;a_mcwwp^4lO!Oti+jV9>9o9gMk?{J9S6@^2Plk)ES4*O|B>uf59 zRF=;WaF(k^72e8-f+~S+Wyn*9hW2l!ScVLvT248=l({KX$u8$*a~A_{E{W6-lVw>h zRiO0V@YA#?gjU478$H76ZJF7z+6PUUZ&$}r_;3j1q~7!}(C{M~>tmAG8In#?=G^Q0 zy{--43(1?jS4&P`x95Gzsi|CVPxXk`kP3Ym7e|ICR~O;6^Q7Lhvs{>%6H?2Q;u=?1NWfbkmOT_-!dbPa)}CB;`3^V8-f4KsVLYunRR zsKPG|*F?_-9C6E?4<0PGQqf_(D-QGMS1quDD2H4DVTtK2c+8$MR+Km8ltn-LJ4e*A z;*w{%{&wV*^Ts@Lqv{Cof#8OsUKCltTi~g23idj^^ttbg)=QTen55TMZf=6|4vbEcO;a(Pg~pX ztM9G)dN1S=40W-f+|B)H30a25ci3+~-Y@~ZzB!gM;j(yYmNFc)wy3&Sv~{(3kh?QG z7JJn=qFuOhR!jAGrUfv2e0J!P?GE1=Xkgvu3$&V2-^IX8!r_w#5;OB*Ta?w&5*^v+ zGNPD<=Ea4hr!_=*KzZ)^CWAGI%)F5;l~q;F$k=JKT3F>-l7^`bmW@lBXJ+~R-KRUX zN~`im{fg;nsHI!7z6cnX+otpJvUb)E@0+Y8j~kfewPSY9d6Jww7k&R875T}6EEiV$ z8qGF4WJb7Y5>DV39pAa6C)v6g=ZRUaqJ0W@18(xZ$H3DRA*F+~KxH>As|cZ|(3iNL z6p`6ui7<*4a>4u(dK$~`RPs}y9lwu-lG$!AK-bmpcBoxw%Qx!pZ*i6BzuV%rnxyUM z@sx%iCJEwe8C{;Lu>D24P7QG+HC=!`=B$!HV%4mFP!A&(XzY9;lqSg^YIR(!$=Q?h z;+wl8L4TeURGBSgJ14DmV_?+8GtS2}TyK{<=&Q;PCMeO70G_k^?a^8infkSNGzBd8 zX?>c})`7yue3y~N<&o}bqPyNOv8d)}Oc9H#BeK4~(H0uxa1VNzW@iohHC%- literal 0 HcmV?d00001 diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index a5fd302dde..5431fdefeb 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -4507,6 +4507,23 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_stickers_box_edit_name_about" = "Choose a name for your set."; "lng_stickers_creator_badge" = "edit"; +"lng_stickers_create_new" = "Create a New Sticker"; +"lng_stickers_add_existing" = "Add an Existing Sticker"; +"lng_stickers_pick_existing_title" = "Choose Sticker"; +"lng_stickers_pick_existing_about" = "Pick a sticker from your library to add it to this set."; +"lng_stickers_pick_existing_empty" = "You don't have any stickers yet."; +"lng_stickers_create_image_title" = "New Sticker"; +"lng_stickers_create_image_about" = "Choose an image to add as a sticker."; +"lng_stickers_create_choose_image" = "Choose Image"; +"lng_stickers_create_image_filter" = "Images"; +"lng_stickers_create_open_failed" = "Could not load image."; +"lng_stickers_create_too_small" = "The image must be at least {size} pixels on each side."; +"lng_stickers_create_choose_emoji" = "Choose an emoji that corresponds to your sticker:"; +"lng_stickers_create_emoji_required" = "Please choose an emoji."; +"lng_stickers_create_uploading" = "Uploading sticker…"; +"lng_stickers_create_upload_failed" = "Sticker upload failed. Please try again."; +"lng_stickers_create_added" = "Sticker added."; + "lng_in_dlg_photo" = "Photo"; "lng_in_dlg_album" = "Album"; "lng_in_dlg_video" = "Video"; diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index bffe23d89b..8f4f4349e2 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -424,6 +424,10 @@ stickersScroll: ScrollArea(boxScroll) { stickersRowDisabledOpacity: 0.4; stickersRowDuration: 200; +stickersAddCellPlusSize: 22px; +stickersAddCellPlusThickness: 2px; +stickersAddCellBgRadius: 28px; + emojiStatusDefault: icon {{ "emoji/stickers_premium", emojiIconFg }}; filtersRemove: IconButton(stickersRemove) { diff --git a/Telegram/SourceFiles/ui/menu_icons.style b/Telegram/SourceFiles/ui/menu_icons.style index 45df55ec92..068d0d2b9a 100644 --- a/Telegram/SourceFiles/ui/menu_icons.style +++ b/Telegram/SourceFiles/ui/menu_icons.style @@ -191,6 +191,8 @@ menuIconOrderPrice: icon {{ "menu/order_price", menuIconColor }}; menuIconOrderDate: icon {{ "menu/order_date", menuIconColor }}; menuIconOrderNumber: icon {{ "menu/order_number", menuIconColor }}; menuIconAdd: icon {{ "menu/add", menuIconColor }}; +menuIconStickerCreate: icon {{ "menu/sticker_add", menuIconColor }}; +menuIconStickerAdd: icon {{ "menu/sticker_select", menuIconColor }}; menuIconRatingGifts: icon {{ "menu/rating_gifts-24x24", menuIconColor }}; menuIconRatingUsers: icon {{ "menu/users_stars-24x24", menuIconColor }}; menuIconRatingRefund: icon {{ "menu/rating_refund-24x24", menuIconColor }}; From 7f93d7ec6f441206c226f03328ef24c3a1863355 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Tue, 14 Apr 2026 08:34:05 +0300 Subject: [PATCH 53/78] Added owned-set add-cell with popup menu to sticker set box. --- .../SourceFiles/boxes/sticker_set_box.cpp | 158 +++++++++++++++++- 1 file changed, 155 insertions(+), 3 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index 15eb12186e..2baa4fc1f7 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -75,6 +75,7 @@ constexpr auto kMinRepaintDelay = crl::time(33); constexpr auto kMinAfterScrollDelay = crl::time(33); constexpr auto kGrayLockOpacity = 0.3; constexpr auto kStickerMoveDuration = crl::time(200); +constexpr auto kOwnedSetStickersMax = 120; using Data::StickersSet; using Data::StickersPack; @@ -382,6 +383,16 @@ private: void startOverAnimation(int index, float64 from, float64 to); int stickerFromGlobalPos(const QPoint &p) const; + [[nodiscard]] bool hasAddCell() const; + [[nodiscard]] int totalCellsCount() const; + [[nodiscard]] QRect addCellRect() const; + [[nodiscard]] bool addCellFromGlobalPos(const QPoint &p) const; + void setAddCellHovered(bool hovered); + void paintAddCell(QPainter &p) const; + void showAddMenu(QPoint globalPos); + void startAddExistingStickerFlow(); + void startCreateNewStickerFlow(); + void installDone(const MTPmessages_StickerSetInstallResult &result); void requestReorder(not_null document, int index); @@ -465,6 +476,8 @@ private: mtpRequestId _installRequest = 0; int _selected = -1; + bool _addCellHovered = false; + bool _addCellPressed = false; base::Timer _previewTimer; int _previewShown = -1; @@ -1030,15 +1043,15 @@ void StickerSetBox::Inner::applySet(const TLStickerSet &set) { _errors.fire(Error::NotFound); return; } + _loaded = true; _perRow = isEmojiSet() ? kEmojiPerRow : kStickersPerRow; - _rowsCount = (_pack.size() + _perRow - 1) / _perRow; _singleSize = isEmojiSet() ? st::emojiSetSize : st::stickersSize; + _rowsCount = (totalCellsCount() + _perRow - 1) / _perRow; resize( _padding.left() + _perRow * _singleSize.width(), _padding.top() + _rowsCount * _singleSize.height() + _padding.bottom()); - _loaded = true; if (const auto previewId = base::take(_previewDocumentId)) { showPreviewForDocument(previewId); } @@ -1169,6 +1182,10 @@ void StickerSetBox::Inner::mousePressEvent(QMouseEvent *e) { if (e->button() != Qt::LeftButton) { return; } + if (addCellFromGlobalPos(e->globalPos())) { + _addCellPressed = !_dragging.enabled; + return; + } const auto index = stickerFromGlobalPos(e->globalPos()); if (index < 0 || index >= _pack.size()) { return; @@ -1307,6 +1324,7 @@ void StickerSetBox::Inner::showPreviewForDocument(DocumentId documentId) { void StickerSetBox::Inner::leaveEventHook(QEvent *e) { setSelected(-1); + setAddCellHovered(false); } void StickerSetBox::Inner::requestReorder( @@ -1399,6 +1417,13 @@ void StickerSetBox::Inner::mouseReleaseEvent(QMouseEvent *e) { _previewShown = -1; return; } + if (_addCellPressed) { + _addCellPressed = false; + if (addCellFromGlobalPos(e->globalPos())) { + showAddMenu(e->globalPos()); + } + return; + } if (!_previewTimer.isActive()) { return; } @@ -1653,8 +1678,27 @@ void StickerSetBox::Inner::fillDeleteStickerBox( } void StickerSetBox::Inner::updateSelected() { - auto selected = stickerFromGlobalPos(QCursor::pos()); + const auto cursor = QCursor::pos(); + const auto onAddCell = addCellFromGlobalPos(cursor); + const auto selected = onAddCell + ? -1 + : stickerFromGlobalPos(cursor); setSelected(setType() == Data::StickersType::Masks ? -1 : selected); + setAddCellHovered(onAddCell); +} + +void StickerSetBox::Inner::setAddCellHovered(bool hovered) { + if (_addCellHovered == hovered) { + return; + } + _addCellHovered = hovered; + if (hasAddCell()) { + setCursor((hovered && !_dragging.enabled) + ? style::cur_pointer + : style::cur_default); + const auto rect = addCellRect(); + rtlupdate(rect.x(), rect.y(), rect.width(), rect.height()); + } } void StickerSetBox::Inner::setSelected(int selected) { @@ -1793,6 +1837,10 @@ void StickerSetBox::Inner::paintEvent(QPaintEvent *e) { paintSticker(p, _dragging.index, pos, paused, now); } + if (hasAddCell()) { + paintAddCell(p); + } + if (_lottiePlayer && !paused) { _lottiePlayer->markFrameShown(); } @@ -2202,4 +2250,108 @@ void StickerSetBox::Inner::repaintItems(crl::time now) { update(); } +bool StickerSetBox::Inner::hasAddCell() const { + return _loaded + && _amSetCreator + && (setType() == Data::StickersType::Stickers) + && !_pack.isEmpty() + && (_pack.size() < kOwnedSetStickersMax); +} + +int StickerSetBox::Inner::totalCellsCount() const { + return _pack.size() + (hasAddCell() ? 1 : 0); +} + +QRect StickerSetBox::Inner::addCellRect() const { + const auto index = _pack.size(); + const auto row = index / _perRow; + const auto column = index % _perRow; + return QRect( + _padding.left() + column * _singleSize.width(), + _padding.top() + row * _singleSize.height(), + _singleSize.width(), + _singleSize.height()); +} + +bool StickerSetBox::Inner::addCellFromGlobalPos(const QPoint &p) const { + if (!hasAddCell()) { + return false; + } + auto local = mapFromGlobal(p); + if (rtl()) { + local.setX(width() - local.x()); + } + const auto rect = addCellRect(); + return rect.contains(local); +} + +void StickerSetBox::Inner::paintAddCell(QPainter &p) const { + const auto ltrRect = addCellRect(); + const auto rect = rtl() + ? QRect( + width() - ltrRect.x() - ltrRect.width(), + ltrRect.y(), + ltrRect.width(), + ltrRect.height()) + : ltrRect; + const auto inner = QRect( + rect::center(rect) - QPoint( + st::stickersAddCellBgRadius, + st::stickersAddCellBgRadius), + Size(st::stickersAddCellBgRadius * 2)); + + auto hq = PainterHighQualityEnabler(p); + const auto base = st::windowSubTextFg->c; + const auto bgAlpha = (_addCellHovered && !_dragging.enabled) + ? 0.22 + : 0.12; + p.setPen(Qt::NoPen); + p.setBrush(anim::with_alpha(base, bgAlpha)); + p.drawEllipse(inner); + + const auto plusHalf = st::stickersAddCellPlusSize / 2; + const auto thickness = st::stickersAddCellPlusThickness; + const auto center = rect.center(); + const auto plusH = QRectF( + center.x() - plusHalf, + center.y() - thickness / 2., + plusHalf * 2, + thickness); + const auto plusV = QRectF( + center.x() - thickness / 2., + center.y() - plusHalf, + thickness, + plusHalf * 2); + const auto radius = thickness / 2.; + p.setBrush(base); + p.drawRoundedRect(plusH, radius, radius); + p.drawRoundedRect(plusV, radius, radius); +} + +void StickerSetBox::Inner::showAddMenu(QPoint globalPos) { + if (_dragging.enabled) { + return; + } + _menu = base::make_unique_q( + this, + st::popupMenuWithIcons); + _menu->addAction( + tr::lng_stickers_create_new(tr::now), + crl::guard(this, [=] { startCreateNewStickerFlow(); }), + &st::menuIconStickerCreate); + _menu->addAction( + tr::lng_stickers_add_existing(tr::now), + crl::guard(this, [=] { startAddExistingStickerFlow(); }), + &st::menuIconStickerAdd); + _menu->popup(globalPos); +} + +void StickerSetBox::Inner::startAddExistingStickerFlow() { + _show->showToast(u"Add an existing sticker: not implemented yet."_q); +} + +void StickerSetBox::Inner::startCreateNewStickerFlow() { + _show->showToast(u"Create a new sticker: not implemented yet."_q); +} + StickerSetBox::Inner::~Inner() = default; From 99ed365695a21ee1fa71aceee462b898966bc526 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 15:36:41 +0300 Subject: [PATCH 54/78] Added flow for adding existing sticker to owned set. --- Telegram/CMakeLists.txt | 4 + .../SourceFiles/api/api_stickers_creator.cpp | 303 ++++++++++++++++++ .../SourceFiles/api/api_stickers_creator.h | 69 ++++ .../SourceFiles/boxes/sticker_picker_box.cpp | 80 +++++ .../SourceFiles/boxes/sticker_picker_box.h | 41 +++ .../SourceFiles/boxes/sticker_set_box.cpp | 37 ++- 6 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 Telegram/SourceFiles/api/api_stickers_creator.cpp create mode 100644 Telegram/SourceFiles/api/api_stickers_creator.h create mode 100644 Telegram/SourceFiles/boxes/sticker_picker_box.cpp create mode 100644 Telegram/SourceFiles/boxes/sticker_picker_box.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index b6499165b8..21278791f4 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -191,6 +191,8 @@ PRIVATE api/api_statistics_data_deserialize.h api/api_statistics_sender.cpp api/api_statistics_sender.h + api/api_stickers_creator.cpp + api/api_stickers_creator.h api/api_suggest_post.cpp api/api_suggest_post.h api/api_text_entities.cpp @@ -374,6 +376,8 @@ PRIVATE boxes/star_gift_preview_box.h boxes/star_gift_resale_box.cpp boxes/star_gift_resale_box.h + boxes/sticker_picker_box.cpp + boxes/sticker_picker_box.h boxes/sticker_set_box.cpp boxes/sticker_set_box.h boxes/stickers_box.cpp diff --git a/Telegram/SourceFiles/api/api_stickers_creator.cpp b/Telegram/SourceFiles/api/api_stickers_creator.cpp new file mode 100644 index 0000000000..b21b33e1ee --- /dev/null +++ b/Telegram/SourceFiles/api/api_stickers_creator.cpp @@ -0,0 +1,303 @@ +/* +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 "api/api_stickers_creator.h" + +#include "apiwrap.h" +#include "base/random.h" +#include "base/unixtime.h" +#include "data/data_document.h" +#include "data/data_session.h" +#include "data/stickers/data_stickers.h" +#include "data/stickers/data_stickers_set.h" +#include "main/main_session.h" +#include "storage/file_upload.h" +#include "storage/localimageloader.h" + +namespace Api { +namespace { + +constexpr auto kStickerSide = 512; + +[[nodiscard]] MTPInputStickerSetItem InputItem( + const MTPInputDocument &document, + const QString &emoji) { + return MTP_inputStickerSetItem( + MTP_flags(0), + document, + MTP_string(emoji), + MTPMaskCoords(), + MTPstring()); +} + +[[nodiscard]] std::shared_ptr PrepareStickerWebp( + MTP::DcId dcId, + DocumentId id, + const QByteArray &bytes) { + const auto filename = u"sticker.webp"_q; + auto attributes = QVector( + 1, + MTP_documentAttributeFilename(MTP_string(filename))); + attributes.push_back(MTP_documentAttributeImageSize( + MTP_int(kStickerSide), + MTP_int(kStickerSide))); + + auto result = MakePreparedFile({ + .id = id, + .type = SendMediaType::File, + }); + result->filename = filename; + result->filemime = u"image/webp"_q; + result->content = bytes; + result->filesize = bytes.size(); + result->setFileData(bytes); + result->document = MTP_document( + MTP_flags(0), + MTP_long(id), + MTP_long(0), + MTP_bytes(), + MTP_int(base::unixtime::now()), + MTP_string("image/webp"), + MTP_long(bytes.size()), + MTP_vector(), + MTPVector(), + MTP_int(dcId), + MTP_vector(std::move(attributes))); + return result; +} + +void FeedSetIfFull( + not_null session, + const MTPmessages_StickerSet &result) { + result.match([&](const MTPDmessages_stickerSet &data) { + session->data().stickers().feedSetFull(data); + session->data().stickers().notifyUpdated( + Data::StickersType::Stickers); + }, [](const auto &) { + }); +} + +} // namespace + +void AddExistingStickerToSet( + not_null session, + const StickerSetIdentifier &set, + not_null document, + const QString &emoji, + Fn done, + Fn fail) { + session->api().request(MTPstickers_AddStickerToSet( + Data::InputStickerSet(set), + InputItem(document->mtpInput(), emoji)) + ).done([=](const MTPmessages_StickerSet &result) { + FeedSetIfFull(session, result); + if (done) { + done(result); + } + }).fail([=](const MTP::Error &error) { + if (fail) { + fail(error.type()); + } + }).handleFloodErrors().send(); +} + +StickerUpload::StickerUpload( + not_null session, + StickerSetIdentifier set, + QByteArray webpBytes, + QString emoji) +: _session(session) +, _set(std::move(set)) +, _bytes(std::move(webpBytes)) +, _emoji(std::move(emoji)) +, _api(&session->mtp()) { +} + +StickerUpload::~StickerUpload() { + cancel(); +} + +void StickerUpload::start( + Fn done, + Fn fail, + Fn progress) { + Expects(!_uploadId); + + _done = std::move(done); + _fail = std::move(fail); + _progress = std::move(progress); + + _documentId = base::RandomValue(); + auto ready = PrepareStickerWebp( + _session->mtp().mainDcId(), + _documentId, + _bytes); + _uploadId = FullMsgId( + _session->userPeerId(), + _session->data().nextLocalMessageId()); + + const auto document = _session->data().document(_documentId); + document->uploadingData = std::make_unique( + document->size > 0 ? document->size : int64(_bytes.size())); + + _session->uploader().documentReady( + ) | rpl::filter([=](const Storage::UploadedMedia &data) { + return data.fullId == _uploadId; + }) | rpl::on_next([=](const Storage::UploadedMedia &data) { + uploadReady(data.info.file); + }, _uploadLifetime); + + _session->uploader().documentFailed( + ) | rpl::filter([=](const FullMsgId &id) { + return id == _uploadId; + }) | rpl::on_next([=] { + uploadFailed(); + }, _uploadLifetime); + + if (_progress) { + _session->uploader().documentProgress( + ) | rpl::filter([=](const FullMsgId &id) { + return id == _uploadId; + }) | rpl::on_next([=] { + uploadProgressed(); + }, _uploadLifetime); + } + + _session->uploader().upload(_uploadId, ready); +} + +void StickerUpload::cancel() { + if (_uploadId) { + _session->uploader().cancel(_uploadId); + _uploadId = FullMsgId(); + } + if (_addRequestId) { + _api.request(_addRequestId).cancel(); + _addRequestId = 0; + } + _uploadLifetime.destroy(); + _done = nullptr; + _fail = nullptr; + _progress = nullptr; +} + +void StickerUpload::uploadProgressed() { + if (!_progress) { + return; + } + const auto document = _session->data().document(_documentId); + if (!document->uploading() || !document->uploadingData) { + return; + } + const auto size = document->uploadingData->size; + if (size <= 0) { + return; + } + const auto percent = int( + (document->uploadingData->offset * 100) / size); + if (percent != _lastReportedPercent) { + _lastReportedPercent = percent; + _progress(percent); + } +} + +void StickerUpload::uploadFailed() { + const auto fail = std::move(_fail); + cancel(); + if (fail) { + fail(QString()); + } +} + +void StickerUpload::uploadReady(const MTPInputFile &file) { + _uploadLifetime.destroy(); + _uploadId = FullMsgId(); + + auto attributes = QVector(); + attributes.push_back(MTP_documentAttributeSticker( + MTP_flags(0), + MTP_string(_emoji), + MTP_inputStickerSetEmpty(), + MTPMaskCoords())); + attributes.push_back(MTP_documentAttributeImageSize( + MTP_int(kStickerSide), + MTP_int(kStickerSide))); + + const auto media = MTP_inputMediaUploadedDocument( + MTP_flags(0), + file, + MTPInputFile(), + MTP_string("image/webp"), + MTP_vector(std::move(attributes)), + MTP_vector(), + MTPInputPhoto(), + MTP_int(0), + MTP_int(0)); + + _addRequestId = _api.request(MTPmessages_UploadMedia( + MTP_flags(0), + MTPstring(), + MTP_inputPeerSelf(), + media + )).done(crl::guard(this, [=](const MTPMessageMedia &result) { + _addRequestId = 0; + auto inputDoc = (MTPInputDocument*)(nullptr); + auto storage = MTPInputDocument(); + result.match([&](const MTPDmessageMediaDocument &data) { + if (const auto doc = data.vdocument()) { + doc->match([&](const MTPDdocument &d) { + storage = MTP_inputDocument( + d.vid(), + d.vaccess_hash(), + d.vfile_reference()); + inputDoc = &storage; + }, [](const auto &) { + }); + } + }, [](const auto &) { + }); + if (inputDoc) { + requestAddSticker(*inputDoc); + } else if (const auto fail = std::move(_fail)) { + cancel(); + fail(QString()); + } + })).fail(crl::guard(this, [=](const MTP::Error &error) { + _addRequestId = 0; + const auto fail = std::move(_fail); + const auto type = error.type(); + cancel(); + if (fail) { + fail(type); + } + })).handleFloodErrors().send(); +} + +void StickerUpload::requestAddSticker(const MTPInputDocument &document) { + _addRequestId = _api.request(MTPstickers_AddStickerToSet( + Data::InputStickerSet(_set), + InputItem(document, _emoji)) + ).done(crl::guard(this, [=](const MTPmessages_StickerSet &result) { + _addRequestId = 0; + FeedSetIfFull(_session, result); + const auto done = std::move(_done); + cancel(); + if (done) { + done(result); + } + })).fail(crl::guard(this, [=](const MTP::Error &error) { + _addRequestId = 0; + const auto fail = std::move(_fail); + const auto type = error.type(); + cancel(); + if (fail) { + fail(type); + } + })).handleFloodErrors().send(); +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_stickers_creator.h b/Telegram/SourceFiles/api/api_stickers_creator.h new file mode 100644 index 0000000000..718b53900e --- /dev/null +++ b/Telegram/SourceFiles/api/api_stickers_creator.h @@ -0,0 +1,69 @@ +/* +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 "base/weak_ptr.h" +#include "data/stickers/data_stickers.h" +#include "mtproto/sender.h" + +class DocumentData; + +namespace Main { +class Session; +} // namespace Main + +namespace Api { + +void AddExistingStickerToSet( + not_null session, + const StickerSetIdentifier &set, + not_null document, + const QString &emoji, + Fn done, + Fn fail); + +class StickerUpload final : public base::has_weak_ptr { +public: + StickerUpload( + not_null session, + StickerSetIdentifier set, + QByteArray webpBytes, + QString emoji); + ~StickerUpload(); + + void start( + Fn done, + Fn fail, + Fn progress = nullptr); + + void cancel(); + +private: + void uploadReady(const MTPInputFile &file); + void uploadFailed(); + void uploadProgressed(); + void requestAddSticker(const MTPInputDocument &document); + + const not_null _session; + StickerSetIdentifier _set; + QByteArray _bytes; + QString _emoji; + MTP::Sender _api; + rpl::lifetime _uploadLifetime; + FullMsgId _uploadId; + DocumentId _documentId = 0; + mtpRequestId _addRequestId = 0; + + Fn _done; + Fn _fail; + Fn _progress; + int _lastReportedPercent = -1; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/boxes/sticker_picker_box.cpp b/Telegram/SourceFiles/boxes/sticker_picker_box.cpp new file mode 100644 index 0000000000..f6cd8ee528 --- /dev/null +++ b/Telegram/SourceFiles/boxes/sticker_picker_box.cpp @@ -0,0 +1,80 @@ +/* +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 "boxes/sticker_picker_box.h" + +#include "chat_helpers/compose/compose_show.h" +#include "chat_helpers/stickers_list_widget.h" +#include "data/data_document.h" +#include "data/data_session.h" +#include "data/stickers/data_stickers.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "ui/widgets/scroll_area.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_layers.h" + +StickerPickerBox::StickerPickerBox( + QWidget*, + std::shared_ptr show, + Fn)> chosen) +: _show(std::move(show)) +, _chosen(std::move(chosen)) { +} + +void StickerPickerBox::prepare() { + setTitle(tr::lng_stickers_pick_existing_title()); + + const auto wrap = Ui::CreateChild(this); + _scroll = Ui::CreateChild(wrap, st::stickersScroll); + + auto descriptor = ChatHelpers::StickersListDescriptor{ + .show = _show, + .mode = ChatHelpers::StickersListMode::UserpicBuilder, + .paused = [] { return false; }, + }; + _list = _scroll->setOwnedWidget( + object_ptr( + _scroll, + std::move(descriptor))); + _list->refreshRecent(); + _list->refreshStickers(); + + _list->chosen( + ) | rpl::on_next([=](const ChatHelpers::FileChosen &chosen) { + const auto document = chosen.document; + if (_chosen) { + _chosen(document); + } + closeBox(); + }, _list->lifetime()); + + rpl::combine( + _scroll->scrollTopValue(), + _scroll->heightValue() + ) | rpl::on_next([=](int top, int height) { + _list->setVisibleTopBottom(top, top + height); + }, _list->lifetime()); + + setDimensions(st::boxWideWidth, st::stickersMaxHeight); + + addButton(tr::lng_cancel(), [=] { closeBox(); }); + + wrap->resize(st::boxWideWidth, st::stickersMaxHeight); + _scroll->resize(wrap->size()); + setInnerWidget(object_ptr::fromRaw(wrap)); +} + +void StickerPickerBox::resizeEvent(QResizeEvent *e) { + BoxContent::resizeEvent(e); + if (_scroll) { + _scroll->resize(_scroll->parentWidget()->size()); + const auto width = _scroll->width(); + _list->resizeToWidth(width); + _list->setMinimalHeight(width, _scroll->height()); + } +} diff --git a/Telegram/SourceFiles/boxes/sticker_picker_box.h b/Telegram/SourceFiles/boxes/sticker_picker_box.h new file mode 100644 index 0000000000..c64e785cfc --- /dev/null +++ b/Telegram/SourceFiles/boxes/sticker_picker_box.h @@ -0,0 +1,41 @@ +/* +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/layers/box_content.h" + +class DocumentData; + +namespace ChatHelpers { +class Show; +class StickersListWidget; +} // namespace ChatHelpers + +namespace Ui { +class ScrollArea; +} // namespace Ui + +class StickerPickerBox final : public Ui::BoxContent { +public: + StickerPickerBox( + QWidget*, + std::shared_ptr show, + Fn)> chosen); + +protected: + void prepare() override; + void resizeEvent(QResizeEvent *e) override; + +private: + const std::shared_ptr _show; + Fn)> _chosen; + + QPointer _scroll; + QPointer _list; + +}; diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index 2baa4fc1f7..642e640967 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -8,10 +8,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/sticker_set_box.h" #include "api/api_common.h" +#include "api/api_stickers_creator.h" #include "api/api_toggling_media.h" #include "apiwrap.h" #include "base/unixtime.h" #include "boxes/premium_preview_box.h" +#include "boxes/sticker_picker_box.h" #include "chat_helpers/compose/compose_show.h" #include "chat_helpers/stickers_list_widget.h" #include "chat_helpers/stickers_lottie.h" @@ -2347,7 +2349,40 @@ void StickerSetBox::Inner::showAddMenu(QPoint globalPos) { } void StickerSetBox::Inner::startAddExistingStickerFlow() { - _show->showToast(u"Add an existing sticker: not implemented yet."_q); + if (!hasAddCell()) { + return; + } + const auto identifier = StickerSetIdentifier{ + .id = _setId, + .accessHash = _setAccessHash, + .shortName = _setShortName, + }; + const auto session = _session; + const auto show = _show; + const auto onChosen = crl::guard(this, [=, this]( + not_null document) { + const auto sticker = document->sticker(); + const auto fallback = QString::fromUtf8("\xF0\x9F\x99\x82"); + const auto emoji = (sticker && !sticker->alt.isEmpty()) + ? sticker->alt + : fallback; + Api::AddExistingStickerToSet( + session, + identifier, + document, + emoji, + crl::guard(this, [=, this](MTPmessages_StickerSet result) { + applySet(result); + show->showToast( + tr::lng_stickers_create_added(tr::now)); + }), + crl::guard(this, [=](QString err) { + show->showToast(err.isEmpty() + ? tr::lng_attach_failed(tr::now) + : err); + })); + }); + _show->showBox(Box(_show, onChosen)); } void StickerSetBox::Inner::startCreateNewStickerFlow() { From 2589a244dddb9aed64d713562a3237c4b62e1d1c Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Tue, 14 Apr 2026 08:35:47 +0300 Subject: [PATCH 55/78] Added flow for creating new static webp sticker. --- Telegram/CMakeLists.txt | 2 + .../SourceFiles/boxes/sticker_creator_box.cpp | 333 ++++++++++++++++++ .../SourceFiles/boxes/sticker_creator_box.h | 78 ++++ .../SourceFiles/boxes/sticker_set_box.cpp | 15 +- 4 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 Telegram/SourceFiles/boxes/sticker_creator_box.cpp create mode 100644 Telegram/SourceFiles/boxes/sticker_creator_box.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 21278791f4..d29a4281ee 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -376,6 +376,8 @@ PRIVATE boxes/star_gift_preview_box.h boxes/star_gift_resale_box.cpp boxes/star_gift_resale_box.h + boxes/sticker_creator_box.cpp + boxes/sticker_creator_box.h boxes/sticker_picker_box.cpp boxes/sticker_picker_box.h boxes/sticker_set_box.cpp diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp new file mode 100644 index 0000000000..25308baf65 --- /dev/null +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -0,0 +1,333 @@ +/* +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 "boxes/sticker_creator_box.h" + +#include "api/api_stickers_creator.h" +#include "chat_helpers/compose/compose_show.h" +#include "core/file_utilities.h" +#include "editor/editor_layer_widget.h" +#include "editor/photo_editor.h" +#include "editor/photo_editor_common.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "ui/emoji_config.h" +#include "ui/image/image.h" +#include "ui/image/image_prepare.h" +#include "ui/layers/box_content.h" +#include "ui/layers/layer_widget.h" +#include "ui/painter.h" +#include "ui/rp_widget.h" +#include "ui/text/text_utilities.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/fields/input_field.h" +#include "ui/widgets/labels.h" +#include "ui/wrap/vertical_layout.h" +#include "window/window_controller.h" +#include "window/window_session_controller.h" +#include "styles/style_boxes.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_layers.h" + +#include +#include + +namespace { + +constexpr auto kStickerSide = 512; +constexpr auto kPreviewSide = 256; +constexpr auto kWebpQuality = 95; + +[[nodiscard]] QImage LoadImageFromFile(const QString &path) { + auto reader = QImageReader(path); + reader.setAutoTransform(true); + auto image = reader.read(); + if (image.format() != QImage::Format_ARGB32_Premultiplied + && image.format() != QImage::Format_ARGB32) { + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + return image; +} + +class PreviewWidget final : public Ui::RpWidget { +public: + PreviewWidget(QWidget *parent, QImage image) + : RpWidget(parent) + , _image(std::move(image)) { + resize(kPreviewSide, kPreviewSide); + } + +protected: + void paintEvent(QPaintEvent *e) override { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + const auto target = QRect(0, 0, width(), height()); + p.drawImage(target, _image); + } + +private: + const QImage _image; + +}; + +void OpenPhotoEditorForSticker( + std::shared_ptr show, + QImage image, + Fn onDone) { + if (image.isNull()) { + show->showToast(tr::lng_stickers_create_open_failed(tr::now)); + return; + } + const auto sessionController = show->resolveWindow(); + if (!sessionController) { + show->showToast(tr::lng_stickers_create_open_failed(tr::now)); + return; + } + const auto windowController = &sessionController->window(); + const auto parentWidget = sessionController->widget(); + + const auto minSide = std::min(image.width(), image.height()); + if (minSide < kStickerSide) { + show->showToast(tr::lng_stickers_create_too_small( + tr::now, + lt_size, + QString::number(kStickerSide))); + return; + } + if ((image.width() > 10 * image.height()) + || (image.height() > 10 * image.width())) { + show->showToast(tr::lng_stickers_create_open_failed(tr::now)); + return; + } + + const auto fileImage = std::make_shared(std::move(image)); + const auto initialCrop = [&] { + const auto i = fileImage; + const auto side = std::min(i->width(), i->height()); + return QRect( + (i->width() - side) / 2, + (i->height() - side) / 2, + side, + side); + }(); + + auto editor = base::make_unique_q( + parentWidget, + windowController, + fileImage, + Editor::PhotoModifications{ .crop = initialCrop }, + Editor::EditorData{ .exactSize = QSize(kStickerSide, kStickerSide) }); + const auto raw = editor.get(); + + auto applyModifications = [=, done = std::move(onDone)]( + const Editor::PhotoModifications &mods) mutable { + auto result = Editor::ImageModified(fileImage->original(), mods); + if (result.size() != QSize(kStickerSide, kStickerSide)) { + result = result.scaled( + kStickerSide, + kStickerSide, + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + } + done(std::move(result)); + }; + + auto layer = std::make_unique( + parentWidget, + std::move(editor)); + Editor::InitEditorLayer(layer.get(), raw, std::move(applyModifications)); + windowController->showLayer( + std::move(layer), + Ui::LayerOption::KeepOther); +} + +} // namespace + +StickerCreatorBox::StickerCreatorBox( + QWidget*, + std::shared_ptr show, + StickerSetIdentifier set, + QImage image, + Fn done) +: _show(std::move(show)) +, _session(&_show->session()) +, _set(std::move(set)) +, _image(std::move(image)) +, _done(std::move(done)) { +} + +StickerCreatorBox::~StickerCreatorBox() = default; + +void StickerCreatorBox::prepare() { + setTitle(tr::lng_stickers_create_image_title()); + + const auto inner = setInnerWidget( + object_ptr(this)); + inner->resizeToWidth(st::boxWideWidth); + + const auto previewHolder = inner->add( + object_ptr(inner), + QMargins(0, st::boxRowPadding.left(), 0, st::boxRowPadding.left()), + style::al_top); + previewHolder->resize(st::boxWideWidth, kPreviewSide); + const auto preview = Ui::CreateChild( + previewHolder, + _image); + previewHolder->widthValue( + ) | rpl::on_next([=](int width) { + preview->move((width - kPreviewSide) / 2, 0); + }, preview->lifetime()); + + inner->add( + object_ptr( + inner, + tr::lng_stickers_create_choose_emoji(), + st::boxLabel), + st::boxRowPadding); + + _emojiField = inner->add( + object_ptr( + inner, + st::editStickerSetNameField, + tr::lng_stickers_create_choose_emoji(), + QString()), + st::boxRowPadding); + _emojiField->setMaxLength(32); + + _status = inner->add( + object_ptr( + inner, + QString(), + st::boxLabel), + st::boxRowPadding); + _status->hide(); + + _addButton = addButton( + tr::lng_box_done(), + [=] { startUpload(); }); + addButton(tr::lng_cancel(), [=] { closeBox(); }); + + setDimensionsToContent(st::boxWideWidth, inner); +} + +QByteArray StickerCreatorBox::encodeWebp() const { + auto image = _image; + if (image.size() != QSize(kStickerSide, kStickerSide)) { + image = image.scaled( + kStickerSide, + kStickerSide, + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + } + if (image.format() != QImage::Format_ARGB32) { + image = image.convertToFormat(QImage::Format_ARGB32); + } + auto bytes = QByteArray(); + auto buffer = QBuffer(&bytes); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "WEBP", kWebpQuality); + return bytes; +} + +void StickerCreatorBox::setState(State state) { + _state = state; + const auto uploading = (state == State::Uploading); + _emojiField->setEnabled(!uploading); + if (uploading) { + _status->show(); + _status->setText(tr::lng_stickers_create_uploading(tr::now)); + } else { + _status->setText(QString()); + _status->hide(); + } +} + +void StickerCreatorBox::startUpload() { + if (_state == State::Uploading) { + return; + } + const auto text = _emojiField->getLastText().trimmed(); + auto emojiLen = 0; + const auto emoji = Ui::Emoji::Find(text, &emojiLen); + if (!emoji || emojiLen != text.size()) { + _emojiField->showError(); + _show->showToast(tr::lng_stickers_create_emoji_required(tr::now)); + return; + } + const auto bytes = encodeWebp(); + if (bytes.isEmpty()) { + _show->showToast(tr::lng_stickers_create_upload_failed(tr::now)); + return; + } + + setState(State::Uploading); + _upload = std::make_unique( + _session, + _set, + bytes, + emoji->text()); + + const auto show = _show; + const auto doneCallback = _done; + _upload->start( + crl::guard(this, [=, this](MTPmessages_StickerSet result) { + _upload = nullptr; + show->showToast(tr::lng_stickers_create_added(tr::now)); + if (doneCallback) { + doneCallback(result); + } + closeBox(); + }), + crl::guard(this, [=, this](QString err) { + _upload = nullptr; + setState(State::ChooseEmoji); + show->showToast(err.isEmpty() + ? tr::lng_stickers_create_upload_failed(tr::now) + : err); + })); +} + +namespace Api { + +void OpenCreateStickerFlow( + std::shared_ptr show, + StickerSetIdentifier set, + Fn done) { + const auto parent = QPointer(show->toastParent()); + + const auto onChosen = [=, set = std::move(set), done = std::move(done)]( + FileDialog::OpenResult &&result) mutable { + if (result.paths.isEmpty() && result.remoteContent.isEmpty()) { + return; + } + const auto path = result.paths.isEmpty() + ? QString() + : result.paths.front(); + auto image = path.isEmpty() + ? QImage::fromData(result.remoteContent) + : LoadImageFromFile(path); + OpenPhotoEditorForSticker( + show, + std::move(image), + [=, set = std::move(set), done = std::move(done)]( + QImage &&prepared) mutable { + show->showBox(Box( + show, + std::move(set), + std::move(prepared), + std::move(done))); + }); + }; + + FileDialog::GetOpenPath( + parent, + tr::lng_stickers_create_choose_image(tr::now), + FileDialog::ImagesFilter(), + std::move(onChosen)); +} + +} // namespace Api diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.h b/Telegram/SourceFiles/boxes/sticker_creator_box.h new file mode 100644 index 0000000000..8966ec48aa --- /dev/null +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.h @@ -0,0 +1,78 @@ +/* +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 "data/stickers/data_stickers.h" +#include "ui/layers/box_content.h" + +#include + +namespace ChatHelpers { +class Show; +} // namespace ChatHelpers + +namespace Main { +class Session; +} // namespace Main + +namespace Ui { +class FlatLabel; +class InputField; +class RoundButton; +} // namespace Ui + +namespace Api { +class StickerUpload; +} // namespace Api + +class StickerCreatorBox final : public Ui::BoxContent { +public: + StickerCreatorBox( + QWidget*, + std::shared_ptr show, + StickerSetIdentifier set, + QImage image, + Fn done); + ~StickerCreatorBox(); + +protected: + void prepare() override; + +private: + enum class State { + ChooseEmoji, + Uploading, + }; + + void setState(State state); + void startUpload(); + [[nodiscard]] QByteArray encodeWebp() const; + + const std::shared_ptr _show; + const not_null _session; + const StickerSetIdentifier _set; + const QImage _image; + const Fn _done; + + State _state = State::ChooseEmoji; + Ui::InputField *_emojiField = nullptr; + Ui::FlatLabel *_status = nullptr; + Ui::RoundButton *_addButton = nullptr; + + std::unique_ptr _upload; + +}; + +namespace Api { + +void OpenCreateStickerFlow( + std::shared_ptr show, + StickerSetIdentifier set, + Fn done = nullptr); + +} // namespace Api diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index 642e640967..0ea2e41d6c 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -13,6 +13,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "apiwrap.h" #include "base/unixtime.h" #include "boxes/premium_preview_box.h" +#include "boxes/sticker_creator_box.h" #include "boxes/sticker_picker_box.h" #include "chat_helpers/compose/compose_show.h" #include "chat_helpers/stickers_list_widget.h" @@ -2386,7 +2387,19 @@ void StickerSetBox::Inner::startAddExistingStickerFlow() { } void StickerSetBox::Inner::startCreateNewStickerFlow() { - _show->showToast(u"Create a new sticker: not implemented yet."_q); + if (!hasAddCell()) { + return; + } + const auto identifier = StickerSetIdentifier{ + .id = _setId, + .accessHash = _setAccessHash, + .shortName = _setShortName, + }; + const auto onDone = crl::guard(this, [=, this]( + MTPmessages_StickerSet result) { + applySet(result); + }); + Api::OpenCreateStickerFlow(_show, identifier, onDone); } StickerSetBox::Inner::~Inner() = default; From 07a952a2a661d3188395fa5c1c636b44163383f6 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 15:48:26 +0300 Subject: [PATCH 56/78] Polished sticker picker scroll wiring and creator upload teardown. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 8 ++++ .../SourceFiles/boxes/sticker_picker_box.cpp | 40 ++++++++----------- .../SourceFiles/boxes/sticker_picker_box.h | 5 --- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index 25308baf65..446665006e 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -212,6 +212,11 @@ void StickerCreatorBox::prepare() { addButton(tr::lng_cancel(), [=] { closeBox(); }); setDimensionsToContent(st::boxWideWidth, inner); + + boxClosing( + ) | rpl::on_next([=, this] { + _upload = nullptr; + }, lifetime()); } QByteArray StickerCreatorBox::encodeWebp() const { @@ -237,6 +242,9 @@ void StickerCreatorBox::setState(State state) { _state = state; const auto uploading = (state == State::Uploading); _emojiField->setEnabled(!uploading); + if (_addButton) { + _addButton->setDisabled(uploading); + } if (uploading) { _status->show(); _status->setText(tr::lng_stickers_create_uploading(tr::now)); diff --git a/Telegram/SourceFiles/boxes/sticker_picker_box.cpp b/Telegram/SourceFiles/boxes/sticker_picker_box.cpp index f6cd8ee528..6bfe7e2e1e 100644 --- a/Telegram/SourceFiles/boxes/sticker_picker_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_picker_box.cpp @@ -14,7 +14,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/stickers/data_stickers.h" #include "lang/lang_keys.h" #include "main/main_session.h" -#include "ui/widgets/scroll_area.h" #include "styles/style_chat_helpers.h" #include "styles/style_layers.h" @@ -29,18 +28,16 @@ StickerPickerBox::StickerPickerBox( void StickerPickerBox::prepare() { setTitle(tr::lng_stickers_pick_existing_title()); - const auto wrap = Ui::CreateChild(this); - _scroll = Ui::CreateChild(wrap, st::stickersScroll); - auto descriptor = ChatHelpers::StickersListDescriptor{ .show = _show, .mode = ChatHelpers::StickersListMode::UserpicBuilder, .paused = [] { return false; }, }; - _list = _scroll->setOwnedWidget( - object_ptr( - _scroll, - std::move(descriptor))); + auto list = object_ptr( + this, + std::move(descriptor)); + _list = list.data(); + _list->refreshRecent(); _list->refreshStickers(); @@ -53,28 +50,25 @@ void StickerPickerBox::prepare() { closeBox(); }, _list->lifetime()); - rpl::combine( - _scroll->scrollTopValue(), - _scroll->heightValue() - ) | rpl::on_next([=](int top, int height) { - _list->setVisibleTopBottom(top, top + height); - }, _list->lifetime()); - + setInnerWidget(std::move(list)); setDimensions(st::boxWideWidth, st::stickersMaxHeight); - addButton(tr::lng_cancel(), [=] { closeBox(); }); + scrolls( + ) | rpl::on_next([=, this] { + if (_list) { + const auto top = scrollTop(); + _list->setVisibleTopBottom(top, top + scrollHeight()); + } + }, lifetime()); - wrap->resize(st::boxWideWidth, st::stickersMaxHeight); - _scroll->resize(wrap->size()); - setInnerWidget(object_ptr::fromRaw(wrap)); + addButton(tr::lng_cancel(), [=] { closeBox(); }); } void StickerPickerBox::resizeEvent(QResizeEvent *e) { BoxContent::resizeEvent(e); - if (_scroll) { - _scroll->resize(_scroll->parentWidget()->size()); - const auto width = _scroll->width(); + if (_list) { + const auto width = this->width(); _list->resizeToWidth(width); - _list->setMinimalHeight(width, _scroll->height()); + _list->setMinimalHeight(width, st::stickersMaxHeight); } } diff --git a/Telegram/SourceFiles/boxes/sticker_picker_box.h b/Telegram/SourceFiles/boxes/sticker_picker_box.h index c64e785cfc..0b43b25149 100644 --- a/Telegram/SourceFiles/boxes/sticker_picker_box.h +++ b/Telegram/SourceFiles/boxes/sticker_picker_box.h @@ -16,10 +16,6 @@ class Show; class StickersListWidget; } // namespace ChatHelpers -namespace Ui { -class ScrollArea; -} // namespace Ui - class StickerPickerBox final : public Ui::BoxContent { public: StickerPickerBox( @@ -35,7 +31,6 @@ private: const std::shared_ptr _show; Fn)> _chosen; - QPointer _scroll; QPointer _list; }; From 4d692e635b1857c48339dfdda2d824d5002d8c95 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 16:23:52 +0300 Subject: [PATCH 57/78] Added flow to open sticker editor with transparent canvas. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index 446665006e..ce1b597ec1 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -13,6 +13,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/editor_layer_widget.h" #include "editor/photo_editor.h" #include "editor/photo_editor_common.h" +#include "editor/scene/scene.h" +#include "editor/scene/scene_item_image.h" #include "lang/lang_keys.h" #include "main/main_session.h" #include "ui/emoji_config.h" @@ -90,42 +92,60 @@ void OpenPhotoEditorForSticker( const auto windowController = &sessionController->window(); const auto parentWidget = sessionController->widget(); - const auto minSide = std::min(image.width(), image.height()); - if (minSide < kStickerSide) { - show->showToast(tr::lng_stickers_create_too_small( - tr::now, - lt_size, - QString::number(kStickerSide))); - return; - } if ((image.width() > 10 * image.height()) || (image.height() > 10 * image.width())) { show->showToast(tr::lng_stickers_create_open_failed(tr::now)); return; } - const auto fileImage = std::make_shared(std::move(image)); - const auto initialCrop = [&] { - const auto i = fileImage; - const auto side = std::min(i->width(), i->height()); - return QRect( - (i->width() - side) / 2, - (i->height() - side) / 2, - side, - side); - }(); + auto canvas = QImage( + kStickerSide, + kStickerSide, + QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::transparent); + const auto baseImage = std::make_shared(std::move(canvas)); + + auto scene = std::make_shared( + QRectF(0, 0, kStickerSide, kStickerSide)); + + const auto userPixmap = QPixmap::fromImage(std::move(image)); + const auto userSize = userPixmap.size(); + const auto fitted = userSize.scaled( + QSize(kStickerSide, kStickerSide), + Qt::KeepAspectRatio); + auto itemData = Editor::ItemBase::Data{ + .initialZoom = 1.0, + .zPtr = scene->lastZ(), + .size = fitted.width(), + .x = kStickerSide / 2, + .y = kStickerSide / 2, + .imageSize = userSize, + }; + auto imageItem = std::make_shared( + QPixmap(userPixmap), + std::move(itemData)); + scene->addItem(std::move(imageItem)); + + auto modifications = Editor::PhotoModifications{ + .crop = QRect(0, 0, kStickerSide, kStickerSide), + .paint = std::move(scene), + }; auto editor = base::make_unique_q( parentWidget, windowController, - fileImage, - Editor::PhotoModifications{ .crop = initialCrop }, - Editor::EditorData{ .exactSize = QSize(kStickerSide, kStickerSide) }); + baseImage, + std::move(modifications), + Editor::EditorData{ + .exactSize = QSize(kStickerSide, kStickerSide), + .cropType = Editor::EditorData::CropType::RoundedRect, + .keepAspectRatio = true, + }); const auto raw = editor.get(); auto applyModifications = [=, done = std::move(onDone)]( const Editor::PhotoModifications &mods) mutable { - auto result = Editor::ImageModified(fileImage->original(), mods); + auto result = Editor::ImageModified(baseImage->original(), mods); if (result.size() != QSize(kStickerSide, kStickerSide)) { result = result.scaled( kStickerSide, From b51c0f1ee5b625c46bad27237497e31c70aef432 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 16:29:55 +0300 Subject: [PATCH 58/78] Replaced emoji input field with emoji row and radial upload button. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 317 +++++++++++++++--- .../SourceFiles/boxes/sticker_creator_box.h | 18 +- .../chat_helpers/chat_helpers.style | 6 + 3 files changed, 277 insertions(+), 64 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index ce1b597ec1..c390506d27 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -9,30 +9,34 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_stickers_creator.h" #include "chat_helpers/compose/compose_show.h" +#include "chat_helpers/emoji_list_widget.h" +#include "chat_helpers/tabbed_selector.h" #include "core/file_utilities.h" #include "editor/editor_layer_widget.h" #include "editor/photo_editor.h" #include "editor/photo_editor_common.h" #include "editor/scene/scene.h" #include "editor/scene/scene_item_image.h" +#include "info/channel_statistics/boosts/giveaway/boost_badge.h" #include "lang/lang_keys.h" #include "main/main_session.h" +#include "ui/abstract_button.h" #include "ui/emoji_config.h" #include "ui/image/image.h" #include "ui/image/image_prepare.h" -#include "ui/layers/box_content.h" +#include "ui/layers/generic_box.h" #include "ui/layers/layer_widget.h" #include "ui/painter.h" #include "ui/rp_widget.h" -#include "ui/text/text_utilities.h" #include "ui/widgets/buttons.h" -#include "ui/widgets/fields/input_field.h" #include "ui/widgets/labels.h" +#include "ui/widgets/scroll_area.h" #include "ui/wrap/vertical_layout.h" #include "window/window_controller.h" #include "window/window_session_controller.h" #include "styles/style_boxes.h" #include "styles/style_chat_helpers.h" +#include "styles/style_info.h" #include "styles/style_layers.h" #include @@ -43,6 +47,7 @@ namespace { constexpr auto kStickerSide = 512; constexpr auto kPreviewSide = 256; constexpr auto kWebpQuality = 95; +constexpr auto kMaxEmojis = 7; [[nodiscard]] QImage LoadImageFromFile(const QString &path) { auto reader = QImageReader(path); @@ -76,6 +81,240 @@ private: }; +void ShowEmojiPickerBox( + not_null box, + std::shared_ptr show, + Fn chosen) { + box->setTitle(tr::lng_stickers_pack_choose_emoji_title()); + box->addRow( + object_ptr( + box, + tr::lng_stickers_pack_choose_emoji_about(), + st::boxLabel)); + + const auto selector = box->addRow( + object_ptr( + box, + ChatHelpers::EmojiListDescriptor{ + .show = show, + .mode = ChatHelpers::EmojiListMode::TopicIcon, + .paused = [] { return false; }, + .st = &st::reactPanelEmojiPan, + }), + QMargins()); + selector->refreshEmoji(); + + selector->chosen( + ) | rpl::on_next([=, weak = base::make_weak(box.get())]( + const ChatHelpers::EmojiChosen &result) { + if (chosen) { + chosen(result.emoji); + } + if (const auto strong = weak.get()) { + strong->closeBox(); + } + }, selector->lifetime()); + + box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); + box->setMaxHeight(st::stickersMaxHeight); +} + +class ActionButton final : public Ui::AbstractButton { +public: + enum class Kind { + Plus, + Minus, + }; + + ActionButton(QWidget *parent, Kind kind) + : AbstractButton(parent) + , _kind(kind) { + resize( + st::stickersCreatorActionSize, + st::stickersCreatorActionSize); + } + +protected: + void paintEvent(QPaintEvent *e) override { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + const auto size = st::stickersCreatorActionSize; + const auto rect = QRect(0, 0, size, size); + const auto bgAlpha = isOver() ? 0.22 : 0.12; + p.setPen(Qt::NoPen); + p.setBrush(anim::with_alpha(st::windowSubTextFg->c, bgAlpha)); + p.drawEllipse(rect); + p.setBrush(st::windowSubTextFg->c); + const auto thickness = st::stickersAddCellPlusThickness; + const auto glyphHalf = st::stickersCreatorEmojiSize / 4; + const auto center = rect.center() + QPointF(0.5, 0.5); + const auto horizontal = QRectF( + center.x() - glyphHalf, + center.y() - thickness / 2., + glyphHalf * 2, + thickness); + const auto radius = thickness / 2.; + p.drawRoundedRect(horizontal, radius, radius); + if (_kind == Kind::Plus) { + const auto vertical = QRectF( + center.x() - thickness / 2., + center.y() - glyphHalf, + thickness, + glyphHalf * 2); + p.drawRoundedRect(vertical, radius, radius); + } + } + +private: + const Kind _kind; + +}; + +class EmojiPickerRow final : public Ui::RpWidget { +public: + EmojiPickerRow( + QWidget *parent, + std::shared_ptr show); + + [[nodiscard]] QString value() const; + [[nodiscard]] rpl::producer countValue() const; + +protected: + void paintEvent(QPaintEvent *e) override; + void resizeEvent(QResizeEvent *e) override; + +private: + void relayout(); + void openPicker(); + void addEmoji(EmojiPtr emoji); + void removeLast(); + [[nodiscard]] int rowContentWidth() const; + + const std::shared_ptr _show; + std::vector _emojis; + rpl::variable _count; + ActionButton *_plus = nullptr; + ActionButton *_minus = nullptr; + +}; + +EmojiPickerRow::EmojiPickerRow( + QWidget *parent, + std::shared_ptr show) +: RpWidget(parent) +, _show(std::move(show)) +, _plus(Ui::CreateChild(this, ActionButton::Kind::Plus)) +, _minus(Ui::CreateChild(this, ActionButton::Kind::Minus)) { + resize(width(), st::stickersCreatorRowHeight); + _plus->setClickedCallback([=] { openPicker(); }); + _minus->setClickedCallback([=] { removeLast(); }); + _minus->hide(); +} + +QString EmojiPickerRow::value() const { + auto result = QString(); + for (const auto emoji : _emojis) { + result.append(emoji->text()); + } + return result; +} + +rpl::producer EmojiPickerRow::countValue() const { + return _count.value(); +} + +int EmojiPickerRow::rowContentWidth() const { + const auto emojiPart = int(_emojis.size()) + * (st::stickersCreatorEmojiSize + st::stickersCreatorEmojiSkip); + const auto plusPart = st::stickersCreatorActionSize; + const auto minusPart = _emojis.empty() + ? 0 + : (st::stickersCreatorActionSize + + st::stickersCreatorActionMargin); + const auto margin = _emojis.empty() + ? 0 + : st::stickersCreatorActionMargin; + return emojiPart + plusPart + minusPart + margin; +} + +void EmojiPickerRow::relayout() { + const auto contentWidth = rowContentWidth(); + const auto offsetX = (width() - contentWidth) / 2; + const auto centerY = height() / 2; + + auto x = offsetX; + + if (!_emojis.empty()) { + _minus->move( + x, + centerY - st::stickersCreatorActionSize / 2); + _minus->show(); + x += st::stickersCreatorActionSize + st::stickersCreatorActionMargin; + } else { + _minus->hide(); + } + + x += int(_emojis.size()) + * (st::stickersCreatorEmojiSize + st::stickersCreatorEmojiSkip); + if (!_emojis.empty()) { + x += st::stickersCreatorActionMargin + - st::stickersCreatorEmojiSkip; + } + + _plus->move(x, centerY - st::stickersCreatorActionSize / 2); + _plus->setVisible(int(_emojis.size()) < kMaxEmojis); + + update(); +} + +void EmojiPickerRow::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + if (_emojis.empty()) { + return; + } + const auto contentWidth = rowContentWidth(); + const auto offsetX = (width() - contentWidth) / 2; + const auto centerY = height() / 2; + + auto x = offsetX + + st::stickersCreatorActionSize + + st::stickersCreatorActionMargin; + const auto y = centerY - st::stickersCreatorEmojiSize / 2; + for (const auto emoji : _emojis) { + Ui::Emoji::Draw(p, emoji, st::stickersCreatorEmojiSize, x, y); + x += st::stickersCreatorEmojiSize + st::stickersCreatorEmojiSkip; + } +} + +void EmojiPickerRow::resizeEvent(QResizeEvent *e) { + relayout(); +} + +void EmojiPickerRow::openPicker() { + const auto addOne = crl::guard(this, [=](EmojiPtr emoji) { + addEmoji(emoji); + }); + _show->showBox(Box(ShowEmojiPickerBox, _show, addOne)); +} + +void EmojiPickerRow::addEmoji(EmojiPtr emoji) { + if (!emoji || int(_emojis.size()) >= kMaxEmojis) { + return; + } + _emojis.push_back(emoji); + _count = int(_emojis.size()); + relayout(); +} + +void EmojiPickerRow::removeLast() { + if (_emojis.empty()) { + return; + } + _emojis.pop_back(); + _count = int(_emojis.size()); + relayout(); +} + void OpenPhotoEditorForSticker( std::shared_ptr show, QImage image, @@ -187,7 +426,6 @@ void StickerCreatorBox::prepare() { const auto inner = setInnerWidget( object_ptr(this)); - inner->resizeToWidth(st::boxWideWidth); const auto previewHolder = inner->add( object_ptr(inner), @@ -209,27 +447,28 @@ void StickerCreatorBox::prepare() { st::boxLabel), st::boxRowPadding); - _emojiField = inner->add( - object_ptr( - inner, - st::editStickerSetNameField, - tr::lng_stickers_create_choose_emoji(), - QString()), - st::boxRowPadding); - _emojiField->setMaxLength(32); + _emojiRow = inner->add( + object_ptr(inner, _show), + QMargins(0, 0, 0, st::boxRowPadding.left())); + _emojiRow->resize(st::boxWideWidth, st::stickersCreatorRowHeight); - _status = inner->add( - object_ptr( - inner, - QString(), - st::boxLabel), - st::boxRowPadding); - _status->hide(); - - _addButton = addButton( - tr::lng_box_done(), + const auto addButton = this->addButton( + rpl::conditional( + _uploading.value(), + rpl::single(QString()), + tr::lng_box_done()), [=] { startUpload(); }); - addButton(tr::lng_cancel(), [=] { closeBox(); }); + this->addButton(tr::lng_cancel(), [=] { closeBox(); }); + + { + using namespace Info::Statistics; + const auto loadingAnimation = InfiniteRadialAnimationWidget( + addButton, + addButton->height() / 2, + &st::editStickerSetNameLoading); + AddChildToWidgetCenter(addButton, loadingAnimation); + loadingAnimation->showOn(_uploading.value()); + } setDimensionsToContent(st::boxWideWidth, inner); @@ -258,31 +497,12 @@ QByteArray StickerCreatorBox::encodeWebp() const { return bytes; } -void StickerCreatorBox::setState(State state) { - _state = state; - const auto uploading = (state == State::Uploading); - _emojiField->setEnabled(!uploading); - if (_addButton) { - _addButton->setDisabled(uploading); - } - if (uploading) { - _status->show(); - _status->setText(tr::lng_stickers_create_uploading(tr::now)); - } else { - _status->setText(QString()); - _status->hide(); - } -} - void StickerCreatorBox::startUpload() { - if (_state == State::Uploading) { + if (_uploading.current()) { return; } - const auto text = _emojiField->getLastText().trimmed(); - auto emojiLen = 0; - const auto emoji = Ui::Emoji::Find(text, &emojiLen); - if (!emoji || emojiLen != text.size()) { - _emojiField->showError(); + const auto emoji = _emojiRow->value(); + if (emoji.isEmpty()) { _show->showToast(tr::lng_stickers_create_emoji_required(tr::now)); return; } @@ -292,18 +512,19 @@ void StickerCreatorBox::startUpload() { return; } - setState(State::Uploading); + _uploading = true; _upload = std::make_unique( _session, _set, bytes, - emoji->text()); + emoji); const auto show = _show; const auto doneCallback = _done; _upload->start( crl::guard(this, [=, this](MTPmessages_StickerSet result) { _upload = nullptr; + _uploading = false; show->showToast(tr::lng_stickers_create_added(tr::now)); if (doneCallback) { doneCallback(result); @@ -312,7 +533,7 @@ void StickerCreatorBox::startUpload() { }), crl::guard(this, [=, this](QString err) { _upload = nullptr; - setState(State::ChooseEmoji); + _uploading = false; show->showToast(err.isEmpty() ? tr::lng_stickers_create_upload_failed(tr::now) : err); diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.h b/Telegram/SourceFiles/boxes/sticker_creator_box.h index 8966ec48aa..b973227fda 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.h +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.h @@ -20,12 +20,6 @@ namespace Main { class Session; } // namespace Main -namespace Ui { -class FlatLabel; -class InputField; -class RoundButton; -} // namespace Ui - namespace Api { class StickerUpload; } // namespace Api @@ -44,12 +38,6 @@ protected: void prepare() override; private: - enum class State { - ChooseEmoji, - Uploading, - }; - - void setState(State state); void startUpload(); [[nodiscard]] QByteArray encodeWebp() const; @@ -59,10 +47,8 @@ private: const QImage _image; const Fn _done; - State _state = State::ChooseEmoji; - Ui::InputField *_emojiField = nullptr; - Ui::FlatLabel *_status = nullptr; - Ui::RoundButton *_addButton = nullptr; + rpl::variable _uploading = false; + Fn _emojiValue; std::unique_ptr _upload; diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index 8f4f4349e2..b39905f5cf 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -428,6 +428,12 @@ stickersAddCellPlusSize: 22px; stickersAddCellPlusThickness: 2px; stickersAddCellBgRadius: 28px; +stickersCreatorEmojiSize: 32px; +stickersCreatorEmojiSkip: 8px; +stickersCreatorRowHeight: 48px; +stickersCreatorActionSize: 32px; +stickersCreatorActionMargin: 12px; + emojiStatusDefault: icon {{ "emoji/stickers_premium", emojiIconFg }}; filtersRemove: IconButton(stickersRemove) { From fc85b09e883b136d2feff0ba6a0fde736831024f Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 16:42:15 +0300 Subject: [PATCH 59/78] Switched existing sticker pick to inline dropdown that excludes self. --- Telegram/CMakeLists.txt | 2 - Telegram/Resources/langs/lang.strings | 2 + .../SourceFiles/boxes/sticker_creator_box.cpp | 7 +- .../SourceFiles/boxes/sticker_picker_box.cpp | 74 ------------------- .../SourceFiles/boxes/sticker_picker_box.h | 36 --------- .../SourceFiles/boxes/sticker_set_box.cpp | 51 +++++++++++-- .../chat_helpers/stickers_list_widget.cpp | 4 + .../chat_helpers/stickers_list_widget.h | 2 + 8 files changed, 58 insertions(+), 120 deletions(-) delete mode 100644 Telegram/SourceFiles/boxes/sticker_picker_box.cpp delete mode 100644 Telegram/SourceFiles/boxes/sticker_picker_box.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index d29a4281ee..a7ca2c4d00 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -378,8 +378,6 @@ PRIVATE boxes/star_gift_resale_box.h boxes/sticker_creator_box.cpp boxes/sticker_creator_box.h - boxes/sticker_picker_box.cpp - boxes/sticker_picker_box.h boxes/sticker_set_box.cpp boxes/sticker_set_box.h boxes/stickers_box.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 5431fdefeb..55fd289d4b 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -4509,6 +4509,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_stickers_create_new" = "Create a New Sticker"; "lng_stickers_add_existing" = "Add an Existing Sticker"; +"lng_stickers_pack_choose_emoji_title" = "Choose Emoji"; +"lng_stickers_pack_choose_emoji_about" = "Pick an emoji that corresponds to this sticker."; "lng_stickers_pick_existing_title" = "Choose Sticker"; "lng_stickers_pick_existing_about" = "Pick a sticker from your library to add it to this set."; "lng_stickers_pick_existing_empty" = "You don't have any stickers yet."; diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index c390506d27..0c28b19cd1 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -447,10 +447,11 @@ void StickerCreatorBox::prepare() { st::boxLabel), st::boxRowPadding); - _emojiRow = inner->add( + const auto emojiRow = inner->add( object_ptr(inner, _show), QMargins(0, 0, 0, st::boxRowPadding.left())); - _emojiRow->resize(st::boxWideWidth, st::stickersCreatorRowHeight); + emojiRow->resize(st::boxWideWidth, st::stickersCreatorRowHeight); + _emojiValue = [=] { return emojiRow->value(); }; const auto addButton = this->addButton( rpl::conditional( @@ -501,7 +502,7 @@ void StickerCreatorBox::startUpload() { if (_uploading.current()) { return; } - const auto emoji = _emojiRow->value(); + const auto emoji = _emojiValue ? _emojiValue() : QString(); if (emoji.isEmpty()) { _show->showToast(tr::lng_stickers_create_emoji_required(tr::now)); return; diff --git a/Telegram/SourceFiles/boxes/sticker_picker_box.cpp b/Telegram/SourceFiles/boxes/sticker_picker_box.cpp deleted file mode 100644 index 6bfe7e2e1e..0000000000 --- a/Telegram/SourceFiles/boxes/sticker_picker_box.cpp +++ /dev/null @@ -1,74 +0,0 @@ -/* -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 "boxes/sticker_picker_box.h" - -#include "chat_helpers/compose/compose_show.h" -#include "chat_helpers/stickers_list_widget.h" -#include "data/data_document.h" -#include "data/data_session.h" -#include "data/stickers/data_stickers.h" -#include "lang/lang_keys.h" -#include "main/main_session.h" -#include "styles/style_chat_helpers.h" -#include "styles/style_layers.h" - -StickerPickerBox::StickerPickerBox( - QWidget*, - std::shared_ptr show, - Fn)> chosen) -: _show(std::move(show)) -, _chosen(std::move(chosen)) { -} - -void StickerPickerBox::prepare() { - setTitle(tr::lng_stickers_pick_existing_title()); - - auto descriptor = ChatHelpers::StickersListDescriptor{ - .show = _show, - .mode = ChatHelpers::StickersListMode::UserpicBuilder, - .paused = [] { return false; }, - }; - auto list = object_ptr( - this, - std::move(descriptor)); - _list = list.data(); - - _list->refreshRecent(); - _list->refreshStickers(); - - _list->chosen( - ) | rpl::on_next([=](const ChatHelpers::FileChosen &chosen) { - const auto document = chosen.document; - if (_chosen) { - _chosen(document); - } - closeBox(); - }, _list->lifetime()); - - setInnerWidget(std::move(list)); - setDimensions(st::boxWideWidth, st::stickersMaxHeight); - - scrolls( - ) | rpl::on_next([=, this] { - if (_list) { - const auto top = scrollTop(); - _list->setVisibleTopBottom(top, top + scrollHeight()); - } - }, lifetime()); - - addButton(tr::lng_cancel(), [=] { closeBox(); }); -} - -void StickerPickerBox::resizeEvent(QResizeEvent *e) { - BoxContent::resizeEvent(e); - if (_list) { - const auto width = this->width(); - _list->resizeToWidth(width); - _list->setMinimalHeight(width, st::stickersMaxHeight); - } -} diff --git a/Telegram/SourceFiles/boxes/sticker_picker_box.h b/Telegram/SourceFiles/boxes/sticker_picker_box.h deleted file mode 100644 index 0b43b25149..0000000000 --- a/Telegram/SourceFiles/boxes/sticker_picker_box.h +++ /dev/null @@ -1,36 +0,0 @@ -/* -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/layers/box_content.h" - -class DocumentData; - -namespace ChatHelpers { -class Show; -class StickersListWidget; -} // namespace ChatHelpers - -class StickerPickerBox final : public Ui::BoxContent { -public: - StickerPickerBox( - QWidget*, - std::shared_ptr show, - Fn)> chosen); - -protected: - void prepare() override; - void resizeEvent(QResizeEvent *e) override; - -private: - const std::shared_ptr _show; - Fn)> _chosen; - - QPointer _list; - -}; diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index 0ea2e41d6c..8b74f0bb41 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -14,7 +14,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/unixtime.h" #include "boxes/premium_preview_box.h" #include "boxes/sticker_creator_box.h" -#include "boxes/sticker_picker_box.h" #include "chat_helpers/compose/compose_show.h" #include "chat_helpers/stickers_list_widget.h" #include "chat_helpers/stickers_lottie.h" @@ -57,6 +56,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/gradient_round_button.h" #include "ui/widgets/menu/menu_add_action_callback.h" #include "ui/widgets/menu/menu_add_action_callback_factory.h" +#include "ui/widgets/inner_dropdown.h" #include "ui/widgets/popup_menu.h" #include "ui/widgets/scroll_area.h" #include "window/window_session_controller.h" @@ -488,6 +488,7 @@ private: bool _previewLocked = false; base::unique_qptr _menu; + base::unique_qptr _pickerDropdown; rpl::event_stream _setInstalled; rpl::event_stream _setArchived; @@ -2353,6 +2354,16 @@ void StickerSetBox::Inner::startAddExistingStickerFlow() { if (!hasAddCell()) { return; } + auto box = (Ui::BoxContent*)nullptr; + for (auto p = parentWidget(); p; p = p->parentWidget()) { + if (const auto candidate = dynamic_cast(p)) { + box = candidate; + break; + } + } + if (!box) { + return; + } const auto identifier = StickerSetIdentifier{ .id = _setId, .accessHash = _setAccessHash, @@ -2360,8 +2371,31 @@ void StickerSetBox::Inner::startAddExistingStickerFlow() { }; const auto session = _session; const auto show = _show; - const auto onChosen = crl::guard(this, [=, this]( - not_null document) { + + _pickerDropdown = base::make_unique_q(box); + const auto dropdown = _pickerDropdown.get(); + dropdown->setAutoHiding(false); + dropdown->setMaxHeight(st::stickersMaxHeight); + + auto descriptor = ChatHelpers::StickersListDescriptor{ + .show = _show, + .mode = ChatHelpers::StickersListMode::UserpicBuilder, + .paused = [] { return false; }, + .excludeSetId = _setId, + }; + const auto list = dropdown->setOwnedWidget( + object_ptr( + dropdown, + std::move(descriptor))); + list->refreshRecent(); + list->refreshStickers(); + + list->chosen( + ) | rpl::on_next([=, this](const ChatHelpers::FileChosen &chosen) { + const auto document = chosen.document; + if (_pickerDropdown) { + _pickerDropdown->hideAnimated(); + } const auto sticker = document->sticker(); const auto fallback = QString::fromUtf8("\xF0\x9F\x99\x82"); const auto emoji = (sticker && !sticker->alt.isEmpty()) @@ -2382,8 +2416,15 @@ void StickerSetBox::Inner::startAddExistingStickerFlow() { ? tr::lng_attach_failed(tr::now) : err); })); - }); - _show->showBox(Box(_show, onChosen)); + }, list->lifetime()); + + const auto desiredWidth = box->width(); + list->resizeToWidth(desiredWidth); + + dropdown->resize(desiredWidth, st::stickersMaxHeight); + dropdown->move(0, 0); + dropdown->setOrigin(Ui::PanelAnimation::Origin::TopLeft); + dropdown->showAnimated(); } void StickerSetBox::Inner::startCreateNewStickerFlow() { diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp index 79172d23ad..0843ed7ccd 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp @@ -213,6 +213,7 @@ StickersListWidget::StickersListWidget( , _section(Section::Stickers) , _isMasks(_mode == Mode::Masks) , _isEffects(_mode == Mode::MessageEffects) +, _excludeSetId(descriptor.excludeSetId) , _updateItemsTimer([=] { updateItems(); }) , _updateSetsTimer([=] { updateSets(); }) , _trendingAddBgOver( @@ -2578,6 +2579,9 @@ bool StickersListWidget::appendSet( uint64 setId, bool externalLayout, AppendSkip skip) { + if (_excludeSetId && setId == _excludeSetId) { + return false; + } const auto &sets = session().data().stickers().sets(); auto it = sets.find(setId); if (it == sets.cend() diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h index ad9f514c24..f28369fcf9 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h @@ -84,6 +84,7 @@ struct StickersListDescriptor { std::vector customRecentList; const style::EmojiPan *st = nullptr; ComposeFeatures features; + uint64 excludeSetId = 0; }; class StickersListWidget final : public TabbedSelector::Inner { @@ -419,6 +420,7 @@ private: Section _section = Section::Stickers; const bool _isMasks; const bool _isEffects; + const uint64 _excludeSetId = 0; base::Timer _updateItemsTimer; base::Timer _updateSetsTimer; From 1d035109ea6e42cb95de8e0abad9b917f0f9da76 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 17:13:22 +0300 Subject: [PATCH 60/78] Replaced sticker emoji picker row with dropdown panel. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 319 ++++++++++++------ .../chat_helpers/chat_helpers.style | 1 + .../SourceFiles/chat_helpers/tabbed_panel.cpp | 11 +- .../SourceFiles/chat_helpers/tabbed_panel.h | 6 +- .../chat_helpers/tabbed_selector.cpp | 2 + .../chat_helpers/tabbed_selector.h | 2 + 6 files changed, 228 insertions(+), 113 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index 0c28b19cd1..a94ffdcfcc 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -8,8 +8,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/sticker_creator_box.h" #include "api/api_stickers_creator.h" +#include "base/event_filter.h" #include "chat_helpers/compose/compose_show.h" -#include "chat_helpers/emoji_list_widget.h" +#include "chat_helpers/tabbed_panel.h" #include "chat_helpers/tabbed_selector.h" #include "core/file_utilities.h" #include "editor/editor_layer_widget.h" @@ -81,49 +82,11 @@ private: }; -void ShowEmojiPickerBox( - not_null box, - std::shared_ptr show, - Fn chosen) { - box->setTitle(tr::lng_stickers_pack_choose_emoji_title()); - box->addRow( - object_ptr( - box, - tr::lng_stickers_pack_choose_emoji_about(), - st::boxLabel)); - - const auto selector = box->addRow( - object_ptr( - box, - ChatHelpers::EmojiListDescriptor{ - .show = show, - .mode = ChatHelpers::EmojiListMode::TopicIcon, - .paused = [] { return false; }, - .st = &st::reactPanelEmojiPan, - }), - QMargins()); - selector->refreshEmoji(); - - selector->chosen( - ) | rpl::on_next([=, weak = base::make_weak(box.get())]( - const ChatHelpers::EmojiChosen &result) { - if (chosen) { - chosen(result.emoji); - } - if (const auto strong = weak.get()) { - strong->closeBox(); - } - }, selector->lifetime()); - - box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); - box->setMaxHeight(st::stickersMaxHeight); -} - class ActionButton final : public Ui::AbstractButton { public: enum class Kind { - Plus, - Minus, + Emoji, + Delete, }; ActionButton(QWidget *parent, Kind kind) @@ -140,32 +103,73 @@ protected: auto hq = PainterHighQualityEnabler(p); const auto size = st::stickersCreatorActionSize; const auto rect = QRect(0, 0, size, size); - const auto bgAlpha = isOver() ? 0.22 : 0.12; - p.setPen(Qt::NoPen); - p.setBrush(anim::with_alpha(st::windowSubTextFg->c, bgAlpha)); - p.drawEllipse(rect); - p.setBrush(st::windowSubTextFg->c); - const auto thickness = st::stickersAddCellPlusThickness; - const auto glyphHalf = st::stickersCreatorEmojiSize / 4; - const auto center = rect.center() + QPointF(0.5, 0.5); - const auto horizontal = QRectF( - center.x() - glyphHalf, - center.y() - thickness / 2., - glyphHalf * 2, - thickness); - const auto radius = thickness / 2.; - p.drawRoundedRect(horizontal, radius, radius); - if (_kind == Kind::Plus) { - const auto vertical = QRectF( - center.x() - thickness / 2., - center.y() - glyphHalf, - thickness, - glyphHalf * 2); - p.drawRoundedRect(vertical, radius, radius); + if (isOver()) { + p.setPen(Qt::NoPen); + p.setBrush(anim::with_alpha(st::windowSubTextFg->c, 0.12)); + p.drawEllipse(rect); + } + + if (_kind == Kind::Emoji) { + const auto &icon = st::stickersCreatorEmojiIcon; + const auto iconX = (size - icon.width()) / 2; + const auto iconY = (size - icon.height()) / 2; + icon.paint(p, iconX, iconY, size); + + const auto line = style::ConvertScaleExact( + st::historyEmojiCircleLine); + auto pen = st::windowSubTextFg->p; + pen.setWidthF(line); + pen.setCapStyle(Qt::RoundCap); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + const auto skipX = icon.width() / 4; + const auto skipY = icon.height() / 4; + p.drawEllipse(QRectF( + iconX + skipX, + iconY + skipY, + icon.width() - 2 * skipX, + icon.height() - 2 * skipY)); + } else { + paintBackspaceGlyph(p, rect); } } private: + void paintBackspaceGlyph(QPainter &p, QRect rect) { + const auto glyphW = style::ConvertScaleExact(18.); + const auto glyphH = style::ConvertScaleExact(13.); + const auto x = rect.x() + (rect.width() - glyphW) / 2.; + const auto y = rect.y() + (rect.height() - glyphH) / 2.; + const auto cornerCut = style::ConvertScaleExact(5.); + const auto stroke = style::ConvertScaleExact(1.5); + + auto pen = st::windowSubTextFg->p; + pen.setWidthF(stroke); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + + auto path = QPainterPath(); + path.moveTo(x, y + glyphH / 2.); + path.lineTo(x + cornerCut, y); + path.lineTo(x + glyphW, y); + path.lineTo(x + glyphW, y + glyphH); + path.lineTo(x + cornerCut, y + glyphH); + path.closeSubpath(); + p.drawPath(path); + + const auto cx = x + (cornerCut + glyphW) / 2. + stroke; + const auto cy = y + glyphH / 2.; + const auto half = style::ConvertScaleExact(2.5); + p.drawLine( + QPointF(cx - half, cy - half), + QPointF(cx + half, cy + half)); + p.drawLine( + QPointF(cx - half, cy + half), + QPointF(cx + half, cy - half)); + } + const Kind _kind; }; @@ -174,7 +178,8 @@ class EmojiPickerRow final : public Ui::RpWidget { public: EmojiPickerRow( QWidget *parent, - std::shared_ptr show); + std::shared_ptr show, + not_null panelContainer); [[nodiscard]] QString value() const; [[nodiscard]] rpl::producer countValue() const; @@ -185,28 +190,34 @@ protected: private: void relayout(); - void openPicker(); + void ensurePanel(); + void togglePanel(); + void updatePanelGeometry(); void addEmoji(EmojiPtr emoji); void removeLast(); [[nodiscard]] int rowContentWidth() const; const std::shared_ptr _show; + const not_null _panelContainer; std::vector _emojis; rpl::variable _count; ActionButton *_plus = nullptr; ActionButton *_minus = nullptr; + base::unique_qptr _panel; }; EmojiPickerRow::EmojiPickerRow( QWidget *parent, - std::shared_ptr show) + std::shared_ptr show, + not_null panelContainer) : RpWidget(parent) , _show(std::move(show)) -, _plus(Ui::CreateChild(this, ActionButton::Kind::Plus)) -, _minus(Ui::CreateChild(this, ActionButton::Kind::Minus)) { +, _panelContainer(panelContainer) +, _plus(Ui::CreateChild(this, ActionButton::Kind::Emoji)) +, _minus(Ui::CreateChild(this, ActionButton::Kind::Delete)) { resize(width(), st::stickersCreatorRowHeight); - _plus->setClickedCallback([=] { openPicker(); }); + _plus->setClickedCallback([=] { togglePanel(); }); _minus->setClickedCallback([=] { removeLast(); }); _minus->hide(); } @@ -224,65 +235,93 @@ rpl::producer EmojiPickerRow::countValue() const { } int EmojiPickerRow::rowContentWidth() const { - const auto emojiPart = int(_emojis.size()) - * (st::stickersCreatorEmojiSize + st::stickersCreatorEmojiSkip); - const auto plusPart = st::stickersCreatorActionSize; - const auto minusPart = _emojis.empty() - ? 0 - : (st::stickersCreatorActionSize - + st::stickersCreatorActionMargin); - const auto margin = _emojis.empty() - ? 0 - : st::stickersCreatorActionMargin; - return emojiPart + plusPart + minusPart + margin; + const auto count = int(_emojis.size()); + const auto plusVisible = (count < kMaxEmojis); + const auto minusVisible = (count > 0); + auto width = 0; + if (plusVisible) { + width += st::stickersCreatorActionSize; + } + if (count > 0) { + if (plusVisible) { + width += st::stickersCreatorActionMargin; + } + width += count * st::stickersCreatorEmojiSize + + (count - 1) * st::stickersCreatorEmojiSkip; + if (minusVisible) { + width += st::stickersCreatorActionMargin; + } + } + if (minusVisible) { + width += st::stickersCreatorActionSize; + } + return width; } void EmojiPickerRow::relayout() { + const auto count = int(_emojis.size()); + const auto plusVisible = (count < kMaxEmojis); + const auto minusVisible = (count > 0); const auto contentWidth = rowContentWidth(); const auto offsetX = (width() - contentWidth) / 2; const auto centerY = height() / 2; + const auto top = centerY - st::stickersCreatorActionSize / 2; auto x = offsetX; - if (!_emojis.empty()) { - _minus->move( - x, - centerY - st::stickersCreatorActionSize / 2); + if (plusVisible) { + _plus->move(x, top); + _plus->show(); + x += st::stickersCreatorActionSize; + } else { + _plus->hide(); + } + + if (count > 0) { + if (plusVisible) { + x += st::stickersCreatorActionMargin; + } + x += count * st::stickersCreatorEmojiSize + + (count - 1) * st::stickersCreatorEmojiSkip; + if (minusVisible) { + x += st::stickersCreatorActionMargin; + } + } + + if (minusVisible) { + _minus->move(x, top); _minus->show(); - x += st::stickersCreatorActionSize + st::stickersCreatorActionMargin; } else { _minus->hide(); } - x += int(_emojis.size()) - * (st::stickersCreatorEmojiSize + st::stickersCreatorEmojiSkip); - if (!_emojis.empty()) { - x += st::stickersCreatorActionMargin - - st::stickersCreatorEmojiSkip; - } - - _plus->move(x, centerY - st::stickersCreatorActionSize / 2); - _plus->setVisible(int(_emojis.size()) < kMaxEmojis); - update(); } void EmojiPickerRow::paintEvent(QPaintEvent *e) { auto p = QPainter(this); - if (_emojis.empty()) { + const auto count = int(_emojis.size()); + if (!count) { return; } + const auto esize = Ui::Emoji::GetSizeLarge(); + const auto size = esize / style::DevicePixelRatio(); const auto contentWidth = rowContentWidth(); const auto offsetX = (width() - contentWidth) / 2; const auto centerY = height() / 2; + const auto plusVisible = (count < kMaxEmojis); - auto x = offsetX - + st::stickersCreatorActionSize - + st::stickersCreatorActionMargin; - const auto y = centerY - st::stickersCreatorEmojiSize / 2; + auto x = offsetX; + if (plusVisible) { + x += st::stickersCreatorActionSize + + st::stickersCreatorActionMargin; + } + const auto slot = st::stickersCreatorEmojiSize; + const auto extra = (slot - size) / 2; + const auto y = centerY - size / 2; for (const auto emoji : _emojis) { - Ui::Emoji::Draw(p, emoji, st::stickersCreatorEmojiSize, x, y); - x += st::stickersCreatorEmojiSize + st::stickersCreatorEmojiSkip; + Ui::Emoji::Draw(p, emoji, esize, x + extra, y); + x += slot + st::stickersCreatorEmojiSkip; } } @@ -290,11 +329,68 @@ void EmojiPickerRow::resizeEvent(QResizeEvent *e) { relayout(); } -void EmojiPickerRow::openPicker() { - const auto addOne = crl::guard(this, [=](EmojiPtr emoji) { - addEmoji(emoji); +void EmojiPickerRow::ensurePanel() { + if (_panel) { + return; + } + using Selector = ChatHelpers::TabbedSelector; + _panel = base::make_unique_q( + _panelContainer.get(), + ChatHelpers::TabbedPanelDescriptor{ + .ownedSelector = object_ptr( + nullptr, + ChatHelpers::TabbedSelectorDescriptor{ + .show = _show, + .st = st::defaultComposeControls.tabbed, + .level = Window::GifPauseReason::Layer, + .mode = Selector::Mode::PeerTitle, + }), + }); + _panel->setDesiredHeightValues( + 1., + st::emojiPanMinHeight / 2, + st::emojiPanMinHeight); + _panel->setDropDown(true); + _panel->setShowAnimationOrigin(Ui::PanelAnimation::Origin::TopLeft); + _panel->hide(); + + _panel->selector()->emojiChosen( + ) | rpl::on_next([=](ChatHelpers::EmojiChosen data) { + addEmoji(data.emoji); + _panel->hideAnimated(); + }, _panel->lifetime()); + + base::install_event_filter(this, _panelContainer, [=]( + not_null event) { + const auto type = event->type(); + if (type == QEvent::Move || type == QEvent::Resize) { + crl::on_main(this, [=] { updatePanelGeometry(); }); + } + return base::EventFilterResult::Continue; }); - _show->showBox(Box(ShowEmojiPickerBox, _show, addOne)); +} + +void EmojiPickerRow::updatePanelGeometry() { + if (!_panel) { + return; + } + const auto container = _panelContainer->size(); + const auto margins = st::emojiPanMargins; + const auto panelWidth = st::emojiPanWidth + + margins.left() + + margins.right(); + const auto panelHeight = st::emojiPanMinHeight + + margins.top() + + margins.bottom(); + const auto top = std::max(0, (container.height() - panelHeight) / 2); + const auto right = (container.width() + panelWidth) / 2; + _panel->moveTopRight(top, right); +} + +void EmojiPickerRow::togglePanel() { + ensurePanel(); + updatePanelGeometry(); + _panel->toggleAnimated(); } void EmojiPickerRow::addEmoji(EmojiPtr emoji) { @@ -446,9 +542,18 @@ void StickerCreatorBox::prepare() { tr::lng_stickers_create_choose_emoji(), st::boxLabel), st::boxRowPadding); + inner->add( + object_ptr( + inner, + tr::lng_stickers_pack_choose_emoji_about(), + st::boxDividerLabel), + st::boxRowPadding); const auto emojiRow = inner->add( - object_ptr(inner, _show), + object_ptr( + inner, + _show, + getDelegate()->outerContainer()), QMargins(0, 0, 0, st::boxRowPadding.left())); emojiRow->resize(st::boxWideWidth, st::stickersCreatorRowHeight); _emojiValue = [=] { return emojiRow->value(); }; diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index b39905f5cf..a334e41bab 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -433,6 +433,7 @@ stickersCreatorEmojiSkip: 8px; stickersCreatorRowHeight: 48px; stickersCreatorActionSize: 32px; stickersCreatorActionMargin: 12px; +stickersCreatorEmojiIcon: icon {{ "chat/input_smile_face", windowSubTextFg }}; emojiStatusDefault: icon {{ "emoji/stickers_premium", emojiIconFg }}; diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_panel.cpp b/Telegram/SourceFiles/chat_helpers/tabbed_panel.cpp index 713efc1111..0a265988c2 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_panel.cpp +++ b/Telegram/SourceFiles/chat_helpers/tabbed_panel.cpp @@ -192,6 +192,10 @@ void TabbedPanel::setDesiredHeightValues( updateContentHeight(); } +void TabbedPanel::setShowAnimationOrigin(Ui::PanelAnimation::Origin origin) { + _showAnimationOrigin = origin; +} + void TabbedPanel::setDropDown(bool dropDown) { selector()->setDropDown(dropDown); _dropDown = dropDown; @@ -380,11 +384,12 @@ void TabbedPanel::startShowAnimation() { if (!_a_show.animating()) { auto image = grabForAnimation(); + const auto origin = _showAnimationOrigin.value_or(_dropDown + ? Ui::PanelAnimation::Origin::TopRight + : Ui::PanelAnimation::Origin::BottomRight); _showAnimation = std::make_unique( _selector->st().showAnimation, - (_dropDown - ? Ui::PanelAnimation::Origin::TopRight - : Ui::PanelAnimation::Origin::BottomRight)); + origin); auto inner = rect().marginsRemoved(st::emojiPanMargins); _showAnimation->setFinalImage( std::move(image), diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_panel.h b/Telegram/SourceFiles/chat_helpers/tabbed_panel.h index 29c9c43ed2..2e4c306851 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_panel.h +++ b/Telegram/SourceFiles/chat_helpers/tabbed_panel.h @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "ui/effects/animations.h" +#include "ui/effects/panel_animation.h" #include "ui/rp_widget.h" #include "ui/widgets/shadow.h" #include "base/timer.h" @@ -17,9 +18,6 @@ namespace Window { class SessionController; } // namespace Window -namespace Ui { -class PanelAnimation; -} // namespace Ui namespace ChatHelpers { @@ -57,6 +55,7 @@ public: int minHeight, int maxHeight); void setDropDown(bool dropDown); + void setShowAnimationOrigin(Ui::PanelAnimation::Origin origin); void hideFast(); bool hiding() const { @@ -122,6 +121,7 @@ private: bool _shouldFinishHide = false; bool _dropDown = false; + std::optional _showAnimationOrigin; bool _hiding = false; bool _hideAfterSlide = false; diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp index a44da34ace..1a6c78a268 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp @@ -381,6 +381,7 @@ TabbedSelector::TabbedSelector( , _show(std::move(descriptor.show)) , _level(descriptor.level) , _customTextColor(std::move(descriptor.customTextColor)) +, _excludeStickerSetId(descriptor.excludeStickerSetId) , _mode(descriptor.mode) , _panelRounding(Ui::PrepareCornerPixmaps(st::emojiPanRadius, _st.bg)) , _categoriesRounding( @@ -644,6 +645,7 @@ TabbedSelector::Tab TabbedSelector::createTab(SelectorTab type, int index) { .paused = paused, .st = &_st, .features = _features, + .excludeSetId = _excludeStickerSetId, }); } case SelectorTab::Gifs: { diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.h b/Telegram/SourceFiles/chat_helpers/tabbed_selector.h index 903da475b1..03c93bffd1 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.h +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.h @@ -100,6 +100,7 @@ struct TabbedSelectorDescriptor { TabbedSelectorMode mode = TabbedSelectorMode::Full; Fn customTextColor; ComposeFeatures features; + uint64 excludeStickerSetId = 0; }; enum class TabbedSearchType { @@ -295,6 +296,7 @@ private: const std::shared_ptr _show; const PauseReason _level = {}; const Fn _customTextColor; + const uint64 _excludeStickerSetId = 0; Ui::Controls::SwipeBackResult _swipeBackData; From 58c74677206216aae0d845910f69ff3555a623d9 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 20:10:35 +0300 Subject: [PATCH 61/78] [img-editor] Locked crop frame as stencil and clipped output by shape. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 1 + Telegram/SourceFiles/editor/editor_crop.cpp | 17 ++++++++ Telegram/SourceFiles/editor/photo_editor.cpp | 1 + .../editor/photo_editor_common.cpp | 39 ++++++++++++++++++- .../SourceFiles/editor/photo_editor_common.h | 26 +++++++------ 5 files changed, 71 insertions(+), 13 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index a94ffdcfcc..5765a4b330 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -475,6 +475,7 @@ void OpenPhotoEditorForSticker( .exactSize = QSize(kStickerSide, kStickerSide), .cropType = Editor::EditorData::CropType::RoundedRect, .keepAspectRatio = true, + .fixedCrop = true, }); const auto raw = editor.get(); diff --git a/Telegram/SourceFiles/editor/editor_crop.cpp b/Telegram/SourceFiles/editor/editor_crop.cpp index 0fd553768f..df2e7422d7 100644 --- a/Telegram/SourceFiles/editor/editor_crop.cpp +++ b/Telegram/SourceFiles/editor/editor_crop.cpp @@ -177,6 +177,10 @@ void Crop::paintFrame(QPainter &p) { p.save(); p.setRenderHint(QPainter::Antialiasing, true); p.fillPath(frameShape, st::photoCropPointFg); + if (_data.fixedCrop) { + p.restore(); + return; + } { const auto cornerLength = std::min( float64(st::photoEditorCropPointSize * 2), @@ -286,6 +290,10 @@ void Crop::convertCropPaintToOriginal() { } void Crop::updateEdges() { + if (_data.fixedCrop) { + _edges.clear(); + return; + } const auto &s = _pointSize; const auto &m = _edgePointMargins; const auto &r = _cropPaint; @@ -338,6 +346,9 @@ Qt::Edges Crop::mouseState(const QPoint &p) { } void Crop::mousePressEvent(QMouseEvent *e) { + if (_data.fixedCrop) { + return; + } computeDownState(e->pos()); if (_down.edge) { setGridVisible(true, false); @@ -345,6 +356,9 @@ void Crop::mousePressEvent(QMouseEvent *e) { } void Crop::mouseReleaseEvent(QMouseEvent *e) { + if (_data.fixedCrop) { + return; + } const auto hadEdge = bool(_down.edge); if (hadEdge) { setGridVisible(false, true); @@ -474,6 +488,9 @@ void Crop::performMove(const QPoint &pos) { } void Crop::mouseMoveEvent(QMouseEvent *e) { + if (_data.fixedCrop) { + return; + } const auto pos = e->pos(); const auto pressedEdge = _down.edge; diff --git a/Telegram/SourceFiles/editor/photo_editor.cpp b/Telegram/SourceFiles/editor/photo_editor.cpp index 989ff8c40b..ac24d71a4d 100644 --- a/Telegram/SourceFiles/editor/photo_editor.cpp +++ b/Telegram/SourceFiles/editor/photo_editor.cpp @@ -237,6 +237,7 @@ PhotoEditor::PhotoEditor( std::move(show), _brushes, _brushTool)) { + _modifications.cropType = data.cropType; sizeValue( ) | rpl::on_next([=](const QSize &size) { diff --git a/Telegram/SourceFiles/editor/photo_editor_common.cpp b/Telegram/SourceFiles/editor/photo_editor_common.cpp index 4fd72ac829..cf95c04f43 100644 --- a/Telegram/SourceFiles/editor/photo_editor_common.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_common.cpp @@ -9,8 +9,40 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene.h" #include "ui/painter.h" +#include "ui/userpic_view.h" namespace Editor { +namespace { + +void ApplyShapeMask(QImage &image, EditorData::CropType type) { + if (type == EditorData::CropType::Rect) { + return; + } + if (image.format() != QImage::Format_ARGB32_Premultiplied) { + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + auto mask = QImage(image.size(), QImage::Format_ARGB32_Premultiplied); + mask.fill(Qt::transparent); + { + auto p = QPainter(&mask); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + const auto rect = QRectF(QPointF(), QSizeF(image.size())); + if (type == EditorData::CropType::Ellipse) { + p.drawEllipse(rect); + } else { + const auto radius = std::min(rect.width(), rect.height()) + * Ui::ForumUserpicRadiusMultiplier(); + p.drawRoundedRect(rect, radius, radius); + } + } + auto p = QPainter(&image); + p.setCompositionMode(QPainter::CompositionMode_DestinationIn); + p.drawImage(0, 0, mask); +} + +} // namespace QImage ImageModified(QImage image, const PhotoModifications &mods) { Expects(!image.isNull()); @@ -32,6 +64,7 @@ QImage ImageModified(QImage image, const PhotoModifications &mods) { auto cropped = mods.crop.isValid() ? image.copy(mods.crop) : image; + ApplyShapeMask(cropped, mods.cropType); QTransform transform; if (mods.flipped) { transform.scale(-1, 1); @@ -43,7 +76,11 @@ QImage ImageModified(QImage image, const PhotoModifications &mods) { } bool PhotoModifications::empty() const { - return !angle && !flipped && !crop.isValid() && !paint; + return !angle + && !flipped + && !crop.isValid() + && cropType == EditorData::CropType::Rect + && !paint; } PhotoModifications::operator bool() const { diff --git a/Telegram/SourceFiles/editor/photo_editor_common.h b/Telegram/SourceFiles/editor/photo_editor_common.h index b52b2110a7..55b11af658 100644 --- a/Telegram/SourceFiles/editor/photo_editor_common.h +++ b/Telegram/SourceFiles/editor/photo_editor_common.h @@ -11,18 +11,6 @@ namespace Editor { class Scene; -struct PhotoModifications { - int angle = 0; - bool flipped = false; - QRect crop; - std::shared_ptr paint = nullptr; - - [[nodiscard]] bool empty() const; - [[nodiscard]] explicit operator bool() const; - ~PhotoModifications(); - -}; - struct EditorData { enum class CropType { Rect, @@ -35,6 +23,20 @@ struct EditorData { QSize exactSize; CropType cropType = CropType::Rect; bool keepAspectRatio = false; + bool fixedCrop = false; +}; + +struct PhotoModifications { + int angle = 0; + bool flipped = false; + QRect crop; + EditorData::CropType cropType = EditorData::CropType::Rect; + std::shared_ptr paint = nullptr; + + [[nodiscard]] bool empty() const; + [[nodiscard]] explicit operator bool() const; + ~PhotoModifications(); + }; [[nodiscard]] QImage ImageModified( From 3697e1b67482a53cdda76977049981eb1bf6b148 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 20:32:51 +0300 Subject: [PATCH 62/78] Replaced existing-sticker picker with centered tabbed panel dropdown. --- .../SourceFiles/boxes/sticker_set_box.cpp | 102 +++++++++++------- 1 file changed, 62 insertions(+), 40 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index 8b74f0bb41..0845973981 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -56,6 +56,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/gradient_round_button.h" #include "ui/widgets/menu/menu_add_action_callback.h" #include "ui/widgets/menu/menu_add_action_callback_factory.h" +#include "base/event_filter.h" +#include "chat_helpers/tabbed_panel.h" +#include "chat_helpers/tabbed_selector.h" #include "ui/widgets/inner_dropdown.h" #include "ui/widgets/popup_menu.h" #include "ui/widgets/scroll_area.h" @@ -329,6 +332,7 @@ public: } void applySet(const TLStickerSet &set); + void setOuterContainer(QPointer container); ~Inner(); @@ -488,7 +492,8 @@ private: bool _previewLocked = false; base::unique_qptr _menu; - base::unique_qptr _pickerDropdown; + base::unique_qptr _pickerPanel; + QPointer _outerContainer; rpl::event_stream _setInstalled; rpl::event_stream _setArchived; @@ -542,6 +547,7 @@ void StickerSetBox::prepare() { _inner = setInnerWidget( object_ptr(this, _show, _set, _type), st::stickersScroll); + _inner->setOuterContainer(getDelegate()->outerContainer()); if (const auto previewId = base::take(_previewDocumentId)) { _inner->showPreviewForDocument(previewId); } @@ -2350,20 +2356,15 @@ void StickerSetBox::Inner::showAddMenu(QPoint globalPos) { _menu->popup(globalPos); } +void StickerSetBox::Inner::setOuterContainer(QPointer container) { + _outerContainer = std::move(container); +} + void StickerSetBox::Inner::startAddExistingStickerFlow() { - if (!hasAddCell()) { - return; - } - auto box = (Ui::BoxContent*)nullptr; - for (auto p = parentWidget(); p; p = p->parentWidget()) { - if (const auto candidate = dynamic_cast(p)) { - box = candidate; - break; - } - } - if (!box) { + if (!hasAddCell() || !_outerContainer) { return; } + const auto container = _outerContainer.data(); const auto identifier = StickerSetIdentifier{ .id = _setId, .accessHash = _setAccessHash, @@ -2372,29 +2373,34 @@ void StickerSetBox::Inner::startAddExistingStickerFlow() { const auto session = _session; const auto show = _show; - _pickerDropdown = base::make_unique_q(box); - const auto dropdown = _pickerDropdown.get(); - dropdown->setAutoHiding(false); - dropdown->setMaxHeight(st::stickersMaxHeight); + using Selector = ChatHelpers::TabbedSelector; + _pickerPanel = base::make_unique_q( + container, + ChatHelpers::TabbedPanelDescriptor{ + .ownedSelector = object_ptr( + nullptr, + ChatHelpers::TabbedSelectorDescriptor{ + .show = _show, + .st = st::defaultComposeControls.tabbed, + .level = Window::GifPauseReason::Layer, + .mode = Selector::Mode::StickersOnly, + .excludeStickerSetId = _setId, + }), + }); + const auto panel = _pickerPanel.get(); + panel->setDesiredHeightValues( + 1., + st::emojiPanMinHeight / 2, + st::emojiPanMinHeight); + panel->setDropDown(true); + panel->setShowAnimationOrigin(Ui::PanelAnimation::Origin::TopLeft); + panel->hide(); - auto descriptor = ChatHelpers::StickersListDescriptor{ - .show = _show, - .mode = ChatHelpers::StickersListMode::UserpicBuilder, - .paused = [] { return false; }, - .excludeSetId = _setId, - }; - const auto list = dropdown->setOwnedWidget( - object_ptr( - dropdown, - std::move(descriptor))); - list->refreshRecent(); - list->refreshStickers(); - - list->chosen( + panel->selector()->fileChosen( ) | rpl::on_next([=, this](const ChatHelpers::FileChosen &chosen) { const auto document = chosen.document; - if (_pickerDropdown) { - _pickerDropdown->hideAnimated(); + if (_pickerPanel) { + _pickerPanel->hideAnimated(); } const auto sticker = document->sticker(); const auto fallback = QString::fromUtf8("\xF0\x9F\x99\x82"); @@ -2416,15 +2422,31 @@ void StickerSetBox::Inner::startAddExistingStickerFlow() { ? tr::lng_attach_failed(tr::now) : err); })); - }, list->lifetime()); + }, panel->lifetime()); - const auto desiredWidth = box->width(); - list->resizeToWidth(desiredWidth); - - dropdown->resize(desiredWidth, st::stickersMaxHeight); - dropdown->move(0, 0); - dropdown->setOrigin(Ui::PanelAnimation::Origin::TopLeft); - dropdown->showAnimated(); + const auto reposition = [=] { + const auto size = container->size(); + const auto margins = st::emojiPanMargins; + const auto panelWidth = st::emojiPanWidth + + margins.left() + + margins.right(); + const auto panelHeight = st::emojiPanMinHeight + + margins.top() + + margins.bottom(); + const auto top = std::max(0, (size.height() - panelHeight) / 2); + const auto right = (size.width() + panelWidth) / 2; + panel->moveTopRight(top, right); + }; + base::install_event_filter(panel, container, [=]( + not_null event) { + const auto type = event->type(); + if (type == QEvent::Move || type == QEvent::Resize) { + crl::on_main(panel, reposition); + } + return base::EventFilterResult::Continue; + }); + reposition(); + panel->showAnimated(); } void StickerSetBox::Inner::startCreateNewStickerFlow() { From 4e526476e3d22010375ff090765a1ed2f7be3ead Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 21:02:20 +0300 Subject: [PATCH 63/78] Replaced sticker creator box with generic box. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 262 +++++++++--------- .../SourceFiles/boxes/sticker_creator_box.h | 46 +-- 2 files changed, 142 insertions(+), 166 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index 5765a4b330..e0770b5d92 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -29,6 +29,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/layers/layer_widget.h" #include "ui/painter.h" #include "ui/rp_widget.h" +#include "ui/vertical_list.h" #include "ui/widgets/buttons.h" #include "ui/widgets/labels.h" #include "ui/widgets/scroll_area.h" @@ -501,92 +502,7 @@ void OpenPhotoEditorForSticker( Ui::LayerOption::KeepOther); } -} // namespace - -StickerCreatorBox::StickerCreatorBox( - QWidget*, - std::shared_ptr show, - StickerSetIdentifier set, - QImage image, - Fn done) -: _show(std::move(show)) -, _session(&_show->session()) -, _set(std::move(set)) -, _image(std::move(image)) -, _done(std::move(done)) { -} - -StickerCreatorBox::~StickerCreatorBox() = default; - -void StickerCreatorBox::prepare() { - setTitle(tr::lng_stickers_create_image_title()); - - const auto inner = setInnerWidget( - object_ptr(this)); - - const auto previewHolder = inner->add( - object_ptr(inner), - QMargins(0, st::boxRowPadding.left(), 0, st::boxRowPadding.left()), - style::al_top); - previewHolder->resize(st::boxWideWidth, kPreviewSide); - const auto preview = Ui::CreateChild( - previewHolder, - _image); - previewHolder->widthValue( - ) | rpl::on_next([=](int width) { - preview->move((width - kPreviewSide) / 2, 0); - }, preview->lifetime()); - - inner->add( - object_ptr( - inner, - tr::lng_stickers_create_choose_emoji(), - st::boxLabel), - st::boxRowPadding); - inner->add( - object_ptr( - inner, - tr::lng_stickers_pack_choose_emoji_about(), - st::boxDividerLabel), - st::boxRowPadding); - - const auto emojiRow = inner->add( - object_ptr( - inner, - _show, - getDelegate()->outerContainer()), - QMargins(0, 0, 0, st::boxRowPadding.left())); - emojiRow->resize(st::boxWideWidth, st::stickersCreatorRowHeight); - _emojiValue = [=] { return emojiRow->value(); }; - - const auto addButton = this->addButton( - rpl::conditional( - _uploading.value(), - rpl::single(QString()), - tr::lng_box_done()), - [=] { startUpload(); }); - this->addButton(tr::lng_cancel(), [=] { closeBox(); }); - - { - using namespace Info::Statistics; - const auto loadingAnimation = InfiniteRadialAnimationWidget( - addButton, - addButton->height() / 2, - &st::editStickerSetNameLoading); - AddChildToWidgetCenter(addButton, loadingAnimation); - loadingAnimation->showOn(_uploading.value()); - } - - setDimensionsToContent(st::boxWideWidth, inner); - - boxClosing( - ) | rpl::on_next([=, this] { - _upload = nullptr; - }, lifetime()); -} - -QByteArray StickerCreatorBox::encodeWebp() const { - auto image = _image; +[[nodiscard]] QByteArray EncodeWebp(QImage image) { if (image.size() != QSize(kStickerSide, kStickerSide)) { image = image.scaled( kStickerSide, @@ -604,51 +520,138 @@ QByteArray StickerCreatorBox::encodeWebp() const { return bytes; } -void StickerCreatorBox::startUpload() { - if (_uploading.current()) { - return; - } - const auto emoji = _emojiValue ? _emojiValue() : QString(); - if (emoji.isEmpty()) { - _show->showToast(tr::lng_stickers_create_emoji_required(tr::now)); - return; - } - const auto bytes = encodeWebp(); - if (bytes.isEmpty()) { - _show->showToast(tr::lng_stickers_create_upload_failed(tr::now)); - return; - } - - _uploading = true; - _upload = std::make_unique( - _session, - _set, - bytes, - emoji); - - const auto show = _show; - const auto doneCallback = _done; - _upload->start( - crl::guard(this, [=, this](MTPmessages_StickerSet result) { - _upload = nullptr; - _uploading = false; - show->showToast(tr::lng_stickers_create_added(tr::now)); - if (doneCallback) { - doneCallback(result); - } - closeBox(); - }), - crl::guard(this, [=, this](QString err) { - _upload = nullptr; - _uploading = false; - show->showToast(err.isEmpty() - ? tr::lng_stickers_create_upload_failed(tr::now) - : err); - })); -} +} // namespace namespace Api { +void CreateStickerBox( + not_null box, + std::shared_ptr show, + StickerSetIdentifier set, + QImage image, + Fn done) { + struct State { + rpl::variable uploading = false; + std::unique_ptr upload; + QPointer addButton; + }; + const auto state = box->lifetime().make_state(); + const auto session = &show->session(); + + box->setTitle(tr::lng_stickers_create_image_title()); + + const auto inner = box->verticalLayout(); + + const auto previewHolder = inner->add( + object_ptr(inner), + QMargins(0, 0, 0, 0), + // QMargins(0, st::boxRowPadding.left(), 0, st::boxRowPadding.left()), + style::al_top); + previewHolder->resize(st::boxWideWidth, kPreviewSide); + const auto preview = Ui::CreateChild( + previewHolder, + image); + previewHolder->widthValue( + ) | rpl::on_next([=](int width) { + preview->move((width - kPreviewSide) / 2, 0); + }, preview->lifetime()); + + Ui::AddSkip(inner); + Ui::AddSkip(inner); + + inner->add( + object_ptr( + inner, + tr::lng_stickers_pack_choose_emoji_about(), + st::boxDividerLabel), + st::boxRowPadding); + + const auto emojiRow = inner->add( + object_ptr( + inner, + show, + box->getDelegate()->outerContainer()), + QMargins(0, 0, 0, st::boxRowPadding.left())); + emojiRow->resize(st::boxWideWidth, st::stickersCreatorRowHeight); + + const auto startUpload = [=, set = std::move(set), done = std::move(done)]( + ) mutable { + if (state->uploading.current()) { + return; + } + const auto emoji = emojiRow->value(); + if (emoji.isEmpty()) { + show->showToast( + tr::lng_stickers_create_emoji_required(tr::now)); + return; + } + const auto bytes = EncodeWebp(image); + if (bytes.isEmpty()) { + show->showToast( + tr::lng_stickers_create_upload_failed(tr::now)); + return; + } + + const auto lockedWidth = state->addButton + ? state->addButton->width() + : 0; + state->uploading = true; + if (state->addButton && lockedWidth > 0) { + state->addButton->resizeToWidth(lockedWidth); + } + state->upload = std::make_unique( + session, + set, + bytes, + emoji); + + const auto doneCallback = done; + state->upload->start( + crl::guard(box, [=](MTPmessages_StickerSet result) { + state->upload = nullptr; + state->uploading = false; + show->showToast(tr::lng_stickers_create_added(tr::now)); + if (doneCallback) { + doneCallback(result); + } + box->closeBox(); + }), + crl::guard(box, [=](QString err) { + state->upload = nullptr; + state->uploading = false; + show->showToast(err.isEmpty() + ? tr::lng_stickers_create_upload_failed(tr::now) + : err); + })); + }; + + const auto addButton = box->addButton( + rpl::conditional( + state->uploading.value(), + rpl::single(QString()), + tr::lng_box_done()), + startUpload); + state->addButton = addButton; + box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); + + { + using namespace Info::Statistics; + const auto loadingAnimation = InfiniteRadialAnimationWidget( + addButton, + addButton->height() / 2, + &st::editStickerSetNameLoading); + AddChildToWidgetCenter(addButton, loadingAnimation); + loadingAnimation->showOn(state->uploading.value()); + } + + box->setWidth(st::boxWideWidth); + + box->boxClosing( + ) | rpl::on_next([=] { + state->upload = nullptr; + }, box->lifetime()); +} + void OpenCreateStickerFlow( std::shared_ptr show, StickerSetIdentifier set, @@ -671,7 +674,8 @@ void OpenCreateStickerFlow( std::move(image), [=, set = std::move(set), done = std::move(done)]( QImage &&prepared) mutable { - show->showBox(Box( + show->showBox(Box( + CreateStickerBox, show, std::move(set), std::move(prepared), diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.h b/Telegram/SourceFiles/boxes/sticker_creator_box.h index b973227fda..75c2a3648a 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.h +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.h @@ -8,7 +8,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "data/stickers/data_stickers.h" -#include "ui/layers/box_content.h" #include @@ -16,45 +15,18 @@ namespace ChatHelpers { class Show; } // namespace ChatHelpers -namespace Main { -class Session; -} // namespace Main +namespace Ui { +class GenericBox; +} // namespace Ui namespace Api { -class StickerUpload; -} // namespace Api -class StickerCreatorBox final : public Ui::BoxContent { -public: - StickerCreatorBox( - QWidget*, - std::shared_ptr show, - StickerSetIdentifier set, - QImage image, - Fn done); - ~StickerCreatorBox(); - -protected: - void prepare() override; - -private: - void startUpload(); - [[nodiscard]] QByteArray encodeWebp() const; - - const std::shared_ptr _show; - const not_null _session; - const StickerSetIdentifier _set; - const QImage _image; - const Fn _done; - - rpl::variable _uploading = false; - Fn _emojiValue; - - std::unique_ptr _upload; - -}; - -namespace Api { +void CreateStickerBox( + not_null box, + std::shared_ptr show, + StickerSetIdentifier set, + QImage image, + Fn done); void OpenCreateStickerFlow( std::shared_ptr show, From 4187b3a3c91a6fdf9c8c665992757a486e56c319 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Tue, 14 Apr 2026 08:01:45 +0300 Subject: [PATCH 64/78] Added emoji picker overlay widget with strip and expanded grid. --- Telegram/CMakeLists.txt | 2 + Telegram/Resources/langs/lang.strings | 1 + .../chat_helpers/chat_helpers.style | 31 + .../chat_helpers/emoji_picker_overlay.cpp | 556 ++++++++++++++++++ .../chat_helpers/emoji_picker_overlay.h | 78 +++ 5 files changed, 668 insertions(+) create mode 100644 Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp create mode 100644 Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index a7ca2c4d00..9ac95495be 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -473,6 +473,8 @@ PRIVATE chat_helpers/emoji_keywords.h chat_helpers/emoji_list_widget.cpp chat_helpers/emoji_list_widget.h + chat_helpers/emoji_picker_overlay.cpp + chat_helpers/emoji_picker_overlay.h chat_helpers/emoji_sets_manager.cpp chat_helpers/emoji_sets_manager.h chat_helpers/emoji_suggestions_widget.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 55fd289d4b..8fa9c856ea 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -4521,6 +4521,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_stickers_create_open_failed" = "Could not load image."; "lng_stickers_create_too_small" = "The image must be at least {size} pixels on each side."; "lng_stickers_create_choose_emoji" = "Choose an emoji that corresponds to your sticker:"; +"lng_stickers_create_emoji_about" = "Choose emojis that match your sticker"; "lng_stickers_create_emoji_required" = "Please choose an emoji."; "lng_stickers_create_uploading" = "Uploading sticker…"; "lng_stickers_create_upload_failed" = "Sticker upload failed. Please try again."; diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index a334e41bab..a10bb9be5f 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -435,6 +435,37 @@ stickersCreatorActionSize: 32px; stickersCreatorActionMargin: 12px; stickersCreatorEmojiIcon: icon {{ "chat/input_smile_face", windowSubTextFg }}; +stickersEmojiPickerRadius: 16px; +stickersEmojiPickerBg: emojiPanBg; +stickersEmojiPickerShadow: windowShadowFg; +stickersEmojiPickerPadding: margins(12px, 10px, 12px, 10px); +stickersEmojiPickerItemSize: 30px; +stickersEmojiPickerItemSkip: 4px; +stickersEmojiPickerStripHeight: 36px; +stickersEmojiPickerExpandedHeight: 220px; +stickersEmojiPickerSelectedBg: windowBgActive; +stickersEmojiPickerSelectedFg: windowBgActive; +stickersEmojiPickerHeaderFg: windowSubTextFg; +stickersEmojiPickerAbout: FlatLabel(defaultFlatLabel) { + minWidth: 100px; + align: align(top); + textFg: windowSubTextFg; + style: TextStyle(defaultTextStyle) { + font: font(12px); + } +} +stickersEmojiPickerSectionHeader: FlatLabel(defaultFlatLabel) { + minWidth: 10px; + align: align(topleft); + textFg: windowSubTextFg; + style: TextStyle(defaultTextStyle) { + font: font(12px semibold); + } +} +stickersEmojiPickerExpandIcon: icon {{ "intro_country_dropdown", windowSubTextFg }}; +stickersEmojiPickerCollapseIcon: icon {{ "intro_country_dropdown-flip_vertical", windowSubTextFg }}; +stickersEmojiPickerExpandSize: 24px; + emojiStatusDefault: icon {{ "emoji/stickers_premium", emojiIconFg }}; filtersRemove: IconButton(stickersRemove) { diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp new file mode 100644 index 0000000000..6f02547624 --- /dev/null +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp @@ -0,0 +1,556 @@ +/* +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 "chat_helpers/emoji_picker_overlay.h" + +#include "ui/abstract_button.h" +#include "ui/emoji_config.h" +#include "ui/painter.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/scroll_area.h" +#include "styles/style_chat_helpers.h" + +#include +#include +#include + +namespace ChatHelpers { +namespace { + +[[nodiscard]] std::vector BuildAllEmojis() { + using Section = Ui::Emoji::Section; + auto result = std::vector(); + for (auto i = int(Section::People); i <= int(Section::Symbols); ++i) { + const auto section = Ui::Emoji::GetSection(Section(i)); + result.reserve(result.size() + section.size()); + for (const auto emoji : section) { + result.push_back(emoji); + } + } + return result; +} + +} // namespace + +class EmojiPickerOverlay::Strip final : public Ui::RpWidget { +public: + Strip(QWidget *parent, not_null owner); + + void refresh(); + +protected: + void paintEvent(QPaintEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; + void mouseReleaseEvent(QMouseEvent *e) override; + void leaveEventHook(QEvent *e) override; + +private: + struct Cell { + EmojiPtr emoji = nullptr; + QRect rect; + }; + + [[nodiscard]] int cellAtPoint(QPoint p) const; + void updateHover(int index); + + const not_null _owner; + std::vector _cells; + int _hover = -1; + int _pressed = -1; + +}; + +class EmojiPickerOverlay::Grid final : public Ui::RpWidget { +public: + Grid(QWidget *parent, not_null owner); + + void setEmojis(std::vector emojis); + int resizeGetHeight(int newWidth) override; + void refresh(); + +protected: + void paintEvent(QPaintEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; + void mouseReleaseEvent(QMouseEvent *e) override; + void leaveEventHook(QEvent *e) override; + +private: + struct Cell { + EmojiPtr emoji = nullptr; + QRect rect; + }; + + [[nodiscard]] int cellAtPoint(QPoint p) const; + void relayoutCells(); + void updateHover(int index); + + const not_null _owner; + std::vector _emojis; + std::vector _cells; + int _columns = 0; + int _hover = -1; + int _pressed = -1; + +}; + +namespace { + +void DrawEmojiCell( + QPainter &p, + const QRect &cell, + EmojiPtr emoji, + bool selected, + bool hovered) { + if (selected) { + p.setPen(Qt::NoPen); + p.setBrush(st::stickersEmojiPickerSelectedBg); + p.drawEllipse(cell); + } else if (hovered) { + p.setPen(Qt::NoPen); + p.setBrush(anim::with_alpha(st::windowSubTextFg->c, 0.12)); + p.drawEllipse(cell); + } + if (!emoji) { + return; + } + const auto esize = Ui::Emoji::GetSizeLarge(); + const auto dpr = style::DevicePixelRatio(); + const auto pixelSize = esize / dpr; + const auto drawSize = std::min( + pixelSize, + st::stickersEmojiPickerItemSize - 4); + const auto x = cell.x() + (cell.width() - drawSize) / 2; + const auto y = cell.y() + (cell.height() - drawSize) / 2; + if (drawSize == pixelSize) { + Ui::Emoji::Draw(p, emoji, esize, x, y); + } else { + const auto target = QRect(x, y, drawSize, drawSize); + auto buffer = QImage( + QSize(pixelSize, pixelSize) * dpr, + QImage::Format_ARGB32_Premultiplied); + buffer.fill(Qt::transparent); + buffer.setDevicePixelRatio(dpr); + { + auto q = QPainter(&buffer); + Ui::Emoji::Draw(q, emoji, esize, 0, 0); + } + auto hq = PainterHighQualityEnabler(p); + p.drawImage(target, buffer); + } +} + +} // namespace + +EmojiPickerOverlay::Strip::Strip( + QWidget *parent, + not_null owner) +: RpWidget(parent) +, _owner(owner) { + setMouseTracking(true); +} + +void EmojiPickerOverlay::Strip::refresh() { + const auto &sel = _owner->_selectedList; + const auto &recent = _owner->_recent; + const auto item = st::stickersEmojiPickerItemSize; + const auto skip = st::stickersEmojiPickerItemSkip; + const auto w = width(); + if (w <= 0 || item <= 0) { + _cells.clear(); + update(); + return; + } + const auto capacity = std::max(1, (w + skip) / (item + skip)); + + auto order = std::vector(); + order.reserve(sel.size() + recent.size()); + for (const auto emoji : sel) { + order.push_back(emoji); + } + for (const auto emoji : recent) { + const auto already = std::find(sel.begin(), sel.end(), emoji) + != sel.end(); + if (!already) { + order.push_back(emoji); + } + } + if (int(order.size()) > capacity) { + order.resize(capacity); + } + + _cells.clear(); + _cells.reserve(order.size()); + auto x = 0; + const auto y = (height() - item) / 2; + for (const auto emoji : order) { + _cells.push_back({ emoji, QRect(x, y, item, item) }); + x += item + skip; + } + _hover = -1; + _pressed = -1; + update(); +} + +int EmojiPickerOverlay::Strip::cellAtPoint(QPoint p) const { + for (auto i = 0; i != int(_cells.size()); ++i) { + if (_cells[i].rect.contains(p)) { + return i; + } + } + return -1; +} + +void EmojiPickerOverlay::Strip::updateHover(int index) { + if (_hover == index) { + return; + } + _hover = index; + update(); +} + +void EmojiPickerOverlay::Strip::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + for (auto i = 0; i != int(_cells.size()); ++i) { + const auto &cell = _cells[i]; + const auto selected = _owner->_selectedList.end() + != std::find( + _owner->_selectedList.begin(), + _owner->_selectedList.end(), + cell.emoji); + DrawEmojiCell(p, cell.rect, cell.emoji, selected, i == _hover); + } +} + +void EmojiPickerOverlay::Strip::mouseMoveEvent(QMouseEvent *e) { + updateHover(cellAtPoint(e->pos())); +} + +void EmojiPickerOverlay::Strip::mousePressEvent(QMouseEvent *e) { + _pressed = cellAtPoint(e->pos()); +} + +void EmojiPickerOverlay::Strip::mouseReleaseEvent(QMouseEvent *e) { + const auto released = cellAtPoint(e->pos()); + const auto index = _pressed; + _pressed = -1; + if (released == index && index >= 0 && index < int(_cells.size())) { + _owner->toggleEmoji(_cells[index].emoji); + } +} + +void EmojiPickerOverlay::Strip::leaveEventHook(QEvent *e) { + updateHover(-1); +} + +EmojiPickerOverlay::Grid::Grid( + QWidget *parent, + not_null owner) +: RpWidget(parent) +, _owner(owner) { + setMouseTracking(true); +} + +void EmojiPickerOverlay::Grid::setEmojis(std::vector emojis) { + _emojis = std::move(emojis); + relayoutCells(); +} + +int EmojiPickerOverlay::Grid::resizeGetHeight(int newWidth) { + resize(newWidth, 0); + relayoutCells(); + return height(); +} + +void EmojiPickerOverlay::Grid::refresh() { + update(); +} + +void EmojiPickerOverlay::Grid::relayoutCells() { + const auto item = st::stickersEmojiPickerItemSize; + const auto skip = st::stickersEmojiPickerItemSkip; + const auto w = width(); + _columns = std::max(1, (w + skip) / (item + skip)); + _cells.clear(); + _cells.reserve(_emojis.size()); + auto col = 0; + auto row = 0; + for (const auto emoji : _emojis) { + const auto x = col * (item + skip); + const auto y = row * (item + skip); + _cells.push_back({ emoji, QRect(x, y, item, item) }); + if (++col >= _columns) { + col = 0; + ++row; + } + } + const auto fullRows = row + (col > 0 ? 1 : 0); + const auto h = fullRows > 0 + ? (fullRows * item + (fullRows - 1) * skip) + : 0; + resize(w, h); + _hover = -1; + _pressed = -1; + update(); +} + +int EmojiPickerOverlay::Grid::cellAtPoint(QPoint p) const { + for (auto i = 0; i != int(_cells.size()); ++i) { + if (_cells[i].rect.contains(p)) { + return i; + } + } + return -1; +} + +void EmojiPickerOverlay::Grid::updateHover(int index) { + if (_hover == index) { + return; + } + _hover = index; + update(); +} + +void EmojiPickerOverlay::Grid::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + const auto clip = e->rect(); + for (auto i = 0; i != int(_cells.size()); ++i) { + const auto &cell = _cells[i]; + if (!cell.rect.intersects(clip)) { + continue; + } + const auto selected = _owner->_selectedList.end() + != std::find( + _owner->_selectedList.begin(), + _owner->_selectedList.end(), + cell.emoji); + DrawEmojiCell(p, cell.rect, cell.emoji, selected, i == _hover); + } +} + +void EmojiPickerOverlay::Grid::mouseMoveEvent(QMouseEvent *e) { + updateHover(cellAtPoint(e->pos())); +} + +void EmojiPickerOverlay::Grid::mousePressEvent(QMouseEvent *e) { + _pressed = cellAtPoint(e->pos()); +} + +void EmojiPickerOverlay::Grid::mouseReleaseEvent(QMouseEvent *e) { + const auto released = cellAtPoint(e->pos()); + const auto index = _pressed; + _pressed = -1; + if (released == index && index >= 0 && index < int(_cells.size())) { + _owner->toggleEmoji(_cells[index].emoji); + } +} + +void EmojiPickerOverlay::Grid::leaveEventHook(QEvent *e) { + updateHover(-1); +} + +EmojiPickerOverlay::EmojiPickerOverlay( + QWidget *parent, + EmojiPickerOverlayDescriptor descriptor) +: RpWidget(parent) +, _aboutText(std::move(descriptor.aboutText)) +, _recent(descriptor.recent.empty() + ? std::vector( + Ui::Emoji::GetDefaultRecent().begin(), + Ui::Emoji::GetDefaultRecent().end()) + : std::move(descriptor.recent)) +, _maxSelected(descriptor.maxSelected) +, _allowExpand(descriptor.allowExpand) +, _selectedList(std::move(descriptor.initialSelected)) { + _allForGrid = BuildAllEmojis(); + + _about = std::make_unique( + this, + _aboutText, + st::stickersEmojiPickerAbout); + + _strip = Ui::CreateChild(this, this); + + if (_allowExpand) { + _expandButton = Ui::CreateChild(this); + _expandButton->resize( + st::stickersEmojiPickerExpandSize, + st::stickersEmojiPickerExpandSize); + _expandButton->setClickedCallback([=] { + setExpanded(!_expanded.current()); + }); + _expandButton->paintRequest( + ) | rpl::on_next([=](const QRect &clip) { + auto p = QPainter(_expandButton); + const auto &icon = _expanded.current() + ? st::stickersEmojiPickerCollapseIcon + : st::stickersEmojiPickerExpandIcon; + const auto x = (_expandButton->width() - icon.width()) / 2; + const auto y = (_expandButton->height() - icon.height()) / 2; + icon.paint(p, x, y, _expandButton->width()); + }, _expandButton->lifetime()); + + _scroll = std::make_unique(this); + _scroll->setFrameStyle(QFrame::NoFrame); + _scroll->hide(); + const auto gridPtr = _scroll->setOwnedWidget( + object_ptr(_scroll.get(), this)); + _grid = gridPtr.data(); + _grid->setEmojis(_allForGrid); + + _expanded.value( + ) | rpl::on_next([=](bool value) { + if (_scroll) { + _scroll->setVisible(value); + } + if (_expandButton) { + _expandButton->update(); + } + resize(width(), value ? expandedHeight() : collapsedHeight()); + relayout(); + }, lifetime()); + } + + _selectedVar = _selectedList; + resize(width(), collapsedHeight()); +} + +EmojiPickerOverlay::~EmojiPickerOverlay() = default; + +const std::vector &EmojiPickerOverlay::selected() const { + return _selectedList; +} + +rpl::producer> +EmojiPickerOverlay::selectedValue() const { + return _selectedVar.value(); +} + +void EmojiPickerOverlay::setExpanded(bool expanded) { + if (!_allowExpand) { + return; + } + _expanded = expanded; +} + +bool EmojiPickerOverlay::expanded() const { + return _expanded.current(); +} + +rpl::producer EmojiPickerOverlay::expandedValue() const { + return _expanded.value(); +} + +int EmojiPickerOverlay::collapsedHeight() const { + const auto &pad = st::stickersEmojiPickerPadding; + const auto aboutH = _about ? _about->height() : 0; + return pad.top() + + aboutH + + st::stickersEmojiPickerStripHeight + + pad.bottom(); +} + +int EmojiPickerOverlay::expandedHeight() const { + return collapsedHeight() + st::stickersEmojiPickerExpandedHeight; +} + +void EmojiPickerOverlay::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(st::stickersEmojiPickerBg); + const auto radius = st::stickersEmojiPickerRadius; + p.drawRoundedRect(rect(), radius, radius); +} + +void EmojiPickerOverlay::resizeEvent(QResizeEvent *e) { + relayout(); +} + +void EmojiPickerOverlay::relayout() { + const auto &pad = st::stickersEmojiPickerPadding; + if (_about) { + _about->resizeToWidth(width() - pad.left() - pad.right()); + _about->moveToLeft(pad.left(), pad.top()); + } + const auto aboutBottom = _about + ? (_about->y() + _about->height()) + : pad.top(); + + const auto stripTop = aboutBottom; + const auto stripH = st::stickersEmojiPickerStripHeight; + const auto expandSize = _expandButton + ? _expandButton->width() + : 0; + const auto expandGap = _expandButton + ? st::stickersEmojiPickerItemSkip + : 0; + const auto stripW = width() + - pad.left() + - pad.right() + - expandSize + - expandGap; + _strip->setGeometry(pad.left(), stripTop, stripW, stripH); + _strip->refresh(); + + if (_expandButton) { + const auto bx = width() - pad.right() - expandSize; + const auto by = stripTop + (stripH - expandSize) / 2; + _expandButton->moveToLeft(bx, by); + } + + if (_scroll) { + const auto scrollTop = stripTop + stripH; + const auto scrollH = std::max( + 0, + height() - scrollTop - pad.bottom()); + _scroll->setGeometry( + pad.left(), + scrollTop, + width() - pad.left() - pad.right(), + scrollH); + if (_grid) { + _grid->resizeGetHeight(_scroll->width()); + } + } +} + +void EmojiPickerOverlay::toggleEmoji(EmojiPtr emoji) { + if (!emoji) { + return; + } + const auto it = std::find( + _selectedList.begin(), + _selectedList.end(), + emoji); + if (it != _selectedList.end()) { + _selectedList.erase(it); + } else { + if (_maxSelected > 0 && int(_selectedList.size()) >= _maxSelected) { + return; + } + _selectedList.push_back(emoji); + } + notifySelectionChanged(); +} + +void EmojiPickerOverlay::notifySelectionChanged() { + _selectedVar = _selectedList; + if (_strip) { + _strip->refresh(); + } + if (_grid) { + _grid->refresh(); + } +} + +void EmojiPickerOverlay::buildSections() { + // Reserved for potential future categorised rendering in the grid. +} + +} // namespace ChatHelpers diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h new file mode 100644 index 0000000000..f4eadcb91a --- /dev/null +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h @@ -0,0 +1,78 @@ +/* +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/rp_widget.h" +#include "ui/emoji_config.h" + +namespace Ui { +class AbstractButton; +class FlatLabel; +class ScrollArea; +} // namespace Ui + +namespace ChatHelpers { + +struct EmojiPickerOverlayDescriptor { + QString aboutText; + std::vector recent; + int maxSelected = 0; + bool allowExpand = true; + std::vector initialSelected; +}; + +class EmojiPickerOverlay final : public Ui::RpWidget { +public: + EmojiPickerOverlay( + QWidget *parent, + EmojiPickerOverlayDescriptor descriptor); + ~EmojiPickerOverlay(); + + [[nodiscard]] const std::vector &selected() const; + [[nodiscard]] rpl::producer> selectedValue() const; + + void setExpanded(bool expanded); + [[nodiscard]] bool expanded() const; + [[nodiscard]] rpl::producer expandedValue() const; + + [[nodiscard]] int collapsedHeight() const; + [[nodiscard]] int expandedHeight() const; + +protected: + void paintEvent(QPaintEvent *e) override; + void resizeEvent(QResizeEvent *e) override; + +private: + class Strip; + class Grid; + + void buildSections(); + void relayout(); + void toggleEmoji(EmojiPtr emoji); + void notifySelectionChanged(); + + const QString _aboutText; + const std::vector _recent; + const int _maxSelected; + const bool _allowExpand; + + std::vector _allForGrid; + + std::vector _selectedList; + rpl::variable> _selectedVar; + rpl::variable _expanded = false; + + std::unique_ptr _about; + Strip *_strip = nullptr; + Ui::AbstractButton *_expandButton = nullptr; + std::unique_ptr _scroll; + Grid *_grid = nullptr; + +}; + +} // namespace ChatHelpers From 3bddcb546148f82dd39dd2039bf6667607cacab4 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 21:31:45 +0300 Subject: [PATCH 65/78] Replaced sticker creator emoji row with new picker overlay. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 397 ++---------------- .../chat_helpers/chat_helpers.style | 7 - 2 files changed, 42 insertions(+), 362 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index e0770b5d92..bd782e1f01 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -8,10 +8,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/sticker_creator_box.h" #include "api/api_stickers_creator.h" -#include "base/event_filter.h" #include "chat_helpers/compose/compose_show.h" -#include "chat_helpers/tabbed_panel.h" -#include "chat_helpers/tabbed_selector.h" +#include "chat_helpers/emoji_picker_overlay.h" #include "core/file_utilities.h" #include "editor/editor_layer_widget.h" #include "editor/photo_editor.h" @@ -21,7 +19,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "info/channel_statistics/boosts/giveaway/boost_badge.h" #include "lang/lang_keys.h" #include "main/main_session.h" -#include "ui/abstract_button.h" #include "ui/emoji_config.h" #include "ui/image/image.h" #include "ui/image/image_prepare.h" @@ -31,14 +28,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/rp_widget.h" #include "ui/vertical_list.h" #include "ui/widgets/buttons.h" -#include "ui/widgets/labels.h" -#include "ui/widgets/scroll_area.h" #include "ui/wrap/vertical_layout.h" #include "window/window_controller.h" #include "window/window_session_controller.h" #include "styles/style_boxes.h" #include "styles/style_chat_helpers.h" -#include "styles/style_info.h" #include "styles/style_layers.h" #include @@ -83,335 +77,6 @@ private: }; -class ActionButton final : public Ui::AbstractButton { -public: - enum class Kind { - Emoji, - Delete, - }; - - ActionButton(QWidget *parent, Kind kind) - : AbstractButton(parent) - , _kind(kind) { - resize( - st::stickersCreatorActionSize, - st::stickersCreatorActionSize); - } - -protected: - void paintEvent(QPaintEvent *e) override { - auto p = QPainter(this); - auto hq = PainterHighQualityEnabler(p); - const auto size = st::stickersCreatorActionSize; - const auto rect = QRect(0, 0, size, size); - if (isOver()) { - p.setPen(Qt::NoPen); - p.setBrush(anim::with_alpha(st::windowSubTextFg->c, 0.12)); - p.drawEllipse(rect); - } - - if (_kind == Kind::Emoji) { - const auto &icon = st::stickersCreatorEmojiIcon; - const auto iconX = (size - icon.width()) / 2; - const auto iconY = (size - icon.height()) / 2; - icon.paint(p, iconX, iconY, size); - - const auto line = style::ConvertScaleExact( - st::historyEmojiCircleLine); - auto pen = st::windowSubTextFg->p; - pen.setWidthF(line); - pen.setCapStyle(Qt::RoundCap); - p.setPen(pen); - p.setBrush(Qt::NoBrush); - const auto skipX = icon.width() / 4; - const auto skipY = icon.height() / 4; - p.drawEllipse(QRectF( - iconX + skipX, - iconY + skipY, - icon.width() - 2 * skipX, - icon.height() - 2 * skipY)); - } else { - paintBackspaceGlyph(p, rect); - } - } - -private: - void paintBackspaceGlyph(QPainter &p, QRect rect) { - const auto glyphW = style::ConvertScaleExact(18.); - const auto glyphH = style::ConvertScaleExact(13.); - const auto x = rect.x() + (rect.width() - glyphW) / 2.; - const auto y = rect.y() + (rect.height() - glyphH) / 2.; - const auto cornerCut = style::ConvertScaleExact(5.); - const auto stroke = style::ConvertScaleExact(1.5); - - auto pen = st::windowSubTextFg->p; - pen.setWidthF(stroke); - pen.setCapStyle(Qt::RoundCap); - pen.setJoinStyle(Qt::RoundJoin); - p.setPen(pen); - p.setBrush(Qt::NoBrush); - - auto path = QPainterPath(); - path.moveTo(x, y + glyphH / 2.); - path.lineTo(x + cornerCut, y); - path.lineTo(x + glyphW, y); - path.lineTo(x + glyphW, y + glyphH); - path.lineTo(x + cornerCut, y + glyphH); - path.closeSubpath(); - p.drawPath(path); - - const auto cx = x + (cornerCut + glyphW) / 2. + stroke; - const auto cy = y + glyphH / 2.; - const auto half = style::ConvertScaleExact(2.5); - p.drawLine( - QPointF(cx - half, cy - half), - QPointF(cx + half, cy + half)); - p.drawLine( - QPointF(cx - half, cy + half), - QPointF(cx + half, cy - half)); - } - - const Kind _kind; - -}; - -class EmojiPickerRow final : public Ui::RpWidget { -public: - EmojiPickerRow( - QWidget *parent, - std::shared_ptr show, - not_null panelContainer); - - [[nodiscard]] QString value() const; - [[nodiscard]] rpl::producer countValue() const; - -protected: - void paintEvent(QPaintEvent *e) override; - void resizeEvent(QResizeEvent *e) override; - -private: - void relayout(); - void ensurePanel(); - void togglePanel(); - void updatePanelGeometry(); - void addEmoji(EmojiPtr emoji); - void removeLast(); - [[nodiscard]] int rowContentWidth() const; - - const std::shared_ptr _show; - const not_null _panelContainer; - std::vector _emojis; - rpl::variable _count; - ActionButton *_plus = nullptr; - ActionButton *_minus = nullptr; - base::unique_qptr _panel; - -}; - -EmojiPickerRow::EmojiPickerRow( - QWidget *parent, - std::shared_ptr show, - not_null panelContainer) -: RpWidget(parent) -, _show(std::move(show)) -, _panelContainer(panelContainer) -, _plus(Ui::CreateChild(this, ActionButton::Kind::Emoji)) -, _minus(Ui::CreateChild(this, ActionButton::Kind::Delete)) { - resize(width(), st::stickersCreatorRowHeight); - _plus->setClickedCallback([=] { togglePanel(); }); - _minus->setClickedCallback([=] { removeLast(); }); - _minus->hide(); -} - -QString EmojiPickerRow::value() const { - auto result = QString(); - for (const auto emoji : _emojis) { - result.append(emoji->text()); - } - return result; -} - -rpl::producer EmojiPickerRow::countValue() const { - return _count.value(); -} - -int EmojiPickerRow::rowContentWidth() const { - const auto count = int(_emojis.size()); - const auto plusVisible = (count < kMaxEmojis); - const auto minusVisible = (count > 0); - auto width = 0; - if (plusVisible) { - width += st::stickersCreatorActionSize; - } - if (count > 0) { - if (plusVisible) { - width += st::stickersCreatorActionMargin; - } - width += count * st::stickersCreatorEmojiSize - + (count - 1) * st::stickersCreatorEmojiSkip; - if (minusVisible) { - width += st::stickersCreatorActionMargin; - } - } - if (minusVisible) { - width += st::stickersCreatorActionSize; - } - return width; -} - -void EmojiPickerRow::relayout() { - const auto count = int(_emojis.size()); - const auto plusVisible = (count < kMaxEmojis); - const auto minusVisible = (count > 0); - const auto contentWidth = rowContentWidth(); - const auto offsetX = (width() - contentWidth) / 2; - const auto centerY = height() / 2; - const auto top = centerY - st::stickersCreatorActionSize / 2; - - auto x = offsetX; - - if (plusVisible) { - _plus->move(x, top); - _plus->show(); - x += st::stickersCreatorActionSize; - } else { - _plus->hide(); - } - - if (count > 0) { - if (plusVisible) { - x += st::stickersCreatorActionMargin; - } - x += count * st::stickersCreatorEmojiSize - + (count - 1) * st::stickersCreatorEmojiSkip; - if (minusVisible) { - x += st::stickersCreatorActionMargin; - } - } - - if (minusVisible) { - _minus->move(x, top); - _minus->show(); - } else { - _minus->hide(); - } - - update(); -} - -void EmojiPickerRow::paintEvent(QPaintEvent *e) { - auto p = QPainter(this); - const auto count = int(_emojis.size()); - if (!count) { - return; - } - const auto esize = Ui::Emoji::GetSizeLarge(); - const auto size = esize / style::DevicePixelRatio(); - const auto contentWidth = rowContentWidth(); - const auto offsetX = (width() - contentWidth) / 2; - const auto centerY = height() / 2; - const auto plusVisible = (count < kMaxEmojis); - - auto x = offsetX; - if (plusVisible) { - x += st::stickersCreatorActionSize - + st::stickersCreatorActionMargin; - } - const auto slot = st::stickersCreatorEmojiSize; - const auto extra = (slot - size) / 2; - const auto y = centerY - size / 2; - for (const auto emoji : _emojis) { - Ui::Emoji::Draw(p, emoji, esize, x + extra, y); - x += slot + st::stickersCreatorEmojiSkip; - } -} - -void EmojiPickerRow::resizeEvent(QResizeEvent *e) { - relayout(); -} - -void EmojiPickerRow::ensurePanel() { - if (_panel) { - return; - } - using Selector = ChatHelpers::TabbedSelector; - _panel = base::make_unique_q( - _panelContainer.get(), - ChatHelpers::TabbedPanelDescriptor{ - .ownedSelector = object_ptr( - nullptr, - ChatHelpers::TabbedSelectorDescriptor{ - .show = _show, - .st = st::defaultComposeControls.tabbed, - .level = Window::GifPauseReason::Layer, - .mode = Selector::Mode::PeerTitle, - }), - }); - _panel->setDesiredHeightValues( - 1., - st::emojiPanMinHeight / 2, - st::emojiPanMinHeight); - _panel->setDropDown(true); - _panel->setShowAnimationOrigin(Ui::PanelAnimation::Origin::TopLeft); - _panel->hide(); - - _panel->selector()->emojiChosen( - ) | rpl::on_next([=](ChatHelpers::EmojiChosen data) { - addEmoji(data.emoji); - _panel->hideAnimated(); - }, _panel->lifetime()); - - base::install_event_filter(this, _panelContainer, [=]( - not_null event) { - const auto type = event->type(); - if (type == QEvent::Move || type == QEvent::Resize) { - crl::on_main(this, [=] { updatePanelGeometry(); }); - } - return base::EventFilterResult::Continue; - }); -} - -void EmojiPickerRow::updatePanelGeometry() { - if (!_panel) { - return; - } - const auto container = _panelContainer->size(); - const auto margins = st::emojiPanMargins; - const auto panelWidth = st::emojiPanWidth - + margins.left() - + margins.right(); - const auto panelHeight = st::emojiPanMinHeight - + margins.top() - + margins.bottom(); - const auto top = std::max(0, (container.height() - panelHeight) / 2); - const auto right = (container.width() + panelWidth) / 2; - _panel->moveTopRight(top, right); -} - -void EmojiPickerRow::togglePanel() { - ensurePanel(); - updatePanelGeometry(); - _panel->toggleAnimated(); -} - -void EmojiPickerRow::addEmoji(EmojiPtr emoji) { - if (!emoji || int(_emojis.size()) >= kMaxEmojis) { - return; - } - _emojis.push_back(emoji); - _count = int(_emojis.size()); - relayout(); -} - -void EmojiPickerRow::removeLast() { - if (_emojis.empty()) { - return; - } - _emojis.pop_back(); - _count = int(_emojis.size()); - relayout(); -} - void OpenPhotoEditorForSticker( std::shared_ptr show, QImage image, @@ -542,44 +207,66 @@ void CreateStickerBox( const auto inner = box->verticalLayout(); + auto pickerDescriptor = ChatHelpers::EmojiPickerOverlayDescriptor{ + .aboutText = tr::lng_stickers_create_emoji_about(tr::now), + .maxSelected = kMaxEmojis, + .allowExpand = true, + }; + const auto overlayExpanded = [&] { + auto probe = ChatHelpers::EmojiPickerOverlay(nullptr, pickerDescriptor); + return probe.expandedHeight(); + }(); + const auto previewHolder = inner->add( object_ptr(inner), QMargins(0, 0, 0, 0), - // QMargins(0, st::boxRowPadding.left(), 0, st::boxRowPadding.left()), style::al_top); - previewHolder->resize(st::boxWideWidth, kPreviewSide); + previewHolder->resize( + st::boxWideWidth, + kPreviewSide + overlayExpanded / 2); const auto preview = Ui::CreateChild( previewHolder, image); + + const auto picker = Ui::CreateChild( + previewHolder, + std::move(pickerDescriptor)); + + auto layoutOverlay = [=] { + const auto w = std::min( + previewHolder->width() - 2 * st::boxRowPadding.left(), + int(kPreviewSide * 1.1)); + const auto h = picker->expanded() + ? picker->expandedHeight() + : picker->collapsedHeight(); + const auto x = (previewHolder->width() - w) / 2; + const auto y = kPreviewSide - h; + picker->setGeometry(x, y, w, h); + picker->raise(); + }; + previewHolder->widthValue( ) | rpl::on_next([=](int width) { preview->move((width - kPreviewSide) / 2, 0); + layoutOverlay(); }, preview->lifetime()); - Ui::AddSkip(inner); - Ui::AddSkip(inner); + picker->expandedValue( + ) | rpl::on_next([=](bool) { + layoutOverlay(); + }, picker->lifetime()); - inner->add( - object_ptr( - inner, - tr::lng_stickers_pack_choose_emoji_about(), - st::boxDividerLabel), - st::boxRowPadding); - - const auto emojiRow = inner->add( - object_ptr( - inner, - show, - box->getDelegate()->outerContainer()), - QMargins(0, 0, 0, st::boxRowPadding.left())); - emojiRow->resize(st::boxWideWidth, st::stickersCreatorRowHeight); + Ui::AddSkip(inner); const auto startUpload = [=, set = std::move(set), done = std::move(done)]( ) mutable { if (state->uploading.current()) { return; } - const auto emoji = emojiRow->value(); + auto emoji = QString(); + for (const auto one : picker->selected()) { + emoji.append(one->text()); + } if (emoji.isEmpty()) { show->showToast( tr::lng_stickers_create_emoji_required(tr::now)); diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index a10bb9be5f..63f6974e93 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -428,13 +428,6 @@ stickersAddCellPlusSize: 22px; stickersAddCellPlusThickness: 2px; stickersAddCellBgRadius: 28px; -stickersCreatorEmojiSize: 32px; -stickersCreatorEmojiSkip: 8px; -stickersCreatorRowHeight: 48px; -stickersCreatorActionSize: 32px; -stickersCreatorActionMargin: 12px; -stickersCreatorEmojiIcon: icon {{ "chat/input_smile_face", windowSubTextFg }}; - stickersEmojiPickerRadius: 16px; stickersEmojiPickerBg: emojiPanBg; stickersEmojiPickerShadow: windowShadowFg; From 7b720b2a3f2273298fb74d1cdbbbe28a96bbd93b Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 21:42:43 +0300 Subject: [PATCH 66/78] Added expand/collapse animation to emoji picker overlay. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 21 +++-- .../chat_helpers/emoji_picker_overlay.cpp | 78 +++++++++++++------ .../chat_helpers/emoji_picker_overlay.h | 10 ++- 3 files changed, 73 insertions(+), 36 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index bd782e1f01..80b891f46c 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -212,10 +212,15 @@ void CreateStickerBox( .maxSelected = kMaxEmojis, .allowExpand = true, }; - const auto overlayExpanded = [&] { + const auto pickerCollapsed = [&] { + auto probe = ChatHelpers::EmojiPickerOverlay(nullptr, pickerDescriptor); + return probe.collapsedHeight(); + }(); + const auto pickerExpanded = [&] { auto probe = ChatHelpers::EmojiPickerOverlay(nullptr, pickerDescriptor); return probe.expandedHeight(); }(); + const auto pickerExtra = pickerExpanded - pickerCollapsed; const auto previewHolder = inner->add( object_ptr(inner), @@ -223,7 +228,7 @@ void CreateStickerBox( style::al_top); previewHolder->resize( st::boxWideWidth, - kPreviewSide + overlayExpanded / 2); + kPreviewSide + pickerExtra); const auto preview = Ui::CreateChild( previewHolder, image); @@ -236,12 +241,9 @@ void CreateStickerBox( const auto w = std::min( previewHolder->width() - 2 * st::boxRowPadding.left(), int(kPreviewSide * 1.1)); - const auto h = picker->expanded() - ? picker->expandedHeight() - : picker->collapsedHeight(); const auto x = (previewHolder->width() - w) / 2; - const auto y = kPreviewSide - h; - picker->setGeometry(x, y, w, h); + const auto y = kPreviewSide - pickerCollapsed; + picker->setGeometry(x, y, w, pickerExpanded); picker->raise(); }; @@ -251,11 +253,6 @@ void CreateStickerBox( layoutOverlay(); }, preview->lifetime()); - picker->expandedValue( - ) | rpl::on_next([=](bool) { - layoutOverlay(); - }, picker->lifetime()); - Ui::AddSkip(inner); const auto startUpload = [=, set = std::move(set), done = std::move(done)]( diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp index 6f02547624..10950c523a 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp @@ -240,7 +240,7 @@ void EmojiPickerOverlay::Strip::mouseReleaseEvent(QMouseEvent *e) { const auto index = _pressed; _pressed = -1; if (released == index && index >= 0 && index < int(_cells.size())) { - _owner->toggleEmoji(_cells[index].emoji); + _owner->toggleEmoji(_cells[index].emoji, false); } } @@ -346,7 +346,7 @@ void EmojiPickerOverlay::Grid::mouseReleaseEvent(QMouseEvent *e) { const auto index = _pressed; _pressed = -1; if (released == index && index >= 0 && index < int(_cells.size())) { - _owner->toggleEmoji(_cells[index].emoji); + _owner->toggleEmoji(_cells[index].emoji, true); } } @@ -402,22 +402,10 @@ EmojiPickerOverlay::EmojiPickerOverlay( object_ptr(_scroll.get(), this)); _grid = gridPtr.data(); _grid->setEmojis(_allForGrid); - - _expanded.value( - ) | rpl::on_next([=](bool value) { - if (_scroll) { - _scroll->setVisible(value); - } - if (_expandButton) { - _expandButton->update(); - } - resize(width(), value ? expandedHeight() : collapsedHeight()); - relayout(); - }, lifetime()); } _selectedVar = _selectedList; - resize(width(), collapsedHeight()); + resize(width(), expandedHeight()); } EmojiPickerOverlay::~EmojiPickerOverlay() = default; @@ -432,10 +420,49 @@ EmojiPickerOverlay::selectedValue() const { } void EmojiPickerOverlay::setExpanded(bool expanded) { - if (!_allowExpand) { + if (!_allowExpand || _expanded.current() == expanded) { return; } + startExpandAnimation(expanded); _expanded = expanded; + if (_expandButton) { + _expandButton->update(); + } +} + +void EmojiPickerOverlay::startExpandAnimation(bool expanded) { + const auto from = _expandAnim.value(expanded ? 0. : 1.); + const auto to = expanded ? 1. : 0.; + _expandAnim.start( + [=] { applyExpandProgress(); }, + from, + to, + st::slideWrapDuration, + anim::easeOutCirc); + applyExpandProgress(); +} + +float64 EmojiPickerOverlay::currentExpandValue() const { + return _expandAnim.value(_expanded.current() ? 1. : 0.); +} + +int EmojiPickerOverlay::currentShownHeight() const { + const auto progress = currentExpandValue(); + return anim::interpolate( + collapsedHeight(), + expandedHeight(), + progress); +} + +void EmojiPickerOverlay::applyExpandProgress() { + const auto h = currentShownHeight(); + setMask(QRegion(0, 0, width(), h)); + if (_scroll) { + const auto progress = currentExpandValue(); + _scroll->setVisible(progress > 0.); + } + relayout(); + update(); } bool EmojiPickerOverlay::expanded() const { @@ -465,13 +492,21 @@ void EmojiPickerOverlay::paintEvent(QPaintEvent *e) { p.setPen(Qt::NoPen); p.setBrush(st::stickersEmojiPickerBg); const auto radius = st::stickersEmojiPickerRadius; - p.drawRoundedRect(rect(), radius, radius); + const auto h = currentShownHeight(); + p.drawRoundedRect(QRect(0, 0, width(), h), radius, radius); } void EmojiPickerOverlay::resizeEvent(QResizeEvent *e) { + setMask(QRegion(0, 0, width(), currentShownHeight())); relayout(); } +void EmojiPickerOverlay::mousePressEvent(QMouseEvent *e) { + if (e->pos().y() > currentShownHeight()) { + e->ignore(); + } +} + void EmojiPickerOverlay::relayout() { const auto &pad = st::stickersEmojiPickerPadding; if (_about) { @@ -520,7 +555,7 @@ void EmojiPickerOverlay::relayout() { } } -void EmojiPickerOverlay::toggleEmoji(EmojiPtr emoji) { +void EmojiPickerOverlay::toggleEmoji(EmojiPtr emoji, bool fromGrid) { if (!emoji) { return; } @@ -537,6 +572,9 @@ void EmojiPickerOverlay::toggleEmoji(EmojiPtr emoji) { _selectedList.push_back(emoji); } notifySelectionChanged(); + if (fromGrid) { + setExpanded(false); + } } void EmojiPickerOverlay::notifySelectionChanged() { @@ -549,8 +587,4 @@ void EmojiPickerOverlay::notifySelectionChanged() { } } -void EmojiPickerOverlay::buildSections() { - // Reserved for potential future categorised rendering in the grid. -} - } // namespace ChatHelpers diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h index f4eadcb91a..0919cdab80 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "ui/rp_widget.h" +#include "ui/effects/animations.h" #include "ui/emoji_config.h" namespace Ui { @@ -46,15 +47,19 @@ public: protected: void paintEvent(QPaintEvent *e) override; void resizeEvent(QResizeEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; private: class Strip; class Grid; - void buildSections(); void relayout(); - void toggleEmoji(EmojiPtr emoji); + void toggleEmoji(EmojiPtr emoji, bool fromGrid); void notifySelectionChanged(); + void startExpandAnimation(bool expanded); + void applyExpandProgress(); + [[nodiscard]] float64 currentExpandValue() const; + [[nodiscard]] int currentShownHeight() const; const QString _aboutText; const std::vector _recent; @@ -72,6 +77,7 @@ private: Ui::AbstractButton *_expandButton = nullptr; std::unique_ptr _scroll; Grid *_grid = nullptr; + Ui::Animations::Simple _expandAnim; }; From 9c21d99e0156da4615bc2ebd14815034fdcb86fa Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 21:49:55 +0300 Subject: [PATCH 67/78] Improved style of emoji picker overlay with shadow and tail. --- .../SourceFiles/boxes/sticker_creator_box.cpp | 40 +++--- .../chat_helpers/chat_helpers.style | 16 ++- .../chat_helpers/emoji_picker_overlay.cpp | 132 +++++++++++++++--- .../chat_helpers/emoji_picker_overlay.h | 19 +++ 4 files changed, 165 insertions(+), 42 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index 80b891f46c..72f1d69720 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -212,23 +212,25 @@ void CreateStickerBox( .maxSelected = kMaxEmojis, .allowExpand = true, }; - const auto pickerCollapsed = [&] { - auto probe = ChatHelpers::EmojiPickerOverlay(nullptr, pickerDescriptor); - return probe.collapsedHeight(); - }(); - const auto pickerExpanded = [&] { - auto probe = ChatHelpers::EmojiPickerOverlay(nullptr, pickerDescriptor); - return probe.expandedHeight(); - }(); - const auto pickerExtra = pickerExpanded - pickerCollapsed; + const auto metrics = ChatHelpers::EmojiPickerOverlay::EstimateMetrics( + pickerDescriptor.aboutText); + const auto pickerCollapsed = metrics.collapsedHeight; + const auto pickerTotalExpanded = metrics.totalExpandedHeight; + const auto shadowExt = metrics.shadowExtent; + + constexpr auto kStickerOverlap = 24; + const auto stickerTop = shadowExt.top() + + pickerCollapsed + - kStickerOverlap; + const auto holderHeight = std::max( + stickerTop + kPreviewSide, + pickerTotalExpanded); const auto previewHolder = inner->add( object_ptr(inner), QMargins(0, 0, 0, 0), style::al_top); - previewHolder->resize( - st::boxWideWidth, - kPreviewSide + pickerExtra); + previewHolder->resize(st::boxWideWidth, holderHeight); const auto preview = Ui::CreateChild( previewHolder, image); @@ -238,18 +240,20 @@ void CreateStickerBox( std::move(pickerDescriptor)); auto layoutOverlay = [=] { - const auto w = std::min( - previewHolder->width() - 2 * st::boxRowPadding.left(), + const auto bubbleW = std::min( + previewHolder->width() + - 2 * st::boxRowPadding.left() + - shadowExt.left() - shadowExt.right(), int(kPreviewSide * 1.1)); - const auto x = (previewHolder->width() - w) / 2; - const auto y = kPreviewSide - pickerCollapsed; - picker->setGeometry(x, y, w, pickerExpanded); + const auto totalW = bubbleW + shadowExt.left() + shadowExt.right(); + const auto x = (previewHolder->width() - totalW) / 2; + picker->setGeometry(x, 0, totalW, pickerTotalExpanded); picker->raise(); }; previewHolder->widthValue( ) | rpl::on_next([=](int width) { - preview->move((width - kPreviewSide) / 2, 0); + preview->move((width - kPreviewSide) / 2, stickerTop); layoutOverlay(); }, preview->lifetime()); diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index 63f6974e93..2a0eec48d5 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -428,14 +428,19 @@ stickersAddCellPlusSize: 22px; stickersAddCellPlusThickness: 2px; stickersAddCellBgRadius: 28px; -stickersEmojiPickerRadius: 16px; +stickersEmojiPickerExpandedRadius: 20px; stickersEmojiPickerBg: emojiPanBg; stickersEmojiPickerShadow: windowShadowFg; -stickersEmojiPickerPadding: margins(12px, 10px, 12px, 10px); +stickersEmojiPickerPadding: margins(12px, 8px, 12px, 0px); stickersEmojiPickerItemSize: 30px; stickersEmojiPickerItemSkip: 4px; -stickersEmojiPickerStripHeight: 36px; +stickersEmojiPickerStripHeight: 40px; stickersEmojiPickerExpandedHeight: 220px; +stickersEmojiPickerStripBubble: icon{ + { "chat/reactions_bubble_shadow", windowShadowFg }, + { "chat/reactions_bubble", emojiPanBg }, +}; +stickersEmojiPickerStripBubbleRight: 20px; stickersEmojiPickerSelectedBg: windowBgActive; stickersEmojiPickerSelectedFg: windowBgActive; stickersEmojiPickerHeaderFg: windowSubTextFg; @@ -458,6 +463,11 @@ stickersEmojiPickerSectionHeader: FlatLabel(defaultFlatLabel) { stickersEmojiPickerExpandIcon: icon {{ "intro_country_dropdown", windowSubTextFg }}; stickersEmojiPickerCollapseIcon: icon {{ "intro_country_dropdown-flip_vertical", windowSubTextFg }}; stickersEmojiPickerExpandSize: 24px; +stickersEmojiPickerBoxShadow: BoxShadow { + blurRadius: 20px; + offset: point(0px, 6px); + opacity: 0.22; +} emojiStatusDefault: icon {{ "emoji/stickers_premium", emojiIconFg }}; diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp index 10950c523a..4671fa1cac 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp @@ -34,6 +34,11 @@ namespace { return result; } +[[nodiscard]] std::vector DefaultRecentVector() { + const auto src = Ui::Emoji::GetDefaultRecent(); + return std::vector(src.begin(), src.end()); +} + } // namespace class EmojiPickerOverlay::Strip final : public Ui::RpWidget { @@ -354,19 +359,47 @@ void EmojiPickerOverlay::Grid::leaveEventHook(QEvent *e) { updateHover(-1); } +EmojiPickerOverlay::Metrics EmojiPickerOverlay::EstimateMetrics( + const QString &aboutText) { + const auto tailHeight = st::stickersEmojiPickerStripBubble.height(); + const auto shadowExtent = Ui::BoxShadow::ExtendFor( + st::stickersEmojiPickerBoxShadow); + const auto &pad = st::stickersEmojiPickerPadding; + auto about = Ui::FlatLabel( + nullptr, + aboutText, + st::stickersEmojiPickerAbout); + const auto collapsedHeight = pad.top() + + about.height() + + st::stickersEmojiPickerStripHeight + + pad.bottom(); + const auto expandedHeight = collapsedHeight + + st::stickersEmojiPickerExpandedHeight; + const auto shadowAndTail = shadowExtent.top() + + shadowExtent.bottom() + + tailHeight; + return { + .shadowExtent = shadowExtent, + .tailHeight = tailHeight, + .collapsedHeight = collapsedHeight, + .expandedHeight = expandedHeight, + .totalCollapsedHeight = collapsedHeight + shadowAndTail, + .totalExpandedHeight = expandedHeight + shadowAndTail, + }; +} + EmojiPickerOverlay::EmojiPickerOverlay( QWidget *parent, EmojiPickerOverlayDescriptor descriptor) : RpWidget(parent) , _aboutText(std::move(descriptor.aboutText)) , _recent(descriptor.recent.empty() - ? std::vector( - Ui::Emoji::GetDefaultRecent().begin(), - Ui::Emoji::GetDefaultRecent().end()) + ? DefaultRecentVector() : std::move(descriptor.recent)) , _maxSelected(descriptor.maxSelected) , _allowExpand(descriptor.allowExpand) -, _selectedList(std::move(descriptor.initialSelected)) { +, _selectedList(std::move(descriptor.initialSelected)) +, _shadow(st::stickersEmojiPickerBoxShadow) { _allForGrid = BuildAllEmojis(); _about = std::make_unique( @@ -405,7 +438,35 @@ EmojiPickerOverlay::EmojiPickerOverlay( } _selectedVar = _selectedList; - resize(width(), expandedHeight()); + resize(width(), totalExpandedHeight()); +} + +QMargins EmojiPickerOverlay::shadowExtent() const { + return _shadow.extend(); +} + +int EmojiPickerOverlay::totalCollapsedHeight() const { + const auto ext = _shadow.extend(); + return collapsedHeight() + ext.top() + ext.bottom() + tailHeight(); +} + +int EmojiPickerOverlay::totalExpandedHeight() const { + const auto ext = _shadow.extend(); + return expandedHeight() + ext.top() + ext.bottom() + tailHeight(); +} + +QRect EmojiPickerOverlay::bubbleRect() const { + const auto ext = _shadow.extend(); + return QRect( + ext.left(), + ext.top(), + width() - ext.left() - ext.right(), + height() - ext.top() - ext.bottom() - tailHeight()); +} + +QRect EmojiPickerOverlay::bubbleShownRect() const { + const auto r = bubbleRect(); + return QRect(r.x(), r.y(), r.width(), currentShownHeight()); } EmojiPickerOverlay::~EmojiPickerOverlay() = default; @@ -455,8 +516,6 @@ int EmojiPickerOverlay::currentShownHeight() const { } void EmojiPickerOverlay::applyExpandProgress() { - const auto h = currentShownHeight(); - setMask(QRegion(0, 0, width(), h)); if (_scroll) { const auto progress = currentExpandValue(); _scroll->setVisible(progress > 0.); @@ -489,33 +548,63 @@ int EmojiPickerOverlay::expandedHeight() const { void EmojiPickerOverlay::paintEvent(QPaintEvent *e) { auto p = QPainter(this); auto hq = PainterHighQualityEnabler(p); + const auto progress = currentExpandValue(); + const auto shown = bubbleShownRect(); + const auto radius = st::stickersEmojiPickerExpandedRadius; + + _shadow.paint(p, shown, radius); p.setPen(Qt::NoPen); p.setBrush(st::stickersEmojiPickerBg); - const auto radius = st::stickersEmojiPickerRadius; - const auto h = currentShownHeight(); - p.drawRoundedRect(QRect(0, 0, width(), h), radius, radius); + p.drawRoundedRect(shown, radius, radius); + + if (progress < 1.) { + paintTailBubble(p, shown, 1. - progress); + } +} + +void EmojiPickerOverlay::paintTailBubble( + QPainter &p, + const QRect &bubble, + float64 opacity) { + const auto &icon = st::stickersEmojiPickerStripBubble; + const auto offsetRight = st::stickersEmojiPickerStripBubbleRight; + const auto x = bubble.right() + 1 - offsetRight - icon.width(); + const auto y = bubble.bottom() + 1; + if (opacity >= 1.) { + icon.paint(p, x, y, width()); + } else { + p.save(); + p.setOpacity(opacity); + icon.paint(p, x, y, width()); + p.restore(); + } } void EmojiPickerOverlay::resizeEvent(QResizeEvent *e) { - setMask(QRegion(0, 0, width(), currentShownHeight())); relayout(); } void EmojiPickerOverlay::mousePressEvent(QMouseEvent *e) { - if (e->pos().y() > currentShownHeight()) { + if (!bubbleShownRect().contains(e->pos())) { e->ignore(); } } +int EmojiPickerOverlay::tailHeight() const { + return st::stickersEmojiPickerStripBubble.height(); +} + void EmojiPickerOverlay::relayout() { const auto &pad = st::stickersEmojiPickerPadding; + const auto bubble = bubbleRect(); + const auto bubbleShown = currentShownHeight(); if (_about) { - _about->resizeToWidth(width() - pad.left() - pad.right()); - _about->moveToLeft(pad.left(), pad.top()); + _about->resizeToWidth(bubble.width() - pad.left() - pad.right()); + _about->moveToLeft(bubble.left() + pad.left(), bubble.top() + pad.top()); } const auto aboutBottom = _about ? (_about->y() + _about->height()) - : pad.top(); + : (bubble.top() + pad.top()); const auto stripTop = aboutBottom; const auto stripH = st::stickersEmojiPickerStripHeight; @@ -525,29 +614,30 @@ void EmojiPickerOverlay::relayout() { const auto expandGap = _expandButton ? st::stickersEmojiPickerItemSkip : 0; - const auto stripW = width() + const auto stripW = bubble.width() - pad.left() - pad.right() - expandSize - expandGap; - _strip->setGeometry(pad.left(), stripTop, stripW, stripH); + _strip->setGeometry(bubble.left() + pad.left(), stripTop, stripW, stripH); _strip->refresh(); if (_expandButton) { - const auto bx = width() - pad.right() - expandSize; + const auto bx = bubble.right() + 1 - pad.right() - expandSize; const auto by = stripTop + (stripH - expandSize) / 2; _expandButton->moveToLeft(bx, by); } if (_scroll) { const auto scrollTop = stripTop + stripH; + const auto bubbleBottom = bubble.top() + bubbleShown; const auto scrollH = std::max( 0, - height() - scrollTop - pad.bottom()); + bubbleBottom - scrollTop - pad.bottom()); _scroll->setGeometry( - pad.left(), + bubble.left() + pad.left(), scrollTop, - width() - pad.left() - pad.right(), + bubble.width() - pad.left() - pad.right(), scrollH); if (_grid) { _grid->resizeGetHeight(_scroll->width()); diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h index 0919cdab80..80432ae8b4 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h @@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/rp_widget.h" #include "ui/effects/animations.h" #include "ui/emoji_config.h" +#include "ui/widgets/shadow.h" namespace Ui { class AbstractButton; @@ -34,6 +35,16 @@ public: EmojiPickerOverlayDescriptor descriptor); ~EmojiPickerOverlay(); + struct Metrics { + QMargins shadowExtent; + int tailHeight = 0; + int collapsedHeight = 0; + int expandedHeight = 0; + int totalCollapsedHeight = 0; + int totalExpandedHeight = 0; + }; + [[nodiscard]] static Metrics EstimateMetrics(const QString &aboutText); + [[nodiscard]] const std::vector &selected() const; [[nodiscard]] rpl::producer> selectedValue() const; @@ -43,6 +54,9 @@ public: [[nodiscard]] int collapsedHeight() const; [[nodiscard]] int expandedHeight() const; + [[nodiscard]] QMargins shadowExtent() const; + [[nodiscard]] int totalCollapsedHeight() const; + [[nodiscard]] int totalExpandedHeight() const; protected: void paintEvent(QPaintEvent *e) override; @@ -58,8 +72,12 @@ private: void notifySelectionChanged(); void startExpandAnimation(bool expanded); void applyExpandProgress(); + void paintTailBubble(QPainter &p, const QRect &bubble, float64 opacity); [[nodiscard]] float64 currentExpandValue() const; [[nodiscard]] int currentShownHeight() const; + [[nodiscard]] int tailHeight() const; + [[nodiscard]] QRect bubbleRect() const; + [[nodiscard]] QRect bubbleShownRect() const; const QString _aboutText; const std::vector _recent; @@ -78,6 +96,7 @@ private: std::unique_ptr _scroll; Grid *_grid = nullptr; Ui::Animations::Simple _expandAnim; + Ui::BoxShadow _shadow; }; From e9944ba83d749a20c7b3f502cbd244b1efaf6170 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 13 Apr 2026 22:16:56 +0300 Subject: [PATCH 68/78] Slightly improved style of scrollbar in emoji picker overlay. --- .../SourceFiles/chat_helpers/chat_helpers.style | 7 +++++++ .../chat_helpers/emoji_picker_overlay.cpp | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index 2a0eec48d5..9df1cf2c8e 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -444,6 +444,12 @@ stickersEmojiPickerStripBubbleRight: 20px; stickersEmojiPickerSelectedBg: windowBgActive; stickersEmojiPickerSelectedFg: windowBgActive; stickersEmojiPickerHeaderFg: windowSubTextFg; +stickersEmojiPickerScroll: ScrollArea(boxScroll) { + width: 14px; + deltax: 5px; + deltat: 4px; + deltab: 18px; +} stickersEmojiPickerAbout: FlatLabel(defaultFlatLabel) { minWidth: 100px; align: align(top); @@ -463,6 +469,7 @@ stickersEmojiPickerSectionHeader: FlatLabel(defaultFlatLabel) { stickersEmojiPickerExpandIcon: icon {{ "intro_country_dropdown", windowSubTextFg }}; stickersEmojiPickerCollapseIcon: icon {{ "intro_country_dropdown-flip_vertical", windowSubTextFg }}; stickersEmojiPickerExpandSize: 24px; +stickersEmojiPickerExpandBg: windowBgRipple; stickersEmojiPickerBoxShadow: BoxShadow { blurRadius: 20px; offset: point(0px, 6px); diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp index 4671fa1cac..da24cd1d80 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp @@ -420,6 +420,10 @@ EmojiPickerOverlay::EmojiPickerOverlay( _expandButton->paintRequest( ) | rpl::on_next([=](const QRect &clip) { auto p = QPainter(_expandButton); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(st::stickersEmojiPickerExpandBg); + p.drawEllipse(_expandButton->rect()); const auto &icon = _expanded.current() ? st::stickersEmojiPickerCollapseIcon : st::stickersEmojiPickerExpandIcon; @@ -428,7 +432,9 @@ EmojiPickerOverlay::EmojiPickerOverlay( icon.paint(p, x, y, _expandButton->width()); }, _expandButton->lifetime()); - _scroll = std::make_unique(this); + _scroll = std::make_unique( + this, + st::stickersEmojiPickerScroll); _scroll->setFrameStyle(QFrame::NoFrame); _scroll->hide(); const auto gridPtr = _scroll->setOwnedWidget( @@ -634,13 +640,18 @@ void EmojiPickerOverlay::relayout() { const auto scrollH = std::max( 0, bubbleBottom - scrollTop - pad.bottom()); + const auto scrollContentWidth = bubble.width() + - pad.left() + - pad.right(); + const auto scrollAreaWidth = scrollContentWidth + + pad.right(); _scroll->setGeometry( bubble.left() + pad.left(), scrollTop, - bubble.width() - pad.left() - pad.right(), + scrollAreaWidth, scrollH); if (_grid) { - _grid->resizeGetHeight(_scroll->width()); + _grid->resizeGetHeight(scrollContentWidth); } } } From 2a816019b6acb91668a9355d264a9c6bcad23e6f Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Tue, 14 Apr 2026 06:54:33 +0300 Subject: [PATCH 69/78] Fixed sticker creator image fitting exactly inside canvas square. --- Telegram/SourceFiles/boxes/sticker_creator_box.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp index 72f1d69720..6a0c116de9 100644 --- a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -33,6 +33,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "window/window_session_controller.h" #include "styles/style_boxes.h" #include "styles/style_chat_helpers.h" +#include "styles/style_editor.h" #include "styles/style_layers.h" #include @@ -93,7 +94,9 @@ void OpenPhotoEditorForSticker( const auto windowController = &sessionController->window(); const auto parentWidget = sessionController->widget(); - if ((image.width() > 10 * image.height()) + if (image.width() <= 0 + || image.height() <= 0 + || (image.width() > 10 * image.height()) || (image.height() > 10 * image.width())) { show->showToast(tr::lng_stickers_create_open_failed(tr::now)); return; @@ -114,10 +117,15 @@ void OpenPhotoEditorForSticker( const auto fitted = userSize.scaled( QSize(kStickerSide, kStickerSide), Qt::KeepAspectRatio); + const auto handle = st::photoEditorItemHandleSize; + const auto itemSize = (userSize.width() >= userSize.height()) + ? int((fitted.height() + handle) + * userSize.width() / float64(userSize.height())) + : (fitted.width() + handle); auto itemData = Editor::ItemBase::Data{ .initialZoom = 1.0, .zPtr = scene->lastZ(), - .size = fitted.width(), + .size = itemSize, .x = kStickerSide / 2, .y = kStickerSide / 2, .imageSize = userSize, From d7cfa68a3f40a80611d91ebf1d8610e3176f9544 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Tue, 14 Apr 2026 08:31:57 +0300 Subject: [PATCH 70/78] [img-editor] Added wheel zoom and middle-button panning for scene items. --- Telegram/SourceFiles/editor/editor_paint.cpp | 67 +++++++++++++++++-- Telegram/SourceFiles/editor/editor_paint.h | 8 ++- .../editor/photo_editor_content.cpp | 48 ++++++++++++- .../SourceFiles/editor/photo_editor_content.h | 1 + 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/Telegram/SourceFiles/editor/editor_paint.cpp b/Telegram/SourceFiles/editor/editor_paint.cpp index d0cd23f2ab..5a28ee302f 100644 --- a/Telegram/SourceFiles/editor/editor_paint.cpp +++ b/Telegram/SourceFiles/editor/editor_paint.cpp @@ -36,6 +36,9 @@ constexpr auto kMinCanvasZoom = 1.; constexpr auto kMaxCanvasZoom = 8.; constexpr auto kCanvasZoomStep = 1.15; constexpr auto kZoomEpsilon = 0.0001; +constexpr auto kMinItemZoom = 0.1; +constexpr auto kMaxItemZoom = 10.; +constexpr auto kCanvasZoomStepFine = 1.015; std::shared_ptr EnsureScene( PhotoModifications &mods, @@ -55,13 +58,15 @@ Paint::Paint( PhotoModifications &modifications, const QSize &imageSize, std::shared_ptr controllers, - Fn blurSource) + Fn blurSource, + bool fixedCrop) : RpWidget(parent) , _controllers(controllers) , _scene(EnsureScene(modifications, imageSize)) , _view(base::make_unique_q(_scene.get(), this)) , _viewport(_view->viewport()) -, _imageSize(imageSize) { +, _imageSize(imageSize) +, _fixedCrop(fixedCrop) { Expects(modifications.paint != nullptr); _scene->setBlurSource(std::move(blurSource)); @@ -165,6 +170,51 @@ Paint::Paint( } +bool Paint::zoomSceneItems(float64 wheelDelta, bool fine) { + if (!wheelDelta) { + return false; + } + const auto step = wheelDelta + / float64(QWheelEvent::DefaultDeltasPerStep); + const auto base = fine ? kCanvasZoomStepFine : kCanvasZoomStep; + const auto factor = std::pow(base, step); + const auto center = rect::center(_scene->sceneRect()); + auto applied = false; + for (const auto &item : _scene->items()) { + const auto raw = item.get(); + const auto oldScale = raw->scale(); + const auto newScale = std::clamp( + oldScale * factor, + kMinItemZoom, + kMaxItemZoom); + if (std::abs(newScale - oldScale) < kZoomEpsilon) { + continue; + } + const auto ratio = newScale / oldScale; + raw->setScale(newScale); + const auto pos = raw->pos(); + raw->setPos(center + (pos - center) * ratio); + applied = true; + } + return applied; +} + +void Paint::panSceneItems(QPointF sceneDelta) { + if (sceneDelta.isNull()) { + return; + } + for (const auto &item : _scene->items()) { + item->setPos(item->pos() + sceneDelta); + } +} + +QPointF Paint::mapWidgetDeltaToScene(QPoint delta) const { + if (!_view) { + return QPointF(delta); + } + return _view->mapToScene(delta) - _view->mapToScene(QPoint()); +} + Paint::~Paint() { if (_viewport) { _viewport->removeEventFilter(this); @@ -393,6 +443,12 @@ bool Paint::eventFilter(QObject *obj, QEvent *e) { return true; } + if (_fixedCrop) { + zoomSceneItems( + delta, + wheel->modifiers().testFlag(Qt::ShiftModifier)); + return true; + } const auto step = delta / float64(QWheelEvent::DefaultDeltasPerStep); const auto factor = std::pow(kCanvasZoomStep, step); const auto newZoom = std::clamp( @@ -421,7 +477,8 @@ bool Paint::eventFilter(QObject *obj, QEvent *e) { const auto mouse = static_cast(e); if (mouse->button() == Qt::MiddleButton) { _pan = { - .active = (_transform.userZoom > kMinCanvasZoom), + .active = (_fixedCrop + || _transform.userZoom > kMinCanvasZoom), .point = mouse->pos(), }; if (_pan.active) { @@ -436,7 +493,9 @@ bool Paint::eventFilter(QObject *obj, QEvent *e) { const auto delta = point - _pan.point; _pan.point = point; - if (_transform.userZoom > kMinCanvasZoom) { + if (_fixedCrop) { + panSceneItems(mapWidgetDeltaToScene(delta)); + } else if (_transform.userZoom > kMinCanvasZoom) { view->horizontalScrollBar()->setValue( view->horizontalScrollBar()->value() - delta.x()); view->verticalScrollBar()->setValue( diff --git a/Telegram/SourceFiles/editor/editor_paint.h b/Telegram/SourceFiles/editor/editor_paint.h index a1dcf00c72..8d100ee4ad 100644 --- a/Telegram/SourceFiles/editor/editor_paint.h +++ b/Telegram/SourceFiles/editor/editor_paint.h @@ -29,7 +29,8 @@ public: PhotoModifications &modifications, const QSize &imageSize, std::shared_ptr controllers, - Fn blurSource); + Fn blurSource, + bool fixedCrop = false); ~Paint() override; [[nodiscard]] std::shared_ptr saveScene() const; @@ -55,6 +56,10 @@ public: void paintImage(QPainter &p, const QPixmap &image) const; void resetView(); + bool zoomSceneItems(float64 wheelDelta, bool fine = false); + void panSceneItems(QPointF sceneDelta); + [[nodiscard]] QPointF mapWidgetDeltaToScene(QPoint delta) const; + private: bool eventFilter(QObject *obj, QEvent *e) override; void updateViewGeometry(); @@ -74,6 +79,7 @@ private: const base::unique_qptr _view; QPointer _viewport; const QSize _imageSize; + const bool _fixedCrop = false; QRect _imageGeometry; QRect _outerGeometry; diff --git a/Telegram/SourceFiles/editor/photo_editor_content.cpp b/Telegram/SourceFiles/editor/photo_editor_content.cpp index 6b0be6f9fc..97a56a706b 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_content.cpp @@ -13,6 +13,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "media/view/media_view_pip.h" #include "storage/storage_media_prepare.h" +#include +#include + namespace Editor { using Media::View::FlipSizeByRotation; @@ -26,6 +29,7 @@ PhotoEditorContent::PhotoEditorContent( EditorData data) : RpWidget(parent) , _photoSize(photo->size()) +, _fixedCrop(data.fixedCrop) , _paint(base::make_unique_q( this, modifications, @@ -42,7 +46,8 @@ PhotoEditorContent::PhotoEditorContent( auto result = img.copy(pixelRect.intersected(img.rect())); result.setDevicePixelRatio(dpr); return result; - })) + }, + data.fixedCrop)) , _crop(base::make_unique_q( this, modifications, @@ -110,6 +115,47 @@ PhotoEditorContent::PhotoEditorContent( }, lifetime()); setupDragArea(); + + if (_fixedCrop) { + const auto pan = _crop->lifetime().make_state< + std::optional + >(); + _crop->events( + ) | rpl::on_next([=](not_null e) { + const auto type = e->type(); + if (type == QEvent::Wheel) { + const auto wheel = static_cast(e.get()); + _paint->zoomSceneItems( + wheel->angleDelta().y(), + wheel->modifiers().testFlag(Qt::ShiftModifier)); + e->accept(); + } else if (type == QEvent::MouseButtonPress) { + const auto mouse = static_cast(e.get()); + if (mouse->button() == Qt::MiddleButton) { + *pan = mouse->pos(); + _crop->setCursor(Qt::ClosedHandCursor); + e->accept(); + } + } else if (type == QEvent::MouseMove) { + if (pan->has_value()) { + const auto mouse = static_cast(e.get()); + const auto point = mouse->pos(); + const auto delta = point - **pan; + *pan = point; + _paint->panSceneItems( + _paint->mapWidgetDeltaToScene(delta)); + e->accept(); + } + } else if (type == QEvent::MouseButtonRelease) { + const auto mouse = static_cast(e.get()); + if (mouse->button() == Qt::MiddleButton && pan->has_value()) { + pan->reset(); + _crop->unsetCursor(); + e->accept(); + } + } + }, _crop->lifetime()); + } } void PhotoEditorContent::applyModifications( diff --git a/Telegram/SourceFiles/editor/photo_editor_content.h b/Telegram/SourceFiles/editor/photo_editor_content.h index 085ace2344..50dbfff3ad 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.h +++ b/Telegram/SourceFiles/editor/photo_editor_content.h @@ -54,6 +54,7 @@ public: private: const QSize _photoSize; + const bool _fixedCrop = false; const base::unique_qptr _paint; const base::unique_qptr _crop; const std::shared_ptr _photo; From 83866089539e8bead0ea8c180c77bcc30c10f469 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Tue, 14 Apr 2026 07:58:42 +0300 Subject: [PATCH 71/78] Added ability to delete owned sticker set from sticker set box. --- Telegram/Resources/langs/lang.strings | 5 + .../SourceFiles/api/api_stickers_creator.cpp | 20 ++++ .../SourceFiles/api/api_stickers_creator.h | 6 ++ .../SourceFiles/boxes/sticker_set_box.cpp | 102 ++++++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 8fa9c856ea..ae7828b7fe 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -4503,6 +4503,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_stickers_context_edit_name" = "Edit name"; "lng_stickers_context_delete" = "Delete sticker"; "lng_stickers_context_delete_sure" = "Are you sure you want to delete the sticker from your sticker set?"; +"lng_stickers_context_delete_pack" = "Delete Pack"; +"lng_stickers_context_delete_pack_everyone" = "Delete for Everyone"; +"lng_stickers_context_delete_pack_self" = "Delete for Myself"; +"lng_stickers_delete_pack_sure" = "Are you sure you want to delete this sticker set for everyone? This cannot be undone."; +"lng_stickers_bot_more_options" = "Check the {bot} bot for more options"; "lng_stickers_box_edit_name_title" = "Edit Sticker Set Name"; "lng_stickers_box_edit_name_about" = "Choose a name for your set."; "lng_stickers_creator_badge" = "edit"; diff --git a/Telegram/SourceFiles/api/api_stickers_creator.cpp b/Telegram/SourceFiles/api/api_stickers_creator.cpp index b21b33e1ee..405719e2c4 100644 --- a/Telegram/SourceFiles/api/api_stickers_creator.cpp +++ b/Telegram/SourceFiles/api/api_stickers_creator.cpp @@ -105,6 +105,26 @@ void AddExistingStickerToSet( }).handleFloodErrors().send(); } +void DeleteStickerSet( + not_null session, + const StickerSetIdentifier &set, + Fn done, + Fn fail) { + session->api().request(MTPstickers_DeleteStickerSet( + Data::InputStickerSet(set)) + ).done([=] { + session->data().stickers().notifyUpdated( + Data::StickersType::Stickers); + if (done) { + done(); + } + }).fail([=](const MTP::Error &error) { + if (fail) { + fail(error.type()); + } + }).send(); +} + StickerUpload::StickerUpload( not_null session, StickerSetIdentifier set, diff --git a/Telegram/SourceFiles/api/api_stickers_creator.h b/Telegram/SourceFiles/api/api_stickers_creator.h index 718b53900e..390fe72e5a 100644 --- a/Telegram/SourceFiles/api/api_stickers_creator.h +++ b/Telegram/SourceFiles/api/api_stickers_creator.h @@ -27,6 +27,12 @@ void AddExistingStickerToSet( Fn done, Fn fail); +void DeleteStickerSet( + not_null session, + const StickerSetIdentifier &set, + Fn done, + Fn fail); + class StickerUpload final : public base::has_weak_ptr { public: StickerUpload( diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index 0845973981..61d45c4ce2 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -18,6 +18,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "chat_helpers/stickers_list_widget.h" #include "chat_helpers/stickers_lottie.h" #include "core/application.h" +#include "core/click_handler_types.h" #include "data/data_document.h" #include "data/data_document_media.h" #include "data/data_file_origin.h" @@ -54,8 +55,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/buttons.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/gradient_round_button.h" +#include "ui/widgets/menu/menu_action.h" #include "ui/widgets/menu/menu_add_action_callback.h" #include "ui/widgets/menu/menu_add_action_callback_factory.h" +#include "ui/widgets/menu/menu_multiline_action.h" #include "base/event_filter.h" #include "chat_helpers/tabbed_panel.h" #include "chat_helpers/tabbed_selector.h" @@ -63,6 +66,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/popup_menu.h" #include "ui/widgets/scroll_area.h" #include "window/window_session_controller.h" +#include "styles/style_chat.h" #include "styles/style_layers.h" #include "styles/style_chat_helpers.h" #include "styles/style_info.h" @@ -794,6 +798,98 @@ void StickerSetBox::updateButtons() { &st::menuIconReorder); }); }(); + const auto fillSetCreatorFooter = [&] { + using Filler = Fn)>; + if (!_inner->amSetCreator()) { + return Filler(nullptr); + } + const auto data = &_session->data(); + return Filler([=, show = _show, set = _set]( + not_null menu) { + const auto weak = base::weak_qptr(this); + const auto deleteEveryone = [=] { + const auto confirm = [=](Fn close) { + Api::DeleteStickerSet( + &data->session(), + set, + [=] { + if (const auto strong = weak.get()) { + strong->closeBox(); + } + }, + [=](const QString &error) { + show->showToast(error); + }); + close(); + }; + show->showBox(Ui::MakeConfirmBox({ + .text = tr::lng_stickers_delete_pack_sure(tr::now), + .confirmed = confirm, + .confirmText + = tr::lng_stickers_remove_pack_confirm(), + .confirmStyle = &st::attentionBoxButton, + })); + }; + const auto deleteSelf = [show, inner = _inner] { + const auto raw = inner.data(); + if (!raw) { + return; + } + auto box = ChatHelpers::MakeConfirmRemoveSetBox( + &show->session(), + st::boxLabel, + raw->setId()); + if (box) { + show->showBox(std::move(box)); + } + }; + const auto deleteAction = menu->addAction( + base::make_unique_q( + menu->menu(), + st::menuWithIconsAttention, + Ui::Menu::CreateAction( + menu->menu().get(), + tr::lng_stickers_context_delete_pack(tr::now), + nullptr), + &st::menuIconDeleteAttention, + &st::menuIconDeleteAttention)); + deleteAction->setMenu( + Ui::CreateChild(menu->menu().get())); + const auto sub = menu->ensureSubmenu( + deleteAction, + st::popupMenuWithIcons); + const auto addSub = Ui::Menu::CreateAddActionCallback(sub); + addSub({ + .text = tr::lng_stickers_context_delete_pack_everyone( + tr::now), + .handler = deleteEveryone, + .icon = &st::menuIconDeleteAttention, + .isAttention = true, + }); + sub->addAction( + tr::lng_stickers_context_delete_pack_self(tr::now), + deleteSelf, + &st::menuIconRemove); + menu->addSeparator(&st::expandedMenuSeparator); + auto item = base::make_unique_q( + menu->menu(), + st::defaultMenu, + st::historyHasCustomEmoji, + QPoint( + st::defaultMenu.itemPadding.left(), + st::defaultMenu.itemPadding.top()), + tr::lng_stickers_bot_more_options( + tr::now, + lt_bot, + Ui::Text::Colorized(tr::bold(u"@stickers"_q)), + Ui::Text::RichLangValue)); + item->clicks( + ) | rpl::on_next([] { + UrlClickHandler::Open(u"https://t.me/stickers"_q); + }, item->lifetime()); + menu->addAction(std::move(item)); + }); + }(); if (_inner->notInstalled()) { if (!_session->premium() && _session->premiumPossible() @@ -841,6 +937,9 @@ void StickerSetBox::updateButtons() { : tr::lng_stickers_share_pack)(tr::now), [=] { share(); closeBox(); }, &st::menuIconShare); + if (fillSetCreatorFooter) { + fillSetCreatorFooter(*menu); + } (*menu)->popup(QCursor::pos()); return true; }); @@ -892,6 +991,9 @@ void StickerSetBox::updateButtons() { : tr::lng_stickers_archive_pack(tr::now)), archive, &st::menuIconArchive); + if (fillSetCreatorFooter) { + fillSetCreatorFooter(*menu); + } } (*menu)->popup(QCursor::pos()); return true; From 640d1f459b76e8fce35790a07c58eb2dbb4ce68a Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 13:30:41 +0300 Subject: [PATCH 72/78] [img-editor] Fixed wheel delta with pressed Shift while zoom canvas. --- Telegram/SourceFiles/editor/editor_paint.cpp | 3 ++- Telegram/SourceFiles/editor/photo_editor_content.cpp | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Telegram/SourceFiles/editor/editor_paint.cpp b/Telegram/SourceFiles/editor/editor_paint.cpp index 5a28ee302f..15577df5c8 100644 --- a/Telegram/SourceFiles/editor/editor_paint.cpp +++ b/Telegram/SourceFiles/editor/editor_paint.cpp @@ -438,7 +438,8 @@ bool Paint::eventFilter(QObject *obj, QEvent *e) { } if (e->type() == QEvent::Wheel) { const auto wheel = static_cast(e); - const auto delta = wheel->angleDelta().y(); + const auto raw = wheel->angleDelta(); + const auto delta = raw.y() ? raw.y() : raw.x(); if (!delta) { return true; } diff --git a/Telegram/SourceFiles/editor/photo_editor_content.cpp b/Telegram/SourceFiles/editor/photo_editor_content.cpp index 97a56a706b..ef83d6b8a8 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_content.cpp @@ -125,8 +125,9 @@ PhotoEditorContent::PhotoEditorContent( const auto type = e->type(); if (type == QEvent::Wheel) { const auto wheel = static_cast(e.get()); + const auto raw = wheel->angleDelta(); _paint->zoomSceneItems( - wheel->angleDelta().y(), + raw.y() ? raw.y() : raw.x(), wheel->modifiers().testFlag(Qt::ShiftModifier)); e->accept(); } else if (type == QEvent::MouseButtonPress) { From 4b26971b48d18bf334b4b52fa76e7deab4affa1b Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 15:37:58 +0300 Subject: [PATCH 73/78] Added menu action with dynamic image thumbnail. --- .../menu/menu_action_with_thumbnail.cpp | 47 +++++++++++++++++++ .../menu/menu_action_with_thumbnail.h | 37 +++++++++++++++ Telegram/cmake/td_ui.cmake | 2 + 3 files changed, 86 insertions(+) create mode 100644 Telegram/SourceFiles/menu/menu_action_with_thumbnail.cpp create mode 100644 Telegram/SourceFiles/menu/menu_action_with_thumbnail.h diff --git a/Telegram/SourceFiles/menu/menu_action_with_thumbnail.cpp b/Telegram/SourceFiles/menu/menu_action_with_thumbnail.cpp new file mode 100644 index 0000000000..c354748979 --- /dev/null +++ b/Telegram/SourceFiles/menu/menu_action_with_thumbnail.cpp @@ -0,0 +1,47 @@ +/* +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 "menu/menu_action_with_thumbnail.h" + +#include "ui/dynamic_image.h" +#include "ui/painter.h" + +namespace Menu { + +ActionWithThumbnail::ActionWithThumbnail( + not_null parent, + const style::Menu &st, + not_null action, + std::shared_ptr thumbnail, + int thumbnailSize) +: Ui::Menu::Action(parent, st, action, nullptr, nullptr) +, _thumbnail(std::move(thumbnail)) +, _thumbnailSize(thumbnailSize) { + if (_thumbnail) { + _thumbnail->subscribeToUpdates([=] { update(); }); + } +} + +ActionWithThumbnail::~ActionWithThumbnail() { + if (_thumbnail) { + _thumbnail->subscribeToUpdates(nullptr); + } +} + +void ActionWithThumbnail::paintEvent(QPaintEvent *e) { + Ui::Menu::Action::paintEvent(e); + if (!_thumbnail) { + return; + } + auto p = QPainter(this); + const auto pos = st().itemIconPosition; + p.drawImage( + QRect(pos.x(), pos.y(), _thumbnailSize, _thumbnailSize), + _thumbnail->image(_thumbnailSize)); +} + +} // namespace Menu diff --git a/Telegram/SourceFiles/menu/menu_action_with_thumbnail.h b/Telegram/SourceFiles/menu/menu_action_with_thumbnail.h new file mode 100644 index 0000000000..42831204b1 --- /dev/null +++ b/Telegram/SourceFiles/menu/menu_action_with_thumbnail.h @@ -0,0 +1,37 @@ +/* +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/widgets/menu/menu_action.h" + +namespace Ui { +class DynamicImage; +} // namespace Ui + +namespace Menu { + +class ActionWithThumbnail final : public Ui::Menu::Action { +public: + ActionWithThumbnail( + not_null parent, + const style::Menu &st, + not_null action, + std::shared_ptr thumbnail, + int thumbnailSize); + ~ActionWithThumbnail(); + +protected: + void paintEvent(QPaintEvent *e) override; + +private: + std::shared_ptr _thumbnail; + int _thumbnailSize = 0; + +}; + +} // namespace Menu diff --git a/Telegram/cmake/td_ui.cmake b/Telegram/cmake/td_ui.cmake index 840ab71395..6fdf4ec5f1 100644 --- a/Telegram/cmake/td_ui.cmake +++ b/Telegram/cmake/td_ui.cmake @@ -223,6 +223,8 @@ PRIVATE menu/gift_resale_filter.cpp menu/gift_resale_filter.h + menu/menu_action_with_thumbnail.cpp + menu/menu_action_with_thumbnail.h menu/menu_checked_action.cpp menu/menu_checked_action.h menu/menu_check_item.cpp From 7b22a2aea7980e0e4161ae3253eef4fbfb1f9332 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 14:28:26 +0300 Subject: [PATCH 74/78] Added Add-to-Sticker-Set submenu for stickers from foreign packs. --- Telegram/Resources/langs/lang.strings | 3 + .../SourceFiles/api/api_stickers_creator.cpp | 137 ++++++++++++++++++ .../SourceFiles/api/api_stickers_creator.h | 28 ++++ .../SourceFiles/boxes/sticker_set_box.cpp | 15 +- .../history/history_inner_widget.cpp | 6 + .../view/history_view_context_menu.cpp | 8 + .../SourceFiles/ui/dynamic_thumbnails.cpp | 10 ++ Telegram/SourceFiles/ui/dynamic_thumbnails.h | 3 + 8 files changed, 203 insertions(+), 7 deletions(-) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index ae7828b7fe..2bcb2a3b06 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -4514,6 +4514,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_stickers_create_new" = "Create a New Sticker"; "lng_stickers_add_existing" = "Add an Existing Sticker"; +"lng_stickers_add_to_set" = "Add to Sticker Set"; +"lng_stickers_already_in_set" = "This Sticker is already in the Set."; +"lng_stickers_set_is_full" = "This Sticker Set is full."; "lng_stickers_pack_choose_emoji_title" = "Choose Emoji"; "lng_stickers_pack_choose_emoji_about" = "Pick an emoji that corresponds to this sticker."; "lng_stickers_pick_existing_title" = "Choose Sticker"; diff --git a/Telegram/SourceFiles/api/api_stickers_creator.cpp b/Telegram/SourceFiles/api/api_stickers_creator.cpp index 405719e2c4..d9d86d9301 100644 --- a/Telegram/SourceFiles/api/api_stickers_creator.cpp +++ b/Telegram/SourceFiles/api/api_stickers_creator.cpp @@ -10,13 +10,23 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "apiwrap.h" #include "base/random.h" #include "base/unixtime.h" +#include "chat_helpers/compose/compose_show.h" #include "data/data_document.h" +#include "data/data_file_origin.h" #include "data/data_session.h" #include "data/stickers/data_stickers.h" #include "data/stickers/data_stickers_set.h" +#include "lang/lang_keys.h" #include "main/main_session.h" +#include "menu/menu_action_with_thumbnail.h" #include "storage/file_upload.h" #include "storage/localimageloader.h" +#include "styles/style_menu_icons.h" +#include "styles/style_widgets.h" +#include "ui/dynamic_thumbnails.h" +#include "ui/widgets/menu/menu_add_action_callback.h" +#include "ui/widgets/menu/menu_common.h" +#include "ui/widgets/popup_menu.h" namespace Api { namespace { @@ -81,6 +91,35 @@ void FeedSetIfFull( }); } +template +void EnumerateOwnedStickerSets( + not_null session, + Callback &&callback) { + const auto &stickers = session->data().stickers(); + const auto &sets = stickers.sets(); + for (const auto setId : stickers.setsOrder()) { + const auto it = sets.find(setId); + if (it == sets.end()) { + continue; + } + const auto set = it->second.get(); + if (!(set->flags & Data::StickersSetFlag::AmCreator) + || (set->type() != Data::StickersType::Stickers)) { + continue; + } + using namespace Data; + if constexpr (std::is_same_v< + bool, + std::invoke_result_t>>) { + if (!callback(set)) { + return; + } + } else { + callback(set); + } + } +} + } // namespace void AddExistingStickerToSet( @@ -105,6 +144,104 @@ void AddExistingStickerToSet( }).handleFloodErrors().send(); } +QString StickerEmojiOrDefault(not_null document) { + if (const auto sticker = document->sticker()) { + if (!sticker->alt.isEmpty()) { + return sticker->alt; + } + } + return QString::fromUtf8("\xF0\x9F\x99\x82"); +} + +bool HasOwnedStickerSets(not_null session) { + auto found = false; + EnumerateOwnedStickerSets(session, [&](not_null) { + found = true; + return false; + }); + return found; +} + +void FillChooseStickerSetMenu( + not_null menu, + std::shared_ptr show, + not_null document) { + const auto session = &show->session(); + const auto emoji = StickerEmojiOrDefault(document); + const auto failToast = [=](QString err) { + show->showToast(err.isEmpty() + ? tr::lng_attach_failed(tr::now) + : err); + }; + EnumerateOwnedStickerSets(session, [&](not_null set) { + const auto identifier = set->identifier(); + const auto coverDocument = set->lookupThumbnailDocument(); + auto thumbnail = coverDocument + ? Ui::MakeDocumentThumbnail( + coverDocument, + Data::FileOriginStickerSet(set->id, set->accessHash)) + : nullptr; + const auto targetSetId = set->id; + const auto handler = crl::guard(session, [=] { + const auto &map = session->data().stickers().sets(); + const auto i = map.find(targetSetId); + if (i != map.end() + && i->second->count >= kStickersInOwnedSetMax) { + show->showToast(tr::lng_stickers_set_is_full(tr::now)); + return; + } + const auto oldCount = (i != map.end()) + ? i->second->count + : 0; + AddExistingStickerToSet( + session, + identifier, + document, + emoji, + crl::guard(session, [=](MTPmessages_StickerSet) { + const auto &map = session->data().stickers().sets(); + const auto i = map.find(targetSetId); + const auto newCount = (i != map.end()) + ? i->second->count + : oldCount; + show->showToast(newCount > oldCount + ? tr::lng_stickers_create_added(tr::now) + : tr::lng_stickers_already_in_set(tr::now)); + }), + crl::guard(session, failToast)); + }); + const auto rawAction = Ui::Menu::CreateAction( + menu.get(), + set->title, + handler); + auto item = base::make_unique_q( + menu->menu(), + menu->menu()->st(), + rawAction, + std::move(thumbnail), + st::menuIconStickerAdd.width()); + menu->addAction(std::move(item)); + }); +} + +void AddAddToStickerSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document) { + const auto session = &show->session(); + if (!HasOwnedStickerSets(session)) { + return; + } + addAction({ + .text = tr::lng_stickers_add_to_set(tr::now), + .icon = &st::menuIconStickerAdd, + .fillSubmenu = [show, document](not_null submenu) { + FillChooseStickerSetMenu(submenu, show, document); + }, + .submenuSt = &st::popupMenuWithIcons, + }); +} + void DeleteStickerSet( not_null session, const StickerSetIdentifier &set, diff --git a/Telegram/SourceFiles/api/api_stickers_creator.h b/Telegram/SourceFiles/api/api_stickers_creator.h index 390fe72e5a..a97affca18 100644 --- a/Telegram/SourceFiles/api/api_stickers_creator.h +++ b/Telegram/SourceFiles/api/api_stickers_creator.h @@ -13,12 +13,25 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL class DocumentData; +namespace ChatHelpers { +class Show; +} // namespace ChatHelpers + namespace Main { class Session; } // namespace Main +namespace Ui { +class PopupMenu; +namespace Menu { +struct MenuCallback; +} // namespace Menu +} // namespace Ui + namespace Api { +inline constexpr auto kStickersInOwnedSetMax = 120; + void AddExistingStickerToSet( not_null session, const StickerSetIdentifier &set, @@ -33,6 +46,21 @@ void DeleteStickerSet( Fn done, Fn fail); +[[nodiscard]] bool HasOwnedStickerSets(not_null session); + +[[nodiscard]] QString StickerEmojiOrDefault( + not_null document); + +void FillChooseStickerSetMenu( + not_null menu, + std::shared_ptr show, + not_null document); + +void AddAddToStickerSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document); + class StickerUpload final : public base::has_weak_ptr { public: StickerUpload( diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index 61d45c4ce2..eabf4b93d5 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -85,7 +85,6 @@ constexpr auto kMinRepaintDelay = crl::time(33); constexpr auto kMinAfterScrollDelay = crl::time(33); constexpr auto kGrayLockOpacity = 0.3; constexpr auto kStickerMoveDuration = crl::time(200); -constexpr auto kOwnedSetStickersMax = 120; using Data::StickersSet; using Data::StickersPack; @@ -1638,6 +1637,12 @@ void StickerSetBox::Inner::contextMenuEvent(QContextMenuEvent *e) { (isFaved ? &st::menuIconUnfave : &st::menuIconFave)); + if (!amSetCreator()) { + Api::AddAddToStickerSetAction( + Ui::Menu::CreateAddActionCallback(_menu.get()), + _show, + document); + } if (amSetCreator()) { const auto addAction = Ui::Menu::CreateAddActionCallback( _menu.get()); @@ -2367,7 +2372,7 @@ bool StickerSetBox::Inner::hasAddCell() const { && _amSetCreator && (setType() == Data::StickersType::Stickers) && !_pack.isEmpty() - && (_pack.size() < kOwnedSetStickersMax); + && (_pack.size() < Api::kStickersInOwnedSetMax); } int StickerSetBox::Inner::totalCellsCount() const { @@ -2504,11 +2509,7 @@ void StickerSetBox::Inner::startAddExistingStickerFlow() { if (_pickerPanel) { _pickerPanel->hideAnimated(); } - const auto sticker = document->sticker(); - const auto fallback = QString::fromUtf8("\xF0\x9F\x99\x82"); - const auto emoji = (sticker && !sticker->alt.isEmpty()) - ? sticker->alt - : fallback; + const auto emoji = Api::StickerEmojiOrDefault(document); Api::AddExistingStickerToSet( session, identifier, diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index 022e624c60..599199c81f 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.cpp +++ b/Telegram/SourceFiles/history/history_inner_widget.cpp @@ -94,6 +94,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "apiwrap.h" #include "api/api_attached_stickers.h" #include "api/api_suggest_post.h" +#include "api/api_stickers_creator.h" #include "api/api_toggling_media.h" #include "api/api_who_reacted.h" #include "api/api_views.h" @@ -3184,6 +3185,11 @@ void HistoryInner::showContextMenu(QContextMenuEvent *e, bool showFromTouch) { _menu->addAction(document->isStickerSetInstalled() ? tr::lng_context_pack_info(tr::now) : tr::lng_context_pack_add(tr::now), [=] { showStickerPackInfo(document); }, &st::menuIconStickers); + } else { + Api::AddAddToStickerSetAction( + Ui::Menu::CreateAddActionCallback(_menu), + _controller->uiShow(), + document); } { const auto isFaved = session->data().stickers().isFaved(document); diff --git a/Telegram/SourceFiles/history/view/history_view_context_menu.cpp b/Telegram/SourceFiles/history/view/history_view_context_menu.cpp index aeb9794c07..58f010229a 100644 --- a/Telegram/SourceFiles/history/view/history_view_context_menu.cpp +++ b/Telegram/SourceFiles/history/view/history_view_context_menu.cpp @@ -15,6 +15,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_ringtones.h" #include "api/api_transcribes.h" #include "api/api_who_reacted.h" +#include "api/api_stickers_creator.h" #include "api/api_toggling_media.h" // Api::ToggleFavedSticker #include "base/qt/qt_key_modifiers.h" #include "base/unixtime.h" @@ -33,6 +34,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "info/profile/info_profile_widget.h" #include "ui/widgets/popup_menu.h" #include "ui/widgets/menu/menu_action.h" +#include "ui/widgets/menu/menu_add_action_callback_factory.h" #include "ui/widgets/menu/menu_common.h" #include "ui/widgets/menu/menu_multiline_action.h" #include "ui/widgets/menu/menu_separator.h" @@ -290,6 +292,12 @@ void AddDocumentActions( [=] { ShowStickerPackInfo(document, list); }, &st::menuIconStickers); } + if (document->sticker() && !document->sticker()->set) { + Api::AddAddToStickerSetAction( + Ui::Menu::CreateAddActionCallback(menu), + controller->uiShow(), + document); + } if (document->sticker()) { const auto isFaved = document->owner().stickers().isFaved(document); menu->addAction( diff --git a/Telegram/SourceFiles/ui/dynamic_thumbnails.cpp b/Telegram/SourceFiles/ui/dynamic_thumbnails.cpp index b6a6514ead..b8c6af32ab 100644 --- a/Telegram/SourceFiles/ui/dynamic_thumbnails.cpp +++ b/Telegram/SourceFiles/ui/dynamic_thumbnails.cpp @@ -1121,6 +1121,16 @@ std::shared_ptr MakeDocumentThumbnail( false); } +std::shared_ptr MakeDocumentThumbnail( + not_null document, + Data::FileOrigin origin) { + return std::make_shared( + document, + origin, + false, + false); +} + std::shared_ptr MakeDocumentThumbnailCenterCrop( not_null document, FullMsgId fullId) { diff --git a/Telegram/SourceFiles/ui/dynamic_thumbnails.h b/Telegram/SourceFiles/ui/dynamic_thumbnails.h index 2cad87b189..7a16058935 100644 --- a/Telegram/SourceFiles/ui/dynamic_thumbnails.h +++ b/Telegram/SourceFiles/ui/dynamic_thumbnails.h @@ -51,6 +51,9 @@ class DynamicImage; [[nodiscard]] std::shared_ptr MakeDocumentThumbnail( not_null document, FullMsgId fullId); +[[nodiscard]] std::shared_ptr MakeDocumentThumbnail( + not_null document, + Data::FileOrigin origin); [[nodiscard]] std::shared_ptr MakeDocumentThumbnailCenterCrop( not_null document, FullMsgId fullId); From d34f875d35ec6050d8e4e5e8f00bbdaed90e1505 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Mon, 20 Apr 2026 16:25:34 +0300 Subject: [PATCH 75/78] Added Add-to-Emoji-Set submenu for custom emoji from foreign packs. --- Telegram/Resources/langs/lang.strings | 4 + .../SourceFiles/api/api_stickers_creator.cpp | 191 ++++++++++++------ .../SourceFiles/api/api_stickers_creator.h | 12 ++ .../SourceFiles/boxes/sticker_set_box.cpp | 6 + 4 files changed, 150 insertions(+), 63 deletions(-) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 2bcb2a3b06..cef13cf059 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -4517,6 +4517,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_stickers_add_to_set" = "Add to Sticker Set"; "lng_stickers_already_in_set" = "This Sticker is already in the Set."; "lng_stickers_set_is_full" = "This Sticker Set is full."; +"lng_emoji_add_to_set" = "Add to Emoji Set"; +"lng_emoji_already_in_set" = "This Emoji is already in the Set."; +"lng_emoji_set_is_full" = "This Emoji Set is full."; +"lng_emoji_added" = "Emoji added."; "lng_stickers_pack_choose_emoji_title" = "Choose Emoji"; "lng_stickers_pack_choose_emoji_about" = "Pick an emoji that corresponds to this sticker."; "lng_stickers_pick_existing_title" = "Choose Sticker"; diff --git a/Telegram/SourceFiles/api/api_stickers_creator.cpp b/Telegram/SourceFiles/api/api_stickers_creator.cpp index d9d86d9301..78436916b6 100644 --- a/Telegram/SourceFiles/api/api_stickers_creator.cpp +++ b/Telegram/SourceFiles/api/api_stickers_creator.cpp @@ -92,19 +92,23 @@ void FeedSetIfFull( } template -void EnumerateOwnedStickerSets( +void EnumerateOwnedSets( not_null session, + Data::StickersType type, Callback &&callback) { const auto &stickers = session->data().stickers(); const auto &sets = stickers.sets(); - for (const auto setId : stickers.setsOrder()) { + const auto &order = (type == Data::StickersType::Emoji) + ? stickers.emojiSetsOrder() + : stickers.setsOrder(); + for (const auto setId : order) { const auto it = sets.find(setId); if (it == sets.end()) { continue; } const auto set = it->second.get(); if (!(set->flags & Data::StickersSetFlag::AmCreator) - || (set->type() != Data::StickersType::Stickers)) { + || (set->type() != type)) { continue; } using namespace Data; @@ -120,6 +124,81 @@ void EnumerateOwnedStickerSets( } } +void FillChooseOwnedSetMenu( + not_null menu, + std::shared_ptr show, + not_null document, + Data::StickersType type) { + const auto session = &show->session(); + const auto emoji = StickerEmojiOrDefault(document); + const auto isEmoji = (type == Data::StickersType::Emoji); + const auto maxCount = isEmoji + ? kEmojiInOwnedSetMax + : kStickersInOwnedSetMax; + const auto fullMessage = isEmoji + ? tr::lng_emoji_set_is_full + : tr::lng_stickers_set_is_full; + const auto addedMessage = isEmoji + ? tr::lng_emoji_added + : tr::lng_stickers_create_added; + const auto alreadyMessage = isEmoji + ? tr::lng_emoji_already_in_set + : tr::lng_stickers_already_in_set; + const auto failToast = [=](QString err) { + show->showToast(err.isEmpty() + ? tr::lng_attach_failed(tr::now) + : err); + }; + EnumerateOwnedSets(session, type, [&](not_null set) { + const auto identifier = set->identifier(); + const auto coverDocument = set->lookupThumbnailDocument(); + auto thumbnail = coverDocument + ? Ui::MakeDocumentThumbnail( + coverDocument, + Data::FileOriginStickerSet(set->id, set->accessHash)) + : nullptr; + const auto targetSetId = set->id; + const auto handler = crl::guard(session, [=] { + const auto &map = session->data().stickers().sets(); + const auto i = map.find(targetSetId); + if (i != map.end() && i->second->count >= maxCount) { + show->showToast(fullMessage(tr::now)); + return; + } + const auto oldCount = (i != map.end()) + ? i->second->count + : 0; + AddExistingStickerToSet( + session, + identifier, + document, + emoji, + crl::guard(session, [=](MTPmessages_StickerSet) { + const auto &map = session->data().stickers().sets(); + const auto i = map.find(targetSetId); + const auto newCount = (i != map.end()) + ? i->second->count + : oldCount; + show->showToast(newCount > oldCount + ? addedMessage(tr::now) + : alreadyMessage(tr::now)); + }), + crl::guard(session, failToast)); + }); + const auto rawAction = Ui::Menu::CreateAction( + menu.get(), + set->title, + handler); + auto item = base::make_unique_q( + menu->menu(), + menu->menu()->st(), + rawAction, + std::move(thumbnail), + st::menuIconStickerAdd.width()); + menu->addAction(std::move(item)); + }); +} + } // namespace void AddExistingStickerToSet( @@ -155,10 +234,25 @@ QString StickerEmojiOrDefault(not_null document) { bool HasOwnedStickerSets(not_null session) { auto found = false; - EnumerateOwnedStickerSets(session, [&](not_null) { - found = true; - return false; - }); + EnumerateOwnedSets( + session, + Data::StickersType::Stickers, + [&](not_null) { + found = true; + return false; + }); + return found; +} + +bool HasOwnedEmojiSets(not_null session) { + auto found = false; + EnumerateOwnedSets( + session, + Data::StickersType::Emoji, + [&](not_null) { + found = true; + return false; + }); return found; } @@ -166,62 +260,15 @@ void FillChooseStickerSetMenu( not_null menu, std::shared_ptr show, not_null document) { - const auto session = &show->session(); - const auto emoji = StickerEmojiOrDefault(document); - const auto failToast = [=](QString err) { - show->showToast(err.isEmpty() - ? tr::lng_attach_failed(tr::now) - : err); - }; - EnumerateOwnedStickerSets(session, [&](not_null set) { - const auto identifier = set->identifier(); - const auto coverDocument = set->lookupThumbnailDocument(); - auto thumbnail = coverDocument - ? Ui::MakeDocumentThumbnail( - coverDocument, - Data::FileOriginStickerSet(set->id, set->accessHash)) - : nullptr; - const auto targetSetId = set->id; - const auto handler = crl::guard(session, [=] { - const auto &map = session->data().stickers().sets(); - const auto i = map.find(targetSetId); - if (i != map.end() - && i->second->count >= kStickersInOwnedSetMax) { - show->showToast(tr::lng_stickers_set_is_full(tr::now)); - return; - } - const auto oldCount = (i != map.end()) - ? i->second->count - : 0; - AddExistingStickerToSet( - session, - identifier, - document, - emoji, - crl::guard(session, [=](MTPmessages_StickerSet) { - const auto &map = session->data().stickers().sets(); - const auto i = map.find(targetSetId); - const auto newCount = (i != map.end()) - ? i->second->count - : oldCount; - show->showToast(newCount > oldCount - ? tr::lng_stickers_create_added(tr::now) - : tr::lng_stickers_already_in_set(tr::now)); - }), - crl::guard(session, failToast)); - }); - const auto rawAction = Ui::Menu::CreateAction( - menu.get(), - set->title, - handler); - auto item = base::make_unique_q( - menu->menu(), - menu->menu()->st(), - rawAction, - std::move(thumbnail), - st::menuIconStickerAdd.width()); - menu->addAction(std::move(item)); - }); + using namespace Data; + FillChooseOwnedSetMenu(menu, show, document, StickersType::Stickers); +} + +void FillChooseEmojiSetMenu( + not_null menu, + std::shared_ptr show, + not_null document) { + FillChooseOwnedSetMenu(menu, show, document, Data::StickersType::Emoji); } void AddAddToStickerSetAction( @@ -242,6 +289,24 @@ void AddAddToStickerSetAction( }); } +void AddAddToEmojiSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document) { + const auto session = &show->session(); + if (!HasOwnedEmojiSets(session)) { + return; + } + addAction({ + .text = tr::lng_emoji_add_to_set(tr::now), + .icon = &st::menuIconEmoji, + .fillSubmenu = [show, document](not_null submenu) { + FillChooseEmojiSetMenu(submenu, show, document); + }, + .submenuSt = &st::popupMenuWithIcons, + }); +} + void DeleteStickerSet( not_null session, const StickerSetIdentifier &set, diff --git a/Telegram/SourceFiles/api/api_stickers_creator.h b/Telegram/SourceFiles/api/api_stickers_creator.h index a97affca18..d537d1cb6e 100644 --- a/Telegram/SourceFiles/api/api_stickers_creator.h +++ b/Telegram/SourceFiles/api/api_stickers_creator.h @@ -31,6 +31,7 @@ struct MenuCallback; namespace Api { inline constexpr auto kStickersInOwnedSetMax = 120; +inline constexpr auto kEmojiInOwnedSetMax = 200; void AddExistingStickerToSet( not_null session, @@ -47,6 +48,7 @@ void DeleteStickerSet( Fn fail); [[nodiscard]] bool HasOwnedStickerSets(not_null session); +[[nodiscard]] bool HasOwnedEmojiSets(not_null session); [[nodiscard]] QString StickerEmojiOrDefault( not_null document); @@ -56,11 +58,21 @@ void FillChooseStickerSetMenu( std::shared_ptr show, not_null document); +void FillChooseEmojiSetMenu( + not_null menu, + std::shared_ptr show, + not_null document); + void AddAddToStickerSetAction( const Ui::Menu::MenuCallback &addAction, std::shared_ptr show, not_null document); +void AddAddToEmojiSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document); + class StickerUpload final : public base::has_weak_ptr { public: StickerUpload( diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index eabf4b93d5..ed97954db8 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -1606,6 +1606,12 @@ void StickerSetBox::Inner::contextMenuEvent(QContextMenuEvent *e) { } }, &st::menuIconCopy); } + if (!amSetCreator()) { + Api::AddAddToEmojiSetAction( + Ui::Menu::CreateAddActionCallback(_menu.get()), + _show, + _pack[index]); + } } else if (details.type != SendMenu::Type::Disabled) { const auto document = _pack[index]; const auto send = crl::guard(this, [=](Api::SendOptions options) { From c0bcdc382920c466445407bbced24f64868069d7 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 20 Apr 2026 22:06:22 +0700 Subject: [PATCH 76/78] Fix build with MSVC. --- Telegram/SourceFiles/editor/scene/scene_item_text.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp index 538515bc61..619079bf90 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -309,7 +309,7 @@ void ItemText::renderContent() { int length = 0; EmojiPtr emoji = nullptr; }; - auto emojiFormats = QList(); + auto emojiFormats = QVector(); auto emojiPositions = std::vector(); { auto pos = 0; From fdc0f1e6485c401d44101a03460e7e01ecd2e907 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 20 Apr 2026 22:06:37 +0700 Subject: [PATCH 77/78] Improve accessibility code for countries list. --- Telegram/SourceFiles/boxes/language_box.cpp | 353 +++++++++++--------- 1 file changed, 190 insertions(+), 163 deletions(-) diff --git a/Telegram/SourceFiles/boxes/language_box.cpp b/Telegram/SourceFiles/boxes/language_box.cpp index fb6ff36cca..d7844fb5bc 100644 --- a/Telegram/SourceFiles/boxes/language_box.cpp +++ b/Telegram/SourceFiles/boxes/language_box.cpp @@ -7,56 +7,55 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "boxes/language_box.h" -#include "data/data_peer_values.h" -#include "lang/lang_keys.h" -#include "base/screen_reader_state.h" -#include "ui/accessible/ui_accessible_item.h" -#include "ui/boxes/choose_language_box.h" -#include "ui/widgets/checkbox.h" -#include "ui/widgets/buttons.h" -#include "ui/widgets/labels.h" -#include "ui/widgets/multi_select.h" -#include "ui/widgets/scroll_area.h" -#include "ui/widgets/dropdown_menu.h" -#include "ui/widgets/box_content_divider.h" -#include "ui/text/text_entity.h" -#include "ui/wrap/vertical_layout.h" -#include "ui/wrap/slide_wrap.h" -#include "ui/effects/ripple_animation.h" -#include "ui/toast/toast.h" -#include "ui/text/text_options.h" -#include "ui/painter.h" -#include "ui/vertical_list.h" -#include "ui/ui_utility.h" -#include "storage/localstorage.h" +#include "base/platform/base_platform_info.h" #include "boxes/abstract_box.h" #include "boxes/premium_preview_box.h" #include "boxes/translate_box.h" -#include "ui/boxes/confirm_box.h" -#include "main/main_session.h" -#include "mainwidget.h" -#include "mainwindow.h" #include "core/application.h" -#include "base/platform/base_platform_info.h" -#include "lang/lang_instance.h" +#include "data/data_peer_values.h" #include "lang/lang_cloud_manager.h" +#include "lang/lang_instance.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" #include "platform/platform_translate_provider.h" #include "settings/settings_common.h" #include "spellcheck/spellcheck_types.h" +#include "storage/localstorage.h" +#include "ui/accessible/ui_accessible_item.h" +#include "ui/boxes/choose_language_box.h" +#include "ui/boxes/confirm_box.h" +#include "ui/effects/ripple_animation.h" +#include "ui/text/text_entity.h" +#include "ui/text/text_options.h" +#include "ui/toast/toast.h" +#include "ui/widgets/box_content_divider.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/checkbox.h" +#include "ui/widgets/dropdown_menu.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/multi_select.h" +#include "ui/widgets/scroll_area.h" +#include "ui/wrap/slide_wrap.h" +#include "ui/wrap/vertical_layout.h" +#include "ui/painter.h" +#include "ui/screen_reader_mode.h" +#include "ui/ui_utility.h" +#include "ui/vertical_list.h" #include "window/window_controller.h" #include "window/window_session_controller.h" -#include "styles/style_layers.h" +#include "mainwidget.h" +#include "mainwindow.h" + #include "styles/style_boxes.h" -#include "styles/style_info.h" -#include "styles/style_passport.h" #include "styles/style_chat_helpers.h" +#include "styles/style_info.h" +#include "styles/style_layers.h" #include "styles/style_menu_icons.h" +#include "styles/style_passport.h" #include "styles/style_settings.h" -#include - -#include #include +#include namespace { @@ -89,80 +88,17 @@ public: static int DefaultRowHeight(); - QAccessible::Role accessibilityRole() override { - return QAccessible::List; - } - - QAccessible::Role accessibilityChildRole() const override { - return QAccessible::RadioButton; - } - - QAccessible::State accessibilityChildState(int index) const override { - QAccessible::State state; - if (base::ScreenReaderState::Instance()->active()) { - state.focusable = true; - } - state.checkable = true; - state.checked = (index == chosenIndex()); - if (index == selected()) { - state.active = true; - if (hasFocus()) { - state.focused = true; - } - } - return state; - } - - int accessibilityChildCount() const override { - return count(); - } - - QString accessibilityChildName(int index) const override { - if (index < 0 || index >= count()) { - return {}; - } - const auto &row = rowByIndex(index); - // Announce native name followed by English name. - return row.data.nativeName + u", "_q + row.data.name; - } - - QRect accessibilityChildRect(int index) const override { - if (index < 0 || index >= count()) { - return QRect(); - } - const auto &row = rowByIndex(index); - return QRect(0, row.top, width(), row.height); - } - - int accessibilityChildColumnCount(int row) const override { - return 2; - } - - QAccessible::Role accessibilityChildSubItemRole() const override { - return QAccessible::Cell; - } - - QString accessibilityChildSubItemName(int row, int column) const override { - if (column == 0) { - return tr::lng_sr_languages_column_native(tr::now); - } else if (column == 1) { - return tr::lng_sr_languages_column_name(tr::now); - } - return {}; - } - - QString accessibilityChildSubItemValue(int row, int column) const override { - if (row < 0 || row >= count()) { - return {}; - } - const auto &data = rowByIndex(row).data; - if (column == 0) { - return data.nativeName; - } else if (column == 1) { - return data.name; - } - return {}; - } + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + QAccessible::Role accessibilityChildRole() const override; + QAccessible::State accessibilityChildState(int index) const override; + int accessibilityChildCount() const override; + QString accessibilityChildName(int index) const override; + QRect accessibilityChildRect(int index) const override; + int accessibilityChildColumnCount(int row) const override; + QAccessible::Role accessibilityChildSubItemRole() const override; + QString accessibilityChildSubItemName(int row, int column) const override; + QString accessibilityChildSubItemValue(int row, int column) const override; protected: int resizeGetHeight(int newWidth) override; @@ -237,6 +173,13 @@ private: void repaintChecked(not_null row); void activateByIndex(int index); + enum class Announce { + No, + OnChange, + Always, + }; + void setSelected(int index, Announce announce); + void showMenu(int index); void setForceRippled(not_null row, bool rippled); bool canShare(not_null row) const; @@ -266,6 +209,26 @@ private: }; +[[nodiscard]] bool ForwardListNavigation( + not_null e, + not_null rows, + int pageHeight) { + const auto key = e->key(); + if (key == Qt::Key_Down) { + rows->selectSkip(1); + } else if (key == Qt::Key_Up) { + rows->selectSkip(-1); + } else if (key == Qt::Key_PageDown || key == Qt::Key_PageUp) { + const auto perPage = std::max( + pageHeight / Rows::DefaultRowHeight(), + 1); + rows->selectSkip((key == Qt::Key_PageDown) ? perPage : -perPage); + } else { + return false; + } + return true; +} + class Content : public Ui::RpWidget { public: Content( @@ -373,53 +336,34 @@ Rows::Rows( update(); setAccessibleName(tr::lng_languages(tr::now)); - - base::ScreenReaderState::Instance()->activeValue( - ) | rpl::on_next([=](bool active) { - setFocusPolicy(active ? Qt::TabFocus : Qt::NoFocus); - }, lifetime()); } void Rows::focusInEvent(QFocusEvent *e) { - // Select first item or chosen item when focus enters. if (selected() < 0 && count() > 0) { const auto chosen = chosenIndex(); - setSelected(chosen >= 0 ? chosen : 0); + setSelected(chosen >= 0 ? chosen : 0, Announce::No); } - RpWidget::focusInEvent(e); - - if (base::ScreenReaderState::Instance()->active()) { - const auto index = selected(); - if (index >= 0) { - InvokeQueued(this, [=] { - if (selected() != index || !hasFocus()) { - return; - } + const auto index = selected(); + if (index >= 0) { + InvokeQueued(this, [=] { + if (selected() == index && hasFocus()) { accessibilityChildFocused(index); - }); - } + } + }); } } void Rows::keyPressEvent(QKeyEvent *e) { + const auto pageHeight = window() ? window()->height() : height(); + if (ForwardListNavigation(e, this, pageHeight)) { + return; + } const auto key = e->key(); - if (key == Qt::Key_Down) { - selectSkip(1); - } else if (key == Qt::Key_Up) { - selectSkip(-1); - } else if (key == Qt::Key_PageDown || key == Qt::Key_PageUp) { - const auto visibleHeight = visibleRegion().boundingRect().height(); - const auto rowsPerPage = std::max(visibleHeight / DefaultRowHeight(), 1); - selectSkip(key == Qt::Key_PageDown ? rowsPerPage : -rowsPerPage); - } else if (key == Qt::Key_Home) { - if (count() > 0) { - setSelected(0); - } - } else if (key == Qt::Key_End) { - if (count() > 0) { - setSelected(count() - 1); - } + if (key == Qt::Key_Home && count() > 0) { + setSelected(0, Announce::Always); + } else if (key == Qt::Key_End && count() > 0) { + setSelected(count() - 1, Announce::Always); } else if (!e->isAutoRepeat() && (key == Qt::Key_Space || key == Qt::Key_Return @@ -697,10 +641,8 @@ void Rows::setForceRippled(not_null row, bool rippled) { void Rows::activateByIndex(int index) { _chosen = rowByIndex(index).data.id; _activations.fire_copy(rowByIndex(index).data); - if (base::ScreenReaderState::Instance()->active()) { - accessibilityChildStateChanged(index, { .checked = true }); - accessibilityChildNameChanged(index); - } + accessibilityChildStateChanged(index, { .checked = true }); + accessibilityChildNameChanged(index); } void Rows::leaveEventHook(QEvent *e) { @@ -795,21 +737,20 @@ void Rows::activateSelected() { void Rows::selectSkip(int dir) { const auto limit = count(); auto now = selected(); - // If no keyboard selection, start from the checked item. if (now < 0) { now = chosenIndex(); } if (now >= 0) { const auto changed = now + dir; if (changed < 0) { - setSelected(0); + setSelected(0, Announce::Always); } else if (changed >= limit) { - setSelected(limit - 1); + setSelected(limit - 1, Announce::Always); } else { - setSelected(changed); + setSelected(changed, Announce::Always); } } else if (dir > 0) { - setSelected(0); + setSelected(0, Announce::Always); } } @@ -823,27 +764,35 @@ void Rows::changeChosen(const QString &chosen) { for (const auto &row : _rows) { row.check->setChecked(row.data.id == chosen, anim::type::normal); } - if (base::ScreenReaderState::Instance()->active()) { - const auto newIndex = chosenIndex(); - if (newIndex != oldIndex && newIndex >= 0) { - accessibilityChildStateChanged(newIndex, { .checked = true }); - accessibilityChildNameChanged(newIndex); - } + const auto newIndex = chosenIndex(); + if (newIndex != oldIndex && newIndex >= 0) { + accessibilityChildStateChanged(newIndex, { .checked = true }); + accessibilityChildNameChanged(newIndex); } } void Rows::setSelected(int selected) { + setSelected(selected, Announce::OnChange); +} + +void Rows::setSelected(int selected, Announce announce) { _mouseSelection = false; const auto limit = count(); - if (selected >= 0 && selected < limit) { - updateSelected(RowSelection{ selected }); + const auto clamped = (selected >= 0 && selected < limit) + ? selected + : -1; + const auto changed = (indexFromSelection(_selected) != clamped) + || (clamped < 0 && !v::is_null(_selected)); + if (clamped >= 0) { + updateSelected(RowSelection{ clamped }); } else { updateSelected({}); } - if (selected >= 0 && selected < limit - && base::ScreenReaderState::Instance()->active()) { - accessibilityChildNameChanged(selected); - accessibilityChildFocused(selected); + const auto shouldAnnounce = (announce == Announce::Always) + || (announce == Announce::OnChange && changed); + if (shouldAnnounce && clamped >= 0) { + accessibilityChildNameChanged(clamped); + accessibilityChildFocused(clamped); } } @@ -1064,6 +1013,84 @@ void Rows::paintEvent(QPaintEvent *e) { } } +QAccessible::Role Rows::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy Rows::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +QAccessible::Role Rows::accessibilityChildRole() const { + return QAccessible::RadioButton; +} + +QAccessible::State Rows::accessibilityChildState(int index) const { + QAccessible::State state; + if (Ui::ScreenReaderModeActive()) { + state.focusable = true; + } + state.checkable = true; + state.checked = (index == chosenIndex()); + if (index == selected()) { + state.active = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; +} + +int Rows::accessibilityChildCount() const { + return count(); +} + +QString Rows::accessibilityChildName(int index) const { + if (index < 0 || index >= count()) { + return {}; + } + const auto &row = rowByIndex(index); + return row.data.nativeName + u", "_q + row.data.name; +} + +QRect Rows::accessibilityChildRect(int index) const { + if (index < 0 || index >= count()) { + return {}; + } + const auto &row = rowByIndex(index); + return QRect(0, row.top, width(), row.height); +} + +int Rows::accessibilityChildColumnCount(int row) const { + return 2; +} + +QAccessible::Role Rows::accessibilityChildSubItemRole() const { + return QAccessible::Cell; +} + +QString Rows::accessibilityChildSubItemName(int row, int column) const { + if (column == 0) { + return tr::lng_sr_languages_column_native(tr::now); + } else if (column == 1) { + return tr::lng_sr_languages_column_name(tr::now); + } + return {}; +} + +QString Rows::accessibilityChildSubItemValue(int row, int column) const { + if (row < 0 || row >= count()) { + return {}; + } + const auto &data = rowByIndex(row).data; + if (column == 0) { + return data.nativeName; + } else if (column == 1) { + return data.name; + } + return {}; +} + Content::Content( QWidget *parent, const Languages &recent, From f1b3588465cf9a9d5a1f359307a2251d87a50a82 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 20 Apr 2026 21:02:51 +0700 Subject: [PATCH 78/78] Beta version 6.7.7. - Proxy auto-rotation option. - Allow seeking in video messages. - Styled text items with emoji support in image editor. - Wheel zoom and middle-button panning in image editor. - Reply header with preview in send files box. - Bring back original video quality option. - Delete owned sticker set from sticker set box. - Add-to-Sticker-Set and Add-to-Emoji-Set submenus for foreign packs. - Screen reader support for language list and country select box. - Fix stories in albums with reorder enabled. - Fix layout for RTL messages. --- Telegram/Resources/uwp/AppX/AppxManifest.xml | 2 +- Telegram/Resources/winrc/Telegram.rc | 8 ++++---- Telegram/Resources/winrc/Updater.rc | 8 ++++---- Telegram/SourceFiles/core/version.h | 6 +++--- Telegram/build/version | 10 +++++----- changelog.txt | 14 ++++++++++++++ 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/Telegram/Resources/uwp/AppX/AppxManifest.xml b/Telegram/Resources/uwp/AppX/AppxManifest.xml index b58e238f6f..3f4054931d 100644 --- a/Telegram/Resources/uwp/AppX/AppxManifest.xml +++ b/Telegram/Resources/uwp/AppX/AppxManifest.xml @@ -10,7 +10,7 @@ + Version="6.7.7.0" /> Telegram Desktop Telegram Messenger LLP diff --git a/Telegram/Resources/winrc/Telegram.rc b/Telegram/Resources/winrc/Telegram.rc index a8324b7fc8..9596318f5c 100644 --- a/Telegram/Resources/winrc/Telegram.rc +++ b/Telegram/Resources/winrc/Telegram.rc @@ -44,8 +44,8 @@ IDI_ICON1 ICON "..\\art\\icon256.ico" // VS_VERSION_INFO VERSIONINFO - FILEVERSION 6,7,6,0 - PRODUCTVERSION 6,7,6,0 + FILEVERSION 6,7,7,0 + PRODUCTVERSION 6,7,7,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -62,10 +62,10 @@ BEGIN BEGIN VALUE "CompanyName", "Telegram FZ-LLC" VALUE "FileDescription", "Telegram Desktop" - VALUE "FileVersion", "6.7.6.0" + VALUE "FileVersion", "6.7.7.0" VALUE "LegalCopyright", "Copyright (C) 2014-2026" VALUE "ProductName", "Telegram Desktop" - VALUE "ProductVersion", "6.7.6.0" + VALUE "ProductVersion", "6.7.7.0" END END BLOCK "VarFileInfo" diff --git a/Telegram/Resources/winrc/Updater.rc b/Telegram/Resources/winrc/Updater.rc index 9e179967e6..74422501c2 100644 --- a/Telegram/Resources/winrc/Updater.rc +++ b/Telegram/Resources/winrc/Updater.rc @@ -35,8 +35,8 @@ LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US // VS_VERSION_INFO VERSIONINFO - FILEVERSION 6,7,6,0 - PRODUCTVERSION 6,7,6,0 + FILEVERSION 6,7,7,0 + PRODUCTVERSION 6,7,7,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -53,10 +53,10 @@ BEGIN BEGIN VALUE "CompanyName", "Telegram FZ-LLC" VALUE "FileDescription", "Telegram Desktop Updater" - VALUE "FileVersion", "6.7.6.0" + VALUE "FileVersion", "6.7.7.0" VALUE "LegalCopyright", "Copyright (C) 2014-2026" VALUE "ProductName", "Telegram Desktop" - VALUE "ProductVersion", "6.7.6.0" + VALUE "ProductVersion", "6.7.7.0" END END BLOCK "VarFileInfo" diff --git a/Telegram/SourceFiles/core/version.h b/Telegram/SourceFiles/core/version.h index 6606e8738a..c871fdba11 100644 --- a/Telegram/SourceFiles/core/version.h +++ b/Telegram/SourceFiles/core/version.h @@ -22,7 +22,7 @@ constexpr auto AppId = "{53F49750-6209-4FBF-9CA8-7A333C87D1ED}"_cs; constexpr auto AppNameOld = "Telegram Win (Unofficial)"_cs; constexpr auto AppName = "Telegram Desktop"_cs; constexpr auto AppFile = "Telegram"_cs; -constexpr auto AppVersion = 6007006; -constexpr auto AppVersionStr = "6.7.6"; -constexpr auto AppBetaVersion = false; +constexpr auto AppVersion = 6007007; +constexpr auto AppVersionStr = "6.7.7"; +constexpr auto AppBetaVersion = true; constexpr auto AppAlphaVersion = TDESKTOP_ALPHA_VERSION; diff --git a/Telegram/build/version b/Telegram/build/version index a8aa8e5d9f..72966b3498 100644 --- a/Telegram/build/version +++ b/Telegram/build/version @@ -1,7 +1,7 @@ -AppVersion 6007006 +AppVersion 6007007 AppVersionStrMajor 6.7 -AppVersionStrSmall 6.7.6 -AppVersionStr 6.7.6 -BetaChannel 0 +AppVersionStrSmall 6.7.7 +AppVersionStr 6.7.7 +BetaChannel 1 AlphaVersion 0 -AppVersionOriginal 6.7.6 +AppVersionOriginal 6.7.7.beta diff --git a/changelog.txt b/changelog.txt index a35e6f601d..b7232a63eb 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,17 @@ +6.7.7 beta (20.04.26) + +- Proxy auto-rotation option. +- Allow seeking in video messages. +- Styled text items with emoji support in image editor. +- Wheel zoom and middle-button panning in image editor. +- Reply header with preview in send files box. +- Bring back original video quality option. +- Delete owned sticker set from sticker set box. +- Add-to-Sticker-Set and Add-to-Emoji-Set submenus for foreign packs. +- Screen reader support for language list and country select box. +- Fix stories in albums with reorder enabled. +- Fix layout for RTL messages. + 6.7.6 (14.04.26) - Gradient-reveal animation for text appearing in messages.