Questions about QCompleter

By default, the QCompleter offers auto-completion suggestion for the entirety of the text in an input widget (QLineEdit or whatever). This means word-by-word suggestion is not possible. So, I tried to subclass the QCompleter and override its splitPath and pathFromIndex method to customize its behavior.

class My_Completer(QCompleter):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setCaseSensitivity(Qt.CaseInsensitive)
        self.setFilterMode(Qt.MatchStartsWith)

        model = QStringListModel()
        model.setStringList([])
        self.setModel(model)

    def update_suggestions(self, suggestions:list):
        model: QStringListModel = self.model()
        model.setStringList(suggestions)

    def pathFromIndex(self, index):
        # Get the selected completion suggestion
        completion = super().pathFromIndex(index)

        # Get the original text
        cursor_pos = self.widget().cursorPosition()
        text = self.widget().text()[:cursor_pos]
        last_space = text.rfind(' ')
        if last_space == -1:
            # No space found, so we replace the entire text
            new_text = completion
        else:
            # Replace only the last word
            new_text = text[:last_space + 1] + completion
        # Append the text after the cursor if any
        remainder_text = self.widget().text()[cursor_pos:]
        return new_text + remainder_text

    def splitPath(self, path):
        cursor_pos = self.widget().cursorPosition()
        text = self.widget().text()[:cursor_pos]

        path = text.split(' ')[-1]
        if path:
            return [path]
        else:
            return ['----']

In the splitPath method, I split the text of the input widget and return the last last word before the cursor position for the completer object to match for possible suggestions. In the pathFromIndex method, I try to insert the selected suggestion to the input widget. The above code could provide word-by-word suggestions, but it has a major flaw.

I noticed that when iterating over the popup suggestions on the UI, the completer object actually emits 2 signals during the process. One is when a suggestion is highlighted. The other is when a suggestion is officially selected, or in Qt’s term, activated.

For both the highlighted and activated signals, they insert the suggestions to the input widget and then set the cursor position to the end of the string. This is not something what I want. When a suggestion is highlighted, nothing should happen. And when a suggestion is activated, it should only insert the suggestion and then set the cursor position to the end of the inserted suggestion, not the end of the entire string. I have tried to connect custom slot functions to the highlighted and activated signals to implement the behavior I want (adjusting the cursor position etc.), but to no avail. The built-in behavior were still there. So there has to be some built-in functions executed after my slot functions.

I guess my question is, how do I or what are the built-in functions I should override to prevent the QCompleter to set the cursor position to the end by default for activated suggestions and completely disable the default highlighted behavior.

Also, there is a minor question about the QCompleter popup style. I can use below qss statement to custmize the backgroud color of the popup. But how do I customize the backgroud color of highlighted suggestions?

            QAbstractItemView {
                background-color: black;
                color: white;
            }

Hello @vitriol welcome to the forum!

I think you’re making things a little more difficult for yourself than you need to. The splitPath method needs to return the element(s) on which to search – in your case, the last word. The pathFromIndex method needs to return the completed replacement – in your case, the entire string, with the last word replaced.

Below is a working example which does this – I used a combobox, as I wasn’t sure what you were using. But it should be simple enough to change – just need to modify the self.widget().currentText() line.

from PySide6.QtWidgets import QApplication, QComboBox, QCompleter
from PySide6.QtCore import Qt, QStringListModel


class My_Completer(QCompleter):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setCaseSensitivity(Qt.CaseInsensitive)
        self.setFilterMode(Qt.MatchStartsWith)

        model = QStringListModel()
        model.setStringList(['hello', 'these', 'are', 'examples'])
        self.setModel(model)

    def update_suggestions(self, suggestions:list):
        model: QStringListModel = self.model()
        model.setStringList(suggestions)

    def pathFromIndex(self, index):
        # Get the selected completion suggestion
        completion = super().pathFromIndex(index)
        
        text = self.widget().currentText()
        parts = text.split(' ')
        parts[-1] = completion
        return ' '.join(parts)

    def splitPath(self, path):
        # Path contains current text.
        parts = path.split(' ')
        if parts:
            return [parts[-1]]
        return ['----']
            
app = QApplication([])
combo = QComboBox()
combo.setEditable(True)
combo.show()

completer = My_Completer()
combo.setCompleter(completer)

app.exec()

Testing this myself I can complete the final word by selecting the completion from the list.

If you want to be able to continue typing “over” the suggestions, you might want to enable “Inline Completion” with:

        self.setCompletionMode(QCompleter.InlineCompletion)

This will pick the best match and show it as highlighted text after the cursor. You can keep typing over it to replace it.

Hope that helps & let me know if this isn’t what you’re looking for.

