Files
fluent_widgets_pyside6/qfluentwidgets/components/date_time/calendar_view.py

672 lines
22 KiB
Python
Raw Normal View History

2025-08-14 18:45:16 +08:00
# 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()