Make emote completion a lot smarter (#4987)

This commit is contained in:
Mm2PL 2023-11-28 11:06:35 +01:00 committed by GitHub
parent a240797b68
commit 6d02bb7304
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 253 additions and 1 deletions

View file

@ -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)

View file

@ -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

View file

@ -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<completion::Source> TabCompletionModel::buildSource(
std::unique_ptr<completion::Source> TabCompletionModel::buildEmoteSource() const
{
if (getSettings()->useSmartEmoteCompletion)
{
return std::make_unique<completion::EmoteSource>(
&this->channel_,
std::make_unique<completion::SmartTabEmoteStrategy>());
}
return std::make_unique<completion::EmoteSource>(
&this->channel_,
std::make_unique<completion::ClassicTabEmoteStrategy>());

View file

@ -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 <Qt>
#include <algorithm>
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<EmoteItem> &items, std::vector<EmoteItem> &output,
const QString &query, bool ignoreColonForCost,
const std::function<bool(EmoteItem, QString, Qt::CaseSensitivity)>
&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<EmoteItem> &items,
std::vector<EmoteItem> &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<EmoteItem> &items,
std::vector<EmoteItem> &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

View file

@ -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<EmoteItem>
{
void apply(const std::vector<EmoteItem> &items,
std::vector<EmoteItem> &output,
const QString &query) const override;
};
class SmartTabEmoteStrategy : public Strategy<EmoteItem>
{
void apply(const std::vector<EmoteItem> &items,
std::vector<EmoteItem> &output,
const QString &query) const override;
};
} // namespace chatterino::completion

View file

@ -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<Qt::KeyboardModifier> pauseChatModifier = {

View file

@ -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<float>(
"Size", {"0.5x", "0.75x", "Default", "1.25x", "1.5x", "2x"},
s.emoteScale,

View file

@ -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<completion::Source> InputCompletionPopup::getSource() const
switch (*this->currentKind_)
{
case CompletionKind::Emote:
if (getSettings()->useSmartEmoteCompletion)
{
return std::make_unique<completion::EmoteSource>(
this->currentChannel_.get(),
std::make_unique<completion::SmartEmoteStrategy>(),
this->callback_);
}
return std::make_unique<completion::EmoteSource>(
this->currentChannel_.get(),
std::make_unique<completion::ClassicEmoteStrategy>(),