Integrating spellchecking into a PyQt5 QTextEdit widget with enchant
Introduction
I was working on a project using PyQt5 when I found myself in need of spellchecking in a QTextEdit widget. I tried to find a reasonable implementation of it, but I didn’t find one. Maybe it exists somewhere out there, but, it was an interesting little side project, so I decided to try making it myself. If you just want the code, you can get it from the GitHub repository.
If you are interested in an explanation of the code, read on.
First, a little introduction to what will be used for this.
PyQt5
This is a set of Python bindings for the cross-platform Qt framework. There is another set of bindings called PySide2. I won’t go into the differences between them here, but all the code here should work fine with PySide2 as well, with only the relevant import statements needing to be changed.
You can install it using pip install PyQt5
.
enchant
This is a spellchecking library written in C and C++. There are other spellchecking libraries of course, but I chose this as it seemed to work better than the others I tried. If you want to use a different library for the spellchecking, you can do that by simple replacing the implementation of the wrapper in the next section.
You can install the library from the GitHub repository, and you can install the Python bindings using pip install pyenchant
.
Wrapping enchant
While this part isn’t necessary, I initially did this in case I needed to change the library doing the spellchecking without needing to change the rest of the code. This wrapper will provide an “interface” to get a list of suggestions given a word, add a word to the personal word list, and to check a particular word’s spelling.
The code itself should be pretty self-explanatory, but I have added some comments as additional explanation.
from typing import Callable
from enchant import DictWithPWL
from PyQt5.QtCore import QTemporaryFile
class SpellCheckWrapper:
def __init__(
self, personal_word_list: list[str], addToDictionary: Callable[[str], None]
):
# Here, we take a function: addToDictionary(str)
# That's what we call when adding a new word to the personal word list
# The reason we take this from outside is that this way, when using this class,
# we can store the permanent personal word list however we like, and this class doesn't need to care
# Creating temporary file for enchant to store the personal word list temporarily
self.file = QTemporaryFile()
self.file.open()
self.dictionary = DictWithPWL(
"en_US",
self.file.fileName(),
)
self.addToDictionary = addToDictionary
self.word_list = set(personal_word_list)
self.load_words()
def load_words(self):
for word in self.word_list:
self.dictionary.add(word)
def suggestions(self, word: str) -> list[str]:
return self.dictionary.suggest(word)
def correction(self, word: str) -> str:
# Get the best match
return self.dictionary.suggest(word)[0]
def add(self, new_word: str) -> bool:
if self.check(new_word):
return False
self.word_list.add(new_word)
self.addToDictionary(new_word)
self.dictionary.add(new_word)
return True
def check(self, word: str) -> bool:
return self.dictionary.check(word)
def getNewWords(self) -> set[str]:
return self.word_list
Custom QSyntaxHighlighter
Qt conveniently has a QSyntaxHighlighter
(docs) class which we can use to show any words that are misspelled. Here, we will subclass it to use a SpellCheckWrapper
instance to check the spellings of all the words, and show the usual red line under any misspelled words.
import re
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QSyntaxHighlighter, QTextCharFormat
from spellcheckwrapper import SpellCheckWrapper
class SpellCheckHighlighter(QSyntaxHighlighter):
# Matches strings of length 2 or more
wordRegEx = re.compile(r"\b([A-Za-z]{2,})\b")
def highlightBlock(self, text: str) -> None:
if not hasattr(self, "speller"):
return
# Formatting for misspelled words
self.misspelledFormat = QTextCharFormat()
self.misspelledFormat.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) # Platform and theme dependent
self.misspelledFormat.setUnderlineColor(Qt.red)
for word_object in self.wordRegEx.finditer(text):
if not self.speller.check(word_object.group()):
self.setFormat(
word_object.start(),
word_object.end() - word_object.start(),
self.misspelledFormat,
)
def setSpeller(self, speller: SpellCheckWrapper):
self.speller = speller
Correction action
Now we need to create a simple QAction
(docs) that will fire a custom signal when clicked, and pass its text as an argument. We will be using this to create the list of suggested words in the context menu.
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QAction
class SpecialAction(QAction):
actionTriggered = pyqtSignal(str)
def __init__(self, *args):
super().__init__(*args)
self.triggered.connect(self.emitTriggered)
def emitTriggered(self):
self.actionTriggered.emit(self.text())
Subclassing QTextEdit
Now we come to the main part of this article. I will break down this class into a few sections. The first one is for the imports and the constructor. Each one after that will be for the other methods in the class.
Imports and constructor
This part should be self-explanatory.
from PyQt5.QtCore import QEvent, Qt, pyqtSlot
from PyQt5.QtGui import QContextMenuEvent, QMouseEvent, QTextCursor
from PyQt5.QtWidgets import QMenu, QTextEdit
# Importing the classes we wrote in the previous sections
from correction_action import SpecialAction
from highlighter import SpellCheckHighlighter
from spellcheckwrapper import SpellCheckWrapper
class SpellTextEdit(QTextEdit):
def __init__(self, *args):
if args and type(args[0]) == SpellCheckWrapper:
super().__init__(*args[1:])
self.speller = args[0]
else:
super().__init__(*args)
self.highlighter = SpellCheckHighlighter(self.document())
if hasattr(self, "speller"):
self.highlighter.setSpeller(self.speller)
Set speller
def setSpeller(self, speller):
self.speller = speller
self.highlighter.setSpeller(self.speller)
Mouse press event
This is a little hack to make it so that right-clicking will move the text cursor to the mouse position. If the mouse press event is a right click, then we change that into a left click.
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.RightButton:
event = QMouseEvent(
QEvent.MouseButtonPress,
event.pos(),
Qt.LeftButton,
Qt.LeftButton,
Qt.NoModifier,
)
super().mousePressEvent(event)
Context menu event
Here, we need to build the context menu. First, we can use the built-in createStandardContextMenu
method to make the basics. Then, we add on the list of suggestions, and a button to add to dictionary.
createSuggestionsMenu
is explained in the next section.
def contextMenuEvent(self, event: QContextMenuEvent) -> None:
self.contextMenu = self.createStandardContextMenu(event.pos())
# Select and retrieve the word under the cursor
textCursor = self.textCursor()
textCursor.select(QTextCursor.WordUnderCursor)
self.setTextCursor(textCursor)
wordToCheck = textCursor.selectedText()
if wordToCheck != "":
suggestions = self.speller.suggestions(wordToCheck)
if len(suggestions) > 0:
self.contextMenu.addSeparator()
self.contextMenu.addMenu(self.createSuggestionsMenu(suggestions))
if not self.speller.check(wordToCheck):
# This action will add the selected word to the personal word list
addToDictionary_action = SpecialAction(
"Add to dictionary", self.contextMenu
)
addToDictionary_action.triggered.connect(self.addToDictionary)
self.contextMenu.addAction(addToDictionary_action)
self.contextMenu.exec_(event.globalPos())
Create suggestions menu
We create the suggestions menu from the given list of suggestions. Here, we use that SpecialAction
from before. When one is clicked, the correctWord
method is called.
def createSuggestionsMenu(self, suggestions: list[str]):
suggestionsMenu = QMenu("Change to", self)
for word in suggestions:
action = SpecialAction(word, self.contextMenu)
action.actionTriggered.connect(self.correctWord)
suggestionsMenu.addAction(action)
return suggestionsMenu
Replace the selected word with the given correction
@pyqtSlot(str)
def correctWord(self, word: str):
textCursor = self.textCursor()
textCursor.beginEditBlock()
textCursor.removeSelectedText()
textCursor.insertText(word)
textCursor.endEditBlock()
Add to dictionary
This adds the selected word to the dictionary.
@pyqtSlot()
def addToDictionary(self):
textCursor = self.textCursor()
new_word = textCursor.selectedText()
self.speller.add(new_word)
self.highlighter.rehighlight()
Conclusion
There you have it. If you want to try it out, I have included a small example application in the GitHub repository.
There are of course some improvements to be made. For example, with the current implementation, if a user selects some text and right-clicks, the selection will change to the word under the pointer. As I wrote this code for use in another project of mine, this basic functionality was enough.