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;
132 int selectionStart;
133 int selectionEnd;
134
135 int autoCompleteBeginPosition = -1;
136 int autoCompleteEndPosition = -1;
137 int wordIsMisspelled = false;
138 bool active;
139 bool automatic;
140 bool autoDetectLanguageDisabled;
141 bool completeRehighlightRequired;
142 bool intraWordEditing;
143 bool spellCheckerFound; // cached d->dict->isValid() value
144 bool connected;
145 int disablePercentage = 0;
146 int disableWordCount = 0;
147 int wordCount, errorCount;
148 QTimer *rehighlightRequest = nullptr;
149 QColor spellColor;
150 SpellcheckHighlighter *const q;
151};
152
153HighlighterPrivate::~HighlighterPrivate()
154{
155}
156
157SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent)
158 : QSyntaxHighlighter(parent)
159 , d(new HighlighterPrivate(this))
160{
161}
162
163SpellcheckHighlighter::~SpellcheckHighlighter()
164{
165 if (document()) {
166 disconnect(sender: document(), signal: nullptr, receiver: this, member: nullptr);
167 }
168}
169
170bool SpellcheckHighlighter::spellCheckerFound() const
171{
172 return d->spellCheckerFound;
173}
174
175void SpellcheckHighlighter::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 = textCursor();
184 if (cursor.hasSelection()) {
185 cursor.clearSelection();
186 }
187 cursor.insertText(text: QString());
188 }
189 // if (d->checksDone == d->checksRequested)
190 // d->completeRehighlightRequired = false;
191 QTimer::singleShot(interval: 0, receiver: this, slot: &SpellcheckHighlighter::slotAutoDetection);
192}
193
194bool SpellcheckHighlighter::automatic() const
195{
196 return d->automatic;
197}
198
199bool SpellcheckHighlighter::autoDetectLanguageDisabled() const
200{
201 return d->autoDetectLanguageDisabled;
202}
203
204bool SpellcheckHighlighter::intraWordEditing() const
205{
206 return d->intraWordEditing;
207}
208
209void SpellcheckHighlighter::setIntraWordEditing(bool editing)
210{
211 d->intraWordEditing = editing;
212}
213
214void SpellcheckHighlighter::setAutomatic(bool automatic)
215{
216 if (automatic == d->automatic) {
217 return;
218 }
219
220 d->automatic = automatic;
221 if (d->automatic) {
222 slotAutoDetection();
223 }
224}
225
226void SpellcheckHighlighter::setAutoDetectLanguageDisabled(bool autoDetectDisabled)
227{
228 d->autoDetectLanguageDisabled = autoDetectDisabled;
229}
230
231void SpellcheckHighlighter::slotAutoDetection()
232{
233 bool savedActive = d->active;
234
235 // don't disable just because 1 of 4 is misspelled.
236 if (d->automatic && d->wordCount >= 10) {
237 // tme = Too many errors
238 /* clang-format off */
239 bool tme = (d->errorCount >= d->disableWordCount)
240 && (d->errorCount * 100 >= d->disablePercentage * d->wordCount);
241 /* clang-format on */
242
243 if (d->active && tme) {
244 d->active = false;
245 } else if (!d->active && !tme) {
246 d->active = true;
247 }
248 }
249
250 if (d->active != savedActive) {
251 if (d->active) {
252 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking enabled."));
253 } else {
254 qCDebug(SONNET_LOG_QUICK) << "Sonnet: Disabling spell checking, too many errors";
255 Q_EMIT activeChanged(
256 description: tr(s: "Too many misspelled words. "
257 "As-you-type spell checking disabled."));
258 }
259
260 d->completeRehighlightRequired = true;
261 d->rehighlightRequest->setInterval(100);
262 d->rehighlightRequest->setSingleShot(true);
263 }
264}
265
266void SpellcheckHighlighter::setActive(bool active)
267{
268 if (active == d->active) {
269 return;
270 }
271 d->active = active;
272 Q_EMIT activeChanged();
273 rehighlight();
274
275 if (d->active) {
276 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking enabled."));
277 } else {
278 Q_EMIT activeChanged(description: tr(s: "As-you-type spell checking disabled."));
279 }
280}
281
282bool SpellcheckHighlighter::active() const
283{
284 return d->active;
285}
286
287static bool hasNotEmptyText(const QString &text)
288{
289 for (int i = 0; i < text.length(); ++i) {
290 if (!text.at(i).isSpace()) {
291 return true;
292 }
293 }
294 return false;
295}
296
297void SpellcheckHighlighter::contentsChange(int pos, int add, int rem)
298{
299 // Invalidate the cache where the text has changed
300 const QTextBlock &lastBlock = document()->findBlock(pos: pos + add - rem);
301 QTextBlock block = document()->findBlock(pos);
302 do {
303 LanguageCache *cache = dynamic_cast<LanguageCache *>(block.userData());
304 if (cache) {
305 cache->invalidate(pos: pos - block.position());
306 }
307 block = block.next();
308 } while (block.isValid() && block < lastBlock);
309}
310
311void SpellcheckHighlighter::highlightBlock(const QString &text)
312{
313 if (!hasNotEmptyText(text) || !d->active || !d->spellCheckerFound) {
314 return;
315 }
316
317 // Avoid spellchecking quotes
318 if (text.isEmpty() || text.at(i: 0) == QLatin1Char('>')) {
319 setFormat(start: 0, count: text.length(), format: d->quoteFormat);
320 return;
321 }
322
323 if (!d->connected) {
324 connect(sender: textDocument(), signal: &QTextDocument::contentsChange, context: this, slot: &SpellcheckHighlighter::contentsChange);
325 d->connected = true;
326 }
327 QTextCursor cursor = textCursor();
328 const int index = cursor.position() + 1;
329
330 const int lengthPosition = text.length() - 1;
331
332 if (index != lengthPosition //
333 || (lengthPosition > 0 && !text[lengthPosition - 1].isLetter())) {
334 d->languageFilter->setBuffer(text);
335
336 LanguageCache *cache = dynamic_cast<LanguageCache *>(currentBlockUserData());
337 if (!cache) {
338 cache = new LanguageCache;
339 setCurrentBlockUserData(cache);
340 }
341
342 const bool autodetectLanguage = d->spellchecker->testAttribute(attr: Speller::AutoDetectLanguage);
343 while (d->languageFilter->hasNext()) {
344 Sonnet::Token sentence = d->languageFilter->next();
345 if (autodetectLanguage && !d->autoDetectLanguageDisabled) {
346 QString lang;
347 QPair<int, int> spos = QPair<int, int>(sentence.position(), sentence.length());
348 // try cache first
349 if (cache->languages.contains(key: spos)) {
350 lang = cache->languages.value(key: spos);
351 } else {
352 lang = d->languageFilter->language();
353 if (!d->languageFilter->isSpellcheckable()) {
354 lang.clear();
355 }
356 cache->languages[spos] = lang;
357 }
358 if (lang.isEmpty()) {
359 continue;
360 }
361 d->spellchecker->setLanguage(lang);
362 }
363
364 d->tokenizer->setBuffer(sentence.toString());
365 int offset = sentence.position();
366 while (d->tokenizer->hasNext()) {
367 Sonnet::Token word = d->tokenizer->next();
368 if (!d->tokenizer->isSpellcheckable()) {
369 continue;
370 }
371 ++d->wordCount;
372 if (d->spellchecker->isMisspelled(word: word.toString())) {
373 ++d->errorCount;
374 if (word.position() + offset <= cursor.position() && cursor.position() <= word.position() + offset + word.length()) {
375 setMisspelledSelected(start: word.position() + offset, count: word.length());
376 } else {
377 setMisspelled(start: word.position() + offset, count: word.length());
378 }
379 } else {
380 unsetMisspelled(start: word.position() + offset, count: word.length());
381 }
382 }
383 }
384 }
385 // QTimer::singleShot( 0, this, SLOT(checkWords()) );
386 setCurrentBlockState(0);
387}
388
389QStringList SpellcheckHighlighter::suggestions(int mousePosition, int max)
390{
391 if (!textDocument()) {
392 return {};
393 }
394
395 Q_EMIT changeCursorPosition(start: mousePosition, end: mousePosition);
396
397 QTextCursor cursor = textCursor();
398
399 QTextCursor cursorAtMouse(textDocument());
400 cursorAtMouse.setPosition(pos: mousePosition);
401
402 // Check if the user clicked a selected word
403 const bool selectedWordClicked = cursor.hasSelection() && mousePosition >= cursor.selectionStart() && mousePosition <= cursor.selectionEnd();
404
405 // Get the word under the (mouse-)cursor and see if it is misspelled.
406 // Don't include apostrophes at the start/end of the word in the selection.
407 QTextCursor wordSelectCursor(cursorAtMouse);
408 wordSelectCursor.clearSelection();
409 wordSelectCursor.select(selection: QTextCursor::WordUnderCursor);
410 d->selectedWord = wordSelectCursor.selectedText();
411
412 // Clear the selection again, we re-select it below (without the apostrophes).
413 wordSelectCursor.setPosition(pos: wordSelectCursor.position() - d->selectedWord.size());
414 if (d->selectedWord.startsWith(c: QLatin1Char('\'')) || d->selectedWord.startsWith(c: QLatin1Char('\"'))) {
415 d->selectedWord = d->selectedWord.right(n: d->selectedWord.size() - 1);
416 wordSelectCursor.movePosition(op: QTextCursor::NextCharacter, QTextCursor::MoveAnchor);
417 }
418 if (d->selectedWord.endsWith(c: QLatin1Char('\'')) || d->selectedWord.endsWith(c: QLatin1Char('\"'))) {
419 d->selectedWord.chop(n: 1);
420 }
421
422 wordSelectCursor.movePosition(op: QTextCursor::NextCharacter, QTextCursor::KeepAnchor, n: d->selectedWord.size());
423
424 int endSelection = wordSelectCursor.selectionEnd();
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