File indexing completed on 2024-04-28 04:00:55
0001 /* 0002 * highlighter.cpp 0003 * 0004 * SPDX-FileCopyrightText: 2004 Zack Rusin <zack@kde.org> 0005 * SPDX-FileCopyrightText: 2006 Laurent Montel <montel@kde.org> 0006 * SPDX-FileCopyrightText: 2013 Martin Sandsmark <martin.sandsmark@org> 0007 * 0008 * SPDX-License-Identifier: LGPL-2.1-or-later 0009 */ 0010 0011 #include "highlighter.h" 0012 0013 #include "languagefilter_p.h" 0014 #include "loader_p.h" 0015 #include "settingsimpl_p.h" 0016 #include "speller.h" 0017 #include "tokenizer_p.h" 0018 0019 #include "ui_debug.h" 0020 0021 #include <QColor> 0022 #include <QEvent> 0023 #include <QHash> 0024 #include <QKeyEvent> 0025 #include <QMetaMethod> 0026 #include <QPlainTextEdit> 0027 #include <QTextCharFormat> 0028 #include <QTextCursor> 0029 #include <QTextEdit> 0030 #include <QTimer> 0031 0032 namespace Sonnet 0033 { 0034 // Cache of previously-determined languages (when using AutoDetectLanguage) 0035 // There is one such cache per block (paragraph) 0036 class LanguageCache : public QTextBlockUserData 0037 { 0038 public: 0039 // Key: QPair<start, length> 0040 // Value: language name 0041 QMap<QPair<int, int>, QString> languages; 0042 0043 // Remove all cached language information after @p pos 0044 void invalidate(int pos) 0045 { 0046 QMutableMapIterator<QPair<int, int>, QString> it(languages); 0047 it.toBack(); 0048 while (it.hasPrevious()) { 0049 it.previous(); 0050 if (it.key().first + it.key().second >= pos) { 0051 it.remove(); 0052 } else { 0053 break; 0054 } 0055 } 0056 } 0057 0058 QString languageAtPos(int pos) const 0059 { 0060 // The data structure isn't really great for such lookups... 0061 QMapIterator<QPair<int, int>, QString> it(languages); 0062 while (it.hasNext()) { 0063 it.next(); 0064 if (it.key().first <= pos && it.key().first + it.key().second >= pos) { 0065 return it.value(); 0066 } 0067 } 0068 return QString(); 0069 } 0070 }; 0071 0072 class HighlighterPrivate 0073 { 0074 public: 0075 HighlighterPrivate(Highlighter *qq, const QColor &col) 0076 : textEdit(nullptr) 0077 , plainTextEdit(nullptr) 0078 , spellColor(col) 0079 , q(qq) 0080 { 0081 tokenizer = new WordTokenizer(); 0082 active = true; 0083 automatic = false; 0084 autoDetectLanguageDisabled = false; 0085 wordCount = 0; 0086 errorCount = 0; 0087 intraWordEditing = false; 0088 completeRehighlightRequired = false; 0089 spellColor = spellColor.isValid() ? spellColor : Qt::red; 0090 languageFilter = new LanguageFilter(new SentenceTokenizer()); 0091 0092 loader = Loader::openLoader(); 0093 loader->settings()->restore(); 0094 0095 spellchecker = new Sonnet::Speller(); 0096 spellCheckerFound = spellchecker->isValid(); 0097 rehighlightRequest = new QTimer(q); 0098 q->connect(rehighlightRequest, &QTimer::timeout, q, &Highlighter::slotRehighlight); 0099 0100 if (!spellCheckerFound) { 0101 return; 0102 } 0103 0104 disablePercentage = loader->settings()->disablePercentageWordError(); 0105 disableWordCount = loader->settings()->disableWordErrorCount(); 0106 0107 completeRehighlightRequired = true; 0108 rehighlightRequest->setInterval(0); 0109 rehighlightRequest->setSingleShot(true); 0110 rehighlightRequest->start(); 0111 } 0112 0113 ~HighlighterPrivate(); 0114 WordTokenizer *tokenizer = nullptr; 0115 LanguageFilter *languageFilter = nullptr; 0116 Loader *loader = nullptr; 0117 Speller *spellchecker = nullptr; 0118 QTextEdit *textEdit = nullptr; 0119 QPlainTextEdit *plainTextEdit = nullptr; 0120 bool active; 0121 bool automatic; 0122 bool autoDetectLanguageDisabled; 0123 bool completeRehighlightRequired; 0124 bool intraWordEditing; 0125 bool spellCheckerFound; // cached d->dict->isValid() value 0126 QMetaObject::Connection contentsChangeConnection; 0127 int disablePercentage = 0; 0128 int disableWordCount = 0; 0129 int wordCount, errorCount; 0130 QTimer *rehighlightRequest = nullptr; 0131 QColor spellColor; 0132 Highlighter *const q; 0133 }; 0134 0135 HighlighterPrivate::~HighlighterPrivate() 0136 { 0137 delete spellchecker; 0138 delete languageFilter; 0139 delete tokenizer; 0140 } 0141 0142 Highlighter::Highlighter(QTextEdit *edit, const QColor &_col) 0143 : QSyntaxHighlighter(edit) 0144 , d(new HighlighterPrivate(this, _col)) 0145 { 0146 d->textEdit = edit; 0147 d->textEdit->installEventFilter(this); 0148 d->textEdit->viewport()->installEventFilter(this); 0149 } 0150 0151 Highlighter::Highlighter(QPlainTextEdit *edit, const QColor &col) 0152 : QSyntaxHighlighter(edit) 0153 , d(new HighlighterPrivate(this, col)) 0154 { 0155 d->plainTextEdit = edit; 0156 setDocument(d->plainTextEdit->document()); 0157 d->plainTextEdit->installEventFilter(this); 0158 d->plainTextEdit->viewport()->installEventFilter(this); 0159 } 0160 0161 Highlighter::~Highlighter() 0162 { 0163 if (d->contentsChangeConnection) { 0164 // prevent crash from QSyntaxHighlighter::~QSyntaxHighlighter -> (...) -> QTextDocument::contentsChange() signal emission: 0165 // ASSERT failure in Sonnet::Highlighter: "Called object is not of the correct type (class destructor may have already run)" 0166 QObject::disconnect(d->contentsChangeConnection); 0167 } 0168 } 0169 0170 bool Highlighter::spellCheckerFound() const 0171 { 0172 return d->spellCheckerFound; 0173 } 0174 0175 void Highlighter::slotRehighlight() 0176 { 0177 if (d->completeRehighlightRequired) { 0178 d->wordCount = 0; 0179 d->errorCount = 0; 0180 rehighlight(); 0181 } else { 0182 // rehighlight the current para only (undo/redo safe) 0183 QTextCursor cursor; 0184 if (d->textEdit) { 0185 cursor = d->textEdit->textCursor(); 0186 } else { 0187 cursor = d->plainTextEdit->textCursor(); 0188 } 0189 if (cursor.hasSelection()) { 0190 cursor.clearSelection(); 0191 } 0192 cursor.insertText(QString()); 0193 } 0194 // if (d->checksDone == d->checksRequested) 0195 // d->completeRehighlightRequired = false; 0196 QTimer::singleShot(0, this, SLOT(slotAutoDetection())); 0197 } 0198 0199 bool Highlighter::automatic() const 0200 { 0201 return d->automatic; 0202 } 0203 0204 bool Highlighter::autoDetectLanguageDisabled() const 0205 { 0206 return d->autoDetectLanguageDisabled; 0207 } 0208 0209 bool Highlighter::intraWordEditing() const 0210 { 0211 return d->intraWordEditing; 0212 } 0213 0214 void Highlighter::setIntraWordEditing(bool editing) 0215 { 0216 d->intraWordEditing = editing; 0217 } 0218 0219 void Highlighter::setAutomatic(bool automatic) 0220 { 0221 if (automatic == d->automatic) { 0222 return; 0223 } 0224 0225 d->automatic = automatic; 0226 if (d->automatic) { 0227 slotAutoDetection(); 0228 } 0229 } 0230 0231 void Highlighter::setAutoDetectLanguageDisabled(bool autoDetectDisabled) 0232 { 0233 d->autoDetectLanguageDisabled = autoDetectDisabled; 0234 } 0235 0236 void Highlighter::slotAutoDetection() 0237 { 0238 bool savedActive = d->active; 0239 0240 // don't disable just because 1 of 4 is misspelled. 0241 if (d->automatic && d->wordCount >= 10) { 0242 // tme = Too many errors 0243 /* clang-format off */ 0244 bool tme = (d->errorCount >= d->disableWordCount) 0245 && (d->errorCount * 100 >= d->disablePercentage * d->wordCount); 0246 /* clang-format on */ 0247 0248 if (d->active && tme) { 0249 d->active = false; 0250 } else if (!d->active && !tme) { 0251 d->active = true; 0252 } 0253 } 0254 0255 if (d->active != savedActive) { 0256 if (d->active) { 0257 Q_EMIT activeChanged(tr("As-you-type spell checking enabled.")); 0258 } else { 0259 qCDebug(SONNET_LOG_UI) << "Sonnet: Disabling spell checking, too many errors"; 0260 Q_EMIT activeChanged( 0261 tr("Too many misspelled words. " 0262 "As-you-type spell checking disabled.")); 0263 } 0264 0265 d->completeRehighlightRequired = true; 0266 d->rehighlightRequest->setInterval(100); 0267 d->rehighlightRequest->setSingleShot(true); 0268 } 0269 } 0270 0271 void Highlighter::setActive(bool active) 0272 { 0273 if (active == d->active) { 0274 return; 0275 } 0276 d->active = active; 0277 rehighlight(); 0278 0279 if (d->active) { 0280 Q_EMIT activeChanged(tr("As-you-type spell checking enabled.")); 0281 } else { 0282 Q_EMIT activeChanged(tr("As-you-type spell checking disabled.")); 0283 } 0284 } 0285 0286 bool Highlighter::isActive() const 0287 { 0288 return d->active; 0289 } 0290 0291 void Highlighter::contentsChange(int pos, int add, int rem) 0292 { 0293 // Invalidate the cache where the text has changed 0294 const QTextBlock &lastBlock = document()->findBlock(pos + add - rem); 0295 QTextBlock block = document()->findBlock(pos); 0296 do { 0297 LanguageCache *cache = dynamic_cast<LanguageCache *>(block.userData()); 0298 if (cache) { 0299 cache->invalidate(pos - block.position()); 0300 } 0301 block = block.next(); 0302 } while (block.isValid() && block < lastBlock); 0303 } 0304 0305 static bool hasNotEmptyText(const QString &text) 0306 { 0307 for (int i = 0; i < text.length(); ++i) { 0308 if (!text.at(i).isSpace()) { 0309 return true; 0310 } 0311 } 0312 return false; 0313 } 0314 0315 void Highlighter::highlightBlock(const QString &text) 0316 { 0317 if (!hasNotEmptyText(text) || !d->active || !d->spellCheckerFound) { 0318 return; 0319 } 0320 0321 if (!d->contentsChangeConnection) { 0322 d->contentsChangeConnection = connect(document(), &QTextDocument::contentsChange, this, &Highlighter::contentsChange); 0323 } 0324 0325 d->languageFilter->setBuffer(text); 0326 0327 LanguageCache *cache = dynamic_cast<LanguageCache *>(currentBlockUserData()); 0328 if (!cache) { 0329 cache = new LanguageCache; 0330 setCurrentBlockUserData(cache); 0331 } 0332 0333 const bool autodetectLanguage = d->spellchecker->testAttribute(Speller::AutoDetectLanguage); 0334 while (d->languageFilter->hasNext()) { 0335 Token sentence = d->languageFilter->next(); 0336 if (autodetectLanguage && !d->autoDetectLanguageDisabled) { 0337 QString lang; 0338 QPair<int, int> spos = QPair<int, int>(sentence.position(), sentence.length()); 0339 // try cache first 0340 if (cache->languages.contains(spos)) { 0341 lang = cache->languages.value(spos); 0342 } else { 0343 lang = d->languageFilter->language(); 0344 if (!d->languageFilter->isSpellcheckable()) { 0345 lang.clear(); 0346 } 0347 cache->languages[spos] = lang; 0348 } 0349 if (lang.isEmpty()) { 0350 continue; 0351 } 0352 d->spellchecker->setLanguage(lang); 0353 } 0354 0355 d->tokenizer->setBuffer(sentence.toString()); 0356 int offset = sentence.position(); 0357 while (d->tokenizer->hasNext()) { 0358 Token word = d->tokenizer->next(); 0359 if (!d->tokenizer->isSpellcheckable()) { 0360 continue; 0361 } 0362 ++d->wordCount; 0363 if (d->spellchecker->isMisspelled(word.toString())) { 0364 ++d->errorCount; 0365 setMisspelled(word.position() + offset, word.length()); 0366 } else { 0367 unsetMisspelled(word.position() + offset, word.length()); 0368 } 0369 } 0370 } 0371 // QTimer::singleShot( 0, this, SLOT(checkWords()) ); 0372 setCurrentBlockState(0); 0373 } 0374 0375 QString Highlighter::currentLanguage() const 0376 { 0377 return d->spellchecker->language(); 0378 } 0379 0380 void Highlighter::setCurrentLanguage(const QString &lang) 0381 { 0382 QString prevLang = d->spellchecker->language(); 0383 d->spellchecker->setLanguage(lang); 0384 d->spellCheckerFound = d->spellchecker->isValid(); 0385 if (!d->spellCheckerFound) { 0386 qCDebug(SONNET_LOG_UI) << "No dictionary for \"" << lang << "\" staying with the current language."; 0387 d->spellchecker->setLanguage(prevLang); 0388 return; 0389 } 0390 d->wordCount = 0; 0391 d->errorCount = 0; 0392 if (d->automatic || d->active) { 0393 d->rehighlightRequest->start(0); 0394 } 0395 } 0396 0397 void Highlighter::setMisspelled(int start, int count) 0398 { 0399 QTextCharFormat format; 0400 format.setFontUnderline(true); 0401 format.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline); 0402 format.setUnderlineColor(d->spellColor); 0403 setFormat(start, count, format); 0404 } 0405 0406 void Highlighter::unsetMisspelled(int start, int count) 0407 { 0408 setFormat(start, count, QTextCharFormat()); 0409 } 0410 0411 bool Highlighter::eventFilter(QObject *o, QEvent *e) 0412 { 0413 if (!d->spellCheckerFound) { 0414 return false; 0415 } 0416 if ((o == d->textEdit || o == d->plainTextEdit) && (e->type() == QEvent::KeyPress)) { 0417 QKeyEvent *k = static_cast<QKeyEvent *>(e); 0418 // d->autoReady = true; 0419 if (d->rehighlightRequest->isActive()) { // try to stay out of the users way 0420 d->rehighlightRequest->start(500); 0421 } 0422 /* clang-format off */ 0423 if (k->key() == Qt::Key_Enter 0424 || k->key() == Qt::Key_Return 0425 || k->key() == Qt::Key_Up 0426 || k->key() == Qt::Key_Down 0427 || k->key() == Qt::Key_Left 0428 || k->key() == Qt::Key_Right 0429 || k->key() == Qt::Key_PageUp 0430 || k->key() == Qt::Key_PageDown 0431 || k->key() == Qt::Key_Home 0432 || k->key() == Qt::Key_End 0433 || (k->modifiers() == Qt::ControlModifier 0434 && (k->key() == Qt::Key_A 0435 || k->key() == Qt::Key_B 0436 || k->key() == Qt::Key_E 0437 || k->key() == Qt::Key_N 0438 || k->key() == Qt::Key_P))) { /* clang-format on */ 0439 if (intraWordEditing()) { 0440 setIntraWordEditing(false); 0441 d->completeRehighlightRequired = true; 0442 d->rehighlightRequest->setInterval(500); 0443 d->rehighlightRequest->setSingleShot(true); 0444 d->rehighlightRequest->start(); 0445 } 0446 } else { 0447 setIntraWordEditing(true); 0448 } 0449 if (k->key() == Qt::Key_Space // 0450 || k->key() == Qt::Key_Enter // 0451 || k->key() == Qt::Key_Return) { 0452 QTimer::singleShot(0, this, SLOT(slotAutoDetection())); 0453 } 0454 } else if (((d->textEdit && (o == d->textEdit->viewport())) // 0455 || (d->plainTextEdit && (o == d->plainTextEdit->viewport()))) // 0456 && (e->type() == QEvent::MouseButtonPress)) { 0457 // d->autoReady = true; 0458 if (intraWordEditing()) { 0459 setIntraWordEditing(false); 0460 d->completeRehighlightRequired = true; 0461 d->rehighlightRequest->setInterval(0); 0462 d->rehighlightRequest->setSingleShot(true); 0463 d->rehighlightRequest->start(); 0464 } 0465 } 0466 return false; 0467 } 0468 0469 void Highlighter::addWordToDictionary(const QString &word) 0470 { 0471 d->spellchecker->addToPersonal(word); 0472 } 0473 0474 void Highlighter::ignoreWord(const QString &word) 0475 { 0476 d->spellchecker->addToSession(word); 0477 } 0478 0479 QStringList Highlighter::suggestionsForWord(const QString &word, int max) 0480 { 0481 QStringList suggestions = d->spellchecker->suggest(word); 0482 if (max >= 0 && suggestions.count() > max) { 0483 suggestions = suggestions.mid(0, max); 0484 } 0485 return suggestions; 0486 } 0487 0488 QStringList Highlighter::suggestionsForWord(const QString &word, const QTextCursor &cursor, int max) 0489 { 0490 LanguageCache *cache = dynamic_cast<LanguageCache *>(cursor.block().userData()); 0491 if (cache) { 0492 const QString cachedLanguage = cache->languageAtPos(cursor.positionInBlock()); 0493 if (!cachedLanguage.isEmpty()) { 0494 d->spellchecker->setLanguage(cachedLanguage); 0495 } 0496 } 0497 QStringList suggestions = d->spellchecker->suggest(word); 0498 if (max >= 0 && suggestions.count() > max) { 0499 suggestions = suggestions.mid(0, max); 0500 } 0501 return suggestions; 0502 } 0503 0504 bool Highlighter::isWordMisspelled(const QString &word) 0505 { 0506 return d->spellchecker->isMisspelled(word); 0507 } 0508 0509 void Highlighter::setMisspelledColor(const QColor &color) 0510 { 0511 d->spellColor = color; 0512 } 0513 0514 bool Highlighter::checkerEnabledByDefault() const 0515 { 0516 return d->loader->settings()->checkerEnabledByDefault(); 0517 } 0518 0519 void Highlighter::setDocument(QTextDocument *document) 0520 { 0521 d->contentsChangeConnection = {}; 0522 QSyntaxHighlighter::setDocument(document); 0523 } 0524 } 0525 0526 #include "moc_highlighter.cpp"