# coding:utf-8 from typing import Union, List, Iterable from PySide6.QtCore import Qt, Signal, QRectF, QPoint, QObject, QEvent from PySide6.QtGui import QPainter, QAction, QCursor, QIcon from PySide6.QtWidgets import QPushButton, QStyledItemDelegate, QStyle from .menu import RoundMenu, MenuItemDelegate from .line_edit import LineEdit, LineEditButton from ...common.animation import TranslateYAnimation from ...common.icon import FluentIconBase, isDarkTheme from ...common.icon import FluentIcon as FIF from ...common.style_sheet import FluentStyleSheet, themeColor class ComboItem: """ Combo box item """ def __init__(self, text: str, icon: Union[str, QIcon, FluentIconBase] = None, userData=None): """ add item Parameters ---------- text: str the text of item icon: str | QIcon | FluentIconBase the icon of item userData: Any user data """ self.text = text self.userData = userData self.icon = icon @property def icon(self): if isinstance(self._icon, QIcon): return self._icon return self._icon.icon() @icon.setter def icon(self, ico: Union[str, QIcon, FluentIconBase]): if ico: self._icon = QIcon(ico) if isinstance(ico, str) else ico else: self._icon = QIcon() class ComboBoxBase: """ Combo box base """ def __init__(self, parent=None, **kwargs): pass def _setUpUi(self): self.isHover = False self.isPressed = False self.items = [] # type: List[ComboItem] self._currentIndex = -1 self.dropMenu = None FluentStyleSheet.COMBO_BOX.apply(self) self.installEventFilter(self) def addItem(self, text, icon: Union[str, QIcon, FluentIconBase] = None, userData=None): """ add item Parameters ---------- text: str the text of item icon: str | QIcon | FluentIconBase """ item = ComboItem(text, icon, userData) self.items.append(item) def addItems(self, texts: Iterable[str]): """ add items Parameters ---------- text: Iterable[str] the text of item """ for text in texts: self.addItem(text) def removeItem(self, index: int): """ Removes the item at the given index from the combobox. This will update the current index if the index is removed. """ if not 0 <= index < len(self.items): return self.items.pop(index) if index < self.currentIndex(): self._onItemClicked(self._currentIndex - 1) elif index == self.currentIndex(): if index > 0: self._onItemClicked(self._currentIndex - 1) else: self.setCurrentIndex(0) self.currentTextChanged.emit(self.currentText()) self.currentIndexChanged.emit(0) def currentIndex(self): return self._currentIndex def setCurrentIndex(self, index: int): """ set current index Parameters ---------- index: int current index """ if not 0 <= index < len(self.items): return self._currentIndex = index self.setText(self.items[index].text) def setText(self, text: str): super().setText(text) self.adjustSize() def currentText(self): if not 0 <= self.currentIndex() < len(self.items): return '' return self.items[self.currentIndex()].text def currentData(self): if not 0 <= self.currentIndex() < len(self.items): return None return self.items[self.currentIndex()].userData def setCurrentText(self, text): """ set the current text displayed in combo box, text should be in the item list Parameters ---------- text: str text displayed in combo box """ if text == self.currentText(): return index = self.findText(text) if index >= 0: self.setCurrentIndex(index) def setItemText(self, index: int, text: str): """ set the text of item Parameters ---------- index: int the index of item text: str new text of item """ if not 0 <= index < len(self.items): return self.items[index].text = text if self.currentIndex() == index: self.setText(text) def itemData(self, index: int): """ Returns the data in the given index """ if not 0 <= index < len(self.items): return None return self.items[index].userData def itemText(self, index: int): """ Returns the text in the given index """ if not 0 <= index < len(self.items): return '' return self.items[index].text def itemIcon(self, index: int): """ Returns the icon in the given index """ if not 0 <= index < len(self.items): return QIcon() return self.items[index].icon def setItemData(self, index: int, value): """ Sets the data role for the item on the given index """ if 0 <= index < len(self.items): self.items[index].userData = value def setItemIcon(self, index: int, icon: Union[str, QIcon, FluentIconBase]): """ Sets the data role for the item on the given index """ if 0 <= index < len(self.items): self.items[index].icon = icon def findData(self, data): """ Returns the index of the item containing the given data, otherwise returns -1 """ for i, item in enumerate(self.items): if item.userData == data: return i return -1 def findText(self, text: str): """ Returns the index of the item containing the given text; otherwise returns -1. """ for i, item in enumerate(self.items): if item.text == text: return i return -1 def clear(self): """ Clears the combobox, removing all items. """ if self.currentIndex() >= 0: self.setText('') self.items.clear() self._currentIndex = -1 def count(self): """ Returns the number of items in the combobox """ return len(self.items) def insertItem(self, index: int, text: str, icon: Union[str, QIcon, FluentIconBase] = None, userData=None): """ Inserts item into the combobox at the given index. """ item = ComboItem(text, icon, userData) self.items.insert(index, item) if index <= self.currentIndex(): self._onItemClicked(self.currentIndex() + 1) def insertItems(self, index: int, texts: Iterable[str]): """ Inserts items into the combobox, starting at the index specified. """ pos = index for text in texts: item = ComboItem(text) self.items.insert(pos, item) pos += 1 if index <= self.currentIndex(): self._onItemClicked(self.currentIndex() + pos - index) def _closeComboMenu(self): if not self.dropMenu: return self.dropMenu.close() self.dropMenu = None def _onDropMenuClosed(self): pos = self.mapFromGlobal(QCursor.pos()) if not self.rect().contains(pos): self.dropMenu = None def _showComboMenu(self): if not self.items: return menu = ComboBoxMenu(self) for i, item in enumerate(self.items): menu.addAction( QAction(item.icon, item.text, triggered=lambda x=i: self._onItemClicked(x))) if menu.view.width() < self.width(): menu.view.setMinimumWidth(self.width()) menu.adjustSize() menu.closedSignal.connect(self._onDropMenuClosed) self.dropMenu = menu # set the selected item if self.currentIndex() >= 0 and self.items: menu.setDefaultAction(menu.menuActions()[self.currentIndex()]) # show menu x = -menu.width()//2 + menu.layout().contentsMargins().left() + self.width()//2 y = self.height() menu.exec(self.mapToGlobal(QPoint(x, y))) def _toggleComboMenu(self): if self.dropMenu: self._closeComboMenu() else: self._showComboMenu() def _onItemClicked(self, index): if index == self.currentIndex(): return self.setCurrentIndex(index) self.currentTextChanged.emit(self.currentText()) self.currentIndexChanged.emit(index) class ComboBox(QPushButton, ComboBoxBase): """ Combo box """ currentIndexChanged = Signal(int) currentTextChanged = Signal(str) def __init__(self, parent=None): super().__init__(parent=parent) self.arrowAni = TranslateYAnimation(self) self._setUpUi() def eventFilter(self, obj, e: QEvent): if obj is self: if e.type() == QEvent.MouseButtonPress: self.isPressed = True elif e.type() == QEvent.MouseButtonRelease: self.isPressed = False elif e.type() == QEvent.Enter: self.isHover = True elif e.type() == QEvent.Leave: self.isHover = False return super().eventFilter(obj, e) def setPlaceholderText(self, text: str): self.setText(text) def mouseReleaseEvent(self, e): super().mouseReleaseEvent(e) self._toggleComboMenu() def paintEvent(self, e): QPushButton.paintEvent(self, e) painter = QPainter(self) painter.setRenderHints(QPainter.Antialiasing) if self.isHover: painter.setOpacity(0.8) elif self.isPressed: painter.setOpacity(0.7) rect = QRectF(self.width()-22, self.height()/2-5+self.arrowAni.y, 10, 10) if isDarkTheme(): FIF.ARROW_DOWN.render(painter, rect) else: FIF.ARROW_DOWN.render(painter, rect, fill="#646464") class EditableComboBox(LineEdit, ComboBoxBase): """ Editable combo box """ currentIndexChanged = Signal(int) currentTextChanged = Signal(str) def __init__(self, parent=None): super().__init__(parent=parent) self._setUpUi() self.dropButton = LineEditButton(FIF.ARROW_DOWN, self) self.setTextMargins(0, 0, 29, 0) self.dropButton.setFixedSize(30, 25) self.hBoxLayout.addWidget(self.dropButton, 0, Qt.AlignRight) self.dropButton.clicked.connect(self._toggleComboMenu) self.textEdited.connect(self._onTextEdited) self.returnPressed.connect(self._onReturnPressed) FluentStyleSheet.LINE_EDIT.apply(self) def currentText(self): return self.text() def clear(self): ComboBoxBase.clear(self) def _onReturnPressed(self): if not self.text(): return index = self.findText(self.text()) if index >= 0 and index != self.currentIndex(): self._currentIndex = index self.currentIndexChanged.emit(index) elif index == -1: self.addItem(self.text()) self.setCurrentIndex(self.count() - 1) def eventFilter(self, obj, e: QEvent): if obj is self: if e.type() == QEvent.MouseButtonPress: self.isPressed = True elif e.type() == QEvent.MouseButtonRelease: self.isPressed = False elif e.type() == QEvent.Enter: self.isHover = True elif e.type() == QEvent.Leave: self.isHover = False return super().eventFilter(obj, e) def _onTextEdited(self, text: str): self._currentIndex = -1 self.currentTextChanged.emit(text) for i, item in enumerate(self.items): if item.text == text: self._currentIndex = i self.currentIndexChanged.emit(i) return def _onDropMenuClosed(self): self.dropMenu = None class ComboMenuItemDelegate(MenuItemDelegate): """ Combo box drop menu item delegate """ def paint(self, painter: QPainter, option, index): super().paint(painter, option, index) if not option.state & QStyle.State_Selected: return painter.save() painter.setRenderHints( QPainter.Antialiasing | QPainter.SmoothPixmapTransform | QPainter.TextAntialiasing) painter.setPen(Qt.NoPen) painter.setBrush(themeColor()) painter.drawRoundedRect(0, 11+option.rect.y(), 3, 15, 1.5, 1.5) painter.restore() class ComboBoxMenu(RoundMenu): """ Combo box menu """ def __init__(self, parent=None): super().__init__(title="", parent=parent) self.view.setViewportMargins(5, 2, 5, 6) self.view.setItemDelegate(ComboMenuItemDelegate()) FluentStyleSheet.COMBO_BOX.apply(self) self.setItemHeight(33)