Sonnet

spellcheckhighlighter.cpp
1 // SPDX-FileCopyrightText: 2013 Aurélien Gâteau <[email protected]>
2 // SPDX-FileCopyrightText: 2020 Christian Mollekopf <[email protected]>
3 // SPDX-FileCopyrightText: 2021 Carl Schwan <[email protected]>
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 
26 using namespace Sonnet;
27 
28 // Cache of previously-determined languages (when using AutoDetectLanguage)
29 // There is one such cache per block (paragraph)
30 class LanguageCache : public QTextBlockUserData
31 {
32 public:
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  {
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 
66 class HighlighterPrivate
67 {
68 public:
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>(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(rehighlightRequest, &QTimer::timeout, q, &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  quoteFormat.setForeground(QColor{"#7f8c8d"});
109  }
110 
111  ~HighlighterPrivate();
112  std::unique_ptr<WordTokenizer> tokenizer;
113  std::unique_ptr<LanguageFilter> languageFilter;
114  Loader *loader = nullptr;
115  std::unique_ptr<Speller> spellchecker;
116 
117  QTextCharFormat errorFormat;
118  QTextCharFormat quoteFormat;
119  std::unique_ptr<Sonnet::GuessLanguage> languageGuesser;
120  QString selectedWord;
121  QQuickTextDocument *document = nullptr;
122  int cursorPosition;
123  int selectionStart;
124  int selectionEnd;
125 
126  int autoCompleteBeginPosition = -1;
127  int autoCompleteEndPosition = -1;
128  int wordIsMisspelled = false;
129  bool active;
130  bool automatic;
131  bool autoDetectLanguageDisabled;
132  bool completeRehighlightRequired;
133  bool intraWordEditing;
134  bool spellCheckerFound; // cached d->dict->isValid() value
135  bool connected;
136  int disablePercentage = 0;
137  int disableWordCount = 0;
138  int wordCount, errorCount;
139  QTimer *rehighlightRequest = nullptr;
140  QColor spellColor;
141  SpellcheckHighlighter *const q;
142 };
143 
144 HighlighterPrivate::~HighlighterPrivate()
145 {
146 }
147 
148 SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent)
149  : QSyntaxHighlighter(parent)
150  , d(new HighlighterPrivate(this))
151 {
152 }
153 
155 {
156  return d->spellCheckerFound;
157 }
158 
160 {
161  if (d->completeRehighlightRequired) {
162  d->wordCount = 0;
163  d->errorCount = 0;
164  rehighlight();
165  } else {
166  // rehighlight the current para only (undo/redo safe)
167  QTextCursor cursor = textCursor();
168  if (cursor.hasSelection()) {
169  cursor.clearSelection();
170  }
171  cursor.insertText(QString());
172  }
173  // if (d->checksDone == d->checksRequested)
174  // d->completeRehighlightRequired = false;
176 }
177 
179 {
180  return d->automatic;
181 }
182 
184 {
185  return d->autoDetectLanguageDisabled;
186 }
187 
188 bool SpellcheckHighlighter::intraWordEditing() const
189 {
190  return d->intraWordEditing;
191 }
192 
193 void SpellcheckHighlighter::setIntraWordEditing(bool editing)
194 {
195  d->intraWordEditing = editing;
196 }
197 
198 void SpellcheckHighlighter::setAutomatic(bool automatic)
199 {
200  if (automatic == d->automatic) {
201  return;
202  }
203 
204  d->automatic = automatic;
205  if (d->automatic) {
206  slotAutoDetection();
207  }
208 }
209 
210 void SpellcheckHighlighter::setAutoDetectLanguageDisabled(bool autoDetectDisabled)
211 {
212  d->autoDetectLanguageDisabled = autoDetectDisabled;
213 }
214 
216 {
217  bool savedActive = d->active;
218 
219  // don't disable just because 1 of 4 is misspelled.
220  if (d->automatic && d->wordCount >= 10) {
221  // tme = Too many errors
222  /* clang-format off */
223  bool tme = (d->errorCount >= d->disableWordCount)
224  && (d->errorCount * 100 >= d->disablePercentage * d->wordCount);
225  /* clang-format on */
226 
227  if (d->active && tme) {
228  d->active = false;
229  } else if (!d->active && !tme) {
230  d->active = true;
231  }
232  }
233 
234  if (d->active != savedActive) {
235  if (d->active) {
236  Q_EMIT activeChanged(tr("As-you-type spell checking enabled."));
237  } else {
238  qCDebug(SONNET_LOG_QUICK) << "Sonnet: Disabling spell checking, too many errors";
239  Q_EMIT activeChanged(
240  tr("Too many misspelled words. "
241  "As-you-type spell checking disabled."));
242  }
243 
244  d->completeRehighlightRequired = true;
245  d->rehighlightRequest->setInterval(100);
246  d->rehighlightRequest->setSingleShot(true);
247  }
248 }
249 
250 void SpellcheckHighlighter::setActive(bool active)
251 {
252  if (active == d->active) {
253  return;
254  }
255  d->active = active;
256  Q_EMIT activeChanged();
257  rehighlight();
258 
259  if (d->active) {
260  Q_EMIT activeChanged(tr("As-you-type spell checking enabled."));
261  } else {
262  Q_EMIT activeChanged(tr("As-you-type spell checking disabled."));
263  }
264 }
265 
267 {
268  return d->active;
269 }
270 
271 static bool hasNotEmptyText(const QString &text)
272 {
273  for (int i = 0; i < text.length(); ++i) {
274  if (!text.at(i).isSpace()) {
275  return true;
276  }
277  }
278  return false;
279 }
280 
281 void SpellcheckHighlighter::contentsChange(int pos, int add, int rem)
282 {
283  // Invalidate the cache where the text has changed
284  const QTextBlock &lastBlock = document()->findBlock(pos + add - rem);
285  QTextBlock block = document()->findBlock(pos);
286  do {
287  LanguageCache *cache = dynamic_cast<LanguageCache *>(block.userData());
288  if (cache) {
289  cache->invalidate(pos - block.position());
290  }
291  block = block.next();
292  } while (block.isValid() && block < lastBlock);
293 }
294 
295 void SpellcheckHighlighter::highlightBlock(const QString &text)
296 {
297  if (!hasNotEmptyText(text) || !d->active || !d->spellCheckerFound) {
298  return;
299  }
300 
301  // Avoid spellchecking quotes
302  if (text.isEmpty() || text.at(0) == QLatin1Char('>')) {
303  setFormat(0, text.length(), d->quoteFormat);
304  return;
305  }
306 
307  if (!d->connected) {
308  connect(textDocument(), &QTextDocument::contentsChange, this, &SpellcheckHighlighter::contentsChange);
309  d->connected = true;
310  }
311  QTextCursor cursor = textCursor();
312  const int index = cursor.position() + 1;
313 
314  const int lengthPosition = text.length() - 1;
315 
316  if (index != lengthPosition //
317  || (lengthPosition > 0 && !text[lengthPosition - 1].isLetter())) {
318  d->languageFilter->setBuffer(text);
319 
320  LanguageCache *cache = dynamic_cast<LanguageCache *>(currentBlockUserData());
321  if (!cache) {
322  cache = new LanguageCache;
323  setCurrentBlockUserData(cache);
324  }
325 
326  const bool autodetectLanguage = d->spellchecker->testAttribute(Speller::AutoDetectLanguage);
327  while (d->languageFilter->hasNext()) {
328  Sonnet::Token sentence = d->languageFilter->next();
329  if (autodetectLanguage && !d->autoDetectLanguageDisabled) {
330  QString lang;
331  QPair<int, int> spos = QPair<int, int>(sentence.position(), sentence.length());
332  // try cache first
333  if (cache->languages.contains(spos)) {
334  lang = cache->languages.value(spos);
335  } else {
336  lang = d->languageFilter->language();
337  if (!d->languageFilter->isSpellcheckable()) {
338  lang.clear();
339  }
340  cache->languages[spos] = lang;
341  }
342  if (lang.isEmpty()) {
343  continue;
344  }
345  d->spellchecker->setLanguage(lang);
346  }
347 
348  d->tokenizer->setBuffer(sentence.toString());
349  int offset = sentence.position();
350  while (d->tokenizer->hasNext()) {
351  Sonnet::Token word = d->tokenizer->next();
352  if (!d->tokenizer->isSpellcheckable()) {
353  continue;
354  }
355  ++d->wordCount;
356  if (d->spellchecker->isMisspelled(word.toString())) {
357  ++d->errorCount;
358  setMisspelled(word.position() + offset, word.length());
359  } else {
360  unsetMisspelled(word.position() + offset, word.length());
361  }
362  }
363  }
364  }
365  // QTimer::singleShot( 0, this, SLOT(checkWords()) );
366  setCurrentBlockState(0);
367 }
368 
370 {
371  if (!textDocument()) {
372  return {};
373  }
374 
375  QTextCursor cursor = textCursor();
376 
377  QTextCursor cursorAtMouse(textDocument());
378  cursorAtMouse.setPosition(mousePosition);
379 
380  // Check if the user clicked a selected word
381  const bool selectedWordClicked = cursor.hasSelection() && mousePosition >= cursor.selectionStart() && mousePosition <= cursor.selectionEnd();
382 
383  // Get the word under the (mouse-)cursor and see if it is misspelled.
384  // Don't include apostrophes at the start/end of the word in the selection.
385  QTextCursor wordSelectCursor(cursorAtMouse);
386  wordSelectCursor.clearSelection();
387  wordSelectCursor.select(QTextCursor::WordUnderCursor);
388  d->selectedWord = wordSelectCursor.selectedText();
389 
390  // Clear the selection again, we re-select it below (without the apostrophes).
391  wordSelectCursor.setPosition(wordSelectCursor.position() - d->selectedWord.size());
392  if (d->selectedWord.startsWith(QLatin1Char('\'')) || d->selectedWord.startsWith(QLatin1Char('\"'))) {
393  d->selectedWord = d->selectedWord.right(d->selectedWord.size() - 1);
395  }
396  if (d->selectedWord.endsWith(QLatin1Char('\'')) || d->selectedWord.endsWith(QLatin1Char('\"'))) {
397  d->selectedWord.chop(1);
398  }
399 
400  wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, d->selectedWord.size());
401 
402  int endSelection = wordSelectCursor.selectionEnd();
403  Q_EMIT wordUnderMouseChanged();
404 
405  bool isMouseCursorInsideWord = true;
406  if ((mousePosition < wordSelectCursor.selectionStart() || mousePosition >= wordSelectCursor.selectionEnd()) //
407  && (d->selectedWord.length() > 1)) {
408  isMouseCursorInsideWord = false;
409  }
410 
411  wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, d->selectedWord.size());
412 
413  d->wordIsMisspelled = isMouseCursorInsideWord && !d->selectedWord.isEmpty() && d->spellchecker->isMisspelled(d->selectedWord);
414  Q_EMIT wordIsMisspelledChanged();
415 
416  if (!d->wordIsMisspelled || selectedWordClicked) {
417  return QStringList{};
418  }
419 
420  if (!selectedWordClicked) {
421  Q_EMIT changeCursorPosition(wordSelectCursor.selectionStart(), endSelection);
422  }
423 
424  LanguageCache *cache = dynamic_cast<LanguageCache *>(cursor.block().userData());
425  if (cache) {
426  const QString cachedLanguage = cache->languageAtPos(cursor.positionInBlock());
427  if (!cachedLanguage.isEmpty()) {
428  d->spellchecker->setLanguage(cachedLanguage);
429  }
430  }
431  QStringList suggestions = d->spellchecker->suggest(d->selectedWord);
432  if (max >= 0 && suggestions.count() > max) {
433  suggestions = suggestions.mid(0, max);
434  }
435 
436  return suggestions;
437 }
438 
440 {
441  return d->spellchecker->language();
442 }
443 
445 {
446  QString prevLang = d->spellchecker->language();
447  d->spellchecker->setLanguage(lang);
448  d->spellCheckerFound = d->spellchecker->isValid();
449  if (!d->spellCheckerFound) {
450  qCDebug(SONNET_LOG_QUICK) << "No dictionary for \"" << lang << "\" staying with the current language.";
451  d->spellchecker->setLanguage(prevLang);
452  return;
453  }
454  d->wordCount = 0;
455  d->errorCount = 0;
456  if (d->automatic || d->active) {
457  d->rehighlightRequest->start(0);
458  }
459 }
460 
461 void SpellcheckHighlighter::setMisspelled(int start, int count)
462 {
463  setFormat(start, count, d->errorFormat);
464 }
465 
466 void SpellcheckHighlighter::unsetMisspelled(int start, int count)
467 {
468  setFormat(start, count, QTextCharFormat());
469 }
470 
472 {
473  d->spellchecker->addToPersonal(word);
474  rehighlight();
475 }
476 
478 {
479  d->spellchecker->addToSession(word);
480  rehighlight();
481 }
482 
484 {
485  textCursor().insertText(replacement);
486 }
487 
488 QQuickTextDocument *SpellcheckHighlighter::quickDocument() const
489 {
490  return d->document;
491 }
492 
493 void SpellcheckHighlighter::setQuickDocument(QQuickTextDocument *document)
494 {
495  if (document == d->document) {
496  return;
497  }
498 
499  if (d->document) {
500  d->document->parent()->removeEventFilter(this);
501  d->document->textDocument()->disconnect(this);
502  }
503  d->document = document;
504  document->parent()->installEventFilter(this);
505  setDocument(document->textDocument());
506  Q_EMIT documentChanged();
507 }
508 
510 {
511  d->connected = false;
513 }
514 
516 {
517  return d->cursorPosition;
518 }
519 
520 void SpellcheckHighlighter::setCursorPosition(int position)
521 {
522  if (position == d->cursorPosition) {
523  return;
524  }
525 
526  d->cursorPosition = position;
527  Q_EMIT cursorPositionChanged();
528 }
529 
531 {
532  return d->selectionStart;
533 }
534 
535 void SpellcheckHighlighter::setSelectionStart(int position)
536 {
537  if (position == d->selectionStart) {
538  return;
539  }
540 
541  d->selectionStart = position;
542  Q_EMIT selectionStartChanged();
543 }
544 
546 {
547  return d->selectionEnd;
548 }
549 
550 void SpellcheckHighlighter::setSelectionEnd(int position)
551 {
552  if (position == d->selectionEnd) {
553  return;
554  }
555 
556  d->selectionEnd = position;
557  Q_EMIT selectionEndChanged();
558 }
559 
560 QTextCursor SpellcheckHighlighter::textCursor() const
561 {
562  QTextDocument *doc = textDocument();
563  if (!doc) {
564  return QTextCursor();
565  }
566 
567  QTextCursor cursor(doc);
568  if (d->selectionStart != d->selectionEnd) {
569  cursor.setPosition(d->selectionStart);
570  cursor.setPosition(d->selectionEnd, QTextCursor::KeepAnchor);
571  } else {
572  cursor.setPosition(d->cursorPosition);
573  }
574  return cursor;
575 }
576 
577 QTextDocument *SpellcheckHighlighter::textDocument() const
578 {
579  if (!d->document) {
580  return nullptr;
581  }
582 
583  return d->document->textDocument();
584 }
585 
587 {
588  return d->wordIsMisspelled;
589 }
590 
592 {
593  return d->selectedWord;
594 }
595 
597 {
598  return d->spellColor;
599 }
600 
601 void SpellcheckHighlighter::setMisspelledColor(const QColor &color)
602 {
603  if (color == d->spellColor) {
604  return;
605  }
606  d->spellColor = color;
607  Q_EMIT misspelledColorChanged();
608 }
609 
611 {
612  return d->spellchecker->isMisspelled(word);
613 }
614 
615 bool SpellcheckHighlighter::eventFilter(QObject *o, QEvent *e)
616 {
617  if (!d->spellCheckerFound) {
618  return false;
619  }
620  if (o == d->document->parent() && (e->type() == QEvent::KeyPress)) {
621  QKeyEvent *k = static_cast<QKeyEvent *>(e);
622 
623  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
624  || 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
625  || (k->modifiers() == Qt::ControlModifier
626  && (k->key() == Qt::Key_A || k->key() == Qt::Key_B || k->key() == Qt::Key_E || k->key() == Qt::Key_N
627  || k->key() == Qt::Key_P))) { /* clang-format on */
628  if (intraWordEditing()) {
629  setIntraWordEditing(false);
630  d->completeRehighlightRequired = true;
631  d->rehighlightRequest->setInterval(500);
632  d->rehighlightRequest->setSingleShot(true);
633  d->rehighlightRequest->start();
634  }
635  } else {
636  setIntraWordEditing(true);
637  }
638  if (k->key() == Qt::Key_Space //
639  || k->key() == Qt::Key_Enter //
640  || k->key() == Qt::Key_Return) {
641  QTimer::singleShot(0, this, SLOT(slotAutoDetection()));
642  }
643  } else if (d->document && e->type() == QEvent::MouseButtonPress) {
644  if (intraWordEditing()) {
645  setIntraWordEditing(false);
646  d->completeRehighlightRequired = true;
647  d->rehighlightRequest->setInterval(0);
648  d->rehighlightRequest->setSingleShot(true);
649  d->rehighlightRequest->start();
650  }
651  }
652  return false;
653 }
654 
655 #include "moc_spellcheckhighlighter.cpp"
void slotRehighlight()
Force a new highlighting.
Q_INVOKABLE void replaceWord(const QString &word)
Replace word at the current cursor position.
void setCurrentLanguage(const QString &language)
Set language to use for spell checking.
void setDocument(QTextDocument *doc)
void clearSelection()
int selectionStart() const const
Q_INVOKABLE void addWordToDictionary(const QString &word)
Adds the given word permanently to the dictionary.
bool wordIsMisspelled
This property holds whether the current word under the mouse is misspelled.
int count(const T &value) const const
QString selectedText() const const
void clear()
QString currentLanguage
This property holds the current language used for spell checking.
QString wordUnderMouse
This property holds the current word under the mouse.
int selectionStart
This property holds the start of the selection.
bool active
This property holds whether spell checking is enabled.
int cursorPosition
This property holds the current cursor position.
QTextBlock block() const const
int selectionEnd
This property holds the end of the selection.
QTextBlock next() const const
QColor misspelledColor
This property holds the spell color.
Qt::KeyboardModifiers modifiers() const const
Q_SCRIPTABLE Q_NOREPLY void start()
Q_INVOKABLE QStringList suggestions(int position, int max=5)
Returns a list of suggested replacements for the given misspelled word.
bool isSpace() const const
void setDocument(QTextDocument *document)
Set a new QTextDocument for this highlighter to operate on.
QTextDocument * textDocument() const const
The Sonnet Highlighter class, used for drawing red lines in text fields when detecting spelling mista...
void installEventFilter(QObject *filterObj)
QList< T > mid(int pos, int length) const const
void timeout()
bool isEmpty() const const
int position() const const
int length() const const
QTextBlockUserData * userData() const const
Key_Enter
int selectionEnd() const const
void select(QTextCursor::SelectionType selection)
The sonnet namespace.
int positionInBlock() const const
void setPosition(int pos, QTextCursor::MoveMode m)
int key() const const
QEvent::Type type() const const
Q_INVOKABLE void ignoreWord(const QString &word)
Ignores the given word.
const QChar at(int position) const const
void insertText(const QString &text)
void slotAutoDetection()
Run auto detection, disabling spell checking if too many errors are found.
bool autoDetectLanguageDisabled
This property holds whether the automatic language detection is disabled overriding the Sonnet global...
bool hasSelection() const const
bool automatic
This property holds whether spell checking is automatically disabled if there's too many errors.
Q_INVOKABLE bool isWordMisspelled(const QString &word)
Checks if a given word is marked as misspelled by the highlighter.
void contentsChange(int position, int charsRemoved, int charsAdded)
ControlModifier
bool movePosition(QTextCursor::MoveOperation operation, QTextCursor::MoveMode mode, int n)
QObject * parent() const const
int position() const const
bool isValid() const const
bool spellCheckerFound
This property holds whether a spell checking backend with support for the currentLanguage was found.
This file is part of the KDE documentation.
Documentation copyright © 1996-2023 The KDE developers.
Generated on Wed Sep 27 2023 03:55:20 by doxygen 1.8.17 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.