A QLineEdit Replacement with Spell-Checking

TL;DR: Here’s the code

Today, I recevied a question which prompted me to revisit my old An Improved QPlainTextEdit for PyQt5 post and, along the way, I got a little nerd-sniped on the idea of adding spell-checking to QLineEdit too. (After all, I will need that for my own use sooner or later.)

Long story short, since QLineEdit doesn’t support syntax highlighting, you either have to re-implement the paint event yourself (which is a little too low-level for me to want to be responsible for shaking the bugs out of it) or you can just reconfigure QPlainTextEdit to look and act like QLineEdit.

It’s not perfect, since it can’t be used in situations like setting a custom QLineEdit subclass for a QComboBox… but since I already have a QPlainTextEdit that does spell-checking…

The process basically breaks down into two roughly equal parts: Working around the warts in how the QWidget APIs expect you to customize your widget sizing heuristics and everything else.

Start by turning off all word-wrapping and forcing the scrollbars to be hidden:

self.setLineWrapMode(QPlainTextEdit.NoWrap)
self.setWordWrapMode(QTextOption.NoWrap)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)

Then, actually force the contents to stay on a single line. You’d think we could just use self.document().setMaximumBlockCount(1), but that disables Undo/Redo, makes Ctrl+V paste the last line when I think it’s more natural to preserve the first, and requires you to override keyPressEvent to prevent Enter/Key_Enter from erasing the contents of the field, so we have to go in manually:

def cb_textChanged(self):
    if self.document().blockCount() > 1:
        self.document().setPlainText(self.document().firstBlock().text())

self.textChanged.connect(self.cb_textChanged)

Going this route also makes it really easy to implement things like converting one-per-line lists of tags/keywords into comma-separated lists. Just replace self.document().firstBlock().text() with document().toRawText() or document().toPlainText() (depending on whether you want a little bit of Unicode normalization as described in the Qt docs) and do something like .replace('\n', ', ') before feeding it to setPlainText. (Though I’d probably use a QRegularExpression since it makes it easier to normalize whitespace while doing the conversion.)

Finally, the last difference I noticed and accounted for was the behaviour of the Tab key. That can be fixed as follows:

self.setTabChangesFocus(True)

def focusInEvent(self, e: QFocusEvent):
    """Override focusInEvent to mimic QLineEdit behaviour"""
    super(OneLineSpellTextEdit, self).focusInEvent(e)

    # TODO: Are there any other things I'm supposed to be checking for?
    if e.reason() in (Qt.BacktabFocusReason, Qt.ShortcutFocusReason,
                      Qt.TabFocusReason):
        self.selectAll()

def focusOutEvent(self, e: QFocusEvent):
    """Override focusOutEvent to mimic QLineEdit behaviour"""
    super(OneLineSpellTextEdit, self).focusOutEvent(e)

    # TODO: Are there any other things I'm supposed to be checking for?
    if e.reason() in (Qt.BacktabFocusReason, Qt.MouseFocusReason,
                      Qt.ShortcutFocusReason, Qt.TabFocusReason):
        # De-select everything and move the cursor to the end
        cur = self.textCursor()
        cur.movePosition(QTextCursor.End)
        self.setTextCursor(cur)

Now for the big hassle… redefining the widget sizing behaviour. You’d think that self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) would be enough, but QPlainTextEdit defines unhelpful sizeHint and minimumSizeHint values and I don’t know where it’s getting them from.

I’ve seen other people doing various hacks, but I saw problems in all of them:

  1. Some people set the fixedHeight on the widget to the lineSpacing() given by the QFontMetrics from its font() … but that doesn’t properly account for the other various bits in the system like padding and borders from the QTextDocument and QStyle which become relevant when a widget is so short.
  2. Some people create a hidden QLineEdit with the same font settings, trigger layout calculations for it, and then harvest the values… but that’s just unacceptably hacky for me.

…so, here’s what I cobbled together from various sources. I don’t know if it’s 100% correct, but it seems to work:

def minimumSizeHint(self):
    """Redefine minimum size hint to match QLineEdit"""
    block_fmt = self.document().firstBlock().blockFormat()
    width = super(OneLineSpellTextEdit, self).minimumSizeHint().width()
    height = int(
        QFontMetricsF(self.font()).lineSpacing() +  # noqa
        block_fmt.topMargin() + block_fmt.bottomMargin() +  # noqa
        self.document().documentMargin() +  # noqa
        2 * self.frameWidth()
    )

    style_opts = QStyleOptionFrame()
    style_opts.initFrom(self)
    style_opts.lineWidth = self.frameWidth()
    # TODO: Is it correct that I'm achieving the correct content height
    #       under test conditions by feeding self.frameWidth() to both
    #       QStyleOptionFrame.lineWidth and the sizeFromContents height
    #       calculation?

    return self.style().sizeFromContents(
        QStyle.CT_LineEdit,
        style_opts,
        QSize(width, height),
        self
    )

def sizeHint(self):
    """Reuse minimumSizeHint for sizeHint"""
    return self.minimumSizeHint()

So… how well does it work? Well, here’s the full code. Try running it yourself. The demonstration will present it side-by-side with a regular QLineEdit.

(Let me know about any divergences in their behaviour that I missed so I can either fix them or document them as intentionally retained.)

CC BY-SA 4.0 A QLineEdit Replacement with Spell-Checking by Stephan Sokolow is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.

This entry was posted in Geek Stuff. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *

By submitting a comment here you grant this site a perpetual license to reproduce your words and name/web site in attribution under the same terms as the associated post.

All comments are moderated. If your comment is generic enough to apply to any post, it will be assumed to be spam. Borderline comments will have their URL field erased before being approved.