# coding:utf-8 from typing import Iterable, List from PySide6.QtCore import Qt, Signal, QSize, QRectF, QPoint, QPropertyAnimation, QEasingCurve, QObject from PySide6.QtGui import QColor, QPainter, QCursor, QRegion from PySide6.QtWidgets import (QApplication, QWidget, QFrame, QVBoxLayout, QHBoxLayout, QGraphicsDropShadowEffect, QSizePolicy, QPushButton, QListWidgetItem) from ..widgets.cycle_list_widget import CycleListWidget from ..widgets.button import TransparentToolButton from ...common.icon import FluentIcon from ...common.style_sheet import FluentStyleSheet, themeColor, isDarkTheme class SeparatorWidget(QWidget): """ Separator widget """ def __init__(self, orient: Qt.Orientation, parent=None): super().__init__(parent=parent) if orient == Qt.Horizontal: self.setFixedHeight(1) else: self.setFixedWidth(1) self.setAttribute(Qt.WA_StyledBackground) FluentStyleSheet.TIME_PICKER.apply(self) class ItemMaskWidget(QWidget): """ Item mask widget """ def __init__(self, listWidgets: List[CycleListWidget], parent=None): super().__init__(parent=parent) self.listWidgets = listWidgets self.setFixedHeight(37) FluentStyleSheet.TIME_PICKER.apply(self) def paintEvent(self, e): painter = QPainter(self) painter.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing) # draw background painter.setPen(Qt.NoPen) painter.setBrush(themeColor()) painter.drawRoundedRect(self.rect().adjusted(4, 0, -3, 0), 5, 5) # draw text painter.setPen(Qt.black if isDarkTheme() else Qt.white) painter.setFont(self.font()) w, h = 0, self.height() for i, p in enumerate(self.listWidgets): painter.save() # draw first item's text x = p.itemSize.width()//2 + 4 + self.x() item1 = p.itemAt(QPoint(x, self.y() + 6)) if not item1: painter.restore() continue iw = item1.sizeHint().width() y = p.visualItemRect(item1).y() painter.translate(w, y - self.y() + 7) self._drawText(item1, painter, 0) # draw second item's text item2 = p.itemAt(self.pos() + QPoint(x, h - 6)) self._drawText(item2, painter, h) painter.restore() w += (iw + 8) # margin: 0 4px; def _drawText(self, item: QListWidgetItem, painter: QPainter, y: int): align = item.textAlignment() w, h = item.sizeHint().width(), item.sizeHint().height() if align & Qt.AlignLeft: rect = QRectF(15, y, w, h) # padding-left: 11px elif align & Qt.AlignRight: rect = QRectF(4, y, w-15, h) # padding-right: 11px elif align & Qt.AlignCenter: rect = QRectF(4, y, w, h) painter.drawText(rect, align, item.text()) class PickerColumnFormatter(QObject): """ Picker column formatter """ def __init__(self): super().__init__() def encode(self, value): """ convert original value to formatted value """ return str(value) def decode(self, value: str): """ convert formatted value to original value """ return str(value) class DigitFormatter(PickerColumnFormatter): """ Digit formatter """ def decode(self, value): return int(value) class PickerColumnButton(QPushButton): """ Picker column button """ def __init__(self, name: str, items: Iterable, width: int, align=Qt.AlignLeft, formatter=None, parent=None): super().__init__(text=name, parent=parent) self._name = name self._value = None # type: str self.setItems(items) self.setAlignment(align) self.setFormatter(formatter) self.setFixedSize(width, 30) self.setObjectName('pickerButton') self.setProperty('hasBorder', False) self.setAttribute(Qt.WA_TransparentForMouseEvents) def align(self): return self._align def setAlignment(self, align=Qt.AlignCenter): """ set the text alignment """ if align == Qt.AlignLeft: self.setProperty('align', 'left') elif align == Qt.AlignRight: self.setProperty('align', 'right') else: self.setProperty('align', 'center') self._align = align self.setStyle(QApplication.style()) def value(self) -> str: if self._value is None: return None return self.formatter().encode(self._value) def setValue(self, v): self._value = v if v is None: self.setText(self.name()) self.setProperty('hasValue', False) else: self.setText(self.value()) self.setProperty('hasValue', True) self.setStyle(QApplication.style()) def items(self): return [self._formatter.encode(i) for i in self._items] def setItems(self, items: Iterable): self._items = list(items) def formatter(self): return self._formatter def setFormatter(self, formatter): self._formatter = formatter or PickerColumnFormatter() def name(self): return self._name def setName(self, name: str): if self.text() == self.name(): self.setText(name) self._name = name def checkColumnIndex(func): """ check whether the index is out of range """ def wrapper(picker, index: int, *args, **kwargs): if not 0 <= index < len(picker.columns): return return func(picker, index, *args, **kwargs) return wrapper class PickerBase(QPushButton): """ Picker base class """ def __init__(self, parent=None): super().__init__(parent=parent) self.columns = [] # type: List[PickerColumnButton] self.hBoxLayout = QHBoxLayout(self) self.hBoxLayout.setSpacing(0) self.hBoxLayout.setContentsMargins(0, 0, 0, 0) self.hBoxLayout.setSizeConstraint(QHBoxLayout.SetFixedSize) FluentStyleSheet.TIME_PICKER.apply(self) self.clicked.connect(self._showPanel) def addColumn(self, name: str, items: Iterable, width: int, align=Qt.AlignCenter, formatter: PickerColumnFormatter = None): """ add column Parameters ---------- name: str the name of column items: Iterable the items of column width: int the width of column align: Qt.AlignmentFlag the text alignment of button formatter: PickerColumnFormatter the formatter of column """ # create column button button = PickerColumnButton(name, items, width, align, formatter, self) self.columns.append(button) self.hBoxLayout.addWidget(button, 0, Qt.AlignLeft) # update the style of buttons for btn in self.columns[:-1]: btn.setProperty('hasBorder', True) btn.setStyle(QApplication.style()) @checkColumnIndex def setColumnAlignment(self, index: int, align=Qt.AlignCenter): """ set the text alignment of specified column """ self.columns[index].setAlignment(align) @checkColumnIndex def setColumnWidth(self, index: int, width: int): """ set the width of specified column """ self.columns[index].setFixedWidth(width) @checkColumnIndex def setColumnTight(self, index: int): """ make the specified column to be tight """ fm = self.fontMetrics() w = max(fm.width(i) for i in self.columns[index].items) + 30 self.setColumnWidth(index, w) @checkColumnIndex def setColumnVisible(self, index: int, isVisible: bool): """ set the text alignment of specified column """ self.columns[index].setVisible(isVisible) def value(self): return [c.value() for c in self.columns if c.isVisible()] def initialValue(self): return [c.initialValue() for c in self.columns if c.isVisible()] @checkColumnIndex def setColumnValue(self, index: int, value): self.columns[index].setValue(value) @checkColumnIndex def setColumnInitialValue(self, index: int, value): self.columns[index].setInitialValue(value) @checkColumnIndex def setColumnFormatter(self, index: int, formatter: PickerColumnFormatter): self.columns[index].setFormatter(formatter) @checkColumnIndex def setColumnItems(self, index: int, items: Iterable): self.columns[index].setItems(items) @checkColumnIndex def encodeValue(self, index: int, value): """ convert original value to formatted value """ return self.columns[index].formatter().encode(value) @checkColumnIndex def decodeValue(self, index: int, value): """ convert formatted value to origin value """ return self.columns[index].formatter().decode(value) @checkColumnIndex def setColumn(self, index: int, name: str, items: Iterable, width: int, align=Qt.AlignCenter): """ set column Parameters ---------- index: int the index of column name: str the name of column items: Iterable the items of column width: int the width of column align: Qt.AlignmentFlag the text alignment of button """ button = self.columns[index] button.setText(name) button.setFixedWidth(width) button.setAlignment(align) def clearColumns(self): """ clear columns """ while self.columns: btn = self.columns.pop() self.hBoxLayout.removeWidget(btn) btn.deleteLater() def enterEvent(self, e): self._setButtonProperty('enter', True) def leaveEvent(self, e): self._setButtonProperty('enter', False) def mousePressEvent(self, e): self._setButtonProperty('pressed', True) super().mousePressEvent(e) def mouseReleaseEvent(self, e): self._setButtonProperty('pressed', False) super().mouseReleaseEvent(e) def _setButtonProperty(self, name, value): """ send event to picker buttons """ for button in self.columns: button.setProperty(name, value) button.setStyle(QApplication.style()) def panelInitialValue(self): """ initial value of panel """ return self.value() def _showPanel(self): """ show panel """ panel = PickerPanel(self) for column in self.columns: if column.isVisible(): panel.addColumn(column.items(), column.width(), column.align()) panel.setValue(self.panelInitialValue()) panel.confirmed.connect(self._onConfirmed) panel.columnValueChanged.connect( lambda i, v: self._onColumnValueChanged(panel, i, v)) w = panel.vBoxLayout.sizeHint().width() - self.width() panel.exec(self.mapToGlobal(QPoint(-w//2, -37 * 4))) def _onConfirmed(self, value: list): for i, v in enumerate(value): self.setColumnValue(i, v) def _onColumnValueChanged(self, panel, index: int, value: str): """ column value changed slot """ pass class PickerToolButton(TransparentToolButton): """ Picker tool button """ def _drawIcon(self, icon, painter, rect): if self.isPressed: painter.setOpacity(1) super()._drawIcon(icon, painter, rect) class PickerPanel(QWidget): """ picker panel """ confirmed = Signal(list) columnValueChanged = Signal(int, str) def __init__(self, parent=None): super().__init__(parent=parent) self.itemHeight = 37 self.listWidgets = [] # type: List[CycleListWidget] self.view = QFrame(self) self.itemMaskWidget = ItemMaskWidget(self.listWidgets, self) self.hSeparatorWidget = SeparatorWidget(Qt.Horizontal, self.view) self.yesButton = PickerToolButton(FluentIcon.ACCEPT, self.view) self.cancelButton = PickerToolButton(FluentIcon.CLOSE, self.view) self.hBoxLayout = QHBoxLayout(self) self.listLayout = QHBoxLayout() self.buttonLayout = QHBoxLayout() self.vBoxLayout = QVBoxLayout(self.view) self.__initWidget() def __initWidget(self): self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint) self.setAttribute(Qt.WA_TranslucentBackground) self.setShadowEffect() self.yesButton.setIconSize(QSize(16, 16)) self.cancelButton.setIconSize(QSize(13, 13)) self.yesButton.setFixedHeight(33) self.cancelButton.setFixedHeight(33) self.hBoxLayout.setContentsMargins(12, 8, 12, 20) self.hBoxLayout.addWidget(self.view, 1, Qt.AlignCenter) self.hBoxLayout.setSizeConstraint(QHBoxLayout.SetMinimumSize) self.vBoxLayout.setSpacing(0) self.vBoxLayout.setContentsMargins(0, 0, 0, 0) self.vBoxLayout.addLayout(self.listLayout, 1) self.vBoxLayout.addWidget(self.hSeparatorWidget) self.vBoxLayout.addLayout(self.buttonLayout, 1) self.vBoxLayout.setSizeConstraint(QVBoxLayout.SetMinimumSize) self.buttonLayout.setSpacing(6) self.buttonLayout.setContentsMargins(3, 3, 3, 3) self.buttonLayout.addWidget(self.yesButton) self.buttonLayout.addWidget(self.cancelButton) self.yesButton.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Expanding) self.cancelButton.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Expanding) self.yesButton.clicked.connect(self._fadeOut) self.yesButton.clicked.connect( lambda: self.confirmed.emit(self.value())) self.cancelButton.clicked.connect(self._fadeOut) self.view.setObjectName('view') FluentStyleSheet.TIME_PICKER.apply(self) def setShadowEffect(self, blurRadius=30, offset=(0, 8), color=QColor(0, 0, 0, 30)): """ add shadow to dialog """ self.shadowEffect = QGraphicsDropShadowEffect(self.view) self.shadowEffect.setBlurRadius(blurRadius) self.shadowEffect.setOffset(*offset) self.shadowEffect.setColor(color) self.view.setGraphicsEffect(None) self.view.setGraphicsEffect(self.shadowEffect) def addColumn(self, items: Iterable, width: int, align=Qt.AlignCenter): """ add one column to view Parameters ---------- items: Iterable[Any] the items to be added width: int the width of item align: Qt.AlignmentFlag the text alignment of item """ if self.listWidgets: self.listLayout.addWidget(SeparatorWidget(Qt.Vertical)) w = CycleListWidget(items, QSize(width, self.itemHeight), align, self) w.vScrollBar.valueChanged.connect(self.itemMaskWidget.update) N = len(self.listWidgets) w.currentItemChanged.connect( lambda i, n=N: self.columnValueChanged.emit(n, i.text())) self.listWidgets.append(w) self.listLayout.addWidget(w) def resizeEvent(self, e): self.itemMaskWidget.resize(self.view.width()-3, self.itemHeight) m = self.hBoxLayout.contentsMargins() self.itemMaskWidget.move(m.left()+2, m.top() + 148) def value(self): """ return the value of columns """ return [i.currentItem().text() for i in self.listWidgets] def setValue(self, value: list): """ set the value of columns """ if len(value) != len(self.listWidgets): return for v, w in zip(value, self.listWidgets): w.setSelectedItem(v) def columnValue(self, index: int) -> str: """ return the value of specified column """ if not 0 <= index < len(self.listWidgets): return return self.listWidgets[index].currentItem().text() def setColumnValue(self, index: int, value: str): """ set the value of specified column """ if not 0 <= index < len(self.listWidgets): return self.listWidgets[index].setSelectedItem(value) def column(self, index: int): """ return the list widget of specified column """ return self.listWidgets[index] def exec(self, pos, ani=True): """ show panel Parameters ---------- pos: QPoint pop-up position ani: bool Whether to show pop-up animation """ if self.isVisible(): return # show before running animation, or the height calculation will be wrong self.show() rect = QApplication.screenAt(QCursor.pos()).availableGeometry() w, h = self.width() + 5, self.height() pos.setX( min(pos.x() - self.layout().contentsMargins().left(), rect.right() - w)) pos.setY(max(rect.top(), min(pos.y() - 4, rect.bottom() - h + 5))) self.move(pos) if not ani: return self.isExpanded = False self.ani = QPropertyAnimation(self.view, b'windowOpacity', self) self.ani.valueChanged.connect(self._onAniValueChanged) self.ani.setStartValue(0) self.ani.setEndValue(1) self.ani.setDuration(150) self.ani.setEasingCurve(QEasingCurve.OutQuad) self.ani.start() def _onAniValueChanged(self, opacity): m = self.layout().contentsMargins() w = self.view.width() + m.left() + m.right() + 120 h = self.view.height() + m.top() + m.bottom() + 12 if not self.isExpanded: y = int(h / 2 * (1 - opacity)) self.setMask(QRegion(0, y, w, h-y*2)) else: y = int(h / 3 * (1 - opacity)) self.setMask(QRegion(0, y, w, h-y*2)) def _fadeOut(self): self.isExpanded = True self.ani = QPropertyAnimation(self, b'windowOpacity', self) self.ani.valueChanged.connect(self._onAniValueChanged) self.ani.finished.connect(self.deleteLater) self.ani.setStartValue(1) self.ani.setEndValue(0) self.ani.setDuration(150) self.ani.setEasingCurve(QEasingCurve.OutQuad) self.ani.start()