File indexing completed on 2024-04-28 15:34:20

0001 // SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
0002 // SPDX-FileCopyrightText: 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
0003 // SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
0004 // SPDX-License-Identifier: LGPL-2.1-or-later
0005 
0006 #include "spellcheckhighlighter.h"
0007 #include "guesslanguage.h"
0008 #include "languagefilter_p.h"
0009 #include "loader_p.h"
0010 #include "settingsimpl_p.h"
0011 #include "speller.h"
0012 #include "tokenizer_p.h"
0013 
0014 #include "quick_debug.h"
0015 
0016 #include <QColor>
0017 #include <QHash>
0018 #include <QKeyEvent>
0019 #include <QMetaMethod>
0020 #include <QTextBoundaryFinder>
0021 #include <QTextCharFormat>
0022 #include <QTextCursor>
0023 #include <QTimer>
0024 #include <memory>
0025 
0026 using namespace Sonnet;
0027 
0028 // Cache of previously-determined languages (when using AutoDetectLanguage)
0029 // There is one such cache per block (paragraph)
0030 class LanguageCache : public QTextBlockUserData
0031 {
0032 public:
0033     // Key: QPair<start, length>
0034     // Value: language name
0035     QMap<QPair<int, int>, QString> languages;
0036 
0037     // Remove all cached language information after @p pos
0038     void invalidate(int pos)
0039     {
0040         QMutableMapIterator<QPair<int, int>, QString> it(languages);
0041         it.toBack();
0042         while (it.hasPrevious()) {
0043             it.previous();
0044             if (it.key().first + it.key().second >= pos) {
0045                 it.remove();
0046             } else {
0047                 break;
0048             }
0049         }
0050     }
0051 
0052     QString languageAtPos(int pos) const
0053     {
0054         // The data structure isn't really great for such lookups...
0055         QMapIterator<QPair<int, int>, QString> it(languages);
0056         while (it.hasNext()) {
0057             it.next();
0058             if (it.key().first <= pos && it.key().first + it.key().second >= pos) {
0059                 return it.value();
0060             }
0061         }
0062         return QString();
0063     }
0064 };
0065 
0066 class HighlighterPrivate
0067 {
0068 public:
0069     HighlighterPrivate(SpellcheckHighlighter *qq)
0070         : q(qq)
0071     {
0072         tokenizer = std::make_unique<WordTokenizer>();
0073         active = true;
0074         automatic = false;
0075         autoDetectLanguageDisabled = false;
0076         connected = false;
0077         wordCount = 0;
0078         errorCount = 0;
0079         intraWordEditing = false;
0080         completeRehighlightRequired = false;
0081         spellColor = spellColor.isValid() ? spellColor : Qt::red;
0082         languageFilter = std::make_unique<LanguageFilter>(new SentenceTokenizer());
0083 
0084         loader = Loader::openLoader();
0085         loader->settings()->restore();
0086 
0087         spellchecker = std::make_unique<Speller>();
0088         spellCheckerFound = spellchecker->isValid();
0089         rehighlightRequest = new QTimer(q);
0090         q->connect(rehighlightRequest, &QTimer::timeout, q, &SpellcheckHighlighter::slotRehighlight);
0091 
0092         if (!spellCheckerFound) {
0093             return;
0094         }
0095 
0096         disablePercentage = loader->settings()->disablePercentageWordError();
0097         disableWordCount = loader->settings()->disableWordErrorCount();
0098 
0099         completeRehighlightRequired = true;
0100         rehighlightRequest->setInterval(0);
0101         rehighlightRequest->setSingleShot(true);
0102         rehighlightRequest->start();
0103 
0104         // Danger red from our color scheme
0105         errorFormat.setForeground(spellColor);
0106         errorFormat.setUnderlineColor(spellColor);
0107         errorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline);
0108         quoteFormat.setForeground(QColor{"#7f8c8d"});
0109     }
0110 
0111     ~HighlighterPrivate();
0112     std::unique_ptr<WordTokenizer> tokenizer;
0113     std::unique_ptr<LanguageFilter> languageFilter;
0114     Loader *loader = nullptr;
0115     std::unique_ptr<Speller> spellchecker;
0116 
0117     QTextCharFormat errorFormat;
0118     QTextCharFormat quoteFormat;
0119     std::unique_ptr<Sonnet::GuessLanguage> languageGuesser;
0120     QString selectedWord;
0121     QQuickTextDocument *document = nullptr;
0122     int cursorPosition;
0123     int selectionStart;
0124     int selectionEnd;
0125 
0126     int autoCompleteBeginPosition = -1;
0127     int autoCompleteEndPosition = -1;
0128     int wordIsMisspelled = false;
0129     bool active;
0130     bool automatic;
0131     bool autoDetectLanguageDisabled;
0132     bool completeRehighlightRequired;
0133     bool intraWordEditing;
0134     bool spellCheckerFound; // cached d->dict->isValid() value
0135     bool connected;
0136     int disablePercentage = 0;
0137     int disableWordCount = 0;
0138     int wordCount, errorCount;
0139     QTimer *rehighlightRequest = nullptr;
0140     QColor spellColor;
0141     SpellcheckHighlighter *const q;
0142 };
0143 
0144 HighlighterPrivate::~HighlighterPrivate()
0145 {
0146 }
0147 
0148 SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent)
0149     : QSyntaxHighlighter(parent)
0150     , d(new HighlighterPrivate(this))
0151 {
0152 }
0153 
0154 bool SpellcheckHighlighter::spellCheckerFound() const
0155 {
0156     return d->spellCheckerFound;
0157 }
0158 
0159 void SpellcheckHighlighter::slotRehighlight()
0160 {
0161     if (d->completeRehighlightRequired) {
0162         d->wordCount = 0;
0163         d->errorCount = 0;
0164         rehighlight();
0165     } else {
0166         // rehighlight the current para only (undo/redo safe)
0167         QTextCursor cursor = textCursor();
0168         if (cursor.hasSelection()) {
0169             cursor.clearSelection();
0170         }
0171         cursor.insertText(QString());
0172     }
0173     // if (d->checksDone == d->checksRequested)
0174     // d->completeRehighlightRequired = false;
0175     QTimer::singleShot(0, this, &SpellcheckHighlighter::slotAutoDetection);
0176 }
0177 
0178 bool SpellcheckHighlighter::automatic() const
0179 {
0180     return d->automatic;
0181 }
0182 
0183 bool SpellcheckHighlighter::autoDetectLanguageDisabled() const
0184 {
0185     return d->autoDetectLanguageDisabled;
0186 }
0187 
0188 bool SpellcheckHighlighter::intraWordEditing() const
0189 {
0190     return d->intraWordEditing;
0191 }
0192 
0193 void SpellcheckHighlighter::setIntraWordEditing(bool editing)
0194 {
0195     d->intraWordEditing = editing;
0196 }
0197 
0198 void SpellcheckHighlighter::setAutomatic(bool automatic)
0199 {
0200     if (automatic == d->automatic) {
0201         return;
0202     }
0203 
0204     d->automatic = automatic;
0205     if (d->automatic) {
0206         slotAutoDetection();
0207     }
0208 }
0209 
0210 void SpellcheckHighlighter::setAutoDetectLanguageDisabled(bool autoDetectDisabled)
0211 {
0212     d->autoDetectLanguageDisabled = autoDetectDisabled;
0213 }
0214 
0215 void SpellcheckHighlighter::slotAutoDetection()
0216 {
0217     bool savedActive = d->active;
0218 
0219     // don't disable just because 1 of 4 is misspelled.
0220     if (d->automatic && d->wordCount >= 10) {
0221         // tme = Too many errors
0222         /* clang-format off */
0223         bool tme = (d->errorCount >= d->disableWordCount)
0224                    && (d->errorCount * 100 >= d->disablePercentage * d->wordCount);
0225         /* clang-format on */
0226 
0227         if (d->active && tme) {
0228             d->active = false;
0229         } else if (!d->active && !tme) {
0230             d->active = true;
0231         }
0232     }
0233 
0234     if (d->active != savedActive) {
0235         if (d->active) {
0236             Q_EMIT activeChanged(tr("As-you-type spell checking enabled."));
0237         } else {
0238             qCDebug(SONNET_LOG_QUICK) << "Sonnet: Disabling spell checking, too many errors";
0239             Q_EMIT activeChanged(
0240                 tr("Too many misspelled words. "
0241                    "As-you-type spell checking disabled."));
0242         }
0243 
0244         d->completeRehighlightRequired = true;
0245         d->rehighlightRequest->setInterval(100);
0246         d->rehighlightRequest->setSingleShot(true);
0247     }
0248 }
0249 
0250 void SpellcheckHighlighter::setActive(bool active)
0251 {
0252     if (active == d->active) {
0253         return;
0254     }
0255     d->active = active;
0256     Q_EMIT activeChanged();
0257     rehighlight();
0258 
0259     if (d->active) {
0260         Q_EMIT activeChanged(tr("As-you-type spell checking enabled."));
0261     } else {
0262         Q_EMIT activeChanged(tr("As-you-type spell checking disabled."));
0263     }
0264 }
0265 
0266 bool SpellcheckHighlighter::active() const
0267 {
0268     return d->active;
0269 }
0270 
0271 static bool hasNotEmptyText(const QString &text)
0272 {
0273     for (int i = 0; i < text.length(); ++i) {
0274         if (!text.at(i).isSpace()) {
0275             return true;
0276         }
0277     }
0278     return false;
0279 }
0280 
0281 void SpellcheckHighlighter::contentsChange(int pos, int add, int rem)
0282 {
0283     // Invalidate the cache where the text has changed
0284     const QTextBlock &lastBlock = document()->findBlock(pos + add - rem);
0285     QTextBlock block = document()->findBlock(pos);
0286     do {
0287         LanguageCache *cache = dynamic_cast<LanguageCache *>(block.userData());
0288         if (cache) {
0289             cache->invalidate(pos - block.position());
0290         }
0291         block = block.next();
0292     } while (block.isValid() && block < lastBlock);
0293 }
0294 
0295 void SpellcheckHighlighter::highlightBlock(const QString &text)
0296 {
0297     if (!hasNotEmptyText(text) || !d->active || !d->spellCheckerFound) {
0298         return;
0299     }
0300 
0301     // Avoid spellchecking quotes
0302     if (text.isEmpty() || text.at(0) == QLatin1Char('>')) {
0303         setFormat(0, text.length(), d->quoteFormat);
0304         return;
0305     }
0306 
0307     if (!d->connected) {
0308         connect(textDocument(), &QTextDocument::contentsChange, this, &SpellcheckHighlighter::contentsChange);
0309         d->connected = true;
0310     }
0311     QTextCursor cursor = textCursor();
0312     const int index = cursor.position() + 1;
0313 
0314     const int lengthPosition = text.length() - 1;
0315 
0316     if (index != lengthPosition //
0317         || (lengthPosition > 0 && !text[lengthPosition - 1].isLetter())) {
0318         d->languageFilter->setBuffer(text);
0319 
0320         LanguageCache *cache = dynamic_cast<LanguageCache *>(currentBlockUserData());
0321         if (!cache) {
0322             cache = new LanguageCache;
0323             setCurrentBlockUserData(cache);
0324         }
0325 
0326         const bool autodetectLanguage = d->spellchecker->testAttribute(Speller::AutoDetectLanguage);
0327         while (d->languageFilter->hasNext()) {
0328             Sonnet::Token sentence = d->languageFilter->next();
0329             if (autodetectLanguage && !d->autoDetectLanguageDisabled) {
0330                 QString lang;
0331                 QPair<int, int> spos = QPair<int, int>(sentence.position(), sentence.length());
0332                 // try cache first
0333                 if (cache->languages.contains(spos)) {
0334                     lang = cache->languages.value(spos);
0335                 } else {
0336                     lang = d->languageFilter->language();
0337                     if (!d->languageFilter->isSpellcheckable()) {
0338                         lang.clear();
0339                     }
0340                     cache->languages[spos] = lang;
0341                 }
0342                 if (lang.isEmpty()) {
0343                     continue;
0344                 }
0345                 d->spellchecker->setLanguage(lang);
0346             }
0347 
0348             d->tokenizer->setBuffer(sentence.toString());
0349             int offset = sentence.position();
0350             while (d->tokenizer->hasNext()) {
0351                 Sonnet::Token word = d->tokenizer->next();
0352                 if (!d->tokenizer->isSpellcheckable()) {
0353                     continue;
0354                 }
0355                 ++d->wordCount;
0356                 if (d->spellchecker->isMisspelled(word.toString())) {
0357                     ++d->errorCount;
0358                     setMisspelled(word.position() + offset, word.length());
0359                 } else {
0360                     unsetMisspelled(word.position() + offset, word.length());
0361                 }
0362             }
0363         }
0364     }
0365     // QTimer::singleShot( 0, this, SLOT(checkWords()) );
0366     setCurrentBlockState(0);
0367 }
0368 
0369 QStringList SpellcheckHighlighter::suggestions(int mousePosition, int max)
0370 {
0371     if (!textDocument()) {
0372         return {};
0373     }
0374 
0375     QTextCursor cursor = textCursor();
0376 
0377     QTextCursor cursorAtMouse(textDocument());
0378     cursorAtMouse.setPosition(mousePosition);
0379 
0380     // Check if the user clicked a selected word
0381     const bool selectedWordClicked = cursor.hasSelection() && mousePosition >= cursor.selectionStart() && mousePosition <= cursor.selectionEnd();
0382 
0383     // Get the word under the (mouse-)cursor and see if it is misspelled.
0384     // Don't include apostrophes at the start/end of the word in the selection.
0385     QTextCursor wordSelectCursor(cursorAtMouse);
0386     wordSelectCursor.clearSelection();
0387     wordSelectCursor.select(QTextCursor::WordUnderCursor);
0388     d->selectedWord = wordSelectCursor.selectedText();
0389 
0390     // Clear the selection again, we re-select it below (without the apostrophes).
0391     wordSelectCursor.setPosition(wordSelectCursor.position() - d->selectedWord.size());
0392     if (d->selectedWord.startsWith(QLatin1Char('\'')) || d->selectedWord.startsWith(QLatin1Char('\"'))) {
0393         d->selectedWord = d->selectedWord.right(d->selectedWord.size() - 1);
0394         wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor);
0395     }
0396     if (d->selectedWord.endsWith(QLatin1Char('\'')) || d->selectedWord.endsWith(QLatin1Char('\"'))) {
0397         d->selectedWord.chop(1);
0398     }
0399 
0400     wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, d->selectedWord.size());
0401 
0402     int endSelection = wordSelectCursor.selectionEnd();
0403     Q_EMIT wordUnderMouseChanged();
0404 
0405     bool isMouseCursorInsideWord = true;
0406     if ((mousePosition < wordSelectCursor.selectionStart() || mousePosition >= wordSelectCursor.selectionEnd()) //
0407         && (d->selectedWord.length() > 1)) {
0408         isMouseCursorInsideWord = false;
0409     }
0410 
0411     wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, d->selectedWord.size());
0412 
0413     d->wordIsMisspelled = isMouseCursorInsideWord && !d->selectedWord.isEmpty() && d->spellchecker->isMisspelled(d->selectedWord);
0414     Q_EMIT wordIsMisspelledChanged();
0415 
0416     if (!d->wordIsMisspelled || selectedWordClicked) {
0417         return QStringList{};
0418     }
0419 
0420     if (!selectedWordClicked) {
0421         Q_EMIT changeCursorPosition(wordSelectCursor.selectionStart(), endSelection);
0422     }
0423 
0424     LanguageCache *cache = dynamic_cast<LanguageCache *>(cursor.block().userData());
0425     if (cache) {
0426         const QString cachedLanguage = cache->languageAtPos(cursor.positionInBlock());
0427         if (!cachedLanguage.isEmpty()) {
0428             d->spellchecker->setLanguage(cachedLanguage);
0429         }
0430     }
0431     QStringList suggestions = d->spellchecker->suggest(d->selectedWord);
0432     if (max >= 0 && suggestions.count() > max) {
0433         suggestions = suggestions.mid(0, max);
0434     }
0435 
0436     return suggestions;
0437 }
0438 
0439 QString SpellcheckHighlighter::currentLanguage() const
0440 {
0441     return d->spellchecker->language();
0442 }
0443 
0444 void SpellcheckHighlighter::setCurrentLanguage(const QString &lang)
0445 {
0446     QString prevLang = d->spellchecker->language();
0447     d->spellchecker->setLanguage(lang);
0448     d->spellCheckerFound = d->spellchecker->isValid();
0449     if (!d->spellCheckerFound) {
0450         qCDebug(SONNET_LOG_QUICK) << "No dictionary for \"" << lang << "\" staying with the current language.";
0451         d->spellchecker->setLanguage(prevLang);
0452         return;
0453     }
0454     d->wordCount = 0;
0455     d->errorCount = 0;
0456     if (d->automatic || d->active) {
0457         d->rehighlightRequest->start(0);
0458     }
0459 }
0460 
0461 void SpellcheckHighlighter::setMisspelled(int start, int count)
0462 {
0463     setFormat(start, count, d->errorFormat);
0464 }
0465 
0466 void SpellcheckHighlighter::unsetMisspelled(int start, int count)
0467 {
0468     setFormat(start, count, QTextCharFormat());
0469 }
0470 
0471 void SpellcheckHighlighter::addWordToDictionary(const QString &word)
0472 {
0473     d->spellchecker->addToPersonal(word);
0474     rehighlight();
0475 }
0476 
0477 void SpellcheckHighlighter::ignoreWord(const QString &word)
0478 {
0479     d->spellchecker->addToSession(word);
0480     rehighlight();
0481 }
0482 
0483 void SpellcheckHighlighter::replaceWord(const QString &replacement)
0484 {
0485     textCursor().insertText(replacement);
0486 }
0487 
0488 QQuickTextDocument *SpellcheckHighlighter::quickDocument() const
0489 {
0490     return d->document;
0491 }
0492 
0493 void SpellcheckHighlighter::setQuickDocument(QQuickTextDocument *document)
0494 {
0495     if (document == d->document) {
0496         return;
0497     }
0498 
0499     if (d->document) {
0500         d->document->parent()->removeEventFilter(this);
0501         d->document->textDocument()->disconnect(this);
0502     }
0503     d->document = document;
0504     document->parent()->installEventFilter(this);
0505     setDocument(document->textDocument());
0506     Q_EMIT documentChanged();
0507 }
0508 
0509 void SpellcheckHighlighter::setDocument(QTextDocument *document)
0510 {
0511     d->connected = false;
0512     QSyntaxHighlighter::setDocument(document);
0513 }
0514 
0515 int SpellcheckHighlighter::cursorPosition() const
0516 {
0517     return d->cursorPosition;
0518 }
0519 
0520 void SpellcheckHighlighter::setCursorPosition(int position)
0521 {
0522     if (position == d->cursorPosition) {
0523         return;
0524     }
0525 
0526     d->cursorPosition = position;
0527     Q_EMIT cursorPositionChanged();
0528 }
0529 
0530 int SpellcheckHighlighter::selectionStart() const
0531 {
0532     return d->selectionStart;
0533 }
0534 
0535 void SpellcheckHighlighter::setSelectionStart(int position)
0536 {
0537     if (position == d->selectionStart) {
0538         return;
0539     }
0540 
0541     d->selectionStart = position;
0542     Q_EMIT selectionStartChanged();
0543 }
0544 
0545 int SpellcheckHighlighter::selectionEnd() const
0546 {
0547     return d->selectionEnd;
0548 }
0549 
0550 void SpellcheckHighlighter::setSelectionEnd(int position)
0551 {
0552     if (position == d->selectionEnd) {
0553         return;
0554     }
0555 
0556     d->selectionEnd = position;
0557     Q_EMIT selectionEndChanged();
0558 }
0559 
0560 QTextCursor SpellcheckHighlighter::textCursor() const
0561 {
0562     QTextDocument *doc = textDocument();
0563     if (!doc) {
0564         return QTextCursor();
0565     }
0566 
0567     QTextCursor cursor(doc);
0568     if (d->selectionStart != d->selectionEnd) {
0569         cursor.setPosition(d->selectionStart);
0570         cursor.setPosition(d->selectionEnd, QTextCursor::KeepAnchor);
0571     } else {
0572         cursor.setPosition(d->cursorPosition);
0573     }
0574     return cursor;
0575 }
0576 
0577 QTextDocument *SpellcheckHighlighter::textDocument() const
0578 {
0579     if (!d->document) {
0580         return nullptr;
0581     }
0582 
0583     return d->document->textDocument();
0584 }
0585 
0586 bool SpellcheckHighlighter::wordIsMisspelled() const
0587 {
0588     return d->wordIsMisspelled;
0589 }
0590 
0591 QString SpellcheckHighlighter::wordUnderMouse() const
0592 {
0593     return d->selectedWord;
0594 }
0595 
0596 QColor SpellcheckHighlighter::misspelledColor() const
0597 {
0598     return d->spellColor;
0599 }
0600 
0601 void SpellcheckHighlighter::setMisspelledColor(const QColor &color)
0602 {
0603     if (color == d->spellColor) {
0604         return;
0605     }
0606     d->spellColor = color;
0607     Q_EMIT misspelledColorChanged();
0608 }
0609 
0610 bool SpellcheckHighlighter::isWordMisspelled(const QString &word)
0611 {
0612     return d->spellchecker->isMisspelled(word);
0613 }
0614 
0615 bool SpellcheckHighlighter::eventFilter(QObject *o, QEvent *e)
0616 {
0617     if (!d->spellCheckerFound) {
0618         return false;
0619     }
0620     if (o == d->document->parent() && (e->type() == QEvent::KeyPress)) {
0621         QKeyEvent *k = static_cast<QKeyEvent *>(e);
0622 
0623         if (k->key() == Qt::Key_Enter || k->key() == Qt::Key_Return || k->key() == Qt::Key_Up || k->key() == Qt::Key_Down || k->key() == Qt::Key_Left
0624             || k->key() == Qt::Key_Right || k->key() == Qt::Key_PageUp || k->key() == Qt::Key_PageDown || k->key() == Qt::Key_Home || k->key() == Qt::Key_End
0625             || (k->modifiers() == Qt::ControlModifier
0626                 && (k->key() == Qt::Key_A || k->key() == Qt::Key_B || k->key() == Qt::Key_E || k->key() == Qt::Key_N
0627                     || k->key() == Qt::Key_P))) { /* clang-format on */
0628             if (intraWordEditing()) {
0629                 setIntraWordEditing(false);
0630                 d->completeRehighlightRequired = true;
0631                 d->rehighlightRequest->setInterval(500);
0632                 d->rehighlightRequest->setSingleShot(true);
0633                 d->rehighlightRequest->start();
0634             }
0635         } else {
0636             setIntraWordEditing(true);
0637         }
0638         if (k->key() == Qt::Key_Space //
0639             || k->key() == Qt::Key_Enter //
0640             || k->key() == Qt::Key_Return) {
0641             QTimer::singleShot(0, this, SLOT(slotAutoDetection()));
0642         }
0643     } else if (d->document && e->type() == QEvent::MouseButtonPress) {
0644         if (intraWordEditing()) {
0645             setIntraWordEditing(false);
0646             d->completeRehighlightRequired = true;
0647             d->rehighlightRequest->setInterval(0);
0648             d->rehighlightRequest->setSingleShot(true);
0649             d->rehighlightRequest->start();
0650         }
0651     }
0652     return false;
0653 }
0654 
0655 #include "moc_spellcheckhighlighter.cpp"