user8513021
user8513021

Reputation:

QPlainTextEdit and QCompleter focus issue

I have read through the QCompleter docs (https://doc.qt.io/qt-5/qcompleter.html) and I've tried to implement QCompleter for a QPlainTextEdit.

Now I've got it to work like this:

enter image description here

But the problem with that is, if you start writing a word that is in the list created by keyword.kwlist, then it focuses on the popup that pops up under the cursor and it doesn't let me keep typing.

But when converting the code from c++ to python on the QCompleter docs page, i could still type even if it offered me a selection of words below.

I've tried setting focus to self.editor but that didn't work. I need help with this and the position of the popup. Right now it's kind of blocking the view of the word.

The way it should function is like this:

enter image description here

but that only works with QLineEdit.

from PyQt5.QtWidgets import QCompleter, QPlainTextEdit, QApplication, QWidget, QHBoxLayout
import sys
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QTextCursor, QFont, QTextOption
import keyword

class Completer(QCompleter):

    insertText = pyqtSignal(str)

    def __init__(self, myKeywords=None, parent=None):

        myKeywords = keyword.kwlist

        QCompleter.__init__(self, myKeywords, parent)
        self.activated.connect(self.changeCompletion)

    def changeCompletion(self, completion):
        if completion.find("(") != -1:
            completion = completion[:completion.find("(")]
            print(completion)
        print("completion is " + str(completion))
        self.insertText.emit(completion + " ")
        self.popup().hide()


class MyTextEdit(QWidget):

    def __init__(self, *args):
        super().__init__(*args)
        font = QFont()

        font.setPointSize(12)
        self.editor = QPlainTextEdit()
        self.setFont(font)
        self.completer = None
        self.hbox = QHBoxLayout(self)
        self.editor.textChanged.connect(self.complete)
        self.hbox.addWidget(self.editor)

    def setCompleter(self, completer):
        if self.completer:
            print("completer is: " + str(completer))
            self.disconnect()

        if not completer:
            print("completer is: " + str(completer))
            return

        completer.setWidget(self)
        completer.setCompletionMode(QCompleter.PopupCompletion)
        completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.completer = completer

        self.completer.insertText.connect(self.insertCompletion)

    def insertCompletion(self, completion):
        tc = self.editor.textCursor()
        extra = (len(completion) - len(self.completer.completionPrefix()))
        tc.movePosition(QTextCursor.Left)
        tc.movePosition(QTextCursor.EndOfWord)
        tc.insertText(completion[-extra:])
        self.editor.setTextCursor(tc)

    def textUnderCursor(self):
        tc = self.editor.textCursor()
        tc.select(QTextCursor.WordUnderCursor)
        return tc.selectedText()

    def complete(self):
        completionPrefix = self.textUnderCursor()
        print("completion prefix is: " + str(completionPrefix))

        self.completer.setCompletionPrefix(completionPrefix)
        popup = self.completer.popup()
        popup.setCurrentIndex(
            self.completer.completionModel().index(0, 0))
        cr = self.editor.cursorRect()
        cr.setWidth(
            self.completer.popup().sizeHintForColumn(0) + self.completer.popup().verticalScrollBar().sizeHint().width())

        self.completer.complete(cr)


if __name__ == "__main__":

    app = QApplication(sys.argv)
    completer = Completer()
    te = MyTextEdit()
    te.setCompleter(completer)
    te.show()
    sys.exit(app.exec_())

Upvotes: 3

Views: 1766

Answers (2)

ssokolow
ssokolow

Reputation: 15345

After poking at the port Christian Karcher posted, I realized it was reinventing functionality in error-prone ways rather than using the relevant Qt APIs the way they were visibly designed to be used, so I went to the Qt 5 version of the C++ example (since I'm developing a Qt 5 app for my Kubuntu LTS desktop) to re-port it more cleanly.

To my surprise, some of the reinventions were taken straight from the example code! I can only assume they were written by different people.

(No wonder I keep running across sub-optimal Qt code online. They're learning from the examples while i'm usually going into the API reference and finding suitable APIs directly.)

Anyway, here's a version that should be both more idiomatic for what the relevant Qt APIs offer and more YAGNI, as well as better matching the intutions for how it should behave based on as-you-type completion I've used in in other apps.

Feel free to use my contributions to it under any license you're allowed to use the original example under.

"""A QPlainTextEdit subclass with completion and some extras

Adapted from
https://doc.qt.io/qt-5/qtwidgets-tools-customcompleter-example.html
with the intent to be a minimally complicated way to get as-you-type
autocomplete in a QTextEdit.

As such, a lot of needless complexity has been removed, and odd design choices
which make the code needlessly difficult to maintain have been corrected.

(eg. If you're going to override `focusInEvent` to prepare to share one
`QCompleter` between multiple text widgets, which you don't do in your example,
why are you forcibly configuring it as case-insensitive inside your
`setCompleter` instead of leaving that up to whoever initializes
the completer?)

Feel free to use this code under any license you're allowed to use the original
Qt example code under. I'm of the opinion that it does stuff that should really
be part of Qt to begin with.
-- Stephan Sokolow
"""

from PyQt5.QtCore import Qt, QStringListModel
from PyQt5.QtGui import QTextCursor, QKeyEvent
from PyQt5.QtWidgets import QCompleter, QPlainTextEdit


class CompletingPlainTextEdit(QPlainTextEdit):
    """QPlainTextEdit with as-you-type completion.

    Includes a couple of extra features to make it more useful as a fake
    "QLineEdit, but with soft word-wrap".
    """

    #: The text that will be inserted after the selected completion.
    #: (Change from ' ' to ', ' for tag-entry fields.)
    completion_tail: str = " "

    #: Set this to True if you want a more convincing fake QLineEdit
    #:
    #: Ignores Enter/Return keypresses but allows newlines to be pasted in
    #: like QLineEdit does... though it doesn't attempt to render them as
    #: non-linebreaking arrow graphics.
    ignore_return: bool = False

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.completions = QStringListModel(self)
        self.completer = QCompleter(self.completions, self)
        self.completer.setWidget(self)
        self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
        self.completer.activated.connect(self.insert_completion)

    def complete(self):
        """Show any available completions at the current cursor position"""
        # TODO: Extend to support custom "what is a word character?" rules.
        #       (See https://stackoverflow.com/q/75485192/435253 for DO/DON'Ts)
        tc = self.textCursor()
        tc.select(QTextCursor.SelectionType.WordUnderCursor)
        selected_text = tc.selectedText()

        # Depend on Qt's definition of word separators to control the popup
        # instead of replicating them in this code and hoping they don't change
        # (eg. Don't show an unfiltered popup after typing a comma, don't
        #  allow fo,<tab> to complete to fo,foo, and don't break if .strip()'s
        #  definition of whitespace differs.)
        if selected_text:
            self.completer.setCompletionPrefix(selected_text)

            popup = self.completer.popup()
            popup.setCurrentIndex(self.completer.completionModel().index(0, 0))
            cr = self.cursorRect()
            cr.setWidth(popup.sizeHintForColumn(0) +
                        popup.verticalScrollBar().sizeHint().width())
            self.completer.complete(cr)
        else:
            self.completer.popup().hide()

    def insert_completion(self, completion):
        """Callback invoked by pressing Tab/Enter in the completion popup"""
        tc = self.textCursor()
        tc.select(QTextCursor.SelectionType.WordUnderCursor)
        tc.insertText(completion + self.completion_tail)

    def keyPressEvent(self, event: QKeyEvent) -> None:
        """Implement the modal interactions between completion and keys"""
        # If completer popup is open. Give it exclusive use of specific keys
        if self.completer.popup().isVisible() and event.key() in [
            # Navigate popup
            Qt.Key.Key_Up,
            Qt.Key.Key_Down,
            # Accept completion
            Qt.Key.Key_Enter,
            Qt.Key.Key_Return,
            Qt.Key.Key_Tab,
            Qt.Key.Key_Backtab,
        ]:
            event.ignore()
            return

        # Fall back to tabChangesFocus (must be off in QPlainTextEdit props)
        if event.key() == Qt.Key_Tab:  # type: ignore[attr-defined]
            event.ignore()  # Prevent QPlainTextEdit from entering literal Tab
            return
        elif event.key() == Qt.Key_Backtab:  # type: ignore[attr-defined]
            event.ignore()  # Prevent QPlainTextEdit from blocking Backtab
            return

        # Remove this line if you don't want a fake QLineEdit with word-wrap
        if self.ignore_return and event.key() in [
                Qt.Key.Key_Enter, Qt.Key.Key_Return]:
            event.ignore()
            return

        # If we reach here, let QPlainTextEdit's normal behaviour happen
        old_len = self.document().characterCount()
        super().keyPressEvent(event)

        # Now that QPlainTextEdit has incorporated any typed character,
        # proper as-you-type completion should react to that (with whitespace
        # and things like the ASCII backspace and delete characters excluded),
        # not a blanket textChanged which reacts to programmatic document
        # manipulation too.
        if event.text().strip() and self.document().characterCount() > old_len:
            self.complete()
        elif self.completer.popup().isVisible():
            self.completer.popup().hide()  # Fix "popup hangs around" bug


if __name__ == "__main__":
    import sys
    from PyQt5.QtCore import QCommandLineOption, QCommandLineParser
    from PyQt5.QtWidgets import QApplication

    app = QApplication(sys.argv)
    parser = QCommandLineParser()
    parser.addHelpOption()
    tail = QCommandLineOption(["tail"],
        "Insert <str> after each completion instead of a blank space")
    tail.setValueName("str")
    tail.setDefaultValue(' ')
    parser.addOption(tail)

    parser.process(app)
    args = parser.positionalArguments()
    if not args:
        print("Usage: {} <completion> ...".format(sys.argv[0]))
        sys.exit(0)

    te = CompletingPlainTextEdit()
    te.completions.setStringList(args)
    te.completion_tail = parser.value(tail)
    te.show()
    sys.exit(app.exec())

I also put it up on GitHub Gist in case anyone prefer it there for some reason.

Upvotes: 0

Christian Karcher
Christian Karcher

Reputation: 3641

I realize the answer might be a tad late, especially since the user is not with us anymore, but for future reference and as a pyqt6 implementation:

There is a full fledged C example provided by Qt, which i have adapted to python. To keep being able to type once the completion popup has popped up, one has to monitor the keypressed events and filter accordingly.

See def keyPressEvent[...] in MyTextEdit.

enter image description here

import keyword
import sys

from PyQt6 import QtGui
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QTextCursor
from PyQt6.QtWidgets import QApplication, QCompleter, QMainWindow, QPlainTextEdit


class TextEdit(QPlainTextEdit):
    """Custom texteditor."""

    def __init__(self):
        super().__init__()
        completer = QCompleter(keyword.kwlist)
        completer.activated.connect(self.insert_completion)
        completer.setWidget(self)
        completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
        completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
        self.completer = completer
        self.textChanged.connect(self.complete)

    def insert_completion(self, completion):
        tc = self.textCursor()
        extra = len(completion) - len(self.completer.completionPrefix())
        tc.movePosition(QTextCursor.MoveOperation.Left)
        tc.movePosition(QTextCursor.MoveOperation.EndOfWord)
        tc.insertText(completion[-extra:] + " ")
        self.setTextCursor(tc)

    @property
    def text_under_cursor(self):
        tc = self.textCursor()
        tc.select(QTextCursor.SelectionType.WordUnderCursor)
        return tc.selectedText()

    def complete(self):
        prefix = self.text_under_cursor
        self.completer.setCompletionPrefix(prefix)
        popup = self.completer.popup()
        cr = self.cursorRect()
        popup.setCurrentIndex(self.completer.completionModel().index(0, 0))
        cr.setWidth(
            self.completer.popup().sizeHintForColumn(0)
            + self.completer.popup().verticalScrollBar().sizeHint().width()
        )
        self.completer.complete(cr)

    def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
        if self.completer.popup().isVisible() and event.key() in [
            Qt.Key.Key_Enter,
            Qt.Key.Key_Return,
            Qt.Key.Key_Up,
            Qt.Key.Key_Down,
            Qt.Key.Key_Tab,
            Qt.Key.Key_Backtab,
        ]:
            event.ignore()
            return
        super().keyPressEvent(event)


class TextCompleter(QMainWindow):
    def __init__(self):
        super().__init__()
        self.editor = TextEdit()
        self.setCentralWidget(self.editor)


if __name__ == "__main__":
    app = QApplication([])
    te = TextCompleter()
    te.show()
    sys.exit(app.exec())

Upvotes: 4

Related Questions