Re: the styling you can use the::item:selected selector to style selected items.

app = QApplication([])
app.setStyleSheet("""
QAbstractItemView::item:selected {
background:blue;
}
""")

The above gives the following appearance on the demo.

image

Thank you for your reply. I am afraid your code wouldn’t do what I had in mind. It seems that I wasn’t clear about my questions earlier. Please allow me to elaborate. Thanks in advance for reading through my questions.

Let’s first look at the default behavior of QCompleter.

from PySide2.QtWidgets import QLineEdit, QCompleter, QApplication
from PySide2.QtCore import QStringListModel, Qt

class MY_Completer(QCompleter):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setCaseSensitivity(Qt.CaseInsensitive)
        self.setFilterMode(Qt.MatchStartsWith)

        model = QStringListModel()
        model.setStringList(['Apple', 'Apple1', 'Apple2', 'Banana', 'Coconut', 'Dragonfruit', 'Eggplant'])
        self.setModel(model)

    def update_suggestions(self, suggestions:list):
        model: QStringListModel = self.model()
        model.setStringList(suggestions)

if __name__ == '__main__':
    app = QApplication([])
    line_edit = QLineEdit()
    line_edit.show()

    completer = MY_Completer()
    line_edit.setCompleter(completer)

    app.exec_()

In the code snippet above, I didn’t override any built-in methods. Typing in “Banana a” wouldn’t show suggestions for apple, because QCompleter by default extracts all the text in the line edit to match for completion suggestions.

This is not what I want. I need word-by-word suggestion. After looking into the Qt documentation (QCompleter Class | Qt Widgets 6.7.2) on QCompleter, I realized that the ‘splitPath’ method is for splitting the text into strings that are used to match and the ‘pathFromIndex’ method returns the completed replacement. So it stands to reason that I should override these two methods to achieve word-by-word suggestions.

class MY_Completer(QCompleter):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setCaseSensitivity(Qt.CaseInsensitive)
        self.setFilterMode(Qt.MatchStartsWith)

        model = QStringListModel()
        model.setStringList(['Apple', 'Apple1', 'Apple2', 'Banana', 'Coconut', 'Dragonfruit', 'Eggplant'])
        self.setModel(model)

    def update_suggestions(self, suggestions:list):
        model: QStringListModel = self.model()
        model.setStringList(suggestions)

    def pathFromIndex(self, index):
        # Get the selected completion suggestion
        completion = super().pathFromIndex(index)

        # Get the original text
        cursor_pos = self.widget().cursorPosition()
        text = self.widget().text()[:cursor_pos]
        last_space = text.rfind(' ')
        if last_space == -1:
            # No space found, so we replace the entire text
            new_text = completion
        else:
            # Replace only the last word
            new_text = text[:last_space + 1] + completion
        # Append the text after the cursor if any
        remainder_text = self.widget().text()[cursor_pos:]
        return new_text + remainder_text

    def splitPath(self, path):
        cursor_pos = self.widget().cursorPosition()
        text = self.widget().text()[:cursor_pos]

        path = text.split(' ')[-1]
        if path:
            return [path]
        return ['----']

The custom completer now provides word-by-word suggestions, but other issues have arisen. If I type in “Apple Banana” first and then move the cursor back before the “B”, typing ‘a’ again will show the suggestions for apple as expected.

But as I was moving up or down the popup suggestions, two things happened. Because my code in the ‘pathFromIndex’ method updates the last word before the cursor position, the completer first updates the highlighted suggestion (“Apple”) into the line edit (results of the pathFromIndex method), and then sets the cursor position to the end of the text (results of a built-in function that I do not know).
image

If I move down the popup suggestions once more, the text in the line edit will be updated to “Apple Apple1”. Again, this is because as my code in the ‘pathFromIndex’ updates the last word before the cursor position, “AppleBanana” turns into ‘Apple1.’

So, it’s pretty clear what caused the issue. When the highlighted signal is emitted, the text in the line edit gets updated and the cursor position is set to the end of the line edit. I don’t want this to happen. When I move up or down through the popup suggestions, nothing should change in the line edit. The suggestion should only be updated in the line edit when it is officially activated. This is where I get stuck. I cannot figure out which built-in function sets the cursor position to the end, or how to completely block this default behavior of having highlighted suggestions update the line edit.

When a suggestion is activated, the ‘activated’ signal is emitted and the same behavior occurs. The ‘pathFromIndex’ method gets called and updates the last word before the cursor position, and then the cursor position is set to the end of the line edit. So, I assume the highlighted and activated signals probably share the same built-in slot function. If I knew which function this is, I could easily override it to prevent the cursor position from moving to the end.

