pythoncustomfluentfluent-designfluentuiguimodernpyqt5pyqt6pyside2pyside6qtqt5qt6softwareuiwidgetswin11winuiwinui3
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
533 lines
16 KiB
533 lines
16 KiB
# coding:utf-8 |
|
import sys |
|
from typing import Union, List, Iterable |
|
|
|
from PyQt5.QtCore import Qt, pyqtSignal, QRectF, QPoint, QObject, QEvent |
|
from PyQt5.QtGui import QPainter, QCursor, QIcon |
|
from PyQt5.QtWidgets import QAction, QPushButton, QApplication |
|
|
|
from .menu import RoundMenu, MenuAnimationType, IndicatorMenuItemDelegate |
|
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.font import setFont |
|
from ...common.style_sheet import FluentStyleSheet |
|
|
|
|
|
class ComboItem: |
|
""" Combo box item """ |
|
|
|
def __init__(self, text: str, icon: Union[str, QIcon, FluentIconBase] = None, userData=None, isEnabled=True): |
|
""" add item |
|
|
|
Parameters |
|
---------- |
|
text: str |
|
the text of item |
|
|
|
icon: str | QIcon | FluentIconBase |
|
the icon of item |
|
|
|
userData: Any |
|
user data |
|
|
|
isEnabled: bool |
|
whether to enable the item |
|
""" |
|
self.text = text |
|
self.userData = userData |
|
self.icon = icon |
|
self.isEnabled = isEnabled |
|
|
|
@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(QObject): |
|
""" Combo box base """ |
|
|
|
currentIndexChanged = pyqtSignal(int) |
|
currentTextChanged = pyqtSignal(str) |
|
activated = pyqtSignal(int) |
|
textActivated = pyqtSignal(str) |
|
|
|
def __init__(self, parent=None, **kwargs): |
|
super().__init__(parent=parent) |
|
self.isHover = False |
|
self.isPressed = False |
|
self.items = [] # type: List[ComboItem] |
|
self._currentIndex = -1 |
|
self._maxVisibleItems = -1 |
|
self.dropMenu = None |
|
self._placeholderText = "" |
|
|
|
FluentStyleSheet.COMBO_BOX.apply(self) |
|
self.installEventFilter(self) |
|
|
|
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 addItem(self, text: str, 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) |
|
if len(self.items) == 1: |
|
self.setCurrentIndex(0) |
|
|
|
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.setCurrentIndex(self._currentIndex - 1) |
|
elif index == self.currentIndex(): |
|
if index > 0: |
|
self.setCurrentIndex(self._currentIndex - 1) |
|
else: |
|
self.setText(self.itemText(0)) |
|
self.currentTextChanged.emit(self.currentText()) |
|
self.currentIndexChanged.emit(0) |
|
|
|
if self.count() == 0: |
|
self.clear() |
|
|
|
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) or index == self.currentIndex(): |
|
return |
|
|
|
oldText = self.currentText() |
|
|
|
self._currentIndex = index |
|
self.setText(self.items[index].text) |
|
|
|
if oldText != self.currentText(): |
|
self.currentTextChanged.emit(self.currentText()) |
|
|
|
self.currentIndexChanged.emit(index) |
|
|
|
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 setItemEnabled(self, index: int, isEnabled: bool): |
|
""" Sets the enabled status of the item on the given index """ |
|
if 0 <= index < len(self.items): |
|
self.items[index].isEnabled = isEnabled |
|
|
|
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.setCurrentIndex(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.setCurrentIndex(self.currentIndex() + pos - index) |
|
|
|
def setMaxVisibleItems(self, num: int): |
|
self._maxVisibleItems = num |
|
|
|
def maxVisibleItems(self): |
|
return self._maxVisibleItems |
|
|
|
def _closeComboMenu(self): |
|
if not self.dropMenu: |
|
return |
|
|
|
# drop menu could be deleted before this method |
|
try: |
|
self.dropMenu.close() |
|
except: |
|
pass |
|
|
|
self.dropMenu = None |
|
|
|
def _onDropMenuClosed(self): |
|
if sys.platform != "win32": |
|
self.dropMenu = None |
|
else: |
|
pos = self.mapFromGlobal(QCursor.pos()) |
|
if not self.rect().contains(pos): |
|
self.dropMenu = None |
|
|
|
def _createComboMenu(self): |
|
return ComboBoxMenu(self) |
|
|
|
def _showComboMenu(self): |
|
if not self.items: |
|
return |
|
|
|
menu = self._createComboMenu() |
|
for i, item in enumerate(self.items): |
|
action = QAction(item.icon, item.text, triggered=lambda c, x=i: self._onItemClicked(x)) |
|
action.setEnabled(item.isEnabled) |
|
menu.addAction(action) |
|
|
|
if menu.view.width() < self.width(): |
|
menu.view.setMinimumWidth(self.width()) |
|
menu.adjustSize() |
|
|
|
menu.setMaxVisibleItems(self.maxVisibleItems()) |
|
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) |
|
menu.closedSignal.connect(self._onDropMenuClosed) |
|
self.dropMenu = menu |
|
|
|
# set the selected item |
|
if self.currentIndex() >= 0 and self.items: |
|
menu.setDefaultAction(menu.actions()[self.currentIndex()]) |
|
|
|
# determine the animation type by choosing the maximum height of view |
|
x = -menu.width()//2 + menu.layout().contentsMargins().left() + self.width()//2 |
|
pd = self.mapToGlobal(QPoint(x, self.height())) |
|
hd = menu.view.heightForAnimation(pd, MenuAnimationType.DROP_DOWN) |
|
|
|
pu = self.mapToGlobal(QPoint(x, 0)) |
|
hu = menu.view.heightForAnimation(pu, MenuAnimationType.PULL_UP) |
|
|
|
if hd >= hu: |
|
menu.view.adjustSize(pd, MenuAnimationType.DROP_DOWN) |
|
menu.exec(pd, aniType=MenuAnimationType.DROP_DOWN) |
|
else: |
|
menu.view.adjustSize(pu, MenuAnimationType.PULL_UP) |
|
menu.exec(pu, aniType=MenuAnimationType.PULL_UP) |
|
|
|
def _toggleComboMenu(self): |
|
if self.dropMenu: |
|
self._closeComboMenu() |
|
else: |
|
self._showComboMenu() |
|
|
|
def _onItemClicked(self, index): |
|
if index != self.currentIndex(): |
|
self.setCurrentIndex(index) |
|
|
|
self.activated.emit(index) |
|
self.textActivated.emit(self.currentText()) |
|
|
|
|
|
class ComboBox(QPushButton, ComboBoxBase): |
|
""" Combo box """ |
|
|
|
currentIndexChanged = pyqtSignal(int) |
|
currentTextChanged = pyqtSignal(str) |
|
activated = pyqtSignal(int) |
|
textActivated = pyqtSignal(str) |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent=parent) |
|
self.arrowAni = TranslateYAnimation(self) |
|
setFont(self) |
|
|
|
def setPlaceholderText(self, text: str): |
|
self._placeholderText = text |
|
|
|
if self.currentIndex() <= 0: |
|
self._updateTextState(True) |
|
self.setText(text) |
|
|
|
def setCurrentIndex(self, index: int): |
|
if index < 0: |
|
self._currentIndex = -1 |
|
self.setPlaceholderText(self._placeholderText) |
|
elif 0 <= index < len(self.items): |
|
self._updateTextState(False) |
|
super().setCurrentIndex(index) |
|
|
|
def _updateTextState(self, isPlaceholder): |
|
if self.property("isPlaceholderText") == isPlaceholder: |
|
return |
|
|
|
self.setProperty("isPlaceholderText", isPlaceholder) |
|
self.setStyle(QApplication.style()) |
|
|
|
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 = pyqtSignal(int) |
|
currentTextChanged = pyqtSignal(str) |
|
activated = pyqtSignal(int) |
|
textActivated = pyqtSignal(str) |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent=parent) |
|
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.textChanged.connect(self._onComboTextChanged) |
|
self.returnPressed.connect(self._onReturnPressed) |
|
|
|
self.clearButton.disconnect() |
|
self.clearButton.clicked.connect(self._onClearButtonClicked) |
|
|
|
def setCompleterMenu(self, menu): |
|
super().setCompleterMenu(menu) |
|
menu.activated.connect(self.__onActivated) |
|
|
|
def __onActivated(self, text): |
|
index = self.findText(text) |
|
if index >= 0: |
|
self.setCurrentIndex(index) |
|
|
|
def currentText(self): |
|
return self.text() |
|
|
|
def setCurrentIndex(self, index: int): |
|
if index >= self.count() or index == self.currentIndex(): |
|
return |
|
|
|
if index < 0: |
|
self._currentIndex = -1 |
|
self.setText("") |
|
self.setPlaceholderText(self._placeholderText) |
|
else: |
|
self._currentIndex = index |
|
self.setText(self.items[index].text) |
|
|
|
def clear(self): |
|
ComboBoxBase.clear(self) |
|
|
|
def setPlaceholderText(self, text: str): |
|
self._placeholderText = text |
|
super().setPlaceholderText(text) |
|
|
|
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 _onComboTextChanged(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 |
|
|
|
def _onClearButtonClicked(self): |
|
LineEdit.clear(self) |
|
self._currentIndex = -1 |
|
|
|
|
|
class ComboBoxMenu(RoundMenu): |
|
""" Combo box menu """ |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(title="", parent=parent) |
|
|
|
self.view.setViewportMargins(0, 2, 0, 6) |
|
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) |
|
self.view.setItemDelegate(IndicatorMenuItemDelegate()) |
|
self.view.setObjectName('comboListWidget') |
|
|
|
self.setItemHeight(33) |
|
|
|
def exec(self, pos, ani=True, aniType=MenuAnimationType.DROP_DOWN): |
|
self.view.adjustSize(pos, aniType) |
|
self.adjustSize() |
|
return super().exec(pos, ani, aniType) |