Formalize zero-width emote implementation (#4314)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Daniel Sage 2023-03-18 12:30:08 -04:00 committed by GitHub
parent db97a14cdc
commit 0acbc0d2c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 947 additions and 146 deletions

View file

@ -3,6 +3,8 @@
## Unversioned ## Unversioned
- Minor: Added support for FrankerFaceZ animated emotes. (#4434) - 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: Ignore unhandled BTTV user-events. (#4438)
- Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Only log debug messages when NDEBUG is not defined. (#4442)
- Dev: Cleaned up theme related code. (#4450) - Dev: Cleaned up theme related code. (#4450)

View file

@ -420,6 +420,8 @@ set(SOURCE_FILES
widgets/Scrollbar.hpp widgets/Scrollbar.hpp
widgets/StreamView.cpp widgets/StreamView.cpp
widgets/StreamView.hpp widgets/StreamView.hpp
widgets/TooltipEntryWidget.cpp
widgets/TooltipEntryWidget.hpp
widgets/TooltipWidget.cpp widgets/TooltipWidget.cpp
widgets/TooltipWidget.hpp widgets/TooltipWidget.hpp
widgets/Window.cpp widgets/Window.cpp

View file

@ -687,6 +687,26 @@ void MessageBuilder::append(std::unique_ptr<MessageElement> element)
this->message().elements.push_back(std::move(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<MessageElement> 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) QString MessageBuilder::matchLink(const QString &string)
{ {
LinkParser linkParser(string); LinkParser linkParser(string);

View file

@ -123,6 +123,10 @@ protected:
virtual void addTextOrEmoji(EmotePtr emote); virtual void addTextOrEmoji(EmotePtr emote);
virtual void addTextOrEmoji(const QString &value); virtual void addTextOrEmoji(const QString &value);
bool isEmpty() const;
MessageElement &back();
std::unique_ptr<MessageElement> releaseBack();
MessageColor textColor_ = MessageColor::Text; MessageColor textColor_ = MessageColor::Text;
private: private:

View file

@ -15,6 +15,24 @@
namespace chatterino { namespace chatterino {
namespace {
// Computes the bounding box for the given vector of images
QSize getBoundingBoxSize(const std::vector<ImagePtr> &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) MessageElement::MessageElement(MessageElementFlags flags)
: flags_(flags) : flags_(flags)
{ {
@ -216,6 +234,168 @@ MessageLayoutElement *EmoteElement::makeImageLayoutElement(
return new ImageLayoutElement(*this, image, size); return new ImageLayoutElement(*this, image, size);
} }
LayeredEmoteElement::LayeredEmoteElement(std::vector<EmotePtr> &&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<QSize> 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<ImagePtr> LayeredEmoteElement::getLoadedImages(float scale)
{
std::vector<ImagePtr> 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<ImagePtr> &images, const std::vector<QSize> &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<QString> 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<QString> &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<EmotePtr> &LayeredEmoteElement::getEmotes() const
{
return this->emotes_;
}
std::vector<EmotePtr> 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<EmotePtr> seen;
};
// Get unique emotes while maintaining relative layering order
NotDuplicate dup;
std::vector<EmotePtr> unique;
std::copy_if(this->emotes_.begin(), this->emotes_.end(),
std::back_insert_iterator(unique), dup);
return unique;
}
// BADGE // BADGE
BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags) BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags)
: MessageElement(flags) : MessageElement(flags)

View file

@ -141,9 +141,7 @@ enum class MessageElementFlag : int64_t {
LowercaseLink = (1LL << 29), LowercaseLink = (1LL << 29),
OriginalLink = (1LL << 30), OriginalLink = (1LL << 30),
// ZeroWidthEmotes are emotes that are supposed to overlay over any pre-existing emotes // Unused: (1LL << 31)
// e.g. BTTV's SoSnowy during christmas season or 7TV's RainTime
ZeroWidthEmote = (1LL << 31),
// for elements of the message reply // for elements of the message reply
RepliedMessage = (1LL << 32), RepliedMessage = (1LL << 32),
@ -321,6 +319,43 @@ private:
EmotePtr emote_; 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<EmotePtr> &&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<EmotePtr> &getEmotes() const;
std::vector<EmotePtr> getUniqueEmotes() const;
const std::vector<QString> &getEmoteTooltips() const;
private:
MessageLayoutElement *makeImageLayoutElement(
const std::vector<ImagePtr> &image, const std::vector<QSize> &sizes,
QSize largestSize);
QString getCopyString() const;
void updateTooltips();
std::vector<ImagePtr> getLoadedImages(float scale);
std::vector<EmotePtr> emotes_;
std::vector<QString> emoteTooltips_;
std::unique_ptr<TextElement> textElement_;
MessageColor textElementColor_;
};
class BadgeElement : public MessageElement class BadgeElement : public MessageElement
{ {
public: public:

View file

@ -67,10 +67,7 @@ void MessageLayoutContainer::clear()
void MessageLayoutContainer::addElement(MessageLayoutElement *element) void MessageLayoutContainer::addElement(MessageLayoutElement *element)
{ {
bool isZeroWidth = if (!this->fitsInLine(element->getRect().width()))
element->getFlags().has(MessageElementFlag::ZeroWidthEmote);
if (!isZeroWidth && !this->fitsInLine(element->getRect().width()))
{ {
this->breakLine(); this->breakLine();
} }
@ -175,14 +172,6 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
this->lineHeight_ = std::max(this->lineHeight_, elementLineHeight); this->lineHeight_ = std::max(this->lineHeight_, elementLineHeight);
auto xOffset = 0; auto xOffset = 0;
bool isZeroWidthEmote = element->getCreator().getFlags().has(
MessageElementFlag::ZeroWidthEmote);
if (isZeroWidthEmote && !isRTLMode)
{
xOffset -= element->getRect().width() + this->spaceWidth_;
}
auto yOffset = 0; auto yOffset = 0;
if (element->getCreator().getFlags().has( if (element->getCreator().getFlags().has(
@ -195,7 +184,7 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
if (getSettings()->removeSpacesBetweenEmotes && if (getSettings()->removeSpacesBetweenEmotes &&
element->getFlags().hasAny({MessageElementFlag::EmoteImages}) && 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 // Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote
if (isRTLMode) if (isRTLMode)
@ -230,16 +219,13 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
} }
// set current x // set current x
if (!isZeroWidthEmote) if (isRTLMode)
{ {
if (isRTLMode) this->currentX_ -= element->getRect().width();
{ }
this->currentX_ -= element->getRect().width(); else
} {
else this->currentX_ += element->getRect().width();
{
this->currentX_ += element->getRect().width();
}
} }
if (element->hasTrailingSpace()) if (element->hasTrailingSpace())

View file

@ -15,6 +15,14 @@
namespace { namespace {
const QChar RTL_EMBED(0x202B); 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
namespace chatterino { namespace chatterino {
@ -184,6 +192,133 @@ int ImageLayoutElement::getXFromIndex(int index)
} }
} }
//
// LAYERED IMAGE
//
LayeredImageLayoutElement::LayeredImageLayoutElement(
MessageElement &creator, std::vector<ImagePtr> images,
std::vector<QSize> 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<LayeredEmoteElement *>(&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 // IMAGE WITH BACKGROUND
// //

View file

@ -83,6 +83,26 @@ protected:
ImagePtr image_; ImagePtr image_;
}; };
class LayeredImageLayoutElement : public MessageLayoutElement
{
public:
LayeredImageLayoutElement(MessageElement &creator,
std::vector<ImagePtr> images,
std::vector<QSize> 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<ImagePtr> images_;
std::vector<QSize> sizes_;
};
class ImageWithBackgroundLayoutElement : public ImageLayoutElement class ImageWithBackgroundLayoutElement : public ImageLayoutElement
{ {
public: public:

View file

@ -1023,6 +1023,7 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name)
auto flags = MessageElementFlags(); auto flags = MessageElementFlags();
auto emote = boost::optional<EmotePtr>{}; auto emote = boost::optional<EmotePtr>{};
bool zeroWidth = false;
// Emote order: // Emote order:
// - FrankerFaceZ Channel // - FrankerFaceZ Channel
@ -1044,10 +1045,7 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name)
(emote = this->twitchChannel->seventvEmote(name))) (emote = this->twitchChannel->seventvEmote(name)))
{ {
flags = MessageElementFlag::SevenTVEmote; flags = MessageElementFlag::SevenTVEmote;
if (emote.value()->zeroWidth) zeroWidth = emote.value()->zeroWidth;
{
flags.set(MessageElementFlag::ZeroWidthEmote);
}
} }
else if ((emote = globalFfzEmotes.emote(name))) else if ((emote = globalFfzEmotes.emote(name)))
{ {
@ -1056,23 +1054,45 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name)
else if ((emote = globalBttvEmotes.emote(name))) else if ((emote = globalBttvEmotes.emote(name)))
{ {
flags = MessageElementFlag::BttvEmote; flags = MessageElementFlag::BttvEmote;
zeroWidth = zeroWidthEmotes.contains(name.string);
if (zeroWidthEmotes.contains(name.string))
{
flags.set(MessageElementFlag::ZeroWidthEmote);
}
} }
else if ((emote = globalSeventvEmotes.globalEmote(name))) else if ((emote = globalSeventvEmotes.globalEmote(name)))
{ {
flags = MessageElementFlag::SevenTVEmote; flags = MessageElementFlag::SevenTVEmote;
if (emote.value()->zeroWidth) zeroWidth = emote.value()->zeroWidth;
{
flags.set(MessageElementFlag::ZeroWidthEmote);
}
} }
if (emote) if (emote)
{ {
if (zeroWidth && getSettings()->enableZeroWidthEmotes &&
!this->isEmpty())
{
// Attempt to merge current zero-width emote into any previous emotes
auto asEmote = dynamic_cast<EmoteElement *>(&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<EmotePtr> layers = {baseEmote, emote.get()};
this->emplace<LayeredEmoteElement>(std::move(layers),
baseEmoteElement->getFlags(),
this->textColor_);
return Success;
}
auto asLayered = dynamic_cast<LayeredEmoteElement *>(&this->back());
if (asLayered)
{
asLayered->addEmoteLayer(emote.get());
return Success;
}
// No emote to merge with, just show as regular emote
}
this->emplace<EmoteElement>(emote.get(), flags, this->textColor_); this->emplace<EmoteElement>(emote.get(), flags, this->textColor_);
return Success; return Success;
} }

View file

@ -213,6 +213,7 @@ public:
false}; false};
BoolSetting enableEmoteImages = {"/emotes/enableEmoteImages", true}; BoolSetting enableEmoteImages = {"/emotes/enableEmoteImages", true};
BoolSetting animateEmotes = {"/emotes/enableGifAnimations", true}; BoolSetting animateEmotes = {"/emotes/enableGifAnimations", true};
BoolSetting enableZeroWidthEmotes = {"/emotes/enableZeroWidthEmotes", true};
FloatSetting emoteScale = {"/emotes/scale", 1.f}; FloatSetting emoteScale = {"/emotes/scale", 1.f};
BoolSetting showUnlistedSevenTVEmotes = { BoolSetting showUnlistedSevenTVEmotes = {
"/emotes/showUnlistedSevenTVEmotes", false}; "/emotes/showUnlistedSevenTVEmotes", false};

View file

@ -0,0 +1,119 @@
#include "widgets/TooltipEntryWidget.hpp"
#include <QVBoxLayout>
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

View file

@ -0,0 +1,42 @@
#pragma once
#include "messages/Image.hpp"
#include <QLabel>
#include <QWidget>
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

View file

@ -6,7 +6,9 @@
#include "singletons/WindowManager.hpp" #include "singletons/WindowManager.hpp"
#include <QPainter> #include <QPainter>
#include <QVBoxLayout>
// number of columns in grid mode
#define GRID_NUM_COLS 3
namespace chatterino { namespace chatterino {
@ -20,8 +22,6 @@ TooltipWidget::TooltipWidget(BaseWidget *parent)
: BaseWindow({BaseWindow::TopMost, BaseWindow::DontFocus, : BaseWindow({BaseWindow::TopMost, BaseWindow::DontFocus,
BaseWindow::DisableLayoutSave}, BaseWindow::DisableLayoutSave},
parent) parent)
, displayImage_(new QLabel(this))
, displayText_(new QLabel(this))
{ {
this->setStyleSheet("color: #fff; background: rgba(11, 11, 11, 0.8)"); this->setStyleSheet("color: #fff; background: rgba(11, 11, 11, 0.8)");
this->setAttribute(Qt::WA_TranslucentBackground); this->setAttribute(Qt::WA_TranslucentBackground);
@ -29,18 +29,10 @@ TooltipWidget::TooltipWidget(BaseWidget *parent)
this->setStayInScreenRect(true); this->setStayInScreenRect(true);
displayImage_->setAlignment(Qt::AlignHCenter); // Default to using vertical layout
displayImage_->setStyleSheet("background: transparent"); this->initializeVLayout();
this->setLayout(this->vLayout_);
displayText_->setAlignment(Qt::AlignHCenter); this->currentStyle_ = TooltipStyle::Vertical;
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);
this->connections_.managedConnect(getFonts()->fontChanged, [this] { this->connections_.managedConnect(getFonts()->fontChanged, [this] {
this->updateFont(); this->updateFont();
@ -49,24 +41,219 @@ TooltipWidget::TooltipWidget(BaseWidget *parent)
auto windows = getApp()->windows; auto windows = getApp()->windows;
this->connections_.managedConnect(windows->gifRepaintRequested, [this] { 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] { 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; bool successfullyUpdated = entry->refreshPixmap();
this->adjustSize(); needSizeAdjustment |= successfullyUpdated;
} }
} }
if (needSizeAdjustment)
{
this->adjustSize();
}
}); });
} }
void TooltipWidget::setOne(const TooltipEntry &entry, TooltipStyle style)
{
this->set({entry}, style);
}
void TooltipWidget::set(const std::vector<TooltipEntry> &entries,
TooltipStyle style)
{
this->setCurrentStyle(style);
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::setVisibleEntries(int n)
{
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::addNewEntry(int absoluteIndex)
{
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;
}
}
// May be nullptr
QLayout *TooltipWidget::currentLayout() const
{
switch (this->currentStyle_)
{
case TooltipStyle::Vertical:
return this->vLayout_;
case TooltipStyle::Grid:
return this->gLayout_;
default:
return nullptr;
}
}
int TooltipWidget::currentLayoutCount() const
{
if (auto *layout = this->currentLayout())
{
return layout->count();
}
return 0;
}
// May be nullptr
TooltipEntryWidget *TooltipWidget::entryAt(int n)
{
if (auto *layout = this->currentLayout())
{
return dynamic_cast<TooltipEntryWidget *>(layout->itemAt(n)->widget());
}
return nullptr;
}
void TooltipWidget::setCurrentStyle(TooltipStyle style)
{
if (this->currentStyle_ == style)
{
// Nothing to update
return;
}
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::deleteCurrentLayout()
{
auto *currentLayout = this->layout();
delete currentLayout;
switch (this->currentStyle_)
{
case TooltipStyle::Vertical:
this->vLayout_ = nullptr;
break;
case TooltipStyle::Grid:
this->gLayout_ = nullptr;
break;
default:
break;
}
}
void TooltipWidget::initializeVLayout()
{
auto *vLayout = new QVBoxLayout(this);
vLayout->setSizeConstraint(QLayout::SetFixedSize);
vLayout->setContentsMargins(10, 5, 10, 5);
vLayout->setSpacing(10);
this->vLayout_ = vLayout;
}
void TooltipWidget::initializeGLayout()
{
auto *gLayout = new QGridLayout(this);
gLayout->setSizeConstraint(QLayout::SetFixedSize);
gLayout->setContentsMargins(10, 5, 10, 5);
gLayout->setHorizontalSpacing(8);
gLayout->setVerticalSpacing(10);
this->gLayout_ = gLayout;
}
void TooltipWidget::themeChangedEvent() void TooltipWidget::themeChangedEvent()
{ {
// this->setStyleSheet("color: #fff; background: #000"); // this->setStyleSheet("color: #fff; background: #000");
@ -90,49 +277,26 @@ void TooltipWidget::updateFont()
getFonts()->getFont(FontStyle::ChatMediumSmall, this->scale())); getFonts()->getFont(FontStyle::ChatMediumSmall, this->scale()));
} }
void TooltipWidget::setText(QString text)
{
this->displayText_->setText(text);
}
void TooltipWidget::setWordWrap(bool wrap) void TooltipWidget::setWordWrap(bool wrap)
{ {
this->displayText_->setWordWrap(wrap); for (int i = 0; i < this->visibleEntries_; ++i)
}
void TooltipWidget::clearImage()
{
this->displayImage_->hide();
this->image_ = nullptr;
this->setImageScale(0, 0);
}
void TooltipWidget::setImage(ImagePtr image)
{
if (this->image_ == image)
{ {
return; auto entry = this->entryAt(i);
if (entry)
{
entry->setWordWrap(wrap);
}
} }
// hide image until loaded and reset scale
this->clearImage();
this->image_ = std::move(image);
this->refreshPixmap();
} }
void TooltipWidget::setImageScale(int w, int h) void TooltipWidget::clearEntries()
{ {
if (this->customImgWidth == w && this->customImgHeight == h) this->setVisibleEntries(0);
{
return;
}
this->customImgWidth = w;
this->customImgHeight = h;
this->refreshPixmap();
} }
void TooltipWidget::hideEvent(QHideEvent *) void TooltipWidget::hideEvent(QHideEvent *)
{ {
this->clearImage(); this->clearEntries();
} }
void TooltipWidget::showEvent(QShowEvent *) void TooltipWidget::showEvent(QShowEvent *)
@ -140,34 +304,6 @@ void TooltipWidget::showEvent(QShowEvent *)
this->adjustSize(); this->adjustSize();
} }
bool TooltipWidget::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;
}
void TooltipWidget::changeEvent(QEvent *) void TooltipWidget::changeEvent(QEvent *)
{ {
// clear parents event // clear parents event

View file

@ -1,9 +1,13 @@
#pragma once #pragma once
#include "widgets/BaseWindow.hpp" #include "widgets/BaseWindow.hpp"
#include "widgets/TooltipEntryWidget.hpp"
#include <pajlada/signals/signalholder.hpp> #include <pajlada/signals/signalholder.hpp>
#include <QGridLayout>
#include <QLabel> #include <QLabel>
#include <QLayout>
#include <QVBoxLayout>
#include <QWidget> #include <QWidget>
namespace chatterino { namespace chatterino {
@ -11,6 +15,15 @@ namespace chatterino {
class Image; class Image;
using ImagePtr = std::shared_ptr<Image>; using ImagePtr = std::shared_ptr<Image>;
struct TooltipEntry {
ImagePtr image;
QString text;
int customWidth = 0;
int customHeight = 0;
};
enum class TooltipStyle { Vertical, Grid };
class TooltipWidget : public BaseWindow class TooltipWidget : public BaseWindow
{ {
Q_OBJECT Q_OBJECT
@ -21,11 +34,13 @@ public:
TooltipWidget(BaseWidget *parent = nullptr); TooltipWidget(BaseWidget *parent = nullptr);
~TooltipWidget() override = default; ~TooltipWidget() override = default;
void setText(QString text); void setOne(const TooltipEntry &entry,
TooltipStyle style = TooltipStyle::Vertical);
void set(const std::vector<TooltipEntry> &entries,
TooltipStyle style = TooltipStyle::Vertical);
void setWordWrap(bool wrap); void setWordWrap(bool wrap);
void clearImage(); void clearEntries();
void setImage(ImagePtr image);
void setImageScale(int w, int h);
protected: protected:
void showEvent(QShowEvent *) override; void showEvent(QShowEvent *) override;
@ -39,17 +54,24 @@ protected:
private: private:
void updateFont(); void updateFont();
// used by WindowManager::gifRepaintRequested signal to progress frames when tooltip image is animated QLayout *currentLayout() const;
bool refreshPixmap(); int currentLayoutCount() const;
TooltipEntryWidget *entryAt(int n);
// set to true when tooltip image did not finish loading yet (pixmapOrLoad returned false) void setVisibleEntries(int n);
bool attemptRefresh{false}; void setCurrentStyle(TooltipStyle style);
void addNewEntry(int absoluteIndex);
void deleteCurrentLayout();
void initializeVLayout();
void initializeGLayout();
int visibleEntries_ = 0;
TooltipStyle currentStyle_;
QVBoxLayout *vLayout_;
QGridLayout *gLayout_;
ImagePtr image_ = nullptr;
int customImgWidth = 0;
int customImgHeight = 0;
QLabel *displayImage_;
QLabel *displayText_;
pajlada::Signals::SignalHolder connections_; pajlada::Signals::SignalHolder connections_;
}; };

View file

@ -64,6 +64,7 @@
#define DRAW_WIDTH (this->width()) #define DRAW_WIDTH (this->width())
#define SELECTION_RESUME_SCROLLING_MSG_THRESHOLD 3 #define SELECTION_RESUME_SCROLLING_MSG_THRESHOLD 3
#define CHAT_HOVER_PAUSE_DURATION 1000 #define CHAT_HOVER_PAUSE_DURATION 1000
#define TOOLTIP_EMOTE_ENTRIES_LIMIT 7
namespace chatterino { namespace chatterino {
namespace { namespace {
@ -1658,10 +1659,12 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event)
auto element = &hoverLayoutElement->getCreator(); auto element = &hoverLayoutElement->getCreator();
bool isLinkValid = hoverLayoutElement->getLink().isValid(); bool isLinkValid = hoverLayoutElement->getLink().isValid();
auto emoteElement = dynamic_cast<const EmoteElement *>(element); auto emoteElement = dynamic_cast<const EmoteElement *>(element);
auto layeredEmoteElement =
dynamic_cast<const LayeredEmoteElement *>(element);
bool isNotEmote = emoteElement == nullptr && layeredEmoteElement == nullptr;
if (element->getTooltip().isEmpty() || if (element->getTooltip().isEmpty() ||
(isLinkValid && emoteElement == nullptr && (isLinkValid && isNotEmote && !getSettings()->linkInfoTooltip))
!getSettings()->linkInfoTooltip))
{ {
tooltipWidget->hide(); tooltipWidget->hide();
} }
@ -1669,7 +1672,7 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event)
{ {
auto badgeElement = dynamic_cast<const BadgeElement *>(element); auto badgeElement = dynamic_cast<const BadgeElement *>(element);
if ((badgeElement || emoteElement) && if ((badgeElement || emoteElement || layeredEmoteElement) &&
getSettings()->emotesTooltipPreview.getValue()) getSettings()->emotesTooltipPreview.getValue())
{ {
if (event->modifiers() == Qt::ShiftModifier || if (event->modifiers() == Qt::ShiftModifier ||
@ -1677,18 +1680,73 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event)
{ {
if (emoteElement) if (emoteElement)
{ {
tooltipWidget->setImage( tooltipWidget->setOne({
emoteElement->getEmote()->images.getImage(3.0)); 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<TooltipEntry> 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) else if (badgeElement)
{ {
tooltipWidget->setImage( tooltipWidget->setOne({
badgeElement->getEmote()->images.getImage(3.0)); badgeElement->getEmote()->images.getImage(3.0),
element->getTooltip(),
});
} }
} }
else else
{ {
tooltipWidget->clearImage(); tooltipWidget->clearEntries();
} }
} }
else else
@ -1711,7 +1769,7 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event)
auto thumbnailSize = getSettings()->thumbnailSize; auto thumbnailSize = getSettings()->thumbnailSize;
if (!thumbnailSize) if (!thumbnailSize)
{ {
tooltipWidget->clearImage(); tooltipWidget->clearEntries();
} }
else else
{ {
@ -1724,19 +1782,23 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event)
shouldHideThumbnail shouldHideThumbnail
? Image::fromResourcePixmap(getResources().streamerMode) ? Image::fromResourcePixmap(getResources().streamerMode)
: element->getThumbnail(); : element->getThumbnail();
tooltipWidget->setImage(std::move(thumb));
if (element->getThumbnailType() == if (element->getThumbnailType() ==
MessageElement::ThumbnailType::Link_Thumbnail) 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->moveTo(this, event->globalPos());
tooltipWidget->setWordWrap(isLinkValid); tooltipWidget->setWordWrap(isLinkValid);
tooltipWidget->setText(element->getTooltip());
tooltipWidget->show(); tooltipWidget->show();
} }
@ -2134,6 +2196,18 @@ void ChannelView::addImageContextMenuItems(
addEmoteContextMenuItems(*emoteElement->getEmote(), creatorFlags, addEmoteContextMenuItems(*emoteElement->getEmote(), creatorFlags,
menu); menu);
} }
else if (auto layeredElement =
dynamic_cast<const LayeredEmoteElement *>(&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 // add seperator

View file

@ -374,6 +374,10 @@ void GeneralPage::initLayout(GeneralPageView &layout)
layout.addCheckbox("Animate", s.animateEmotes); layout.addCheckbox("Animate", s.animateEmotes);
layout.addCheckbox("Animate only when Chatterino is focused", layout.addCheckbox("Animate only when Chatterino is focused",
s.animationsWhenFocused); 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 :", layout.addCheckbox("Enable emote auto-completion by typing :",
s.emoteCompletionWithColon); s.emoteCompletionWithColon);
layout.addDropdown<float>( layout.addDropdown<float>(

View file

@ -960,8 +960,7 @@ void SplitHeader::enterEvent(QEvent *event)
} }
auto *tooltip = TooltipWidget::instance(); auto *tooltip = TooltipWidget::instance();
tooltip->clearImage(); tooltip->setOne({nullptr, this->tooltipText_});
tooltip->setText(this->tooltipText_);
tooltip->setWordWrap(true); tooltip->setWordWrap(true);
tooltip->adjustSize(); tooltip->adjustSize();
auto pos = this->mapToGlobal(this->rect().bottomLeft()) + auto pos = this->mapToGlobal(this->rect().bottomLeft()) +