pythoncomboboxpyqt5

Is there a way to make the LineEdit-Part of a Combobox look like the (delegated) Combobox-Items?


I want to change the appearance of a QCombobox including the items and the "LineEdit-Part" to look nice/professional.

The easy part:
The combo box functions both as a search field and for selecting symbols that have already been found successfully. As a symbol often provides several results, these must be easily distinguishable from each other as combo box items.

The hard part/where i failed:
In order to make the result/Comboboxitems look good, I decided to use a QStyledItemDelegate. This works, but I have not yet found a way to transfer this look to the LineEdit part.

Ideally, the behavior of the combobox would be as follows:

I assume it must/can be done with a CustomQCombobox. However all my attempts failed horribly.

A working and code snippet as base/to experiment with:

import sys
from PyQt5 import QtWidgets, QtCore, QtGui
import qdarkstyle
# from Custom_Widgets import CustomComboBox

class ComboBoxItemDelegate(QtWidgets.QStyledItemDelegate):
    #This delegate will make the Combobox Items look nice

    def paint(self, painter, option, index):
        primary = index.data(QtCore.Qt.DisplayRole)
        secondary = index.data(QtCore.Qt.UserRole)

        if secondary is None:
            secondary = ""

        # Highlight Background if you mouseover
        if option.state & QtWidgets.QStyle.State_Selected:
            painter.fillRect(option.rect, option.palette.highlight())
        else:
            painter.fillRect(option.rect, option.palette.base())
        
        # Make/Define the "Boxes" where primary and secondary will be written in 
        rect = option.rect.adjusted(5, 0, -5, 0)  
        primaryRect = QtCore.QRect(rect.left(), rect.top(), rect.width(), rect.height()//2)
        secondaryRect = QtCore.QRect(rect.left(), rect.top() + rect.height()//2, rect.width(), rect.height()//2)
        
        #Draw Primary
        primaryFont = option.font
        painter.setFont(primaryFont)
        painter.setPen(option.palette.text().color())
        painter.drawText(primaryRect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, primary)
        
        #Draw Secondary
        secondaryFont = QtGui.QFont(option.font)
        secondaryFont.setPointSize(option.font.pointSize() - 1)
        painter.setFont(secondaryFont)
        painter.setPen(QtGui.QColor("gray"))
        painter.drawText(secondaryRect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, secondary)

    def sizeHint(self, option, index):
        #Make it fit
        size = super().sizeHint(option, index)
        size.setHeight(int(size.height() * 1.6))
        return size


class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()
        items = [{'additional_info':'HELLO', 'Item':'SYMBOL'},
                   {'additional_info':'WORLD', 'Item':'G.I. JOE'},
                   {'additional_info':'NOVABRAIN', 'Item':'FLATEARTH'},
                   {'additional_info':'SUPERSTAR', 'Item':'BOB THE BUILDER'}]
        for item in items:
            self.initial_filling(item['Item'], item['additional_info'])

    def initial_filling(self, primary, secondary):
        self.searchComboBox.addItem(primary)
        index = self.searchComboBox.model().index(self.searchComboBox.count()-1, 0)
        self.searchComboBox.model().setData(index, secondary, role=QtCore.Qt.UserRole)

    def initUI(self):
        self.setWindowTitle("My best Widget")
        self.resize(324, 500)
        central_widget = QtWidgets.QWidget()
        self.setCentralWidget(central_widget)
        layout = QtWidgets.QVBoxLayout(central_widget)

        # Creating the Combobox
        self.searchComboBox = QtWidgets.QComboBox()
        self.searchComboBox.setEditable(True)
        self.searchComboBox.setItemDelegate(ComboBoxItemDelegate(self.searchComboBox))
        self.searchComboBox.setInsertPolicy(QtWidgets.QComboBox.NoInsert)
        layout.addWidget(self.searchComboBox)

        #Usually triggers API. Just think of it as "make menu" function 
        self.searchComboBox.lineEdit().returnPressed.connect(self.on_search_return)
        #Updating LineEdit but ugly version
        self.searchComboBox.currentIndexChanged.connect(self.updateComboBoxLineEdit)

        # !(not relevant)! Dummy Table !(not relevant)!
        self.table = QtWidgets.QTableWidget()
        self.table.setColumnCount(3)
        self.table.setHorizontalHeaderLabels(["Some", "Nice", "Table"])
        self.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint)
        self.table.verticalHeader().setVisible(False)
        self.table.setShowGrid(False)
        layout.addWidget(self.table)

    def on_search_return(self):
        #PREPARE INPUT FOR API CALL AND GETTING RESULT
        symbol = self.searchComboBox.currentText().strip().upper()
        if not symbol:
            return

        #.... RESULTS FROM API
        
        results = [{'additional_info':'HELLO', 'Item':symbol},
                   {'additional_info':'WORLD', 'Item':symbol},
                   {'additional_info':'GALAXY', 'Item':symbol},
                   {'additional_info':'STAR', 'Item':symbol}]
        self.show_lookup_menu(results)


    def show_lookup_menu(self, results):
        #Creating Menu based on results
        menu = QtWidgets.QMenu(self)
        for result in results:
            text = f"{result['additional_info']} - {result['Item']}"
            action = QtWidgets.QAction(text, menu)
            action.setData(result)
            menu.addAction(action)
        menu.triggered.connect(self.on_menu_action_triggered)
        pos = self.searchComboBox.mapToGlobal(QtCore.QPoint(0, self.searchComboBox.height()))
        menu.exec_(pos)


    def on_menu_action_triggered(self, action):
        result = action.data()
        if result:
            print("You chose: ", result)
            primary = result["Item"]
            secondary = result["additional_info"]
            if self.searchComboBox.findText(primary) == -1:
                self.searchComboBox.addItem(primary)
                index = self.searchComboBox.model().index(self.searchComboBox.count()-1, 0)
                self.searchComboBox.model().setData(index, secondary, role=QtCore.Qt.UserRole)


    def updateComboBoxLineEdit(self, index):
        #Sadly LineEdit-Part of Combobox has no Delegate...
        if index >= 0:
            primary = self.searchComboBox.itemText(index)
            secondary = self.searchComboBox.itemData(index, role=QtCore.Qt.UserRole)
            if secondary:
                combined = f"{primary} {secondary}"
            else:
                combined = primary
            self.searchComboBox.lineEdit().setText(combined)


def main():
    qt_app = QtWidgets.QApplication(sys.argv)
    qt_app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5())

    window = MainWindow()
    window.show()

    sys.exit(qt_app.exec_())

