Skip to content

Commit

Permalink
Make emote completion a lot smarter
Browse files Browse the repository at this point in the history
  • Loading branch information
Mm2PL committed Nov 27, 2023
1 parent a240797 commit 4b9ebb9
Showing 1 changed file with 157 additions and 50 deletions.
207 changes: 157 additions & 50 deletions src/controllers/completion/strategies/ClassicEmoteStrategy.cpp
Original file line number Diff line number Diff line change
@@ -1,52 +1,168 @@
#include "controllers/completion/strategies/ClassicEmoteStrategy.hpp"

#include "common/QLogging.hpp"
#include "controllers/completion/sources/EmoteSource.hpp"
#include "singletons/Settings.hpp"
#include "util/Helpers.hpp"

namespace chatterino::completion {
#include <qnamespace.h>

void ClassicEmoteStrategy::apply(const std::vector<EmoteItem> &items,
std::vector<EmoteItem> &output,
const QString &query) const
{
QString normalizedQuery = query;
if (normalizedQuery.startsWith(':'))
{
normalizedQuery = normalizedQuery.mid(1);
}
#include <algorithm>

// First pass: filter by contains match
for (const auto &item : items)
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]. Negative cost means exact match.
*/
int costOfEmote(const QString &query, const QString &emote,
bool prioritizeUpper)
{
if (item.searchName.contains(normalizedQuery, Qt::CaseInsensitive))
int score = 0;

if (prioritizeUpper)
{
output.push_back(item);
// 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;
};

// Second pass: if there is an exact match, put that emote first
for (size_t i = 1; i < output.size(); i++)
// 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,
const std::function<bool(EmoteItem, QString, Qt::CaseSensitivity)>
&matchingFunction)
{
auto emoteText = output.at(i).searchName;
// 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();
});

// 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)
// 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)
{
auto emote = output[i];
output.erase(output.begin() + int(i));
output.insert(output.begin(), emote);
break;
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](const EmoteItem &a,
const EmoteItem &b) -> bool {
auto costA = costOfEmote(query, a.searchName, prioritizeUpper);
auto costB = costOfEmote(query, b.searchName, prioritizeUpper);
if (costA == costB)
{
// Case difference and length came up tied for (a, b), break the tie
return QString::compare(a.searchName, b.searchName,
Qt::CaseInsensitive) < 0;
}

return costA < costB;
});
}
}
} // namespace

struct CompletionEmoteOrder {
bool operator()(const EmoteItem &a, const EmoteItem &b) const
void ClassicEmoteStrategy::apply(const std::vector<EmoteItem> &items,
std::vector<EmoteItem> &output,
const QString &query) const
{
QString normalizedQuery = query;
if (normalizedQuery.startsWith(':'))
{
return compareEmoteStrings(a.searchName, b.searchName);
normalizedQuery = normalizedQuery.mid(1);
}
};
completeEmotes(items, output, normalizedQuery,
[](const EmoteItem &left, const QString &right,
Qt::CaseSensitivity caseHandling) {
return left.searchName.contains(right, caseHandling);
});
}

void ClassicTabEmoteStrategy::apply(const std::vector<EmoteItem> &items,
std::vector<EmoteItem> &output,
Expand All @@ -60,26 +176,17 @@ void ClassicTabEmoteStrategy::apply(const std::vector<EmoteItem> &items,
// tab completion with : prefix should do emojis only
emojiOnly = true;
}

std::set<EmoteItem, CompletionEmoteOrder> 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());
completeEmotes(items, output, normalizedQuery,
[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

0 comments on commit 4b9ebb9

Please sign in to comment.