|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2021 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 sys |
|
12 |
|
13 import enchant |
|
14 from enchant import tokenize |
|
15 from enchant.errors import TokenizerNotFoundError, DictNotFoundError |
|
16 from enchant.utils import trim_suggestions |
|
17 |
|
18 from PyQt6.QtCore import pyqtSlot, Qt, QCoreApplication |
|
19 from PyQt6.QtGui import ( |
|
20 QAction, QActionGroup, QSyntaxHighlighter, QTextBlockUserData, |
|
21 QTextCharFormat, QTextCursor |
|
22 ) |
|
23 from PyQt6.QtWidgets import QMenu, QTextEdit, QPlainTextEdit |
|
24 |
|
25 # TODO: add user dictionaries with respective menu entries |
|
26 |
|
27 |
|
28 class SpellCheckMixin(): |
|
29 """ |
|
30 Class implementing the spell-check mixin for the widget classes. |
|
31 """ |
|
32 # don't show more than this to keep the menu manageable |
|
33 MaxSuggestions = 20 |
|
34 |
|
35 def __init__(self): |
|
36 """ |
|
37 Constructor |
|
38 """ |
|
39 self.__highlighter = EnchantHighlighter(self.document()) |
|
40 try: |
|
41 # Start with a default dictionary based on the current locale. |
|
42 spellDict = enchant.Dict() |
|
43 except DictNotFoundError: |
|
44 # Use English dictionary if no locale dictionary is available. |
|
45 spellDict = enchant.Dict("en") |
|
46 self.__highlighter.setDict(spellDict) |
|
47 |
|
48 def contextMenuEvent(self, evt): |
|
49 """ |
|
50 Protected method to handle context menu events to add a spelling |
|
51 suggestions submenu. |
|
52 |
|
53 @param evt reference to the context menu event |
|
54 @type QContextMenuEvent |
|
55 """ |
|
56 menu = self.__createSpellcheckContextMenu(evt.pos()) |
|
57 menu.exec(evt.globalPos()) |
|
58 |
|
59 def __createSpellcheckContextMenu(self, pos): |
|
60 """ |
|
61 Private method to create the spell-check context menu. |
|
62 |
|
63 @param pos position of the mouse pointer |
|
64 @type QPoint |
|
65 @return context menu with additional spell-check entries |
|
66 @rtype QMenu |
|
67 """ |
|
68 menu = self.createStandardContextMenu(pos) |
|
69 |
|
70 # Add a submenu for setting the spell-check language and |
|
71 # document format. |
|
72 menu.addSeparator() |
|
73 menu.addMenu(self.__createLanguagesMenu(menu)) |
|
74 menu.addMenu(self.__createFormatsMenu(menu)) |
|
75 |
|
76 # Try to retrieve a menu of corrections for the right-clicked word |
|
77 spellMenu = self.__createCorrectionsMenu( |
|
78 self.__cursorForMisspelling(pos), menu) |
|
79 |
|
80 if spellMenu: |
|
81 menu.insertSeparator(menu.actions()[0]) |
|
82 menu.insertMenu(menu.actions()[0], spellMenu) |
|
83 |
|
84 return menu |
|
85 |
|
86 def __createCorrectionsMenu(self, cursor, parent=None): |
|
87 """ |
|
88 Private method to create a menu for corrections of the selected word. |
|
89 |
|
90 @param cursor reference to the text cursor |
|
91 @type QTextCursor |
|
92 @param parent reference to the parent widget (defaults to None) |
|
93 @type QWidget (optional) |
|
94 @return menu with corrections |
|
95 @rtype QMenu |
|
96 """ |
|
97 if cursor is None: |
|
98 return None |
|
99 |
|
100 text = cursor.selectedText() |
|
101 suggestions = trim_suggestions(text, |
|
102 self.__highlighter.dict().suggest(text), |
|
103 SpellCheckMixin.MaxSuggestions) |
|
104 |
|
105 spellMenu = QMenu( |
|
106 QCoreApplication.translate("SpellCheckMixin", |
|
107 "Spelling Suggestions"), |
|
108 parent) |
|
109 for word in suggestions: |
|
110 act = spellMenu.addAction(word) |
|
111 act.setData((cursor, word)) |
|
112 |
|
113 # Only return the menu if it's non-empty |
|
114 if spellMenu.actions(): |
|
115 spellMenu.triggered.connect(self.__correctWord) |
|
116 return spellMenu |
|
117 |
|
118 return None |
|
119 |
|
120 def __createLanguagesMenu(self, parent=None): |
|
121 """ |
|
122 Private method to create a menu for selecting the spell-check language. |
|
123 |
|
124 @param parent reference to the parent widget (defaults to None) |
|
125 @type QWidget (optional) |
|
126 @return menu with spell-check languages |
|
127 @rtype QMenu |
|
128 """ |
|
129 curLanguage = self.__highlighter.dict().tag |
|
130 languageMenu = QMenu( |
|
131 QCoreApplication.translate("SpellCheckMixin", "Language"), |
|
132 parent) |
|
133 languageActions = QActionGroup(languageMenu) |
|
134 |
|
135 for language in enchant.list_languages(): |
|
136 act = QAction(language, languageActions) |
|
137 act.setCheckable(True) |
|
138 act.setChecked(language == curLanguage) |
|
139 act.setData(language) |
|
140 languageMenu.addAction(act) |
|
141 |
|
142 languageMenu.triggered.connect(self.__setLanguage) |
|
143 return languageMenu |
|
144 |
|
145 def __createFormatsMenu(self, parent=None): |
|
146 """ |
|
147 Private method to create a menu for selecting the document format. |
|
148 |
|
149 @param parent reference to the parent widget (defaults to None) |
|
150 @type QWidget (optional) |
|
151 @return menu with document formats |
|
152 @rtype QMenu |
|
153 """ |
|
154 formatMenu = QMenu( |
|
155 QCoreApplication.translate("SpellCheckMixin", "Format"), |
|
156 parent) |
|
157 formatActions = QActionGroup(formatMenu) |
|
158 |
|
159 curFormat = self.__highlighter.chunkers() |
|
160 for name, chunkers in ( |
|
161 (QCoreApplication.translate("SpellCheckMixin", "Text"), |
|
162 []), |
|
163 (QCoreApplication.translate("SpellCheckMixin", "HTML"), |
|
164 [tokenize.HTMLChunker]) |
|
165 ): |
|
166 act = QAction(name, formatActions) |
|
167 act.setCheckable(True) |
|
168 act.setChecked(chunkers == curFormat) |
|
169 act.setData(chunkers) |
|
170 formatMenu.addAction(act) |
|
171 |
|
172 formatMenu.triggered.connect(self.__setFormat) |
|
173 return formatMenu |
|
174 |
|
175 def __cursorForMisspelling(self, pos): |
|
176 """ |
|
177 Private method to create a text cursor selecting the misspelled word. |
|
178 |
|
179 @param pos position of the misspelled word |
|
180 @type QPoint |
|
181 @return text cursor for the misspelled word |
|
182 @rtype QTextCursor |
|
183 """ |
|
184 cursor = self.cursorForPosition(pos) |
|
185 misspelledWords = getattr(cursor.block().userData(), "misspelled", []) |
|
186 |
|
187 # If the cursor is within a misspelling, select the word |
|
188 for (start, end) in misspelledWords: |
|
189 if start <= cursor.positionInBlock() <= end: |
|
190 blockPosition = cursor.block().position() |
|
191 |
|
192 cursor.setPosition(blockPosition + start, |
|
193 QTextCursor.MoveMode.MoveAnchor) |
|
194 cursor.setPosition(blockPosition + end, |
|
195 QTextCursor.MoveMode.KeepAnchor) |
|
196 break |
|
197 |
|
198 if cursor.hasSelection(): |
|
199 return cursor |
|
200 else: |
|
201 return None |
|
202 |
|
203 @pyqtSlot(QAction) |
|
204 def __correctWord(self, act): |
|
205 """ |
|
206 Private slot to correct the misspelled word with the selected |
|
207 correction. |
|
208 |
|
209 @param act reference to the selected action |
|
210 @type QAction |
|
211 """ |
|
212 cursor, word = act.data() |
|
213 |
|
214 cursor.beginEditBlock() |
|
215 cursor.removeSelectedText() |
|
216 cursor.insertText(word) |
|
217 cursor.endEditBlock() |
|
218 |
|
219 @pyqtSlot(QAction) |
|
220 def __setLanguage(self, act): |
|
221 """ |
|
222 Private slot to set the selected language. |
|
223 |
|
224 @param act reference to the selected action |
|
225 @type QAction |
|
226 """ |
|
227 language = act.data() |
|
228 self.__highlighter.setDict(enchant.Dict(language)) |
|
229 |
|
230 @pyqtSlot(QAction) |
|
231 def __setFormat(self, act): |
|
232 """ |
|
233 Private slot to set the selected document format. |
|
234 |
|
235 @param act reference to the selected action |
|
236 @type QAction |
|
237 """ |
|
238 chunkers = act.data() |
|
239 self.__highlighter.setChunkers(chunkers) |
|
240 |
|
241 def setFormat(self, formatName): |
|
242 """ |
|
243 Public method to set the document format. |
|
244 |
|
245 @param formatName name of the document format |
|
246 @type str |
|
247 """ |
|
248 self.__highlighter.setChunkers( |
|
249 [tokenize.HTMLChunker] |
|
250 if format == "html" else |
|
251 [] |
|
252 ) |
|
253 |
|
254 def dict(self): |
|
255 """ |
|
256 Public method to get a reference to the dictionary in use. |
|
257 |
|
258 @return reference to the current dictionary |
|
259 @rtype enchant.Dict |
|
260 """ |
|
261 return self.__highlighter.dict() |
|
262 |
|
263 def setDict(self, spellDict): |
|
264 """ |
|
265 Public method to set the dictionary to be used. |
|
266 |
|
267 @param spellDict reference to the spell-check dictionary |
|
268 @type emchant.Dict |
|
269 """ |
|
270 self.__highlighter.setDict(spellDict) |
|
271 |
|
272 |
|
273 class EnchantHighlighter(QSyntaxHighlighter): |
|
274 """ |
|
275 Class implementing a QSyntaxHighlighter subclass that consults a |
|
276 pyEnchant dictionary to highlight misspelled words. |
|
277 """ |
|
278 TokenFilters = (tokenize.EmailFilter, tokenize.URLFilter) |
|
279 |
|
280 # Define the spell-check style once and just assign it as necessary |
|
281 ErrorFormat = QTextCharFormat() |
|
282 ErrorFormat.setUnderlineColor(Qt.GlobalColor.red) |
|
283 ErrorFormat.setUnderlineStyle( |
|
284 QTextCharFormat.UnderlineStyle.SpellCheckUnderline) |
|
285 |
|
286 def __init__(self, *args): |
|
287 """ |
|
288 Constructor |
|
289 |
|
290 @param *args list of arguments for the QSyntaxHighlighter |
|
291 @type list |
|
292 """ |
|
293 QSyntaxHighlighter.__init__(self, *args) |
|
294 |
|
295 self.__spellDict = None |
|
296 self.__chunkers = [] |
|
297 |
|
298 def chunkers(self): |
|
299 """ |
|
300 Public method to get the chunkers in use. |
|
301 |
|
302 @return list of chunkers in use |
|
303 @rtype list |
|
304 """ |
|
305 return self.__chunkers |
|
306 |
|
307 def setChunkers(self, chunkers): |
|
308 """ |
|
309 Public method to set the chunkers to be used. |
|
310 |
|
311 @param chunkers chunkers to be used |
|
312 @type list |
|
313 """ |
|
314 self.__chunkers = chunkers |
|
315 self.setDict(self.dict()) |
|
316 |
|
317 def dict(self): |
|
318 """ |
|
319 Public method to get the spelling dictionary in use. |
|
320 |
|
321 @return spelling dictionary |
|
322 @rtype enchant.Dict |
|
323 """ |
|
324 return self.__spellDict |
|
325 |
|
326 def setDict(self, spellDict): |
|
327 """ |
|
328 Public method to set the spelling dictionary to be used. |
|
329 |
|
330 @param spellDict spelling dictionary |
|
331 @type enchant.Dict |
|
332 """ |
|
333 try: |
|
334 self.__tokenizer = tokenize.get_tokenizer( |
|
335 spellDict.tag, |
|
336 chunkers=self.__chunkers, |
|
337 filters=EnchantHighlighter.TokenFilters) |
|
338 except TokenizerNotFoundError: |
|
339 # Fall back to the "good for most euro languages" English tokenizer |
|
340 self.__tokenizer = tokenize.get_tokenizer( |
|
341 chunkers=self.__chunkers, |
|
342 filters=EnchantHighlighter.TokenFilters) |
|
343 self.__spellDict = spellDict |
|
344 |
|
345 self.rehighlight() |
|
346 |
|
347 def highlightBlock(self, text): |
|
348 """ |
|
349 Public method to apply the text highlight. |
|
350 |
|
351 @param text text to be spell-checked |
|
352 @type str |
|
353 """ |
|
354 """Overridden QSyntaxHighlighter method to apply the highlight""" |
|
355 if not self.__spellDict: |
|
356 return |
|
357 |
|
358 # Build a list of all misspelled words and highlight them |
|
359 misspellings = [] |
|
360 for (word, pos) in self.__tokenizer(text): |
|
361 if not self.__spellDict.check(word): |
|
362 self.setFormat(pos, len(word), EnchantHighlighter.ErrorFormat) |
|
363 misspellings.append((pos, pos + len(word))) |
|
364 |
|
365 # Store the list so the context menu can reuse this tokenization pass |
|
366 # (Block-relative values so editing other blocks won't invalidate them) |
|
367 data = QTextBlockUserData() |
|
368 data.misspelled = misspellings |
|
369 self.setCurrentBlockUserData(data) |
|
370 |
|
371 |
|
372 class EricSpellCheckedPlainTextEdit(QPlainTextEdit, SpellCheckMixin): |
|
373 """ |
|
374 Class implementing a QPlainTextEdit with built-in spell checker. |
|
375 """ |
|
376 def __init__(self, *args): |
|
377 """ |
|
378 Constructor |
|
379 |
|
380 @param *args list of arguments for the QPlainTextEdit constructor. |
|
381 @type list |
|
382 """ |
|
383 QPlainTextEdit.__init__(self, *args) |
|
384 SpellCheckMixin.__init__(self) |
|
385 |
|
386 |
|
387 class EricSpellCheckedTextEdit(QTextEdit, SpellCheckMixin): |
|
388 """ |
|
389 Class implementing a QTextEdit with built-in spell checker. |
|
390 """ |
|
391 def __init__(self, *args): |
|
392 """ |
|
393 Constructor |
|
394 |
|
395 @param *args list of arguments for the QPlainTextEdit constructor. |
|
396 @type list |
|
397 """ |
|
398 QTextEdit.__init__(self, *args) |
|
399 SpellCheckMixin.__init__(self) |
|
400 |
|
401 self.setFormat("html") |
|
402 |
|
403 def setAcceptRichText(self, accept): |
|
404 """ |
|
405 Public method to set the text edit mode. |
|
406 |
|
407 @param accept flag indicating to accept rich text |
|
408 @type bool |
|
409 """ |
|
410 QTextEdit.setAcceptRichText(accept) |
|
411 self.setFormat("html" if accept else "text") |
|
412 |
|
413 if __name__ == '__main__': |
|
414 from PyQt6.QtWidgets import QApplication |
|
415 |
|
416 app = QApplication(sys.argv) |
|
417 spellEdit = EricSpellCheckedPlainTextEdit() |
|
418 spellEdit.show() |
|
419 |
|
420 sys.exit(app.exec()) |