if __name__ == "__main__":
    main()

Ideally, I would like to keep the look (primary/secondary written on top of each other). It would also be fine to write primary and secondary one after the other.
It would only be important to me that secondary is gray and one level smaller (so that primary and secondary can be easily distinguished)


Solution

  • I have solved the problem by nesting two CustomWidgets.

    First I created a custom QLineEdit and overwrote the paintEvent for “Widget does not have the focus”.

    Next, a custom QCombobox has to be created. In this, the build-in lineedit of each combobox is overwritten with the CustomLineEdit just created.

    Finally, you should overwrite the sizeHint method of the CustomCombobox so that there is enough space for the two-line display.

    Edit:

    I have added an additional query to make sure that whenever a popup of the combobox (menu or dropdown) is open, the LineEdit behaves/draws normally.

    import sys
    from PyQt5 import QtWidgets, QtCore, QtGui
    
    class ComboBoxItemDelegate(QtWidgets.QStyledItemDelegate):
        #This delegate will make the Combobox Items look nice
    
        def paint(self, painter, option, index):
            primary = index.data(QtCore.Qt.DisplayRole)
            secondary = index.data(QtCore.Qt.UserRole)
    
            if secondary is None:
                secondary = ""
    
            # Highlight Background if you mouseover
            painter.save()
            if option.state & QtWidgets.QStyle.State_Selected:
                painter.fillRect(option.rect, option.palette.highlight())
            else:
                painter.fillRect(option.rect, option.palette.base())
            
            # Make/Define the "Boxes" where primary and secondary will be written in 
            rect = option.rect.adjusted(5, 0, -5, 0)  
            primaryRect = QtCore.QRect(rect.left(), rect.top(), rect.width(), rect.height()//2)
            secondaryRect = QtCore.QRect(rect.left(), rect.top() + rect.height()//2, rect.width(), rect.height()//2)
            
            #Draw Primary
            primaryFont = option.font
            painter.setFont(primaryFont)
            painter.setPen(option.palette.text().color())
            painter.drawText(primaryRect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, primary)
            
            #Draw Secondary
            secondaryFont = QtGui.QFont(option.font)
            secondaryFont.setPointSize(option.font.pointSize() - 1)
            painter.setFont(secondaryFont)
            painter.setPen(QtGui.QColor("gray"))
            painter.drawText(secondaryRect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, secondary)
            painter.restore()
    
    
        def sizeHint(self, option, index):
            #Make it fit
            size = super().sizeHint(option, index)
            size.setHeight(int(size.height() * 1.6))
            return size
        
    class CustomLineEdit(QtWidgets.QLineEdit):
        # First we need to create a custom QLineEdit where we overwrite the "not focused" paintEvent
        def __init__(self, parent=None):
            super().__init__(parent)
    
        def paintEvent(self, event):
            #only overwrite "not focus" part
            if self.hasFocus():
                super().paintEvent(event)
                return
            # if an menu/dropdown is open and it has the same parent, 
            # draw default
            top = QtWidgets.QApplication.activePopupWidget() 
            if top is not None and top.parent() == self.parent(): 
                super().paintEvent(event) 
                return
            
            painter = QtGui.QPainter(self)
            
            #Basic Background
            opt = QtWidgets.QStyleOptionFrame()
            self.initStyleOption(opt)
            
            #getting parent for "what to draw" (primary/secondary) and font (at least I tried)
            combo = self.parent()
            if isinstance(combo, QtWidgets.QComboBox):
                current_index = combo.currentIndex()
                model_index = combo.model().index(current_index, 0)
                display_text = model_index.data(QtCore.Qt.DisplayRole)
                user_text = model_index.data(QtCore.Qt.UserRole)
                base_font = combo.font()
            else:
                display_text = self.text()
                user_text = ""
                base_font = self.font()
            
            # Getting "to be painted"-Area and divide them into areas
            text_rect = self.style().subElementRect(QtWidgets.QStyle.SE_LineEditContents, opt, self)
            text_rect = text_rect.adjusted(2, 0, 0, 0)  #custom padding to align with dropdown items
            line_height = text_rect.height() // 2
            primary_rect = QtCore.QRect(text_rect.left(), text_rect.top(), text_rect.width(), line_height)
            secondary_rect = QtCore.QRect(text_rect.left(), text_rect.top() + line_height, text_rect.width(), line_height)
            
            # Draw primary (top)
            painter.setFont(base_font)
            painter.setPen(self.palette().color(QtGui.QPalette.Text))
            painter.drawText(primary_rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, str(display_text))
    
            # Draw secondary (bottom)
            font = QtGui.QFont(base_font)
            font.setPointSize(font.pointSize() - 1)
            painter.setFont(font)
            painter.setPen(QtGui.QColor("gray"))
            painter.drawText(secondary_rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, str(user_text))
    
    
    class CustomComboBox(QtWidgets.QComboBox):
        #Then create a Customcombobox with this customlineedit
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setEditable(True)
            self._customLineEdit = CustomLineEdit(self)
            self.setLineEdit(self._customLineEdit)
        
        # sizeHint is needed or else the font/strings will look compressed
        def sizeHint(self):
            size = super().sizeHint()
            size.setHeight(int(size.height() * 1.6))
            return size
    
    
    class MainWindow(QtWidgets.QMainWindow):
        def __init__(self):
            super().__init__()
            self.initUI()
            items = [{'additional_info':'HELLO', 'Item':'1111'},
                       {'additional_info':'WORLD', 'Item':'G.I. JOE'},
                       {'additional_info':'NOVABRAIN', 'Item':'FLATEARTH'},
                       {'additional_info':'SUPERSTAR', 'Item':'BOB THE BUILDER'}]
            for item in items:
                self.initial_filling(item['Item'], item['additional_info'])
    
        def initial_filling(self, primary, secondary):
            self.searchComboBox.addItem(primary)
            index = self.searchComboBox.model().index(self.searchComboBox.count()-1, 0)
            self.searchComboBox.model().setData(index, secondary, role=QtCore.Qt.UserRole)
    
        def initUI(self):
            self.setWindowTitle("My best Widget")
            self.resize(324, 500)
            central_widget = QtWidgets.QWidget()
            self.setCentralWidget(central_widget)
            layout = QtWidgets.QVBoxLayout(central_widget)
    
            # Creating the Combobox
            self.searchComboBox = CustomComboBox()
            self.searchComboBox.setEditable(True)
            self.searchComboBox.setItemDelegate(ComboBoxItemDelegate(self.searchComboBox))
            self.searchComboBox.setInsertPolicy(QtWidgets.QComboBox.NoInsert)
            layout.addWidget(self.searchComboBox)
    
            #Usually triggers API. Just think of it as "make menu" function 
            self.searchComboBox.lineEdit().returnPressed.connect(self.on_search_return)
            self.searchComboBox.currentIndexChanged.connect(self.updateComboBoxLineEdit)
    
            # !(not relevant)! Dummy Table !(not relevant)!
            self.table = QtWidgets.QTableWidget()
            self.table.setColumnCount(3)
            self.table.setHorizontalHeaderLabels(["Some", "Nice", "Table"])
            self.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint)
            self.table.verticalHeader().setVisible(False)
            self.table.setShowGrid(False)
            layout.addWidget(self.table)
    
        def on_search_return(self):
            #PREPARE INPUT FOR API CALL AND GETTING RESULT
            symbol = self.searchComboBox.currentText().strip().upper()
            if not symbol:
                return
    
            #.... RESULTS FROM API
            
            results = [{'additional_info':'HELLO', 'Item':symbol},
                       {'additional_info':'WORLD', 'Item':symbol},
                       {'additional_info':'GALAXY', 'Item':symbol},
                       {'additional_info':'STAR', 'Item':symbol}]
            self.show_lookup_menu(results)
    
    
        def show_lookup_menu(self, results):
            print("You selected:", results)
    
            #Creating Menu based on results
            menu = QtWidgets.QMenu(self.searchComboBox)
            menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
            for result in results:
                text = f"{result['additional_info']} - {result['Item']}"
                action = QtWidgets.QAction(text, menu)
                action.setData(result)
                menu.addAction(action)
            menu.triggered.connect(self.on_menu_action_triggered)
            pos = self.searchComboBox.mapToGlobal(QtCore.QPoint(0, self.searchComboBox.height()))
            menu.popup(pos)
    
    
        def on_menu_action_triggered(self, action):
            result = action.data()
            if result:
                print("Ausgewähltes Ergebnis:", result)
                primary = result["Item"]
                secondary = result["additional_info"]
                if self.searchComboBox.findText(primary) == -1:
                    self.searchComboBox.addItem(primary)
                    index = self.searchComboBox.model().index(self.searchComboBox.count()-1, 0)
                    self.searchComboBox.model().setData(index, secondary, role=QtCore.Qt.UserRole)
    
    
        def updateComboBoxLineEdit(self, index):
            #Sadly LineEdit-Part of Combobox has no Delegate...
            if index >= 0:
                primary = self.searchComboBox.itemText(index)
                secondary = self.searchComboBox.itemData(index, role=QtCore.Qt.UserRole)
                if secondary:
                    combined = f"{primary} {secondary}"
                else:
                    combined = primary
                self.searchComboBox.lineEdit().setText(combined)
    
    
    def main():
        qt_app = QtWidgets.QApplication(sys.argv)
    
        window = MainWindow()
        window.show()
    
        sys.exit(qt_app.exec_())
    
    if __name__ == "__main__":
        main()
    

    The only flaw as far as I can see, is that the fonts and font sizes do not match (ComboboxDelegate vs. CustomLineEdit/CustomCombobox)

    Here is a screenshot with other content to illustrate the problem:

    enter image description here

    The difference is clearly visible if you compare the number “1” in the LineEdit with the “1” in the dropdown.