1// SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
2// SPDX-FileCopyrightText: 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
3// SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
4// SPDX-License-Identifier: LGPL-2.1-or-later
5
6#include "spellcheckhighlighter.h"
7#include "guesslanguage.h"
8#include "languagefilter_p.h"
9#include "loader_p.h"
10#include "settingsimpl_p.h"
11#include "speller.h"
12#include "tokenizer_p.h"
13
14#include "quick_debug.h"
15
16#include <QColor>
17#include <QHash>
18#include <QKeyEvent>
19#include <QMetaMethod>
20#include <QTextBoundaryFinder>
21#include <QTextCharFormat>
22#include <QTextCursor>
23#include <QTimer>
24#include <memory>
25
26using namespace Sonnet;
27
28// Cache of previously-determined languages (when using AutoDetectLanguage)
29// There is one such cache per block (paragraph)
30class LanguageCache : public QTextBlockUserData
31{
32public:
33 // Key: QPair<start, length>
34 // Value: language name
35 QMap<QPair<int, int>, QString> languages;
36
37 // Remove all cached language information after @p pos
38 void invalidate(int pos)
39 {
40 QMutableMapIterator<QPair<int, int>, QString> it(languages);
41 it.toBack();
42 while (it.hasPrevious()) {
43 it.previous();
44 if (it.key().first + it.key().second >= pos) {
45 it.remove();
46 } else {
47 break;
48 }
49 }
50 }
51
52 QString languageAtPos(int pos) const
53 {
54 // The data structure isn't really great for such lookups...
55 QMapIterator<QPair<int, int>, QString> it(languages);
56 while (it.hasNext()) {
57 it.next();
58 if (it.key().first <= pos && it.key().first + it.key().second >= pos) {
59 return it.value();
60 }
61 }
62 return QString();
63 }
64};
65
66class HighlighterPrivate
67{
68public:
69 HighlighterPrivate(SpellcheckHighlighter *qq)
70 : q(qq)
71 {
72 tokenizer = std::make_unique<WordTokenizer>();
73 active = true;
74 automatic = false;
75 autoDetectLanguageDisabled = false;
76 connected = false;
77 wordCount = 0;
78 errorCount = 0;
79 intraWordEditing = false;
80 completeRehighlightRequired = false;
81 spellColor = spellColor.isValid() ? spellColor : Qt::red;
82 languageFilter = std::make_unique<LanguageFilter>(args: new SentenceTokenizer());
83
84 loader = Loader::openLoader();
85 loader->settings()->restore();
86
87 spellchecker = std::make_unique<Speller>();
88 spellCheckerFound = spellchecker->isValid();
89 rehighlightRequest = new QTimer(q);
90 q->connect(sender: rehighlightRequest, signal: &QTimer::timeout, context: q, slot: &SpellcheckHighlighter::slotRehighlight);
91
92 if (!spellCheckerFound) {
93 return;
94 }
95
96 disablePercentage = loader->settings()->disablePercentageWordError();
97 disableWordCount = loader->settings()->disableWordErrorCount();
98
99 completeRehighlightRequired = true;
100 rehighlightRequest->setInterval(0);
101 rehighlightRequest->setSingleShot(true);
102 rehighlightRequest->start();
103
104 // Danger red from our color scheme
105 errorFormat.setForeground(spellColor);
106 errorFormat.setUnderlineColor(spellColor);
107 errorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline);
108
109 selectedErrorFormat.setForeground(spellColor);
110 auto bg = spellColor;
111 bg.setAlphaF(0.1);
112 selectedErrorFormat.setBackground(bg);
113 selectedErrorFormat.setUnderlineColor(spellColor);
114 selectedErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline);
115
116 quoteFormat.setForeground(QColor{"#7f8c8d"});
117 }
118
119 ~HighlighterPrivate();
120 std::unique_ptr<WordTokenizer> tokenizer;
121 std::unique_ptr<LanguageFilter> languageFilter;
122 Loader *loader = nullptr;
123 std::unique_ptr<Speller> spellchecker;
124
125 QTextCharFormat errorFormat;
126 QTextCharFormat selectedErrorFormat;
127 QTextCharFormat quoteFormat;
128 std::unique_ptr<Sonnet::GuessLanguage> languageGuesser;
129 QString selectedWord;
130 QQuickTextDocument *document = nullptr;
131 int cursorPosition = 0;
132 int selectionStart = 0;
133 int selectionEnd = 0;
134
135 int autoCompleteBeginPosition = -1;
136 int autoCompleteEndPosition = -1;
137 int wordIsMisspelled = false;
138 bool active = false;
139 bool automatic = false;
140 bool autoDetectLanguageDisabled = false;
141 bool completeRehighlightRequired = false;
142 bool intraWordEditing = false;
143 bool spellCheckerFound = false; // cached d->dict->isValid() value
144 bool connected = false;
145 int disablePercentage = 0;
146 int disableWordCount = 0;
147 int wordCount = 0;
148 int errorCount = 0;
149 QTimer *rehighlightRequest = nullptr;
150 QColor spellColor;
151 SpellcheckHighlighter *const q;
152};
153
154HighlighterPrivate::~HighlighterPrivate()
155{
156}
157
158SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent)
159 : QSyntaxHighlighter(parent)
160 , d(new HighlighterPrivate(this))
161{
162}
163
164SpellcheckHighlighter::~SpellcheckHighlighter()
165{
166 if (document()) {
167 disconnect(sender: document(), signal: nullptr, receiver: this, member: nullptr);
168 }
169}
170
171bool SpellcheckHighlighter::spellCheckerFound() const
172{
173 return d->spellCheckerFound;
174}
175
176void SpellcheckHighlighter::slotRehighlight()
177{
178 if (d->completeRehighlightRequired) {
179 d->wordCount = 0;
180 d->errorCount = 0;
181 rehighlight();
182 } else {
183 // rehighlight the current para only (undo/redo safe)
184 QTextCursor cursor = textCursor();
185 if (cursor.hasSelection()) {
186 cursor.clearSelection();
187 }
188 cursor.insertText(text: QString());
189 }
190 // if (d->checksDone == d->checksRequested)
191 // d->completeRehighlightRequired = false;
192 QTimer::singleShot(interval: 0, receiver: this, slot: &SpellcheckHighlighter::slotAutoDetection);
193}
194
195bool SpellcheckHighlighter::automatic() const
196{
197 return d->automatic;
198}
199
200bool SpellcheckHighlighter::autoDetectLanguageDisabled() const
201{
202 return d->autoDetectLanguageDisabled;
203}
204
205bool SpellcheckHighlighter::intraWordEditing() const
206{
207 return d->intraWordEditing;
208}
209
210void SpellcheckHighlighter::setIntraWordEditing(bool editing)
211{
212 d->intraWordEditing = editing;
213}
214
215void SpellcheckHighlighter::setAutomatic(bool automatic)
216{
217 if (automatic == d->automatic) {
218 return;
219 }
220
221 d->automatic = automatic;
222 if (d->automatic) {
223 slotAutoDetection();
224 }
225}
226
227void SpellcheckHighlighter::setAutoDetectLanguageDisabled(bool autoDetectDisabled)
228{
229 d->autoDetectLanguageDisabled = autoDetectDisabled;
230}
231
232void SpellcheckHighlighter::slotAutoDetection()
233{
234 bool savedActive = d->active;
235
236 // don't disable just because 1 of 4 is misspelled.
237 if (d->automatic && d->wordCount >= 10) {
238 // tme = Too many errors
239 /* clang-format off */
240 bool tme = (d->errorCount >= d->disableWordCount)
241 && (d->errorCount * 100 >= d->disablePercentage * d->wordCount);
242 /* clang-format on */
243
244 if (d->active && tme) {
245 d->active = false;
246 } else if (!d->active && !tme) {
247 d->active = true;
248 }
249 }
250
251 if (d->active != savedActive) {
252 if (d->active) {
253 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking enabled."));
254 } else {
255 qCDebug(SONNET_LOG_QUICK) << "Sonnet: Disabling spell checking, too many errors";
256 Q_EMIT activeChanged(
257 description: tr(s: "Too many misspelled words. "
258 "As-you-type spell checking disabled."));
259 }
260
261 d->completeRehighlightRequired = true;
262 d->rehighlightRequest->setInterval(100);
263 d->rehighlightRequest->setSingleShot(true);
264 }
265}
266
267void SpellcheckHighlighter::setActive(bool active)
268{
269 if (active == d->active) {
270 return;
271 }
272 d->active = active;
273 Q_EMIT activeChanged();
274 rehighlight();
275
276 if (d->active) {
277 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking enabled."));
278 } else {
279 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking disabled."));
280 }
281}
282
283bool SpellcheckHighlighter::active() const
284{
285 return d->active;
286}
287
288static bool hasNotEmptyText(const QString &text)
289{
290 for (int i = 0; i < text.length(); ++i) {
291 if (!text.at(i).isSpace()) {
292 return true;
293 }
294 }
295 return false;
296}
297
298void SpellcheckHighlighter::contentsChange(int pos, int add, int rem)
299{
300 // Invalidate the cache where the text has changed
301 const QTextBlock &lastBlock = document()->findBlock(pos: pos + add - rem);
302 QTextBlock block = document()->findBlock(pos);
303 do {
304 LanguageCache *cache = dynamic_cast<LanguageCache *>(block.userData());
305 if (cache) {
306 cache->invalidate(pos: pos - block.position());
307 }
308 block = block.next();
309 } while (block.isValid() && block < lastBlock);
310}
311
312void SpellcheckHighlighter::highlightBlock(const QString &text)
313{
314 if (!hasNotEmptyText(text) || !d->active || !d->spellCheckerFound) {
315 return;
316 }
317
318 // Avoid spellchecking quotes
319 if (text.isEmpty() || text.at(i: 0) == QLatin1Char('>')) {
320 setFormat(start: 0, count: text.length(), format: d->quoteFormat);
321 return;
322 }
323
324 if (!d->connected) {
325 connect(sender: textDocument(), signal: &QTextDocument::contentsChange, context: this, slot: &SpellcheckHighlighter::contentsChange);
326 d->connected = true;
327 }
328 QTextCursor cursor = textCursor();
329 const int index = cursor.position() + 1;
330
331 const int lengthPosition = text.length() - 1;
332
333 if (index != lengthPosition //
334 || (lengthPosition > 0 && !text[lengthPosition - 1].isLetter())) {
335 d->languageFilter->setBuffer(text);
336
337 LanguageCache *cache = dynamic_cast<LanguageCache *>(currentBlockUserData());
338 if (!cache) {
339 cache = new LanguageCache;
340 setCurrentBlockUserData(cache);
341 }
342
343 const bool autodetectLanguage = d->spellchecker->testAttribute(attr: Speller::AutoDetectLanguage);
344 while (d->languageFilter->hasNext()) {
345 Sonnet::Token sentence = d->languageFilter->next();
346 if (autodetectLanguage && !d->autoDetectLanguageDisabled) {
347 QString lang;
348 QPair<int, int> spos = QPair<int, int>(sentence.position(), sentence.length());
349 // try cache first
350 if (cache->languages.contains(key: spos)) {
351 lang = cache->languages.value(key: spos);
352 } else {
353 lang = d->languageFilter->language();
354 if (!d->languageFilter->isSpellcheckable()) {
355 lang.clear();
356 }
357 cache->languages[spos] = lang;
358 }
359 if (lang.isEmpty()) {
360 continue;
361 }
362 d->spellchecker->setLanguage(lang);
363 }
364
365 d->tokenizer->setBuffer(sentence.toString());
366 int offset = sentence.position();
367 while (d->tokenizer->hasNext()) {
368 Sonnet::Token word = d->tokenizer->next();
369 if (!d->tokenizer->isSpellcheckable()) {
370 continue;
371 }
372 ++d->wordCount;
373 if (d->spellchecker->isMisspelled(word: word.toString())) {
374 ++d->errorCount;
375 if (word.position() + offset <= cursor.position() && cursor.position() <= word.position() + offset + word.length()) {
376 setMisspelledSelected(start: word.position() + offset, count: word.length());
377 } else {
378 setMisspelled(start: word.position() + offset, count: word.length());
379 }
380 } else {
381 unsetMisspelled(start: word.position() + offset, count: word.length());
382 }
383 }
384 }
385 }
386 // QTimer::singleShot( 0, this, SLOT(checkWords()) );
387 setCurrentBlockState(0);
388}
389
390QStringList SpellcheckHighlighter::suggestions(int mousePosition, int max)
391{
392 if (!textDocument()) {
393 return {};
394 }
395
396 Q_EMIT changeCursorPosition(start: mousePosition, end: mousePosition);
397
398 QTextCursor cursor = textCursor();
399
400 QTextCursor cursorAtMouse(textDocument());
401 cursorAtMouse.setPosition(pos: mousePosition);
402
403 // Check if the user clicked a selected word
404 const bool selectedWordClicked = cursor.hasSelection() && mousePosition >= cursor.selectionStart() && mousePosition <= cursor.selectionEnd();
405
406 // Get the word under the (mouse-)cursor and see if it is misspelled.
407 // Don't include apostrophes at the start/end of the word in the selection.
408 QTextCursor wordSelectCursor(cursorAtMouse);
409 wordSelectCursor.clearSelection();
410 wordSelectCursor.select(selection: QTextCursor::WordUnderCursor);
411 d->selectedWord = wordSelectCursor.selectedText();
412
413 // Clear the selection again, we re-select it below (without the apostrophes).
414 wordSelectCursor.setPosition(pos: wordSelectCursor.position() - d->selectedWord.size());
415 if (d->selectedWord.startsWith(c: QLatin1Char('\'')) || d->selectedWord.startsWith(c: QLatin1Char('\"'))) {
416 d->selectedWord = d->selectedWord.right(n: d->selectedWord.size() - 1);
417 wordSelectCursor.movePosition(op: QTextCursor::NextCharacter, QTextCursor::MoveAnchor);
418 }
419 if (d->selectedWord.endsWith(c: QLatin1Char('\'')) || d->selectedWord.endsWith(c: QLatin1Char('\"'))) {
420 d->selectedWord.chop(n: 1);
421 }
422
423 wordSelectCursor.movePosition(op: QTextCursor::NextCharacter, QTextCursor::KeepAnchor, n: d->selectedWord.size());
424
425 Q_EMIT wordUnderMouseChanged();
426
427 bool isMouseCursorInsideWord = true;
428 if ((mousePosition < wordSelectCursor.selectionStart() || mousePosition >= wordSelectCursor.selectionEnd()) //
429 && (d->selectedWord.length() > 1)) {
430 isMouseCursorInsideWord = false;
431 }
432
433 wordSelectCursor.movePosition(op: QTextCursor::NextCharacter, QTextCursor::KeepAnchor, n: d->selectedWord.size());
434
435 d->wordIsMisspelled = isMouseCursorInsideWord && !d->selectedWord.isEmpty() && d->spellchecker->isMisspelled(word: d->selectedWord);
436 Q_EMIT wordIsMisspelledChanged();
437
438 if (!d->wordIsMisspelled || selectedWordClicked) {
439 return QStringList{};
440 }
441
442 LanguageCache *cache = dynamic_cast<LanguageCache *>(cursor.block().userData());
443 if (cache) {
444 const QString cachedLanguage = cache->languageAtPos(pos: cursor.positionInBlock());
445 if (!cachedLanguage.isEmpty()) {
446 d->spellchecker->setLanguage(cachedLanguage);
447 }
448 }
449 QStringList suggestions = d->spellchecker->suggest(word: d->selectedWord);
450 if (max >= 0 && suggestions.count() > max) {
451 suggestions = suggestions.mid(pos: 0, len: max);
452 }
453
454 return suggestions;
455}
456
457QString SpellcheckHighlighter::currentLanguage() const
458{
459 return d->spellchecker->language();
460}
461
462void SpellcheckHighlighter::setCurrentLanguage(const QString &lang)
463{
464 QString prevLang = d->spellchecker->language();
465 d->spellchecker->setLanguage(lang);
466 d->spellCheckerFound = d->spellchecker->isValid();
467 if (!d->spellCheckerFound) {
468 qCDebug(SONNET_LOG_QUICK) << "No dictionary for \"" << lang << "\" staying with the current language.";
469 d->spellchecker->setLanguage(prevLang);
470 return;
471 }
472 d->wordCount = 0;
473 d->errorCount = 0;
474 if (d->automatic || d->active) {
475 d->rehighlightRequest->start(msec: 0);
476 }
477}
478
479void SpellcheckHighlighter::setMisspelled(int start, int count)
480{
481 setFormat(start, count, format: d->errorFormat);
482}
483
484void SpellcheckHighlighter::setMisspelledSelected(int start, int count)
485{
486 setFormat(start, count, format: d->selectedErrorFormat);
487}
488
489void SpellcheckHighlighter::unsetMisspelled(int start, int count)
490{
491 setFormat(start, count, format: QTextCharFormat());
492}
493
494void SpellcheckHighlighter::addWordToDictionary(const QString &word)
495{
496 d->spellchecker->addToPersonal(word);
497 rehighlight();
498}
499
500void SpellcheckHighlighter::ignoreWord(const QString &word)
501{
502 d->spellchecker->addToSession(word);
503 rehighlight();
504}
505
506void SpellcheckHighlighter::replaceWord(const QString &replacement, int at)
507{
508 QTextCursor textCursorUnderUserCursor(textDocument());
509 textCursorUnderUserCursor.setPosition(pos: at == -1 ? d->cursorPosition : at);
510
511 // Get the word under the cursor
512 QTextCursor wordSelectCursor(textCursorUnderUserCursor);
513 wordSelectCursor.clearSelection();
514 wordSelectCursor.select(selection: QTextCursor::WordUnderCursor);
515
516 auto selectedWord = wordSelectCursor.selectedText();
517
518 // Trim leading and trailing apostrophes
519 wordSelectCursor.setPosition(pos: wordSelectCursor.position() - selectedWord.size());
520 if (selectedWord.startsWith(c: QLatin1Char('\'')) || selectedWord.startsWith(c: QLatin1Char('\"'))) {
521 selectedWord = selectedWord.right(n: selectedWord.size() - 1);
522 wordSelectCursor.movePosition(op: QTextCursor::NextCharacter, QTextCursor::MoveAnchor);
523 }
524 if (selectedWord.endsWith(c: QLatin1Char('\'')) || d->selectedWord.endsWith(c: QLatin1Char('\"'))) {
525 selectedWord.chop(n: 1);
526 }
527
528 wordSelectCursor.movePosition(op: QTextCursor::NextCharacter, QTextCursor::KeepAnchor, n: d->selectedWord.size());
529
530 wordSelectCursor.insertText(text: replacement);
531}
532
533QQuickTextDocument *SpellcheckHighlighter::quickDocument() const
534{
535 return d->document;
536}
537
538void SpellcheckHighlighter::setQuickDocument(QQuickTextDocument *document)
539{
540 if (document == d->document) {
541 return;
542 }
543
544 if (d->document) {
545 d->document->parent()->removeEventFilter(obj: this);
546 d->document->textDocument()->disconnect(receiver: this);
547 }
548 d->document = document;
549 document->parent()->installEventFilter(filterObj: this);
550 setDocument(document->textDocument());
551 Q_EMIT documentChanged();
552}
553
554void SpellcheckHighlighter::setDocument(QTextDocument *document)
555{
556 d->connected = false;
557 QSyntaxHighlighter::setDocument(document);
558}
559
560int SpellcheckHighlighter::cursorPosition() const
561{
562 return d->cursorPosition;
563}
564
565void SpellcheckHighlighter::setCursorPosition(int position)
566{
567 if (position == d->cursorPosition) {
568 return;
569 }
570
571 d->cursorPosition = position;
572 d->rehighlightRequest->start(msec: 0);
573 Q_EMIT cursorPositionChanged();
574}
575
576int SpellcheckHighlighter::selectionStart() const
577{
578 return d->selectionStart;
579}
580
581void SpellcheckHighlighter::setSelectionStart(int position)
582{
583 if (position == d->selectionStart) {
584 return;
585 }
586
587 d->selectionStart = position;
588 Q_EMIT selectionStartChanged();
589}
590
591int SpellcheckHighlighter::selectionEnd() const
592{
593 return d->selectionEnd;
594}
595
596void SpellcheckHighlighter::setSelectionEnd(int position)
597{
598 if (position == d->selectionEnd) {
599 return;
600 }
601
602 d->selectionEnd = position;
603 Q_EMIT selectionEndChanged();
604}
605
606QTextCursor SpellcheckHighlighter::textCursor() const
607{
608 QTextDocument *doc = textDocument();
609 if (!doc) {
610 return QTextCursor();
611 }
612
613 QTextCursor cursor(doc);
614 if (d->selectionStart != d->selectionEnd) {
615 cursor.setPosition(pos: d->selectionStart);
616 cursor.setPosition(pos: d->selectionEnd, mode: QTextCursor::KeepAnchor);
617 } else {
618 cursor.setPosition(pos: d->cursorPosition);
619 }
620 return cursor;
621}
622
623QTextDocument *SpellcheckHighlighter::textDocument() const
624{
625 if (!d->document) {
626 return nullptr;
627 }
628
629 return d->document->textDocument();
630}
631
632bool SpellcheckHighlighter::wordIsMisspelled() const
633{
634 return d->wordIsMisspelled;
635}
636
637QString SpellcheckHighlighter::wordUnderMouse() const
638{
639 return d->selectedWord;
640}
641
642QColor SpellcheckHighlighter::misspelledColor() const
643{
644 return d->spellColor;
645}
646
647void SpellcheckHighlighter::setMisspelledColor(const QColor &color)
648{
649 if (color == d->spellColor) {
650 return;
651 }
652 d->spellColor = color;
653 Q_EMIT misspelledColorChanged();
654}
655
656bool SpellcheckHighlighter::isWordMisspelled(const QString &word)
657{
658 return d->spellchecker->isMisspelled(word);
659}
660
661bool SpellcheckHighlighter::eventFilter(QObject *o, QEvent *e)
662{
663 if (!d->spellCheckerFound) {
664 return false;
665 }
666 if (o == d->document->parent() && (e->type() == QEvent::KeyPress)) {
667 QKeyEvent *k = static_cast<QKeyEvent *>(e);
668
669 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
670 || 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
671 || (k->modifiers() == Qt::ControlModifier
672 && (k->key() == Qt::Key_A || k->key() == Qt::Key_B || k->key() == Qt::Key_E || k->key() == Qt::Key_N
673 || k->key() == Qt::Key_P))) { /* clang-format on */
674 if (intraWordEditing()) {
675 setIntraWordEditing(false);
676 d->completeRehighlightRequired = true;
677 d->rehighlightRequest->setInterval(500);
678 d->rehighlightRequest->setSingleShot(true);
679 d->rehighlightRequest->start();
680 }
681 } else {
682 setIntraWordEditing(true);
683 }
684 if (k->key() == Qt::Key_Space //
685 || k->key() == Qt::Key_Enter //
686 || k->key() == Qt::Key_Return) {
687 QTimer::singleShot(msec: 0, receiver: this, SLOT(slotAutoDetection()));
688 }
689 } else if (d->document && e->type() == QEvent::MouseButtonPress) {
690 if (intraWordEditing()) {
691 setIntraWordEditing(false);
692 d->completeRehighlightRequired = true;
693 d->rehighlightRequest->setInterval(0);
694 d->rehighlightRequest->setSingleShot(true);
695 d->rehighlightRequest->start();
696 }
697 }
698 return false;
699}
700
701#include "moc_spellcheckhighlighter.cpp"
702

source code of sonnet/src/quick/spellcheckhighlighter.cpp