pythoncustomfluentfluent-designfluentuiguimodernpyqt5pyqt6pyside2pyside6qtqt5qt6softwareuiwidgetswin11winuiwinui3
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
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)
|
|
|