diff -r 63cc81d35ddc -r 5ccf32f95805 eric7/EricWidgets/EricSpellCheckedTextEdit.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric7/EricWidgets/EricSpellCheckedTextEdit.py Tue Jun 15 19:42:36 2021 +0200 @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing QTextEdit and QPlainTextEdit widgets with embedded spell +checking. +""" + +import sys + +import enchant +from enchant import tokenize +from enchant.errors import TokenizerNotFoundError, DictNotFoundError +from enchant.utils import trim_suggestions + +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 + + +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): + """ + Constructor + """ + 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. + + @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 + languageMenu = QMenu( + QCoreApplication.translate("SpellCheckMixin", "Language"), + parent) + languageActions = QActionGroup(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) + + 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( + [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) + + +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 + """ + 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) + + +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(accept) + self.setFormat("html" if accept else "text") + +if __name__ == '__main__': + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv) + spellEdit = EricSpellCheckedPlainTextEdit() + spellEdit.show() + + sys.exit(app.exec())