mirror-chatterino2/src/widgets/helper/ChannelView.cpp
Alexey Kutepov 00414eb779 Synchronize Clipboard with Primary Selection on Linux when copying (#1502)
* Introduce crossPlatformCopy()

It sets the text of the clipboard and also syncs it with the selection
clipboard if it is supported. Such behaviour is pretty common for X11
application on Unix-like Operating Systems.

* Fix clang-format remarks

* Fix weird clang-format config discrepancy between my machine and CI

* Remove clipboard argument from crossPlatformCopy

* Fix clang-format remarks
2020-01-24 21:36:51 +01:00

1804 lines
52 KiB
C++

#include "ChannelView.hpp"
#include <QClipboard>
#include <QDebug>
#include <QDesktopServices>
#include <QGraphicsBlurEffect>
#include <QMessageBox>
#include <QPainter>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <functional>
#include <memory>
#include "Application.hpp"
#include "common/Common.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "debug/Benchmark.hpp"
#include "messages/Emote.hpp"
#include "messages/LimitedQueueSnapshot.hpp"
#include "messages/Message.hpp"
#include "messages/MessageElement.hpp"
#include "messages/layouts/MessageLayout.hpp"
#include "messages/layouts/MessageLayoutElement.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "singletons/Settings.hpp"
#include "singletons/Theme.hpp"
#include "singletons/TooltipPreviewImage.hpp"
#include "singletons/WindowManager.hpp"
#include "util/Clipboard.hpp"
#include "util/DistanceBetweenPoints.hpp"
#include "util/IncognitoBrowser.hpp"
#include "widgets/Scrollbar.hpp"
#include "widgets/TooltipWidget.hpp"
#include "widgets/dialogs/UserInfoPopup.hpp"
#include "widgets/helper/EffectLabel.hpp"
#include "widgets/splits/Split.hpp"
#define DRAW_WIDTH (this->width())
#define SELECTION_RESUME_SCROLLING_MSG_THRESHOLD 3
#define CHAT_HOVER_PAUSE_DURATION 1000
namespace chatterino {
namespace {
void addEmoteContextMenuItems(const Emote &emote,
MessageElementFlags creatorFlags, QMenu &menu)
{
auto openAction = menu.addAction("Open");
auto openMenu = new QMenu;
openAction->setMenu(openMenu);
auto copyAction = menu.addAction("Copy");
auto copyMenu = new QMenu;
copyAction->setMenu(copyMenu);
// see if the QMenu actually gets destroyed
QObject::connect(openMenu, &QMenu::destroyed, [] {
QMessageBox(QMessageBox::Information, "xD", "the menu got deleted")
.exec();
});
// Add copy and open links for 1x, 2x, 3x
auto addImageLink = [&](const ImagePtr &image, char scale) {
if (!image->isEmpty())
{
copyMenu->addAction(
QString(scale) + "x link",
[url = image->url()] { crossPlatformCopy(url.string); });
openMenu->addAction(
QString(scale) + "x link", [url = image->url()] {
QDesktopServices::openUrl(QUrl(url.string));
});
}
};
addImageLink(emote.images.getImage1(), '1');
addImageLink(emote.images.getImage2(), '2');
addImageLink(emote.images.getImage3(), '3');
// Copy and open emote page link
auto addPageLink = [&](const QString &name) {
copyMenu->addSeparator();
openMenu->addSeparator();
copyMenu->addAction(
"Copy " + name + " emote link",
[url = emote.homePage] { crossPlatformCopy(url.string); });
openMenu->addAction(
"Open " + name + " emote link", [url = emote.homePage] {
QDesktopServices::openUrl(QUrl(url.string)); //
});
};
if (creatorFlags.has(MessageElementFlag::BttvEmote))
{
addPageLink("BTTV");
}
else if (creatorFlags.has(MessageElementFlag::FfzEmote))
{
addPageLink("FFZ");
}
}
} // namespace
ChannelView::ChannelView(BaseWidget *parent)
: BaseWidget(parent)
, scrollBar_(new Scrollbar(this))
{
this->setMouseTracking(true);
this->initializeLayout();
this->initializeScrollbar();
this->initializeSignals();
this->pauseTimer_.setSingleShot(true);
QObject::connect(&this->pauseTimer_, &QTimer::timeout, this, [this] {
/// remove elements that are finite
for (auto it = this->pauses_.begin(); it != this->pauses_.end();)
it = it->second ? this->pauses_.erase(it) : ++it;
this->updatePauses();
});
auto shortcut = new QShortcut(QKeySequence("Ctrl+C"), this);
QObject::connect(shortcut, &QShortcut::activated,
[this] { crossPlatformCopy(this->getSelectedText()); });
this->clickTimer_ = new QTimer(this);
this->clickTimer_->setSingleShot(true);
this->clickTimer_->setInterval(500);
this->setFocusPolicy(Qt::FocusPolicy::StrongFocus);
}
void ChannelView::initializeLayout()
{
this->goToBottom_ = new EffectLabel(this, 0);
this->goToBottom_->setStyleSheet(
"background-color: rgba(0,0,0,0.66); color: #FFF;");
this->goToBottom_->getLabel().setText("More messages below");
this->goToBottom_->setVisible(false);
QObject::connect(this->goToBottom_, &EffectLabel::leftClicked, this, [=] {
QTimer::singleShot(180, [=] {
this->scrollBar_->scrollToBottom(
getSettings()->enableSmoothScrollingNewMessages.getValue());
});
});
}
void ChannelView::initializeScrollbar()
{
this->scrollBar_->getCurrentValueChanged().connect([this] {
this->performLayout(true);
this->queueUpdate();
});
}
void ChannelView::initializeSignals()
{
this->connections_.push_back(
getApp()->windows->wordFlagsChanged.connect([this] {
this->queueLayout();
this->update();
}));
getSettings()->showLastMessageIndicator.connect(
[this](auto, auto) { this->update(); }, this->connections_);
connections_.push_back(getApp()->windows->gifRepaintRequested.connect(
[&] { this->queueUpdate(); }));
connections_.push_back(
getApp()->windows->layoutRequested.connect([&](Channel *channel) {
if (this->isVisible() &&
(channel == nullptr || this->channel_.get() == channel))
{
this->queueLayout();
}
}));
connections_.push_back(
getApp()->fonts->fontChanged.connect([this] { this->queueLayout(); }));
}
bool ChannelView::pausable() const
{
return pausable_;
}
void ChannelView::setPausable(bool value)
{
this->pausable_ = value;
}
bool ChannelView::paused() const
{
/// No elements in the map -> not paused
return this->pausable() && !this->pauses_.empty();
}
void ChannelView::pause(PauseReason reason, boost::optional<uint> msecs)
{
if (msecs)
{
/// Msecs has a value
auto timePoint =
SteadyClock::now() + std::chrono::milliseconds(msecs.get());
auto it = this->pauses_.find(reason);
if (it == this->pauses_.end())
{
/// No value found so we insert a new one.
this->pauses_[reason] = timePoint;
}
else
{
/// If the new time point is newer then we override.
if (it->second && it->second.get() < timePoint)
it->second = timePoint;
}
}
else
{
/// Msecs is none -> pause is infinite.
/// We just override the value.
this->pauses_[reason] = boost::none;
}
this->updatePauses();
}
void ChannelView::unpause(PauseReason reason)
{
/// Remove the value from the map
this->pauses_.erase(reason);
this->updatePauses();
}
void ChannelView::updatePauses()
{
using namespace std::chrono;
if (this->pauses_.empty())
{
this->unpaused();
/// No pauses so we can stop the timer
this->pauseEnd_ = boost::none;
this->pauseTimer_.stop();
this->scrollBar_->offset(this->pauseScrollOffset_);
this->pauseScrollOffset_ = 0;
this->queueLayout();
}
else if (std::any_of(this->pauses_.begin(), this->pauses_.end(),
[](auto &&value) { return !value.second; }))
{
/// Some of the pauses are infinite
this->pauseEnd_ = boost::none;
this->pauseTimer_.stop();
}
else
{
/// Get the maximum pause
auto pauseEnd =
std::max_element(
this->pauses_.begin(), this->pauses_.end(),
[](auto &&a, auto &&b) { return a.second > b.second; })
->second.get();
if (pauseEnd != this->pauseEnd_)
{
/// Start the timer
this->pauseEnd_ = pauseEnd;
this->pauseTimer_.start(
duration_cast<milliseconds>(pauseEnd - SteadyClock::now()));
}
}
}
void ChannelView::unpaused()
{
/// Move selection
this->selection_.selectionMin.messageIndex -= this->pauseSelectionOffset_;
this->selection_.selectionMax.messageIndex -= this->pauseSelectionOffset_;
this->selection_.start.messageIndex -= this->pauseSelectionOffset_;
this->selection_.end.messageIndex -= this->pauseSelectionOffset_;
this->pauseSelectionOffset_ = 0;
}
void ChannelView::themeChangedEvent()
{
BaseWidget::themeChangedEvent();
this->queueLayout();
}
void ChannelView::scaleChangedEvent(float scale)
{
BaseWidget::scaleChangedEvent(scale);
if (this->goToBottom_)
{
auto factor = this->qtFontScale();
#ifdef Q_OS_MACOS
factor = scale * 80.f / this->logicalDpiX() * this->devicePixelRatioF();
#endif
this->goToBottom_->getLabel().setFont(
getFonts()->getFont(FontStyle::UiMedium, factor));
}
}
void ChannelView::queueUpdate()
{
// if (this->updateTimer.isActive()) {
// this->updateQueued = true;
// return;
// }
// this->repaint();
this->update();
// this->updateTimer.start();
}
void ChannelView::queueLayout()
{
// if (!this->layoutCooldown->isActive()) {
this->performLayout();
// this->layoutCooldown->start();
// } else {
// this->layoutQueued = true;
// }
}
void ChannelView::performLayout(bool causedByScrollbar)
{
// BenchmarkGuard benchmark("layout");
/// Get messages and check if there are at least 1
auto messages = this->getMessagesSnapshot();
this->showingLatestMessages_ =
this->scrollBar_->isAtBottom() || !this->scrollBar_->isVisible();
/// Layout visible messages
this->layoutVisibleMessages(messages);
/// Update scrollbar
this->updateScrollbar(messages, causedByScrollbar);
this->goToBottom_->setVisible(this->enableScrollingToBottom_ &&
this->scrollBar_->isVisible() &&
!this->scrollBar_->isAtBottom());
}
void ChannelView::layoutVisibleMessages(
LimitedQueueSnapshot<MessageLayoutPtr> &messages)
{
const auto start = size_t(this->scrollBar_->getCurrentValue());
const auto layoutWidth = this->getLayoutWidth();
const auto flags = this->getFlags();
auto redrawRequired = false;
if (messages.size() > start)
{
auto y = int(-(messages[start]->getHeight() *
(fmod(this->scrollBar_->getCurrentValue(), 1))));
for (auto i = start; i < messages.size() && y <= this->height(); i++)
{
auto message = messages[i];
redrawRequired |=
message->layout(layoutWidth, this->scale(), flags);
y += message->getHeight();
}
}
if (redrawRequired)
this->queueUpdate();
}
void ChannelView::updateScrollbar(
LimitedQueueSnapshot<MessageLayoutPtr> &messages, bool causedByScrollbar)
{
if (messages.size() == 0)
{
this->scrollBar_->setVisible(false);
return;
}
/// Layout the messages at the bottom
auto h = this->height() - 8;
auto flags = this->getFlags();
auto layoutWidth = this->getLayoutWidth();
auto showScrollbar = false;
// convert i to int since it checks >= 0
for (auto i = int(messages.size()) - 1; i >= 0; i--)
{
auto *message = messages[i].get();
message->layout(layoutWidth, this->scale(), flags);
h -= message->getHeight();
if (h < 0) // break condition
{
this->scrollBar_->setLargeChange((messages.size() - i) +
qreal(h) / message->getHeight());
showScrollbar = true;
break;
}
}
/// Update scrollbar values
this->scrollBar_->setVisible(showScrollbar);
if (!showScrollbar && !causedByScrollbar)
{
this->scrollBar_->setDesiredValue(0);
}
this->scrollBar_->setMaximum(messages.size());
// If we were showing the latest messages and the scrollbar now wants to be
// rendered, scroll to bottom
if (this->enableScrollingToBottom_ && this->showingLatestMessages_ &&
showScrollbar)
{
this->scrollBar_->scrollToBottom(
// this->messageWasAdded &&
getSettings()->enableSmoothScrollingNewMessages.getValue());
this->messageWasAdded_ = false;
}
}
void ChannelView::clearMessages()
{
// Clear all stored messages in this chat widget
this->messages_.clear();
this->scrollBar_->clearHighlights();
this->queueLayout();
}
Scrollbar &ChannelView::getScrollBar()
{
return *this->scrollBar_;
}
QString ChannelView::getSelectedText()
{
QString result = "";
LimitedQueueSnapshot<MessageLayoutPtr> messagesSnapshot =
this->getMessagesSnapshot();
Selection _selection = this->selection_;
if (_selection.isEmpty())
{
return result;
}
for (int msg = _selection.selectionMin.messageIndex;
msg <= _selection.selectionMax.messageIndex; msg++)
{
MessageLayoutPtr layout = messagesSnapshot[msg];
int from = msg == _selection.selectionMin.messageIndex
? _selection.selectionMin.charIndex
: 0;
int to = msg == _selection.selectionMax.messageIndex
? _selection.selectionMax.charIndex
: layout->getLastCharacterIndex() + 1;
layout->addSelectionText(result, from, to);
}
return result;
}
bool ChannelView::hasSelection()
{
return !this->selection_.isEmpty();
}
void ChannelView::clearSelection()
{
this->selection_ = Selection();
queueLayout();
}
void ChannelView::setEnableScrollingToBottom(bool value)
{
this->enableScrollingToBottom_ = value;
}
bool ChannelView::getEnableScrollingToBottom() const
{
return this->enableScrollingToBottom_;
}
void ChannelView::setOverrideFlags(boost::optional<MessageElementFlags> value)
{
this->overrideFlags_ = value;
}
const boost::optional<MessageElementFlags> &ChannelView::getOverrideFlags()
const
{
return this->overrideFlags_;
}
LimitedQueueSnapshot<MessageLayoutPtr> ChannelView::getMessagesSnapshot()
{
if (!this->paused() /*|| this->scrollBar_->isVisible()*/)
{
this->snapshot_ = this->messages_.getSnapshot();
}
return this->snapshot_;
}
ChannelPtr ChannelView::channel()
{
return this->channel_;
}
void ChannelView::setChannel(ChannelPtr channel)
{
/// Clear connections from the last channel
this->channelConnections_.clear();
this->clearMessages();
this->scrollBar_->clearHighlights();
// on new message
this->channelConnections_.push_back(channel->messageAppended.connect(
[this](MessagePtr &message,
boost::optional<MessageFlags> overridingFlags) {
this->messageAppended(message, overridingFlags);
}));
this->channelConnections_.push_back(channel->messagesAddedAtStart.connect(
[this](std::vector<MessagePtr> &messages) {
this->messageAddedAtStart(messages);
}));
// on message removed
this->channelConnections_.push_back(
channel->messageRemovedFromStart.connect([this](MessagePtr &message) {
this->messageRemoveFromStart(message);
}));
// on message replaced
this->channelConnections_.push_back(channel->messageReplaced.connect(
[this](size_t index, MessagePtr replacement) {
this->messageReplaced(index, replacement);
}));
auto snapshot = channel->getMessageSnapshot();
for (size_t i = 0; i < snapshot.size(); i++)
{
MessageLayoutPtr deleted;
auto messageLayout = new MessageLayout(snapshot[i]);
if (this->lastMessageHasAlternateBackground_)
{
messageLayout->flags.set(MessageLayoutFlag::AlternateBackground);
}
this->lastMessageHasAlternateBackground_ =
!this->lastMessageHasAlternateBackground_;
if (channel->shouldIgnoreHighlights())
{
messageLayout->flags.set(MessageLayoutFlag::IgnoreHighlights);
}
this->messages_.pushBack(MessageLayoutPtr(messageLayout), deleted);
this->scrollBar_->addHighlight(snapshot[i]->getScrollBarHighlight());
}
this->channel_ = channel;
this->queueLayout();
this->queueUpdate();
// Notifications
if (auto tc = dynamic_cast<TwitchChannel *>(channel.get()))
{
this->connections_.push_back(tc->liveStatusChanged.connect([this]() {
this->liveStatusChanged.invoke(); //
}));
}
}
void ChannelView::messageAppended(MessagePtr &message,
boost::optional<MessageFlags> overridingFlags)
{
MessageLayoutPtr deleted;
auto *messageFlags = &message->flags;
if (overridingFlags)
{
messageFlags = overridingFlags.get_ptr();
}
auto messageRef = new MessageLayout(message);
if (this->lastMessageHasAlternateBackground_)
{
messageRef->flags.set(MessageLayoutFlag::AlternateBackground);
}
if (this->channel_->shouldIgnoreHighlights())
{
messageRef->flags.set(MessageLayoutFlag::IgnoreHighlights);
}
this->lastMessageHasAlternateBackground_ =
!this->lastMessageHasAlternateBackground_;
if (this->messages_.pushBack(MessageLayoutPtr(messageRef), deleted))
{
if (this->paused())
{
if (!this->scrollBar_->isAtBottom())
this->pauseScrollOffset_--;
}
else
{
if (this->scrollBar_->isAtBottom())
this->scrollBar_->scrollToBottom();
else
this->scrollBar_->offset(-1);
}
}
if (!messageFlags->has(MessageFlag::DoNotTriggerNotification))
{
if (messageFlags->has(MessageFlag::Highlighted))
{
this->tabHighlightRequested.invoke(HighlightState::Highlighted);
}
else
{
this->tabHighlightRequested.invoke(HighlightState::NewMessage);
}
}
if (this->channel_->getType() != Channel::Type::TwitchMentions)
{
this->scrollBar_->addHighlight(message->getScrollBarHighlight());
}
this->messageWasAdded_ = true;
this->queueLayout();
}
void ChannelView::messageAddedAtStart(std::vector<MessagePtr> &messages)
{
std::vector<MessageLayoutPtr> messageRefs;
messageRefs.resize(messages.size());
/// Create message layouts
for (size_t i = 0; i < messages.size(); i++)
{
auto layout = new MessageLayout(messages.at(i));
// alternate color
if (!this->lastMessageHasAlternateBackgroundReverse_)
layout->flags.set(MessageLayoutFlag::AlternateBackground);
this->lastMessageHasAlternateBackgroundReverse_ =
!this->lastMessageHasAlternateBackgroundReverse_;
messageRefs.at(i) = MessageLayoutPtr(layout);
}
/// Add the messages at the start
if (this->messages_.pushFront(messageRefs).size() > 0)
{
if (this->scrollBar_->isAtBottom())
this->scrollBar_->scrollToBottom();
else
this->scrollBar_->offset(qreal(messages.size()));
}
/// Add highlights
std::vector<ScrollbarHighlight> highlights;
highlights.reserve(messages.size());
for (size_t i = 0; i < messages.size(); i++)
{
highlights.push_back(messages.at(i)->getScrollBarHighlight());
}
this->scrollBar_->addHighlightsAtStart(highlights);
this->messageWasAdded_ = true;
this->queueLayout();
}
void ChannelView::messageRemoveFromStart(MessagePtr &message)
{
if (this->paused())
{
this->pauseSelectionOffset_ += 1;
}
else
{
this->selection_.selectionMin.messageIndex--;
this->selection_.selectionMax.messageIndex--;
this->selection_.start.messageIndex--;
this->selection_.end.messageIndex--;
}
this->queueLayout();
}
void ChannelView::messageReplaced(size_t index, MessagePtr &replacement)
{
if (index >= this->messages_.getSnapshot().size())
{
return;
}
MessageLayoutPtr newItem(new MessageLayout(replacement));
auto snapshot = this->messages_.getSnapshot();
if (index >= snapshot.size())
{
qDebug() << "Tried to replace out of bounds message. Index:" << index
<< ". Length:" << snapshot.size();
return;
}
const auto &message = snapshot[index];
if (message->flags.has(MessageLayoutFlag::AlternateBackground))
{
newItem->flags.set(MessageLayoutFlag::AlternateBackground);
}
this->scrollBar_->replaceHighlight(index,
replacement->getScrollBarHighlight());
this->messages_.replaceItem(message, newItem);
this->queueLayout();
}
void ChannelView::updateLastReadMessage()
{
auto _snapshot = this->getMessagesSnapshot();
if (_snapshot.size() > 0)
{
this->lastReadMessage_ = _snapshot[_snapshot.size() - 1];
}
this->update();
}
void ChannelView::resizeEvent(QResizeEvent *)
{
this->scrollBar_->setGeometry(this->width() - this->scrollBar_->width(), 0,
this->scrollBar_->width(), this->height());
this->goToBottom_->setGeometry(0, this->height() - int(this->scale() * 26),
this->width(), int(this->scale() * 26));
this->scrollBar_->raise();
this->queueLayout();
this->update();
}
void ChannelView::setSelection(const SelectionItem &start,
const SelectionItem &end)
{
// selections
if (!this->selecting_ && start != end)
{
// this->messagesAddedSinceSelectionPause_ = 0;
this->selecting_ = true;
// this->pausedBySelection_ = true;
}
this->selection_ = Selection(start, end);
this->selectionChanged.invoke();
}
MessageElementFlags ChannelView::getFlags() const
{
auto app = getApp();
if (this->overrideFlags_)
{
return this->overrideFlags_.get();
}
MessageElementFlags flags = app->windows->getWordFlags();
Split *split = dynamic_cast<Split *>(this->parentWidget());
if (split != nullptr)
{
if (split->getModerationMode())
{
flags.set(MessageElementFlag::ModeratorTools);
}
if (this->channel_ == app->twitch.server->mentionsChannel)
{
flags.set(MessageElementFlag::ChannelName);
}
}
return flags;
}
void ChannelView::paintEvent(QPaintEvent * /*event*/)
{
// BenchmarkGuard benchmark("paint");
QPainter painter(this);
painter.fillRect(rect(), this->theme->splits.background);
// draw messages
this->drawMessages(painter);
// draw paused sign
if (this->paused())
{
auto a = this->scale() * 16;
auto brush = QBrush(QColor(127, 127, 127, 63));
painter.fillRect(QRectF(this->width() - a, a / 4, a / 4, a), brush);
painter.fillRect(QRectF(this->width() - a / 2, a / 4, a / 4, a), brush);
}
}
// if overlays is false then it draws the message, if true then it draws things
// such as the grey overlay when a message is disabled
void ChannelView::drawMessages(QPainter &painter)
{
auto messagesSnapshot = this->getMessagesSnapshot();
size_t start = size_t(this->scrollBar_->getCurrentValue());
if (start >= messagesSnapshot.size())
{
return;
}
int y = int(-(messagesSnapshot[start].get()->getHeight() *
(fmod(this->scrollBar_->getCurrentValue(), 1))));
MessageLayout *end = nullptr;
bool windowFocused = this->window() == QApplication::activeWindow();
for (size_t i = start; i < messagesSnapshot.size(); ++i)
{
MessageLayout *layout = messagesSnapshot[i].get();
bool isLastMessage = false;
if (getSettings()->showLastMessageIndicator)
{
isLastMessage = this->lastReadMessage_.get() == layout;
}
layout->paint(painter, DRAW_WIDTH, y, i, this->selection_,
isLastMessage, windowFocused);
y += layout->getHeight();
end = layout;
if (y > this->height())
{
break;
}
}
if (end == nullptr)
{
return;
}
// remove messages that are on screen
// the messages that are left at the end get their buffers reset
for (size_t i = start; i < messagesSnapshot.size(); ++i)
{
auto it = this->messagesOnScreen_.find(messagesSnapshot[i]);
if (it != this->messagesOnScreen_.end())
{
this->messagesOnScreen_.erase(it);
}
}
// delete the message buffers that aren't on screen
for (const std::shared_ptr<MessageLayout> &item : this->messagesOnScreen_)
{
item->deleteBuffer();
}
this->messagesOnScreen_.clear();
// add all messages on screen to the map
for (size_t i = start; i < messagesSnapshot.size(); ++i)
{
std::shared_ptr<MessageLayout> layout = messagesSnapshot[i];
this->messagesOnScreen_.insert(layout);
if (layout.get() == end)
{
break;
}
}
}
void ChannelView::wheelEvent(QWheelEvent *event)
{
if (event->orientation() != Qt::Vertical)
return;
if (event->modifiers() & Qt::ControlModifier)
{
event->ignore();
return;
}
if (this->scrollBar_->isVisible())
{
float mouseMultiplier = getSettings()->mouseScrollMultiplier;
qreal desired = this->scrollBar_->getDesiredValue();
qreal delta = event->delta() * qreal(1.5) * mouseMultiplier;
auto snapshot = this->getMessagesSnapshot();
int snapshotLength = int(snapshot.size());
int i = std::min<int>(int(desired), snapshotLength);
if (delta > 0)
{
qreal scrollFactor = fmod(desired, 1);
qreal currentScrollLeft =
int(scrollFactor * snapshot[i]->getHeight());
for (; i >= 0; i--)
{
if (delta < currentScrollLeft)
{
desired -= scrollFactor * (delta / currentScrollLeft);
break;
}
else
{
delta -= currentScrollLeft;
desired -= scrollFactor;
}
if (i == 0)
{
desired = 0;
}
else
{
snapshot[i - 1]->layout(this->getLayoutWidth(),
this->scale(), this->getFlags());
scrollFactor = 1;
currentScrollLeft = snapshot[i - 1]->getHeight();
}
}
}
else
{
delta = -delta;
qreal scrollFactor = 1 - fmod(desired, 1);
qreal currentScrollLeft =
int(scrollFactor * snapshot[i]->getHeight());
for (; i < snapshotLength; i++)
{
if (delta < currentScrollLeft)
{
desired +=
scrollFactor * (qreal(delta) / currentScrollLeft);
break;
}
else
{
delta -= currentScrollLeft;
desired += scrollFactor;
}
if (i == snapshotLength - 1)
{
desired = snapshot.size();
}
else
{
snapshot[i + 1]->layout(this->getLayoutWidth(),
this->scale(), this->getFlags());
scrollFactor = 1;
currentScrollLeft = snapshot[i + 1]->getHeight();
}
}
}
this->scrollBar_->setDesiredValue(desired, true);
}
}
void ChannelView::enterEvent(QEvent *)
{
}
void ChannelView::leaveEvent(QEvent *)
{
this->unpause(PauseReason::Mouse);
this->queueLayout();
}
void ChannelView::mouseMoveEvent(QMouseEvent *event)
{
/// Pause on hover
if (float pauseTime = getSettings()->pauseOnHoverDuration;
pauseTime > 0.001f)
{
this->pause(PauseReason::Mouse, uint(pauseTime * 1000.f));
}
else if (pauseTime < -0.5f)
{
this->pause(PauseReason::Mouse);
}
auto tooltipWidget = TooltipWidget::instance();
std::shared_ptr<MessageLayout> layout;
QPoint relativePos;
int messageIndex;
// no message under cursor
if (!tryGetMessageAt(event->pos(), layout, relativePos, messageIndex))
{
this->setCursor(Qt::ArrowCursor);
tooltipWidget->hide();
return;
}
// is selecting
if (this->isMouseDown_)
{
// this->pause(PauseReason::Selecting, 300);
int index = layout->getSelectionIndex(relativePos);
this->setSelection(this->selection_.start,
SelectionItem(messageIndex, index));
this->queueUpdate();
}
// message under cursor is collapsed
if (layout->flags.has(MessageLayoutFlag::Collapsed))
{
this->setCursor(Qt::PointingHandCursor);
tooltipWidget->hide();
return;
}
// check if word underneath cursor
const MessageLayoutElement *hoverLayoutElement =
layout->getElementAt(relativePos);
if (hoverLayoutElement == nullptr)
{
this->setCursor(Qt::ArrowCursor);
tooltipWidget->hide();
return;
}
if (this->isDoubleClick_)
{
int wordStart;
int wordEnd;
this->getWordBounds(layout.get(), hoverLayoutElement, relativePos,
wordStart, wordEnd);
SelectionItem newStart(messageIndex, wordStart);
SelectionItem newEnd(messageIndex, wordEnd);
// Selection changed in same message
if (messageIndex == this->doubleClickSelection_.origMessageIndex)
{
// Selecting to the left
if (wordStart < this->selection_.start.charIndex &&
!this->doubleClickSelection_.selectingRight)
{
this->doubleClickSelection_.selectingLeft = true;
// Ensure that the original word stays selected(Edge case)
if (wordStart > this->doubleClickSelection_.originalEnd)
{
this->setSelection(
this->doubleClickSelection_.origStartItem, newEnd);
}
else
{
this->setSelection(newStart, this->selection_.end);
}
// Selecting to the right
}
else if (wordEnd > this->selection_.end.charIndex &&
!this->doubleClickSelection_.selectingLeft)
{
this->doubleClickSelection_.selectingRight = true;
// Ensure that the original word stays selected(Edge case)
if (wordEnd < this->doubleClickSelection_.originalStart)
{
this->setSelection(newStart,
this->doubleClickSelection_.origEndItem);
}
else
{
this->setSelection(this->selection_.start, newEnd);
}
}
// Swapping from selecting left to selecting right
if (wordStart > this->selection_.start.charIndex &&
!this->doubleClickSelection_.selectingRight)
{
if (wordStart > this->doubleClickSelection_.originalEnd)
{
this->doubleClickSelection_.selectingLeft = false;
this->doubleClickSelection_.selectingRight = true;
this->setSelection(
this->doubleClickSelection_.origStartItem, newEnd);
}
else
{
this->setSelection(newStart, this->selection_.end);
}
// Swapping from selecting right to selecting left
}
else if (wordEnd < this->selection_.end.charIndex &&
!this->doubleClickSelection_.selectingLeft)
{
if (wordEnd < this->doubleClickSelection_.originalStart)
{
this->doubleClickSelection_.selectingLeft = true;
this->doubleClickSelection_.selectingRight = false;
this->setSelection(newStart,
this->doubleClickSelection_.origEndItem);
}
else
{
this->setSelection(this->selection_.start, newEnd);
}
}
// Selection changed in a different message
}
else
{
// Message over the original
if (messageIndex < this->selection_.start.messageIndex)
{
// Swapping from left to right selecting
if (!this->doubleClickSelection_.selectingLeft)
{
this->doubleClickSelection_.selectingLeft = true;
this->doubleClickSelection_.selectingRight = false;
}
if (wordStart < this->selection_.start.charIndex &&
!this->doubleClickSelection_.selectingRight)
{
this->doubleClickSelection_.selectingLeft = true;
}
this->setSelection(newStart,
this->doubleClickSelection_.origEndItem);
// Message under the original
}
else if (messageIndex > this->selection_.end.messageIndex)
{
// Swapping from right to left selecting
if (!this->doubleClickSelection_.selectingRight)
{
this->doubleClickSelection_.selectingLeft = false;
this->doubleClickSelection_.selectingRight = true;
}
if (wordEnd > this->selection_.end.charIndex &&
!this->doubleClickSelection_.selectingLeft)
{
this->doubleClickSelection_.selectingRight = true;
}
this->setSelection(this->doubleClickSelection_.origStartItem,
newEnd);
// Selection changed in non original message
}
else
{
if (this->doubleClickSelection_.selectingLeft)
{
this->setSelection(newStart, this->selection_.end);
}
else
{
this->setSelection(this->selection_.start, newEnd);
}
}
}
// Reset direction of selection
if (wordStart == this->doubleClickSelection_.originalStart &&
wordEnd == this->doubleClickSelection_.originalEnd)
{
this->doubleClickSelection_.selectingLeft =
this->doubleClickSelection_.selectingRight = false;
}
}
const auto &tooltip = hoverLayoutElement->getCreator().getTooltip();
bool isLinkValid = hoverLayoutElement->getLink().isValid();
if (tooltip.isEmpty())
{
tooltipWidget->hide();
}
else if (isLinkValid && !getSettings()->linkInfoTooltip)
{
tooltipWidget->hide();
}
else
{
auto &tooltipPreviewImage = TooltipPreviewImage::instance();
auto emoteElement = dynamic_cast<const EmoteElement *>(
&hoverLayoutElement->getCreator());
auto badgeElement = dynamic_cast<const BadgeElement *>(
&hoverLayoutElement->getCreator());
if ((badgeElement || emoteElement) &&
getSettings()->emotesTooltipPreview.getValue())
{
if (event->modifiers() == Qt::ShiftModifier ||
getSettings()->emotesTooltipPreview.getValue() == 1)
{
if (emoteElement)
{
tooltipPreviewImage.setImage(
emoteElement->getEmote()->images.getImage(3.0));
}
else if (badgeElement)
{
tooltipPreviewImage.setImage(
badgeElement->getEmote()->images.getImage(3.0));
}
}
else
{
tooltipPreviewImage.setImage(nullptr);
}
}
else
{
tooltipPreviewImage.setImage(nullptr);
}
tooltipWidget->moveTo(this, event->globalPos());
tooltipWidget->setWordWrap(isLinkValid);
tooltipWidget->setText(tooltip);
tooltipWidget->adjustSize();
tooltipWidget->show();
tooltipWidget->raise();
}
// check if word has a link
if (isLinkValid)
{
this->setCursor(Qt::PointingHandCursor);
}
else
{
this->setCursor(Qt::ArrowCursor);
}
}
void ChannelView::mousePressEvent(QMouseEvent *event)
{
this->mouseDown.invoke(event);
std::shared_ptr<MessageLayout> layout;
QPoint relativePos;
int messageIndex;
if (!tryGetMessageAt(event->pos(), layout, relativePos, messageIndex))
{
setCursor(Qt::ArrowCursor);
auto messagesSnapshot = this->getMessagesSnapshot();
if (messagesSnapshot.size() == 0)
{
return;
}
// Start selection at the last message at its last index
if (event->button() == Qt::LeftButton)
{
auto lastMessageIndex = messagesSnapshot.size() - 1;
auto lastMessage = messagesSnapshot[lastMessageIndex];
auto lastCharacterIndex = lastMessage->getLastCharacterIndex();
SelectionItem selectionItem(lastMessageIndex, lastCharacterIndex);
this->setSelection(selectionItem, selectionItem);
}
return;
}
// check if message is collapsed
switch (event->button())
{
case Qt::LeftButton: {
this->lastPressPosition_ = event->screenPos();
this->isMouseDown_ = true;
if (layout->flags.has(MessageLayoutFlag::Collapsed))
return;
if (getSettings()->linksDoubleClickOnly.getValue())
{
this->pause(PauseReason::DoubleClick, 200);
}
int index = layout->getSelectionIndex(relativePos);
auto selectionItem = SelectionItem(messageIndex, index);
this->setSelection(selectionItem, selectionItem);
}
break;
case Qt::RightButton: {
this->lastRightPressPosition_ = event->screenPos();
this->isRightMouseDown_ = true;
}
break;
default:;
}
this->update();
}
void ChannelView::mouseReleaseEvent(QMouseEvent *event)
{
// check if mouse was pressed
if (event->button() == Qt::LeftButton)
{
this->doubleClickSelection_.selectingLeft =
this->doubleClickSelection_.selectingRight = false;
if (this->isDoubleClick_)
{
this->isDoubleClick_ = false;
// Was actually not a wanted triple-click
if (fabsf(distanceBetweenPoints(this->lastDClickPosition_,
event->screenPos())) > 10.f)
{
this->clickTimer_->stop();
return;
}
}
else if (this->isMouseDown_)
{
this->isMouseDown_ = false;
if (fabsf(distanceBetweenPoints(this->lastPressPosition_,
event->screenPos())) > 15.f)
{
return;
}
}
else
{
return;
}
}
else if (event->button() == Qt::RightButton)
{
if (this->isRightMouseDown_)
{
this->isRightMouseDown_ = false;
if (fabsf(distanceBetweenPoints(this->lastRightPressPosition_,
event->screenPos())) > 15.f)
{
return;
}
}
else
{
return;
}
}
else
{
// not left or right button
return;
}
// find message
this->queueLayout();
std::shared_ptr<MessageLayout> layout;
QPoint relativePos;
int messageIndex;
// no message found
if (!tryGetMessageAt(event->pos(), layout, relativePos, messageIndex))
{
// No message at clicked position
return;
}
// message under cursor is collapsed
if (layout->flags.has(MessageLayoutFlag::Collapsed))
{
layout->flags.set(MessageLayoutFlag::Expanded);
layout->flags.set(MessageLayoutFlag::RequiresLayout);
this->queueLayout();
return;
}
const MessageLayoutElement *hoverLayoutElement =
layout->getElementAt(relativePos);
// Triple-clicking a message selects the whole message
if (this->clickTimer_->isActive() && this->selecting_)
{
if (fabsf(distanceBetweenPoints(this->lastDClickPosition_,
event->screenPos())) < 10.f)
{
this->selectWholeMessage(layout.get(), messageIndex);
}
}
if (hoverLayoutElement == nullptr)
{
return;
}
// handle the click
this->handleMouseClick(event, hoverLayoutElement, layout.get());
}
void ChannelView::handleMouseClick(QMouseEvent *event,
const MessageLayoutElement *hoveredElement,
MessageLayout *layout)
{
switch (event->button())
{
case Qt::LeftButton: {
if (this->selecting_)
{
// this->pausedBySelection = false;
this->selecting_ = false;
// this->pauseTimeout.stop();
// this->pausedTemporarily = false;
this->queueLayout();
}
auto &link = hoveredElement->getLink();
if (!getSettings()->linksDoubleClickOnly)
{
this->handleLinkClick(event, link, layout);
}
// Invoke to signal from EmotePopup.
if (link.type == Link::InsertText)
{
this->linkClicked.invoke(link);
}
}
break;
case Qt::RightButton: {
auto insertText = [=](QString text) {
if (auto split = dynamic_cast<Split *>(this->parentWidget()))
{
split->insertTextToInput(text);
}
};
auto &link = hoveredElement->getLink();
if (link.type == Link::UserInfo)
{
const bool commaMention = getSettings()->mentionUsersWithComma;
insertText("@" + link.value + (commaMention ? ", " : " "));
}
else if (link.type == Link::UserWhisper)
{
insertText("/w " + link.value + " ");
}
else
{
this->addContextMenuItems(hoveredElement, layout);
}
}
break;
default:;
}
}
void ChannelView::addContextMenuItems(
const MessageLayoutElement *hoveredElement, MessageLayout *layout)
{
const auto &creator = hoveredElement->getCreator();
auto creatorFlags = creator.getFlags();
static QMenu *menu = new QMenu;
menu->clear();
// Emote actions
if (creatorFlags.hasAny(
{MessageElementFlag::EmoteImages, MessageElementFlag::EmojiImage}))
{
const auto emoteElement = dynamic_cast<const EmoteElement *>(&creator);
if (emoteElement)
addEmoteContextMenuItems(*emoteElement->getEmote(), creatorFlags,
*menu);
}
// add seperator
if (!menu->actions().empty())
{
menu->addSeparator();
}
// Link copy
if (hoveredElement->getLink().type == Link::Url)
{
QString url = hoveredElement->getLink().value;
// open link
menu->addAction("Open link",
[url] { QDesktopServices::openUrl(QUrl(url)); });
// open link default
if (supportsIncognitoLinks())
{
menu->addAction("Open link incognito",
[url] { openLinkIncognito(url); });
}
menu->addAction("Copy link", [url] { crossPlatformCopy(url); });
menu->addSeparator();
}
// Copy actions
if (!this->selection_.isEmpty())
{
menu->addAction("Copy selection",
[this] { crossPlatformCopy(this->getSelectedText()); });
}
menu->addAction("Copy message", [layout] {
QString copyString;
layout->addSelectionText(copyString, 0, INT_MAX,
CopyMode::OnlyTextAndEmotes);
crossPlatformCopy(copyString);
});
menu->addAction("Copy full message", [layout] {
QString copyString;
layout->addSelectionText(copyString);
crossPlatformCopy(copyString);
});
// Open in new split.
if (hoveredElement->getLink().type == Link::Url)
{
static QRegularExpression twitchChannelRegex(
R"(^(?:https?:\/\/)?(?:www\.|go\.)?twitch\.tv\/(?<username>[a-z0-9_]{3,}))",
QRegularExpression::CaseInsensitiveOption);
static QSet<QString> ignoredUsernames{
"videos", "settings", "directory", "jobs", "friends",
"inventory", "payments", "subscriptions", "messages",
};
auto twitchMatch =
twitchChannelRegex.match(hoveredElement->getLink().value);
auto twitchUsername = twitchMatch.captured("username");
if (!twitchUsername.isEmpty() &&
!ignoredUsernames.contains(twitchUsername))
{
menu->addSeparator();
menu->addAction("Open in new split", [twitchUsername, this] {
this->joinToChannel.invoke(twitchUsername);
});
}
}
menu->popup(QCursor::pos());
menu->raise();
return;
}
void ChannelView::mouseDoubleClickEvent(QMouseEvent *event)
{
std::shared_ptr<MessageLayout> layout;
QPoint relativePos;
int messageIndex;
if (!tryGetMessageAt(event->pos(), layout, relativePos, messageIndex))
{
return;
}
// message under cursor is collapsed
if (layout->flags.has(MessageLayoutFlag::Collapsed))
{
return;
}
const MessageLayoutElement *hoverLayoutElement =
layout->getElementAt(relativePos);
this->lastDClickPosition_ = event->screenPos();
if (hoverLayoutElement == nullptr)
{
// Possibility for triple click which doesn't have to be over an
// existing layout element
this->clickTimer_->start();
return;
}
if (!this->isMouseDown_)
{
this->isDoubleClick_ = true;
int wordStart;
int wordEnd;
this->getWordBounds(layout.get(), hoverLayoutElement, relativePos,
wordStart, wordEnd);
this->clickTimer_->start();
SelectionItem wordMin(messageIndex, wordStart);
SelectionItem wordMax(messageIndex, wordEnd);
this->doubleClickSelection_.originalStart = wordStart;
this->doubleClickSelection_.originalEnd = wordEnd;
this->doubleClickSelection_.origMessageIndex = messageIndex;
this->doubleClickSelection_.origStartItem = wordMin;
this->doubleClickSelection_.origEndItem = wordMax;
this->setSelection(wordMin, wordMax);
}
if (getSettings()->linksDoubleClickOnly)
{
auto &link = hoverLayoutElement->getLink();
this->handleLinkClick(event, link, layout.get());
}
}
void ChannelView::hideEvent(QHideEvent *)
{
for (auto &layout : this->messagesOnScreen_)
{
layout->deleteBuffer();
}
this->messagesOnScreen_.clear();
}
void ChannelView::showUserInfoPopup(const QString &userName)
{
auto *userPopup = new UserInfoPopup;
userPopup->setData(userName, this->channel_);
userPopup->setActionOnFocusLoss(BaseWindow::Delete);
QPoint offset(int(150 * this->scale()), int(70 * this->scale()));
userPopup->move(QCursor::pos() - offset);
userPopup->show();
}
void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link,
MessageLayout *layout)
{
if (event->button() != Qt::LeftButton)
{
return;
}
switch (link.type)
{
case Link::UserWhisper:
case Link::UserInfo: {
auto user = link.value;
this->showUserInfoPopup(user);
qDebug() << "Clicked " << user << "s message";
}
break;
case Link::Url: {
if (getSettings()->openLinksIncognito && supportsIncognitoLinks())
openLinkIncognito(link.value);
else
QDesktopServices::openUrl(QUrl(link.value));
}
break;
case Link::UserAction: {
QString value = link.value;
value.replace("{user}", layout->getMessage()->loginName)
.replace("{channel}", this->channel_->getName())
.replace("{msg-id}", layout->getMessage()->id)
.replace("{message}", layout->getMessage()->messageText);
this->channel_->sendMessage(value);
}
break;
case Link::AutoModAllow: {
getApp()->accounts->twitch.getCurrent()->autoModAllow(link.value);
}
break;
case Link::AutoModDeny: {
getApp()->accounts->twitch.getCurrent()->autoModDeny(link.value);
}
default:;
}
}
bool ChannelView::tryGetMessageAt(QPoint p,
std::shared_ptr<MessageLayout> &_message,
QPoint &relativePos, int &index)
{
auto messagesSnapshot = this->getMessagesSnapshot();
size_t start = this->scrollBar_->getCurrentValue();
if (start >= messagesSnapshot.size())
{
return false;
}
int y = -(messagesSnapshot[start]->getHeight() *
(fmod(this->scrollBar_->getCurrentValue(), 1)));
for (size_t i = start; i < messagesSnapshot.size(); ++i)
{
auto message = messagesSnapshot[i];
if (p.y() < y + message->getHeight())
{
relativePos = QPoint(p.x(), p.y() - y);
_message = message;
index = i;
return true;
}
y += message->getHeight();
}
return false;
}
int ChannelView::getLayoutWidth() const
{
if (this->scrollBar_->isVisible())
return int(this->width() - scrollbarPadding * this->scale());
return this->width();
}
void ChannelView::selectWholeMessage(MessageLayout *layout, int &messageIndex)
{
SelectionItem msgStart(messageIndex,
layout->getFirstMessageCharacterIndex());
SelectionItem msgEnd(messageIndex, layout->getLastCharacterIndex());
this->setSelection(msgStart, msgEnd);
}
void ChannelView::getWordBounds(MessageLayout *layout,
const MessageLayoutElement *element,
const QPoint &relativePos, int &wordStart,
int &wordEnd)
{
const int mouseInWordIndex = element->getMouseOverIndex(relativePos);
wordStart = layout->getSelectionIndex(relativePos) - mouseInWordIndex;
const int selectionLength = element->getSelectionIndexCount();
const int length =
element->hasTrailingSpace() ? selectionLength - 1 : selectionLength;
wordEnd = wordStart + length;
}
} // namespace chatterino