src/eric7/EricWidgets/EricSpellCheckedTextEdit.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 9021
62d6f565f740
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2021 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing QTextEdit and QPlainTextEdit widgets with embedded spell
8 checking.
9 """
10
11 import contextlib
12
13 try:
14 import enchant
15 import enchant.tokenize
16 from enchant.errors import TokenizerNotFoundError, DictNotFoundError
17 from enchant.utils import trim_suggestions
18 ENCHANT_AVAILABLE = True
19 except ImportError:
20 ENCHANT_AVAILABLE = False
21
22 from PyQt6.QtCore import pyqtSlot, Qt, QCoreApplication
23 from PyQt6.QtGui import (
24 QAction, QActionGroup, QSyntaxHighlighter, QTextBlockUserData,
25 QTextCharFormat, QTextCursor
26 )
27 from PyQt6.QtWidgets import QMenu, QTextEdit, QPlainTextEdit
28
29 if ENCHANT_AVAILABLE:
30 class SpellCheckMixin():
31 """
32 Class implementing the spell-check mixin for the widget classes.
33 """
34 # don't show more than this to keep the menu manageable
35 MaxSuggestions = 20
36
37 # default language to be used when no other is set
38 DefaultLanguage = None
39
40 # default user lists
41 DefaultUserWordList = None
42 DefaultUserExceptionList = None
43
44 def __init__(self):
45 """
46 Constructor
47 """
48 self.__highlighter = EnchantHighlighter(self.document())
49 try:
50 # Start with a default dictionary based on the current locale
51 # or the configured default language.
52 spellDict = enchant.DictWithPWL(
53 SpellCheckMixin.DefaultLanguage,
54 SpellCheckMixin.DefaultUserWordList,
55 SpellCheckMixin.DefaultUserExceptionList
56 )
57 except DictNotFoundError:
58 try:
59 # Use English dictionary if no locale dictionary is
60 # available or the default one could not be found.
61 spellDict = enchant.DictWithPWL(
62 "en",
63 SpellCheckMixin.DefaultUserWordList,
64 SpellCheckMixin.DefaultUserExceptionList
65 )
66 except DictNotFoundError:
67 # Still no dictionary could be found. Forget about spell
68 # checking.
69 spellDict = None
70
71 self.__highlighter.setDict(spellDict)
72
73 def contextMenuEvent(self, evt):
74 """
75 Protected method to handle context menu events to add a spelling
76 suggestions submenu.
77
78 @param evt reference to the context menu event
79 @type QContextMenuEvent
80 """
81 menu = self.__createSpellcheckContextMenu(evt.pos())
82 menu.exec(evt.globalPos())
83
84 def __createSpellcheckContextMenu(self, pos):
85 """
86 Private method to create the spell-check context menu.
87
88 @param pos position of the mouse pointer
89 @type QPoint
90 @return context menu with additional spell-check entries
91 @rtype QMenu
92 """
93 menu = self.createStandardContextMenu(pos)
94
95 # Add a submenu for setting the spell-check language and
96 # document format.
97 menu.addSeparator()
98 self.__addRemoveEntry(self.__cursorForPosition(pos), menu)
99 menu.addMenu(self.__createLanguagesMenu(menu))
100 menu.addMenu(self.__createFormatsMenu(menu))
101
102 # Try to retrieve a menu of corrections for the right-clicked word
103 spellMenu = self.__createCorrectionsMenu(
104 self.__cursorForMisspelling(pos), menu)
105
106 if spellMenu:
107 menu.insertSeparator(menu.actions()[0])
108 menu.insertMenu(menu.actions()[0], spellMenu)
109
110 return menu
111
112 def __createCorrectionsMenu(self, cursor, parent=None):
113 """
114 Private method to create a menu for corrections of the selected
115 word.
116
117 @param cursor reference to the text cursor
118 @type QTextCursor
119 @param parent reference to the parent widget (defaults to None)
120 @type QWidget (optional)
121 @return menu with corrections
122 @rtype QMenu
123 """
124 if cursor is None:
125 return None
126
127 text = cursor.selectedText()
128 suggestions = trim_suggestions(
129 text, self.__highlighter.dict().suggest(text),
130 SpellCheckMixin.MaxSuggestions)
131
132 spellMenu = QMenu(
133 QCoreApplication.translate("SpellCheckMixin",
134 "Spelling Suggestions"),
135 parent)
136 for word in suggestions:
137 act = spellMenu.addAction(word)
138 act.setData((cursor, word))
139
140 if suggestions:
141 spellMenu.addSeparator()
142
143 # add management entry
144 act = spellMenu.addAction(QCoreApplication.translate(
145 "SpellCheckMixin", "Add to Dictionary"))
146 act.setData((cursor, text, "add"))
147
148 spellMenu.triggered.connect(self.__spellMenuTriggered)
149 return spellMenu
150
151 def __addRemoveEntry(self, cursor, menu):
152 """
153 Private method to create a menu entry to remove the word at the
154 menu position.
155
156 @param cursor reference to the text cursor for the misspelled word
157 @type QTextCursor
158 @param menu reference to the context menu
159 @type QMenu
160 """
161 if cursor is None:
162 return
163
164 text = cursor.selectedText()
165 menu.addAction(QCoreApplication.translate(
166 "SpellCheckMixin",
167 "Remove '{0}' from Dictionary").format(text),
168 lambda: self.__addToUserDict(text, "remove"))
169
170 def __createLanguagesMenu(self, parent=None):
171 """
172 Private method to create a menu for selecting the spell-check
173 language.
174
175 @param parent reference to the parent widget (defaults to None)
176 @type QWidget (optional)
177 @return menu with spell-check languages
178 @rtype QMenu
179 """
180 curLanguage = self.__highlighter.dict().tag.lower()
181 languageMenu = QMenu(
182 QCoreApplication.translate("SpellCheckMixin", "Language"),
183 parent)
184 languageActions = QActionGroup(languageMenu)
185
186 for language in sorted(enchant.list_languages()):
187 act = QAction(language, languageActions)
188 act.setCheckable(True)
189 act.setChecked(language.lower() == curLanguage)
190 act.setData(language)
191 languageMenu.addAction(act)
192
193 languageMenu.triggered.connect(self.__setLanguage)
194 return languageMenu
195
196 def __createFormatsMenu(self, parent=None):
197 """
198 Private method to create a menu for selecting the document format.
199
200 @param parent reference to the parent widget (defaults to None)
201 @type QWidget (optional)
202 @return menu with document formats
203 @rtype QMenu
204 """
205 formatMenu = QMenu(
206 QCoreApplication.translate("SpellCheckMixin", "Format"),
207 parent)
208 formatActions = QActionGroup(formatMenu)
209
210 curFormat = self.__highlighter.chunkers()
211 for name, chunkers in (
212 (QCoreApplication.translate("SpellCheckMixin", "Text"),
213 []),
214 (QCoreApplication.translate("SpellCheckMixin", "HTML"),
215 [enchant.tokenize.HTMLChunker])
216 ):
217 act = QAction(name, formatActions)
218 act.setCheckable(True)
219 act.setChecked(chunkers == curFormat)
220 act.setData(chunkers)
221 formatMenu.addAction(act)
222
223 formatMenu.triggered.connect(self.__setFormat)
224 return formatMenu
225
226 def __cursorForPosition(self, pos):
227 """
228 Private method to create a text cursor selecting the word at the
229 given position.
230
231 @param pos position of the misspelled word
232 @type QPoint
233 @return text cursor for the word
234 @rtype QTextCursor
235 """
236 cursor = self.cursorForPosition(pos)
237 cursor.select(QTextCursor.SelectionType.WordUnderCursor)
238
239 if cursor.hasSelection():
240 return cursor
241 else:
242 return None
243
244 def __cursorForMisspelling(self, pos):
245 """
246 Private method to create a text cursor selecting the misspelled
247 word.
248
249 @param pos position of the misspelled word
250 @type QPoint
251 @return text cursor for the misspelled word
252 @rtype QTextCursor
253 """
254 cursor = self.cursorForPosition(pos)
255 misspelledWords = getattr(cursor.block().userData(),
256 "misspelled", [])
257
258 # If the cursor is within a misspelling, select the word
259 for (start, end) in misspelledWords:
260 if start <= cursor.positionInBlock() <= end:
261 blockPosition = cursor.block().position()
262
263 cursor.setPosition(blockPosition + start,
264 QTextCursor.MoveMode.MoveAnchor)
265 cursor.setPosition(blockPosition + end,
266 QTextCursor.MoveMode.KeepAnchor)
267 break
268
269 if cursor.hasSelection():
270 return cursor
271 else:
272 return None
273
274 def __correctWord(self, cursor, word):
275 """
276 Private method to replace some misspelled text.
277
278 @param cursor reference to the text cursor for the misspelled word
279 @type QTextCursor
280 @param word replacement text
281 @type str
282 """
283 cursor.beginEditBlock()
284 cursor.removeSelectedText()
285 cursor.insertText(word)
286 cursor.endEditBlock()
287
288 def __addToUserDict(self, word, command):
289 """
290 Private method to add a word to the user word or exclude list.
291
292 @param word text to be added
293 @type str
294 @param command command indicating the user dictionary type
295 @type str
296 """
297 if word:
298 dictionary = self.__highlighter.dict()
299 if command == "add":
300 dictionary.add(word)
301 elif command == "remove":
302 dictionary.remove(word)
303
304 self.__highlighter.rehighlight()
305
306 @pyqtSlot(QAction)
307 def __spellMenuTriggered(self, act):
308 """
309 Private slot to handle a selection of the spell menu.
310
311 @param act reference to the selected action
312 @type QAction
313 """
314 data = act.data()
315 if len(data) == 2:
316 # replace the misspelled word
317 self.__correctWord(*data)
318
319 elif len(data) == 3:
320 # dictionary management action
321 _, word, command = data
322 self.__addToUserDict(word, command)
323
324 @pyqtSlot(QAction)
325 def __setLanguage(self, act):
326 """
327 Private slot to set the selected language.
328
329 @param act reference to the selected action
330 @type QAction
331 """
332 language = act.data()
333 self.setLanguage(language)
334
335 @pyqtSlot(QAction)
336 def __setFormat(self, act):
337 """
338 Private slot to set the selected document format.
339
340 @param act reference to the selected action
341 @type QAction
342 """
343 chunkers = act.data()
344 self.__highlighter.setChunkers(chunkers)
345
346 def setFormat(self, formatName):
347 """
348 Public method to set the document format.
349
350 @param formatName name of the document format
351 @type str
352 """
353 self.__highlighter.setChunkers(
354 [enchant.tokenize.HTMLChunker]
355 if format == "html" else
356 []
357 )
358
359 def dict(self):
360 """
361 Public method to get a reference to the dictionary in use.
362
363 @return reference to the current dictionary
364 @rtype enchant.Dict
365 """
366 return self.__highlighter.dict()
367
368 def setDict(self, spellDict):
369 """
370 Public method to set the dictionary to be used.
371
372 @param spellDict reference to the spell-check dictionary
373 @type emchant.Dict
374 """
375 self.__highlighter.setDict(spellDict)
376
377 @pyqtSlot(str)
378 def setLanguage(self, language):
379 """
380 Public slot to set the spellchecker language.
381
382 @param language language to be set
383 @type str
384 """
385 epwl = self.dict().pwl
386 pwl = (
387 epwl.provider.file
388 if isinstance(epwl, enchant.Dict) else
389 None
390 )
391
392 epel = self.dict().pel
393 pel = (
394 epel.provider.file
395 if isinstance(epel, enchant.Dict) else
396 None
397 )
398 self.setLanguageWithPWL(language, pwl, pel)
399
400 @pyqtSlot(str, str, str)
401 def setLanguageWithPWL(self, language, pwl, pel):
402 """
403 Public slot to set the spellchecker language and associated user
404 word lists.
405
406 @param language language to be set
407 @type str
408 @param pwl file name of the personal word list
409 @type str
410 @param pel file name of the personal exclude list
411 @type str
412 """
413 try:
414 spellDict = enchant.DictWithPWL(language, pwl, pel)
415 except DictNotFoundError:
416 try:
417 # Use English dictionary if a dictionary for the given
418 # language is not available.
419 spellDict = enchant.DictWithPWL("en", pwl, pel)
420 except DictNotFoundError:
421 # Still no dictionary could be found. Forget about spell
422 # checking.
423 spellDict = None
424 self.__highlighter.setDict(spellDict)
425
426 @classmethod
427 def setDefaultLanguage(cls, language, pwl=None, pel=None):
428 """
429 Class method to set the default spell-check language.
430
431 @param language language to be set as default
432 @type str
433 @param pwl file name of the personal word list
434 @type str
435 @param pel file name of the personal exclude list
436 @type str
437 """
438 with contextlib.suppress(DictNotFoundError):
439 cls.DefaultUserWordList = pwl
440 cls.DefaultUserExceptionList = pel
441
442 # set default language only, if a dictionary is available
443 enchant.Dict(language)
444 cls.DefaultLanguage = language
445
446 class EnchantHighlighter(QSyntaxHighlighter):
447 """
448 Class implementing a QSyntaxHighlighter subclass that consults a
449 pyEnchant dictionary to highlight misspelled words.
450 """
451 TokenFilters = (enchant.tokenize.EmailFilter,
452 enchant.tokenize.URLFilter)
453
454 # Define the spell-check style once and just assign it as necessary
455 ErrorFormat = QTextCharFormat()
456 ErrorFormat.setUnderlineColor(Qt.GlobalColor.red)
457 ErrorFormat.setUnderlineStyle(
458 QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
459
460 def __init__(self, *args):
461 """
462 Constructor
463
464 @param *args list of arguments for the QSyntaxHighlighter
465 @type list
466 """
467 QSyntaxHighlighter.__init__(self, *args)
468
469 self.__spellDict = None
470 self.__tokenizer = None
471 self.__chunkers = []
472
473 def chunkers(self):
474 """
475 Public method to get the chunkers in use.
476
477 @return list of chunkers in use
478 @rtype list
479 """
480 return self.__chunkers
481
482 def setChunkers(self, chunkers):
483 """
484 Public method to set the chunkers to be used.
485
486 @param chunkers chunkers to be used
487 @type list
488 """
489 self.__chunkers = chunkers
490 self.setDict(self.dict())
491
492 def dict(self):
493 """
494 Public method to get the spelling dictionary in use.
495
496 @return spelling dictionary
497 @rtype enchant.Dict
498 """
499 return self.__spellDict
500
501 def setDict(self, spellDict):
502 """
503 Public method to set the spelling dictionary to be used.
504
505 @param spellDict spelling dictionary
506 @type enchant.Dict
507 """
508 if spellDict:
509 try:
510 self.__tokenizer = enchant.tokenize.get_tokenizer(
511 spellDict.tag,
512 chunkers=self.__chunkers,
513 filters=EnchantHighlighter.TokenFilters)
514 except TokenizerNotFoundError:
515 # Fall back to the "good for most euro languages"
516 # English tokenizer
517 self.__tokenizer = enchant.tokenize.get_tokenizer(
518 chunkers=self.__chunkers,
519 filters=EnchantHighlighter.TokenFilters)
520 else:
521 self.__tokenizer = None
522
523 self.__spellDict = spellDict
524
525 self.rehighlight()
526
527 def highlightBlock(self, text):
528 """
529 Public method to apply the text highlight.
530
531 @param text text to be spell-checked
532 @type str
533 """
534 """Overridden QSyntaxHighlighter method to apply the highlight"""
535 if self.__spellDict is None or self.__tokenizer is None:
536 return
537
538 # Build a list of all misspelled words and highlight them
539 misspellings = []
540 for (word, pos) in self.__tokenizer(text):
541 if not self.__spellDict.check(word):
542 self.setFormat(pos, len(word),
543 EnchantHighlighter.ErrorFormat)
544 misspellings.append((pos, pos + len(word)))
545
546 # Store the list so the context menu can reuse this tokenization
547 # pass (Block-relative values so editing other blocks won't
548 # invalidate them)
549 data = QTextBlockUserData()
550 data.misspelled = misspellings
551 self.setCurrentBlockUserData(data)
552
553 else:
554
555 class SpellCheckMixin():
556 """
557 Class implementing the spell-check mixin for the widget classes.
558 """
559 #
560 # This is just a stub to provide the same API as the enchant enabled
561 # one.
562 #
563 def __init__(self):
564 """
565 Constructor
566 """
567 pass
568
569 def setFormat(self, formatName):
570 """
571 Public method to set the document format.
572
573 @param formatName name of the document format
574 @type str
575 """
576 pass
577
578 def dict(self):
579 """
580 Public method to get a reference to the dictionary in use.
581
582 @return reference to the current dictionary
583 @rtype enchant.Dict
584 """
585 pass
586
587 def setDict(self, spellDict):
588 """
589 Public method to set the dictionary to be used.
590
591 @param spellDict reference to the spell-check dictionary
592 @type emchant.Dict
593 """
594 pass
595
596 @pyqtSlot(str)
597 def setLanguage(self, language):
598 """
599 Public slot to set the spellchecker language.
600
601 @param language language to be set
602 @type str
603 """
604 pass
605
606 @pyqtSlot(str, str, str)
607 def setLanguageWithPWL(self, language, pwl, pel):
608 """
609 Public slot to set the spellchecker language and associated user
610 word lists.
611
612 @param language language to be set
613 @type str
614 @param pwl file name of the personal word list
615 @type str
616 @param pel file name of the personal exclude list
617 @type str
618 """
619 pass
620
621 @classmethod
622 def setDefaultLanguage(cls, language, pwl=None, pel=None):
623 """
624 Class method to set the default spell-check language.
625
626 @param language language to be set as default
627 @type str
628 @param pwl file name of the personal word list
629 @type str
630 @param pel file name of the personal exclude list
631 @type str
632 """
633 pass
634
635
636 class EricSpellCheckedPlainTextEdit(QPlainTextEdit, SpellCheckMixin):
637 """
638 Class implementing a QPlainTextEdit with built-in spell checker.
639 """
640 def __init__(self, *args):
641 """
642 Constructor
643
644 @param *args list of arguments for the QPlainTextEdit constructor.
645 @type list
646 """
647 QPlainTextEdit.__init__(self, *args)
648 SpellCheckMixin.__init__(self)
649
650
651 class EricSpellCheckedTextEdit(QTextEdit, SpellCheckMixin):
652 """
653 Class implementing a QTextEdit with built-in spell checker.
654 """
655 def __init__(self, *args):
656 """
657 Constructor
658
659 @param *args list of arguments for the QPlainTextEdit constructor.
660 @type list
661 """
662 QTextEdit.__init__(self, *args)
663 SpellCheckMixin.__init__(self)
664
665 self.setFormat("html")
666
667 def setAcceptRichText(self, accept):
668 """
669 Public method to set the text edit mode.
670
671 @param accept flag indicating to accept rich text
672 @type bool
673 """
674 QTextEdit.setAcceptRichText(self, accept)
675 self.setFormat("html" if accept else "text")
676
677 if __name__ == '__main__':
678 import sys
679 import os
680 from PyQt6.QtWidgets import QApplication
681
682 if ENCHANT_AVAILABLE:
683 dictPath = os.path.expanduser(os.path.join("~", ".eric7", "spelling"))
684 SpellCheckMixin.setDefaultLanguage(
685 "en_US",
686 os.path.join(dictPath, "pwl.dic"),
687 os.path.join(dictPath, "pel.dic")
688 )
689
690 app = QApplication(sys.argv)
691 spellEdit = EricSpellCheckedPlainTextEdit()
692 spellEdit.show()
693
694 sys.exit(app.exec())

eric ide

mercurial