diff --git a/Telegram/SourceFiles/data/data_story.cpp b/Telegram/SourceFiles/data/data_story.cpp index edd4670d8..d9c37de4e 100644 --- a/Telegram/SourceFiles/data/data_story.cpp +++ b/Telegram/SourceFiles/data/data_story.cpp @@ -41,13 +41,10 @@ using UpdateFlag = StoryUpdate::Flag; return { .geometry = { corner / 100., size / 100. }, .rotation = data.vrotation().v, + .radius = data.vradius().value_or_empty(), }; } -[[nodiscard]] uint32 ParseMilliKelvin(double celcius) { - return uint32(std::clamp(celcius + 273.15, 0., 1'000'000.) * 1000.); -} - [[nodiscard]] TextWithEntities StripLinks(TextWithEntities text) { const auto link = [&](const EntityInText &entity) { return (entity.type() == EntityType::CustomUrl) @@ -176,8 +173,11 @@ using UpdateFlag = StoryUpdate::Flag; result.emplace(WeatherArea{ .area = ParseArea(data.vcoordinates()), .emoji = qs(data.vemoji()), - .color = Ui::ColorFromSerialized(data.vcolor().v), - .millicelcius = int(data.vtemperature_c().v * 1000.), + .color = Ui::Color32FromSerialized(data.vcolor().v), + .millicelsius = int(1000. * std::clamp( + data.vtemperature_c().v, + -274., + 1'000'000.)), }); }, [&](const MTPDinputMediaAreaChannelPost &data) { LOG(("API Error: Unexpected inputMediaAreaChannelPost from API.")); @@ -721,6 +721,10 @@ const std::vector &Story::urlAreas() const { return _urlAreas; } +const std::vector &Story::weatherAreas() const { + return _weatherAreas; +} + void Story::applyChanges( StoryMedia media, const MTPDstoryItem &data, @@ -825,6 +829,7 @@ void Story::applyFields( auto suggestedReactions = std::vector(); auto channelPosts = std::vector(); auto urlAreas = std::vector(); + auto weatherAreas = std::vector(); if (const auto areas = data.vmedia_areas()) { for (const auto &area : areas->v) { if (const auto location = ParseLocation(area)) { @@ -840,6 +845,8 @@ void Story::applyFields( channelPosts.push_back(*post); } else if (auto url = ParseUrlArea(area)) { urlAreas.push_back(*url); + } else if (auto weather = ParseWeatherArea(area)) { + weatherAreas.push_back(*weather); } } } @@ -853,6 +860,7 @@ void Story::applyFields( = (_suggestedReactions != suggestedReactions); const auto channelPostsChanged = (_channelPosts != channelPosts); const auto urlAreasChanged = (_urlAreas != urlAreas); + const auto weatherAreasChanged = (_weatherAreas != weatherAreas); const auto reactionChanged = (_sentReactionId != reaction); _out = out; @@ -881,6 +889,9 @@ void Story::applyFields( if (urlAreasChanged) { _urlAreas = std::move(urlAreas); } + if (weatherAreasChanged) { + _weatherAreas = std::move(weatherAreas); + } if (reactionChanged) { _sentReactionId = reaction; } @@ -891,7 +902,8 @@ void Story::applyFields( || mediaChanged || locationsChanged || channelPostsChanged - || urlAreasChanged; + || urlAreasChanged + || weatherAreasChanged; const auto reactionsChanged = reactionChanged || suggestedReactionsChanged; if (!initial && (changed || reactionsChanged)) { diff --git a/Telegram/SourceFiles/data/data_story.h b/Telegram/SourceFiles/data/data_story.h index 4dfc7a712..bd508591c 100644 --- a/Telegram/SourceFiles/data/data_story.h +++ b/Telegram/SourceFiles/data/data_story.h @@ -81,6 +81,7 @@ struct StoryViews { struct StoryArea { QRectF geometry; float64 rotation = 0; + float64 radius = 0; friend inline bool operator==( const StoryArea &, @@ -135,7 +136,7 @@ struct WeatherArea { StoryArea area; QString emoji; QColor color; - int millicelcius = 0; + int millicelsius = 0; friend inline bool operator==( const WeatherArea &, @@ -219,6 +220,8 @@ public: -> const std::vector &; [[nodiscard]] auto urlAreas() const -> const std::vector &; + [[nodiscard]] auto weatherAreas() const + -> const std::vector &; void applyChanges( StoryMedia media, @@ -270,6 +273,7 @@ private: std::vector _suggestedReactions; std::vector _channelPosts; std::vector _urlAreas; + std::vector _weatherAreas; StoryViews _views; StoryViews _channelReactions; const TimeId _date = 0; diff --git a/Telegram/SourceFiles/media/stories/media_stories_controller.cpp b/Telegram/SourceFiles/media/stories/media_stories_controller.cpp index c5fd13992..ca558abfd 100644 --- a/Telegram/SourceFiles/media/stories/media_stories_controller.cpp +++ b/Telegram/SourceFiles/media/stories/media_stories_controller.cpp @@ -536,8 +536,9 @@ void Controller::rebuildActiveAreas(const Layout &layout) const { int(base::SafeRound(general.width() * scale.width())), int(base::SafeRound(general.height() * scale.height())) ).translated(origin); - if (const auto reaction = area.reaction.get()) { - reaction->setAreaGeometry(area.geometry); + area.radius = scale.width() * area.radiusOriginal / 100.; + if (const auto view = area.view.get()) { + view->setAreaGeometry(area.geometry, area.radius); } } } @@ -1050,6 +1051,9 @@ void Controller::updateAreas(Data::Story *story) { const auto &urlAreas = story ? story->urlAreas() : std::vector(); + const auto &weatherAreas = story + ? story->weatherAreas() + : std::vector(); if (_locations != locations) { _locations = locations; _areas.clear(); @@ -1062,13 +1066,18 @@ void Controller::updateAreas(Data::Story *story) { _urlAreas = urlAreas; _areas.clear(); } + if (_weatherAreas != weatherAreas) { + _weatherAreas = weatherAreas; + _areas.clear(); + } const auto reactionsCount = int(suggestedReactions.size()); if (_suggestedReactions.size() == reactionsCount && !_areas.empty()) { for (auto i = 0; i != reactionsCount; ++i) { const auto count = suggestedReactions[i].count; if (_suggestedReactions[i].count != count) { _suggestedReactions[i].count = count; - _areas[i + _locations.size()].reaction->updateCount(count); + const auto view = _areas[i + _locations.size()].view.get(); + view->updateReactionsCount(count); } if (_suggestedReactions[i] != suggestedReactions[i]) { _suggestedReactions = suggestedReactions; @@ -1206,7 +1215,8 @@ ClickHandlerPtr Controller::lookupAreaHandler(QPoint point) const { || (_locations.empty() && _suggestedReactions.empty() && _channelPosts.empty() - && _urlAreas.empty())) { + && _urlAreas.empty() + && _weatherAreas.empty())) { return nullptr; } else if (_areas.empty()) { const auto now = story(); @@ -1240,7 +1250,7 @@ ClickHandlerPtr Controller::lookupAreaHandler(QPoint point) const { } } }), - .reaction = std::move(widget), + .view = std::move(widget), }); } if (const auto session = now ? &now->session() : nullptr) { @@ -1261,19 +1271,27 @@ ClickHandlerPtr Controller::lookupAreaHandler(QPoint point) const { .handler = std::make_shared(url.url), }); } + for (const auto &weather : _weatherAreas) { + auto widget = _reactions->makeWeatherAreaWidget(weather); + const auto raw = widget.get(); + _areas.push_back({ + .original = weather.area.geometry, + .radiusOriginal = weather.area.radius, + .rotation = weather.area.rotation, + .handler = std::make_shared([=] { + raw->toggleMode(); + }), + .view = std::move(widget), + }); + } rebuildActiveAreas(*layout); } - const auto circleContains = [&](QRect circle) { - const auto radius = std::min(circle.width(), circle.height()) / 2; - const auto delta = circle.center() - point; - return QPoint::dotProduct(delta, delta) < (radius * radius); - }; for (const auto &area : _areas) { const auto center = area.geometry.center(); const auto angle = -area.rotation; - const auto contains = area.reaction - ? circleContains(area.geometry) + const auto contains = area.view + ? area.view->contains(point) : area.geometry.contains(Rotated(point, center, angle)); if (contains) { return area.handler; diff --git a/Telegram/SourceFiles/media/stories/media_stories_controller.h b/Telegram/SourceFiles/media/stories/media_stories_controller.h index 3d486fa87..590dee3f6 100644 --- a/Telegram/SourceFiles/media/stories/media_stories_controller.h +++ b/Telegram/SourceFiles/media/stories/media_stories_controller.h @@ -68,7 +68,7 @@ struct ContentLayout; class CaptionFullView; class RepostView; enum class ReactionsMode; -class SuggestedReactionView; +class StoryAreaView; struct RepostClickHandler; enum class HeaderLayout { @@ -208,10 +208,12 @@ private: }; struct ActiveArea { QRectF original; + float64 radiusOriginal = 0.; QRect geometry; float64 rotation = 0.; + float64 radius = 0.; ClickHandlerPtr handler; - std::unique_ptr reaction; + std::unique_ptr view; }; void initLayout(); @@ -303,6 +305,7 @@ private: std::vector _suggestedReactions; std::vector _channelPosts; std::vector _urlAreas; + std::vector _weatherAreas; mutable std::vector _areas; std::vector _cachedSourcesList; diff --git a/Telegram/SourceFiles/media/stories/media_stories_reactions.cpp b/Telegram/SourceFiles/media/stories/media_stories_reactions.cpp index af45d9150..f5e2582ef 100644 --- a/Telegram/SourceFiles/media/stories/media_stories_reactions.cpp +++ b/Telegram/SourceFiles/media/stories/media_stories_reactions.cpp @@ -11,6 +11,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/unixtime.h" #include "boxes/premium_preview_box.h" #include "chat_helpers/compose/compose_show.h" +#include "chat_helpers/stickers_lottie.h" +#include "chat_helpers/stickers_emoji_pack.h" #include "data/data_changes.h" #include "data/data_document.h" #include "data/data_document_media.h" @@ -20,6 +22,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/admin_log/history_admin_log_item.h" #include "history/view/media/history_view_custom_emoji.h" #include "history/view/media/history_view_media_unwrapped.h" +#include "history/view/media/history_view_sticker_player.h" #include "history/view/reactions/history_view_reactions_selector.h" #include "history/view/history_view_element.h" #include "history/history_item_reply_markup.h" @@ -61,7 +64,7 @@ constexpr auto kStoppingFadeDuration = crl::time(150); class ReactionView final : public Ui::RpWidget - , public SuggestedReactionView + , public StoryAreaView , public HistoryView::DefaultElementDelegate { public: ReactionView( @@ -69,9 +72,11 @@ public: not_null session, const Data::SuggestedReaction &reaction); - void setAreaGeometry(QRect geometry) override; - void updateCount(int count) override; + void setAreaGeometry(QRect geometry, float64 radius) override; + void updateReactionsCount(int count) override; void playEffect() override; + void toggleMode() override; + bool contains(QPoint point) override; private: using Element = HistoryView::Element; @@ -108,6 +113,7 @@ private: Ui::Text::String _counter; Ui::Animations::Simple _counterAnimation; QRectF _bubbleGeometry; + QRect _apiGeometry; int _size = 0; int _mediaLeft = 0; int _mediaTop = 0; @@ -126,6 +132,58 @@ private: }; +class WeatherView final : public Ui::RpWidget, public StoryAreaView { +public: + WeatherView( + QWidget *parent, + not_null session, + const Data::WeatherArea &data); + + void setAreaGeometry(QRect geometry, float64 radius) override; + void updateReactionsCount(int count) override; + void playEffect() override; + void toggleMode() override; + bool contains(QPoint point) override; + +private: + void paintEvent(QPaintEvent *e) override; + + void cacheBackground(); + void watchForSticker(); + void setStickerFrom(not_null document); + [[nodiscard]] QSize stickerSize() const; + + const not_null _session; + Data::WeatherArea _data; + EmojiPtr _emoji; + QColor _fg; + QImage _background; + QFont _font; + QRectF _rect; + QRect _wrapped; + float64 _radius = 0.; + int _emojiSize = 0; + int _padding = 0; + bool _celsius = true; + + std::shared_ptr _sticker; + rpl::lifetime _lifetime; + +}; + +[[nodiscard]] QPoint Rotated(QPoint point, QPoint origin, float64 angle) { + if (std::abs(angle) < 1.) { + return point; + } + const auto alpha = angle / 180. * M_PI; + const auto acos = cos(alpha); + const auto asin = sin(alpha); + point -= origin; + return origin + QPoint( + int(base::SafeRound(acos * point.x() - asin * point.y())), + int(base::SafeRound(asin * point.x() + acos * point.y()))); +} + [[nodiscard]] AdminLog::OwnedItem GenerateFakeItem( not_null delegate, not_null history) { @@ -140,6 +198,13 @@ private: return AdminLog::OwnedItem(delegate, item); } +[[nodiscard]] QColor ChooseWeatherFg(const QColor &bg) { + const auto luminance = (0.2126 * bg.redF()) + + (0.7152 * bg.greenF()) + + (0.0722 * bg.blueF()); + return (luminance > 0.705) ? QColor(0, 0, 0) : QColor(255, 255, 255); +} + ReactionView::ReactionView( QWidget *parent, not_null session, @@ -198,7 +263,7 @@ ReactionView::ReactionView( }, lifetime()); _data.count = 0; - updateCount(reaction.count); + updateReactionsCount(reaction.count); _counterAnimation.stop(); setupCustomChatStylePalette(); @@ -212,7 +277,8 @@ void ReactionView::setupCustomChatStylePalette() { _chatStyle->applyCustomPalette(_chatStyle.get()); } -void ReactionView::setAreaGeometry(QRect geometry) { +void ReactionView::setAreaGeometry(QRect geometry, float64 radius) { + _apiGeometry = geometry; _size = std::min(geometry.width(), geometry.height()); _bubble = _size * kSuggestedBubbleSize; _bigOffset = _bubble * kSuggestedTailBigOffset; @@ -228,7 +294,7 @@ void ReactionView::setAreaGeometry(QRect geometry) { updateEffectGeometry(); } -void ReactionView::updateCount(int count) { +void ReactionView::updateReactionsCount(int count) { if (_data.count == count) { return; } @@ -283,6 +349,17 @@ void ReactionView::playEffect() { } } +void ReactionView::toggleMode() { + Unexpected("ReactionView::toggleMode."); +} + +bool ReactionView::contains(QPoint point) { + const auto circle = _apiGeometry; + const auto radius = std::min(circle.width(), circle.height()) / 2; + const auto delta = circle.center() - point; + return QPoint::dotProduct(delta, delta) < (radius * radius); +} + void ReactionView::paintEffectFrame( QPainter &p, not_null effect, @@ -457,6 +534,205 @@ void ReactionView::cacheBackground() { paintShape(_data.dark ? dark : QColor(255, 255, 255)); } +WeatherView::WeatherView( + QWidget *parent, + not_null session, + const Data::WeatherArea &data) +: RpWidget(parent) +, _session(session) +, _data(data) +, _emoji(Ui::Emoji::Find(_data.emoji)) +, _fg(ChooseWeatherFg(_data.color)) { + watchForSticker(); + setAttribute(Qt::WA_TransparentForMouseEvents); + show(); +} + +void WeatherView::watchForSticker() { + if (!_emoji) { + return; + } + const auto emojiStickers = &_session->emojiStickersPack(); + if (const auto sticker = emojiStickers->stickerForEmoji(_emoji)) { + setStickerFrom(sticker.document); + } else { + emojiStickers->refreshed() | rpl::map([=] { + return emojiStickers->stickerForEmoji(_emoji).document; + }) | rpl::filter([=](DocumentData *document) { + return document != nullptr; + }) | rpl::take( + 1 + ) | rpl::start_with_next([=](not_null document) { + setStickerFrom(document); + update(); + }, _lifetime); + } +} + +void WeatherView::setAreaGeometry(QRect geometry, float64 radius) { + const auto diagxdiag = (geometry.width() * geometry.width()) + + (geometry.height() * geometry.height()); + const auto diag = std::sqrt(diagxdiag); + const auto topleft = QRectF(geometry).center() + - QPointF(diag / 2., diag / 2.); + const auto bottomright = topleft + QPointF(diag, diag); + const auto left = int(std::floor(topleft.x())); + const auto top = int(std::floor(topleft.y())); + const auto right = int(std::ceil(bottomright.x())); + const auto bottom = int(std::ceil(bottomright.y())); + setGeometry(left, top, right - left, bottom - top); + _rect = QRectF(geometry).translated(-left, -top); + _radius = radius; + + _emojiSize = int(base::SafeRound(_rect.height() * 2 / 3.)); + _font = st::semiboldFont->f; + _font.setPixelSize(_emojiSize); + _background = {}; +} + +void WeatherView::updateReactionsCount(int count) { + Unexpected("WeatherView::updateRactionsCount."); +} + +void WeatherView::playEffect() { + Unexpected("WeatherView::playEffect."); +} + +void WeatherView::toggleMode() { + _celsius = !_celsius; + _background = {}; + update(); +} + +bool WeatherView::contains(QPoint point) { + const auto geometry = _rect.translated(pos()).toRect(); + const auto angle = -_data.area.rotation; + return geometry.contains(Rotated(point, geometry.center(), angle)); +} + +void WeatherView::paintEvent(QPaintEvent *e) { + auto p = Painter(this); + if (_background.size() != size() * style::DevicePixelRatio()) { + cacheBackground(); + } + p.drawImage(0, 0, _background); + if (_sticker && _sticker->ready()) { + auto hq = PainterHighQualityEnabler(p); + const auto rcenter = _wrapped.center(); + p.translate(rcenter); + p.rotate(_data.area.rotation); + p.translate(-rcenter); + + const auto image = _sticker->frame( + stickerSize(), + QColor(0, 0, 0, 0), + false, + crl::now(), + false).image; + const auto size = image.size() / style::DevicePixelRatio(); + const auto rect = QRectF( + _wrapped.x() + _padding + (_emojiSize - size.width()) / 2., + _wrapped.y() + (_wrapped.height() - size.height()) / 2., + size.width(), + size.height()); + const auto scenter = rect.center(); + const auto scale = (_emojiSize * 1.) / stickerSize().width(); + p.translate(scenter); + p.scale(scale, scale); + p.translate(-scenter); + p.drawImage(rect, image); + _sticker->markFrameShown(); + } +} + +QSize WeatherView::stickerSize() const { + return QSize(st::chatIntroStickerSize, st::chatIntroStickerSize); +} + +void WeatherView::setStickerFrom(not_null document) { + if (_sticker || !_emoji) { + return; + } + const auto media = document->createMediaView(); + media->checkStickerLarge(); + media->goodThumbnailWanted(); + + rpl::single() | rpl::then( + document->owner().session().downloaderTaskFinished() + ) | rpl::filter([=] { + return media->loaded(); + }) | rpl::take(1) | rpl::start_with_next([=] { + const auto sticker = document->sticker(); + if (sticker->isLottie()) { + _sticker = std::make_shared( + ChatHelpers::LottiePlayerFromDocument( + media.get(), + ChatHelpers::StickerLottieSize::StickerSet, + stickerSize(), + Lottie::Quality::High)); + } else if (sticker->isWebm()) { + _sticker = std::make_shared( + media->owner()->location(), + media->bytes(), + stickerSize()); + } else { + _sticker = std::make_shared( + media->owner()->location(), + media->bytes(), + stickerSize()); + } + _sticker->setRepaintCallback([=] { update(); }); + update(); + }, _lifetime); +} + +void WeatherView::cacheBackground() { + const auto ratio = style::DevicePixelRatio(); + _background = QImage( + size() * ratio, + QImage::Format_ARGB32_Premultiplied); + _background.setDevicePixelRatio(ratio); + _background.fill(Qt::transparent); + + auto p = QPainter(&_background); + auto hq = PainterHighQualityEnabler(p); + p.setBrush(_data.color); + p.setPen(Qt::NoPen); + const auto center = _rect.center(); + p.translate(center); + p.rotate(_data.area.rotation); + p.translate(-center); + + const auto format = [](float64 value) { + return QString::number(int(base::SafeRound(value * 10)) / 10.); + }; + const auto text = [&] { + const auto celsius = _data.millicelsius / 1000.; + if (_celsius) { + return format(celsius); + } + const auto fahrenheit = (celsius * 9.0 / 5.0) + 32; + return format(fahrenheit); + }().append(QChar(0xb0)).append(_celsius ? "C" : "F"); + const auto metrics = QFontMetrics(_font); + const auto textWidth = metrics.horizontalAdvance(text); + _padding = int(_rect.height() / 6); + const auto fullWidth = (_emoji ? _emojiSize : 0) + + textWidth + + (2 * _padding); + const auto left = _rect.x() + (_rect.width() - fullWidth) / 2; + _wrapped = QRect(left, _rect.y(), fullWidth, _rect.height()); + + p.drawRoundedRect(_wrapped, _radius, _radius); + + p.setPen(_fg); + p.setFont(_font); + p.drawText(_wrapped.marginsRemoved( + { _padding + (_emoji ? _emojiSize : 0), 0, _padding, 0 }), + text, + style::al_center); +} + [[nodiscard]] Data::ReactionId HeartReactionId() { return { QString() + QChar(10084) }; } @@ -804,13 +1080,21 @@ auto Reactions::chosen() const -> rpl::producer { auto Reactions::makeSuggestedReactionWidget( const Data::SuggestedReaction &reaction) --> std::unique_ptr { +-> std::unique_ptr { return std::make_unique( _controller->wrap(), &_controller->uiShow()->session(), reaction); } +auto Reactions::makeWeatherAreaWidget(const Data::WeatherArea &data) +-> std::unique_ptr { + return std::make_unique( + _controller->wrap(), + &_controller->uiShow()->session(), + data); +} + void Reactions::setReplyFieldState( rpl::producer focused, rpl::producer hasSendText) { diff --git a/Telegram/SourceFiles/media/stories/media_stories_reactions.h b/Telegram/SourceFiles/media/stories/media_stories_reactions.h index de9f0e0ce..b17e2e19d 100644 --- a/Telegram/SourceFiles/media/stories/media_stories_reactions.h +++ b/Telegram/SourceFiles/media/stories/media_stories_reactions.h @@ -16,6 +16,7 @@ struct ReactionId; class Session; class Story; struct SuggestedReaction; +struct WeatherArea; } // namespace Data namespace HistoryView::Reactions { @@ -41,13 +42,15 @@ enum class ReactionsMode { Reaction, }; -class SuggestedReactionView { +class StoryAreaView { public: - virtual ~SuggestedReactionView() = default; + virtual ~StoryAreaView() = default; - virtual void setAreaGeometry(QRect geometry) = 0; - virtual void updateCount(int count) = 0; + virtual void setAreaGeometry(QRect geometry, float64 radius) = 0; + virtual void updateReactionsCount(int count) = 0; virtual void playEffect() = 0; + virtual void toggleMode() = 0; + virtual bool contains(QPoint point) = 0; }; class Reactions final { @@ -79,7 +82,9 @@ public: [[nodiscard]] auto makeSuggestedReactionWidget( const Data::SuggestedReaction &reaction) - -> std::unique_ptr; + -> std::unique_ptr; + [[nodiscard]] auto makeWeatherAreaWidget(const Data::WeatherArea &data) + -> std::unique_ptr; void setReplyFieldState( rpl::producer focused, diff --git a/Telegram/SourceFiles/ui/color_int_conversion.cpp b/Telegram/SourceFiles/ui/color_int_conversion.cpp index a1b0bcb45..5c9c57f07 100644 --- a/Telegram/SourceFiles/ui/color_int_conversion.cpp +++ b/Telegram/SourceFiles/ui/color_int_conversion.cpp @@ -22,4 +22,12 @@ std::optional MaybeColorFromSerialized(quint32 serialized) { : std::make_optional(ColorFromSerialized(serialized)); } +QColor Color32FromSerialized(quint32 serialized) { + return QColor( + int((serialized >> 24) & 0xFFU), + int((serialized >> 16) & 0xFFU), + int((serialized >> 8) & 0xFFU), + int(serialized & 0xFFU)); +} + } // namespace Ui diff --git a/Telegram/SourceFiles/ui/color_int_conversion.h b/Telegram/SourceFiles/ui/color_int_conversion.h index ed2bb6a18..1102863b2 100644 --- a/Telegram/SourceFiles/ui/color_int_conversion.h +++ b/Telegram/SourceFiles/ui/color_int_conversion.h @@ -12,5 +12,6 @@ namespace Ui { [[nodiscard]] QColor ColorFromSerialized(quint32 serialized); [[nodiscard]] std::optional MaybeColorFromSerialized( quint32 serialized); +[[nodiscard]] QColor Color32FromSerialized(quint32 serialized); } // namespace Ui