eric7/EricWidgets/EricSpellCheckedTextEdit.py

Sat, 19 Jun 2021 12:17:17 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Sat, 19 Jun 2021 12:17:17 +0200
branch
eric7
changeset 8432
074407b4c107
parent 8431
bed315a45088
child 8629
1b58dc890b87
permissions
-rw-r--r--

Extended the spellcheck text edits to add and remove words from user specific dictionaries.

# -*- 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

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:
                # 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
                )
            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.__highlighter.setDict(enchant.Dict(
                language,
                SpellCheckMixin.DefaultUserWordList,
                SpellCheckMixin.DefaultUserExceptionList
            ))
        
        @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, 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.__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
    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())

eric ide

mercurial