QtSpell  1.0.1
Spell checking for Qt text widgets
TextEditChecker.cpp
1 /* QtSpell - Spell checking for Qt text widgets.
2  * Copyright (c) 2014-2022 Sandro Mani
3  *
4  * This program is free software; you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation; either version 2 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12  * GNU General Public License for more details.
13  *
14  * You should have received a copy of the GNU General Public License along
15  * with this program; if not, write to the Free Software Foundation, Inc.,
16  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17  */
18 
19 #include "QtSpell.hpp"
20 #include "TextEditChecker_p.hpp"
21 #include "UndoRedoStack.hpp"
22 
23 #include <QDebug>
24 #include <QPlainTextEdit>
25 #include <QTextEdit>
26 #include <QTextBlock>
27 
28 namespace QtSpell {
29 
30 TextEditCheckerPrivate::TextEditCheckerPrivate()
31  : CheckerPrivate()
32 {
33 }
34 
35 TextEditCheckerPrivate::~TextEditCheckerPrivate()
36 {
37 }
38 
40 
41 QString TextCursor::nextChar(int num) const
42 {
43  TextCursor testCursor(*this);
44  if(num > 1)
45  testCursor.movePosition(NextCharacter, MoveAnchor, num - 1);
46  else
47  testCursor.setPosition(testCursor.position());
48  testCursor.movePosition(NextCharacter, KeepAnchor);
49  return testCursor.selectedText();
50 }
51 
52 QString TextCursor::prevChar(int num) const
53 {
54  TextCursor testCursor(*this);
55  if(num > 1)
56  testCursor.movePosition(PreviousCharacter, MoveAnchor, num - 1);
57  else
58  testCursor.setPosition(testCursor.position());
59  testCursor.movePosition(PreviousCharacter, KeepAnchor);
60  return testCursor.selectedText();
61 }
62 
63 void TextCursor::moveWordStart(MoveMode moveMode)
64 {
65  movePosition(StartOfWord, moveMode);
66  qDebug() << "Start: " << position() << ": " << prevChar(2) << prevChar() << "|" << nextChar();
67  // If we are in front of a quote...
68  if(nextChar() == "'"){
69  // If the previous char is alphanumeric, move left one word, otherwise move right one char
70  if(prevChar().contains(m_wordRegEx)){
71  movePosition(WordLeft, moveMode);
72  }else{
73  movePosition(NextCharacter, moveMode);
74  }
75  }
76  // If the previous char is a quote, and the char before that is alphanumeric, move left one word
77  else if(prevChar() == "'" && prevChar(2).contains(m_wordRegEx)){
78  movePosition(WordLeft, moveMode, 2); // 2: because quote counts as a word boundary
79  }
80 }
81 
82 void TextCursor::moveWordEnd(MoveMode moveMode)
83 {
84  movePosition(EndOfWord, moveMode);
85  qDebug() << "End: " << position() << ": " << prevChar() << " | " << nextChar() << "|" << nextChar(2);
86  // If we are in behind of a quote...
87  if(prevChar() == "'"){
88  // If the next char is alphanumeric, move right one word, otherwise move left one char
89  if(nextChar().contains(m_wordRegEx)){
90  movePosition(WordRight, moveMode);
91  }else{
92  movePosition(PreviousCharacter, moveMode);
93  }
94  }
95  // If the next char is a quote, and the char after that is alphanumeric, move right one word
96  else if(nextChar() == "'" && nextChar(2).contains(m_wordRegEx)){
97  movePosition(WordRight, moveMode, 2); // 2: because quote counts as a word boundary
98  }
99 }
100 
102 
104  : Checker(*new TextEditCheckerPrivate(), parent)
105 {
106 }
107 
109 {
110  Q_D(TextEditChecker);
111  d->setTextEdit(nullptr);
112 }
113 
114 void TextEditChecker::setTextEdit(QTextEdit* textEdit)
115 {
116  Q_D(TextEditChecker);
117  d->setTextEdit(textEdit ? new TextEditProxyT<QTextEdit>(textEdit) : nullptr);
118 }
119 
120 void TextEditChecker::setTextEdit(QPlainTextEdit* textEdit)
121 {
122  Q_D(TextEditChecker);
123  d->setTextEdit(textEdit ? new TextEditProxyT<QPlainTextEdit>(textEdit) : nullptr);
124 }
125 
126 void TextEditCheckerPrivate::setTextEdit(TextEditProxy *newTextEdit)
127 {
128  Q_Q(TextEditChecker);
129  if(textEdit){
130  QObject::disconnect(textEdit, &TextEditProxy::editDestroyed, q, &TextEditChecker::slotDetachTextEdit);
131  QObject::disconnect(textEdit, &TextEditProxy::textChanged, q, &TextEditChecker::slotCheckDocumentChanged);
132  QObject::disconnect(textEdit, &TextEditProxy::customContextMenuRequested, q, &TextEditChecker::slotShowContextMenu);
133  QObject::disconnect(textEdit->document(), &QTextDocument::contentsChange, q, &TextEditChecker::slotCheckRange);
134  textEdit->setContextMenuPolicy(oldContextMenuPolicy);
135  textEdit->removeEventFilter(q);
136 
137  // Remove spelling format
138  QTextCursor cursor = textEdit->textCursor();
139  cursor.movePosition(QTextCursor::Start);
140  cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor);
141  QTextCharFormat fmt = cursor.charFormat();
142  QTextCharFormat defaultFormat = QTextCharFormat();
143  fmt.setFontUnderline(defaultFormat.fontUnderline());
144  fmt.setUnderlineColor(defaultFormat.underlineColor());
145  fmt.setUnderlineStyle(defaultFormat.underlineStyle());
146  cursor.setCharFormat(fmt);
147  }
148  bool undoWasEnabled = undoRedoStack != nullptr;
149  q->setUndoRedoEnabled(false);
150  delete textEdit;
151  document = nullptr;
152  textEdit = newTextEdit;
153  if(textEdit){
154  bool wasModified = textEdit->document()->isModified();
155  document = textEdit->document();
156  QObject::connect(textEdit, &TextEditProxy::editDestroyed, q, &TextEditChecker::slotDetachTextEdit);
157  QObject::connect(textEdit, &TextEditProxy::textChanged, q, &TextEditChecker::slotCheckDocumentChanged);
158  QObject::connect(textEdit, &TextEditProxy::customContextMenuRequested, q, &TextEditChecker::slotShowContextMenu);
159  QObject::connect(textEdit->document(), &QTextDocument::contentsChange, q, &TextEditChecker::slotCheckRange);
160  oldContextMenuPolicy = textEdit->contextMenuPolicy();
161  q->setUndoRedoEnabled(undoWasEnabled);
162  textEdit->setContextMenuPolicy(Qt::CustomContextMenu);
163  textEdit->installEventFilter(q);
164  q->checkSpelling();
165  textEdit->document()->setModified(wasModified);
166  } else {
167  if(undoWasEnabled){
168  // Crate dummy instance
169  q->setUndoRedoEnabled(true);
170  }
171  }
172 }
173 
175 {
176  Q_D(TextEditChecker);
177  d->noSpellingProperty = propertyId;
178 }
179 
181 {
182  Q_D(const TextEditChecker);
183  return d->noSpellingProperty;
184 }
185 
186 bool TextEditChecker::eventFilter(QObject* obj, QEvent* event)
187 {
188  if(event->type() == QEvent::KeyPress){
189  QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
190  if(keyEvent->key() == Qt::Key_Z && keyEvent->modifiers() == Qt::CTRL){
191  undo();
192  return true;
193  }else if(keyEvent->key() == Qt::Key_Z && keyEvent->modifiers() == (Qt::CTRL | Qt::SHIFT)){
194  redo();
195  return true;
196  }
197  }
198  return QObject::eventFilter(obj, event);
199 }
200 
201 void TextEditChecker::checkSpelling(int start, int end)
202 {
203  Q_D(TextEditChecker);
204  if (!d->textEdit) {
205  return;
206  }
207  if(end == -1){
208  QTextCursor tmpCursor(d->textEdit->textCursor());
209  tmpCursor.movePosition(QTextCursor::End);
210  end = tmpCursor.position();
211  }
212 
213  // stop contentsChange signals from being emitted due to changed charFormats
214  d->textEdit->document()->blockSignals(true);
215 
216  qDebug() << "Checking range " << start << " - " << end;
217 
218  QTextCharFormat errorFmt;
219  errorFmt.setFontUnderline(true);
220  errorFmt.setUnderlineColor(Qt::red);
221  errorFmt.setUnderlineStyle(QTextCharFormat::WaveUnderline);
222  QTextCharFormat defaultFormat = QTextCharFormat();
223 
224  TextCursor cursor(d->textEdit->textCursor());
225  cursor.beginEditBlock();
226  cursor.setPosition(start);
227  while(cursor.position() < end) {
228  cursor.moveWordEnd(QTextCursor::KeepAnchor);
229  bool correct;
230  QString word = cursor.selectedText();
231  if(d->noSpellingPropertySet(cursor)) {
232  correct = true;
233  qDebug() << "Skipping word:" << word << "(" << cursor.anchor() << "-" << cursor.position() << ")";
234  } else {
235  correct = checkWord(word);
236  qDebug() << "Checking word:" << word << "(" << cursor.anchor() << "-" << cursor.position() << "), correct:" << correct;
237  }
238  if(!correct){
239  cursor.mergeCharFormat(errorFmt);
240  }else{
241  QTextCharFormat fmt = cursor.charFormat();
242  fmt.setFontUnderline(defaultFormat.fontUnderline());
243  fmt.setUnderlineColor(defaultFormat.underlineColor());
244  fmt.setUnderlineStyle(defaultFormat.underlineStyle());
245  cursor.setCharFormat(fmt);
246  }
247  // Go to next word start
248  while(cursor.position() < end && !cursor.isWordChar(cursor.nextChar())){
249  cursor.movePosition(QTextCursor::NextCharacter);
250  }
251  }
252  cursor.endEditBlock();
253 
254  d->textEdit->document()->blockSignals(false);
255 }
256 
257 bool TextEditCheckerPrivate::noSpellingPropertySet(const QTextCursor &cursor) const
258 {
259  if(noSpellingProperty < QTextFormat::UserProperty) {
260  return false;
261  }
262  if(cursor.charFormat().intProperty(noSpellingProperty) == 1) {
263  return true;
264  }
265  const QVector<QTextLayout::FormatRange>& formats = cursor.block().layout()->formats();
266  int pos = cursor.positionInBlock();
267  foreach(const QTextLayout::FormatRange& range, formats) {
268  if(pos > range.start && pos <= range.start + range.length && range.format.intProperty(noSpellingProperty) == 1) {
269  return true;
270  }
271  }
272  return false;
273 }
274 
276 {
277  Q_D(TextEditChecker);
278  if(d->undoRedoStack){
279  d->undoRedoStack->clear();
280  }
281 }
282 
284 {
285  Q_D(TextEditChecker);
286  if(enabled == (d->undoRedoStack != nullptr)){
287  return;
288  }
289  if(!enabled){
290  delete d->undoRedoStack;
291  d->undoRedoStack = nullptr;
292  emit undoAvailable(false);
293  emit redoAvailable(false);
294  }else{
295  d->undoRedoStack = new UndoRedoStack(d->textEdit);
296  connect(d->undoRedoStack, &QtSpell::UndoRedoStack::undoAvailable, this, &TextEditChecker::undoAvailable);
297  connect(d->undoRedoStack, &QtSpell::UndoRedoStack::redoAvailable, this, &TextEditChecker::redoAvailable);
298  }
299 }
300 
301 QString TextEditChecker::getWord(int pos, int* start, int* end) const
302 {
303  Q_D(const TextEditChecker);
304  TextCursor cursor(d->textEdit->textCursor());
305  cursor.setPosition(pos);
306  cursor.moveWordStart();
307  cursor.moveWordEnd(QTextCursor::KeepAnchor);
308  if(start)
309  *start = cursor.anchor();
310  if(end)
311  *end = cursor.position();
312  return cursor.selectedText();
313 }
314 
315 void TextEditChecker::insertWord(int start, int end, const QString &word)
316 {
317  Q_D(TextEditChecker);
318  QTextCursor cursor(d->textEdit->textCursor());
319  cursor.setPosition(start);
320  cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, end - start);
321  cursor.insertText(word);
322 }
323 
324 void TextEditChecker::slotShowContextMenu(const QPoint &pos)
325 {
326  Q_D(TextEditChecker);
327  QPoint globalPos = d->textEdit->mapToGlobal(pos);
328  QMenu* menu = d->textEdit->createStandardContextMenu();
329  int wordPos = d->textEdit->cursorForPosition(pos).position();
330  showContextMenu(menu, globalPos, wordPos);
331 }
332 
333 void TextEditChecker::slotCheckDocumentChanged()
334 {
335  Q_D(TextEditChecker);
336  if(d->document != d->textEdit->document()) {
337  bool undoWasEnabled = d->undoRedoStack != nullptr;
338  setUndoRedoEnabled(false);
339  if(d->document){
340  disconnect(d->document, &QTextDocument::contentsChange, this, &TextEditChecker::slotCheckRange);
341  }
342  d->document = d->textEdit->document();
343  connect(d->document, &QTextDocument::contentsChange, this, &TextEditChecker::slotCheckRange);
344  setUndoRedoEnabled(undoWasEnabled);
345  }
346 }
347 
348 void TextEditChecker::slotDetachTextEdit()
349 {
350  Q_D(TextEditChecker);
351  bool undoWasEnabled = d->undoRedoStack != nullptr;
352  setUndoRedoEnabled(false);
353  delete d->textEdit;
354  d->textEdit = nullptr;
355  d->document = nullptr;
356  if(undoWasEnabled){
357  // Crate dummy instance
358  setUndoRedoEnabled(true);
359  }
360 }
361 
362 void TextEditChecker::slotCheckRange(int pos, int removed, int added)
363 {
364  Q_D(TextEditChecker);
365  if(d->undoRedoStack != nullptr && !d->undoRedoInProgress){
366  d->undoRedoStack->handleContentsChange(pos, removed, added);
367  }
368 
369  // Qt Bug? Apparently, when contents is pasted at pos = 0, added and removed are too large by 1
370  TextCursor c(d->textEdit->textCursor());
371  c.movePosition(QTextCursor::End);
372  int len = c.position();
373  if(pos == 0 && added > len){
374  --added;
375  }
376 
377  // Set default format on inserted text
378  c.beginEditBlock();
379  c.setPosition(pos);
380  c.moveWordStart();
381  c.setPosition(pos + added, QTextCursor::KeepAnchor);
382  c.moveWordEnd(QTextCursor::KeepAnchor);
383  QTextCharFormat fmt = c.charFormat();
384  QTextCharFormat defaultFormat = QTextCharFormat();
385  fmt.setFontUnderline(defaultFormat.fontUnderline());
386  fmt.setUnderlineColor(defaultFormat.underlineColor());
387  fmt.setUnderlineStyle(defaultFormat.underlineStyle());
388  c.setCharFormat(fmt);
389  checkSpelling(c.anchor(), c.position());
390  c.endEditBlock();
391 }
392 
394 {
395  Q_D(TextEditChecker);
396  if(d->undoRedoStack != nullptr){
397  d->undoRedoInProgress = true;
398  d->undoRedoStack->undo();
399  d->textEdit->ensureCursorVisible();
400  d->undoRedoInProgress = false;
401  }
402 }
403 
405 {
406  Q_D(TextEditChecker);
407  if(d->undoRedoStack != nullptr){
408  d->undoRedoInProgress = true;
409  d->undoRedoStack->redo();
410  d->textEdit->ensureCursorVisible();
411  d->undoRedoInProgress = false;
412  }
413 }
414 
416 {
417  Q_D(const TextEditChecker);
418  return d->textEdit != 0;
419 }
420 
421 } // QtSpell
void undo()
Undo the last edit operation.
TextEditChecker(QObject *parent=0)
TextEditChecker object constructor.
void clearUndoRedo()
Clears the undo/redo stack.
void setTextEdit(QTextEdit *textEdit)
Set the QTextEdit to check.
void setUndoRedoEnabled(bool enabled)
Sets whether undo/redo functionality is enabled.
bool isAttached() const
Returns whether a widget is attached to the checker.
Checker class for QTextEdit widgets.
Definition: QtSpell.hpp:220
void redoAvailable(bool available)
Emitted when the redo stak changes.
void undoAvailable(bool available)
Emitted when the undo stack changes.
void insertWord(int start, int end, const QString &word)
Replaces the specified range with the specified word.
QString prevChar(int num=1) const
Retreive the num-th previous character.
void checkSpelling(int start=0, int end=-1)
Check the spelling.
int noSpellingPropertyId() const
Returns the current QTextCharFormat property identifier which marks whether a word ought to be spell-...
QtSpell namespace.
Definition: Checker.cpp:77
void moveWordEnd(MoveMode moveMode=MoveAnchor)
Move the cursor to the end of the current word. Cursor must be inside a word. This method correctly h...
void moveWordStart(MoveMode moveMode=MoveAnchor)
Move the cursor to the start of the current word. Cursor must be inside a word. This method correctly...
void redo()
Redo the last edit operation.
void setNoSpellingPropertyId(int propertyId)
Set the QTextCharFormat property identifier which marks whether a word ought to be spell-checked...
QString getWord(int pos, int *start=0, int *end=0) const
Get the word at the specified cursor position.
An enhanced QTextCursor.
An abstract class providing spell checking support.
Definition: QtSpell.hpp:49
~TextEditChecker()
TextEditChecker object destructor.
QString nextChar(int num=1) const
Retreive the num-th next character.
bool checkWord(const QString &word) const
Check the specified word.
Definition: Checker.cpp:204