feat: spellcheck input (#6446)

Reviewed-by: Mm2PL <mm2pl+gh@kotmisia.pl>
Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
Reviewed-by: fourtf <tf.four@gmail.com>
This commit is contained in:
Nerixyz
2026-01-02 15:56:29 +01:00
committed by GitHub
parent f4212028d6
commit 95bc67fea0
33 changed files with 854 additions and 14 deletions
+8
View File
@@ -5,6 +5,7 @@
#include "controllers/completion/TabCompletionModel.hpp"
#include "singletons/Settings.hpp"
#include <QMenu>
#include <QMimeData>
#include <QMimeDatabase>
#include <QObject>
@@ -337,4 +338,11 @@ void ResizingTextEdit::insertFromMimeData(const QMimeData *source)
insertPlainText(source->text());
}
void ResizingTextEdit::contextMenuEvent(QContextMenuEvent *event)
{
QObjectPtr<QMenu> menu{this->createStandardContextMenu(event->pos())};
this->contextMenuRequested.invoke(menu.get(), event->pos());
menu->exec(event->globalPos());
}
} // namespace chatterino
+3
View File
@@ -21,6 +21,7 @@ public:
pajlada::Signals::NoArgSignal focused;
pajlada::Signals::NoArgSignal focusLost;
pajlada::Signals::Signal<const QMimeData *> imagePasted;
pajlada::Signals::Signal<QMenu *, QPoint> contextMenuRequested;
void setCompleter(QCompleter *c);
/**
@@ -39,6 +40,8 @@ protected:
bool canInsertFromMimeData(const QMimeData *source) const override;
void insertFromMimeData(const QMimeData *source) override;
void contextMenuEvent(QContextMenuEvent *event) override;
private:
// hadSpace is set to true in case the "textUnderCursor" word was after a
// space
+4
View File
@@ -146,6 +146,10 @@ AboutPage::AboutPage()
addLicense(form.getElement(), "Unicode",
"https://www.unicode.org/copyright.html",
":/licenses/unicode.txt");
#ifdef CHATTERINO_WITH_SPELLCHECK
addLicense(form.getElement(), "Hunspell",
"https://hunspell.github.io", ":/licenses/hunspell.txt");
#endif
}
// Attributions
+15
View File
@@ -481,6 +481,21 @@ void GeneralPage::initLayout(GeneralPageView &layout)
"yours is successfully sent in the matching channel.")
->addTo(layout);
#ifdef CHATTERINO_WITH_SPELLCHECK
SettingWidget::checkbox("Check spelling by default (experimental)",
s.enableSpellChecking)
->setTooltip(
u"Check the spelling of words in the input box of all splits by "
"default. This can be overwritten per split in the context menu."
" Chatterino does not include dictionaries - they have to "
"be downloaded or created manually. Chatterino expects Hunspell "
"dictionaries in '" %
getApp()->getPaths().dictionariesDirectory %
u"'. The file index.aff has to contain the affixes and index.dic "
u"must contain the dictionary (subject to change).")
->addTo(layout);
#endif
layout.addTitle("Messages");
SettingWidget::checkbox("Separate with lines", s.separateMessages)
+104
View File
@@ -0,0 +1,104 @@
#include "widgets/splits/InputHighlighter.hpp"
#include "Application.hpp"
#include "common/Aliases.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "controllers/spellcheck/SpellChecker.hpp"
#include "messages/Emote.hpp"
#include "providers/bttv/BttvEmotes.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include <QTextCharFormat>
#include <QTextDocument>
namespace {
using namespace chatterino;
bool isIgnoredWord(TwitchChannel *twitch, const QString &word)
{
EmoteName name{word};
if (twitch)
{
if (twitch->bttvEmote(name) || twitch->ffzEmote(name) ||
twitch->seventvEmote(name))
{
return true;
}
auto locals = twitch->localTwitchEmotes();
if (locals->contains(name))
{
return true;
}
if (twitch->accessChatters()->contains(word))
{
return true;
}
}
if (getApp()->getBttvEmotes()->emote(name) ||
getApp()->getFfzEmotes()->emote(name) ||
getApp()->getSeventvEmotes()->globalEmote(name))
{
return true;
}
return getApp()
->getAccounts()
->twitch.getCurrent()
->twitchEmote(name)
.has_value();
}
} // namespace
namespace chatterino {
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)
{
this->spellFmt.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
this->spellFmt.setUnderlineColor(Qt::red);
}
InputHighlighter::~InputHighlighter() = default;
void InputHighlighter::setChannel(const std::shared_ptr<Channel> &channel)
{
auto twitch = std::dynamic_pointer_cast<TwitchChannel>(channel);
this->channel = twitch;
this->rehighlight();
}
void InputHighlighter::highlightBlock(const QString &text)
{
if (!this->spellChecker.isLoaded())
{
return;
}
auto *channel = this->channel.lock().get();
QStringView textView = text;
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
auto it = this->wordRegex.globalMatchView(textView);
#else
auto it = this->wordRegex.globalMatch(textView);
#endif
while (it.hasNext())
{
auto match = it.next();
auto text = match.captured();
if (!isIgnoredWord(channel, text) && !this->spellChecker.check(text))
{
this->setFormat(static_cast<int>(match.capturedStart()),
static_cast<int>(text.size()), this->spellFmt);
}
}
}
} // namespace chatterino
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <QRegularExpression>
#include <QString>
#include <QSyntaxHighlighter>
#include <memory>
class QTextDocument;
namespace chatterino {
class Channel;
class TwitchChannel;
class SpellChecker;
/// This highlights the text in the split input.
/// Currently, it only does spell checking.
class InputHighlighter : public QSyntaxHighlighter
{
public:
InputHighlighter(SpellChecker &spellChecker, QObject *parent);
~InputHighlighter() override;
InputHighlighter(const InputHighlighter &) = delete;
InputHighlighter(InputHighlighter &&) = delete;
InputHighlighter &operator=(const InputHighlighter &) = delete;
InputHighlighter &operator=(InputHighlighter &&) = delete;
void setChannel(const std::shared_ptr<Channel> &channel);
protected:
void highlightBlock(const QString &text) override;
private:
SpellChecker &spellChecker;
QTextCharFormat spellFmt;
std::weak_ptr<TwitchChannel> channel;
QRegularExpression wordRegex;
};
} // namespace chatterino
+10
View File
@@ -894,6 +894,16 @@ bool Split::getModerationMode() const
return this->moderationMode_;
}
std::optional<bool> Split::checkSpellingOverride() const
{
return this->input_->checkSpellingOverride();
}
void Split::setCheckSpellingOverride(std::optional<bool> override)
{
this->input_->setCheckSpellingOverride(override);
}
void Split::insertTextToInput(const QString &text)
{
this->input_->insertText(text);
+3
View File
@@ -62,6 +62,9 @@ public:
void setModerationMode(bool value);
bool getModerationMode() const;
std::optional<bool> checkSpellingOverride() const;
void setCheckSpellingOverride(std::optional<bool> override);
void insertTextToInput(const QString &text);
void showChangeChannelPopup(const char *dialogTitle, bool empty,
+2
View File
@@ -908,6 +908,7 @@ void SplitContainer::applyFromDescriptorRecursively(
split->setChannel(WindowManager::decodeChannel(splitNode));
split->setModerationMode(splitNode.moderationMode_);
split->setFilters(splitNode.filters_);
split->setCheckSpellingOverride(splitNode.spellCheckOverride);
this->insertSplit(split);
@@ -943,6 +944,7 @@ void SplitContainer::applyFromDescriptorRecursively(
split->setFilters(splitNode.filters_);
split->setChannel(WindowManager::decodeChannel(splitNode));
split->setModerationMode(splitNode.moderationMode_);
split->setCheckSpellingOverride(splitNode.spellCheckOverride);
auto node = std::make_shared<Node>();
node->parent_ = baseNode;
+85 -5
View File
@@ -5,6 +5,7 @@
#include "common/QLogging.hpp"
#include "controllers/commands/CommandController.hpp"
#include "controllers/hotkeys/HotkeyController.hpp"
#include "controllers/spellcheck/SpellChecker.hpp"
#include "messages/Link.hpp"
#include "messages/Message.hpp"
#include "providers/twitch/TwitchChannel.hpp"
@@ -25,6 +26,7 @@
#include "widgets/Notebook.hpp"
#include "widgets/Scrollbar.hpp"
#include "widgets/splits/InputCompletionPopup.hpp"
#include "widgets/splits/InputHighlighter.hpp"
#include "widgets/splits/Split.hpp"
#include "widgets/splits/SplitContainer.hpp"
@@ -73,14 +75,26 @@ SplitInput::SplitInput(QWidget *parent, Split *_chatWidget,
new QCompleter(this->split_->getChannel()->completionModel);
this->ui_.textEdit->setCompleter(completer);
// NOLINTNEXTLINE(cppcoreguidelines-prefer-member-initializer)
auto *spellChecker = getApp()->getSpellChecker();
this->inputHighlighter = new InputHighlighter(*spellChecker, this);
this->inputHighlighter->setChannel(this->split_->getChannel());
this->signalHolder_.managedConnect(this->split_->channelChanged, [this] {
auto channel = this->split_->getChannel();
auto *completer = new QCompleter(channel->completionModel);
this->ui_.textEdit->setCompleter(completer);
this->inputHighlighter->setChannel(this->split_->getChannel());
});
getSettings()->enableSpellChecking.connect(
[this] {
this->checkSpellingChanged();
},
this->signalHolder_);
// misc
this->installKeyPressedEvent();
this->installTextEditEvents();
this->addShortcuts();
// The textEdit's signal will be destroyed before this SplitInput is
// destroyed, so we can safely ignore this signal's connection.
@@ -720,7 +734,7 @@ bool SplitInput::eventFilter(QObject *obj, QEvent *event)
return BaseWidget::eventFilter(obj, event);
}
void SplitInput::installKeyPressedEvent()
void SplitInput::installTextEditEvents()
{
// We can safely ignore this signal's connection because SplitInput owns
// the textEdit object, so it will always be deleted before SplitInput
@@ -752,10 +766,42 @@ void SplitInput::installKeyPressedEvent()
}
});
#ifdef DEBUG
assert(this->keyPressedEventInstalled == false);
this->keyPressedEventInstalled = true;
std::ignore = this->ui_.textEdit->contextMenuRequested.connect(
[this](QMenu *menu, QPoint pos) {
#ifdef CHATTERINO_WITH_SPELLCHECK
menu->addSeparator();
auto *spellcheckAction = new QAction("Check spelling", menu);
spellcheckAction->setCheckable(true);
spellcheckAction->setChecked(this->shouldCheckSpelling());
QObject::connect(spellcheckAction, &QAction::toggled, this,
[this](bool enabled) {
this->checkSpellingOverride_ = enabled;
this->checkSpellingChanged();
});
menu->addAction(spellcheckAction);
auto cursor = this->ui_.textEdit->cursorForPosition(pos);
cursor.select(QTextCursor::WordUnderCursor);
auto word = cursor.selectedText();
if (!word.isEmpty())
{
auto suggestions =
getApp()->getSpellChecker()->suggestions(word);
for (const auto &sugg : suggestions)
{
auto qSugg = QString::fromStdString(sugg);
menu->addAction(qSugg, [this, qSugg, cursor]() mutable {
cursor.insertText(qSugg);
this->ui_.textEdit->setTextCursor(cursor);
});
}
}
#else
(void)menu;
(void)pos;
(void)this;
#endif
});
}
void SplitInput::mousePressEvent(QMouseEvent *event)
@@ -1329,6 +1375,40 @@ void SplitInput::setBackgroundColor(QColor newColor)
this->updateTextEditPalette();
}
std::optional<bool> SplitInput::checkSpellingOverride() const
{
return this->checkSpellingOverride_;
}
void SplitInput::setCheckSpellingOverride(std::optional<bool> override)
{
this->checkSpellingOverride_ = override;
this->checkSpellingChanged();
}
bool SplitInput::shouldCheckSpelling() const
{
if (this->checkSpellingOverride_)
{
return *this->checkSpellingOverride_;
}
return getSettings()->enableSpellChecking;
}
void SplitInput::checkSpellingChanged()
{
QTextDocument *target = nullptr;
if (this->shouldCheckSpelling())
{
target = this->ui_.textEdit->document();
}
if (this->inputHighlighter->document() != target)
{
this->inputHighlighter->setDocument(target);
}
}
void SplitInput::updateFonts()
{
auto *app = getApp();
+12 -4
View File
@@ -20,11 +20,13 @@ namespace chatterino {
class Split;
class EmotePopup;
class InputCompletionPopup;
class InputHighlighter;
class MessageView;
class LabelButton;
class ResizingTextEdit;
class ChannelView;
class SvgButton;
class SpellCheckHighlighter;
enum class CompletionKind;
class SplitInput : public BaseWidget
@@ -78,6 +80,9 @@ public:
void triggerSelfMessageReceived();
std::optional<bool> checkSpellingOverride() const;
void setCheckSpellingOverride(std::optional<bool> override);
pajlada::Signals::Signal<const QString &> textChanged;
pajlada::Signals::NoArgSignal selectionChanged;
@@ -102,10 +107,7 @@ protected:
void addShortcuts() override;
void initLayout();
bool eventFilter(QObject *obj, QEvent *event) override;
#ifdef DEBUG
bool keyPressedEventInstalled{};
#endif
void installKeyPressedEvent();
void installTextEditEvents();
void onCursorPositionChanged();
void onTextChanged();
void updateEmoteButton();
@@ -188,6 +190,12 @@ protected:
QPropertyAnimation backgroundColorAnimation;
std::optional<bool> checkSpellingOverride_;
bool shouldCheckSpelling() const;
void checkSpellingChanged();
InputHighlighter *inputHighlighter = nullptr;
void updateFonts();
private Q_SLOTS: