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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user