1/*
2 * highlighter.cpp
3 *
4 * SPDX-FileCopyrightText: 2004 Zack Rusin <zack@kde.org>
5 * SPDX-FileCopyrightText: 2006 Laurent Montel <montel@kde.org>
6 * SPDX-FileCopyrightText: 2013 Martin Sandsmark <martin.sandsmark@org>
7 *
8 * SPDX-License-Identifier: LGPL-2.1-or-later
9 */
10
11#include "highlighter.h"
12
13#include "languagefilter_p.h"
14#include "loader_p.h"
15#include "settingsimpl_p.h"
16#include "speller.h"
17#include "tokenizer_p.h"
18
19#include "ui_debug.h"
20
21#include <QColor>
22#include <QEvent>
23#include <QHash>
24#include <QKeyEvent>
25#include <QMetaMethod>
26#include <QPlainTextEdit>
27#include <QTextCharFormat>
28#include <QTextCursor>
29#include <QTextEdit>
30#include <QTimer>
31
32namespace Sonnet
33{
34// Cache of previously-determined languages (when using AutoDetectLanguage)
35// There is one such cache per block (paragraph)
36class LanguageCache : public QTextBlockUserData
37{
38public:
39 // Key: QPair<start, length>
40 // Value: language name
41 QMap<QPair<int, int>, QString> languages;
42
43 // Remove all cached language information after @p pos
44 void invalidate(int pos)
45 {
46 QMutableMapIterator<QPair<int, int>, QString> it(languages);
47 it.toBack();
48 while (it.hasPrevious()) {
49 it.previous();
50 if (it.key().first + it.key().second >= pos) {
51 it.remove();
52 } else {
53 break;
54 }
55 }
56 }
57
58 QString languageAtPos(int pos) const
59 {
60 // The data structure isn't really great for such lookups...
61 QMapIterator<QPair<int, int>, QString> it(languages);
62 while (it.hasNext()) {
63 it.next();
64 if (it.key().first <= pos && it.key().first + it.key().second >= pos) {
65 return it.value();
66 }
67 }
68 return QString();
69 }
70};
71
72class HighlighterPrivate
73{
74public:
75 HighlighterPrivate(Highlighter *qq, const QColor &col)
76 : textEdit(nullptr)
77 , plainTextEdit(nullptr)
78 , spellColor(col)
79 , q(qq)
80 {
81 tokenizer = new WordTokenizer();
82 active = true;
83 automatic = false;
84 autoDetectLanguageDisabled = false;
85 wordCount = 0;
86 errorCount = 0;
87 intraWordEditing = false;
88 completeRehighlightRequired = false;
89 spellColor = spellColor.isValid() ? spellColor : Qt::red;
90 languageFilter = new LanguageFilter(new SentenceTokenizer());
91
92 loader = Loader::openLoader();
93 loader->settings()->restore();
94
95 spellchecker = new Sonnet::Speller();
96 spellCheckerFound = spellchecker->isValid();
97 rehighlightRequest = new QTimer(q);
98 q->connect(sender: rehighlightRequest, signal: &QTimer::timeout, context: q, slot: &Highlighter::slotRehighlight);
99
100 if (!spellCheckerFound) {
101 return;
102 }
103
104 disablePercentage = loader->settings()->disablePercentageWordError();
105 disableWordCount = loader->settings()->disableWordErrorCount();
106
107 completeRehighlightRequired = true;
108 rehighlightRequest->setInterval(0);
109 rehighlightRequest->setSingleShot(true);
110 rehighlightRequest->start();
111 }
112
113 ~HighlighterPrivate();
114 WordTokenizer *tokenizer = nullptr;
115 LanguageFilter *languageFilter = nullptr;
116 Loader *loader = nullptr;
117 Speller *spellchecker = nullptr;
118 QTextEdit *textEdit = nullptr;
119 QPlainTextEdit *plainTextEdit = nullptr;
120 bool active;
121 bool automatic;
122 bool autoDetectLanguageDisabled;
123 bool completeRehighlightRequired;
124 bool intraWordEditing;
125 bool spellCheckerFound; // cached d->dict->isValid() value
126 QMetaObject::Connection contentsChangeConnection;
127 int disablePercentage = 0;
128 int disableWordCount = 0;
129 int wordCount, errorCount;
130 QTimer *rehighlightRequest = nullptr;
131 QColor spellColor;
132 Highlighter *const q;
133};
134
135HighlighterPrivate::~HighlighterPrivate()
136{
137 delete spellchecker;
138 delete languageFilter;
139 delete tokenizer;
140}
141
142Highlighter::Highlighter(QTextEdit *edit, const QColor &_col)
143 : QSyntaxHighlighter(edit)
144 , d(new HighlighterPrivate(this, _col))
145{
146 d->textEdit = edit;
147 d->textEdit->installEventFilter(filterObj: this);
148 d->textEdit->viewport()->installEventFilter(filterObj: this);
149}
150
151Highlighter::Highlighter(QPlainTextEdit *edit, const QColor &col)
152 : QSyntaxHighlighter(edit)
153 , d(new HighlighterPrivate(this, col))
154{
155 d->plainTextEdit = edit;
156 setDocument(d->plainTextEdit->document());
157 d->plainTextEdit->installEventFilter(filterObj: this);
158 d->plainTextEdit->viewport()->installEventFilter(filterObj: this);
159}
160
161Highlighter::~Highlighter()
162{
163 if (d->contentsChangeConnection) {
164 // prevent crash from QSyntaxHighlighter::~QSyntaxHighlighter -> (...) -> QTextDocument::contentsChange() signal emission:
165 // ASSERT failure in Sonnet::Highlighter: "Called object is not of the correct type (class destructor may have already run)"
166 QObject::disconnect(d->contentsChangeConnection);
167 }
168}
169
170bool Highlighter::spellCheckerFound() const
171{
172 return d->spellCheckerFound;
173}
174
175void Highlighter::slotRehighlight()
176{
177 if (d->completeRehighlightRequired) {
178 d->wordCount = 0;
179 d->errorCount = 0;
180 rehighlight();
181 } else {
182 // rehighlight the current para only (undo/redo safe)
183 QTextCursor cursor;
184 if (d->textEdit) {
185 cursor = d->textEdit->textCursor();
186 } else {
187 cursor = d->plainTextEdit->textCursor();
188 }
189 if (cursor.hasSelection()) {
190 cursor.clearSelection();
191 }
192 cursor.insertText(text: QString());
193 }
194 // if (d->checksDone == d->checksRequested)
195 // d->completeRehighlightRequired = false;
196 QTimer::singleShot(msec: 0, receiver: this, SLOT(slotAutoDetection()));
197}
198
199bool Highlighter::automatic() const
200{
201 return d->automatic;
202}
203
204bool Highlighter::autoDetectLanguageDisabled() const
205{
206 return d->autoDetectLanguageDisabled;
207}
208
209bool Highlighter::intraWordEditing() const
210{
211 return d->intraWordEditing;
212}
213
214void Highlighter::setIntraWordEditing(bool editing)
215{
216 d->intraWordEditing = editing;
217}
218
219void Highlighter::setAutomatic(bool automatic)
220{
221 if (automatic == d->automatic) {
222 return;
223 }
224
225 d->automatic = automatic;
226 if (d->automatic) {
227 slotAutoDetection();
228 }
229}
230
231void Highlighter::setAutoDetectLanguageDisabled(bool autoDetectDisabled)
232{
233 d->autoDetectLanguageDisabled = autoDetectDisabled;
234}
235
236void Highlighter::slotAutoDetection()
237{
238 bool savedActive = d->active;
239
240 // don't disable just because 1 of 4 is misspelled.
241 if (d->automatic && d->wordCount >= 10) {
242 // tme = Too many errors
243 /* clang-format off */
244 bool tme = (d->errorCount >= d->disableWordCount)
245 && (d->errorCount * 100 >= d->disablePercentage * d->wordCount);
246 /* clang-format on */
247
248 if (d->active && tme) {
249 d->active = false;
250 } else if (!d->active && !tme) {
251 d->active = true;
252 }
253 }
254
255 if (d->active != savedActive) {
256 if (d->active) {
257 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking enabled."));
258 } else {
259 qCDebug(SONNET_LOG_UI) << "Sonnet: Disabling spell checking, too many errors";
260 Q_EMIT activeChanged(
261 description: tr(s: "Too many misspelled words. "
262 "As-you-type spell checking disabled."));
263 }
264
265 d->completeRehighlightRequired = true;
266 d->rehighlightRequest->setInterval(100);
267 d->rehighlightRequest->setSingleShot(true);
268 }
269}
270
271void Highlighter::setActive(bool active)
272{
273 if (active == d->active) {
274 return;
275 }
276 d->active = active;
277 rehighlight();
278
279 if (d->active) {
280 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking enabled."));
281 } else {
282 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking disabled."));
283 }
284}
285
286bool Highlighter::isActive() const
287{
288 return d->active;
289}
290
291void Highlighter::contentsChange(int pos, int add, int rem)
292{
293 // Invalidate the cache where the text has changed
294 const QTextBlock &lastBlock = document()->findBlock(pos: pos + add - rem);
295 QTextBlock block = document()->findBlock(pos);
296 do {
297 LanguageCache *cache = dynamic_cast<LanguageCache *>(block.userData());
298 if (cache) {
299 cache->invalidate(pos: pos - block.position());
300 }
301 block = block.next();
302 } while (block.isValid() && block < lastBlock);
303}
304
305static bool hasNotEmptyText(const QString &text)
306{
307 for (int i = 0; i < text.length(); ++i) {
308 if (!text.at(i).isSpace()) {
309 return true;
310 }
311 }
312 return false;
313}
314
315void Highlighter::highlightBlock(const QString &text)
316{
317 if (!hasNotEmptyText(text) || !d->active || !d->spellCheckerFound) {
318 return;
319 }
320
321 if (!d->contentsChangeConnection) {
322 d->contentsChangeConnection = connect(sender: document(), signal: &QTextDocument::contentsChange, context: this, slot: &Highlighter::contentsChange);
323 }
324
325 d->languageFilter->setBuffer(text);
326
327 LanguageCache *cache = dynamic_cast<LanguageCache *>(currentBlockUserData());
328 if (!cache) {
329 cache = new LanguageCache;
330 setCurrentBlockUserData(cache);
331 }
332
333 const bool autodetectLanguage = d->spellchecker->testAttribute(attr: Speller::AutoDetectLanguage);
334 while (d->languageFilter->hasNext()) {
335 Token sentence = d->languageFilter->next();
336 if (autodetectLanguage && !d->autoDetectLanguageDisabled) {
337 QString lang;
338 QPair<int, int> spos = QPair<int, int>(sentence.position(), sentence.length());
339 // try cache first
340 if (cache->languages.contains(key: spos)) {
341 lang = cache->languages.value(key: spos);
342 } else {
343 lang = d->languageFilter->language();
344 if (!d->languageFilter->isSpellcheckable()) {
345 lang.clear();
346 }
347 cache->languages[spos] = lang;
348 }
349 if (lang.isEmpty()) {
350 continue;
351 }
352 d->spellchecker->setLanguage(lang);
353 }
354
355 d->tokenizer->setBuffer(sentence.toString());
356 int offset = sentence.position();
357 while (d->tokenizer->hasNext()) {
358 Token word = d->tokenizer->next();
359 if (!d->tokenizer->isSpellcheckable()) {
360 continue;
361 }
362 ++d->wordCount;
363 if (d->spellchecker->isMisspelled(word: word.toString())) {
364 ++d->errorCount;
365 setMisspelled(start: word.position() + offset, count: word.length());
366 } else {
367 unsetMisspelled(start: word.position() + offset, count: word.length());
368 }
369 }
370 }
371 // QTimer::singleShot( 0, this, SLOT(checkWords()) );
372 setCurrentBlockState(0);
373}
374
375QString Highlighter::currentLanguage() const
376{
377 return d->spellchecker->language();
378}
379
380void Highlighter::setCurrentLanguage(const QString &lang)
381{
382 QString prevLang = d->spellchecker->language();
383 d->spellchecker->setLanguage(lang);
384 d->spellCheckerFound = d->spellchecker->isValid();
385 if (!d->spellCheckerFound) {
386 qCDebug(SONNET_LOG_UI) << "No dictionary for \"" << lang << "\" staying with the current language.";
387 d->spellchecker->setLanguage(prevLang);
388 return;
389 }
390 d->wordCount = 0;
391 d->errorCount = 0;
392 if (d->automatic || d->active) {
393 d->rehighlightRequest->start(msec: 0);
394 }
395}
396
397void Highlighter::setMisspelled(int start, int count)
398{
399 QTextCharFormat format;
400 format.setFontUnderline(true);
401 format.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
402 format.setUnderlineColor(d->spellColor);
403 setFormat(start, count, format);
404}
405
406void Highlighter::unsetMisspelled(int start, int count)
407{
408 setFormat(start, count, format: QTextCharFormat());
409}
410
411bool Highlighter::eventFilter(QObject *o, QEvent *e)
412{
413 if (!d->spellCheckerFound) {
414 return false;
415 }
416 if ((o == d->textEdit || o == d->plainTextEdit) && (e->type() == QEvent::KeyPress)) {
417 QKeyEvent *k = static_cast<QKeyEvent *>(e);
418 // d->autoReady = true;
419 if (d->rehighlightRequest->isActive()) { // try to stay out of the users way
420 d->rehighlightRequest->start(msec: 500);
421 }
422 /* clang-format off */
423 if (k->key() == Qt::Key_Enter
424 || k->key() == Qt::Key_Return
425 || k->key() == Qt::Key_Up
426 || k->key() == Qt::Key_Down
427 || k->key() == Qt::Key_Left
428 || k->key() == Qt::Key_Right
429 || k->key() == Qt::Key_PageUp
430 || k->key() == Qt::Key_PageDown
431 || k->key() == Qt::Key_Home
432 || k->key() == Qt::Key_End
433 || (k->modifiers() == Qt::ControlModifier
434 && (k->key() == Qt::Key_A
435 || k->key() == Qt::Key_B
436 || k->key() == Qt::Key_E
437 || k->key() == Qt::Key_N
438 || k->key() == Qt::Key_P))) { /* clang-format on */
439 if (intraWordEditing()) {
440 setIntraWordEditing(false);
441 d->completeRehighlightRequired = true;
442 d->rehighlightRequest->setInterval(500);
443 d->rehighlightRequest->setSingleShot(true);
444 d->rehighlightRequest->start();
445 }
446 } else {
447 setIntraWordEditing(true);
448 }
449 if (k->key() == Qt::Key_Space //
450 || k->key() == Qt::Key_Enter //
451 || k->key() == Qt::Key_Return) {
452 QTimer::singleShot(msec: 0, receiver: this, SLOT(slotAutoDetection()));
453 }
454 } else if (((d->textEdit && (o == d->textEdit->viewport())) //
455 || (d->plainTextEdit && (o == d->plainTextEdit->viewport()))) //
456 && (e->type() == QEvent::MouseButtonPress)) {
457 // d->autoReady = true;
458 if (intraWordEditing()) {
459 setIntraWordEditing(false);
460 d->completeRehighlightRequired = true;
461 d->rehighlightRequest->setInterval(0);
462 d->rehighlightRequest->setSingleShot(true);
463 d->rehighlightRequest->start();
464 }
465 }
466 return false;
467}
468
469void Highlighter::addWordToDictionary(const QString &word)
470{
471 d->spellchecker->addToPersonal(word);
472}
473
474void Highlighter::ignoreWord(const QString &word)
475{
476 d->spellchecker->addToSession(word);
477}
478
479QStringList Highlighter::suggestionsForWord(const QString &word, int max)
480{
481 QStringList suggestions = d->spellchecker->suggest(word);
482 if (max >= 0 && suggestions.count() > max) {
483 suggestions = suggestions.mid(pos: 0, len: max);
484 }
485 return suggestions;
486}
487
488QStringList Highlighter::suggestionsForWord(const QString &word, const QTextCursor &cursor, int max)
489{
490 LanguageCache *cache = dynamic_cast<LanguageCache *>(cursor.block().userData());
491 if (cache) {
492 const QString cachedLanguage = cache->languageAtPos(pos: cursor.positionInBlock());
493 if (!cachedLanguage.isEmpty()) {
494 d->spellchecker->setLanguage(cachedLanguage);
495 }
496 }
497 QStringList suggestions = d->spellchecker->suggest(word);
498 if (max >= 0 && suggestions.count() > max) {
499 suggestions = suggestions.mid(pos: 0, len: max);
500 }
501 return suggestions;
502}
503
504bool Highlighter::isWordMisspelled(const QString &word)
505{
506 return d->spellchecker->isMisspelled(word);
507}
508
509void Highlighter::setMisspelledColor(const QColor &color)
510{
511 d->spellColor = color;
512}
513
514bool Highlighter::checkerEnabledByDefault() const
515{
516 return d->loader->settings()->checkerEnabledByDefault();
517}
518
519void Highlighter::setDocument(QTextDocument *document)
520{
521 d->contentsChangeConnection = {};
522 QSyntaxHighlighter::setDocument(document);
523}
524}
525
526#include "moc_highlighter.cpp"
527

source code of sonnet/src/ui/highlighter.cpp