Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make emote completion a lot smarter #4987

Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions src/controllers/completion/TabCompletionModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>());
Expand Down
204 changes: 204 additions & 0 deletions src/controllers/completion/strategies/SmartEmoteStrategy.cpp
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions src/controllers/completion/strategies/SmartEmoteStrategy.hpp
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions src/singletons/Settings.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ class Settings
"/behaviour/autocompletion/emoteCompletionWithColon", true};
BoolSetting showUsernameCompletionMenu = {
"/behaviour/autocompletion/showUsernameCompletionMenu", true};
BoolSetting useSmartEmoteCompletion = {
"/experiments/useSmartEmoteCompletion",
false,
};

FloatSetting pauseOnHoverDuration = {"/behaviour/pauseOnHoverDuration", 0};
EnumSetting<Qt::KeyboardModifier> pauseChatModifier = {
Expand Down
2 changes: 2 additions & 0 deletions src/widgets/settingspages/GeneralPage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/widgets/splits/InputCompletionPopup.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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>(),
Expand Down
Loading