I have tried connecting custom slot functions to both the highlighted and activated signals, but they have no effect. I can only guess that the built-in function is executed after my custom functions.

Hope my explanations make sense to you. I have been stuck for days. Your help will be greatly appreciated.

Re: I have also tried the ‘::item:selected’ selector. It wouldn’t work. Blue is the default color for highlighted suggestions. If I set it to yellow or any other color, highlighted suggestion would still have blue as the background color.

“”"
QAbstractItemView {
background-color: green;
color: white;
}
QAbstractItemView::item:selected {
background-color: yellow;
color: white;“”"

That gives me a yellow highlight:

image

Which version of PySide6 are you using?

Thanks for the additional detail @vitriol

Reading that description I’m not sure that this widget is the right way to achieve it. A few things that aren’t clear to me –

  1. Do you want to only be able to add things which are in the suggested list? Or should the user be able to type whatever.
  2. Do you want suggestions to be given when typing in the middle of the text? It sounds like it from the above example. This sounds more like tag/entity completion, than normal text completion.
  3. What’s the expected behavior if I start typing Apple in the middle of the word Banana?

I think maybe this would be better handled with a custom widget. You could add a series of tokens to a field & then complete independently on each one. Visually it might look identical, but you eliminate the fiddling.

Can you explain why you want to be able to do this, what’s the use-case etc.?

But anyway, everything is possible –

from PySide6.QtWidgets import QApplication, QComboBox, QCompleter, QLineEdit
from PySide6.QtCore import Qt, QStringListModel, QTimer


class My_Completer(QCompleter):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._latest_text = ""
        self._latest_suffix = ""
        
        self.setCaseSensitivity(Qt.CaseInsensitive)
        self.setFilterMode(Qt.MatchStartsWith)

        model = QStringListModel()
        model.setStringList(['hello', 'these', 'are', 'examples', 'Apple', 'Banana'])
        self.setModel(model)

    def update_suggestions(self, suggestions:list):
        model: QStringListModel = self.model()
        model.setStringList(suggestions)

    def pathFromIndex(self, index):
        cursor_pos = self.widget().cursorPosition()
        # Get the selected completion suggestion
        completion = super().pathFromIndex(index)
        
        parts = self._latest_text.split(' ')
        new_cursor_pos = cursor_pos + len(completion) - len(parts[-1]) 
        parts[-1] = completion
           
        # This 0 queues it immediately, after the signals have completed.
        QTimer.singleShot(0, lambda: self.widget().setCursorPosition(new_cursor_pos))
        return ' '.join(parts) + self._latest_suffix

    def splitPath(self, path):
        cursor_pos = self.widget().cursorPosition()
        current_text = self.widget().text()
        self._latest_text = current_text[:cursor_pos]
        self._latest_suffix = current_text[cursor_pos:]
        
        # Path contains current text.
        parts = self._latest_text.split(' ')
        if parts:
            return [parts[-1]]
        return ['----']
            
app = QApplication([])
app.setStyleSheet("""
QAbstractItemView::item:selected {
background:yellow;
}
""")

edit = QLineEdit()
edit.show()

completer = My_Completer()
edit.setCompleter(completer)

app.exec()

This does what I think you want. The “magic” is the use of the singleShot timer to queue the cursor movement. By using a timeout of zero, it happens “immediately”, but because it goes in the event queue, it will happen after everything else that’s already been triggered.

This is slightly icky, but sometimes needs must.

If I think of a way to do this more elegantly I’ll let you know. You can block signals on objects (to prevent them emitting the standard signals), but right now I can’t see how to achieve that without it being worse.

I am using PySide2. The QAbstractItemView::item:selected selector is not working for some reason.
image

Tried on PySide6, it’s working as supposed.

Thank you for the last answer. Although using singleShot does not provent hightlighted suggestions from being udpated into the line edit, it does do the trick for adjusting the cursor position. I think it’s good enough for me for now.

As for my use-case, I actually want to use completers on two types of widgets. One is combined with line edit, and in such cases, users should only be able to add things which are in the suggested list. The other is combined with text edit and users should be able to type whatever. I know completer is not available for text-edit-based widget by default, so I will implement the necessary custom handling.

For text edit, what I want is essentially a completer functions pretty much like the one in PyCharm, popup suggestions appear as I type and characters to be matched for possible suggestions are the ones before the cursor and after the last space. For instance, say I have typed this sentence “This is an example efghijk”, if I move the cursor before ‘h’ and type a ‘z’, the part to be matched should be “efgz”. Popup suggestions do not get updated to the text edit when highlighted and are only added when activated.

Having tried many approaches, I agree this would be better handled with a custom widget. I will look into how to do this.

Thanks again for all your help.