diff --git a/CHANGELOG.md b/CHANGELOG.md index d350f6a58..227776dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Major: Added username autocompletion popup menu when typing usernames with an @ prefix. (#1979, #2866) - Major: Added ability to toggle visibility of Channel Tabs - This can be done by right-clicking the tab area or pressing the keyboard shortcut (default: Ctrl+U). (#2600) - Minor: Restore automod functionality for moderators (#2817, #2887) - Minor: Add setting for username style (#2889, #2891) diff --git a/chatterino.pro b/chatterino.pro index 2dfee10d3..7cdba86a0 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -318,8 +318,8 @@ SOURCES += \ src/widgets/settingspages/NotificationPage.cpp \ src/widgets/settingspages/SettingsPage.cpp \ src/widgets/splits/ClosedSplits.cpp \ - src/widgets/splits/EmoteInputItem.cpp \ - src/widgets/splits/EmoteInputPopup.cpp \ + src/widgets/splits/InputCompletionItem.cpp \ + src/widgets/splits/InputCompletionPopup.cpp \ src/widgets/splits/Split.cpp \ src/widgets/splits/SplitContainer.cpp \ src/widgets/splits/SplitHeader.cpp \ @@ -579,8 +579,8 @@ HEADERS += \ src/widgets/settingspages/NotificationPage.hpp \ src/widgets/settingspages/SettingsPage.hpp \ src/widgets/splits/ClosedSplits.hpp \ - src/widgets/splits/EmoteInputItem.hpp \ - src/widgets/splits/EmoteInputPopup.hpp \ + src/widgets/splits/InputCompletionItem.hpp \ + src/widgets/splits/InputCompletionPopup.hpp \ src/widgets/splits/Split.hpp \ src/widgets/splits/SplitContainer.hpp \ src/widgets/splits/SplitHeader.hpp \ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index aa3f8654c..9061840d1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -437,10 +437,10 @@ set(SOURCE_FILES widgets/splits/ClosedSplits.cpp widgets/splits/ClosedSplits.hpp - widgets/splits/EmoteInputItem.cpp - widgets/splits/EmoteInputItem.hpp - widgets/splits/EmoteInputPopup.cpp - widgets/splits/EmoteInputPopup.hpp + widgets/splits/InputCompletionItem.cpp + widgets/splits/InputCompletionItem.hpp + widgets/splits/InputCompletionPopup.cpp + widgets/splits/InputCompletionPopup.hpp widgets/splits/Split.cpp widgets/splits/Split.hpp widgets/splits/SplitContainer.cpp diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index ffb6815d9..808e7656b 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -171,6 +171,8 @@ public: "/behaviour/autocompletion/userCompletionOnlyWithAt", false}; BoolSetting emoteCompletionWithColon = { "/behaviour/autocompletion/emoteCompletionWithColon", true}; + BoolSetting showUsernameCompletionMenu = { + "/behaviour/autocompletion/showUsernameCompletionMenu", true}; FloatSetting pauseOnHoverDuration = {"/behaviour/pauseOnHoverDuration", 0}; EnumSetting pauseChatModifier = { diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 5d96e9ac5..275744493 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -612,6 +612,8 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Color @usernames", s.colorUsernames); layout.addCheckbox("Try to find usernames without @ prefix", s.findAllUsernames); + layout.addCheckbox("Show username autocompletion popup menu", + s.showUsernameCompletionMenu); const QStringList usernameDisplayModes = {"Username", "Localized name", "Username and localized name"}; diff --git a/src/widgets/splits/EmoteInputItem.cpp b/src/widgets/splits/EmoteInputItem.cpp deleted file mode 100644 index 5612b16cd..000000000 --- a/src/widgets/splits/EmoteInputItem.cpp +++ /dev/null @@ -1,63 +0,0 @@ -#include "EmoteInputItem.hpp" - -namespace chatterino { - -EmoteInputItem::EmoteInputItem(const EmotePtr &emote, const QString &text, - ActionCallback action) - : emote_(emote) - , text_(text) - , action_(action) -{ -} - -void EmoteInputItem::action() -{ - if (this->action_ && this->emote_) - this->action_(this->emote_->name.string); -} - -void EmoteInputItem::paint(QPainter *painter, const QRect &rect) const -{ - painter->setRenderHint(QPainter::SmoothPixmapTransform); - painter->setRenderHint(QPainter::Antialiasing); - - auto margin = 4; - auto imageHeight = ICON_SIZE.height() - margin * 2; - - QRect iconRect{ - rect.topLeft() + QPoint{margin, margin}, - QSize{imageHeight, imageHeight}, - }; - - if (this->emote_) - { - if (auto image = this->emote_->images.getImage(2)) - { - if (auto pixmap = image->pixmapOrLoad()) - { - if (image->height() != 0) - { - auto aspectRatio = - double(image->width()) / double(image->height()); - - iconRect = { - rect.topLeft() + QPoint{margin, margin}, - QSize(int(imageHeight * aspectRatio), imageHeight)}; - painter->drawPixmap(iconRect, *pixmap); - } - } - } - } - - QRect textRect = - QRect(iconRect.topRight() + QPoint{margin, 0}, - QSize(rect.width() - iconRect.width(), iconRect.height())); - painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, this->text_); -} - -QSize EmoteInputItem::sizeHint(const QRect &rect) const -{ - return QSize(rect.width(), ICON_SIZE.height()); -} - -} // namespace chatterino diff --git a/src/widgets/splits/InputCompletionItem.cpp b/src/widgets/splits/InputCompletionItem.cpp new file mode 100644 index 000000000..99994b605 --- /dev/null +++ b/src/widgets/splits/InputCompletionItem.cpp @@ -0,0 +1,76 @@ +#include "InputCompletionItem.hpp" + +namespace chatterino { + +InputCompletionItem::InputCompletionItem(const EmotePtr &emote, + const QString &text, + ActionCallback action) + : emote_(emote) + , text_(text) + , action_(action) +{ +} + +void InputCompletionItem::action() +{ + if (this->action_) + { + if (this->emote_) + this->action_(this->emote_->name.string); + else + this->action_(this->text_); + } +} + +void InputCompletionItem::paint(QPainter *painter, const QRect &rect) const +{ + auto margin = 4; + QRect textRect; + if (this->emote_) + { + painter->setRenderHint(QPainter::SmoothPixmapTransform); + painter->setRenderHint(QPainter::Antialiasing); + + auto imageHeight = ICON_SIZE.height() - margin * 2; + + QRect iconRect{ + rect.topLeft() + QPoint{margin, margin}, + QSize{imageHeight, imageHeight}, + }; + + if (auto image = this->emote_->images.getImage(2)) + { + if (auto pixmap = image->pixmapOrLoad()) + { + if (image->height() != 0) + { + auto aspectRatio = + double(image->width()) / double(image->height()); + + iconRect = { + rect.topLeft() + QPoint{margin, margin}, + QSize(int(imageHeight * aspectRatio), imageHeight)}; + painter->drawPixmap(iconRect, *pixmap); + } + } + } + + textRect = + QRect(iconRect.topRight() + QPoint{margin, 0}, + QSize(rect.width() - iconRect.width(), iconRect.height())); + } + else + { + textRect = QRect(rect.topLeft() + QPoint{margin, 0}, + QSize(rect.width(), rect.height())); + } + + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, this->text_); +} + +QSize InputCompletionItem::sizeHint(const QRect &rect) const +{ + return QSize(rect.width(), ICON_SIZE.height()); +} + +} // namespace chatterino diff --git a/src/widgets/splits/EmoteInputItem.hpp b/src/widgets/splits/InputCompletionItem.hpp similarity index 76% rename from src/widgets/splits/EmoteInputItem.hpp rename to src/widgets/splits/InputCompletionItem.hpp index d0798c2ea..9ea1260a1 100644 --- a/src/widgets/splits/EmoteInputItem.hpp +++ b/src/widgets/splits/InputCompletionItem.hpp @@ -6,13 +6,13 @@ namespace chatterino { -class EmoteInputItem : public GenericListItem +class InputCompletionItem : public GenericListItem { using ActionCallback = std::function; public: - EmoteInputItem(const EmotePtr &emote, const QString &text, - ActionCallback action); + InputCompletionItem(const EmotePtr &emote, const QString &text, + ActionCallback action); // GenericListItem interface public: diff --git a/src/widgets/splits/EmoteInputPopup.cpp b/src/widgets/splits/InputCompletionPopup.cpp similarity index 76% rename from src/widgets/splits/EmoteInputPopup.cpp rename to src/widgets/splits/InputCompletionPopup.cpp index 2291afe6a..689aa1c8a 100644 --- a/src/widgets/splits/EmoteInputPopup.cpp +++ b/src/widgets/splits/InputCompletionPopup.cpp @@ -1,4 +1,4 @@ -#include "EmoteInputPopup.hpp" +#include "InputCompletionPopup.hpp" #include "Application.hpp" #include "controllers/accounts/AccountController.hpp" @@ -10,7 +10,7 @@ #include "singletons/Emotes.hpp" #include "util/LayoutCreator.hpp" #include "widgets/listview/GenericListView.hpp" -#include "widgets/splits/EmoteInputItem.hpp" +#include "widgets/splits/InputCompletionItem.hpp" namespace chatterino { namespace { @@ -41,7 +41,7 @@ namespace { } } // namespace -EmoteInputPopup::EmoteInputPopup(QWidget *parent) +InputCompletionPopup::InputCompletionPopup(QWidget *parent) : BasePopup({BasePopup::EnableCustomFrame, BasePopup::Frameless, BasePopup::DontFocus}, parent) @@ -56,7 +56,7 @@ EmoteInputPopup::EmoteInputPopup(QWidget *parent) this->redrawTimer_.setInterval(33); } -void EmoteInputPopup::initLayout() +void InputCompletionPopup::initLayout() { LayoutCreator creator = {this}; @@ -71,7 +71,7 @@ void EmoteInputPopup::initLayout() }); } -void EmoteInputPopup::updateEmotes(const QString &text, ChannelPtr channel) +void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel) { std::vector<_Emote> emotes; auto tc = dynamic_cast(channel.get()); @@ -122,11 +122,11 @@ void EmoteInputPopup::updateEmotes(const QString &text, ChannelPtr channel) int count = 0; for (auto &&emote : emotes) { - this->model_.addItem(std::make_unique( + this->model_.addItem(std::make_unique( emote.emote, emote.displayName + " - " + emote.providerName, this->callback_)); - if (count++ == maxEmoteCount) + if (count++ == maxEntryCount) break; } @@ -136,22 +136,45 @@ void EmoteInputPopup::updateEmotes(const QString &text, ChannelPtr channel) } } -bool EmoteInputPopup::eventFilter(QObject *watched, QEvent *event) +void InputCompletionPopup::updateUsers(const QString &text, ChannelPtr channel) +{ + auto twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel) + { + auto chatters = twitchChannel->accessChatters()->filterByPrefix(text); + this->model_.clear(); + int count = 0; + for (const auto &name : chatters) + { + this->model_.addItem(std::make_unique( + nullptr, name, this->callback_)); + + if (count++ == maxEntryCount) + break; + } + if (!chatters.empty()) + { + this->ui_.listView->setCurrentIndex(this->model_.index(0)); + } + } +} + +bool InputCompletionPopup::eventFilter(QObject *watched, QEvent *event) { return this->ui_.listView->eventFilter(watched, event); } -void EmoteInputPopup::setInputAction(ActionCallback callback) +void InputCompletionPopup::setInputAction(ActionCallback callback) { this->callback_ = std::move(callback); } -void EmoteInputPopup::showEvent(QShowEvent *) +void InputCompletionPopup::showEvent(QShowEvent *) { this->redrawTimer_.start(); } -void EmoteInputPopup::hideEvent(QHideEvent *) +void InputCompletionPopup::hideEvent(QHideEvent *) { this->redrawTimer_.stop(); } diff --git a/src/widgets/splits/EmoteInputPopup.hpp b/src/widgets/splits/InputCompletionPopup.hpp similarity index 78% rename from src/widgets/splits/EmoteInputPopup.hpp rename to src/widgets/splits/InputCompletionPopup.hpp index 1d8c7e735..6329a8a64 100644 --- a/src/widgets/splits/EmoteInputPopup.hpp +++ b/src/widgets/splits/InputCompletionPopup.hpp @@ -9,16 +9,17 @@ namespace chatterino { class GenericListView; -class EmoteInputPopup : public BasePopup +class InputCompletionPopup : public BasePopup { using ActionCallback = std::function; - constexpr static int maxEmoteCount = 200; + constexpr static int maxEntryCount = 200; public: - EmoteInputPopup(QWidget *parent = nullptr); + InputCompletionPopup(QWidget *parent = nullptr); void updateEmotes(const QString &text, ChannelPtr channel); + void updateUsers(const QString &text, ChannelPtr channel); virtual bool eventFilter(QObject *, QEvent *event) override; void setInputAction(ActionCallback callback); diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index f9fbb6f53..91be55d62 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -15,7 +15,7 @@ #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/EffectLabel.hpp" #include "widgets/helper/ResizingTextEdit.hpp" -#include "widgets/splits/EmoteInputPopup.hpp" +#include "widgets/splits/InputCompletionPopup.hpp" #include "widgets/splits/Split.hpp" #include "widgets/splits/SplitContainer.hpp" #include "widgets/splits/SplitInput.hpp" @@ -45,7 +45,7 @@ SplitInput::SplitInput(Split *_chatWidget) // misc this->installKeyPressedEvent(); this->ui_.textEdit->focusLost.connect([this] { - this->hideColonMenu(); + this->hideCompletionPopup(); }); this->scaleChangedEvent(this->scale()); } @@ -202,7 +202,7 @@ void SplitInput::installKeyPressedEvent() auto app = getApp(); this->ui_.textEdit->keyPressed.connect([this, app](QKeyEvent *event) { - if (auto popup = this->emoteInputPopup_.get()) + if (auto popup = this->inputCompletionPopup_.get()) { if (popup->isVisible()) { @@ -451,26 +451,30 @@ void SplitInput::installKeyPressedEvent() void SplitInput::onTextChanged() { - this->updateColonMenu(); + this->updateCompletionPopup(); } void SplitInput::onCursorPositionChanged() { - this->updateColonMenu(); + this->updateCompletionPopup(); } -void SplitInput::updateColonMenu() +void SplitInput::updateCompletionPopup() { auto channel = this->split_->getChannel().get(); - if (!getSettings()->emoteCompletionWithColon || - (!dynamic_cast(channel) && - !(channel->getType() == Channel::Type::TwitchWhispers))) + auto tc = dynamic_cast(channel); + bool showEmoteCompletion = + getSettings()->emoteCompletionWithColon && + (tc || (channel->getType() == Channel::Type::TwitchWhispers)); + bool showUsernameCompletion = + tc && getSettings()->showUsernameCompletionMenu; + if (!showEmoteCompletion && !showUsernameCompletion) { - this->hideColonMenu(); + this->hideCompletionPopup(); return; } - // check if in : + // check if in completion prefix auto &edit = *this->ui_.textEdit; auto text = edit.toPlainText(); @@ -478,7 +482,7 @@ void SplitInput::updateColonMenu() if (text.length() == 0) { - this->hideColonMenu(); + this->hideCompletionPopup(); return; } @@ -486,41 +490,54 @@ void SplitInput::updateColonMenu() { if (text[i] == ' ') { - this->hideColonMenu(); + this->hideCompletionPopup(); return; } - else if (text[i] == ':') + else if (text[i] == ':' && showEmoteCompletion) { if (i == 0 || text[i - 1].isSpace()) - this->showColonMenu(text.mid(i, position - i + 1).mid(1)); + this->showCompletionPopup(text.mid(i, position - i + 1).mid(1), + true); else - this->hideColonMenu(); + this->hideCompletionPopup(); + return; + } + else if (text[i] == '@' && showUsernameCompletion) + { + if (i == 0 || text[i - 1].isSpace()) + this->showCompletionPopup(text.mid(i, position - i + 1).mid(1), + false); + else + this->hideCompletionPopup(); return; } } - this->hideColonMenu(); + this->hideCompletionPopup(); } -void SplitInput::showColonMenu(const QString &text) +void SplitInput::showCompletionPopup(const QString &text, bool emoteCompletion) { - if (!this->emoteInputPopup_.get()) + if (!this->inputCompletionPopup_.get()) { - this->emoteInputPopup_ = new EmoteInputPopup(this); - this->emoteInputPopup_->setInputAction( + this->inputCompletionPopup_ = new InputCompletionPopup(this); + this->inputCompletionPopup_->setInputAction( [that = QObjectRef(this)](const QString &text) mutable { if (auto this2 = that.get()) { - this2->insertColonText(text); - this2->hideColonMenu(); + this2->insertCompletionText(text); + this2->hideCompletionPopup(); } }); } - auto popup = this->emoteInputPopup_.get(); + auto popup = this->inputCompletionPopup_.get(); assert(popup); - popup->updateEmotes(text, this->split_->getChannel()); + if (emoteCompletion) // autocomplete emotes + popup->updateEmotes(text, this->split_->getChannel()); + else // autocomplete usernames + popup->updateUsers(text, this->split_->getChannel()); auto pos = this->mapToGlobal({0, 0}) - QPoint(0, popup->height()) + QPoint((this->width() - popup->width()) / 2, 0); @@ -529,13 +546,13 @@ void SplitInput::showColonMenu(const QString &text) popup->show(); } -void SplitInput::hideColonMenu() +void SplitInput::hideCompletionPopup() { - if (auto popup = this->emoteInputPopup_.get()) + if (auto popup = this->inputCompletionPopup_.get()) popup->hide(); } -void SplitInput::insertColonText(const QString &input_) +void SplitInput::insertCompletionText(const QString &input_) { auto &edit = *this->ui_.textEdit; auto input = input_ + ' '; @@ -545,10 +562,21 @@ void SplitInput::insertColonText(const QString &input_) for (int i = clamp(position, 0, text.length() - 1); i >= 0; i--) { + bool done = false; if (text[i] == ':') { - auto cursor = edit.textCursor(); + done = true; + } + else if (text[i] == '@') + { + input = "@" + input_ + + (getSettings()->mentionUsersWithComma ? ", " : " "); + done = true; + } + if (done) + { + auto cursor = edit.textCursor(); edit.setText(text.remove(i, position - i).insert(i, input)); cursor.setPosition(i + input.size()); diff --git a/src/widgets/splits/SplitInput.hpp b/src/widgets/splits/SplitInput.hpp index 7e97aa6e3..52e9b015d 100644 --- a/src/widgets/splits/SplitInput.hpp +++ b/src/widgets/splits/SplitInput.hpp @@ -16,7 +16,7 @@ namespace chatterino { class Split; class EmotePopup; -class EmoteInputPopup; +class InputCompletionPopup; class EffectLabel; class ResizingTextEdit; @@ -48,15 +48,15 @@ private: void onCursorPositionChanged(); void onTextChanged(); void updateEmoteButton(); - void updateColonMenu(); - void showColonMenu(const QString &text); - void hideColonMenu(); - void insertColonText(const QString &text); + void updateCompletionPopup(); + void showCompletionPopup(const QString &text, bool emoteCompletion); + void hideCompletionPopup(); + void insertCompletionText(const QString &text); void openEmotePopup(); Split *const split_; QObjectRef emotePopup_; - QObjectRef emoteInputPopup_; + QObjectRef inputCompletionPopup_; struct { ResizingTextEdit *textEdit;