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:
With no focus/was not clicked:
Combobox.LineEdit
looks optically the same as the selected Combobox-Item
.
When clicked/has focus:
Combobox.LineEdit
looks like a LineEdit
/fulfills the function of the search field
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)
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:
The difference is clearly visible if you compare the number “1” in the LineEdit with the “1” in the dropdown.