From e01a603a80f939bf7dfca18cf90d08cb72d153c2 Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Sat, 11 Jul 2026 18:29:53 +0300 Subject: [PATCH] Added profile media tabs host and island strip. --- Telegram/CMakeLists.txt | 8 + Telegram/SourceFiles/info/info.style | 46 ++ .../profile/tabs/info_profile_tab_content.h | 69 +++ .../tabs/info_profile_tab_skeleton.cpp | 154 ++++++ .../profile/tabs/info_profile_tab_skeleton.h | 23 + .../tabs/info_profile_tab_top_bar_bindings.h | 28 + .../profile/tabs/info_profile_tabs_host.cpp | 513 ++++++++++++++++++ .../profile/tabs/info_profile_tabs_host.h | 100 ++++ .../profile/tabs/info_profile_tabs_strip.cpp | 442 +++++++++++++++ .../profile/tabs/info_profile_tabs_strip.h | 91 ++++ 10 files changed, 1474 insertions(+) create mode 100644 Telegram/SourceFiles/info/profile/tabs/info_profile_tab_content.h create mode 100644 Telegram/SourceFiles/info/profile/tabs/info_profile_tab_skeleton.cpp create mode 100644 Telegram/SourceFiles/info/profile/tabs/info_profile_tab_skeleton.h create mode 100644 Telegram/SourceFiles/info/profile/tabs/info_profile_tab_top_bar_bindings.h create mode 100644 Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_host.cpp create mode 100644 Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_host.h create mode 100644 Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_strip.cpp create mode 100644 Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_strip.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 817e43aa20..532a44eb33 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -1234,6 +1234,14 @@ PRIVATE info/profile/info_profile_values.h info/profile/info_profile_widget.cpp info/profile/info_profile_widget.h + info/profile/tabs/info_profile_tab_skeleton.cpp + info/profile/tabs/info_profile_tab_skeleton.h + info/profile/tabs/info_profile_tab_content.h + info/profile/tabs/info_profile_tab_top_bar_bindings.h + info/profile/tabs/info_profile_tabs_host.cpp + info/profile/tabs/info_profile_tabs_host.h + info/profile/tabs/info_profile_tabs_strip.cpp + info/profile/tabs/info_profile_tabs_strip.h info/reactions_list/info_reactions_list_widget.cpp info/reactions_list/info_reactions_list_widget.h info/requests_list/info_requests_list_widget.cpp diff --git a/Telegram/SourceFiles/info/info.style b/Telegram/SourceFiles/info/info.style index 95a43b450c..f5e8cbfe37 100644 --- a/Telegram/SourceFiles/info/info.style +++ b/Telegram/SourceFiles/info/info.style @@ -63,6 +63,21 @@ defaultSubTabs: SubTabs { bg: windowBg; } +ProfileTabsStrip { + height: pixels; + margin: margins; + skip: pixels; + tabPadding: margins; + activeSkip: pixels; + style: TextStyle; + fg: color; + fgActive: color; + bg: color; + bgActive: color; + ripple: RippleAnimation; + duration: int; +} + infoMediaHeaderFg: windowFg; infoMediaSearch: SearchFieldRow { @@ -827,8 +842,39 @@ infoMediaSkip: 2px; infoMediaLeft: 3px; infoMediaMargin: margins(0px, 6px, 0px, 2px); infoMediaMinGridSize: 82px; +infoMediaSkeletonMinHeight: 240px; +infoMediaSkeletonRowHeight: 64px; +infoMediaSkeletonRowPadding: 12px; +infoMediaSkeletonBarHeight: 10px; +infoMediaSkeletonFileThumb: 46px; +infoMediaSkeletonLinkRow: 56px; +infoMediaSkeletonLinkThumb: 32px; +infoMediaSkeletonSoundSize: 42px; +infoMediaTabsRightSkip: 13px; infoMediaGridSizeStep: 16px; +infoProfileTabsStrip: ProfileTabsStrip { + height: 48px; + margin: margins(8px, 6px, 8px, 6px); + skip: 0px; + tabPadding: margins(16px, 0px, 16px, 0px); + activeSkip: 4px; + style: semiboldTextStyle; + fg: windowSubTextFg; + fgActive: lightButtonFg; + bg: windowBg; + bgActive: lightButtonBgOver; + ripple: RippleAnimation(defaultRippleAnimation) { + color: lightButtonBgRipple; + } + duration: 200; +} +infoProfileTabsShadow: BoxShadow { + blurRadius: 8px; + offset: point(0px, 2px); + opacity: 0.18; +} + infoCommonGroupsMargin: margins(0px, 2px, 0px, 2px); infoCommonGroupsListItem: PeerListItem(defaultPeerListItem) { height: 52px; diff --git a/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_content.h b/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_content.h new file mode 100644 index 0000000000..7a56084809 --- /dev/null +++ b/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_content.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 "info/profile/tabs/info_profile_tab_top_bar_bindings.h" + +class PeerData; + +namespace Data { +class ForumTopic; +class SavedSublist; +} // namespace Data + +namespace Ui { +class RpWidget; +} // namespace Ui + +namespace Info { +class Controller; +} // namespace Info + +namespace Info::Profile { + +struct MediaTabContext { + not_null controller; + not_null peer; + Data::ForumTopic *topic = nullptr; + Data::SavedSublist *sublist = nullptr; + PeerData *migrated = nullptr; + QWidget *parent = nullptr; + Fn scrollToRequest; + Fn onlineCountChanged; +}; + +class MediaTabContent { +public: + virtual ~MediaTabContent() = default; + + [[nodiscard]] virtual not_null widget() = 0; + [[nodiscard]] virtual TabTopBarBindings topBarBindings() = 0; + + virtual void deactivated() { + } + + virtual void setVisibleRegion(int top, int bottom) { + } + + virtual void setTopOverlay(int height) { + } + + virtual void saveScrollState(QByteArray &out) { + } + virtual void restoreScrollState(const QByteArray &in) { + } +}; + +struct MediaTabDescriptor { + QString id; + rpl::producer title; + rpl::producer shown; + Fn(MediaTabContext)> factory; +}; + +} // namespace Info::Profile diff --git a/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_skeleton.cpp b/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_skeleton.cpp new file mode 100644 index 0000000000..c205a67b48 --- /dev/null +++ b/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_skeleton.cpp @@ -0,0 +1,154 @@ +/* +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 "info/profile/tabs/info_profile_tab_skeleton.h" + +#include "ui/effects/animations.h" +#include "ui/effects/glare.h" +#include "ui/painter.h" +#include "ui/rp_widget.h" +#include "styles/style_info.h" + +namespace Info::Profile { +namespace { + +using Type = Storage::SharedMediaType; + +void PaintGridSkeleton(QPainter &p, int width, int height) { + const auto minSize = st::infoMediaMinGridSize; + const auto columns = std::max(width / minSize, 1); + const auto size = (width - (columns - 1)) / float64(columns); + for (auto y = 0.; y < height; y += size + 1) { + for (auto i = 0; i != columns; ++i) { + p.drawRect(QRectF(i * (size + 1), y, size, size)); + } + } +} + +void PaintRowsSkeleton( + QPainter &p, + int width, + int height, + int rowHeight, + int thumbSize, + bool round, + int lines) { + const auto padding = st::infoMediaSkeletonRowPadding; + const auto bar = st::infoMediaSkeletonBarHeight; + const auto widths = std::array{ 3, 2, 1 }; + for (auto y = 0; y < height; y += rowHeight) { + const auto thumbTop = y + (rowHeight - thumbSize) / 2; + if (round) { + p.drawEllipse(padding, thumbTop, thumbSize, thumbSize); + } else { + p.drawRoundedRect( + QRect(padding, thumbTop, thumbSize, thumbSize), + st::roundRadiusSmall, + st::roundRadiusSmall); + } + const auto left = padding + thumbSize + padding; + const auto available = width - left - padding; + if (available <= 0) { + continue; + } + const auto linesHeight = lines * bar + (lines - 1) * (bar / 2); + auto barTop = y + (rowHeight - linesHeight) / 2; + for (auto line = 0; line != lines; ++line) { + p.drawRoundedRect( + left, + barTop, + available * widths[line % widths.size()] / 5, + bar, + bar / 2., + bar / 2.); + barTop += bar + (bar / 2); + } + } +} + +} // namespace + +object_ptr CreateTabSkeleton( + not_null parent, + Storage::SharedMediaType type) { + auto widget = object_ptr(parent); + const auto raw = widget.data(); + raw->setAttribute(Qt::WA_TransparentForMouseEvents); + + struct State { + Ui::GlareEffect glare; + }; + const auto state = raw->lifetime().make_state(); + + raw->paintRequest( + ) | rpl::on_next([=] { + auto p = QPainter(raw); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(st::windowBgOver); + const auto width = raw->width(); + const auto height = raw->height(); + switch (type) { + case Type::Photo: + case Type::Video: + case Type::GIF: + PaintGridSkeleton(p, width, height); + break; + case Type::File: + PaintRowsSkeleton( + p, + width, + height, + st::infoMediaSkeletonRowHeight, + st::infoMediaSkeletonFileThumb, + false, + 3); + break; + case Type::Link: + PaintRowsSkeleton( + p, + width, + height, + st::infoMediaSkeletonLinkRow, + st::infoMediaSkeletonLinkThumb, + false, + 3); + break; + default: + PaintRowsSkeleton( + p, + width, + height, + st::infoMediaSkeletonLinkRow, + st::infoMediaSkeletonSoundSize, + true, + 2); + break; + } + auto &glare = state->glare; + if (glare.glare.birthTime) { + const auto progress = glare.progress(crl::now()); + const auto x = (-glare.width) + + (width + glare.width * 2) * progress; + p.drawTiledPixmap(x, 0, glare.width, height, glare.pixmap, 0, 0); + } + }, raw->lifetime()); + + raw->widthValue( + ) | rpl::on_next([=](int width) { + state->glare.width = width; + state->glare.validate( + st::windowBg->c, + [=] { raw->update(); }, + crl::time(1000), + crl::time(1000)); + }, raw->lifetime()); + + return widget; +} + +} // namespace Info::Profile diff --git a/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_skeleton.h b/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_skeleton.h new file mode 100644 index 0000000000..322ef189e8 --- /dev/null +++ b/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_skeleton.h @@ -0,0 +1,23 @@ +/* +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/object_ptr.h" +#include "storage/storage_shared_media.h" + +namespace Ui { +class RpWidget; +} // namespace Ui + +namespace Info::Profile { + +[[nodiscard]] object_ptr CreateTabSkeleton( + not_null parent, + Storage::SharedMediaType type); + +} // namespace Info::Profile diff --git a/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_top_bar_bindings.h b/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_top_bar_bindings.h new file mode 100644 index 0000000000..3058a5e54e --- /dev/null +++ b/Telegram/SourceFiles/info/profile/tabs/info_profile_tab_top_bar_bindings.h @@ -0,0 +1,28 @@ +/* +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 "info/info_wrap_widget.h" + +namespace Ui::Menu { +struct MenuCallback; +} // namespace Ui::Menu + +namespace Info::Profile { + +struct TabTopBarBindings { + rpl::producer title; + rpl::producer subtitle; + Fn fillMenu; + rpl::producer selectedItems; + rpl::producer searchEnabledByContent; + Fn selectionAction; + Fn applySearchQuery; +}; + +} // namespace Info::Profile diff --git a/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_host.cpp b/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_host.cpp new file mode 100644 index 0000000000..cf3018ff8b --- /dev/null +++ b/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_host.cpp @@ -0,0 +1,513 @@ +/* +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 "info/profile/tabs/info_profile_tabs_host.h" + +#include "info/profile/tabs/info_profile_tabs_strip.h" +#include "ui/widgets/scroll_area.h" +#include "ui/effects/slide_animation.h" +#include "ui/painter.h" +#include "ui/ui_utility.h" +#include "styles/style_basic.h" +#include "styles/style_info.h" + +namespace Info::Profile { + +TabsHost::TabsHost(not_null parent, Descriptor descriptor) +: RpWidget(parent) +, _context(descriptor.context) +, _tabs(std::move(descriptor.tabs)) +, _strip(Ui::CreateChild(this, st::infoProfileTabsStrip)) +, _stripWeak(_strip) +, _body(Ui::CreateChild(this)) { + _strip->show(); + _body->show(); + if (_tabs.empty()) { + return; + } + _stripTitles.assign(_tabs.size(), QString()); + _tabsShown.assign(_tabs.size(), false); + wireStripTitles(); + wireTabsVisibility(); + _strip->activated( + ) | rpl::on_next([this](const QString &id) { + _userChosenTab = true; + _pendingRestoreId = QString(); + if (id == _activeId) { + // The viewport filler grows after the first scroll collapses + // the cover, so settle with a second queued pass. + const auto fire = [this] { + _scrollToRequests.fire({ 0, -1 }); + }; + fire(); + InvokeQueued(this, fire); + } else { + activateTab(id); + } + }, lifetime()); + + _body->heightValue( + ) | rpl::on_next([this](int) { + scheduleHeightSync(); + }, _body->lifetime()); + rpl::merge( + _strip->heightValue() | rpl::to_empty, + _strip->naturalWidthValue() | rpl::to_empty + ) | rpl::on_next([this] { + scheduleHeightSync(); + }, lifetime()); +} + +void TabsHost::scheduleBodySync() { + if (_bodySyncQueued) { + return; + } + _bodySyncQueued = true; + InvokeQueued(this, [=] { + if (_bodySyncQueued) { + syncBodyNow(); + } + }); +} + +void TabsHost::syncBodyNow() { + _bodySyncQueued = false; + const auto active = _activeTab.current(); + if (!active) { + return; + } + const auto widget = active->widget(); + if (!widget->isHidden() && _body->height() != widget->height()) { + _body->resize(_body->width(), widget->height()); + } +} + +void TabsHost::scheduleHeightSync() { + if (_heightSyncQueued) { + return; + } + _heightSyncQueued = true; + InvokeQueued(this, [=] { + if (_heightSyncQueued) { + syncHeightNow(); + } + }); +} + +void TabsHost::syncHeightNow() { + _heightSyncQueued = false; + resizeToWidth(width()); +} + +TabsHost::~TabsHost() { + // Lists notify their delegates while being destroyed, so the tab + // widgets must die before the adapters owning those delegates. + delete base::take(_body); +} + +void TabsHost::wireStripTitles() { + for (auto i = 0; i != int(_tabs.size()); ++i) { + rpl::duplicate( + _tabs[i].title + ) | rpl::on_next([this, i](QString text) { + if (_stripTitles[i] != text) { + _stripTitles[i] = std::move(text); + syncStripTitles(); + } + }, lifetime()); + } +} + +void TabsHost::wireTabsVisibility() { + for (auto i = 0; i != int(_tabs.size()); ++i) { + rpl::duplicate( + _tabs[i].shown + ) | rpl::on_next([this, i](bool shown) { + if (_tabsShown[i] != shown) { + _tabsShown[i] = shown; + syncStripTitles(); + if (shown + && !_pendingRestoreId.isEmpty() + && (_tabs[i].id == _pendingRestoreId)) { + restoreActiveTab(base::take(_pendingRestoreId)); + } + ensureActiveVisible(); + scheduleHeightSync(); + } + }, lifetime()); + } +} + +void TabsHost::syncStripTitles() { + auto stripTabs = std::vector(); + stripTabs.reserve(_tabs.size()); + for (auto i = 0; i != int(_tabs.size()); ++i) { + if (!_tabsShown[i]) { + continue; + } + stripTabs.push_back({ + .id = _tabs[i].id, + .text = _stripTitles[i], + }); + } + _strip->setTabs(std::move(stripTabs)); + if (!_activeId.isEmpty()) { + const auto it = ranges::find( + _tabs, + _activeId, + &MediaTabDescriptor::id); + if (it != end(_tabs) && _tabsShown[it - begin(_tabs)]) { + _strip->setActiveTab(_activeId); + } + } +} + +void TabsHost::ensureActiveVisible() { + const auto firstVisible = [&] { + for (auto i = 0; i != int(_tabs.size()); ++i) { + if (_tabsShown[i]) { + return i; + } + } + return -1; + }(); + const auto activeIndex = _activeId.isEmpty() + ? -1 + : int(ranges::find(_tabs, _activeId, &MediaTabDescriptor::id) + - begin(_tabs)); + const auto activeVisible = (activeIndex >= 0) + && (activeIndex < int(_tabs.size())) + && _tabsShown[activeIndex]; + if (activeVisible + && (_userChosenTab || activeIndex == firstVisible)) { + return; + } + if (firstVisible >= 0) { + if (activeIndex != firstVisible) { + activateTab(_tabs[firstVisible].id); + } + return; + } + if (!_activeId.isEmpty()) { + if (const auto previous = _contents[_activeId].get()) { + previous->deactivated(); + previous->widget()->hide(); + } + _activeId = QString(); + _activeTab = nullptr; + } +} + +void TabsHost::restoreActiveTab(const QString &id) { + const auto it = ranges::find(_tabs, id, &MediaTabDescriptor::id); + if (it == end(_tabs)) { + return; + } else if (_tabsShown[it - begin(_tabs)]) { + _userChosenTab = true; + activateTab(id, false); + } else { + _pendingRestoreId = id; + } +} + +QRect TabsHost::bodyGeometry() const { + return _body->geometry(); +} + +Fn TabsHost::prepareSwitch(bool toNextTab) { + if (_activeId.isEmpty()) { + return nullptr; + } + const auto active = int(ranges::find( + _tabs, + _activeId, + &MediaTabDescriptor::id) - begin(_tabs)); + if (active >= int(_tabs.size())) { + return nullptr; + } + const auto delta = toNextTab ? 1 : -1; + for (auto i = active + delta + ; i >= 0 && i < int(_tabs.size()) + ; i += delta) { + if (!_tabsShown[i]) { + continue; + } + return crl::guard(this, [=, id = _tabs[i].id] { + _userChosenTab = true; + _pendingRestoreId = QString(); + activateTab(id); + }); + } + return nullptr; +} + +void TabsHost::activateTab(const QString &id, bool animated) { + if (_activeId == id) { + _strip->setActiveTab(id); + return; + } + const auto it = ranges::find(_tabs, id, &MediaTabDescriptor::id); + if (it == end(_tabs) || !_tabsShown[it - begin(_tabs)]) { + return; + } + _strip->setActiveTab(id); + + auto &cached = _contents[id]; + const auto created = !cached; + if (created) { + auto context = _context; + context.parent = _body; + context.scrollToRequest = [weak = base::make_weak(this), id]( + int ymin, + int ymax) { + const auto that = weak.get(); + if (!that) { + return; + } + const auto i = that->_contents.find(id); + if (i == end(that->_contents)) { + return; + } + // Flush the queued height coalescers, otherwise the exact + // scroll below clamps to a stale scroll range. + that->syncBodyNow(); + that->syncHeightNow(); + const auto widget = i->second->widget(); + const auto top = Ui::MapFrom( + that, + widget.get(), + QPoint(0, ymin)).y(); + that->_scrollToRequests.fire({ + top, + (ymax < 0) ? -1 : (top + (ymax - ymin)), + }); + }; + cached = it->factory(std::move(context)); + } + const auto previousId = _activeId; + const auto previousIndex = previousId.isEmpty() + ? -1 + : int(ranges::find(_tabs, previousId, &MediaTabDescriptor::id) + - begin(_tabs)); + _activeId = id; + + const auto previous = previousId.isEmpty() + ? nullptr + : _contents[previousId].get(); + if (previous) { + if (_visibleTop >= 0) { + _tabScrollTops[previousId] = _visibleTop; + } else { + _tabScrollTops.remove(previousId); + } + _keepMinHeight = std::max(_visibleBottom, 0); + previous->deactivated(); + } + + const auto active = cached.get(); + const auto widget = active->widget(); + const auto raw = widget.get(); + if (created) { + if (raw->parentWidget() != _body) { + raw->setParent(_body); + } + _body->widthValue( + ) | rpl::on_next([raw](int newWidth) { + if (!raw->isHidden()) { + raw->resizeToWidth(newWidth); + } + }, raw->lifetime()); + raw->heightValue( + ) | rpl::on_next([this, raw](int) { + if (!raw->isHidden()) { + scheduleBodySync(); + } + }, raw->lifetime()); + } + raw->show(); + raw->resizeToWidth(_body->width()); + _body->resize(_body->width(), raw->height()); + + _activeTab = active; + pushViewportToActive(); + + if (previous) { + auto wasCache = QPixmap(); + if (animated + && _visibleBottom > _visibleTop + && _body->width() > 0) { + const auto part = bodyVisibleRect().intersected( + previous->widget()->rect()); + if (!part.isEmpty()) { + wasCache = Ui::GrabWidget(previous->widget(), part); + } + } + if (_visibleTop >= 0) { + const auto i = _tabScrollTops.find(id); + const auto restore = (i != end(_tabScrollTops)) + ? i->second + : 0; + if (restore != _visibleTop) { + const auto fire = [this, restore] { + _scrollToRequests.fire({ restore, -1 }); + }; + fire(); + InvokeQueued(this, fire); + } + } + if (animated) { + startSlideAnimation( + std::move(wasCache), + active, + previousIndex > int(it - begin(_tabs))); + } + previous->widget()->hide(); + } +} + +QRect TabsHost::bodyVisibleRect() const { + const auto bodyTop = _stripHeight; + const auto top = std::max(_visibleTop - bodyTop, 0); + const auto bottom = std::min( + _visibleBottom - bodyTop, + std::max(_body->height(), height() - bodyTop)); + return QRect( + 0, + top, + _body->width(), + std::max(bottom - top, 1)); +} + +void TabsHost::startSlideAnimation( + QPixmap wasCache, + not_null now, + bool slideLeft) { + if (_visibleBottom <= _visibleTop || _body->width() <= 0) { + return; + } + const auto rect = bodyVisibleRect(); + const auto part = rect.intersected(now->widget()->rect()); + auto nowCache = part.isEmpty() + ? QPixmap() + : Ui::GrabWidget(now->widget(), part); + if (wasCache.isNull() || nowCache.isNull()) { + return; + } + _slideRect = rect.translated(0, _stripHeight); + _slideAnimation = std::make_unique(); + _slideAnimation->setSnapshots( + std::move(wasCache), + std::move(nowCache)); + _slideAnimation->start(slideLeft, [=] { + if (_slideAnimation && !_slideAnimation->animating()) { + _slideAnimation = nullptr; + _body->show(); + } + update(); + }, st::slideDuration); + _body->hide(); + update(); +} + +void TabsHost::paintEvent(QPaintEvent *e) { + if (!_slideAnimation) { + return; + } + auto p = QPainter(this); + p.fillRect(_slideRect, st::windowBg); + _slideAnimation->paintFrame( + p, + _slideRect.x(), + _slideRect.y(), + width()); +} + +void TabsHost::pushViewportToActive() { + if (const auto active = _activeTab.current()) { + active->setTopOverlay((_visibleTop >= 0) ? _stripHeight : 0); + active->setVisibleRegion( + _visibleTop - _stripHeight, + _visibleBottom - _stripHeight); + } +} + +rpl::producer TabsHost::activeTabValue() const { + return _activeTab.value(); +} + +rpl::producer TabsHost::scrollToRequests() const { + return _scrollToRequests.events(); +} + +rpl::producer TabsHost::activeTabBindings() const { + return _activeTab.value( + ) | rpl::map([](MediaTabContent *tab) { + return tab ? tab->topBarBindings() : TabTopBarBindings(); + }); +} + +not_null TabsHost::stripWidget() const { + return _strip; +} + +void TabsHost::returnStrip() { + const auto strip = _stripWeak.get(); + if (!strip) { + return; + } + if (strip->parentWidget() != this) { + strip->setParent(this); + strip->show(); + } + resizeToWidth(width()); +} + +void TabsHost::setVisibleRegion(int top, int bottom) { + if (_visibleTop == top && _visibleBottom == bottom) { + return; + } + const auto heightChanged = ((_visibleBottom - _visibleTop) + != (bottom - top)); + _visibleTop = top; + _visibleBottom = bottom; + pushViewportToActive(); + if (heightChanged) { + const auto want = std::max( + _stripHeight + _body->height(), + bottom - top); + if (want != height()) { + scheduleHeightSync(); + } + } +} + +int TabsHost::resizeGetHeight(int newWidth) { + if (!ranges::contains(_tabsShown, true)) { + return 0; + } + if (const auto strip = _stripWeak.get() + ; strip && strip->parentWidget() == this) { + const auto stripWidth = std::min(strip->naturalWidth(), newWidth); + strip->resizeToWidth(stripWidth); + strip->moveToLeft((newWidth - stripWidth) / 2, 0); + _stripHeight = strip->height(); + } + const auto bodyTop = _stripHeight; + _body->resizeToWidth(std::max(newWidth, 1)); + _body->moveToLeft(0, bodyTop); + + const auto natural = bodyTop + _body->height(); + const auto viewport = _visibleBottom - _visibleTop; + if (_keepMinHeight + && ((natural >= _keepMinHeight) + || (_visibleBottom <= std::max(natural, viewport)))) { + _keepMinHeight = 0; + } + return std::max({ natural, viewport, _keepMinHeight }); +} + +} // namespace Info::Profile diff --git a/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_host.h b/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_host.h new file mode 100644 index 0000000000..13c237e581 --- /dev/null +++ b/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_host.h @@ -0,0 +1,100 @@ +/* +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/object_ptr.h" +#include "base/flat_map.h" +#include "info/profile/tabs/info_profile_tab_content.h" +#include "ui/rp_widget.h" + +namespace Ui { +class RpWidget; +class SlideAnimation; +struct ScrollToRequest; +} // namespace Ui + +namespace Info::Profile { + +class TabsStrip; + +class TabsHost final : public Ui::RpWidget { +public: + struct Descriptor { + MediaTabContext context; + std::vector tabs; + }; + + TabsHost(not_null parent, Descriptor descriptor); + ~TabsHost(); + + [[nodiscard]] rpl::producer activeTabValue() const; + [[nodiscard]] rpl::producer activeTabBindings() const; + [[nodiscard]] rpl::producer scrollToRequests() const; + + [[nodiscard]] not_null stripWidget() const; + void returnStrip(); + void setVisibleRegion(int top, int bottom); + + [[nodiscard]] QString activeId() const { + return _activeId; + } + void activateTab(const QString &id, bool animated = true); + void restoreActiveTab(const QString &id); + + [[nodiscard]] QRect bodyGeometry() const; + [[nodiscard]] Fn prepareSwitch(bool toNextTab); + +protected: + int resizeGetHeight(int newWidth) override; + void paintEvent(QPaintEvent *e) override; + +private: + void wireStripTitles(); + void wireTabsVisibility(); + void syncStripTitles(); + void ensureActiveVisible(); + void pushViewportToActive(); + void scheduleBodySync(); + void scheduleHeightSync(); + void syncBodyNow(); + void syncHeightNow(); + [[nodiscard]] QRect bodyVisibleRect() const; + void startSlideAnimation( + QPixmap wasCache, + not_null now, + bool slideLeft); + + const MediaTabContext _context; + std::vector _tabs; + std::vector _stripTitles; + std::vector _tabsShown; + + TabsStrip *_strip = nullptr; + base::weak_qptr _stripWeak; + Ui::RpWidget *_body = nullptr; + int _stripHeight = 0; + int _visibleTop = 0; + int _visibleBottom = 0; + + std::unique_ptr _slideAnimation; + QRect _slideRect; + + base::flat_map> _contents; + base::flat_map _tabScrollTops; + rpl::event_stream _scrollToRequests; + QString _activeId; + QString _pendingRestoreId; + bool _userChosenTab = false; + bool _bodySyncQueued = false; + bool _heightSyncQueued = false; + int _keepMinHeight = 0; + rpl::variable _activeTab = nullptr; + +}; + +} // namespace Info::Profile diff --git a/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_strip.cpp b/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_strip.cpp new file mode 100644 index 0000000000..86144eef2a --- /dev/null +++ b/Telegram/SourceFiles/info/profile/tabs/info_profile_tabs_strip.cpp @@ -0,0 +1,442 @@ +/* +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 "info/profile/tabs/info_profile_tabs_strip.h" + +#include "ui/effects/animation_value.h" +#include "ui/effects/animation_value_f.h" +#include "ui/effects/ripple_animation.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "ui/ui_utility.h" +#include "styles/style_basic.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_info.h" + +#include + +namespace Info::Profile { +namespace { + +void PaintIslandOutline( + QPainter &p, + const QRectF &island, + float64 radius, + const style::color &bg) { + const auto light = (bg->c.lightness() >= 128); + const auto width = light ? (0.6 * st::lineWidth) : double(st::lineWidth); + if (light) { + p.setPen(QPen(QColor(0, 0, 0, 20), width)); + } else { + auto grad = QLinearGradient(0, island.top(), 0, island.bottom()); + grad.setColorAt(0., QColor(255, 255, 255, 40)); + grad.setColorAt(0.5, QColor(255, 255, 255, 0)); + grad.setColorAt(1., QColor(255, 255, 255, 20)); + p.setPen(QPen(QBrush(grad), width)); + } + p.setBrush(Qt::NoBrush); + const auto half = 0.5 * width; + const auto stroke = light + ? island.marginsAdded(QMarginsF(half, half, half, half)) + : island.adjusted(half, half, -half, -half); + const auto strokeRadius = light ? (radius + half) : (radius - half); + p.drawRoundedRect(stroke, strokeRadius, strokeRadius); +} + +} // namespace + +TabsStrip::TabsStrip(QWidget *parent, const style::ProfileTabsStrip &st) +: RpWidget(parent) +, _st(st) +, _shadow(st::infoProfileTabsShadow) { + setObjectName(u"profileTabsStrip"_q); + setMouseTracking(true); +} + +void TabsStrip::setTabs(std::vector tabs) { + const auto activeId = (_active >= 0 && _active < int(_buttons.size())) + ? _buttons[_active].tab.id + : QString(); + setSelected(-1); + const auto pillHeight = _st.height + - _st.margin.top() + - _st.margin.bottom(); + auto buttons = std::vector