File indexing completed on 2023-09-24 04:15:31
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"