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"