From 37009e8e6bf318dbafff9cac169fad5ea82e1f24 Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Sun, 24 Sep 2023 08:17:17 -0400 Subject: [PATCH] Consolidate input completion code in preparation for advanced completion strategies (#4639) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/CMakeLists.txt | 24 +- src/common/Channel.cpp | 2 +- src/common/Channel.hpp | 4 +- src/common/ChatterSet.cpp | 5 + src/common/ChatterSet.hpp | 4 + src/common/CompletionModel.cpp | 287 ------------------ src/common/CompletionModel.hpp | 75 ----- .../completion/CompletionModel.cpp | 34 +++ .../completion/CompletionModel.hpp | 44 +++ .../completion/TabCompletionModel.cpp | 119 ++++++++ .../completion/TabCompletionModel.hpp | 55 ++++ .../completion/sources/CommandSource.cpp | 101 ++++++ .../completion/sources/CommandSource.hpp | 50 +++ .../completion/sources/EmoteSource.cpp | 152 ++++++++++ .../completion/sources/EmoteSource.hpp | 63 ++++ .../completion/sources/Helpers.hpp | 50 +++ src/controllers/completion/sources/Source.hpp | 47 +++ .../completion/sources/UnifiedSource.cpp | 80 +++++ .../completion/sources/UnifiedSource.hpp | 42 +++ .../completion/sources/UserSource.cpp | 69 +++++ .../completion/sources/UserSource.hpp | 53 ++++ .../strategies/ClassicEmoteStrategy.cpp | 85 ++++++ .../strategies/ClassicEmoteStrategy.hpp | 22 ++ .../strategies/ClassicUserStrategy.cpp | 23 ++ .../strategies/ClassicUserStrategy.hpp | 15 + .../completion/strategies/CommandStrategy.cpp | 39 +++ .../completion/strategies/CommandStrategy.hpp | 21 ++ .../completion/strategies/Strategy.hpp | 27 ++ src/util/Helpers.cpp | 13 + src/util/Helpers.hpp | 2 + src/widgets/dialogs/EmotePopup.cpp | 5 +- src/widgets/helper/ResizingTextEdit.cpp | 15 +- src/widgets/helper/ResizingTextEdit.hpp | 1 - src/widgets/splits/InputCompletionPopup.cpp | 215 +++---------- src/widgets/splits/InputCompletionPopup.hpp | 36 +-- src/widgets/splits/SplitInput.cpp | 21 +- src/widgets/splits/SplitInput.hpp | 3 +- tests/src/InputCompletion.cpp | 75 +++-- 39 files changed, 1358 insertions(+), 621 deletions(-) delete mode 100644 src/common/CompletionModel.cpp delete mode 100644 src/common/CompletionModel.hpp create mode 100644 src/controllers/completion/CompletionModel.cpp create mode 100644 src/controllers/completion/CompletionModel.hpp create mode 100644 src/controllers/completion/TabCompletionModel.cpp create mode 100644 src/controllers/completion/TabCompletionModel.hpp create mode 100644 src/controllers/completion/sources/CommandSource.cpp create mode 100644 src/controllers/completion/sources/CommandSource.hpp create mode 100644 src/controllers/completion/sources/EmoteSource.cpp create mode 100644 src/controllers/completion/sources/EmoteSource.hpp create mode 100644 src/controllers/completion/sources/Helpers.hpp create mode 100644 src/controllers/completion/sources/Source.hpp create mode 100644 src/controllers/completion/sources/UnifiedSource.cpp create mode 100644 src/controllers/completion/sources/UnifiedSource.hpp create mode 100644 src/controllers/completion/sources/UserSource.cpp create mode 100644 src/controllers/completion/sources/UserSource.hpp create mode 100644 src/controllers/completion/strategies/ClassicEmoteStrategy.cpp create mode 100644 src/controllers/completion/strategies/ClassicEmoteStrategy.hpp create mode 100644 src/controllers/completion/strategies/ClassicUserStrategy.cpp create mode 100644 src/controllers/completion/strategies/ClassicUserStrategy.hpp create mode 100644 src/controllers/completion/strategies/CommandStrategy.cpp create mode 100644 src/controllers/completion/strategies/CommandStrategy.hpp create mode 100644 src/controllers/completion/strategies/Strategy.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c09025..34d073cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Dev: Refactor `Image` & Image's `Frames`. (#4773) - Dev: Add `WindowManager::getLastSelectedWindow()` to replace `getMainWindow()`. (#4816) - Dev: Clarify signal connection lifetimes where applicable. (#4818) +- Dev: Laid the groundwork for advanced input completion strategies. (#4639) ## 2.4.5 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5e30ffd2..c2909e14 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -24,8 +24,6 @@ set(SOURCE_FILES common/ChatterinoSetting.hpp common/ChatterSet.cpp common/ChatterSet.hpp - common/CompletionModel.cpp - common/CompletionModel.hpp common/Credentials.cpp common/Credentials.hpp common/DownloadManager.cpp @@ -77,6 +75,28 @@ set(SOURCE_FILES controllers/commands/CommandModel.cpp controllers/commands/CommandModel.hpp + controllers/completion/CompletionModel.cpp + controllers/completion/CompletionModel.hpp + controllers/completion/sources/Source.hpp + controllers/completion/sources/CommandSource.cpp + controllers/completion/sources/CommandSource.hpp + controllers/completion/sources/EmoteSource.cpp + controllers/completion/sources/EmoteSource.hpp + controllers/completion/sources/Helpers.hpp + controllers/completion/sources/UnifiedSource.cpp + controllers/completion/sources/UnifiedSource.hpp + controllers/completion/sources/UserSource.cpp + controllers/completion/sources/UserSource.hpp + controllers/completion/strategies/Strategy.hpp + controllers/completion/strategies/ClassicEmoteStrategy.cpp + controllers/completion/strategies/ClassicEmoteStrategy.hpp + controllers/completion/strategies/ClassicUserStrategy.cpp + controllers/completion/strategies/ClassicUserStrategy.hpp + controllers/completion/strategies/CommandStrategy.cpp + controllers/completion/strategies/CommandStrategy.hpp + controllers/completion/TabCompletionModel.cpp + controllers/completion/TabCompletionModel.hpp + controllers/filters/FilterModel.cpp controllers/filters/FilterModel.hpp controllers/filters/FilterRecord.cpp diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index 96ab49ef..8e731bf4 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -26,7 +26,7 @@ namespace chatterino { // Channel // Channel::Channel(const QString &name, Type type) - : completionModel(*this) + : completionModel(*this, nullptr) , lastDate_(QDate::currentDate()) , name_(name) , messages_(getSettings()->scrollbackSplitLimit) diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 40c97306..81347c1a 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -1,7 +1,7 @@ #pragma once -#include "common/CompletionModel.hpp" #include "common/FlagsEnum.hpp" +#include "controllers/completion/TabCompletionModel.hpp" #include "messages/LimitedQueue.hpp" #include @@ -108,7 +108,7 @@ public: static std::shared_ptr getEmpty(); - CompletionModel completionModel; + TabCompletionModel completionModel; QDate lastDate_; protected: diff --git a/src/common/ChatterSet.cpp b/src/common/ChatterSet.cpp index 46875b45..2b6c21a5 100644 --- a/src/common/ChatterSet.cpp +++ b/src/common/ChatterSet.cpp @@ -56,4 +56,9 @@ std::vector ChatterSet::filterByPrefix(const QString &prefix) const return result; } +std::vector> ChatterSet::all() const +{ + return {this->items.begin(), this->items.end()}; +} + } // namespace chatterino diff --git a/src/common/ChatterSet.hpp b/src/common/ChatterSet.hpp index 7483aa69..f1345f43 100644 --- a/src/common/ChatterSet.hpp +++ b/src/common/ChatterSet.hpp @@ -39,6 +39,10 @@ public: /// are in mixed case if available. std::vector filterByPrefix(const QString &prefix) const; + /// Get all recent chatters. The first pair element contains the username + /// in lowercase, while the second pair element is the original case. + std::vector> all() const; + private: // user name in lower case -> user name in normal case cache::lru_cache items; diff --git a/src/common/CompletionModel.cpp b/src/common/CompletionModel.cpp deleted file mode 100644 index 61f1cc24..00000000 --- a/src/common/CompletionModel.cpp +++ /dev/null @@ -1,287 +0,0 @@ -#include "common/CompletionModel.hpp" - -#include "Application.hpp" -#include "common/ChatterSet.hpp" -#include "controllers/accounts/AccountController.hpp" -#include "controllers/commands/Command.hpp" -#include "controllers/commands/CommandController.hpp" -#include "messages/Emote.hpp" -#include "providers/twitch/TwitchAccount.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchCommon.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" -#include "singletons/Emotes.hpp" -#include "singletons/Settings.hpp" -#include "util/Helpers.hpp" -#include "util/QStringHash.hpp" - -#include - -#include - -namespace chatterino { - -// -// TaggedString -// - -CompletionModel::TaggedString::TaggedString(QString _string, Type _type) - : string(std::move(_string)) - , type(_type) -{ -} - -bool CompletionModel::TaggedString::isEmote() const -{ - return this->type > Type::EmoteStart && this->type < Type::EmoteEnd; -} - -bool CompletionModel::TaggedString::operator<(const TaggedString &that) const -{ - if (this->isEmote() != that.isEmote()) - { - return this->isEmote(); - } - - return CompletionModel::compareStrings(this->string, that.string); -} - -// -// CompletionModel -// -CompletionModel::CompletionModel(Channel &channel) - : channel_(channel) -{ -} - -int CompletionModel::columnCount(const QModelIndex &parent) const -{ - (void)parent; // unused - - return 1; -} - -QVariant CompletionModel::data(const QModelIndex &index, int role) const -{ - (void)role; // unused - - std::shared_lock lock(this->itemsMutex_); - - auto it = this->items_.begin(); - std::advance(it, index.row()); - return {it->string}; -} - -int CompletionModel::rowCount(const QModelIndex &parent) const -{ - (void)parent; // unused - - std::shared_lock lock(this->itemsMutex_); - - return this->items_.size(); -} - -void CompletionModel::refresh(const QString &prefix, bool isFirstWord) -{ - std::unique_lock lock(this->itemsMutex_); - - this->items_.clear(); - - if (prefix.length() < 2 || !this->channel_.isTwitchChannel()) - { - return; - } - - auto *app = getIApp(); - // Twitch channel - auto *tc = dynamic_cast(&this->channel_); - - auto addString = [=, this](const QString &str, TaggedString::Type type) { - // Special case for handling default Twitch commands - if (type == TaggedString::TwitchCommand) - { - if (prefix.size() < 2) - { - return; - } - - auto prefixChar = prefix.at(0); - - static std::set validPrefixChars{'/', '.'}; - - if (validPrefixChars.find(prefixChar) == validPrefixChars.end()) - { - return; - } - - if (startsWithOrContains((prefixChar + str), prefix, - Qt::CaseInsensitive, - getSettings()->prefixOnlyEmoteCompletion)) - { - this->items_.emplace((prefixChar + str + " "), type); - } - - return; - } - - if (startsWithOrContains(str, prefix, Qt::CaseInsensitive, - getSettings()->prefixOnlyEmoteCompletion)) - { - this->items_.emplace(str + " ", type); - } - }; - - if (auto account = app->getAccounts()->twitch.getCurrent()) - { - // Twitch Emotes available globally - for (const auto &emote : account->accessEmotes()->emotes) - { - addString(emote.first.string, TaggedString::TwitchGlobalEmote); - } - - // Twitch Emotes available locally - auto localEmoteData = account->accessLocalEmotes(); - if (tc != nullptr && - localEmoteData->find(tc->roomId()) != localEmoteData->end()) - { - for (const auto &emote : localEmoteData->at(tc->roomId())) - { - addString(emote.first.string, - TaggedString::Type::TwitchLocalEmote); - } - } - } - - // 7TV Global - for (const auto &emote : - *app->getTwitch()->getSeventvEmotes().globalEmotes()) - { - addString(emote.first.string, TaggedString::Type::SeventvGlobalEmote); - } - // Bttv Global - for (const auto &emote : *app->getTwitch()->getBttvEmotes().emotes()) - { - addString(emote.first.string, TaggedString::Type::BTTVChannelEmote); - } - - // Ffz Global - for (const auto &emote : *app->getTwitch()->getFfzEmotes().emotes()) - { - addString(emote.first.string, TaggedString::Type::FFZChannelEmote); - } - - // Emojis - if (prefix.startsWith(":")) - { - const auto &emojiShortCodes = - app->getEmotes()->getEmojis()->getShortCodes(); - for (const auto &m : emojiShortCodes) - { - addString(QString(":%1:").arg(m), TaggedString::Type::Emoji); - } - } - - // - // Stuff below is available only in regular Twitch channels - if (tc == nullptr) - { - return; - } - - // Usernames - if (prefix.startsWith("@")) - { - QString usernamePrefix = prefix; - usernamePrefix.remove(0, 1); - - auto chatters = tc->accessChatters()->filterByPrefix(usernamePrefix); - - for (const auto &name : chatters) - { - addString( - "@" + formatUserMention(name, isFirstWord, - getSettings()->mentionUsersWithComma), - TaggedString::Type::Username); - } - } - else if (!getSettings()->userCompletionOnlyWithAt) - { - auto chatters = tc->accessChatters()->filterByPrefix(prefix); - - for (const auto &name : chatters) - { - addString(formatUserMention(name, isFirstWord, - getSettings()->mentionUsersWithComma), - TaggedString::Type::Username); - } - } - - // 7TV Channel - for (const auto &emote : *tc->seventvEmotes()) - { - addString(emote.first.string, TaggedString::Type::SeventvChannelEmote); - } - // Bttv Channel - for (const auto &emote : *tc->bttvEmotes()) - { - addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote); - } - - // Ffz Channel - for (const auto &emote : *tc->ffzEmotes()) - { - addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote); - } -#ifdef CHATTERINO_HAVE_PLUGINS - for (const auto &command : app->getCommands()->pluginCommands()) - { - addString(command, TaggedString::PluginCommand); - } -#endif - // Custom Chatterino commands - for (const auto &command : app->getCommands()->items) - { - addString(command.name, TaggedString::CustomCommand); - } - - // Default Chatterino commands - for (const auto &command : - app->getCommands()->getDefaultChatterinoCommandList()) - { - addString(command, TaggedString::ChatterinoCommand); - } - - // Default Twitch commands - for (const auto &command : TWITCH_DEFAULT_COMMANDS) - { - addString(command, TaggedString::TwitchCommand); - } -} - -std::vector CompletionModel::allItems() const -{ - std::shared_lock lock(this->itemsMutex_); - - std::vector results; - results.reserve(this->items_.size()); - for (const auto &item : this->items_) - { - results.push_back(item.string); - } - return results; -} - -bool CompletionModel::compareStrings(const QString &a, const QString &b) -{ - // try comparing insensitively, if they are the same then senstively - // (fixes order of LuL and LUL) - int k = QString::compare(a, b, Qt::CaseInsensitive); - if (k == 0) - { - return a > b; - } - - return k < 0; -} - -} // namespace chatterino diff --git a/src/common/CompletionModel.hpp b/src/common/CompletionModel.hpp deleted file mode 100644 index affbd3f1..00000000 --- a/src/common/CompletionModel.hpp +++ /dev/null @@ -1,75 +0,0 @@ -#pragma once - -#include - -#include -#include -#include - -class InputCompletionTest; - -namespace chatterino { - -class Channel; - -class CompletionModel : public QAbstractListModel -{ - struct TaggedString { - enum Type { - Username, - - // emotes - EmoteStart, - FFZGlobalEmote, - FFZChannelEmote, - BTTVGlobalEmote, - BTTVChannelEmote, - SeventvGlobalEmote, - SeventvChannelEmote, - TwitchGlobalEmote, - TwitchLocalEmote, - TwitchSubscriberEmote, - Emoji, - EmoteEnd, - // end emotes - - CustomCommand, - ChatterinoCommand, - TwitchCommand, -#ifdef CHATTERINO_HAVE_PLUGINS - PluginCommand, -#endif - }; - - TaggedString(QString _string, Type type); - - bool isEmote() const; - bool operator<(const TaggedString &that) const; - - const QString string; - const Type type; - }; - -public: - CompletionModel(Channel &channel); - - int columnCount(const QModelIndex &parent) const override; - QVariant data(const QModelIndex &index, int role) const override; - int rowCount(const QModelIndex &parent) const override; - - void refresh(const QString &prefix, bool isFirstWord = false); - - static bool compareStrings(const QString &a, const QString &b); - -private: - std::vector allItems() const; - - mutable std::shared_mutex itemsMutex_; - std::set items_; - - Channel &channel_; - - friend class ::InputCompletionTest; -}; - -} // namespace chatterino diff --git a/src/controllers/completion/CompletionModel.cpp b/src/controllers/completion/CompletionModel.cpp new file mode 100644 index 00000000..ef19688c --- /dev/null +++ b/src/controllers/completion/CompletionModel.cpp @@ -0,0 +1,34 @@ +#include "controllers/completion/CompletionModel.hpp" + +#include "controllers/completion/sources/Source.hpp" + +namespace chatterino { + +CompletionModel::CompletionModel(QObject *parent) + : GenericListModel(parent) +{ +} + +void CompletionModel::setSource(std::unique_ptr source) +{ + this->source_ = std::move(source); +} + +bool CompletionModel::hasSource() const +{ + return this->source_ != nullptr; +} + +void CompletionModel::updateResults(const QString &query, size_t maxCount) +{ + if (this->source_) + { + this->source_->update(query); + + // Copy results to this model + this->clear(); + this->source_->addToListModel(*this, maxCount); + } +} + +} // namespace chatterino diff --git a/src/controllers/completion/CompletionModel.hpp b/src/controllers/completion/CompletionModel.hpp new file mode 100644 index 00000000..f787a965 --- /dev/null +++ b/src/controllers/completion/CompletionModel.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "widgets/listview/GenericListModel.hpp" + +#include +#include + +namespace chatterino { + +namespace completion { + class Source; +} // namespace completion + +/// @brief Represents the kind of completion occurring +enum class CompletionKind { + Emote, + User, +}; + +/// @brief CompletionModel is a GenericListModel intended to provide completion +/// suggestions to an InputCompletionPopup. The popup can determine the appropriate +/// source based on the current input and the user's preferences. +class CompletionModel final : public GenericListModel +{ +public: + explicit CompletionModel(QObject *parent); + + /// @brief Sets the Source for subsequent queries + /// @param source Source to use + void setSource(std::unique_ptr source); + + /// @return Whether the model has a source set + bool hasSource() const; + + /// @brief Updates the model based on the completion query + /// @param query Completion query + /// @param maxCount Maximum number of results. Zero indicates unlimited. + void updateResults(const QString &query, size_t maxCount = 0); + +private: + std::unique_ptr source_{}; +}; + +}; // namespace chatterino diff --git a/src/controllers/completion/TabCompletionModel.cpp b/src/controllers/completion/TabCompletionModel.cpp new file mode 100644 index 00000000..b4f77b78 --- /dev/null +++ b/src/controllers/completion/TabCompletionModel.cpp @@ -0,0 +1,119 @@ +#include "controllers/completion/TabCompletionModel.hpp" + +#include "common/Channel.hpp" +#include "controllers/completion/sources/CommandSource.hpp" +#include "controllers/completion/sources/EmoteSource.hpp" +#include "controllers/completion/sources/UnifiedSource.hpp" +#include "controllers/completion/sources/UserSource.hpp" +#include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" +#include "controllers/completion/strategies/ClassicUserStrategy.hpp" +#include "controllers/completion/strategies/CommandStrategy.hpp" +#include "singletons/Settings.hpp" + +namespace chatterino { + +TabCompletionModel::TabCompletionModel(Channel &channel, QObject *parent) + : QStringListModel(parent) + , channel_(channel) +{ +} + +void TabCompletionModel::updateResults(const QString &query, bool isFirstWord) +{ + this->updateSourceFromQuery(query); + + if (this->source_) + { + this->source_->update(query); + + // Copy results to this model + QStringList results; + this->source_->addToStringList(results, 0, isFirstWord); + this->setStringList(results); + } +} + +void TabCompletionModel::updateSourceFromQuery(const QString &query) +{ + auto deducedKind = this->deduceSourceKind(query); + if (!deducedKind) + { + // unable to determine what kind of completion is occurring + this->sourceKind_ = std::nullopt; + this->source_ = nullptr; + return; + } + + if (this->sourceKind_ == *deducedKind) + { + // Source already properly configured + return; + } + + this->sourceKind_ = *deducedKind; + this->source_ = this->buildSource(*deducedKind); +} + +std::optional + TabCompletionModel::deduceSourceKind(const QString &query) const +{ + if (query.length() < 2 || !this->channel_.isTwitchChannel()) + { + return std::nullopt; + } + + // Check for cases where we can definitively say what kind of completion is taking place. + + if (query.startsWith('@')) + { + return SourceKind::User; + } + else if (query.startsWith(':')) + { + return SourceKind::Emote; + } + else if (query.startsWith('/') || query.startsWith('.')) + { + return SourceKind::Command; + } + + // At this point, we note that emotes can be completed without using a : + // Therefore, we must also consider that the user could be completing an emote + // OR a mention depending on their completion settings. + + if (getSettings()->userCompletionOnlyWithAt) + { + return SourceKind::Emote; + } + + // Either is possible, use unified source + return SourceKind::EmoteAndUser; +} + +std::unique_ptr TabCompletionModel::buildSource( + SourceKind kind) const +{ + switch (kind) + { + case SourceKind::Emote: + return std::make_unique( + &this->channel_, + std::make_unique()); + case SourceKind::User: + return std::make_unique( + &this->channel_, + std::make_unique()); + case SourceKind::Command: + return std::make_unique( + std::make_unique(true)); + case SourceKind::EmoteAndUser: + return std::make_unique( + &this->channel_, + std::make_unique(), + std::make_unique()); + default: + return nullptr; + } +} + +} // namespace chatterino diff --git a/src/controllers/completion/TabCompletionModel.hpp b/src/controllers/completion/TabCompletionModel.hpp new file mode 100644 index 00000000..20473b6f --- /dev/null +++ b/src/controllers/completion/TabCompletionModel.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "controllers/completion/sources/Source.hpp" + +#include +#include +#include + +#include + +namespace chatterino { + +class Channel; + +/// @brief TabCompletionModel is a QStringListModel intended to provide tab +/// completion to a ResizingTextInput. The model automatically selects a completion +/// source based on the current query before updating the results. +class TabCompletionModel : public QStringListModel +{ +public: + /// @brief Initializes a new TabCompletionModel bound to a Channel. + /// The reference to the Channel must live as long as the TabCompletionModel. + /// @param channel Channel reference + /// @param parent Model parent + explicit TabCompletionModel(Channel &channel, QObject *parent); + + /// @brief Updates the model based on the completion query + /// @param query Completion query + /// @param isFirstWord Whether the completion is the first word in the input + void updateResults(const QString &query, bool isFirstWord = false); + +private: + enum class SourceKind { Emote, User, Command, EmoteAndUser }; + + /// @brief Updates the internal completion source based on the current query. + /// The completion source will only change if the deduced completion kind + /// changes (see deduceSourceKind). + /// @param query Completion query + void updateSourceFromQuery(const QString &query); + + /// @brief Attempts to deduce the source kind from the current query. If the + /// bound Channel is not a TwitchChannel or if the query is too short, no + /// query type will be deduced to prevent completions. + /// @param query Completion query + /// @return An optional SourceKind deduced from the query + std::optional deduceSourceKind(const QString &query) const; + + std::unique_ptr buildSource(SourceKind kind) const; + + Channel &channel_; + std::unique_ptr source_{}; + std::optional sourceKind_{}; +}; + +} // namespace chatterino diff --git a/src/controllers/completion/sources/CommandSource.cpp b/src/controllers/completion/sources/CommandSource.cpp new file mode 100644 index 00000000..b114969b --- /dev/null +++ b/src/controllers/completion/sources/CommandSource.cpp @@ -0,0 +1,101 @@ +#include "controllers/completion/sources/CommandSource.hpp" + +#include "Application.hpp" +#include "controllers/commands/Command.hpp" +#include "controllers/commands/CommandController.hpp" +#include "controllers/completion/sources/Helpers.hpp" +#include "providers/twitch/TwitchCommon.hpp" + +namespace chatterino::completion { + +namespace { + + void addCommand(const QString &command, std::vector &out) + { + if (command.startsWith('/') || command.startsWith('.')) + { + out.push_back({command.mid(1), command.at(0)}); + } + else + { + out.push_back({command, '/'}); + } + } + +} // namespace + +CommandSource::CommandSource(std::unique_ptr strategy, + ActionCallback callback) + : strategy_(std::move(strategy)) + , callback_(std::move(callback)) +{ + this->initializeItems(); +} + +void CommandSource::update(const QString &query) +{ + this->output_.clear(); + if (this->strategy_) + { + this->strategy_->apply(this->items_, this->output_, query); + } +} + +void CommandSource::addToListModel(GenericListModel &model, + size_t maxCount) const +{ + addVecToListModel(this->output_, model, maxCount, + [this](const CommandItem &command) { + return std::make_unique( + nullptr, command.name, this->callback_); + }); +} + +void CommandSource::addToStringList(QStringList &list, size_t maxCount, + bool /* isFirstWord */) const +{ + addVecToStringList(this->output_, list, maxCount, + [](const CommandItem &command) { + return command.prefix + command.name + " "; + }); +} + +void CommandSource::initializeItems() +{ + std::vector commands; + +#ifdef CHATTERINO_HAVE_PLUGINS + for (const auto &command : getApp()->commands->pluginCommands()) + { + addCommand(command, commands); + } +#endif + + // Custom Chatterino commands + for (const auto &command : getIApp()->getCommands()->items) + { + addCommand(command.name, commands); + } + + // Default Chatterino commands + auto x = getIApp()->getCommands()->getDefaultChatterinoCommandList(); + for (const auto &command : x) + { + addCommand(command, commands); + } + + // Default Twitch commands + for (const auto &command : TWITCH_DEFAULT_COMMANDS) + { + addCommand(command, commands); + } + + this->items_ = std::move(commands); +} + +const std::vector &CommandSource::output() const +{ + return this->output_; +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/CommandSource.hpp b/src/controllers/completion/sources/CommandSource.hpp new file mode 100644 index 00000000..66c2a54c --- /dev/null +++ b/src/controllers/completion/sources/CommandSource.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "controllers/completion/sources/Source.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +#include + +#include +#include +#include + +namespace chatterino::completion { + +struct CommandItem { + QString name{}; + QChar prefix{}; +}; + +class CommandSource : public Source +{ +public: + using ActionCallback = std::function; + using CommandStrategy = Strategy; + + /// @brief Initializes a source for CommandItems. + /// @param strategy Strategy to apply + /// @param callback ActionCallback to invoke upon InputCompletionItem selection. + /// See InputCompletionItem::action(). Can be nullptr. + CommandSource(std::unique_ptr strategy, + ActionCallback callback = nullptr); + + void update(const QString &query) override; + void addToListModel(GenericListModel &model, + size_t maxCount = 0) const override; + void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const override; + + const std::vector &output() const; + +private: + void initializeItems(); + + std::unique_ptr strategy_; + ActionCallback callback_; + + std::vector items_{}; + std::vector output_{}; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/EmoteSource.cpp b/src/controllers/completion/sources/EmoteSource.cpp new file mode 100644 index 00000000..95d2f32f --- /dev/null +++ b/src/controllers/completion/sources/EmoteSource.cpp @@ -0,0 +1,152 @@ +#include "controllers/completion/sources/EmoteSource.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/completion/sources/Helpers.hpp" +#include "providers/emoji/Emojis.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Emotes.hpp" + +namespace chatterino::completion { + +namespace { + + void addEmotes(std::vector &out, const EmoteMap &map, + const QString &providerName) + { + for (auto &&emote : map) + { + out.push_back({.emote = emote.second, + .searchName = emote.first.string, + .tabCompletionName = emote.first.string, + .displayName = emote.second->name.string, + .providerName = providerName, + .isEmoji = false}); + } + } + + void addEmojis(std::vector &out, const EmojiMap &map) + { + map.each([&](const QString &, const std::shared_ptr &emoji) { + for (auto &&shortCode : emoji->shortCodes) + { + out.push_back( + {.emote = emoji->emote, + .searchName = shortCode, + .tabCompletionName = QStringLiteral(":%1:").arg(shortCode), + .displayName = shortCode, + .providerName = "Emoji", + .isEmoji = true}); + } + }); + } + +} // namespace + +EmoteSource::EmoteSource(const Channel *channel, + std::unique_ptr strategy, + ActionCallback callback) + : strategy_(std::move(strategy)) + , callback_(std::move(callback)) +{ + this->initializeFromChannel(channel); +} + +void EmoteSource::update(const QString &query) +{ + this->output_.clear(); + if (this->strategy_) + { + this->strategy_->apply(this->items_, this->output_, query); + } +} + +void EmoteSource::addToListModel(GenericListModel &model, size_t maxCount) const +{ + addVecToListModel(this->output_, model, maxCount, + [this](const EmoteItem &e) { + return std::make_unique( + e.emote, e.displayName + " - " + e.providerName, + this->callback_); + }); +} + +void EmoteSource::addToStringList(QStringList &list, size_t maxCount, + bool /* isFirstWord */) const +{ + addVecToStringList(this->output_, list, maxCount, [](const EmoteItem &e) { + return e.tabCompletionName + " "; + }); +} + +void EmoteSource::initializeFromChannel(const Channel *channel) +{ + auto *app = getIApp(); + + std::vector emotes; + const auto *tc = dynamic_cast(channel); + // returns true also for special Twitch channels (/live, /mentions, /whispers, etc.) + if (channel->isTwitchChannel()) + { + if (auto user = app->getAccounts()->twitch.getCurrent()) + { + // Twitch Emotes available globally + auto emoteData = user->accessEmotes(); + addEmotes(emotes, emoteData->emotes, "Twitch Emote"); + + // Twitch Emotes available locally + auto localEmoteData = user->accessLocalEmotes(); + if ((tc != nullptr) && + localEmoteData->find(tc->roomId()) != localEmoteData->end()) + { + if (const auto *localEmotes = &localEmoteData->at(tc->roomId())) + { + addEmotes(emotes, *localEmotes, "Local Twitch Emotes"); + } + } + } + + if (tc) + { + // TODO extract "Channel {BetterTTV,7TV,FrankerFaceZ}" text into a #define. + if (auto bttv = tc->bttvEmotes()) + { + addEmotes(emotes, *bttv, "Channel BetterTTV"); + } + if (auto ffz = tc->ffzEmotes()) + { + addEmotes(emotes, *ffz, "Channel FrankerFaceZ"); + } + if (auto seventv = tc->seventvEmotes()) + { + addEmotes(emotes, *seventv, "Channel 7TV"); + } + } + + if (auto bttvG = app->getTwitch()->getBttvEmotes().emotes()) + { + addEmotes(emotes, *bttvG, "Global BetterTTV"); + } + if (auto ffzG = app->getTwitch()->getFfzEmotes().emotes()) + { + addEmotes(emotes, *ffzG, "Global FrankerFaceZ"); + } + if (auto seventvG = app->getTwitch()->getSeventvEmotes().globalEmotes()) + { + addEmotes(emotes, *seventvG, "Global 7TV"); + } + } + + addEmojis(emotes, app->getEmotes()->getEmojis()->getEmojis()); + + this->items_ = std::move(emotes); +} + +const std::vector &EmoteSource::output() const +{ + return this->output_; +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/EmoteSource.hpp b/src/controllers/completion/sources/EmoteSource.hpp new file mode 100644 index 00000000..4f61fbc2 --- /dev/null +++ b/src/controllers/completion/sources/EmoteSource.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "common/Channel.hpp" +#include "controllers/completion/sources/Source.hpp" +#include "controllers/completion/strategies/Strategy.hpp" +#include "messages/Emote.hpp" + +#include + +#include +#include +#include + +namespace chatterino::completion { + +struct EmoteItem { + /// Emote image to show in input popup + EmotePtr emote{}; + /// Name to check completion queries against + QString searchName{}; + /// Name to insert into split input upon tab completing + QString tabCompletionName{}; + /// Display name within input popup + QString displayName{}; + /// Emote provider name for input popup + QString providerName{}; + /// Whether emote is emoji + bool isEmoji{}; +}; + +class EmoteSource : public Source +{ +public: + using ActionCallback = std::function; + using EmoteStrategy = Strategy; + + /// @brief Initializes a source for EmoteItems from the given channel + /// @param channel Channel to initialize emotes from + /// @param strategy Strategy to apply + /// @param callback ActionCallback to invoke upon InputCompletionItem selection. + /// See InputCompletionItem::action(). Can be nullptr. + EmoteSource(const Channel *channel, std::unique_ptr strategy, + ActionCallback callback = nullptr); + + void update(const QString &query) override; + void addToListModel(GenericListModel &model, + size_t maxCount = 0) const override; + void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const override; + + const std::vector &output() const; + +private: + void initializeFromChannel(const Channel *channel); + + std::unique_ptr strategy_; + ActionCallback callback_; + + std::vector items_{}; + std::vector output_{}; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/Helpers.hpp b/src/controllers/completion/sources/Helpers.hpp new file mode 100644 index 00000000..74198f14 --- /dev/null +++ b/src/controllers/completion/sources/Helpers.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "widgets/listview/GenericListModel.hpp" + +#include + +#include + +namespace chatterino::completion { + +namespace { + + size_t sizeWithinLimit(size_t size, size_t limit) + { + if (limit == 0) + { + return size; + } + return std::min(size, limit); + } + +} // namespace + +template +void addVecToListModel(const std::vector &input, GenericListModel &model, + size_t maxCount, Mapper mapper) +{ + const size_t count = sizeWithinLimit(input.size(), maxCount); + model.reserve(model.rowCount() + count); + + for (size_t i = 0; i < count; ++i) + { + model.addItem(mapper(input[i])); + } +} + +template +void addVecToStringList(const std::vector &input, QStringList &list, + size_t maxCount, Mapper mapper) +{ + const size_t count = sizeWithinLimit(input.size(), maxCount); + list.reserve(list.count() + count); + + for (size_t i = 0; i < count; ++i) + { + list.push_back(mapper(input[i])); + } +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/Source.hpp b/src/controllers/completion/sources/Source.hpp new file mode 100644 index 00000000..b78ce06e --- /dev/null +++ b/src/controllers/completion/sources/Source.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "widgets/listview/GenericListModel.hpp" +#include "widgets/splits/InputCompletionItem.hpp" + +#include + +#include +#include +#include + +namespace chatterino::completion { + +/// @brief A Source represents a source for generating completion suggestions. +/// +/// The source can be queried to update its suggestions and then write the completion +/// suggestions to a GenericListModel or QStringList depending on the consumer's +/// requirements. +/// +/// For example, consider providing emotes for completion. The Source instance +/// initialized with every available emote in the channel (including global +/// emotes). As the user updates their query by typing, the suggestions are +/// refined and the output model is updated. +class Source +{ +public: + virtual ~Source() = default; + + /// @brief Updates the internal completion suggestions for the given query + /// @param query Query to complete against + virtual void update(const QString &query) = 0; + + /// @brief Appends the internal completion suggestions to a GenericListModel + /// @param model GenericListModel to add suggestions to + /// @param maxCount Maximum number of suggestions. Zero indicates unlimited. + virtual void addToListModel(GenericListModel &model, + size_t maxCount = 0) const = 0; + + /// @brief Appends the internal completion suggestions to a QStringList + /// @param list QStringList to add suggestions to + /// @param maxCount Maximum number of suggestions. Zero indicates unlimited. + /// @param isFirstWord Whether the completion is the first word in the input + virtual void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const = 0; +}; + +}; // namespace chatterino::completion diff --git a/src/controllers/completion/sources/UnifiedSource.cpp b/src/controllers/completion/sources/UnifiedSource.cpp new file mode 100644 index 00000000..e68c860b --- /dev/null +++ b/src/controllers/completion/sources/UnifiedSource.cpp @@ -0,0 +1,80 @@ +#include "controllers/completion/sources/UnifiedSource.hpp" + +namespace chatterino::completion { + +UnifiedSource::UnifiedSource( + const Channel *channel, + std::unique_ptr emoteStrategy, + std::unique_ptr userStrategy, + ActionCallback callback) + : emoteSource_(channel, std::move(emoteStrategy), callback) + , usersSource_(channel, std::move(userStrategy), callback, + false) // disable adding @ to front +{ +} + +void UnifiedSource::update(const QString &query) +{ + this->emoteSource_.update(query); + this->usersSource_.update(query); +} + +void UnifiedSource::addToListModel(GenericListModel &model, + size_t maxCount) const +{ + if (maxCount == 0) + { + this->emoteSource_.addToListModel(model, 0); + this->usersSource_.addToListModel(model, 0); + return; + } + + // Otherwise, make sure to only add maxCount elements in total. We prioritize + // accepting results from the emote source before the users source (arbitrarily). + + int startingSize = model.rowCount(); + + // Add up to maxCount elements + this->emoteSource_.addToListModel(model, maxCount); + + int used = model.rowCount() - startingSize; + if (used >= maxCount) + { + // Used up our limit on emotes + return; + } + + // Only add maxCount - used to ensure the total added doesn't exceed maxCount + this->usersSource_.addToListModel(model, maxCount - used); +} + +void UnifiedSource::addToStringList(QStringList &list, size_t maxCount, + bool isFirstWord) const +{ + if (maxCount == 0) + { + this->emoteSource_.addToStringList(list, 0, isFirstWord); + this->usersSource_.addToStringList(list, 0, isFirstWord); + return; + } + + // Otherwise, make sure to only add maxCount elements in total. We prioritize + // accepting results from the emote source before the users source (arbitrarily). + + int startingSize = list.size(); + + // Add up to maxCount elements + this->emoteSource_.addToStringList(list, maxCount, isFirstWord); + + int used = list.size() - startingSize; + if (used >= maxCount) + { + // Used up our limit on emotes + return; + } + + // Only add maxCount - used to ensure the total added doesn't exceed maxCount + this->usersSource_.addToStringList(list, maxCount - used, isFirstWord); +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/UnifiedSource.hpp b/src/controllers/completion/sources/UnifiedSource.hpp new file mode 100644 index 00000000..15a0f12f --- /dev/null +++ b/src/controllers/completion/sources/UnifiedSource.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "common/Channel.hpp" +#include "controllers/completion/sources/EmoteSource.hpp" +#include "controllers/completion/sources/Source.hpp" +#include "controllers/completion/sources/UserSource.hpp" + +#include +#include + +namespace chatterino::completion { + +class UnifiedSource : public Source +{ +public: + using ActionCallback = std::function; + + /// @brief Initializes a unified completion source for the given channel. + /// Resolves both emotes and usernames for autocompletion. + /// @param channel Channel to initialize emotes and users from. Must be a + /// TwitchChannel or completion is a no-op. + /// @param emoteStrategy Strategy for selecting emotes + /// @param userStrategy Strategy for selecting users + /// @param callback ActionCallback to invoke upon InputCompletionItem selection. + /// See InputCompletionItem::action(). Can be nullptr. + UnifiedSource(const Channel *channel, + std::unique_ptr emoteStrategy, + std::unique_ptr userStrategy, + ActionCallback callback = nullptr); + + void update(const QString &query) override; + void addToListModel(GenericListModel &model, + size_t maxCount = 0) const override; + void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const override; + +private: + EmoteSource emoteSource_; + UserSource usersSource_; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/UserSource.cpp b/src/controllers/completion/sources/UserSource.cpp new file mode 100644 index 00000000..46b16bcd --- /dev/null +++ b/src/controllers/completion/sources/UserSource.cpp @@ -0,0 +1,69 @@ +#include "controllers/completion/sources/UserSource.hpp" + +#include "controllers/completion/sources/Helpers.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "singletons/Settings.hpp" +#include "util/Helpers.hpp" + +namespace chatterino::completion { + +UserSource::UserSource(const Channel *channel, + std::unique_ptr strategy, + ActionCallback callback, bool prependAt) + : strategy_(std::move(strategy)) + , callback_(std::move(callback)) + , prependAt_(prependAt) +{ + this->initializeFromChannel(channel); +} + +void UserSource::update(const QString &query) +{ + this->output_.clear(); + if (this->strategy_) + { + this->strategy_->apply(this->items_, this->output_, query); + } +} + +void UserSource::addToListModel(GenericListModel &model, size_t maxCount) const +{ + addVecToListModel(this->output_, model, maxCount, + [this](const UserItem &user) { + return std::make_unique( + nullptr, user.second, this->callback_); + }); +} + +void UserSource::addToStringList(QStringList &list, size_t maxCount, + bool isFirstWord) const +{ + bool mentionComma = getSettings()->mentionUsersWithComma; + addVecToStringList(this->output_, list, maxCount, + [this, isFirstWord, mentionComma](const UserItem &user) { + const auto userMention = formatUserMention( + user.second, isFirstWord, mentionComma); + QString strTemplate = this->prependAt_ + ? QStringLiteral("@%1 ") + : QStringLiteral("%1 "); + return strTemplate.arg(userMention); + }); +} + +void UserSource::initializeFromChannel(const Channel *channel) +{ + const auto *tc = dynamic_cast(channel); + if (!tc) + { + return; + } + + this->items_ = tc->accessChatters()->all(); +} + +const std::vector &UserSource::output() const +{ + return this->output_; +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/UserSource.hpp b/src/controllers/completion/sources/UserSource.hpp new file mode 100644 index 00000000..316b33c7 --- /dev/null +++ b/src/controllers/completion/sources/UserSource.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "common/Channel.hpp" +#include "controllers/completion/sources/Source.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +#include + +#include +#include +#include +#include + +namespace chatterino::completion { + +using UserItem = std::pair; + +class UserSource : public Source +{ +public: + using ActionCallback = std::function; + using UserStrategy = Strategy; + + /// @brief Initializes a source for UserItems from the given channel. + /// @param channel Channel to initialize users from. Must be a TwitchChannel + /// or completion is a no-op. + /// @param strategy Strategy to apply + /// @param callback ActionCallback to invoke upon InputCompletionItem selection. + /// See InputCompletionItem::action(). Can be nullptr. + /// @param prependAt Whether to prepend @ to string completion suggestions. + UserSource(const Channel *channel, std::unique_ptr strategy, + ActionCallback callback = nullptr, bool prependAt = true); + + void update(const QString &query) override; + void addToListModel(GenericListModel &model, + size_t maxCount = 0) const override; + void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const override; + + const std::vector &output() const; + +private: + void initializeFromChannel(const Channel *channel); + + std::unique_ptr strategy_; + ActionCallback callback_; + bool prependAt_; + + std::vector items_{}; + std::vector output_{}; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp b/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp new file mode 100644 index 00000000..1a427483 --- /dev/null +++ b/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp @@ -0,0 +1,85 @@ +#include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" + +#include "singletons/Settings.hpp" +#include "util/Helpers.hpp" + +namespace chatterino::completion { + +void ClassicEmoteStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + QString normalizedQuery = query; + if (normalizedQuery.startsWith(':')) + { + normalizedQuery = normalizedQuery.mid(1); + } + + // First pass: filter by contains match + for (const auto &item : items) + { + if (item.searchName.contains(normalizedQuery, Qt::CaseInsensitive)) + { + output.push_back(item); + } + } + + // Second pass: if there is an exact match, put that emote first + for (size_t i = 1; i < output.size(); i++) + { + auto emoteText = output.at(i).searchName; + + // test for match or match with colon at start for emotes like ":)" + if (emoteText.compare(normalizedQuery, Qt::CaseInsensitive) == 0 || + emoteText.compare(":" + normalizedQuery, Qt::CaseInsensitive) == 0) + { + auto emote = output[i]; + output.erase(output.begin() + int(i)); + output.insert(output.begin(), emote); + break; + } + } +} + +struct CompletionEmoteOrder { + bool operator()(const EmoteItem &a, const EmoteItem &b) const + { + return compareEmoteStrings(a.searchName, b.searchName); + } +}; + +void ClassicTabEmoteStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + bool emojiOnly = false; + QString normalizedQuery = query; + if (normalizedQuery.startsWith(':')) + { + normalizedQuery = normalizedQuery.mid(1); + // tab completion with : prefix should do emojis only + emojiOnly = true; + } + + std::set emotes; + + for (const auto &item : items) + { + if (emojiOnly ^ item.isEmoji) + { + continue; + } + + if (startsWithOrContains(item.searchName, normalizedQuery, + Qt::CaseInsensitive, + getSettings()->prefixOnlyEmoteCompletion)) + { + emotes.insert(item); + } + } + + output.reserve(emotes.size()); + output.assign(emotes.begin(), emotes.end()); +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/ClassicEmoteStrategy.hpp b/src/controllers/completion/strategies/ClassicEmoteStrategy.hpp new file mode 100644 index 00000000..d231c8ac --- /dev/null +++ b/src/controllers/completion/strategies/ClassicEmoteStrategy.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "controllers/completion/sources/EmoteSource.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +namespace chatterino::completion { + +class ClassicEmoteStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +class ClassicTabEmoteStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/ClassicUserStrategy.cpp b/src/controllers/completion/strategies/ClassicUserStrategy.cpp new file mode 100644 index 00000000..1f002ed5 --- /dev/null +++ b/src/controllers/completion/strategies/ClassicUserStrategy.cpp @@ -0,0 +1,23 @@ +#include "controllers/completion/strategies/ClassicUserStrategy.hpp" + +namespace chatterino::completion { + +void ClassicUserStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + QString lowerQuery = query.toLower(); + if (lowerQuery.startsWith('@')) + { + lowerQuery = lowerQuery.mid(1); + } + + for (const auto &item : items) + { + if (item.first.startsWith(lowerQuery)) + { + output.push_back(item); + } + } +} +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/ClassicUserStrategy.hpp b/src/controllers/completion/strategies/ClassicUserStrategy.hpp new file mode 100644 index 00000000..7575d127 --- /dev/null +++ b/src/controllers/completion/strategies/ClassicUserStrategy.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include "controllers/completion/sources/UserSource.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +namespace chatterino::completion { + +class ClassicUserStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/CommandStrategy.cpp b/src/controllers/completion/strategies/CommandStrategy.cpp new file mode 100644 index 00000000..f5aad454 --- /dev/null +++ b/src/controllers/completion/strategies/CommandStrategy.cpp @@ -0,0 +1,39 @@ +#include "controllers/completion/strategies/CommandStrategy.hpp" + +namespace chatterino::completion { + +CommandStrategy::CommandStrategy(bool startsWithOnly) + : startsWithOnly_(startsWithOnly) +{ +} + +void CommandStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + QString normalizedQuery = query; + if (normalizedQuery.startsWith('/') || normalizedQuery.startsWith('.')) + { + normalizedQuery = normalizedQuery.mid(1); + } + + if (startsWithOnly_) + { + std::copy_if(items.begin(), items.end(), + std::back_insert_iterator(output), + [&normalizedQuery](const CommandItem &item) { + return item.name.startsWith(normalizedQuery, + Qt::CaseInsensitive); + }); + } + else + { + std::copy_if( + items.begin(), items.end(), std::back_insert_iterator(output), + [&normalizedQuery](const CommandItem &item) { + return item.name.contains(normalizedQuery, Qt::CaseInsensitive); + }); + } +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/CommandStrategy.hpp b/src/controllers/completion/strategies/CommandStrategy.hpp new file mode 100644 index 00000000..4f9ca126 --- /dev/null +++ b/src/controllers/completion/strategies/CommandStrategy.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "controllers/completion/sources/CommandSource.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +namespace chatterino::completion { + +class CommandStrategy : public Strategy +{ +public: + CommandStrategy(bool startsWithOnly); + + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; + +private: + bool startsWithOnly_; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/Strategy.hpp b/src/controllers/completion/strategies/Strategy.hpp new file mode 100644 index 00000000..d1056991 --- /dev/null +++ b/src/controllers/completion/strategies/Strategy.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include + +namespace chatterino::completion { + +/// @brief An Strategy implements ordering and filtering of completion items in +/// response to a query. +/// @tparam T Type of items to consider +template +class Strategy +{ +public: + virtual ~Strategy() = default; + + /// @brief Applies the strategy, taking the input items and storing the + /// appropriate output items in the desired order. + /// @param items Input items to consider + /// @param output Output vector for items + /// @param query Completion query + virtual void apply(const std::vector &items, std::vector &output, + const QString &query) const = 0; +}; + +} // namespace chatterino::completion diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index d780910a..1329c3cd 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -271,4 +271,17 @@ int64_t parseDurationToSeconds(const QString &inputString, return (int64_t)currentValue; } +bool compareEmoteStrings(const QString &a, const QString &b) +{ + // try comparing insensitively, if they are the same then sensitively + // (fixes order of LuL and LUL) + int k = QString::compare(a, b, Qt::CaseInsensitive); + if (k == 0) + { + return a > b; + } + + return k < 0; +} + } // namespace chatterino diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index b2b64d7e..f066f7a9 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -153,4 +153,6 @@ std::vector splitListIntoBatches(const T &list, int batchSize = 100) return batches; } +bool compareEmoteStrings(const QString &a, const QString &b); + } // namespace chatterino diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index 9a5121f8..66d9f2df 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -1,7 +1,6 @@ #include "EmotePopup.hpp" #include "Application.hpp" -#include "common/CompletionModel.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/hotkeys/HotkeyController.hpp" @@ -16,6 +15,7 @@ #include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" #include "singletons/WindowManager.hpp" +#include "util/Helpers.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/TrimRegExpValidator.hpp" #include "widgets/Notebook.hpp" @@ -58,8 +58,7 @@ auto makeEmoteMessage(const EmoteMap &map, const MessageElementFlag &emoteFlag) std::sort(vec.begin(), vec.end(), [](const std::pair &l, const std::pair &r) { - return CompletionModel::compareStrings(l.first.string, - r.first.string); + return compareEmoteStrings(l.first.string, r.first.string); }); for (const auto &emote : vec) { diff --git a/src/widgets/helper/ResizingTextEdit.cpp b/src/widgets/helper/ResizingTextEdit.cpp index aa93299f..1f400ee4 100644 --- a/src/widgets/helper/ResizingTextEdit.cpp +++ b/src/widgets/helper/ResizingTextEdit.cpp @@ -1,8 +1,8 @@ #include "widgets/helper/ResizingTextEdit.hpp" #include "common/Common.hpp" -#include "common/CompletionModel.hpp" #include "common/QLogging.hpp" +#include "controllers/completion/TabCompletionModel.hpp" #include "singletons/Settings.hpp" #include @@ -160,16 +160,18 @@ void ResizingTextEdit::keyPressEvent(QKeyEvent *event) return; } + // always expected to be TabCompletionModel auto *completionModel = - static_cast(this->completer_->model()); + dynamic_cast(this->completer_->model()); + assert(completionModel != nullptr); if (!this->completionInProgress_) { // First type pressing tab after modifying a message, we refresh our // completion model this->completer_->setModel(completionModel); - completionModel->refresh(currentCompletionPrefix, - this->isFirstWord()); + completionModel->updateResults(currentCompletionPrefix, + this->isFirstWord()); this->completionInProgress_ = true; { // this blocks cursor movement events from resetting tab completion @@ -346,9 +348,4 @@ void ResizingTextEdit::insertFromMimeData(const QMimeData *source) insertPlainText(source->text()); } -QCompleter *ResizingTextEdit::getCompleter() const -{ - return this->completer_; -} - } // namespace chatterino diff --git a/src/widgets/helper/ResizingTextEdit.hpp b/src/widgets/helper/ResizingTextEdit.hpp index 01a174cd..6842a312 100644 --- a/src/widgets/helper/ResizingTextEdit.hpp +++ b/src/widgets/helper/ResizingTextEdit.hpp @@ -23,7 +23,6 @@ public: pajlada::Signals::Signal imagePasted; void setCompleter(QCompleter *c); - QCompleter *getCompleter() const; /** * Resets a completion for this text if one was is progress. * See `completionInProgress_`. diff --git a/src/widgets/splits/InputCompletionPopup.cpp b/src/widgets/splits/InputCompletionPopup.cpp index 81f330b5..2828551e 100644 --- a/src/widgets/splits/InputCompletionPopup.cpp +++ b/src/widgets/splits/InputCompletionPopup.cpp @@ -1,138 +1,12 @@ -#include "InputCompletionPopup.hpp" +#include "widgets/splits/InputCompletionPopup.hpp" -#include "Application.hpp" -#include "controllers/accounts/AccountController.hpp" -#include "messages/Emote.hpp" -#include "providers/bttv/BttvEmotes.hpp" -#include "providers/ffz/FfzEmotes.hpp" -#include "providers/seventv/SeventvEmotes.hpp" -#include "providers/twitch/TwitchAccount.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" -#include "singletons/Emotes.hpp" +#include "controllers/completion/sources/UserSource.hpp" +#include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" +#include "controllers/completion/strategies/ClassicUserStrategy.hpp" #include "singletons/Theme.hpp" #include "util/LayoutCreator.hpp" -#include "widgets/listview/GenericListView.hpp" #include "widgets/splits/InputCompletionItem.hpp" -namespace { - -using namespace chatterino; -using namespace chatterino::detail; - -void addEmotes(std::vector &out, const EmoteMap &map, - const QString &text, const QString &providerName) -{ - for (auto &&emote : map) - { - if (emote.first.string.contains(text, Qt::CaseInsensitive)) - { - out.push_back( - {emote.second, emote.second->name.string, providerName}); - } - } -} - -void addEmojis(std::vector &out, const EmojiMap &map, - const QString &text) -{ - map.each([&](const QString &, const std::shared_ptr &emoji) { - for (auto &&shortCode : emoji->shortCodes) - { - if (shortCode.contains(text, Qt::CaseInsensitive)) - { - out.push_back({emoji->emote, shortCode, "Emoji"}); - } - } - }); -} - -} // namespace - -namespace chatterino::detail { - -std::vector buildCompletionEmoteList(const QString &text, - ChannelPtr channel) -{ - std::vector emotes; - auto *app = getIApp(); - auto *tc = dynamic_cast(channel.get()); - // returns true also for special Twitch channels (/live, /mentions, /whispers, etc.) - if (channel->isTwitchChannel()) - { - if (auto user = app->getAccounts()->twitch.getCurrent()) - { - // Twitch Emotes available globally - auto emoteData = user->accessEmotes(); - addEmotes(emotes, emoteData->emotes, text, "Twitch Emote"); - - // Twitch Emotes available locally - auto localEmoteData = user->accessLocalEmotes(); - if (tc && - localEmoteData->find(tc->roomId()) != localEmoteData->end()) - { - if (const auto *localEmotes = &localEmoteData->at(tc->roomId())) - { - addEmotes(emotes, *localEmotes, text, - "Local Twitch Emotes"); - } - } - } - - if (tc) - { - // TODO extract "Channel {BetterTTV,7TV,FrankerFaceZ}" text into a #define. - if (auto bttv = tc->bttvEmotes()) - { - addEmotes(emotes, *bttv, text, "Channel BetterTTV"); - } - if (auto ffz = tc->ffzEmotes()) - { - addEmotes(emotes, *ffz, text, "Channel FrankerFaceZ"); - } - if (auto seventv = tc->seventvEmotes()) - { - addEmotes(emotes, *seventv, text, "Channel 7TV"); - } - } - - if (auto bttvG = app->getTwitch()->getBttvEmotes().emotes()) - { - addEmotes(emotes, *bttvG, text, "Global BetterTTV"); - } - if (auto ffzG = app->getTwitch()->getFfzEmotes().emotes()) - { - addEmotes(emotes, *ffzG, text, "Global FrankerFaceZ"); - } - if (auto seventvG = app->getTwitch()->getSeventvEmotes().globalEmotes()) - { - addEmotes(emotes, *seventvG, text, "Global 7TV"); - } - } - - addEmojis(emotes, app->getEmotes()->getEmojis()->getEmojis(), text); - - // if there is an exact match, put that emote first - for (size_t i = 1; i < emotes.size(); i++) - { - auto emoteText = emotes.at(i).displayName; - - // test for match or match with colon at start for emotes like ":)" - if (emoteText.compare(text, Qt::CaseInsensitive) == 0 || - emoteText.compare(":" + text, Qt::CaseInsensitive) == 0) - { - auto emote = emotes[i]; - emotes.erase(emotes.begin() + int(i)); - emotes.insert(emotes.begin(), emote); - break; - } - } - - return emotes; -} - -} // namespace chatterino::detail - namespace chatterino { InputCompletionPopup::InputCompletionPopup(QWidget *parent) @@ -153,60 +27,66 @@ InputCompletionPopup::InputCompletionPopup(QWidget *parent) this->redrawTimer_.setInterval(33); } -void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel) +void InputCompletionPopup::updateCompletion(const QString &text, + CompletionKind kind, + ChannelPtr channel) { - auto emotes = detail::buildCompletionEmoteList(text, std::move(channel)); - - this->model_.clear(); - - int count = 0; - for (auto &&emote : emotes) + if (this->currentKind_ != kind || this->currentChannel_ != channel) { - this->model_.addItem(std::make_unique( - emote.emote, emote.displayName + " - " + emote.providerName, - this->callback_)); - - if (count++ == MAX_ENTRY_COUNT) - { - break; - } + // New completion context + this->beginCompletion(kind, std::move(channel)); } - if (!emotes.empty()) + assert(this->model_.hasSource()); + this->model_.updateResults(text, MAX_ENTRY_COUNT); + + // Move selection to top row + if (this->model_.rowCount() != 0) { this->ui_.listView->setCurrentIndex(this->model_.index(0)); } } -void InputCompletionPopup::updateUsers(const QString &text, ChannelPtr channel) +std::unique_ptr InputCompletionPopup::getSource() const { - auto *tc = dynamic_cast(channel.get()); - if (!tc) + assert(this->currentChannel_ != nullptr); + + if (!this->currentKind_) { - return; + return nullptr; } - auto chatters = tc->accessChatters()->filterByPrefix(text); - this->model_.clear(); - - if (chatters.empty()) + // Currently, strategies are hard coded. + switch (*this->currentKind_) { - return; + case CompletionKind::Emote: + return std::make_unique( + this->currentChannel_.get(), + std::make_unique(), + this->callback_); + case CompletionKind::User: + return std::make_unique( + this->currentChannel_.get(), + std::make_unique(), + this->callback_); + default: + return nullptr; } +} - int count = 0; - for (const auto &name : chatters) - { - this->model_.addItem(std::make_unique( - nullptr, name, this->callback_)); +void InputCompletionPopup::beginCompletion(CompletionKind kind, + ChannelPtr channel) +{ + this->currentKind_ = kind; + this->currentChannel_ = std::move(channel); + this->model_.setSource(this->getSource()); +} - if (count++ == MAX_ENTRY_COUNT) - { - break; - } - } - - this->ui_.listView->setCurrentIndex(this->model_.index(0)); +void InputCompletionPopup::endCompletion() +{ + this->currentKind_ = std::nullopt; + this->currentChannel_ = nullptr; + this->model_.setSource(nullptr); } void InputCompletionPopup::setInputAction(ActionCallback callback) @@ -227,6 +107,7 @@ void InputCompletionPopup::showEvent(QShowEvent * /*event*/) void InputCompletionPopup::hideEvent(QHideEvent * /*event*/) { this->redrawTimer_.stop(); + this->endCompletion(); } void InputCompletionPopup::themeChangedEvent() diff --git a/src/widgets/splits/InputCompletionPopup.hpp b/src/widgets/splits/InputCompletionPopup.hpp index 4709878e..f5fb4139 100644 --- a/src/widgets/splits/InputCompletionPopup.hpp +++ b/src/widgets/splits/InputCompletionPopup.hpp @@ -1,10 +1,13 @@ #pragma once +#include "controllers/completion/CompletionModel.hpp" +#include "controllers/completion/sources/Source.hpp" #include "widgets/BasePopup.hpp" -#include "widgets/listview/GenericListModel.hpp" +#include "widgets/listview/GenericListView.hpp" #include #include +#include #include namespace chatterino { @@ -12,35 +15,19 @@ namespace chatterino { class Channel; using ChannelPtr = std::shared_ptr; -struct Emote; -using EmotePtr = std::shared_ptr; - -namespace detail { - - struct CompletionEmote { - EmotePtr emote; - QString displayName; - QString providerName; - }; - - std::vector buildCompletionEmoteList(const QString &text, - ChannelPtr channel); - -} // namespace detail - class GenericListView; class InputCompletionPopup : public BasePopup { using ActionCallback = std::function; - constexpr static int MAX_ENTRY_COUNT = 200; + constexpr static size_t MAX_ENTRY_COUNT = 200; public: InputCompletionPopup(QWidget *parent = nullptr); - void updateEmotes(const QString &text, ChannelPtr channel); - void updateUsers(const QString &text, ChannelPtr channel); + void updateCompletion(const QString &text, CompletionKind kind, + ChannelPtr channel); void setInputAction(ActionCallback callback); @@ -54,14 +41,21 @@ protected: private: void initLayout(); + void beginCompletion(CompletionKind kind, ChannelPtr channel); + void endCompletion(); + + std::unique_ptr getSource() const; struct { GenericListView *listView; } ui_{}; - GenericListModel model_; + CompletionModel model_; ActionCallback callback_; QTimer redrawTimer_; + + std::optional currentKind_{}; + ChannelPtr currentChannel_{}; }; } // namespace chatterino diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index 1d9553df..5c92c99f 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -51,7 +51,7 @@ SplitInput::SplitInput(QWidget *parent, Split *_chatWidget, this->initLayout(); auto completer = - new QCompleter(&this->split_->getChannel().get()->completionModel); + new QCompleter(&this->split_->getChannel()->completionModel); this->ui_.textEdit->setCompleter(completer); this->signalHolder_.managedConnect(this->split_->channelChanged, [this] { @@ -746,8 +746,8 @@ void SplitInput::updateCompletionPopup() { if (i == 0 || text[i - 1].isSpace()) { - this->showCompletionPopup(text.mid(i, position - i + 1).mid(1), - true); + this->showCompletionPopup(text.mid(i, position - i + 1), + CompletionKind::Emote); } else { @@ -760,8 +760,8 @@ void SplitInput::updateCompletionPopup() { if (i == 0 || text[i - 1].isSpace()) { - this->showCompletionPopup(text.mid(i, position - i + 1).mid(1), - false); + this->showCompletionPopup(text.mid(i, position - i + 1), + CompletionKind::User); } else { @@ -774,7 +774,7 @@ void SplitInput::updateCompletionPopup() this->hideCompletionPopup(); } -void SplitInput::showCompletionPopup(const QString &text, bool emoteCompletion) +void SplitInput::showCompletionPopup(const QString &text, CompletionKind kind) { if (this->inputCompletionPopup_.isNull()) { @@ -792,14 +792,7 @@ void SplitInput::showCompletionPopup(const QString &text, bool emoteCompletion) auto *popup = this->inputCompletionPopup_.data(); assert(popup); - if (emoteCompletion) - { - popup->updateEmotes(text, this->split_->getChannel()); - } - else - { - popup->updateUsers(text, this->split_->getChannel()); - } + popup->updateCompletion(text, kind, this->split_->getChannel()); auto pos = this->mapToGlobal(QPoint{0, 0}) - QPoint(0, popup->height()) + QPoint((this->width() - popup->width()) / 2, 0); diff --git a/src/widgets/splits/SplitInput.hpp b/src/widgets/splits/SplitInput.hpp index eec7fab8..5a15ebbd 100644 --- a/src/widgets/splits/SplitInput.hpp +++ b/src/widgets/splits/SplitInput.hpp @@ -22,6 +22,7 @@ class EffectLabel; class MessageThread; class ResizingTextEdit; class ChannelView; +enum class CompletionKind; class SplitInput : public BaseWidget { @@ -99,7 +100,7 @@ protected: void onTextChanged(); void updateEmoteButton(); void updateCompletionPopup(); - void showCompletionPopup(const QString &text, bool emoteCompletion); + void showCompletionPopup(const QString &text, CompletionKind kind); void hideCompletionPopup(); void insertCompletionText(const QString &input_) const; void openEmotePopup(); diff --git a/tests/src/InputCompletion.cpp b/tests/src/InputCompletion.cpp index c1489059..aadfc463 100644 --- a/tests/src/InputCompletion.cpp +++ b/tests/src/InputCompletion.cpp @@ -1,7 +1,9 @@ #include "Application.hpp" #include "common/Aliases.hpp" -#include "common/CompletionModel.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" +#include "controllers/completion/strategies/ClassicUserStrategy.hpp" +#include "controllers/completion/strategies/Strategy.hpp" #include "messages/Emote.hpp" #include "mocks/EmptyApplication.hpp" #include "mocks/Helix.hpp" @@ -24,6 +26,7 @@ namespace { using namespace chatterino; +using namespace chatterino::completion; using ::testing::Exactly; class MockTwitchIrcServer : public ITwitchIrcServer @@ -147,8 +150,6 @@ protected: this->mockApplication->emotes.initialize(*this->settings, *this->paths); this->channelPtr = std::make_shared("icelys"); - this->completionModel = - std::make_unique(*this->channelPtr); this->initializeEmotes(); } @@ -159,7 +160,6 @@ protected: this->settings.reset(); this->paths.reset(); this->mockHelix.reset(); - this->completionModel.reset(); this->channelPtr.reset(); this->settingsDir_.reset(); @@ -173,7 +173,6 @@ protected: std::unique_ptr mockHelix; ChannelPtr channelPtr; - std::unique_ptr completionModel; private: void initializeEmotes() @@ -205,28 +204,29 @@ private: } protected: - auto queryEmoteCompletion(const QString &fullQuery) + auto queryClassicEmoteCompletion(const QString &fullQuery) { - // At the moment, buildCompletionEmoteList does not want the ':'. - QString normalizedQuery = fullQuery; - if (normalizedQuery.startsWith(':')) - { - normalizedQuery = normalizedQuery.mid(1); - } + EmoteSource source(this->channelPtr.get(), + std::make_unique()); + source.update(fullQuery); - return chatterino::detail::buildCompletionEmoteList(normalizedQuery, - this->channelPtr); + std::vector out(source.output()); + return out; } - auto queryTabCompletion(const QString &fullQuery, bool isFirstWord) + auto queryClassicTabCompletion(const QString &fullQuery, bool isFirstWord) { - this->completionModel->refresh(fullQuery, isFirstWord); - return this->completionModel->allItems(); + EmoteSource source(this->channelPtr.get(), + std::make_unique()); + source.update(fullQuery); + + QStringList m; + source.addToStringList(m, 0, isFirstWord); + return m; } }; -void containsRoughly(std::span span, - std::set values) +void containsRoughly(std::span span, std::set values) { for (const auto &v : values) { @@ -244,27 +244,26 @@ void containsRoughly(std::span span, } } -TEST_F(InputCompletionTest, EmoteNameFiltering) +TEST_F(InputCompletionTest, ClassicEmoteNameFiltering) { // The completion doesn't guarantee an ordering for a specific category of emotes. // This tests a specific implementation of the underlying std::unordered_map, // so depending on the standard library used when compiling, this might yield // different results. - auto completion = queryEmoteCompletion(":feels"); + auto completion = queryClassicEmoteCompletion(":feels"); ASSERT_EQ(completion.size(), 3); // all these matches are BTTV global emotes ASSERT_EQ(completion[0].displayName, "FeelsBirthdayMan"); ASSERT_EQ(completion[1].displayName, "FeelsBadMan"); ASSERT_EQ(completion[2].displayName, "FeelsGoodMan"); - // all these matches are Twitch global emotes - completion = queryEmoteCompletion(":)"); + completion = queryClassicEmoteCompletion(":)"); ASSERT_EQ(completion.size(), 3); ASSERT_EQ(completion[0].displayName, ":)"); // Exact match with : prefix containsRoughly({completion.begin() + 1, 2}, {":-)", "B-)"}); - completion = queryEmoteCompletion(":cat"); + completion = queryClassicEmoteCompletion(":cat"); ASSERT_TRUE(completion.size() >= 2); // emoji exact match comes first ASSERT_EQ(completion[0].displayName, "cat"); @@ -272,9 +271,9 @@ TEST_F(InputCompletionTest, EmoteNameFiltering) ASSERT_EQ(completion[1].displayName, "CatBag"); } -TEST_F(InputCompletionTest, EmoteExactNameMatching) +TEST_F(InputCompletionTest, ClassicEmoteExactNameMatching) { - auto completion = queryEmoteCompletion(":cat"); + auto completion = queryClassicEmoteCompletion(":cat"); ASSERT_TRUE(completion.size() >= 2); // emoji exact match comes first ASSERT_EQ(completion[0].displayName, "cat"); @@ -282,22 +281,22 @@ TEST_F(InputCompletionTest, EmoteExactNameMatching) ASSERT_EQ(completion[1].displayName, "CatBag"); // not exactly "salt", SaltyCorn BTTV emote comes first - completion = queryEmoteCompletion(":sal"); + completion = queryClassicEmoteCompletion(":sal"); ASSERT_TRUE(completion.size() >= 3); ASSERT_EQ(completion[0].displayName, "SaltyCorn"); ASSERT_EQ(completion[1].displayName, "green_salad"); ASSERT_EQ(completion[2].displayName, "salt"); // exactly "salt", emoji comes first - completion = queryEmoteCompletion(":salt"); + completion = queryClassicEmoteCompletion(":salt"); ASSERT_TRUE(completion.size() >= 2); ASSERT_EQ(completion[0].displayName, "salt"); ASSERT_EQ(completion[1].displayName, "SaltyCorn"); } -TEST_F(InputCompletionTest, EmoteProviderOrdering) +TEST_F(InputCompletionTest, ClassicEmoteProviderOrdering) { - auto completion = queryEmoteCompletion(":clap"); + auto completion = queryClassicEmoteCompletion(":clap"); // Current implementation leads to the exact first match being ignored when // checking for exact matches. This is probably not intended behavior but // this test is just verifying that the implementation stays the same. @@ -324,13 +323,13 @@ TEST_F(InputCompletionTest, EmoteProviderOrdering) ASSERT_EQ(completion[4].providerName, "Emoji"); } -TEST_F(InputCompletionTest, TabCompletionEmote) +TEST_F(InputCompletionTest, ClassicTabCompletionEmote) { - auto completion = queryTabCompletion(":feels", false); + auto completion = queryClassicTabCompletion(":feels", false); ASSERT_EQ(completion.size(), 0); // : prefix matters here // no : prefix defaults to emote completion - completion = queryTabCompletion("feels", false); + completion = queryClassicTabCompletion("feels", false); ASSERT_EQ(completion.size(), 3); // note: different order from : menu ASSERT_EQ(completion[0], "FeelsBadMan "); @@ -338,22 +337,22 @@ TEST_F(InputCompletionTest, TabCompletionEmote) ASSERT_EQ(completion[2], "FeelsGoodMan "); // no : prefix, emote completion. Duplicate Clap should be removed - completion = queryTabCompletion("cla", false); + completion = queryClassicTabCompletion("cla", false); ASSERT_EQ(completion.size(), 2); ASSERT_EQ(completion[0], "Clap "); ASSERT_EQ(completion[1], "Clap2 "); - completion = queryTabCompletion("peepoHappy", false); + completion = queryClassicTabCompletion("peepoHappy", false); ASSERT_EQ(completion.size(), 0); // no peepoHappy emote - completion = queryTabCompletion("Aware", false); + completion = queryClassicTabCompletion("Aware", false); ASSERT_EQ(completion.size(), 1); ASSERT_EQ(completion[0], "Aware "); // trailing space added } -TEST_F(InputCompletionTest, TabCompletionEmoji) +TEST_F(InputCompletionTest, ClassicTabCompletionEmoji) { - auto completion = queryTabCompletion(":cla", false); + auto completion = queryClassicTabCompletion(":cla", false); ASSERT_EQ(completion.size(), 8); ASSERT_EQ(completion[0], ":clap: "); ASSERT_EQ(completion[1], ":clap_tone1: ");