diff --git a/CHANGELOG.md b/CHANGELOG.md index 57fd2f216..845d117b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) - Minor: The `/reply` command now replies to the latest message of the user. (#4919) - Minor: All sound capabilities can now be disabled by setting your "Sound backend" setting to "Null" and restarting Chatterino. (#4978) +- Minor: Add an option to use new experimental smarter emote completion. (#4987) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9b75d7e90..86c24c15b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -123,13 +123,15 @@ set(SOURCE_FILES 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/strategies/SmartEmoteStrategy.cpp + controllers/completion/strategies/SmartEmoteStrategy.cpp + controllers/completion/strategies/Strategy.hpp controllers/completion/TabCompletionModel.cpp controllers/completion/TabCompletionModel.hpp diff --git a/src/controllers/completion/TabCompletionModel.cpp b/src/controllers/completion/TabCompletionModel.cpp index 159e89f3d..74e826529 100644 --- a/src/controllers/completion/TabCompletionModel.cpp +++ b/src/controllers/completion/TabCompletionModel.cpp @@ -8,6 +8,7 @@ #include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" #include "controllers/completion/strategies/ClassicUserStrategy.hpp" #include "controllers/completion/strategies/CommandStrategy.hpp" +#include "controllers/completion/strategies/SmartEmoteStrategy.hpp" #include "singletons/Settings.hpp" namespace chatterino { @@ -123,6 +124,13 @@ std::unique_ptr TabCompletionModel::buildSource( std::unique_ptr TabCompletionModel::buildEmoteSource() const { + if (getSettings()->useSmartEmoteCompletion) + { + return std::make_unique( + &this->channel_, + std::make_unique()); + } + return std::make_unique( &this->channel_, std::make_unique()); diff --git a/src/controllers/completion/strategies/SmartEmoteStrategy.cpp b/src/controllers/completion/strategies/SmartEmoteStrategy.cpp new file mode 100644 index 000000000..aa6e43127 --- /dev/null +++ b/src/controllers/completion/strategies/SmartEmoteStrategy.cpp @@ -0,0 +1,204 @@ +#include "controllers/completion/strategies/SmartEmoteStrategy.hpp" + +#include "common/QLogging.hpp" +#include "controllers/completion/sources/EmoteSource.hpp" +#include "singletons/Settings.hpp" +#include "util/Helpers.hpp" + +#include + +#include + +namespace chatterino::completion { +namespace { + /** + * @brief This function calculates the "cost" of the changes that need to + * be done to the query to make it the value. + * + * By default an emote with more differences in character casing from the + * query will get a higher cost, each additional letter also increases cost. + * + * @param prioritizeUpper If set, then differences in casing don't matter, but + * instead the more lowercase letters an emote contains, the higher cost it + * will get. Additional letters also increase the cost in this mode. + * + * @return How different the emote is from query. Values in the range [-10, + * \infty]. + */ + int costOfEmote(const QString &query, const QString &emote, + bool prioritizeUpper) + { + int score = 0; + + if (prioritizeUpper) + { + // We are in case 3, push 'more uppercase' emotes to the top + for (const auto i : emote) + { + score += int(!i.isUpper()); + } + } + else + { + // Push more matching emotes to the top + int len = std::min(emote.size(), query.size()); + for (int i = 0; i < len; i++) + { + // Different casing gets a higher cost score + score += query.at(i).isUpper() ^ emote.at(i).isUpper(); + } + } + // No case differences, put this at the top + if (score == 0) + { + score = -10; + } + + auto diff = emote.size() - query.size(); + if (diff > 0) + { + // Case changes are way less changes to the user compared to adding characters + score += diff * 100; + } + return score; + }; + + // This contains the brains of emote tab completion. Updates output to sorted completions. + // Ensure that the query string is already normalized, that is doesn't have a leading ':' + // matchingFunction is used for testing if the emote should be included in the search. + void completeEmotes( + const std::vector &items, std::vector &output, + const QString &query, bool ignoreColonForCost, + const std::function + &matchingFunction) + { + // Given these emotes: pajaW, PAJAW + // There are a few cases of input: + // 1. "pajaw" expect {pajaW, PAJAW} - no uppercase characters, do regular case insensitive search + // 2. "PA" expect {PAJAW} - uppercase characters, case sensitive search gives results + // 3. "Pajaw" expect {PAJAW, pajaW} - case sensitive search doesn't give results, need to use sorting + // 4. "NOTHING" expect {} - no results + // 5. "nothing" expect {} - same as 4 but first search is case insensitive + + // Check if the query contains any uppercase characters + // This tells us if we're in case 1 or 5 vs all others + bool haveUpper = + std::any_of(query.begin(), query.end(), [](const QChar &c) { + return c.isUpper(); + }); + + // First search, for case 1 it will be case insensitive, + // for cases 2, 3 and 4 it will be case sensitive + for (const auto &item : items) + { + if (matchingFunction( + item, query, + haveUpper ? Qt::CaseSensitive : Qt::CaseInsensitive)) + { + output.push_back(item); + } + } + + // if case 3: then true; false otherwise + bool prioritizeUpper = false; + + // No results from search + if (output.empty()) + { + if (!haveUpper) + { + // Optimisation: First search was case insensitive, but we found nothing + // There is nothing to be found: case 5. + return; + } + // Case sensitive search from case 2 found nothing, therefore we can + // only be in case 3 or 4. + + prioritizeUpper = true; + // Run the search again but this time without case sensitivity + for (const auto &item : items) + { + if (matchingFunction(item, query, Qt::CaseInsensitive)) + { + output.push_back(item); + } + } + if (output.empty()) + { + // The second search found nothing, so don't even try to sort: case 4 + return; + } + } + + std::sort(output.begin(), output.end(), + [query, prioritizeUpper, ignoreColonForCost]( + const EmoteItem &a, const EmoteItem &b) -> bool { + auto tempA = a.searchName; + auto tempB = b.searchName; + if (ignoreColonForCost && tempA.startsWith(":")) + { + tempA = tempA.mid(1); + } + if (ignoreColonForCost && tempB.startsWith(":")) + { + tempB = tempB.mid(1); + } + + auto costA = costOfEmote(query, tempA, prioritizeUpper); + auto costB = costOfEmote(query, tempB, prioritizeUpper); + if (costA == costB) + { + // Case difference and length came up tied for (a, b), break the tie + return QString::compare(tempA, tempB, + Qt::CaseInsensitive) < 0; + } + + return costA < costB; + }); + } +} // namespace + +void SmartEmoteStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + QString normalizedQuery = query; + bool ignoreColonForCost = false; + if (normalizedQuery.startsWith(':')) + { + normalizedQuery = normalizedQuery.mid(1); + ignoreColonForCost = true; + } + completeEmotes(items, output, normalizedQuery, ignoreColonForCost, + [](const EmoteItem &left, const QString &right, + Qt::CaseSensitivity caseHandling) { + return left.searchName.contains(right, caseHandling); + }); +} + +void SmartTabEmoteStrategy::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; + } + completeEmotes(items, output, normalizedQuery, false, + [emojiOnly](const EmoteItem &left, const QString &right, + Qt::CaseSensitivity caseHandling) -> bool { + if (emojiOnly ^ left.isEmoji) + { + return false; + } + return startsWithOrContains( + left.searchName, right, caseHandling, + getSettings()->prefixOnlyEmoteCompletion); + }); +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/SmartEmoteStrategy.hpp b/src/controllers/completion/strategies/SmartEmoteStrategy.hpp new file mode 100644 index 000000000..365e106b0 --- /dev/null +++ b/src/controllers/completion/strategies/SmartEmoteStrategy.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "controllers/completion/sources/EmoteSource.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +namespace chatterino::completion { + +class SmartEmoteStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +class SmartTabEmoteStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +} // namespace chatterino::completion diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 40d7be51e..8e650de87 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -218,6 +218,10 @@ public: "/behaviour/autocompletion/emoteCompletionWithColon", true}; BoolSetting showUsernameCompletionMenu = { "/behaviour/autocompletion/showUsernameCompletionMenu", true}; + BoolSetting useSmartEmoteCompletion = { + "/experiments/useSmartEmoteCompletion", + false, + }; FloatSetting pauseOnHoverDuration = {"/behaviour/pauseOnHoverDuration", 0}; EnumSetting pauseChatModifier = { diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 48ecdb486..d2b5c9908 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -484,6 +484,8 @@ void GeneralPage::initLayout(GeneralPageView &layout) "cvMask and 7TV's RainTime, will appear as normal emotes."); layout.addCheckbox("Enable emote auto-completion by typing :", s.emoteCompletionWithColon); + layout.addCheckbox("Use experimental smarter emote completion.", + s.useSmartEmoteCompletion); layout.addDropdown( "Size", {"0.5x", "0.75x", "Default", "1.25x", "1.5x", "2x"}, s.emoteScale, diff --git a/src/widgets/splits/InputCompletionPopup.cpp b/src/widgets/splits/InputCompletionPopup.cpp index 2828551ea..c103774db 100644 --- a/src/widgets/splits/InputCompletionPopup.cpp +++ b/src/widgets/splits/InputCompletionPopup.cpp @@ -3,6 +3,8 @@ #include "controllers/completion/sources/UserSource.hpp" #include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" #include "controllers/completion/strategies/ClassicUserStrategy.hpp" +#include "controllers/completion/strategies/SmartEmoteStrategy.hpp" +#include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "util/LayoutCreator.hpp" #include "widgets/splits/InputCompletionItem.hpp" @@ -60,6 +62,13 @@ std::unique_ptr InputCompletionPopup::getSource() const switch (*this->currentKind_) { case CompletionKind::Emote: + if (getSettings()->useSmartEmoteCompletion) + { + return std::make_unique( + this->currentChannel_.get(), + std::make_unique(), + this->callback_); + } return std::make_unique( this->currentChannel_.get(), std::make_unique(),