From f285ada36c01758b95f9be73fd855d8875d164e0 Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 25 Feb 2024 18:19:20 +0100 Subject: [PATCH] Estimate size of images to avoid layout shifts (#5192) --- CHANGELOG.md | 1 + src/messages/Image.cpp | 20 ++++++----- src/messages/Image.hpp | 15 +++++--- src/providers/bttv/BttvEmotes.cpp | 19 ++++++---- src/providers/chatterino/ChatterinoBadges.cpp | 18 ++++++++-- src/providers/emoji/Emojis.cpp | 3 +- src/providers/ffz/FfzBadges.cpp | 21 +++++++---- src/providers/ffz/FfzEmotes.cpp | 25 ++++++++----- src/providers/seventv/SeventvEmotes.cpp | 2 +- src/providers/twitch/ChannelPointReward.cpp | 21 +++++++---- src/providers/twitch/TwitchBadges.cpp | 26 ++++++++++---- src/providers/twitch/TwitchChannel.cpp | 36 +++++++++++-------- src/providers/twitch/TwitchEmotes.cpp | 8 +++-- 13 files changed, 145 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 749438123..ecb8f536c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,7 @@ - Dev: Specialize `Atomic>` if underlying standard library supports it. (#5133) - Dev: Added the `developer_name` field to the Linux AppData specification. (#5138) - Dev: Twitch messages can be sent using Twitch's Helix API instead of IRC (disabled by default). (#5200) +- Dev: Added estimation for image sizes to avoid layout shifts. (#5192) ## 2.4.6 diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index d33670470..f39485d5f 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -318,7 +318,7 @@ Image::~Image() } } -ImagePtr Image::fromUrl(const Url &url, qreal scale) +ImagePtr Image::fromUrl(const Url &url, qreal scale, QSize expectedSize) { static std::unordered_map> cache; static std::mutex mutex; @@ -329,7 +329,7 @@ ImagePtr Image::fromUrl(const Url &url, qreal scale) if (!shared) { - cache[url] = shared = ImagePtr(new Image(url, scale)); + cache[url] = shared = ImagePtr(new Image(url, scale, expectedSize)); } return shared; @@ -382,9 +382,11 @@ Image::Image() { } -Image::Image(const Url &url, qreal scale) +Image::Image(const Url &url, qreal scale, QSize expectedSize) : url_(url) , scale_(scale) + , expectedSize_(expectedSize.isValid() ? expectedSize + : (QSize(16, 16) * scale)) , shouldLoad_(true) , frames_(std::make_unique()) { @@ -477,11 +479,11 @@ int Image::width() const if (auto pixmap = this->frames_->first()) { - return int(pixmap->width() * this->scale_); + return static_cast(pixmap->width() * this->scale_); } - // No frames loaded, use our default magic width 16 - return 16; + // No frames loaded, use the expected size + return static_cast(this->expectedSize_.width() * this->scale_); } int Image::height() const @@ -490,11 +492,11 @@ int Image::height() const if (auto pixmap = this->frames_->first()) { - return int(pixmap->height() * this->scale_); + return static_cast(pixmap->height() * this->scale_); } - // No frames loaded, use our default magic height 16 - return 16; + // No frames loaded, use the expected size + return static_cast(this->expectedSize_.height() * this->scale_); } void Image::actuallyLoad() diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index 6e1052a8a..2eb0fcf04 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -73,7 +73,8 @@ public: Image(Image &&) = delete; Image &operator=(Image &&) = delete; - static ImagePtr fromUrl(const Url &url, qreal scale = 1); + static ImagePtr fromUrl(const Url &url, qreal scale = 1, + QSize expectedSize = {}); static ImagePtr fromResourcePixmap(const QPixmap &pixmap, qreal scale = 1); static ImagePtr getEmpty(); @@ -93,7 +94,7 @@ public: private: Image(); - Image(const Url &url, qreal scale); + Image(const Url &url, qreal scale, QSize expectedSize); Image(qreal scale); void setPixmap(const QPixmap &pixmap); @@ -102,12 +103,18 @@ private: const Url url_{}; const qreal scale_{1}; + /// @brief The expected size of this image once its loaded. + /// + /// This doesn't represent the actual size (it can be different) - it's + /// just an estimation and provided to avoid (large) layout shifts when + /// loading images. + const QSize expectedSize_{16, 16}; std::atomic_bool empty_{false}; - mutable std::chrono::time_point lastUsed_; - bool shouldLoad_{false}; + mutable std::chrono::time_point lastUsed_; + // gui thread only std::unique_ptr frames_{}; diff --git a/src/providers/bttv/BttvEmotes.cpp b/src/providers/bttv/BttvEmotes.cpp index d9e694afb..f76417fa1 100644 --- a/src/providers/bttv/BttvEmotes.cpp +++ b/src/providers/bttv/BttvEmotes.cpp @@ -22,6 +22,8 @@ namespace { "This channel has no BetterTTV channel emotes."); QString emoteLinkFormat("https://betterttv.com/emotes/%1"); + // BTTV doesn't provide any data on the size, so we assume an emote is 28x28 + constexpr QSize EMOTE_BASE_SIZE(28, 28); struct CreateEmoteResult { EmoteId id; @@ -65,9 +67,12 @@ namespace { auto emote = Emote({ name, - ImageSet{Image::fromUrl(getEmoteLinkV3(id, "1x"), 1), - Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5), - Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25)}, + ImageSet{Image::fromUrl(getEmoteLinkV3(id, "1x"), 1, + EMOTE_BASE_SIZE), + Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5, + EMOTE_BASE_SIZE * 2), + Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25, + EMOTE_BASE_SIZE * 4)}, Tooltip{name.string + "
Global BetterTTV Emote"}, Url{emoteLinkFormat.arg(id.string)}, }); @@ -90,9 +95,11 @@ namespace { auto emote = Emote({ name, ImageSet{ - Image::fromUrl(getEmoteLinkV3(id, "1x"), 1), - Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5), - Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25), + Image::fromUrl(getEmoteLinkV3(id, "1x"), 1, EMOTE_BASE_SIZE), + Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5, + EMOTE_BASE_SIZE * 2), + Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25, + EMOTE_BASE_SIZE * 4), }, Tooltip{ QString("%1
%2 BetterTTV Emote
By: %3") diff --git a/src/providers/chatterino/ChatterinoBadges.cpp b/src/providers/chatterino/ChatterinoBadges.cpp index cb5e7e472..a22d5b994 100644 --- a/src/providers/chatterino/ChatterinoBadges.cpp +++ b/src/providers/chatterino/ChatterinoBadges.cpp @@ -3,6 +3,7 @@ #include "common/network/NetworkRequest.hpp" #include "common/network/NetworkResult.hpp" #include "messages/Emote.hpp" +#include "messages/Image.hpp" #include #include @@ -44,12 +45,23 @@ void ChatterinoBadges::loadChatterinoBadges() jsonRoot.value("badges").toArray()) { auto jsonBadge = jsonBadgeValue.toObject(); + // The sizes for the images are only an estimation, there might + // be badges with different sizes. + constexpr QSize baseSize(18, 18); auto emote = Emote{ .name = EmoteName{}, .images = - ImageSet{Url{jsonBadge.value("image1").toString()}, - Url{jsonBadge.value("image2").toString()}, - Url{jsonBadge.value("image3").toString()}}, + ImageSet{ + Image::fromUrl( + Url{jsonBadge.value("image1").toString()}, 1.0, + baseSize), + Image::fromUrl( + Url{jsonBadge.value("image2").toString()}, 0.5, + baseSize * 2), + Image::fromUrl( + Url{jsonBadge.value("image3").toString()}, 0.25, + baseSize * 4), + }, .tooltip = Tooltip{jsonBadge.value("tooltip").toString()}, .homePage = Url{}, }; diff --git a/src/providers/emoji/Emojis.cpp b/src/providers/emoji/Emojis.cpp index 142fd0cb4..b644df830 100644 --- a/src/providers/emoji/Emojis.cpp +++ b/src/providers/emoji/Emojis.cpp @@ -285,7 +285,8 @@ void Emojis::loadEmojiSet() } QString url = urlPrefix + code + ".png"; emoji->emote = std::make_shared(Emote{ - EmoteName{emoji->value}, ImageSet{Image::fromUrl({url}, 0.35)}, + EmoteName{emoji->value}, + ImageSet{Image::fromUrl({url}, 0.35, {64, 64})}, Tooltip{":" + emoji->shortCodes[0] + ":
Emoji"}, Url{}}); } }); diff --git a/src/providers/ffz/FfzBadges.cpp b/src/providers/ffz/FfzBadges.cpp index 3c7a98604..561f47ea3 100644 --- a/src/providers/ffz/FfzBadges.cpp +++ b/src/providers/ffz/FfzBadges.cpp @@ -3,6 +3,7 @@ #include "common/network/NetworkRequest.hpp" #include "common/network/NetworkResult.hpp" #include "messages/Emote.hpp" +#include "messages/Image.hpp" #include "providers/ffz/FfzUtil.hpp" #include @@ -68,13 +69,21 @@ void FfzBadges::load() { auto jsonBadge = jsonBadge_.toObject(); auto jsonUrls = jsonBadge.value("urls").toObject(); + QSize baseSize(jsonBadge["width"].toInt(18), + jsonBadge["height"].toInt(18)); - auto emote = - Emote{EmoteName{}, - ImageSet{parseFfzUrl(jsonUrls.value("1").toString()), - parseFfzUrl(jsonUrls.value("2").toString()), - parseFfzUrl(jsonUrls.value("4").toString())}, - Tooltip{jsonBadge.value("title").toString()}, Url{}}; + auto emote = Emote{ + EmoteName{}, + ImageSet{Image::fromUrl( + parseFfzUrl(jsonUrls.value("1").toString()), + 1.0, baseSize), + Image::fromUrl( + parseFfzUrl(jsonUrls.value("2").toString()), + 0.5, baseSize * 2), + Image::fromUrl( + parseFfzUrl(jsonUrls.value("4").toString()), + 0.25, baseSize * 4)}, + Tooltip{jsonBadge.value("title").toString()}, Url{}}; Badge badge; diff --git a/src/providers/ffz/FfzEmotes.cpp b/src/providers/ffz/FfzEmotes.cpp index 7f34cfeda..8e589e9bd 100644 --- a/src/providers/ffz/FfzEmotes.cpp +++ b/src/providers/ffz/FfzEmotes.cpp @@ -20,6 +20,10 @@ const auto &LOG = chatterinoFfzemotes; const QString CHANNEL_HAS_NO_EMOTES( "This channel has no FrankerFaceZ channel emotes."); +// FFZ doesn't provide any data on the size for room badges, +// so we assume 18x18 (same as a Twitch badge) +constexpr QSize BASE_BADGE_SIZE(18, 18); + Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale) { auto emote = urls[emoteScale]; @@ -33,20 +37,23 @@ Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale) return parseFfzUrl(emote.toString()); } -void fillInEmoteData(const QJsonObject &urls, const EmoteName &name, - const QString &tooltip, Emote &emoteData) +void fillInEmoteData(const QJsonObject &emote, const QJsonObject &urls, + const EmoteName &name, const QString &tooltip, + Emote &emoteData) { auto url1x = getEmoteLink(urls, "1"); auto url2x = getEmoteLink(urls, "2"); auto url3x = getEmoteLink(urls, "4"); + QSize baseSize(emote["width"].toInt(28), emote["height"].toInt(28)); //, code, tooltip emoteData.name = name; emoteData.images = ImageSet{ - Image::fromUrl(url1x, 1), - url2x.string.isEmpty() ? Image::getEmpty() : Image::fromUrl(url2x, 0.5), + Image::fromUrl(url1x, 1, baseSize), + url2x.string.isEmpty() ? Image::getEmpty() + : Image::fromUrl(url2x, 0.5, baseSize * 2), url3x.string.isEmpty() ? Image::getEmpty() - : Image::fromUrl(url3x, 0.25)}; + : Image::fromUrl(url3x, 0.25, baseSize * 4)}; emoteData.tooltip = {tooltip}; } @@ -78,7 +85,7 @@ void parseEmoteSetInto(const QJsonObject &emoteSet, const QString &kind, } Emote emote; - fillInEmoteData(urls, name, + fillInEmoteData(emoteJson, urls, name, QString("%1
%2 FFZ Emote
By: %3") .arg(name.string, kind, author.string), emote); @@ -132,13 +139,13 @@ std::optional parseAuthorityBadge(const QJsonObject &badgeUrls, auto authorityBadge3x = getEmoteLink(badgeUrls, "4"); auto authorityBadgeImageSet = ImageSet{ - Image::fromUrl(authorityBadge1x, 1), + Image::fromUrl(authorityBadge1x, 1, BASE_BADGE_SIZE), authorityBadge2x.string.isEmpty() ? Image::getEmpty() - : Image::fromUrl(authorityBadge2x, 0.5), + : Image::fromUrl(authorityBadge2x, 0.5, BASE_BADGE_SIZE * 2), authorityBadge3x.string.isEmpty() ? Image::getEmpty() - : Image::fromUrl(authorityBadge3x, 0.25), + : Image::fromUrl(authorityBadge3x, 0.25, BASE_BADGE_SIZE * 4), }; authorityBadge = std::make_shared(Emote{ diff --git a/src/providers/seventv/SeventvEmotes.cpp b/src/providers/seventv/SeventvEmotes.cpp index 5d70d59a4..e96e83f65 100644 --- a/src/providers/seventv/SeventvEmotes.cpp +++ b/src/providers/seventv/SeventvEmotes.cpp @@ -463,7 +463,7 @@ ImageSet SeventvEmotes::createImageSet(const QJsonObject &emoteData) auto image = Image::fromUrl( {QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, - scale); + scale, {static_cast(width), file["height"].toInt(16)}); sizes.at(nextSize) = image; nextSize++; diff --git a/src/providers/twitch/ChannelPointReward.cpp b/src/providers/twitch/ChannelPointReward.cpp index 04d429316..62d93e3f0 100644 --- a/src/providers/twitch/ChannelPointReward.cpp +++ b/src/providers/twitch/ChannelPointReward.cpp @@ -27,22 +27,31 @@ ChannelPointReward::ChannelPointReward(const QJsonObject &redemption) } auto imageValue = reward.value("image"); + // From Twitch docs + // The size is only an estimation, the actual size might vary. + constexpr QSize baseSize(28, 28); if (imageValue.isObject()) { auto imageObject = imageValue.toObject(); this->image = ImageSet{ - Image::fromUrl({imageObject.value("url_1x").toString()}, 1), - Image::fromUrl({imageObject.value("url_2x").toString()}, 0.5), - Image::fromUrl({imageObject.value("url_4x").toString()}, 0.25), + Image::fromUrl({imageObject.value("url_1x").toString()}, 1, + baseSize), + Image::fromUrl({imageObject.value("url_2x").toString()}, 0.5, + baseSize * 2), + Image::fromUrl({imageObject.value("url_4x").toString()}, 0.25, + baseSize * 4), }; } else { static const ImageSet defaultImage{ - Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("1.png")}, 1), - Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("2.png")}, 0.5), - Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("4.png")}, 0.25)}; + Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("1.png")}, 1, + baseSize), + Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("2.png")}, 0.5, + baseSize * 2), + Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("4.png")}, 0.25, + baseSize * 4)}; this->image = defaultImage; } } diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index 862020faf..4d3f85112 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -18,6 +18,13 @@ #include #include +namespace { + +// From Twitch docs - expected size for a badge (1x) +constexpr QSize BADGE_BASE_SIZE(18, 18); + +} // namespace + namespace chatterino { void TwitchBadges::loadTwitchBadges() @@ -37,9 +44,12 @@ void TwitchBadges::loadTwitchBadges() .name = EmoteName{}, .images = ImageSet{ - Image::fromUrl(version.imageURL1x, 1), - Image::fromUrl(version.imageURL2x, .5), - Image::fromUrl(version.imageURL4x, .25), + Image::fromUrl(version.imageURL1x, 1, + BADGE_BASE_SIZE), + Image::fromUrl(version.imageURL2x, .5, + BADGE_BASE_SIZE * 2), + Image::fromUrl(version.imageURL4x, .25, + BADGE_BASE_SIZE * 4), }, .tooltip = Tooltip{version.title}, .homePage = version.clickURL, @@ -100,17 +110,19 @@ void TwitchBadges::parseTwitchBadges(QJsonObject root) for (auto vIt = versions.begin(); vIt != versions.end(); ++vIt) { auto versionObj = vIt.value().toObject(); - auto emote = Emote{ .name = {""}, .images = ImageSet{ Image::fromUrl( - {versionObj.value("image_url_1x").toString()}, 1), + {versionObj.value("image_url_1x").toString()}, 1, + BADGE_BASE_SIZE), Image::fromUrl( - {versionObj.value("image_url_2x").toString()}, .5), + {versionObj.value("image_url_2x").toString()}, .5, + BADGE_BASE_SIZE * 2), Image::fromUrl( - {versionObj.value("image_url_4x").toString()}, .25), + {versionObj.value("image_url_4x").toString()}, .25, + BADGE_BASE_SIZE * 4), }, .tooltip = Tooltip{versionObj.value("title").toString()}, .homePage = Url{versionObj.value("click_url").toString()}, diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 162f9aebf..b1a70166e 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -72,6 +72,9 @@ namespace { // Maximum number of chatters to fetch when refreshing chatters constexpr auto MAX_CHATTERS_TO_FETCH = 5000; + + // From Twitch docs - expected size for a badge (1x) + constexpr QSize BASE_BADGE_SIZE(18, 18); } // namespace TwitchChannel::TwitchChannel(const QString &name) @@ -1466,9 +1469,12 @@ void TwitchChannel::refreshBadges() .name = EmoteName{}, .images = ImageSet{ - Image::fromUrl(version.imageURL1x, 1), - Image::fromUrl(version.imageURL2x, .5), - Image::fromUrl(version.imageURL4x, .25), + Image::fromUrl(version.imageURL1x, 1, + BASE_BADGE_SIZE), + Image::fromUrl(version.imageURL2x, .5, + BASE_BADGE_SIZE * 2), + Image::fromUrl(version.imageURL4x, .25, + BASE_BADGE_SIZE * 4), }, .tooltip = Tooltip{version.title}, .homePage = version.clickURL, @@ -1543,25 +1549,25 @@ void TwitchChannel::refreshCheerEmotes() // Combine the prefix (e.g. BibleThump) with the tier (1, 100 etc.) auto emoteTooltip = set.prefix + tier.id + "
Twitch Cheer Emote"; + auto makeImageSet = [](const HelixCheermoteImage &image) { + return ImageSet{ + Image::fromUrl(image.imageURL1x, 1.0, + BASE_BADGE_SIZE), + Image::fromUrl(image.imageURL2x, 0.5, + BASE_BADGE_SIZE * 2), + Image::fromUrl(image.imageURL4x, 0.25, + BASE_BADGE_SIZE * 4), + }; + }; cheerEmote.animatedEmote = std::make_shared(Emote{ .name = EmoteName{"cheer emote"}, - .images = - ImageSet{ - tier.darkAnimated.imageURL1x, - tier.darkAnimated.imageURL2x, - tier.darkAnimated.imageURL4x, - }, + .images = makeImageSet(tier.darkAnimated), .tooltip = Tooltip{emoteTooltip}, .homePage = Url{}, }); cheerEmote.staticEmote = std::make_shared(Emote{ .name = EmoteName{"cheer emote"}, - .images = - ImageSet{ - tier.darkStatic.imageURL1x, - tier.darkStatic.imageURL2x, - tier.darkStatic.imageURL4x, - }, + .images = makeImageSet(tier.darkStatic), .tooltip = Tooltip{emoteTooltip}, .homePage = Url{}, }); diff --git a/src/providers/twitch/TwitchEmotes.cpp b/src/providers/twitch/TwitchEmotes.cpp index 8c2efa0e2..4c87e472b 100644 --- a/src/providers/twitch/TwitchEmotes.cpp +++ b/src/providers/twitch/TwitchEmotes.cpp @@ -44,12 +44,14 @@ EmotePtr TwitchEmotes::getOrCreateEmote(const EmoteId &id, if (!shared) { + // From Twitch docs - expected size for an emote (1x) + constexpr QSize baseSize(28, 28); (*cache)[id] = shared = std::make_shared(Emote{ EmoteName{name}, ImageSet{ - Image::fromUrl(getEmoteLink(id, "1.0"), 1), - Image::fromUrl(getEmoteLink(id, "2.0"), 0.5), - Image::fromUrl(getEmoteLink(id, "3.0"), 0.25), + Image::fromUrl(getEmoteLink(id, "1.0"), 1, baseSize), + Image::fromUrl(getEmoteLink(id, "2.0"), 0.5, baseSize * 2), + Image::fromUrl(getEmoteLink(id, "3.0"), 0.25, baseSize * 4), }, Tooltip{name.toHtmlEscaped() + "
Twitch Emote"}, });