eric7/EricWidgets/EricSpellCheckedTextEdit.py

branch
eric7
changeset 8427
5ccf32f95805
child 8428
2deec2f8a9ab
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())

eric ide

mercurial