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.
525 lines
17 KiB
525 lines
17 KiB
# coding:utf-8
|
|
from PySide6.QtCore import (QEvent, QEasingCurve, Qt, Signal, QPropertyAnimation, Property, QRectF,
|
|
QTimer, QPoint, QObject)
|
|
from PySide6.QtGui import QPainter, QColor, QMouseEvent
|
|
from PySide6.QtWidgets import (QWidget, QToolButton, QAbstractScrollArea, QGraphicsOpacityEffect,
|
|
QHBoxLayout, QVBoxLayout, QApplication, QAbstractItemView, QListView)
|
|
|
|
from ...common.icon import FluentIcon
|
|
from ...common.style_sheet import isDarkTheme
|
|
from ...common.smooth_scroll import SmoothScroll
|
|
|
|
class ArrowButton(QToolButton):
|
|
""" Arrow button """
|
|
|
|
def __init__(self, icon: FluentIcon, parent=None):
|
|
super().__init__(parent=parent)
|
|
self.setFixedSize(10, 10)
|
|
self._icon = icon
|
|
|
|
def paintEvent(self, e):
|
|
painter = QPainter(self)
|
|
painter.setRenderHints(QPainter.Antialiasing)
|
|
|
|
s = 7 if self.isDown() else 8
|
|
x = (self.width() - s) / 2
|
|
self._icon.render(painter, QRectF(x, x, s, s), fill="#858789")
|
|
|
|
|
|
class ScrollBarGroove(QWidget):
|
|
""" Scroll bar groove """
|
|
|
|
def __init__(self, orient: Qt.Orientation, parent):
|
|
super().__init__(parent=parent)
|
|
if orient == Qt.Vertical:
|
|
self.setFixedWidth(12)
|
|
self.upButton = ArrowButton(FluentIcon.CARE_UP_SOLID, self)
|
|
self.downButton = ArrowButton(FluentIcon.CARE_DOWN_SOLID, self)
|
|
self.setLayout(QVBoxLayout(self))
|
|
self.layout().addWidget(self.upButton, 0, Qt.AlignHCenter)
|
|
self.layout().addStretch(1)
|
|
self.layout().addWidget(self.downButton, 0, Qt.AlignHCenter)
|
|
self.layout().setContentsMargins(0, 3, 0, 3)
|
|
else:
|
|
self.setFixedHeight(12)
|
|
self.upButton = ArrowButton(FluentIcon.CARE_LEFT_SOLID, self)
|
|
self.downButton = ArrowButton(FluentIcon.CARE_RIGHT_SOLID, self)
|
|
self.setLayout(QHBoxLayout(self))
|
|
self.layout().addWidget(self.upButton, 0, Qt.AlignVCenter)
|
|
self.layout().addStretch(1)
|
|
self.layout().addWidget(self.downButton, 0, Qt.AlignVCenter)
|
|
self.layout().setContentsMargins(3, 0, 3, 0)
|
|
|
|
self.opacityEffect = QGraphicsOpacityEffect(self)
|
|
self.opacityAni = QPropertyAnimation(self.opacityEffect, b'opacity', self)
|
|
self.setGraphicsEffect(self.opacityEffect)
|
|
self.opacityEffect.setOpacity(0)
|
|
|
|
def fadeIn(self):
|
|
self.opacityAni.setEndValue(1)
|
|
self.opacityAni.setDuration(150)
|
|
self.opacityAni.start()
|
|
|
|
def fadeOut(self):
|
|
self.opacityAni.setEndValue(0)
|
|
self.opacityAni.setDuration(150)
|
|
self.opacityAni.start()
|
|
|
|
def paintEvent(self, e):
|
|
painter = QPainter(self)
|
|
painter.setRenderHints(QPainter.Antialiasing)
|
|
painter.setPen(Qt.NoPen)
|
|
|
|
if not isDarkTheme():
|
|
painter.setBrush(QColor(252, 252, 252, 217))
|
|
else:
|
|
painter.setBrush(QColor(44, 44, 44, 245))
|
|
|
|
painter.drawRoundedRect(self.rect(), 6, 6)
|
|
|
|
|
|
class ScrollBarHandle(QWidget):
|
|
""" Scroll bar handle """
|
|
|
|
def __init__(self, orient: Qt.Orientation, parent=None):
|
|
super().__init__(parent)
|
|
self.orient = orient
|
|
if orient == Qt.Vertical:
|
|
self.setFixedWidth(3)
|
|
else:
|
|
self.setFixedHeight(3)
|
|
|
|
def paintEvent(self, e):
|
|
painter = QPainter(self)
|
|
painter.setRenderHints(QPainter.Antialiasing)
|
|
painter.setPen(Qt.NoPen)
|
|
|
|
r = self.width() / 2 if self.orient == Qt.Vertical else self.height() / 2
|
|
c = QColor(255, 255, 255, 139) if isDarkTheme() else QColor(0, 0, 0, 114)
|
|
painter.setBrush(c)
|
|
painter.drawRoundedRect(self.rect(), r, r)
|
|
|
|
|
|
class ScrollBar(QWidget):
|
|
""" Fluent scroll bar """
|
|
|
|
rangeChanged = Signal(tuple)
|
|
valueChanged = Signal(int)
|
|
sliderPressed = Signal()
|
|
sliderReleased = Signal()
|
|
sliderMoved = Signal()
|
|
|
|
def __init__(self, orient: Qt.Orientation, parent: QAbstractScrollArea):
|
|
super().__init__(parent)
|
|
self.groove = ScrollBarGroove(orient, self)
|
|
self.handle = ScrollBarHandle(orient, self)
|
|
self.timer = QTimer(self)
|
|
|
|
self._orientation = orient
|
|
self._singleStep = 1
|
|
self._pageStep = 50
|
|
self._padding = 14
|
|
|
|
self._minimum = 0
|
|
self._maximum = 0
|
|
self._value = 0
|
|
|
|
self._isPressed = False
|
|
self._isEnter = False
|
|
self._isExpanded = False
|
|
self._pressedPos = QPoint()
|
|
self._isForceHidden = False
|
|
|
|
if orient == Qt.Vertical:
|
|
self.partnerBar = parent.verticalScrollBar()
|
|
QAbstractScrollArea.setVerticalScrollBarPolicy(parent, Qt.ScrollBarAlwaysOff)
|
|
else:
|
|
self.partnerBar = parent.horizontalScrollBar()
|
|
QAbstractScrollArea.setHorizontalScrollBarPolicy(parent, Qt.ScrollBarAlwaysOff)
|
|
|
|
self.__initWidget(parent)
|
|
|
|
def __initWidget(self, parent):
|
|
self.groove.upButton.clicked.connect(self._onPageUp)
|
|
self.groove.downButton.clicked.connect(self._onPageDown)
|
|
self.groove.opacityAni.valueChanged.connect(self._onOpacityAniValueChanged)
|
|
|
|
self.partnerBar.rangeChanged.connect(self.setRange)
|
|
self.partnerBar.valueChanged.connect(self._onValueChanged)
|
|
self.valueChanged.connect(self.partnerBar.setValue)
|
|
|
|
parent.installEventFilter(self)
|
|
|
|
self.setRange(self.partnerBar.minimum(), self.partnerBar.maximum())
|
|
self.setVisible(self.maximum() > 0 and not self._isForceHidden)
|
|
self._adjustPos(self.parent().size())
|
|
|
|
def _onPageUp(self):
|
|
self.setValue(self.value() - self.pageStep())
|
|
|
|
def _onPageDown(self):
|
|
self.setValue(self.value() + self.pageStep())
|
|
|
|
def _onValueChanged(self, value):
|
|
self.val = value
|
|
|
|
def value(self):
|
|
return self._value
|
|
|
|
@Property(int, notify=valueChanged)
|
|
def val(self):
|
|
return self._value
|
|
|
|
@val.setter
|
|
def val(self, value: int):
|
|
if value == self.value():
|
|
return
|
|
|
|
value = max(self.minimum(), min(value, self.maximum()))
|
|
self._value = value
|
|
self.valueChanged.emit(value)
|
|
|
|
# adjust the position of handle
|
|
self._adjustHandlePos()
|
|
|
|
def minimum(self):
|
|
return self._minimum
|
|
|
|
def maximum(self):
|
|
return self._maximum
|
|
|
|
def orientation(self):
|
|
return self._orientation
|
|
|
|
def pageStep(self):
|
|
return self._pageStep
|
|
|
|
def singleStep(self):
|
|
return self._singleStep
|
|
|
|
def isSliderDown(self):
|
|
return self._isPressed
|
|
|
|
def setValue(self, value: int):
|
|
self.val = value
|
|
|
|
def setMinimum(self, min: int):
|
|
if min == self.minimum():
|
|
return
|
|
|
|
self._minimum = min
|
|
self.rangeChanged.emit((min, self.maximum()))
|
|
|
|
def setMaximum(self, max: int):
|
|
if max == self.maximum():
|
|
return
|
|
|
|
self._maximum = max
|
|
self.rangeChanged.emit((self.minimum(), max))
|
|
|
|
def setRange(self, min: int, max: int):
|
|
if min > max or (min == self.minimum() and max == self.maximum()):
|
|
return
|
|
|
|
self.setMinimum(min)
|
|
self.setMaximum(max)
|
|
|
|
self._adjustHandleSize()
|
|
self._adjustHandlePos()
|
|
self.setVisible(max > 0 and not self._isForceHidden)
|
|
|
|
self.rangeChanged.emit((min, max))
|
|
|
|
def setPageStep(self, step: int):
|
|
if step >= 1:
|
|
self._pageStep = step
|
|
|
|
def setSingleStep(self, step: int):
|
|
if step >= 1:
|
|
self._singleStep = step
|
|
|
|
def setSliderDown(self, isDown: bool):
|
|
self._isPressed = True
|
|
if isDown:
|
|
self.sliderPressed.emit()
|
|
else:
|
|
self.sliderReleased.emit()
|
|
|
|
def expand(self):
|
|
""" expand scroll bar """
|
|
if self._isExpanded or not self.isEnter:
|
|
return
|
|
|
|
self._isExpanded = True
|
|
self.groove.fadeIn()
|
|
|
|
def collapse(self):
|
|
""" collapse scroll bar """
|
|
if not self._isExpanded or self.isEnter:
|
|
return
|
|
|
|
self._isExpanded = False
|
|
self.groove.fadeOut()
|
|
|
|
def enterEvent(self, e):
|
|
self.isEnter = True
|
|
self.timer.stop()
|
|
self.timer.singleShot(200, self.expand)
|
|
|
|
def leaveEvent(self, e):
|
|
self.isEnter = False
|
|
self.timer.stop()
|
|
self.timer.singleShot(200, self.collapse)
|
|
|
|
def eventFilter(self, obj, e: QEvent):
|
|
if obj is not self.parent():
|
|
return super().eventFilter(obj, e)
|
|
|
|
# adjust the position of slider
|
|
if e.type() == QEvent.Resize:
|
|
self._adjustPos(e.size())
|
|
|
|
return super().eventFilter(obj, e)
|
|
|
|
def resizeEvent(self, e):
|
|
self.groove.resize(self.size())
|
|
|
|
def mousePressEvent(self, e: QMouseEvent):
|
|
super().mousePressEvent(e)
|
|
self._isPressed = True
|
|
self._pressedPos = e.pos()
|
|
|
|
if self.childAt(e.pos()) is self.handle or not self._isSlideResion(e.pos()):
|
|
return
|
|
|
|
if self.orientation() == Qt.Vertical:
|
|
if e.pos().y() > self.handle.geometry().bottom():
|
|
value = e.pos().y() - self.handle.height() - self._padding
|
|
else:
|
|
value = e.pos().y() - self._padding
|
|
else:
|
|
if e.pos().x() > self.handle.geometry().right():
|
|
value = e.pos().x() - self.handle.width() - self._padding
|
|
else:
|
|
value = e.pos().x() - self._padding
|
|
|
|
self.setValue(int(value / self._slideLength() * self.maximum()))
|
|
self.sliderPressed.emit()
|
|
|
|
def mouseReleaseEvent(self, e):
|
|
super().mouseReleaseEvent(e)
|
|
self._isPressed = False
|
|
self.sliderReleased.emit()
|
|
|
|
def mouseMoveEvent(self, e: QMouseEvent):
|
|
if self.orientation() == Qt.Vertical:
|
|
dv = e.pos().y() - self._pressedPos.y()
|
|
else:
|
|
dv = e.pos().x() - self._pressedPos.x()
|
|
|
|
# don't use `self.setValue()`, because it could be reimplemented
|
|
dv = dv / self._slideLength() * (self.maximum() - self.minimum())
|
|
ScrollBar.setValue(self, self.value() + dv)
|
|
|
|
self._pressedPos = e.pos()
|
|
self.sliderMoved.emit()
|
|
|
|
def _adjustPos(self, size):
|
|
if self.orientation() == Qt.Vertical:
|
|
self.resize(12, size.height() - 2)
|
|
self.move(size.width() - 13, 1)
|
|
else:
|
|
self.resize(size.width() - 2, 12)
|
|
self.move(1, size.height() - 13)
|
|
|
|
def _adjustHandleSize(self):
|
|
p = self.parent()
|
|
if self.orientation() == Qt.Vertical:
|
|
total = self.maximum() - self.minimum() + p.height()
|
|
s = int(self._grooveLength() * p.height() / max(total, 1))
|
|
self.handle.setFixedHeight(max(40, s))
|
|
else:
|
|
total = self.maximum() - self.minimum() + p.width()
|
|
s = int(self._grooveLength() * p.width() / max(total, 1))
|
|
self.handle.setFixedWidth(max(40, s))
|
|
|
|
def _adjustHandlePos(self):
|
|
total = max(self.maximum() - self.minimum(), 1)
|
|
delta = int(self.value() / total * self._slideLength())
|
|
|
|
if self.orientation() == Qt.Vertical:
|
|
x = self.width() - self.handle.width() - 3
|
|
self.handle.move(x, self._padding + delta)
|
|
else:
|
|
y = self.height() - self.handle.height() - 3
|
|
self.handle.move(self._padding + delta, y)
|
|
|
|
def _grooveLength(self):
|
|
if self.orientation() == Qt.Vertical:
|
|
return self.height() - 2 * self._padding
|
|
|
|
return self.width() - 2 * self._padding
|
|
|
|
def _slideLength(self):
|
|
if self.orientation() == Qt.Vertical:
|
|
return self._grooveLength() - self.handle.height()
|
|
|
|
return self._grooveLength() - self.handle.width()
|
|
|
|
def _isSlideResion(self, pos: QPoint):
|
|
if self.orientation() == Qt.Vertical:
|
|
return self._padding <= pos.y() <= self.height() - self._padding
|
|
|
|
return self._padding <= pos.x() <= self.width() - self._padding
|
|
|
|
def _onOpacityAniValueChanged(self):
|
|
opacity = self.groove.opacityEffect.opacity()
|
|
if self.orientation() == Qt.Vertical:
|
|
self.handle.setFixedWidth(int(3 + opacity * 3))
|
|
else:
|
|
self.handle.setFixedHeight(int(3 + opacity * 3))
|
|
|
|
self._adjustHandlePos()
|
|
|
|
def setForceHidden(self, isHidden: bool):
|
|
""" whether to force the scrollbar to be hidden """
|
|
self._isForceHidden = isHidden
|
|
self.setVisible(self.maximum() > 0 and not isHidden)
|
|
|
|
def wheelEvent(self, e):
|
|
QApplication.sendEvent(self.parent().viewport(), e)
|
|
|
|
|
|
class SmoothScrollBar(ScrollBar):
|
|
""" Smooth scroll bar """
|
|
|
|
def __init__(self, orient: Qt.Orientation, parent):
|
|
super().__init__(orient, parent)
|
|
self.duration = 500
|
|
self.ani = QPropertyAnimation()
|
|
self.ani.setTargetObject(self)
|
|
self.ani.setPropertyName(b"val")
|
|
self.ani.setEasingCurve(QEasingCurve.OutCubic)
|
|
self.ani.setDuration(self.duration)
|
|
|
|
self.__value = self.value()
|
|
|
|
def setValue(self, value):
|
|
if value == self.value():
|
|
return
|
|
|
|
# stop running animation
|
|
self.ani.stop()
|
|
|
|
# adjust the duration
|
|
dv = abs(value - self.value())
|
|
if dv < 50:
|
|
self.ani.setDuration(int(self.duration * dv / 70))
|
|
else:
|
|
self.ani.setDuration(self.duration)
|
|
|
|
self.ani.setStartValue(self.value())
|
|
self.ani.setEndValue(value)
|
|
self.ani.start()
|
|
|
|
def scrollValue(self, value):
|
|
""" scroll the specified distance """
|
|
self.__value += value
|
|
self.__value = max(self.minimum(), self.__value)
|
|
self.__value = min(self.maximum(), self.__value)
|
|
self.setValue(self.__value)
|
|
|
|
def scrollTo(self, value):
|
|
""" scroll to the specified position """
|
|
self.__value = value
|
|
self.__value = max(self.minimum(), self.__value)
|
|
self.__value = min(self.maximum(), self.__value)
|
|
self.setValue(self.__value)
|
|
|
|
def resetValue(self, value):
|
|
self.__value = value
|
|
|
|
def mousePressEvent(self, e):
|
|
self.ani.stop()
|
|
super().mousePressEvent(e)
|
|
self.__value = self.value()
|
|
|
|
def mouseMoveEvent(self, e):
|
|
self.ani.stop()
|
|
super().mouseMoveEvent(e)
|
|
self.__value = self.value()
|
|
|
|
def setScrollAnimation(self, duration, easing=QEasingCurve.OutCubic):
|
|
""" set scroll animation
|
|
|
|
Parameters
|
|
----------
|
|
duration: int
|
|
scroll duration
|
|
|
|
easing: QEasingCurve
|
|
animation type
|
|
"""
|
|
self.duration = duration
|
|
self.ani.setDuration(duration)
|
|
self.ani.setEasingCurve(easing)
|
|
|
|
|
|
class SmoothScrollDelegate(QObject):
|
|
""" Smooth scroll delegate """
|
|
|
|
def __init__(self, parent: QAbstractScrollArea, useAni=False):
|
|
"""
|
|
Parameters
|
|
----------
|
|
parent: QAbstractScrollArea
|
|
the scrolling area being delegated
|
|
|
|
useAni: bool
|
|
whether to use `QPropertyAnimation` to achieve smooth scrolling
|
|
"""
|
|
super().__init__(parent)
|
|
self.useAni = useAni
|
|
self.vScrollBar = SmoothScrollBar(Qt.Vertical, parent)
|
|
self.hScrollBar = SmoothScrollBar(Qt.Horizontal, parent)
|
|
self.verticalSmoothScroll = SmoothScroll(parent, Qt.Vertical)
|
|
self.horizonSmoothScroll = SmoothScroll(parent, Qt.Horizontal)
|
|
|
|
if isinstance(parent, QAbstractItemView):
|
|
parent.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
|
|
parent.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
|
|
if isinstance(parent, QListView):
|
|
parent.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
|
|
parent.horizontalScrollBar().setStyleSheet("QScrollBar:horizontal{height: 0px}")
|
|
|
|
parent.viewport().installEventFilter(self)
|
|
parent.setVerticalScrollBarPolicy = self.setVerticalScrollBarPolicy
|
|
parent.setHorizontalScrollBarPolicy = self.setHorizontalScrollBarPolicy
|
|
|
|
def eventFilter(self, obj, e: QEvent):
|
|
if e.type() == QEvent.Wheel:
|
|
if e.angleDelta().y() != 0:
|
|
if not self.useAni:
|
|
self.verticalSmoothScroll.wheelEvent(e)
|
|
else:
|
|
self.vScrollBar.scrollValue(-e.angleDelta().y())
|
|
else:
|
|
if not self.useAni:
|
|
self.horizonSmoothScroll.wheelEvent(e)
|
|
else:
|
|
self.hScrollBar.scrollValue(-e.angleDelta().x())
|
|
|
|
e.setAccepted(True)
|
|
return True
|
|
|
|
return super().eventFilter(obj, e)
|
|
|
|
def setVerticalScrollBarPolicy(self, policy):
|
|
QAbstractScrollArea.setVerticalScrollBarPolicy(self.parent(), Qt.ScrollBarAlwaysOff)
|
|
self.vScrollBar.setForceHidden(policy == Qt.ScrollBarAlwaysOff)
|
|
|
|
def setHorizontalScrollBarPolicy(self, policy):
|
|
QAbstractScrollArea.setHorizontalScrollBarPolicy(self.parent(), Qt.ScrollBarAlwaysOff)
|
|
self.hScrollBar.setForceHidden(policy == Qt.ScrollBarAlwaysOff)
|
|
|