A fluent design widgets library based on C++ Qt/PyQt/PySide. Make Qt Great Again.
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.

430 lines
13 KiB

# coding:utf-8
from typing import List, Union
from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex, QSize, pyqtProperty, QRectF, QPropertyAnimation, QSizeF
from PyQt5.QtGui import QPixmap, QPainter, QColor, QImage, QWheelEvent, QPainterPath, QImageReader
from PyQt5.QtWidgets import QStyleOptionViewItem, QListWidget, QStyledItemDelegate, QListWidgetItem
from ...common.overload import singledispatchmethod
from ...common.style_sheet import isDarkTheme, FluentStyleSheet
from ...common.icon import drawIcon, FluentIcon
from .scroll_bar import SmoothScrollBar
from .button import ToolButton
class ScrollButton(ToolButton):
""" Scroll button """
def _postInit(self):
self._opacity = 0
self.opacityAni = QPropertyAnimation(self, b'opacity', self)
self.opacityAni.setDuration(150)
@pyqtProperty(float)
def opacity(self):
return self._opacity
@opacity.setter
def opacity(self, o: float):
self._opacity = o
self.update()
def isTransparent(self):
return self.opacity == 0
def fadeIn(self):
self.opacityAni.setStartValue(self.opacity)
self.opacityAni.setEndValue(1)
self.opacityAni.start()
def fadeOut(self):
self.opacityAni.setStartValue(self.opacity)
self.opacityAni.setEndValue(0)
self.opacityAni.start()
def paintEvent(self, e):
painter = QPainter(self)
painter.setRenderHints(QPainter.Antialiasing)
painter.setPen(Qt.NoPen)
painter.setOpacity(self.opacity)
# draw background
if not isDarkTheme():
painter.setBrush(QColor(252, 252, 252, 217))
else:
painter.setBrush(QColor(44, 44, 44, 245))
painter.drawRoundedRect(self.rect(), 4, 4)
# draw icon
if isDarkTheme():
color = QColor(255, 255, 255)
opacity = 0.773 if self.isHover or self.isPressed else 0.541
else:
color = QColor(0, 0, 0)
opacity = 0.616 if self.isHover or self.isPressed else 0.45
painter.setOpacity(self.opacity * opacity)
s = 6 if self.isPressed else 8
w, h = self.width(), self.height()
x, y = (w - s) / 2, (h - s) / 2
drawIcon(self._icon, painter, QRectF(x, y, s, s), fill=color.name())
class FlipImageDelegate(QStyledItemDelegate):
""" Flip view image delegate """
def __init__(self, parent=None):
super().__init__(parent)
self.borderRadius = 0
def itemSize(self, index: int):
p = self.parent() # type: FlipView
return p.item(index).sizeHint()
def setBorderRadius(self, radius: int):
self.borderRadius = radius
self.parent().viewport().update()
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):
painter.save()
painter.setRenderHints(QPainter.Antialiasing)
painter.setPen(Qt.NoPen)
size = self.itemSize(index.row()) # type: QSize
p = self.parent() # type: FlipView
# draw image
r = p.devicePixelRatioF()
image = index.data(Qt.UserRole) # type: QImage
if image is None:
return painter.restore()
# lazy load image
if image.isNull() and index.data(Qt.ItemDataRole.DisplayRole):
image.load(index.data(Qt.ItemDataRole.DisplayRole))
index.model().setData(index, image, Qt.ItemDataRole.UserRole)
x = option.rect.x() + int((option.rect.width() - size.width()) / 2)
y = option.rect.y() + int((option.rect.height() - size.height()) / 2)
rect = QRectF(x, y, size.width(), size.height())
# clipped path
path = QPainterPath()
path.addRoundedRect(rect, self.borderRadius, self.borderRadius)
subPath = QPainterPath()
subPath.addRoundedRect(QRectF(p.rect()), self.borderRadius, self.borderRadius)
path = path.intersected(subPath)
image = image.scaled(size * r, p.aspectRatioMode, Qt.SmoothTransformation)
painter.setClipPath(path)
# center crop image
if p.aspectRatioMode == Qt.AspectRatioMode.KeepAspectRatioByExpanding:
iw, ih = image.width(), image.height()
size = QSizeF(size) * r
x, y = (iw - size.width()) / 2, (ih - size.height()) / 2
image = image.copy(int(x), int(y), int(size.width()), int(size.height()))
painter.drawImage(rect, image)
painter.restore()
class FlipView(QListWidget):
""" Flip view
Constructors
------------
* FlipView(`parent`: QWidget = None)
* FlipView(`orient`: Qt.Orientation, `parent`: QWidget = None)
"""
currentIndexChanged = pyqtSignal(int)
@singledispatchmethod
def __init__(self, parent=None):
super().__init__(parent=parent)
self.orientation = Qt.Horizontal
self._postInit()
@__init__.register
def _(self, orientation: Qt.Orientation, parent=None):
super().__init__(parent=parent)
self.orientation = orientation
self._postInit()
def _postInit(self):
self.isHover = False
self._currentIndex = -1
self._aspectRatioMode = Qt.AspectRatioMode.IgnoreAspectRatio
self._itemSize = QSize(480, 270) # 16:9
self.delegate = FlipImageDelegate(self)
self.scrollBar = SmoothScrollBar(self.orientation, self)
self.scrollBar.setScrollAnimation(500)
self.scrollBar.setForceHidden(True)
# self.setUniformItemSizes(True)
self.setMinimumSize(self.itemSize)
self.setItemDelegate(self.delegate)
self.setMovement(QListWidget.Static)
self.setVerticalScrollMode(self.ScrollPerPixel)
self.setHorizontalScrollMode(self.ScrollPerPixel)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
FluentStyleSheet.FLIP_VIEW.apply(self)
if self.isHorizontal():
self.setFlow(QListWidget.LeftToRight)
self.preButton = ScrollButton(FluentIcon.CARE_LEFT_SOLID, self)
self.nextButton = ScrollButton(FluentIcon.CARE_RIGHT_SOLID, self)
self.preButton.setFixedSize(16, 38)
self.nextButton.setFixedSize(16, 38)
else:
self.preButton = ScrollButton(FluentIcon.CARE_UP_SOLID, self)
self.nextButton = ScrollButton(FluentIcon.CARE_DOWN_SOLID, self)
self.preButton.setFixedSize(38, 16)
self.nextButton.setFixedSize(38, 16)
# connect signal to slot
self.preButton.clicked.connect(self.scrollPrevious)
self.nextButton.clicked.connect(self.scrollNext)
def isHorizontal(self):
return self.orientation == Qt.Horizontal
def setItemSize(self, size: QSize):
""" set the size of item """
if size == self.itemSize:
return
self._itemSize = size
for i in range(self.count()):
self._adjustItemSize(self.item(i))
self.viewport().update()
def getItemSize(self):
""" get the size of item """
return self._itemSize
def setBorderRadius(self, radius: int):
""" set the border radius of item """
self.delegate.setBorderRadius(radius)
def getBorderRadius(self):
return self.delegate.borderRadius
def scrollPrevious(self):
""" scroll to previous item """
self.setCurrentIndex(self.currentIndex() - 1)
def scrollNext(self):
""" scroll to next item """
self.setCurrentIndex(self.currentIndex() + 1)
def setCurrentIndex(self, index: int):
""" set current index """
if not 0 <= index < self.count() or index == self.currentIndex():
return
self.scrollToIndex(index)
# update the visibility of scroll button
if index == 0:
self.preButton.fadeOut()
elif self.preButton.isTransparent() and self.isHover:
self.preButton.fadeIn()
if index == self.count() - 1:
self.nextButton.fadeOut()
elif self.nextButton.isTransparent() and self.isHover:
self.nextButton.fadeIn()
# fire signal
self.currentIndexChanged.emit(index)
def scrollToIndex(self, index):
if not 0 <= index < self.count():
return
self._currentIndex = index
if self.isHorizontal():
value = sum(self.item(i).sizeHint().width() for i in range(index))
else:
value = sum(self.item(i).sizeHint().height() for i in range(index))
value += (2 * index + 1) * self.spacing()
self.scrollBar.scrollTo(value)
def currentIndex(self):
return self._currentIndex
def image(self, index: int):
if not 0 <= index < self.count():
return QImage()
return self.item(index).data(Qt.UserRole)
def addImage(self, image: Union[QImage, QPixmap, str]):
""" add image """
self.addImages([image])
def addImages(self, images: List[Union[QImage, QPixmap, str]], targetSize: QSize = None):
""" add images """
if not images:
return
N = self.count()
self.addItems([''] * len(images))
for i in range(N, self.count()):
self.setItemImage(i, images[i - N], targetSize=targetSize)
if self.currentIndex() < 0:
self._currentIndex = 0
def setItemImage(self, index: int, image: Union[QImage, QPixmap, str], targetSize: QSize = None):
""" set the image of specified item """
if not 0 <= index < self.count():
return
item = self.item(index)
# convert image to QImage
if isinstance(image, QPixmap):
image = image.toImage()
# lazy load
if isinstance(image, QImage):
item.setData(Qt.ItemDataRole.UserRole, image)
else:
item.setData(Qt.ItemDataRole.UserRole, QImage())
item.setData(Qt.ItemDataRole.DisplayRole, image)
self._adjustItemSize(item)
def _adjustItemSize(self, item: QListWidgetItem):
image = self.itemImage(self.row(item), load=False)
if not image.isNull():
size = image.size()
else:
imagePath = item.data(Qt.ItemDataRole.DisplayRole) or ""
size = QImageReader(imagePath).size().expandedTo(QSize(1, 1))
if self.aspectRatioMode == Qt.AspectRatioMode.KeepAspectRatio:
if self.isHorizontal():
h = self.itemSize.height()
w = int(size.width() * h / size.height())
else:
w = self.itemSize.width()
h = int(size.height() * w / size.width())
else:
w, h = self.itemSize.width(), self.itemSize.height()
item.setSizeHint(QSize(w, h))
def itemImage(self, index: int, load=True) -> QImage:
""" get the image of specified item
Parameters
----------
index: int
the index of image
load: bool
whether to load image data
"""
if not 0 <= index < self.count():
return
item = self.item(index)
image = item.data(Qt.ItemDataRole.UserRole) # type: QImage
if image is None:
return QImage()
imagePath = item.data(Qt.ItemDataRole.DisplayRole)
if image.isNull() and imagePath and load:
image.load(imagePath)
return image
def resizeEvent(self, e):
w, h = self.width(), self.height()
bw, bh = self.preButton.width(), self.preButton.height()
if self.isHorizontal():
self.preButton.move(2, int(h / 2 - bh / 2))
self.nextButton.move(w - bw - 2, int(h / 2 - bh / 2))
else:
self.preButton.move(int(w / 2 - bw / 2), 2)
self.nextButton.move(int(w / 2 - bw / 2), h - bh - 2)
def enterEvent(self, e):
super().enterEvent(e)
self.isHover = True
if self.currentIndex() > 0:
self.preButton.fadeIn()
if self.currentIndex() < self.count() - 1:
self.nextButton.fadeIn()
def leaveEvent(self, e):
super().leaveEvent(e)
self.isHover = False
self.preButton.fadeOut()
self.nextButton.fadeOut()
def showEvent(self, e):
self.scrollBar.duration = 0
self.scrollToIndex(self.currentIndex())
self.scrollBar.duration = 500
def wheelEvent(self, e: QWheelEvent):
e.setAccepted(True)
if self.scrollBar.ani.state() == QPropertyAnimation.Running:
return
if e.angleDelta().y() < 0:
self.scrollNext()
else:
self.scrollPrevious()
def getAspectRatioMode(self):
return self._aspectRatioMode
def setAspectRatioMode(self, mode: Qt.AspectRatioMode):
if mode == self.aspectRatioMode:
return
self._aspectRatioMode = mode
for i in range(self.count()):
self._adjustItemSize(self.item(i))
self.viewport().update()
itemSize = pyqtProperty(QSize, getItemSize, setItemSize)
borderRadius = pyqtProperty(int, getBorderRadius, setBorderRadius)
aspectRatioMode = pyqtProperty(Qt.AspectRatioMode, getAspectRatioMode, setAspectRatioMode)
class HorizontalFlipView(FlipView):
""" Horizontal flip view """
def __init__(self, parent=None):
super().__init__(Qt.Horizontal, parent)
class VerticalFlipView(FlipView):
""" Vertical flip view """
def __init__(self, parent=None):
super().__init__(Qt.Vertical, parent)