eric7/EricWidgets/EricSpellCheckedTextEdit.py

Tue, 15 Jun 2021 19:42:36 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Tue, 15 Jun 2021 19:42:36 +0200
branch
eric7
changeset 8427
5ccf32f95805
child 8428
2deec2f8a9ab
permissions
-rw-r--r--

EricWidgets: added a QTextEdit and QPlainTextEdit subclass with built-in spell-checker.

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