diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d438b58..d39aa66d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,7 +86,7 @@ - Dev: Unwrapped `LimitedQueueSnapshot` to `std::vector`. (#6606) - Dev: Simplified uses of `getMessageSnapshot`. (#6607) - Dev: Disabled `llvm-prefer-static-over-anonymous-namespace` in clang-tidy. (#6610) -- Dev: Added experimental spell checker support. (#6446, #6703, #6722, #6730, #6731) +- Dev: Added experimental spell checker support. (#6446, #6703, #6722, #6730, #6731, #6779) - Dev: Added Clazy linting in CI. (#6623) - Dev: Added custom clang-tidy module linting in CI. (#6626) - Dev: CMake option `USE_ALTERNATE_LINKER` now errors if the given linker can't be found. (#6692) diff --git a/src/widgets/splits/InputHighlighter.cpp b/src/widgets/splits/InputHighlighter.cpp index 5b35b4e1..f6a5b434 100644 --- a/src/widgets/splits/InputHighlighter.cpp +++ b/src/widgets/splits/InputHighlighter.cpp @@ -69,12 +69,24 @@ bool isIgnoredWord(TwitchChannel *twitch, const QString &word) namespace chatterino { +namespace inputhighlight::detail { + +// FIXME: this also matches URLs - this probably needs to be some function like Firefox' mozEnglishWordUtils::FindNextWord +QRegularExpression wordRegex() +{ + static QRegularExpression regex{ + R"(\p{L}(?:\P{Z}+\p{L}+)*)", + QRegularExpression::PatternOption::UseUnicodePropertiesOption, + }; + return regex; +} + +} // namespace inputhighlight::detail + InputHighlighter::InputHighlighter(SpellChecker &spellChecker, QObject *parent) : QSyntaxHighlighter(parent) , spellChecker(spellChecker) - // FIXME: this also matches URLs - this probably needs to be some function like Firefox' mozEnglishWordUtils::FindNextWord - , wordRegex(R"(\p{L}(?:\P{Z}+\p{L}+)*)", - QRegularExpression::PatternOption::UseUnicodePropertiesOption) + , wordRegex(inputhighlight::detail::wordRegex()) { this->spellFmt.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline); this->spellFmt.setUnderlineColor(Qt::red); @@ -88,12 +100,36 @@ void InputHighlighter::setChannel(const std::shared_ptr &channel) this->rehighlight(); } +std::vector InputHighlighter::getSpellCheckedWords(const QString &text) +{ + std::vector words; + this->visitWords(text, [&](const QString &word, qsizetype /*start*/, + qsizetype /*count*/) { + words.emplace_back(word); + }); + return words; +} + void InputHighlighter::highlightBlock(const QString &text) { if (!this->spellChecker.isLoaded()) { return; } + this->visitWords( + text, [&](const QString &word, qsizetype start, qsizetype count) { + if (!this->spellChecker.check(word)) + { + this->setFormat(static_cast(start), + static_cast(count), this->spellFmt); + } + }); +} + +void InputHighlighter::visitWords( + const QString &text, + std::invocable auto &&cb) +{ auto *channel = this->channel.lock().get(); QStringView textView = text; @@ -112,11 +148,10 @@ void InputHighlighter::highlightBlock(const QString &text) { auto match = it.next(); auto text = match.captured(); - if (!isIgnoredWord(channel, text) && !this->spellChecker.check(text)) + if (!isIgnoredWord(channel, text)) { - this->setFormat( - static_cast(match.capturedStart() + cmdTriggerLen), - static_cast(text.size()), this->spellFmt); + cb(text, static_cast(match.capturedStart() + cmdTriggerLen), + static_cast(text.size())); } } } diff --git a/src/widgets/splits/InputHighlighter.hpp b/src/widgets/splits/InputHighlighter.hpp index 94f78542..b60481bc 100644 --- a/src/widgets/splits/InputHighlighter.hpp +++ b/src/widgets/splits/InputHighlighter.hpp @@ -8,6 +8,7 @@ #include #include +#include #include class QTextDocument; @@ -18,6 +19,12 @@ class Channel; class TwitchChannel; class SpellChecker; +namespace inputhighlight::detail { + +QRegularExpression wordRegex(); + +} // namespace inputhighlight::detail + /// This highlights the text in the split input. /// Currently, it only does spell checking. class InputHighlighter : public QSyntaxHighlighter @@ -32,10 +39,20 @@ public: void setChannel(const std::shared_ptr &channel); + /// Do a pass over the whole text and filter out all words that will be + /// checked by the spell checker. + std::vector getSpellCheckedWords(const QString &text); + protected: void highlightBlock(const QString &text) override; private: + /// Visit all words that are not ignored + void visitWords( + const QString &text, + std::invocable auto &&cb); + SpellChecker &spellChecker; QTextCharFormat spellFmt; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bfbbdc4c..ac3ce1cb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,6 +60,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/TwitchChannel.cpp ${CMAKE_CURRENT_LIST_DIR}/src/TwitchUserColor.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FunctionRef.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/InputHighlighter.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.hpp diff --git a/tests/src/InputHighlighter.cpp b/tests/src/InputHighlighter.cpp new file mode 100644 index 00000000..fb204005 --- /dev/null +++ b/tests/src/InputHighlighter.cpp @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: 2026 Contributors to Chatterino +// +// SPDX-License-Identifier: MIT + +#include "widgets/splits/InputHighlighter.hpp" + +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/Command.hpp" +#include "controllers/commands/CommandController.hpp" +#include "controllers/highlights/HighlightController.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" +#include "controllers/spellcheck/SpellChecker.hpp" +#include "messages/Emote.hpp" +#include "mocks/BaseApplication.hpp" +#include "mocks/EmoteController.hpp" +#include "mocks/TwitchIrcServer.hpp" +#include "providers/twitch/IrcMessageHandler.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchBadges.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "Test.hpp" + +#include +#include +#include + +using namespace chatterino; +using namespace Qt::Literals; + +namespace { + +class MockApplication : public mock::BaseApplication +{ +public: + MockApplication() + : commandController(this->paths_) + { + } + + EmoteController *getEmotes() override + { + return &this->emotes; + } + + AccountController *getAccounts() override + { + return &this->accounts; + } + + ITwitchIrcServer *getTwitch() override + { + return &this->twitch; + } + + BttvEmotes *getBttvEmotes() override + { + return &this->bttvEmotes; + } + + FfzEmotes *getFfzEmotes() override + { + return &this->ffzEmotes; + } + + SeventvEmotes *getSeventvEmotes() override + { + return &this->seventvEmotes; + } + + CommandController *getCommands() override + { + return &this->commandController; + } + + AccountController accounts; + mock::EmoteController emotes; + mock::MockTwitchIrcServer twitch; + BttvEmotes bttvEmotes; + FfzEmotes ffzEmotes; + SeventvEmotes seventvEmotes; + CommandController commandController; +}; + +std::pair makeEmote(const QString &name) +{ + EmoteName emoteName{name}; + auto ptr = std::make_shared(Emote{.name = emoteName}); + return {emoteName, ptr}; +} + +using EmoteMapPtr = std::shared_ptr; + +EmoteMapPtr makeEmotes(auto &&...emotes) +{ + auto map = std::make_shared(); + ((map->emplace(makeEmote(std::forward(emotes)))), ...); + return map; +} + +struct MockEmotes { + EmoteMapPtr seventv; + EmoteMapPtr bttv; + EmoteMapPtr ffz; + EmoteMapPtr twitchAccount; + + static MockEmotes channel() + { + return { + .seventv = makeEmotes(u"7TVEmote"_s, u"PogChamp"_s), + .bttv = makeEmotes(u"BTTVEmote"_s, u"Kappa"_s), + .ffz = makeEmotes(u"FFZEmote"_s, u"Keepo"_s), + }; + } + + static MockEmotes global() + { + return { + .seventv = makeEmotes(u"7TVGlobal"_s), + .bttv = makeEmotes(u"BTTVGlobal"_s), + .ffz = makeEmotes(u"FFZGlobal"_s), + .twitchAccount = makeEmotes(u"MyCoolTwitchEmote"_s), + }; + } +}; + +std::shared_ptr makeMockTwitchChannel(const QString &name) +{ + auto chan = std::make_shared(name); + auto mocks = MockEmotes::channel(); + chan->setSeventvEmotes(std::move(mocks.seventv)); + chan->setBttvEmotes(std::move(mocks.bttv)); + chan->setFfzEmotes(std::move(mocks.ffz)); + + chan->addRecentChatter("UserChatter"); + chan->addRecentChatter("UserColor"); + chan->addRecentChatter("MyUser42"); + chan->addRecentChatter("123kappa123"); + + return chan; +} + +} // namespace + +class InputHighlighterTest : public ::testing::Test +{ +public: + void SetUp() override + { + this->mockApplication = std::make_unique(); + auto mocks = MockEmotes::global(); + this->mockApplication->seventvEmotes.setGlobalEmotes(mocks.seventv); + this->mockApplication->bttvEmotes.setEmotes(mocks.bttv); + this->mockApplication->ffzEmotes.setEmotes(mocks.ffz); + this->mockApplication->getAccounts()->twitch.getCurrent()->setEmotes( + mocks.twitchAccount); + this->channel = makeMockTwitchChannel(u"twitchdev"_s); + this->mockApplication->commandController.items.append( + Command("/my-command", "")); + this->mockApplication->commandController.items.append( + Command("command space", "")); + this->mockApplication->commandController.items.append( + Command("command with more than one space", "")); + } + + void TearDown() override + { + this->channel.reset(); + this->mockApplication.reset(); + } + + std::shared_ptr channel; + std::unique_ptr mockApplication; +}; + +TEST_F(InputHighlighterTest, getSpellCheckedWords) +{ + struct Case { + QString input; + std::vector words; + }; + + std::vector cases{ + {.input = "", .words = {}}, + {.input = "word", .words = {"word"}}, + {.input = "word word", .words = {"word", "word"}}, + {.input = " word word ", .words = {"word", "word"}}, + {.input = "word?", .words = {"word"}}, + {.input = "word?word", .words = {"word?word"}}, + {.input = "word-word", .words = {"word-word"}}, + { + .input = "channel emotes 7TVEmote a BTTVEmote b FFZEmote c", + .words = {"channel", "emotes", "TVEmote", "a", "b", "c"}, + }, + { + .input = "global emotes 7TVGlobal a BTTVGlobal b FFZGlobal c " + "MyCoolTwitchEmote d", + // FIXME: TVGlobal shouldn't show up + .words = {"global", "emotes", "TVGlobal", "a", "b", "c", "d"}, + }, + { + .input = "/ban a user", + .words = {"a", "user"}, + }, + { + .input = "/my-command some text", + .words = {"some", "text"}, + }, + { + .input = "command space and some text", + .words = {"and", "some", "text"}, + }, + { + .input = "command with more than one space and some text", + .words = {"and", "some", "text"}, + }, + { + .input = "command with more than one and some text", + .words = + { + "command", + "with", + "more", + "than", + "one", + "and", + "some", + "text", + }, + }, + { + .input = "Hey, @userchatter a 123kappa123 b MyUser42 c", + // FIXME: 'kappa' and 'MyUser' shouldn't show up + .words = {"Hey", "a", "kappa", "b", "MyUser", "c"}, + }, + }; + + SpellChecker nullSpellChecker; + InputHighlighter highlighter(nullSpellChecker, nullptr); + highlighter.setChannel(this->channel); + for (size_t i = 0; i < cases.size(); i++) + { + const auto &c = cases[i]; + auto got = highlighter.getSpellCheckedWords(c.input); + ASSERT_EQ(got, c.words) << "index=" << i; + } +} + +TEST(InputHighlight, wordRegex) +{ + struct Case { + QStringView input; + std::vector words; + }; + + std::vector cases{ + {.input = u"", .words = {}}, + {.input = u"word", .words = {u"word"}}, + {.input = u"word word", .words = {u"word", u"word"}}, + {.input = u" word word ", .words = {u"word", u"word"}}, + {.input = u"word?", .words = {u"word"}}, + {.input = u"word?word", .words = {u"word?word"}}, + {.input = u"a12word", .words = {u"a12word"}}, + {.input = u"äwördü", .words = {u"äwördü"}}, + // FIXME: should be the whole input + {.input = u"123kappa123", .words = {u"kappa"}}, + {.input = u"123kappa", .words = {u"kappa"}}, + {.input = u"kappa123", .words = {u"kappa"}}, + {.input = u"abc @foo bar@baz@", .words = {u"abc", u"foo", u"bar@baz"}}, + {.input = u"1234567 word a123", .words = {u"word", u"a"}}, + }; + + auto re = inputhighlight::detail::wordRegex(); + for (size_t i = 0; i < cases.size(); i++) + { + const auto &c = cases[i]; + std::vector got; + auto match = re.globalMatchView(c.input); + while (match.hasNext()) + { + got.emplace_back(match.next().capturedView()); + } + ASSERT_EQ(got, c.words) << "index=" << i; + } +}