--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/EricWidgets/EricSpellCheckedTextEdit.py Thu Jul 07 11:23:56 2022 +0200 @@ -0,0 +1,694 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021 - 2022 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 + +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 + + # default user lists + DefaultUserWordList = None + DefaultUserExceptionList = None + + def __init__(self): + """ + Constructor + """ + self.__highlighter = EnchantHighlighter(self.document()) + try: + # Start with a default dictionary based on the current locale + # or the configured default language. + spellDict = enchant.DictWithPWL( + SpellCheckMixin.DefaultLanguage, + SpellCheckMixin.DefaultUserWordList, + SpellCheckMixin.DefaultUserExceptionList + ) + except DictNotFoundError: + try: + # Use English dictionary if no locale dictionary is + # available or the default one could not be found. + spellDict = enchant.DictWithPWL( + "en", + SpellCheckMixin.DefaultUserWordList, + SpellCheckMixin.DefaultUserExceptionList + ) + except DictNotFoundError: + # Still no dictionary could be found. Forget about spell + # checking. + spellDict = None + + 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() + self.__addRemoveEntry(self.__cursorForPosition(pos), menu) + 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)) + + if suggestions: + spellMenu.addSeparator() + + # add management entry + act = spellMenu.addAction(QCoreApplication.translate( + "SpellCheckMixin", "Add to Dictionary")) + act.setData((cursor, text, "add")) + + spellMenu.triggered.connect(self.__spellMenuTriggered) + return spellMenu + + def __addRemoveEntry(self, cursor, menu): + """ + Private method to create a menu entry to remove the word at the + menu position. + + @param cursor reference to the text cursor for the misspelled word + @type QTextCursor + @param menu reference to the context menu + @type QMenu + """ + if cursor is None: + return + + text = cursor.selectedText() + menu.addAction(QCoreApplication.translate( + "SpellCheckMixin", + "Remove '{0}' from Dictionary").format(text), + lambda: self.__addToUserDict(text, "remove")) + + 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 __cursorForPosition(self, pos): + """ + Private method to create a text cursor selecting the word at the + given position. + + @param pos position of the misspelled word + @type QPoint + @return text cursor for the word + @rtype QTextCursor + """ + cursor = self.cursorForPosition(pos) + cursor.select(QTextCursor.SelectionType.WordUnderCursor) + + if cursor.hasSelection(): + return cursor + else: + return None + + 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 + + def __correctWord(self, cursor, word): + """ + Private method to replace some misspelled text. + + @param cursor reference to the text cursor for the misspelled word + @type QTextCursor + @param word replacement text + @type str + """ + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.insertText(word) + cursor.endEditBlock() + + def __addToUserDict(self, word, command): + """ + Private method to add a word to the user word or exclude list. + + @param word text to be added + @type str + @param command command indicating the user dictionary type + @type str + """ + if word: + dictionary = self.__highlighter.dict() + if command == "add": + dictionary.add(word) + elif command == "remove": + dictionary.remove(word) + + self.__highlighter.rehighlight() + + @pyqtSlot(QAction) + def __spellMenuTriggered(self, act): + """ + Private slot to handle a selection of the spell menu. + + @param act reference to the selected action + @type QAction + """ + data = act.data() + if len(data) == 2: + # replace the misspelled word + self.__correctWord(*data) + + elif len(data) == 3: + # dictionary management action + _, word, command = data + self.__addToUserDict(word, command) + + @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.setLanguage(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) + + @pyqtSlot(str) + def setLanguage(self, language): + """ + Public slot to set the spellchecker language. + + @param language language to be set + @type str + """ + epwl = self.dict().pwl + pwl = ( + epwl.provider.file + if isinstance(epwl, enchant.Dict) else + None + ) + + epel = self.dict().pel + pel = ( + epel.provider.file + if isinstance(epel, enchant.Dict) else + None + ) + self.setLanguageWithPWL(language, pwl, pel) + + @pyqtSlot(str, str, str) + def setLanguageWithPWL(self, language, pwl, pel): + """ + Public slot to set the spellchecker language and associated user + word lists. + + @param language language to be set + @type str + @param pwl file name of the personal word list + @type str + @param pel file name of the personal exclude list + @type str + """ + try: + spellDict = enchant.DictWithPWL(language, pwl, pel) + except DictNotFoundError: + try: + # Use English dictionary if a dictionary for the given + # language is not available. + spellDict = enchant.DictWithPWL("en", pwl, pel) + except DictNotFoundError: + # Still no dictionary could be found. Forget about spell + # checking. + spellDict = None + self.__highlighter.setDict(spellDict) + + @classmethod + def setDefaultLanguage(cls, language, pwl=None, pel=None): + """ + Class method to set the default spell-check language. + + @param language language to be set as default + @type str + @param pwl file name of the personal word list + @type str + @param pel file name of the personal exclude list + @type str + """ + with contextlib.suppress(DictNotFoundError): + cls.DefaultUserWordList = pwl + cls.DefaultUserExceptionList = pel + + # 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.__tokenizer = 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 + """ + if spellDict: + 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) + else: + self.__tokenizer = None + + 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 self.__spellDict is None or self.__tokenizer is None: + 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) + +else: + + class SpellCheckMixin(): + """ + Class implementing the spell-check mixin for the widget classes. + """ + # + # This is just a stub to provide the same API as the enchant enabled + # one. + # + def __init__(self): + """ + Constructor + """ + pass + + def setFormat(self, formatName): + """ + Public method to set the document format. + + @param formatName name of the document format + @type str + """ + pass + + def dict(self): + """ + Public method to get a reference to the dictionary in use. + + @return reference to the current dictionary + @rtype enchant.Dict + """ + pass + + def setDict(self, spellDict): + """ + Public method to set the dictionary to be used. + + @param spellDict reference to the spell-check dictionary + @type emchant.Dict + """ + pass + + @pyqtSlot(str) + def setLanguage(self, language): + """ + Public slot to set the spellchecker language. + + @param language language to be set + @type str + """ + pass + + @pyqtSlot(str, str, str) + def setLanguageWithPWL(self, language, pwl, pel): + """ + Public slot to set the spellchecker language and associated user + word lists. + + @param language language to be set + @type str + @param pwl file name of the personal word list + @type str + @param pel file name of the personal exclude list + @type str + """ + pass + + @classmethod + def setDefaultLanguage(cls, language, pwl=None, pel=None): + """ + Class method to set the default spell-check language. + + @param language language to be set as default + @type str + @param pwl file name of the personal word list + @type str + @param pel file name of the personal exclude list + @type str + """ + pass + + +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") + +if __name__ == '__main__': + import sys + import os + from PyQt6.QtWidgets import QApplication + + if ENCHANT_AVAILABLE: + dictPath = os.path.expanduser(os.path.join("~", ".eric7", "spelling")) + SpellCheckMixin.setDefaultLanguage( + "en_US", + os.path.join(dictPath, "pwl.dic"), + os.path.join(dictPath, "pel.dic") + ) + + app = QApplication(sys.argv) + spellEdit = EricSpellCheckedPlainTextEdit() + spellEdit.show() + + sys.exit(app.exec())