diff --git a/CHANGELOG.md b/CHANGELOG.md index 887ba3c202d..5e616c30021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unversioned - Minor: Added support for FrankerFaceZ animated emotes. (#4434) +- Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) +- Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) - Dev: Ignore unhandled BTTV user-events. (#4438) - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0c53153b773..98368033c33 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -420,6 +420,8 @@ set(SOURCE_FILES widgets/Scrollbar.hpp widgets/StreamView.cpp widgets/StreamView.hpp + widgets/TooltipEntryWidget.cpp + widgets/TooltipEntryWidget.hpp widgets/TooltipWidget.cpp widgets/TooltipWidget.hpp widgets/Window.cpp diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index d97b12e24a9..bc0cfdbab53 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -687,6 +687,26 @@ void MessageBuilder::append(std::unique_ptr element) this->message().elements.push_back(std::move(element)); } +bool MessageBuilder::isEmpty() const +{ + return this->message_->elements.empty(); +} + +MessageElement &MessageBuilder::back() +{ + assert(!this->isEmpty()); + return *this->message().elements.back(); +} + +std::unique_ptr MessageBuilder::releaseBack() +{ + assert(!this->isEmpty()); + + auto ptr = std::move(this->message().elements.back()); + this->message().elements.pop_back(); + return ptr; +} + QString MessageBuilder::matchLink(const QString &string) { LinkParser linkParser(string); diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 96ab6658691..9a7d6864338 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -123,6 +123,10 @@ class MessageBuilder virtual void addTextOrEmoji(EmotePtr emote); virtual void addTextOrEmoji(const QString &value); + bool isEmpty() const; + MessageElement &back(); + std::unique_ptr releaseBack(); + MessageColor textColor_ = MessageColor::Text; private: diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index ea76b591868..29558a8e321 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -15,6 +15,24 @@ namespace chatterino { +namespace { + + // Computes the bounding box for the given vector of images + QSize getBoundingBoxSize(const std::vector &images) + { + int width = 0; + int height = 0; + for (const auto &img : images) + { + width = std::max(width, img->width()); + height = std::max(height, img->height()); + } + + return QSize(width, height); + } + +} // namespace + MessageElement::MessageElement(MessageElementFlags flags) : flags_(flags) { @@ -216,6 +234,168 @@ MessageLayoutElement *EmoteElement::makeImageLayoutElement( return new ImageLayoutElement(*this, image, size); } +LayeredEmoteElement::LayeredEmoteElement(std::vector &&emotes, + MessageElementFlags flags, + const MessageColor &textElementColor) + : MessageElement(flags) + , emotes_(std::move(emotes)) + , textElementColor_(textElementColor) +{ + this->updateTooltips(); +} + +void LayeredEmoteElement::addEmoteLayer(const EmotePtr &emote) +{ + this->emotes_.push_back(emote); + this->updateTooltips(); +} + +void LayeredEmoteElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + if (flags.hasAny(this->getFlags())) + { + if (flags.has(MessageElementFlag::EmoteImages)) + { + auto images = this->getLoadedImages(container.getScale()); + if (images.empty()) + { + return; + } + + auto emoteScale = getSettings()->emoteScale.getValue(); + float overallScale = emoteScale * container.getScale(); + + auto largestSize = getBoundingBoxSize(images) * overallScale; + std::vector individualSizes; + individualSizes.reserve(this->emotes_.size()); + for (auto img : images) + { + individualSizes.push_back(QSize(img->width(), img->height()) * + overallScale); + } + + container.addElement(this->makeImageLayoutElement( + images, individualSizes, largestSize) + ->setLink(this->getLink())); + } + else + { + if (this->textElement_) + { + this->textElement_->addToContainer(container, + MessageElementFlag::Misc); + } + } + } +} + +std::vector LayeredEmoteElement::getLoadedImages(float scale) +{ + std::vector res; + res.reserve(this->emotes_.size()); + + for (auto emote : this->emotes_) + { + auto image = emote->images.getImageOrLoaded(scale); + if (image->isEmpty()) + { + continue; + } + res.push_back(image); + } + return res; +} + +MessageLayoutElement *LayeredEmoteElement::makeImageLayoutElement( + const std::vector &images, const std::vector &sizes, + QSize largestSize) +{ + return new LayeredImageLayoutElement(*this, images, sizes, largestSize); +} + +void LayeredEmoteElement::updateTooltips() +{ + if (!this->emotes_.empty()) + { + QString copyStr = this->getCopyString(); + this->textElement_.reset(new TextElement( + copyStr, MessageElementFlag::Misc, this->textElementColor_)); + this->setTooltip(copyStr); + } + + std::vector result; + result.reserve(this->emotes_.size()); + + for (auto &emote : this->emotes_) + { + result.push_back(emote->tooltip.string); + } + + this->emoteTooltips_ = std::move(result); +} + +const std::vector &LayeredEmoteElement::getEmoteTooltips() const +{ + return this->emoteTooltips_; +} + +QString LayeredEmoteElement::getCleanCopyString() const +{ + QString result; + for (size_t i = 0; i < this->emotes_.size(); ++i) + { + if (i != 0) + { + result += " "; + } + result += + TwitchEmotes::cleanUpEmoteCode(this->emotes_[i]->getCopyString()); + } + return result; +} + +QString LayeredEmoteElement::getCopyString() const +{ + QString result; + for (size_t i = 0; i < this->emotes_.size(); ++i) + { + if (i != 0) + { + result += " "; + } + result += this->emotes_[i]->getCopyString(); + } + return result; +} + +const std::vector &LayeredEmoteElement::getEmotes() const +{ + return this->emotes_; +} + +std::vector LayeredEmoteElement::getUniqueEmotes() const +{ + // Functor for std::copy_if that keeps track of seen elements + struct NotDuplicate { + bool operator()(const EmotePtr &element) + { + return seen.insert(element).second; + } + + private: + std::set seen; + }; + + // Get unique emotes while maintaining relative layering order + NotDuplicate dup; + std::vector unique; + std::copy_if(this->emotes_.begin(), this->emotes_.end(), + std::back_insert_iterator(unique), dup); + + return unique; +} + // BADGE BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags) : MessageElement(flags) diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 0ce8443fcac..5f6dcca1d9a 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -141,9 +141,7 @@ enum class MessageElementFlag : int64_t { LowercaseLink = (1LL << 29), OriginalLink = (1LL << 30), - // ZeroWidthEmotes are emotes that are supposed to overlay over any pre-existing emotes - // e.g. BTTV's SoSnowy during christmas season or 7TV's RainTime - ZeroWidthEmote = (1LL << 31), + // Unused: (1LL << 31) // for elements of the message reply RepliedMessage = (1LL << 32), @@ -321,6 +319,43 @@ class EmoteElement : public MessageElement EmotePtr emote_; }; +// A LayeredEmoteElement represents multiple Emotes layered on top of each other. +// This class takes care of rendering animated and non-animated emotes in the +// correct order and aligning them in the right way. +class LayeredEmoteElement : public MessageElement +{ +public: + LayeredEmoteElement( + std::vector &&emotes, MessageElementFlags flags, + const MessageColor &textElementColor = MessageColor::Text); + + void addEmoteLayer(const EmotePtr &emote); + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + + // Returns a concatenation of each emote layer's cleaned copy string + QString getCleanCopyString() const; + const std::vector &getEmotes() const; + std::vector getUniqueEmotes() const; + const std::vector &getEmoteTooltips() const; + +private: + MessageLayoutElement *makeImageLayoutElement( + const std::vector &image, const std::vector &sizes, + QSize largestSize); + + QString getCopyString() const; + void updateTooltips(); + std::vector getLoadedImages(float scale); + + std::vector emotes_; + std::vector emoteTooltips_; + + std::unique_ptr textElement_; + MessageColor textElementColor_; +}; + class BadgeElement : public MessageElement { public: diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index 28050782a0c..540e9dfac43 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -67,10 +67,7 @@ void MessageLayoutContainer::clear() void MessageLayoutContainer::addElement(MessageLayoutElement *element) { - bool isZeroWidth = - element->getFlags().has(MessageElementFlag::ZeroWidthEmote); - - if (!isZeroWidth && !this->fitsInLine(element->getRect().width())) + if (!this->fitsInLine(element->getRect().width())) { this->breakLine(); } @@ -175,14 +172,6 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, this->lineHeight_ = std::max(this->lineHeight_, elementLineHeight); auto xOffset = 0; - bool isZeroWidthEmote = element->getCreator().getFlags().has( - MessageElementFlag::ZeroWidthEmote); - - if (isZeroWidthEmote && !isRTLMode) - { - xOffset -= element->getRect().width() + this->spaceWidth_; - } - auto yOffset = 0; if (element->getCreator().getFlags().has( @@ -195,7 +184,7 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, if (getSettings()->removeSpacesBetweenEmotes && element->getFlags().hasAny({MessageElementFlag::EmoteImages}) && - !isZeroWidthEmote && shouldRemoveSpaceBetweenEmotes()) + shouldRemoveSpaceBetweenEmotes()) { // Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote if (isRTLMode) @@ -230,16 +219,13 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, } // set current x - if (!isZeroWidthEmote) + if (isRTLMode) { - if (isRTLMode) - { - this->currentX_ -= element->getRect().width(); - } - else - { - this->currentX_ += element->getRect().width(); - } + this->currentX_ -= element->getRect().width(); + } + else + { + this->currentX_ += element->getRect().width(); } if (element->hasTrailingSpace()) diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index ab75c16dbac..cd071b4f252 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -15,6 +15,14 @@ namespace { const QChar RTL_EMBED(0x202B); + +void alignRectBottomCenter(QRectF &rect, const QRectF &reference) +{ + QPointF newCenter(reference.center().x(), + reference.bottom() - (rect.height() / 2.0)); + rect.moveCenter(newCenter); +} + } // namespace namespace chatterino { @@ -184,6 +192,133 @@ int ImageLayoutElement::getXFromIndex(int index) } } +// +// LAYERED IMAGE +// + +LayeredImageLayoutElement::LayeredImageLayoutElement( + MessageElement &creator, std::vector images, + std::vector sizes, QSize largestSize) + : MessageLayoutElement(creator, largestSize) + , images_(std::move(images)) + , sizes_(std::move(sizes)) +{ + assert(this->images_.size() == this->sizes_.size()); + this->trailingSpace = creator.hasTrailingSpace(); +} + +void LayeredImageLayoutElement::addCopyTextToString(QString &str, uint32_t from, + uint32_t to) const +{ + const auto *layeredEmoteElement = + dynamic_cast(&this->getCreator()); + if (layeredEmoteElement) + { + // cleaning is taken care in call + str += layeredEmoteElement->getCleanCopyString(); + if (this->hasTrailingSpace()) + { + str += " "; + } + } +} + +int LayeredImageLayoutElement::getSelectionIndexCount() const +{ + return this->trailingSpace ? 2 : 1; +} + +void LayeredImageLayoutElement::paint(QPainter &painter) +{ + auto fullRect = QRectF(this->getRect()); + + for (size_t i = 0; i < this->images_.size(); ++i) + { + auto &img = this->images_[i]; + if (img == nullptr) + { + continue; + } + + auto pixmap = img->pixmapOrLoad(); + if (img->animated()) + { + // As soon as we see an animated emote layer, we can stop rendering + // the static emotes. The paintAnimated function will render any + // static emotes layered on top of the first seen animated emote. + return; + } + + if (pixmap) + { + // Matching the web chat behavior, we center the emote within the overall + // binding box. E.g. small overlay emotes like cvMask will sit in the direct + // center of even wide emotes. + auto &size = this->sizes_[i]; + QRectF destRect(0, 0, size.width(), size.height()); + alignRectBottomCenter(destRect, fullRect); + + painter.drawPixmap(destRect, *pixmap, QRectF()); + } + } +} + +void LayeredImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) +{ + auto fullRect = QRectF(this->getRect()); + fullRect.moveTop(fullRect.y() + yOffset); + bool animatedFlag = false; + + for (size_t i = 0; i < this->images_.size(); ++i) + { + auto &img = this->images_[i]; + if (img == nullptr) + { + continue; + } + + // If we have a static emote layered on top of an animated emote, we need + // to render the static emote again after animating anything below it. + if (img->animated() || animatedFlag) + { + if (auto pixmap = img->pixmapOrLoad()) + { + // Matching the web chat behavior, we center the emote within the overall + // binding box. E.g. small overlay emotes like cvMask will sit in the direct + // center of even wide emotes. + auto &size = this->sizes_[i]; + QRectF destRect(0, 0, size.width(), size.height()); + alignRectBottomCenter(destRect, fullRect); + + painter.drawPixmap(destRect, *pixmap, QRectF()); + animatedFlag = true; + } + } + } +} + +int LayeredImageLayoutElement::getMouseOverIndex(const QPoint &abs) const +{ + return 0; +} + +int LayeredImageLayoutElement::getXFromIndex(int index) +{ + if (index <= 0) + { + return this->getRect().left(); + } + else if (index == 1) + { + // fourtf: remove space width + return this->getRect().right(); + } + else + { + return this->getRect().right(); + } +} + // // IMAGE WITH BACKGROUND // diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 86e662e54ad..e4c930845b0 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -83,6 +83,26 @@ class ImageLayoutElement : public MessageLayoutElement ImagePtr image_; }; +class LayeredImageLayoutElement : public MessageLayoutElement +{ +public: + LayeredImageLayoutElement(MessageElement &creator, + std::vector images, + std::vector sizes, QSize largestSize); + +protected: + void addCopyTextToString(QString &str, uint32_t from = 0, + uint32_t to = UINT32_MAX) const override; + int getSelectionIndexCount() const override; + void paint(QPainter &painter) override; + void paintAnimated(QPainter &painter, int yOffset) override; + int getMouseOverIndex(const QPoint &abs) const override; + int getXFromIndex(int index) override; + + std::vector images_; + std::vector sizes_; +}; + class ImageWithBackgroundLayoutElement : public ImageLayoutElement { public: diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index c8c4da40996..99895a246e2 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1023,6 +1023,7 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) auto flags = MessageElementFlags(); auto emote = boost::optional{}; + bool zeroWidth = false; // Emote order: // - FrankerFaceZ Channel @@ -1044,10 +1045,7 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) (emote = this->twitchChannel->seventvEmote(name))) { flags = MessageElementFlag::SevenTVEmote; - if (emote.value()->zeroWidth) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = emote.value()->zeroWidth; } else if ((emote = globalFfzEmotes.emote(name))) { @@ -1056,23 +1054,45 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) else if ((emote = globalBttvEmotes.emote(name))) { flags = MessageElementFlag::BttvEmote; - - if (zeroWidthEmotes.contains(name.string)) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = zeroWidthEmotes.contains(name.string); } else if ((emote = globalSeventvEmotes.globalEmote(name))) { flags = MessageElementFlag::SevenTVEmote; - if (emote.value()->zeroWidth) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = emote.value()->zeroWidth; } if (emote) { + if (zeroWidth && getSettings()->enableZeroWidthEmotes && + !this->isEmpty()) + { + // Attempt to merge current zero-width emote into any previous emotes + auto asEmote = dynamic_cast(&this->back()); + if (asEmote) + { + // Make sure to access asEmote before taking ownership when releasing + auto baseEmote = asEmote->getEmote(); + // Need to remove EmoteElement and replace with LayeredEmoteElement + auto baseEmoteElement = this->releaseBack(); + + std::vector layers = {baseEmote, emote.get()}; + this->emplace(std::move(layers), + baseEmoteElement->getFlags(), + this->textColor_); + return Success; + } + + auto asLayered = dynamic_cast(&this->back()); + if (asLayered) + { + asLayered->addEmoteLayer(emote.get()); + return Success; + } + + // No emote to merge with, just show as regular emote + } + this->emplace(emote.get(), flags, this->textColor_); return Success; } diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 8b0b32f7739..bd1620e5ec8 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -213,6 +213,7 @@ class Settings : public ABSettings, public ConcurrentSettings false}; BoolSetting enableEmoteImages = {"/emotes/enableEmoteImages", true}; BoolSetting animateEmotes = {"/emotes/enableGifAnimations", true}; + BoolSetting enableZeroWidthEmotes = {"/emotes/enableZeroWidthEmotes", true}; FloatSetting emoteScale = {"/emotes/scale", 1.f}; BoolSetting showUnlistedSevenTVEmotes = { "/emotes/showUnlistedSevenTVEmotes", false}; diff --git a/src/widgets/TooltipEntryWidget.cpp b/src/widgets/TooltipEntryWidget.cpp new file mode 100644 index 00000000000..6fbaec1fdd2 --- /dev/null +++ b/src/widgets/TooltipEntryWidget.cpp @@ -0,0 +1,119 @@ +#include "widgets/TooltipEntryWidget.hpp" + +#include + +namespace chatterino { + +TooltipEntryWidget::TooltipEntryWidget(QWidget *parent) + : TooltipEntryWidget(nullptr, "", 0, 0, parent) +{ +} + +TooltipEntryWidget::TooltipEntryWidget(ImagePtr image, const QString &text, + int customWidth, int customHeight, + QWidget *parent) + : QWidget(parent) + , image_(image) + , customImgWidth_(customWidth) + , customImgHeight_(customHeight) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + this->setLayout(layout); + + this->displayImage_ = new QLabel(); + this->displayImage_->setAlignment(Qt::AlignHCenter); + this->displayImage_->setStyleSheet("background: transparent"); + this->displayText_ = new QLabel(text); + this->displayText_->setAlignment(Qt::AlignHCenter); + this->displayText_->setStyleSheet("background: transparent"); + + layout->addWidget(this->displayImage_); + layout->addWidget(this->displayText_); +} + +void TooltipEntryWidget::setWordWrap(bool wrap) +{ + this->displayText_->setWordWrap(wrap); +} + +void TooltipEntryWidget::setImageScale(int w, int h) +{ + if (this->customImgWidth_ == w && this->customImgHeight_ == h) + { + return; + } + this->customImgWidth_ = w; + this->customImgHeight_ = h; + this->refreshPixmap(); +} + +void TooltipEntryWidget::setText(const QString &text) +{ + this->displayText_->setText(text); +} + +void TooltipEntryWidget::setImage(ImagePtr image) +{ + if (this->image_ == image) + { + return; + } + + this->clearImage(); + this->image_ = std::move(image); + this->refreshPixmap(); +} + +void TooltipEntryWidget::clearImage() +{ + this->displayImage_->hide(); + this->image_ = nullptr; + this->setImageScale(0, 0); +} + +bool TooltipEntryWidget::refreshPixmap() +{ + if (!this->image_) + { + return false; + } + + auto pixmap = this->image_->pixmapOrLoad(); + if (!pixmap) + { + this->attemptRefresh_ = true; + return false; + } + + if (this->customImgWidth_ > 0 || this->customImgHeight_ > 0) + { + this->displayImage_->setPixmap(pixmap->scaled(this->customImgWidth_, + this->customImgHeight_, + Qt::KeepAspectRatio)); + } + else + { + this->displayImage_->setPixmap(*pixmap); + } + this->displayImage_->show(); + + return true; +} + +bool TooltipEntryWidget::animated() const +{ + return this->image_ && this->image_->animated(); +} + +bool TooltipEntryWidget::hasImage() const +{ + return this->image_ != nullptr; +} + +bool TooltipEntryWidget::attemptRefresh() const +{ + return this->attemptRefresh_; +} + +} // namespace chatterino diff --git a/src/widgets/TooltipEntryWidget.hpp b/src/widgets/TooltipEntryWidget.hpp new file mode 100644 index 00000000000..0bc6d68f430 --- /dev/null +++ b/src/widgets/TooltipEntryWidget.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "messages/Image.hpp" + +#include +#include + +namespace chatterino { + +class TooltipEntryWidget : public QWidget +{ + Q_OBJECT + +public: + TooltipEntryWidget(QWidget *parent = nullptr); + TooltipEntryWidget(ImagePtr image, const QString &text, int customWidth, + int customHeight, QWidget *parent = nullptr); + + void setImageScale(int w, int h); + void setWordWrap(bool wrap); + + void setText(const QString &text); + void setImage(ImagePtr image); + void clearImage(); + bool refreshPixmap(); + + bool animated() const; + bool hasImage() const; + bool attemptRefresh() const; + +private: + QLabel *displayImage_ = nullptr; + QLabel *displayText_ = nullptr; + + bool attemptRefresh_ = false; + + ImagePtr image_ = nullptr; + int customImgWidth_ = 0; + int customImgHeight_ = 0; +}; + +} // namespace chatterino diff --git a/src/widgets/TooltipWidget.cpp b/src/widgets/TooltipWidget.cpp index ac89f5bfaac..98ad4b971be 100644 --- a/src/widgets/TooltipWidget.cpp +++ b/src/widgets/TooltipWidget.cpp @@ -6,7 +6,9 @@ #include "singletons/WindowManager.hpp" #include -#include + +// number of columns in grid mode +#define GRID_NUM_COLS 3 namespace chatterino { @@ -20,8 +22,6 @@ TooltipWidget::TooltipWidget(BaseWidget *parent) : BaseWindow({BaseWindow::TopMost, BaseWindow::DontFocus, BaseWindow::DisableLayoutSave}, parent) - , displayImage_(new QLabel(this)) - , displayText_(new QLabel(this)) { this->setStyleSheet("color: #fff; background: rgba(11, 11, 11, 0.8)"); this->setAttribute(Qt::WA_TranslucentBackground); @@ -29,18 +29,10 @@ TooltipWidget::TooltipWidget(BaseWidget *parent) this->setStayInScreenRect(true); - displayImage_->setAlignment(Qt::AlignHCenter); - displayImage_->setStyleSheet("background: transparent"); - - displayText_->setAlignment(Qt::AlignHCenter); - displayText_->setStyleSheet("background: transparent"); - - auto *layout = new QVBoxLayout(this); - layout->setSizeConstraint(QLayout::SetFixedSize); - layout->setContentsMargins(10, 5, 10, 5); - layout->addWidget(displayImage_); - layout->addWidget(displayText_); - this->setLayout(layout); + // Default to using vertical layout + this->initializeVLayout(); + this->setLayout(this->vLayout_); + this->currentStyle_ = TooltipStyle::Vertical; this->connections_.managedConnect(getFonts()->fontChanged, [this] { this->updateFont(); @@ -49,123 +41,267 @@ TooltipWidget::TooltipWidget(BaseWidget *parent) auto windows = getApp()->windows; this->connections_.managedConnect(windows->gifRepaintRequested, [this] { - if (this->image_ && this->image_->animated()) + for (int i = 0; i < this->visibleEntries_; ++i) { - this->refreshPixmap(); + auto entry = this->entryAt(i); + if (entry && entry->animated()) + { + entry->refreshPixmap(); + } } }); this->connections_.managedConnect(windows->miscUpdate, [this] { - if (this->image_ && this->attemptRefresh) + bool needSizeAdjustment = false; + for (int i = 0; i < this->visibleEntries_; ++i) { - if (this->refreshPixmap()) + auto entry = this->entryAt(i); + if (entry->hasImage() && entry->attemptRefresh()) { - this->attemptRefresh = false; - this->adjustSize(); + bool successfullyUpdated = entry->refreshPixmap(); + needSizeAdjustment |= successfullyUpdated; } } + + if (needSizeAdjustment) + { + this->adjustSize(); + } }); } -void TooltipWidget::themeChangedEvent() +void TooltipWidget::setOne(const TooltipEntry &entry, TooltipStyle style) { - // this->setStyleSheet("color: #fff; background: #000"); + this->set({entry}, style); } -void TooltipWidget::paintEvent(QPaintEvent *) +void TooltipWidget::set(const std::vector &entries, + TooltipStyle style) { - QPainter painter(this); + this->setCurrentStyle(style); - painter.fillRect(this->rect(), QColor(0, 0, 0, int(0.8 * 255))); + int delta = entries.size() - this->currentLayoutCount(); + if (delta > 0) + { + // Need to add more TooltipEntry instances + int base = this->currentLayoutCount(); + for (int i = 0; i < delta; ++i) + { + this->addNewEntry(base + i); + } + } + + this->setVisibleEntries(entries.size()); + + for (int i = 0; i < entries.size(); ++i) + { + if (auto entryWidget = this->entryAt(i)) + { + auto &entry = entries[i]; + entryWidget->setImage(entry.image); + entryWidget->setText(entry.text); + entryWidget->setImageScale(entry.customWidth, entry.customHeight); + } + } } -void TooltipWidget::scaleChangedEvent(float) +void TooltipWidget::setVisibleEntries(int n) { - this->updateFont(); + for (int i = 0; i < this->currentLayoutCount(); ++i) + { + auto *entry = this->entryAt(i); + if (entry == nullptr) + { + continue; + } + + if (i >= n) + { + entry->hide(); + entry->clearImage(); + } + else + { + entry->show(); + } + } + this->visibleEntries_ = n; } -void TooltipWidget::updateFont() +void TooltipWidget::addNewEntry(int absoluteIndex) { - this->setFont( - getFonts()->getFont(FontStyle::ChatMediumSmall, this->scale())); + switch (this->currentStyle_) + { + case TooltipStyle::Vertical: + this->vLayout_->addWidget(new TooltipEntryWidget(), + Qt::AlignHCenter); + return; + case TooltipStyle::Grid: + if (absoluteIndex == 0) + { + // Top row spans all columns + this->gLayout_->addWidget(new TooltipEntryWidget(), 0, 0, 1, + GRID_NUM_COLS, Qt::AlignCenter); + } + else + { + int row = ((absoluteIndex - 1) / GRID_NUM_COLS) + 1; + int col = (absoluteIndex - 1) % GRID_NUM_COLS; + this->gLayout_->addWidget(new TooltipEntryWidget(), row, col, + Qt::AlignHCenter | Qt::AlignBottom); + } + return; + default: + return; + } } -void TooltipWidget::setText(QString text) +// May be nullptr +QLayout *TooltipWidget::currentLayout() const { - this->displayText_->setText(text); + switch (this->currentStyle_) + { + case TooltipStyle::Vertical: + return this->vLayout_; + case TooltipStyle::Grid: + return this->gLayout_; + default: + return nullptr; + } } -void TooltipWidget::setWordWrap(bool wrap) +int TooltipWidget::currentLayoutCount() const { - this->displayText_->setWordWrap(wrap); + if (auto *layout = this->currentLayout()) + { + return layout->count(); + } + return 0; } -void TooltipWidget::clearImage() +// May be nullptr +TooltipEntryWidget *TooltipWidget::entryAt(int n) { - this->displayImage_->hide(); - this->image_ = nullptr; - this->setImageScale(0, 0); + if (auto *layout = this->currentLayout()) + { + return dynamic_cast(layout->itemAt(n)->widget()); + } + return nullptr; } -void TooltipWidget::setImage(ImagePtr image) +void TooltipWidget::setCurrentStyle(TooltipStyle style) { - if (this->image_ == image) + if (this->currentStyle_ == style) { + // Nothing to update return; } - // hide image until loaded and reset scale - this->clearImage(); - this->image_ = std::move(image); - this->refreshPixmap(); + + this->clearEntries(); + this->deleteCurrentLayout(); + + switch (style) + { + case TooltipStyle::Vertical: + this->initializeVLayout(); + this->setLayout(this->vLayout_); + break; + case TooltipStyle::Grid: + this->initializeGLayout(); + this->setLayout(this->gLayout_); + break; + default: + break; + } + + this->currentStyle_ = style; } -void TooltipWidget::setImageScale(int w, int h) +void TooltipWidget::deleteCurrentLayout() { - if (this->customImgWidth == w && this->customImgHeight == h) + auto *currentLayout = this->layout(); + delete currentLayout; + + switch (this->currentStyle_) { - return; + case TooltipStyle::Vertical: + this->vLayout_ = nullptr; + break; + case TooltipStyle::Grid: + this->gLayout_ = nullptr; + break; + default: + break; } - this->customImgWidth = w; - this->customImgHeight = h; - this->refreshPixmap(); } -void TooltipWidget::hideEvent(QHideEvent *) +void TooltipWidget::initializeVLayout() { - this->clearImage(); + auto *vLayout = new QVBoxLayout(this); + vLayout->setSizeConstraint(QLayout::SetFixedSize); + vLayout->setContentsMargins(10, 5, 10, 5); + vLayout->setSpacing(10); + this->vLayout_ = vLayout; } -void TooltipWidget::showEvent(QShowEvent *) +void TooltipWidget::initializeGLayout() { - this->adjustSize(); + auto *gLayout = new QGridLayout(this); + gLayout->setSizeConstraint(QLayout::SetFixedSize); + gLayout->setContentsMargins(10, 5, 10, 5); + gLayout->setHorizontalSpacing(8); + gLayout->setVerticalSpacing(10); + this->gLayout_ = gLayout; } -bool TooltipWidget::refreshPixmap() +void TooltipWidget::themeChangedEvent() { - if (!this->image_) - { - return false; - } + // this->setStyleSheet("color: #fff; background: #000"); +} - auto pixmap = this->image_->pixmapOrLoad(); - if (!pixmap) - { - this->attemptRefresh = true; - return false; - } +void TooltipWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); - if (this->customImgWidth > 0 || this->customImgHeight > 0) - { - this->displayImage_->setPixmap(pixmap->scaled( - this->customImgWidth, this->customImgHeight, Qt::KeepAspectRatio)); - } - else + painter.fillRect(this->rect(), QColor(0, 0, 0, int(0.8 * 255))); +} + +void TooltipWidget::scaleChangedEvent(float) +{ + this->updateFont(); +} + +void TooltipWidget::updateFont() +{ + this->setFont( + getFonts()->getFont(FontStyle::ChatMediumSmall, this->scale())); +} + +void TooltipWidget::setWordWrap(bool wrap) +{ + for (int i = 0; i < this->visibleEntries_; ++i) { - this->displayImage_->setPixmap(*pixmap); + auto entry = this->entryAt(i); + if (entry) + { + entry->setWordWrap(wrap); + } } - this->displayImage_->show(); +} + +void TooltipWidget::clearEntries() +{ + this->setVisibleEntries(0); +} + +void TooltipWidget::hideEvent(QHideEvent *) +{ + this->clearEntries(); +} - return true; +void TooltipWidget::showEvent(QShowEvent *) +{ + this->adjustSize(); } void TooltipWidget::changeEvent(QEvent *) diff --git a/src/widgets/TooltipWidget.hpp b/src/widgets/TooltipWidget.hpp index 7e1c0d2dda1..526ec7a1c9a 100644 --- a/src/widgets/TooltipWidget.hpp +++ b/src/widgets/TooltipWidget.hpp @@ -1,9 +1,13 @@ #pragma once #include "widgets/BaseWindow.hpp" +#include "widgets/TooltipEntryWidget.hpp" #include +#include #include +#include +#include #include namespace chatterino { @@ -11,6 +15,15 @@ namespace chatterino { class Image; using ImagePtr = std::shared_ptr; +struct TooltipEntry { + ImagePtr image; + QString text; + int customWidth = 0; + int customHeight = 0; +}; + +enum class TooltipStyle { Vertical, Grid }; + class TooltipWidget : public BaseWindow { Q_OBJECT @@ -21,11 +34,13 @@ class TooltipWidget : public BaseWindow TooltipWidget(BaseWidget *parent = nullptr); ~TooltipWidget() override = default; - void setText(QString text); + void setOne(const TooltipEntry &entry, + TooltipStyle style = TooltipStyle::Vertical); + void set(const std::vector &entries, + TooltipStyle style = TooltipStyle::Vertical); + void setWordWrap(bool wrap); - void clearImage(); - void setImage(ImagePtr image); - void setImageScale(int w, int h); + void clearEntries(); protected: void showEvent(QShowEvent *) override; @@ -39,17 +54,24 @@ class TooltipWidget : public BaseWindow private: void updateFont(); - // used by WindowManager::gifRepaintRequested signal to progress frames when tooltip image is animated - bool refreshPixmap(); + QLayout *currentLayout() const; + int currentLayoutCount() const; + TooltipEntryWidget *entryAt(int n); + + void setVisibleEntries(int n); + void setCurrentStyle(TooltipStyle style); + void addNewEntry(int absoluteIndex); + + void deleteCurrentLayout(); + void initializeVLayout(); + void initializeGLayout(); + + int visibleEntries_ = 0; - // set to true when tooltip image did not finish loading yet (pixmapOrLoad returned false) - bool attemptRefresh{false}; + TooltipStyle currentStyle_; + QVBoxLayout *vLayout_; + QGridLayout *gLayout_; - ImagePtr image_ = nullptr; - int customImgWidth = 0; - int customImgHeight = 0; - QLabel *displayImage_; - QLabel *displayText_; pajlada::Signals::SignalHolder connections_; }; diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index ac8b153c9db..46a0dcfbb07 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -64,6 +64,7 @@ #define DRAW_WIDTH (this->width()) #define SELECTION_RESUME_SCROLLING_MSG_THRESHOLD 3 #define CHAT_HOVER_PAUSE_DURATION 1000 +#define TOOLTIP_EMOTE_ENTRIES_LIMIT 7 namespace chatterino { namespace { @@ -1658,10 +1659,12 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) auto element = &hoverLayoutElement->getCreator(); bool isLinkValid = hoverLayoutElement->getLink().isValid(); auto emoteElement = dynamic_cast(element); + auto layeredEmoteElement = + dynamic_cast(element); + bool isNotEmote = emoteElement == nullptr && layeredEmoteElement == nullptr; if (element->getTooltip().isEmpty() || - (isLinkValid && emoteElement == nullptr && - !getSettings()->linkInfoTooltip)) + (isLinkValid && isNotEmote && !getSettings()->linkInfoTooltip)) { tooltipWidget->hide(); } @@ -1669,7 +1672,7 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) { auto badgeElement = dynamic_cast(element); - if ((badgeElement || emoteElement) && + if ((badgeElement || emoteElement || layeredEmoteElement) && getSettings()->emotesTooltipPreview.getValue()) { if (event->modifiers() == Qt::ShiftModifier || @@ -1677,18 +1680,73 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) { if (emoteElement) { - tooltipWidget->setImage( - emoteElement->getEmote()->images.getImage(3.0)); + tooltipWidget->setOne({ + emoteElement->getEmote()->images.getImage(3.0), + element->getTooltip(), + }); + } + else if (layeredEmoteElement) + { + auto &layeredEmotes = layeredEmoteElement->getEmotes(); + // Should never be empty but ensure it + if (!layeredEmotes.empty()) + { + std::vector entries; + entries.reserve(layeredEmotes.size()); + + auto &emoteTooltips = + layeredEmoteElement->getEmoteTooltips(); + + // Someone performing some tomfoolery could put an emote with tens, + // if not hundreds of zero-width emotes on a single emote. If the + // tooltip may take up more than three rows, truncate everything else. + bool truncating = false; + size_t upperLimit = layeredEmotes.size(); + if (layeredEmotes.size() > TOOLTIP_EMOTE_ENTRIES_LIMIT) + { + upperLimit = TOOLTIP_EMOTE_ENTRIES_LIMIT - 1; + truncating = true; + } + + for (size_t i = 0; i < upperLimit; ++i) + { + auto &emote = layeredEmotes[i]; + if (i == 0) + { + // First entry gets a large image and full description + entries.push_back({emote->images.getImage(3.0), + emoteTooltips[i]}); + } + else + { + // Every other entry gets a small image and just the emote name + entries.push_back({emote->images.getImage(1.0), + emote->name.string}); + } + } + + if (truncating) + { + entries.push_back({nullptr, "..."}); + } + + auto style = layeredEmotes.size() > 2 + ? TooltipStyle::Grid + : TooltipStyle::Vertical; + tooltipWidget->set(entries, style); + } } else if (badgeElement) { - tooltipWidget->setImage( - badgeElement->getEmote()->images.getImage(3.0)); + tooltipWidget->setOne({ + badgeElement->getEmote()->images.getImage(3.0), + element->getTooltip(), + }); } } else { - tooltipWidget->clearImage(); + tooltipWidget->clearEntries(); } } else @@ -1711,7 +1769,7 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) auto thumbnailSize = getSettings()->thumbnailSize; if (!thumbnailSize) { - tooltipWidget->clearImage(); + tooltipWidget->clearEntries(); } else { @@ -1724,19 +1782,23 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) shouldHideThumbnail ? Image::fromResourcePixmap(getResources().streamerMode) : element->getThumbnail(); - tooltipWidget->setImage(std::move(thumb)); if (element->getThumbnailType() == MessageElement::ThumbnailType::Link_Thumbnail) { - tooltipWidget->setImageScale(thumbnailSize, thumbnailSize); + tooltipWidget->setOne({std::move(thumb), + element->getTooltip(), thumbnailSize, + thumbnailSize}); + } + else + { + tooltipWidget->setOne({std::move(thumb), ""}); } } } tooltipWidget->moveTo(this, event->globalPos()); tooltipWidget->setWordWrap(isLinkValid); - tooltipWidget->setText(element->getTooltip()); tooltipWidget->show(); } @@ -2134,6 +2196,18 @@ void ChannelView::addImageContextMenuItems( addEmoteContextMenuItems(*emoteElement->getEmote(), creatorFlags, menu); } + else if (auto layeredElement = + dynamic_cast(&creator)) + { + // Give each emote its own submenu + for (auto &emote : layeredElement->getUniqueEmotes()) + { + auto emoteAction = menu.addAction(emote->name.string); + auto emoteMenu = new QMenu(&menu); + emoteAction->setMenu(emoteMenu); + addEmoteContextMenuItems(*emote, creatorFlags, *emoteMenu); + } + } } // add seperator diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 4f6523aa557..b016d68a15c 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -374,6 +374,10 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Animate", s.animateEmotes); layout.addCheckbox("Animate only when Chatterino is focused", s.animationsWhenFocused); + layout.addCheckbox( + "Enable zero-width emotes", s.enableZeroWidthEmotes, + "When disabled, emotes that overlap other emotes, such as BTTV's " + "cvMask and 7TV's RainTime, will appear as normal emotes."); layout.addCheckbox("Enable emote auto-completion by typing :", s.emoteCompletionWithColon); layout.addDropdown( diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index a7781119baf..4b6601e17f7 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -960,8 +960,7 @@ void SplitHeader::enterEvent(QEvent *event) } auto *tooltip = TooltipWidget::instance(); - tooltip->clearImage(); - tooltip->setText(this->tooltipText_); + tooltip->setOne({nullptr, this->tooltipText_}); tooltip->setWordWrap(true); tooltip->adjustSize(); auto pos = this->mapToGlobal(this->rect().bottomLeft()) +