# coding: utf-8 from math import ceil from collections import defaultdict, Counter from typing import Tuple, Type from PySide6.QtCore import (Qt, QRectF, Signal, QSize, QModelIndex, QDate, QCalendar, QEasingCurve, QPropertyAnimation, QParallelAnimationGroup, QPoint, QRect, QStringListModel) from PySide6.QtGui import QPainter, QColor, QCursor from PySide6.QtWidgets import (QApplication, QFrame, QPushButton, QHBoxLayout, QVBoxLayout, QListWidget, QListWidgetItem, QStyledItemDelegate, QStyle, QStyleOptionViewItem, QLabel, QWidget, QStackedWidget, QGraphicsDropShadowEffect, QListView) from ...common.icon import FluentIcon as FIF from ...common.style_sheet import isDarkTheme, FluentStyleSheet, themeColor, ThemeColor from ...common.font import getFont from ...common.screen import getCurrentScreenGeometry from ..widgets.button import TransparentToolButton from ..widgets.scroll_bar import SmoothScrollBar class ScrollButton(TransparentToolButton): """ Scroll button """ def _drawIcon(self, icon, painter: QPainter, rect: QRectF): pass def paintEvent(self, e): super().paintEvent(e) painter = QPainter(self) painter.setRenderHints(QPainter.Antialiasing) if not self.isPressed: w, h = 10, 10 else: w, h = 9, 9 x = (self.width() - w) / 2 y = (self.height() - h) / 2 if not isDarkTheme(): self._icon.render(painter, QRectF(x, y, w, h), fill="#5e5e5e") else: self._icon.render(painter, QRectF(x, y, w, h), fill="#9c9c9c") class ScrollItemDelegate(QStyledItemDelegate): def __init__(self, min, max): super().__init__() self.setRange(min, max) self.font = getFont() self.pressedIndex = QModelIndex() self.currentIndex = QModelIndex() self.selectedIndex = QModelIndex() def setRange(self, min, max): self.min = min self.max = max def setPressedIndex(self, index: QModelIndex): self.pressedIndex = index def setCurrentIndex(self, index: QModelIndex): self.currentIndex = index def setSelectedIndex(self, index: QModelIndex): self.selectedIndex = index def paint(self, painter, option, index): painter.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing) self._drawBackground(painter, option, index) self._drawText(painter, option, index) def _drawBackground(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): painter.save() # outer ring if index != self.selectedIndex: painter.setPen(Qt.NoPen) else: painter.setPen(themeColor()) if index == self.currentIndex: if index == self.pressedIndex: painter.setBrush(ThemeColor.LIGHT_2.color()) elif option.state & QStyle.State_MouseOver: painter.setBrush(ThemeColor.LIGHT_1.color()) else: painter.setBrush(themeColor()) else: c = 255 if isDarkTheme() else 0 if index == self.pressedIndex: painter.setBrush(QColor(c, c, c, 7)) elif option.state & QStyle.State_MouseOver: painter.setBrush(QColor(c, c, c, 9)) else: painter.setBrush(Qt.transparent) m = self._itemMargin() painter.drawEllipse(option.rect.adjusted(m, m, -m, -m)) painter.restore() def _drawText(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): painter.save() painter.setFont(self.font) if index == self.currentIndex: c = 0 if isDarkTheme() else 255 painter.setPen(QColor(c, c, c)) else: painter.setPen(Qt.white if isDarkTheme() else Qt.black) if not (self.min <= index.data(Qt.UserRole) <= self.max or option.state & QStyle.State_MouseOver) or \ index == self.pressedIndex: painter.setOpacity(0.6) text = index.data(Qt.DisplayRole) painter.drawText(option.rect, Qt.AlignCenter, text) painter.restore() def _itemMargin(self): return 0 class YearScrollItemDelegate(ScrollItemDelegate): """ Year scroll item delegate """ def _itemMargin(self): return 8 class DayScrollItemDelegate(ScrollItemDelegate): """ Day scroll item delegate """ def _itemMargin(self): return 3 class ScrollViewBase(QListWidget): """ Scroll view base class """ pageChanged = Signal(int) def __init__(self, Delegate: Type[ScrollItemDelegate], parent=None): super().__init__(parent) self.cols = 4 self.pageRows = 3 self.currentPage = 0 self.vScrollBar = SmoothScrollBar(Qt.Vertical, self) self.delegate = Delegate(0, 0) self.currentDate = QDate.currentDate() self.date = QDate.currentDate() self.minYear = self.currentDate.year() - 100 self.maxYear = self.currentDate.year() + 100 self.setUniformItemSizes(True) self._initItems() self.__initWidget() def __initWidget(self): self.setSpacing(0) self.setMovement(QListWidget.Static) self.setGridSize(self.gridSize()) self.setViewportMargins(0, 0, 0, 0) self.setItemDelegate(self.delegate) self.setViewMode(QListWidget.IconMode) self.setResizeMode(QListWidget.Adjust) self.vScrollBar.ani.finished.connect(self._onFirstScrollFinished) self.vScrollBar.setScrollAnimation(1) self.setDate(self.date) self.vScrollBar.setForceHidden(True) self.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) def _onFirstScrollFinished(self): self.vScrollBar.setScrollAnimation(300, QEasingCurve.OutQuad) self.vScrollBar.ani.finished.disconnect() def scrollUp(self): self.scrollToPage(self.currentPage - 1) def scrollDown(self): self.scrollToPage(self.currentPage + 1) def scrollToPage(self, page: int): if not 0 <= page <= ceil(self.model().rowCount() / (self.pageRows * self.cols)): return self.currentPage = page y = self.gridSize().height() * self.pageRows * page self.vScrollBar.setValue(y) self.delegate.setRange(*self.currentPageRange()) self.pageChanged.emit(page) def currentPageRange(self): return 0, 0 def setDate(self, date: QDate): self.scrollToDate(date) def scrollToDate(self, date: QDate): pass def _setPressedIndex(self, index): self.delegate.setPressedIndex(index) self.viewport().update() def _setSelectedIndex(self, index): self.delegate.setSelectedIndex(index) self.viewport().update() def wheelEvent(self, e): if self.vScrollBar.ani.state() == QPropertyAnimation.Running: return if e.angleDelta().y() < 0: self.scrollDown() else: self.scrollUp() def mousePressEvent(self, e): super().mousePressEvent(e) if e.button() == Qt.LeftButton and self.indexAt(e.pos()).row() >= 0: self._setPressedIndex(self.currentIndex()) def mouseReleaseEvent(self, e): super().mouseReleaseEvent(e) self._setPressedIndex(QModelIndex()) def gridSize(self) -> QSize: return QSize(76, 76) class CalendarViewBase(QFrame): """ Calendar view base class """ resetted = Signal() titleClicked = Signal() itemClicked = Signal(QDate) def __init__(self, parent=None): super().__init__(parent) self.titleButton = QPushButton(self) self.resetButton = ScrollButton(FIF.CANCEL, self) self.upButton = ScrollButton(FIF.CARE_UP_SOLID, self) self.downButton = ScrollButton(FIF.CARE_DOWN_SOLID, self) self.scrollView = None # type: ScrollViewBase self.hBoxLayout = QHBoxLayout() self.vBoxLayout = QVBoxLayout(self) self.__initWidget() def __initWidget(self): self.setFixedSize(314, 355) self.upButton.setFixedSize(32, 34) self.downButton.setFixedSize(32, 34) self.resetButton.setFixedSize(32, 34) self.titleButton.setFixedHeight(34) self.hBoxLayout.setContentsMargins(9, 8, 9, 8) self.hBoxLayout.setSpacing(7) self.hBoxLayout.addWidget(self.titleButton, 1, Qt.AlignVCenter) self.hBoxLayout.addWidget(self.resetButton, 0, Qt.AlignVCenter) self.hBoxLayout.addWidget(self.upButton, 0, Qt.AlignVCenter) self.hBoxLayout.addWidget(self.downButton, 0, Qt.AlignVCenter) self.setResetEnabled(False) self.vBoxLayout.setContentsMargins(0, 0, 0, 0) self.vBoxLayout.setSpacing(0) self.vBoxLayout.addLayout(self.hBoxLayout) self.vBoxLayout.setAlignment(Qt.AlignTop) self.titleButton.setObjectName('titleButton') FluentStyleSheet.CALENDAR_PICKER.apply(self) self.titleButton.clicked.connect(self.titleClicked) self.resetButton.clicked.connect(self.resetted) self.upButton.clicked.connect(self._onScrollUp) self.downButton.clicked.connect(self._onScrollDown) def setScrollView(self, view: ScrollViewBase): self.scrollView = view self.scrollView.itemClicked.connect(lambda i: self.itemClicked.emit(i.data(Qt.UserRole))) self.vBoxLayout.addWidget(view) view.pageChanged.connect(self._updateTitle) self._updateTitle() def setResetEnabled(self, isEnabled: bool): self.resetButton.setVisible(isEnabled) def isResetEnabled(self): return self.resetButton.isVisible() def setDate(self, date: QDate): self.scrollView.setDate(date) self._updateTitle() def setTitle(self, title: str): self.titleButton.setText(title) def currentPageDate(self) -> QDate: raise NotImplementedError def _onScrollUp(self): self.scrollView.scrollUp() self._updateTitle() def _onScrollDown(self): self.scrollView.scrollDown() self._updateTitle() def _updateTitle(self): pass class YearScrollView(ScrollViewBase): """ Year scroll view """ def __init__(self, parent=None): super().__init__(YearScrollItemDelegate, parent) def _initItems(self): years = range(self.minYear, self.maxYear+1) self.addItems([str(i) for i in years]) for i, year in enumerate(years): item = self.item(i) item.setData(Qt.UserRole, QDate(year, 1, 1)) item.setSizeHint(self.sizeHint()) if year == self.currentDate.year(): self.delegate.setCurrentIndex(self.indexFromItem(item)) def scrollToDate(self, date: QDate): page = (date.year() - self.minYear) // 12 self.scrollToPage(page) def currentPageRange(self): pageSize = self.pageRows * self.cols left = self.currentPage * pageSize + self.minYear years = defaultdict(int) for i in range(left, left + 16): y = i // 10 * 10 years[y] += 1 year = Counter(years).most_common()[0][0] return QDate(year, 1, 1), QDate(year + 10, 1, 1) class YearCalendarView(CalendarViewBase): """ Year calendar view """ def __init__(self, parent=None): super().__init__(parent) self.setScrollView(YearScrollView(self)) self.titleButton.setEnabled(False) def _updateTitle(self): left, right = self.scrollView.currentPageRange() self.setTitle(f'{left.year()} - {right.year()}') class MonthScrollView(ScrollViewBase): """ Month scroll view """ def __init__(self, parent=None): super().__init__(YearScrollItemDelegate, parent) def _initItems(self): self.months = [ self.tr('Jan'), self.tr('Feb'), self.tr('Mar'), self.tr('Apr'), self.tr('May'), self.tr('Jun'), self.tr('Jul'), self.tr('Aug'), self.tr('Sep'), self.tr('Oct'), self.tr('Nov'), self.tr('Dec'), ] self.addItems(self.months * 201) # add month items for i in range(12 * 201): year = i // 12 + self.minYear m = i % 12 + 1 item = self.item(i) item.setData(Qt.UserRole, QDate(year, m, 1)) item.setSizeHint(self.gridSize()) if year == self.currentDate.year() and m == self.currentDate.month(): self.delegate.setCurrentIndex(self.indexFromItem(item)) def scrollToDate(self, date: QDate): page = date.year() - self.minYear self.scrollToPage(page) def currentPageRange(self): year = self.minYear + self.currentPage return QDate(year, 1, 1), QDate(year, 12, 31) class MonthCalendarView(CalendarViewBase): """ Month calendar view """ def __init__(self, parent=None): super().__init__(parent) self.setScrollView(MonthScrollView(self)) def _updateTitle(self): date, _ = self.scrollView.currentPageRange() self.setTitle(str(date.year())) def currentPageDate(self) -> QDate: date, _ = self.scrollView.currentPageRange() item = self.scrollView.currentItem() month = item.data(Qt.UserRole).month() if item else 1 return QDate(date.year(), month, 1) class DayScrollView(ScrollViewBase): """ Day scroll view """ def __init__(self, parent=None): super().__init__(DayScrollItemDelegate, parent) self.cols = 7 self.pageRows = 4 self.vBoxLayout = QHBoxLayout(self) # add week day labels self.weekDays = [ self.tr('Mo'), self.tr('Tu'), self.tr('We'), self.tr('Th'), self.tr('Fr'), self.tr('Sa'), self.tr('Su') ] self.weekDayGroup = QWidget(self) self.weekDayLayout = QHBoxLayout(self.weekDayGroup) self.weekDayGroup.setObjectName('weekDayGroup') for day in self.weekDays: label = QLabel(day) label.setObjectName('weekDayLabel') self.weekDayLayout.addWidget(label, 1, Qt.AlignHCenter) self.setViewportMargins(0, 38, 0, 0) self.vBoxLayout.setAlignment(Qt.AlignTop) self.vBoxLayout.setContentsMargins(0, 0, 0, 0) self.weekDayLayout.setSpacing(0) self.weekDayLayout.setContentsMargins(3, 12, 3, 12) self.vBoxLayout.addWidget(self.weekDayGroup) def gridSize(self) -> QSize: return QSize(44, 44) def _initItems(self): startDate = QDate(self.minYear, 1, 1) endDate = QDate(self.maxYear, 12, 31) currentDate = startDate # add placeholder bias = currentDate.dayOfWeek() - 1 for i in range(bias): item = QListWidgetItem(self) item.setFlags(Qt.NoItemFlags) self.addItem(item) # add day items items, dates = [], [] while currentDate <= endDate: items.append(str(currentDate.day())) dates.append(QDate(currentDate)) currentDate = currentDate.addDays(1) self.addItems(items) for i in range(bias, self.count()): item = self.item(i) item.setData(Qt.UserRole, dates[i-bias]) item.setSizeHint(self.gridSize()) self.delegate.setCurrentIndex(self.model().index(self._dateToRow(self.currentDate))) def setDate(self, date: QDate): self.scrollToDate(date) self.setCurrentIndex(self.model().index(self._dateToRow(date))) self.delegate.setSelectedIndex(self.currentIndex()) def scrollToDate(self, date: QDate): page = (date.year() - self.minYear) * 12 + date.month() - 1 self.scrollToPage(page) def scrollToPage(self, page: int): if not 0 <= page <= 201 * 12 - 1: return self.currentPage = page index = self._dateToRow(self._pageToDate()) y = index // self.cols * self.gridSize().height() self.vScrollBar.scrollTo(y) self.delegate.setRange(*self.currentPageRange()) self.pageChanged.emit(page) def currentPageRange(self): date = self._pageToDate() return date, date.addMonths(1).addDays(-1) def _pageToDate(self): year = self.currentPage // 12 + self.minYear month = self.currentPage % 12 + 1 return QDate(year, month, 1) def _dateToRow(self, date: QDate): startDate = QDate(self.minYear, 1, 1) days = startDate.daysTo(date) return days + startDate.dayOfWeek() - 1 def mouseReleaseEvent(self, e): super().mouseReleaseEvent(e) self._setSelectedIndex(self.currentIndex()) class DayCalendarView(CalendarViewBase): """ Day calendar view """ def __init__(self, parent=None): super().__init__(parent) self.setScrollView(DayScrollView(self)) def _updateTitle(self): date = self.currentPageDate() name = QCalendar().monthName(self.locale(), date.month(), date.year()) self.setTitle(f'{name} {date.year()}') def currentPageDate(self) -> QDate: date, _ = self.scrollView.currentPageRange() return date def scrollToDate(self, date: QDate): self.scrollView.scrollToDate(date) self._updateTitle() class CalendarView(QWidget): """ Calendar view """ resetted = Signal() dateChanged = Signal(QDate) def __init__(self, parent=None): super().__init__(parent) self.hBoxLayout = QHBoxLayout(self) self.date = QDate() self._isResetEnabled = False self.stackedWidget = QStackedWidget(self) self.yearView = YearCalendarView(self) self.monthView = MonthCalendarView(self) self.dayView = DayCalendarView(self) self.opacityAni = QPropertyAnimation(self, b'windowOpacity', self) self.slideAni = QPropertyAnimation(self, b'geometry', self) self.aniGroup = QParallelAnimationGroup(self) self.__initWidget() def __initWidget(self): self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint) self.setAttribute(Qt.WA_TranslucentBackground) self.setAttribute(Qt.WA_DeleteOnClose, True) self.stackedWidget.addWidget(self.dayView) self.stackedWidget.addWidget(self.monthView) self.stackedWidget.addWidget(self.yearView) self.hBoxLayout.setContentsMargins(12, 8, 12, 20) self.hBoxLayout.addWidget(self.stackedWidget) self.setShadowEffect() self.dayView.setDate(QDate.currentDate()) self.aniGroup.addAnimation(self.opacityAni) self.aniGroup.addAnimation(self.slideAni) self.dayView.titleClicked.connect(self._onDayViewTitleClicked) self.monthView.titleClicked.connect(self._onMonthTitleClicked) self.monthView.itemClicked.connect(self._onMonthItemClicked) self.yearView.itemClicked.connect(self._onYearItemClicked) self.dayView.itemClicked.connect(self._onDayItemClicked) self.monthView.resetted.connect(self._onResetted) self.yearView.resetted.connect(self._onResetted) self.dayView.resetted.connect(self._onResetted) def setShadowEffect(self, blurRadius=30, offset=(0, 8), color=QColor(0, 0, 0, 30)): """ add shadow to dialog """ self.shadowEffect = QGraphicsDropShadowEffect(self.stackedWidget) self.shadowEffect.setBlurRadius(blurRadius) self.shadowEffect.setOffset(*offset) self.shadowEffect.setColor(color) self.stackedWidget.setGraphicsEffect(None) self.stackedWidget.setGraphicsEffect(self.shadowEffect) def isRestEnabled(self): return self._isResetEnabled def setResetEnabled(self, isEnabled: bool): """ set the visibility of reset button """ self._isResetEnabled = isEnabled self.yearView.setResetEnabled(isEnabled) self.monthView.setResetEnabled(isEnabled) self.dayView.setResetEnabled(isEnabled) def _onResetted(self): self.resetted.emit() self.close() def _onDayViewTitleClicked(self): self.stackedWidget.setCurrentWidget(self.monthView) self.monthView.setDate(self.dayView.currentPageDate()) def _onMonthTitleClicked(self): self.stackedWidget.setCurrentWidget(self.yearView) self.yearView.setDate(self.monthView.currentPageDate()) def _onMonthItemClicked(self, date: QDate): self.stackedWidget.setCurrentWidget(self.dayView) self.dayView.scrollToDate(date) def _onYearItemClicked(self, date: QDate): self.stackedWidget.setCurrentWidget(self.monthView) self.monthView.setDate(date) def _onDayItemClicked(self, date: QDate): self.close() if date != self.date: self.date = date self.dateChanged.emit(date) def setDate(self, date: QDate): """ set the selected date """ self.dayView.setDate(date) self.date = date def exec(self, pos: QPoint, ani=True): """ show calendar view """ if self.isVisible(): return rect = getCurrentScreenGeometry() w, h = self.sizeHint().width() + 5, self.sizeHint().height() pos.setX(max(rect.left(), min(pos.x(), rect.right() - w))) pos.setY(max(rect.top(), min(pos.y() - 4, rect.bottom() - h + 5))) self.move(pos) if not ani: return self.show() self.opacityAni.setStartValue(0) self.opacityAni.setEndValue(1) self.opacityAni.setDuration(150) self.opacityAni.setEasingCurve(QEasingCurve.OutQuad) self.slideAni.setStartValue(QRect(pos-QPoint(0, 8), self.sizeHint())) self.slideAni.setEndValue(QRect(pos, self.sizeHint())) self.slideAni.setDuration(150) self.slideAni.setEasingCurve(QEasingCurve.OutQuad) self.aniGroup.start() self.show()