Wed, 16 Jun 2021 17:47:50 +0200
Changed some dialogs to use the new QTextEdit and QPlainTextEdit subclasses with spell checker.
# -*- coding: utf-8 -*- # Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing QTextEdit and QPlainTextEdit widgets with embedded spell checking. """ import contextlib try: import enchant import enchant.tokenize from enchant.errors import TokenizerNotFoundError, DictNotFoundError from enchant.utils import trim_suggestions ENCHANT_AVAILABLE = True except ImportError: ENCHANT_AVAILABLE = False from PyQt6.QtCore import pyqtSlot, Qt, QCoreApplication from PyQt6.QtGui import ( QAction, QActionGroup, QSyntaxHighlighter, QTextBlockUserData, QTextCharFormat, QTextCursor ) from PyQt6.QtWidgets import QMenu, QTextEdit, QPlainTextEdit # TODO: add user dictionaries with respective menu entries if ENCHANT_AVAILABLE: class SpellCheckMixin(): """ Class implementing the spell-check mixin for the widget classes. """ # don't show more than this to keep the menu manageable MaxSuggestions = 20 # default language to be used when no other is set DefaultLanguage = None def __init__(self): """ Constructor """ self.__highlighter = EnchantHighlighter(self.document()) try: # Start with a default dictionary based on the current locale. spellDict = ( enchant.Dict(SpellCheckMixin.DefaultLanguage) if bool(SpellCheckMixin.DefaultLanguage) else enchant.Dict() ) except DictNotFoundError: # Use English dictionary if no locale dictionary is available # or the default one could not be found. spellDict = enchant.Dict("en") self.__highlighter.setDict(spellDict) def contextMenuEvent(self, evt): """ Protected method to handle context menu events to add a spelling suggestions submenu. @param evt reference to the context menu event @type QContextMenuEvent """ menu = self.__createSpellcheckContextMenu(evt.pos()) menu.exec(evt.globalPos()) def __createSpellcheckContextMenu(self, pos): """ Private method to create the spell-check context menu. @param pos position of the mouse pointer @type QPoint @return context menu with additional spell-check entries @rtype QMenu """ menu = self.createStandardContextMenu(pos) # Add a submenu for setting the spell-check language and # document format. menu.addSeparator() menu.addMenu(self.__createLanguagesMenu(menu)) menu.addMenu(self.__createFormatsMenu(menu)) # Try to retrieve a menu of corrections for the right-clicked word spellMenu = self.__createCorrectionsMenu( self.__cursorForMisspelling(pos), menu) if spellMenu: menu.insertSeparator(menu.actions()[0]) menu.insertMenu(menu.actions()[0], spellMenu) return menu def __createCorrectionsMenu(self, cursor, parent=None): """ Private method to create a menu for corrections of the selected word. @param cursor reference to the text cursor @type QTextCursor @param parent reference to the parent widget (defaults to None) @type QWidget (optional) @return menu with corrections @rtype QMenu """ if cursor is None: return None text = cursor.selectedText() suggestions = trim_suggestions( text, self.__highlighter.dict().suggest(text), SpellCheckMixin.MaxSuggestions) spellMenu = QMenu( QCoreApplication.translate("SpellCheckMixin", "Spelling Suggestions"), parent) for word in suggestions: act = spellMenu.addAction(word) act.setData((cursor, word)) # Only return the menu if it's non-empty if spellMenu.actions(): spellMenu.triggered.connect(self.__correctWord) return spellMenu return None def __createLanguagesMenu(self, parent=None): """ Private method to create a menu for selecting the spell-check language. @param parent reference to the parent widget (defaults to None) @type QWidget (optional) @return menu with spell-check languages @rtype QMenu """ curLanguage = self.__highlighter.dict().tag.lower() languageMenu = QMenu( QCoreApplication.translate("SpellCheckMixin", "Language"), parent) languageActions = QActionGroup(languageMenu) for language in sorted(enchant.list_languages()): act = QAction(language, languageActions) act.setCheckable(True) act.setChecked(language.lower() == curLanguage) act.setData(language) languageMenu.addAction(act) languageMenu.triggered.connect(self.__setLanguage) return languageMenu def __createFormatsMenu(self, parent=None): """ Private method to create a menu for selecting the document format. @param parent reference to the parent widget (defaults to None) @type QWidget (optional) @return menu with document formats @rtype QMenu """ formatMenu = QMenu( QCoreApplication.translate("SpellCheckMixin", "Format"), parent) formatActions = QActionGroup(formatMenu) curFormat = self.__highlighter.chunkers() for name, chunkers in ( (QCoreApplication.translate("SpellCheckMixin", "Text"), []), (QCoreApplication.translate("SpellCheckMixin", "HTML"), [enchant.tokenize.HTMLChunker]) ): act = QAction(name, formatActions) act.setCheckable(True) act.setChecked(chunkers == curFormat) act.setData(chunkers) formatMenu.addAction(act) formatMenu.triggered.connect(self.__setFormat) return formatMenu def __cursorForMisspelling(self, pos): """ Private method to create a text cursor selecting the misspelled word. @param pos position of the misspelled word @type QPoint @return text cursor for the misspelled word @rtype QTextCursor """ cursor = self.cursorForPosition(pos) misspelledWords = getattr(cursor.block().userData(), "misspelled", []) # If the cursor is within a misspelling, select the word for (start, end) in misspelledWords: if start <= cursor.positionInBlock() <= end: blockPosition = cursor.block().position() cursor.setPosition(blockPosition + start, QTextCursor.MoveMode.MoveAnchor) cursor.setPosition(blockPosition + end, QTextCursor.MoveMode.KeepAnchor) break if cursor.hasSelection(): return cursor else: return None @pyqtSlot(QAction) def __correctWord(self, act): """ Private slot to correct the misspelled word with the selected correction. @param act reference to the selected action @type QAction """ cursor, word = act.data() cursor.beginEditBlock() cursor.removeSelectedText() cursor.insertText(word) cursor.endEditBlock() @pyqtSlot(QAction) def __setLanguage(self, act): """ Private slot to set the selected language. @param act reference to the selected action @type QAction """ language = act.data() self.__highlighter.setDict(enchant.Dict(language)) @pyqtSlot(QAction) def __setFormat(self, act): """ Private slot to set the selected document format. @param act reference to the selected action @type QAction """ chunkers = act.data() self.__highlighter.setChunkers(chunkers) def setFormat(self, formatName): """ Public method to set the document format. @param formatName name of the document format @type str """ self.__highlighter.setChunkers( [enchant.tokenize.HTMLChunker] if format == "html" else [] ) def dict(self): """ Public method to get a reference to the dictionary in use. @return reference to the current dictionary @rtype enchant.Dict """ return self.__highlighter.dict() def setDict(self, spellDict): """ Public method to set the dictionary to be used. @param spellDict reference to the spell-check dictionary @type emchant.Dict """ self.__highlighter.setDict(spellDict) @classmethod def setDefaultLanguage(cls, language): """ Class method to set the default spell-check language. @param language language to be set as default @type str """ with contextlib.suppress(DictNotFoundError): # set default language only, if a dictionary is available enchant.Dict(language) cls.DefaultLanguage = language class EnchantHighlighter(QSyntaxHighlighter): """ Class implementing a QSyntaxHighlighter subclass that consults a pyEnchant dictionary to highlight misspelled words. """ TokenFilters = (enchant.tokenize.EmailFilter, enchant.tokenize.URLFilter) # Define the spell-check style once and just assign it as necessary ErrorFormat = QTextCharFormat() ErrorFormat.setUnderlineColor(Qt.GlobalColor.red) ErrorFormat.setUnderlineStyle( QTextCharFormat.UnderlineStyle.SpellCheckUnderline) def __init__(self, *args): """ Constructor @param *args list of arguments for the QSyntaxHighlighter @type list """ QSyntaxHighlighter.__init__(self, *args) self.__spellDict = None self.__chunkers = [] def chunkers(self): """ Public method to get the chunkers in use. @return list of chunkers in use @rtype list """ return self.__chunkers def setChunkers(self, chunkers): """ Public method to set the chunkers to be used. @param chunkers chunkers to be used @type list """ self.__chunkers = chunkers self.setDict(self.dict()) def dict(self): """ Public method to get the spelling dictionary in use. @return spelling dictionary @rtype enchant.Dict """ return self.__spellDict def setDict(self, spellDict): """ Public method to set the spelling dictionary to be used. @param spellDict spelling dictionary @type enchant.Dict """ try: self.__tokenizer = enchant.tokenize.get_tokenizer( spellDict.tag, chunkers=self.__chunkers, filters=EnchantHighlighter.TokenFilters) except TokenizerNotFoundError: # Fall back to the "good for most euro languages" # English tokenizer self.__tokenizer = enchant.tokenize.get_tokenizer( chunkers=self.__chunkers, filters=EnchantHighlighter.TokenFilters) self.__spellDict = spellDict self.rehighlight() def highlightBlock(self, text): """ Public method to apply the text highlight. @param text text to be spell-checked @type str """ """Overridden QSyntaxHighlighter method to apply the highlight""" if not self.__spellDict: return # Build a list of all misspelled words and highlight them misspellings = [] for (word, pos) in self.__tokenizer(text): if not self.__spellDict.check(word): self.setFormat(pos, len(word), EnchantHighlighter.ErrorFormat) misspellings.append((pos, pos + len(word))) # Store the list so the context menu can reuse this tokenization # pass (Block-relative values so editing other blocks won't # invalidate them) data = QTextBlockUserData() data.misspelled = misspellings self.setCurrentBlockUserData(data) class EricSpellCheckedPlainTextEdit(QPlainTextEdit, SpellCheckMixin): """ Class implementing a QPlainTextEdit with built-in spell checker. """ def __init__(self, *args): """ Constructor @param *args list of arguments for the QPlainTextEdit constructor. @type list """ QPlainTextEdit.__init__(self, *args) SpellCheckMixin.__init__(self) class EricSpellCheckedTextEdit(QTextEdit, SpellCheckMixin): """ Class implementing a QTextEdit with built-in spell checker. """ def __init__(self, *args): """ Constructor @param *args list of arguments for the QPlainTextEdit constructor. @type list """ QTextEdit.__init__(self, *args) SpellCheckMixin.__init__(self) self.setFormat("html") def setAcceptRichText(self, accept): """ Public method to set the text edit mode. @param accept flag indicating to accept rich text @type bool """ QTextEdit.setAcceptRichText(self, accept) self.setFormat("html" if accept else "text") else: # enchant not available EricSpellCheckedPlainTextEdit = QPlainTextEdit EricSpellCheckedTextEdit = QTextEdit if __name__ == '__main__': import sys from PyQt6.QtWidgets import QApplication if ENCHANT_AVAILABLE: SpellCheckMixin.setDefaultLanguage("en_US") app = QApplication(sys.argv) spellEdit = EricSpellCheckedPlainTextEdit() spellEdit.show() sys.exit(app.exec())