--- a/eric7/EricWidgets/EricSpellCheckedTextEdit.py Tue Jun 15 19:42:36 2021 +0200 +++ b/eric7/EricWidgets/EricSpellCheckedTextEdit.py Wed Jun 16 17:47:50 2021 +0200 @@ -8,12 +8,16 @@ checking. """ -import sys +import contextlib -import enchant -from enchant import tokenize -from enchant.errors import TokenizerNotFoundError, DictNotFoundError -from enchant.utils import trim_suggestions +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 ( @@ -24,395 +28,429 @@ # TODO: add user dictionaries with respective menu entries - -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 - - def __init__(self): +if ENCHANT_AVAILABLE: + class SpellCheckMixin(): """ - Constructor + Class implementing the spell-check mixin for the widget classes. """ - self.__highlighter = EnchantHighlighter(self.document()) - try: - # Start with a default dictionary based on the current locale. - spellDict = enchant.Dict() - except DictNotFoundError: - # Use English dictionary if no locale dictionary is available. - 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. + # 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 - @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. + 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()) - @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) + 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 - 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: + 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 - 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 - languageMenu = QMenu( - QCoreApplication.translate("SpellCheckMixin", "Language"), - parent) - languageActions = QActionGroup(languageMenu) + 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 - for language in enchant.list_languages(): - act = QAction(language, languageActions) - act.setCheckable(True) - act.setChecked(language == 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"), - [tokenize.HTMLChunker]) - ): - act = QAction(name, formatActions) - act.setCheckable(True) - act.setChecked(chunkers == curFormat) - act.setData(chunkers) - formatMenu.addAction(act) + 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 - 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", []) + 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 - # 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 + @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() - 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() + @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)) - cursor.beginEditBlock() - cursor.removeSelectedText() - cursor.insertText(word) - cursor.endEditBlock() - - @pyqtSlot(QAction) - def __setLanguage(self, act): - """ - Private slot to set the selected 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) - @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. + 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 + [] + ) - @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. + 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() - @param formatName name of the document format - @type str - """ - self.__highlighter.setChunkers( - [tokenize.HTMLChunker] - if format == "html" else - [] - ) - - def dict(self): + 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): """ - 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) - - -class EnchantHighlighter(QSyntaxHighlighter): - """ - Class implementing a QSyntaxHighlighter subclass that consults a - pyEnchant dictionary to highlight misspelled words. - """ - TokenFilters = (tokenize.EmailFilter, 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 + Class implementing a QSyntaxHighlighter subclass that consults a + pyEnchant dictionary to highlight misspelled words. """ - try: - self.__tokenizer = 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 = 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) - + TokenFilters = (enchant.tokenize.EmailFilter, + enchant.tokenize.URLFilter) -class EricSpellCheckedPlainTextEdit(QPlainTextEdit, SpellCheckMixin): - """ - Class implementing a QPlainTextEdit with built-in spell checker. - """ - def __init__(self, *args): - """ - Constructor + # 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 - @param *args list of arguments for the QPlainTextEdit constructor. - @type list - """ - QPlainTextEdit.__init__(self, *args) - SpellCheckMixin.__init__(self) - + 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 EricSpellCheckedTextEdit(QTextEdit, SpellCheckMixin): - """ - Class implementing a QTextEdit with built-in spell checker. - """ - def __init__(self, *args): + class EricSpellCheckedPlainTextEdit(QPlainTextEdit, SpellCheckMixin): + """ + Class implementing a QPlainTextEdit with built-in spell checker. """ - Constructor - - @param *args list of arguments for the QPlainTextEdit constructor. - @type list + 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. """ - QTextEdit.__init__(self, *args) - SpellCheckMixin.__init__(self) + 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") - 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(accept) - self.setFormat("html" if accept else "text") + 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()