Files
2025-08-14 18:45:16 +08:00

658 lines
22 KiB
Python

# coding:utf-8
from enum import Enum
from typing import Union
from PySide6.QtCore import Qt, QPoint, QObject, QPointF, QTimer, QPropertyAnimation, QEvent
from PySide6.QtGui import QPainter, QColor, QPainterPath, QIcon, QCursor, QPolygonF, QPixmap, QImage
from PySide6.QtWidgets import QWidget, QHBoxLayout, QApplication, QGraphicsDropShadowEffect
from ...common.icon import FluentIconBase
from ...common.screen import getCurrentScreenGeometry
from ...common.style_sheet import isDarkTheme
from .flyout import FlyoutView, FlyoutViewBase
class TeachingTipTailPosition(Enum):
""" Teaching tip tail position """
TOP = 0
BOTTOM = 1
LEFT = 2
RIGHT = 3
TOP_LEFT = 4
TOP_RIGHT = 5
BOTTOM_LEFT = 6
BOTTOM_RIGHT = 7
LEFT_TOP = 8
LEFT_BOTTOM = 9
RIGHT_TOP = 10
RIGHT_BOTTOM = 11
NONE = 12
class ImagePosition(Enum):
TOP = 0
BOTTOM = 1
LEFT = 2
RIGHT = 3
class TeachingTipView(FlyoutView):
""" Teaching tip view """
def __init__(self, title: str, content: str, icon: Union[FluentIconBase, QIcon, str] = None,
image: Union[str, QPixmap, QImage] = None, isClosable=True, tailPosition=TeachingTipTailPosition.BOTTOM,
parent=None):
self.manager = TeachingTipManager.make(tailPosition)
self.hBoxLayout = QHBoxLayout()
self.hBoxLayout.setContentsMargins(0, 0, 0, 0)
super().__init__(title, content, icon, image, isClosable, parent)
def _adjustImage(self):
if self.manager.imagePosition() in [ImagePosition.TOP, ImagePosition.BOTTOM]:
return super()._adjustImage()
h = self.vBoxLayout.sizeHint().height() - 2
self.imageLabel.scaledToHeight(h)
def _addImageToLayout(self):
self.imageLabel.setHidden(self.imageLabel.isNull())
pos = self.manager.imagePosition()
if pos == ImagePosition.TOP:
self.imageLabel.setBorderRadius(8, 8, 0, 0)
self.vBoxLayout.insertWidget(0, self.imageLabel)
elif pos == ImagePosition.BOTTOM:
self.imageLabel.setBorderRadius(0, 0, 8, 8)
self.vBoxLayout.addWidget(self.imageLabel)
elif pos == ImagePosition.LEFT:
self.vBoxLayout.removeItem(self.vBoxLayout.itemAt(0))
self.hBoxLayout.addLayout(self.viewLayout)
self.vBoxLayout.addLayout(self.hBoxLayout)
self.imageLabel.setBorderRadius(8, 0, 8, 0)
self.hBoxLayout.insertWidget(0, self.imageLabel)
elif pos == ImagePosition.RIGHT:
self.vBoxLayout.removeItem(self.vBoxLayout.itemAt(0))
self.hBoxLayout.addLayout(self.viewLayout)
self.vBoxLayout.addLayout(self.hBoxLayout)
self.imageLabel.setBorderRadius(0, 8, 0, 8)
self.hBoxLayout.addWidget(self.imageLabel)
def paintEvent(self, e):
pass
class TeachTipBubble(QWidget):
""" Teaching tip bubble """
def __init__(self, view: FlyoutViewBase, tailPosition=TeachingTipTailPosition.BOTTOM, parent=None):
super().__init__(parent=parent)
self.manager = TeachingTipManager.make(tailPosition)
self.hBoxLayout = QHBoxLayout(self)
self.view = view
self.manager.doLayout(self)
self.hBoxLayout.addWidget(self.view)
def setView(self, view: QWidget):
self.hBoxLayout.removeWidget(self.view)
self.view.deleteLater()
self.view = view
self.hBoxLayout.addWidget(view)
def paintEvent(self, e):
painter = QPainter(self)
painter.setRenderHints(QPainter.Antialiasing)
painter.setBrush(
QColor(40, 40, 40) if isDarkTheme() else QColor(248, 248, 248))
painter.setPen(
QColor(23, 23, 23) if isDarkTheme() else QColor(0, 0, 0, 17))
self.manager.draw(self, painter)
class TeachingTip(QWidget):
""" Teaching tip """
def __init__(self, view: FlyoutViewBase, target: QWidget, duration=1000,
tailPosition=TeachingTipTailPosition.BOTTOM, parent=None, isDeleteOnClose=True):
"""
Parameters
----------
target: QWidget
the target widget to show tip
view: FlyoutViewBase
teaching tip view
duration: int
the time for teaching tip to display in milliseconds. If duration is less than zero,
teaching tip will never disappear.
tailPosition: TeachingTipTailPosition
the position of bubble tail
parent: QWidget
parent widget
isDeleteOnClose: bool
whether delete flyout automatically when flyout is closed
"""
super().__init__(parent=parent)
self.target = target
self.duration = duration
self.isDeleteOnClose = isDeleteOnClose
self.manager = TeachingTipManager.make(tailPosition)
self.hBoxLayout = QHBoxLayout(self)
self.opacityAni = QPropertyAnimation(self, b'windowOpacity', self)
self.bubble = TeachTipBubble(view, tailPosition, self)
self.hBoxLayout.setContentsMargins(15, 8, 15, 20)
self.hBoxLayout.addWidget(self.bubble)
self.setShadowEffect()
# set style
self.setAttribute(Qt.WA_TranslucentBackground)
self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint)
if parent and parent.window():
parent.window().installEventFilter(self)
def setShadowEffect(self, blurRadius=35, offset=(0, 8)):
""" add shadow to dialog """
color = QColor(0, 0, 0, 80 if isDarkTheme() else 30)
self.shadowEffect = QGraphicsDropShadowEffect(self.bubble)
self.shadowEffect.setBlurRadius(blurRadius)
self.shadowEffect.setOffset(*offset)
self.shadowEffect.setColor(color)
self.bubble.setGraphicsEffect(None)
self.bubble.setGraphicsEffect(self.shadowEffect)
def _fadeOut(self):
""" fade out """
self.opacityAni.setDuration(167)
self.opacityAni.setStartValue(1)
self.opacityAni.setEndValue(0)
self.opacityAni.finished.connect(self.close)
self.opacityAni.start()
def showEvent(self, e):
if self.duration >= 0:
QTimer.singleShot(self.duration, self._fadeOut)
self.move(self.manager.position(self))
self.adjustSize()
self.opacityAni.setDuration(167)
self.opacityAni.setStartValue(0)
self.opacityAni.setEndValue(1)
self.opacityAni.start()
super().showEvent(e)
def closeEvent(self, e):
if self.isDeleteOnClose:
self.deleteLater()
super().closeEvent(e)
def eventFilter(self, obj, e: QEvent):
if self.parent() and obj is self.parent().window():
if e.type() in [QEvent.Resize, QEvent.WindowStateChange, QEvent.Move]:
self.move(self.manager.position(self))
return super().eventFilter(obj, e)
def addWidget(self, widget: QWidget, stretch=0, align=Qt.AlignLeft):
""" add widget to teaching tip """
self.view.addSpacing(8)
self.view.addWidget(widget, stretch, align)
@property
def view(self):
return self.bubble.view
def setView(self, view):
self.bubble.setView(view)
@classmethod
def make(cls, view: FlyoutViewBase, target: QWidget, duration=1000, tailPosition=TeachingTipTailPosition.BOTTOM,
parent=None, isDeleteOnClose=True):
"""
Parameters
----------
view: FlyoutViewBase
teaching tip view
target: QWidget
the target widget to show tip
duration: int
the time for teaching tip to display in milliseconds. If duration is less than zero,
teaching tip will never disappear.
tailPosition: TeachingTipTailPosition
the position of bubble tail
parent: QWidget
parent widget
isDeleteOnClose: bool
whether delete flyout automatically when flyout is closed
"""
w = cls(view, target, duration, tailPosition, parent, isDeleteOnClose)
w.show()
return w
@classmethod
def create(cls, target: QWidget, title: str, content: str, icon: Union[FluentIconBase, QIcon, str] = None,
image: Union[str, QPixmap, QImage] = None, isClosable=True, duration=1000,
tailPosition=TeachingTipTailPosition.BOTTOM, parent=None, isDeleteOnClose=True):
"""
Parameters
----------
target: QWidget
the target widget to show tip
title: str
the title of teaching tip
content: str
the content of teaching tip
icon: InfoBarIcon | FluentIconBase | QIcon | str
the icon of teaching tip
image: str | QPixmap | QImage
the image of teaching tip
isClosable: bool
whether to show the close button
duraction: int
the time for teaching tip to display in milliseconds. If duration is less than zero,
teaching tip will never disappear.
parent: QWidget
parent widget
isDeleteOnClose: bool
whether delete flyout automatically when flyout is closed
"""
view = TeachingTipView(title, content, icon, image, isClosable, tailPosition)
w = cls.make(view, target, duration, tailPosition, parent, isDeleteOnClose)
view.closed.connect(w.close)
return w
class PopupTeachingTip(TeachingTip):
""" Pop up teaching tip """
def __init__(self, view: FlyoutViewBase, target: QWidget, duration=1000,
tailPosition=TeachingTipTailPosition.BOTTOM, parent=None, isDeleteOnClose=True):
super().__init__(view, target, duration, tailPosition, parent, isDeleteOnClose)
self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)
class TeachingTipManager(QObject):
""" Teaching tip manager """
def __init__(self):
super().__init__()
def doLayout(self, tip: TeachTipBubble):
""" manage the layout of tip """
tip.hBoxLayout.setContentsMargins(0, 0, 0, 0)
def imagePosition(self):
return ImagePosition.TOP
def position(self, tip: TeachingTip) -> QPoint:
pos = self._pos(tip)
x, y = pos.x(), pos.y()
rect = getCurrentScreenGeometry()
x = max(rect.left(), min(pos.x(), rect.right() - tip.width() - 4))
y = max(rect.top(), min(pos.y(), rect.bottom() - tip.height() - 4))
return QPoint(x, y)
def draw(self, tip: TeachTipBubble, painter: QPainter):
""" draw the shape of bubble """
rect = tip.rect().adjusted(1, 1, -1, -1)
painter.drawRoundedRect(rect, 8, 8)
def _pos(self, tip: TeachingTip):
""" return the poisition of tip """
return tip.pos()
@staticmethod
def make(position: TeachingTipTailPosition):
""" mask teaching tip manager according to the display position """
managers = {
TeachingTipTailPosition.TOP: TopTailTeachingTipManager,
TeachingTipTailPosition.BOTTOM: BottomTailTeachingTipManager,
TeachingTipTailPosition.LEFT: LeftTailTeachingTipManager,
TeachingTipTailPosition.RIGHT: RightTailTeachingTipManager,
TeachingTipTailPosition.TOP_RIGHT: TopRightTailTeachingTipManager,
TeachingTipTailPosition.BOTTOM_RIGHT: BottomRightTailTeachingTipManager,
TeachingTipTailPosition.TOP_LEFT: TopLeftTailTeachingTipManager,
TeachingTipTailPosition.BOTTOM_LEFT: BottomLeftTailTeachingTipManager,
TeachingTipTailPosition.LEFT_TOP: LeftTopTailTeachingTipManager,
TeachingTipTailPosition.LEFT_BOTTOM: LeftBottomTailTeachingTipManager,
TeachingTipTailPosition.RIGHT_TOP: RightTopTailTeachingTipManager,
TeachingTipTailPosition.RIGHT_BOTTOM: RightBottomTailTeachingTipManager,
TeachingTipTailPosition.NONE: TeachingTipManager,
}
if position not in managers:
raise ValueError(
f'`{position}` is an invalid teaching tip position.')
return managers[position]()
class TopTailTeachingTipManager(TeachingTipManager):
""" Top tail teaching tip manager """
def doLayout(self, tip):
tip.hBoxLayout.setContentsMargins(0, 8, 0, 0)
def imagePosition(self):
return ImagePosition.BOTTOM
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pt = tip.hBoxLayout.contentsMargins().top()
path = QPainterPath()
path.addRoundedRect(1, pt, w - 2, h - pt - 1, 8, 8)
path.addPolygon(
QPolygonF([QPointF(w/2 - 7, pt), QPointF(w/2, 1), QPointF(w/2 + 7, pt)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
pos = target.mapToGlobal(QPoint(0, target.height()))
x = pos.x() + target.width()//2 - tip.sizeHint().width()//2
y = pos.y() - tip.layout().contentsMargins().top()
return QPoint(x, y)
class BottomTailTeachingTipManager(TeachingTipManager):
""" Bottom tail teaching tip manager """
def doLayout(self, tip):
tip.hBoxLayout.setContentsMargins(0, 0, 0, 8)
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pb = tip.hBoxLayout.contentsMargins().bottom()
path = QPainterPath()
path.addRoundedRect(1, 1, w - 2, h - pb - 1, 8, 8)
path.addPolygon(
QPolygonF([QPointF(w/2 - 7, h - pb), QPointF(w/2, h - 1), QPointF(w/2 + 7, h - pb)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
pos = target.mapToGlobal(QPoint())
x = pos.x() + target.width()//2 - tip.sizeHint().width()//2
y = pos.y() - tip.sizeHint().height() + tip.layout().contentsMargins().bottom()
return QPoint(x, y)
class LeftTailTeachingTipManager(TeachingTipManager):
""" Left tail teaching tip manager """
def doLayout(self, tip):
tip.hBoxLayout.setContentsMargins(8, 0, 0, 0)
def imagePosition(self):
return ImagePosition.RIGHT
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pl = 8
path = QPainterPath()
path.addRoundedRect(pl, 1, w - pl - 2, h - 2, 8, 8)
path.addPolygon(
QPolygonF([QPointF(pl, h/2 - 7), QPointF(1, h/2), QPointF(pl, h/2 + 7)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
m = tip.layout().contentsMargins()
pos = target.mapToGlobal(QPoint(target.width(), 0))
x = pos.x() - m.left()
y = pos.y() - tip.view.sizeHint().height()//2 + target.height()//2 - m.top()
return QPoint(x, y)
class RightTailTeachingTipManager(TeachingTipManager):
""" Left tail teaching tip manager """
def doLayout(self, tip):
tip.hBoxLayout.setContentsMargins(0, 0, 8, 0)
def imagePosition(self):
return ImagePosition.LEFT
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pr = 8
path = QPainterPath()
path.addRoundedRect(1, 1, w - pr - 1, h - 2, 8, 8)
path.addPolygon(
QPolygonF([QPointF(w - pr, h/2 - 7), QPointF(w - 1, h/2), QPointF(w - pr, h/2 + 7)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
m = tip.layout().contentsMargins()
pos = target.mapToGlobal(QPoint(0, 0))
x = pos.x() - tip.sizeHint().width() + m.right()
y = pos.y() - tip.view.sizeHint().height()//2 + target.height()//2 - m.top()
return QPoint(x, y)
class TopLeftTailTeachingTipManager(TopTailTeachingTipManager):
""" Top left tail teaching tip manager """
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pt = tip.hBoxLayout.contentsMargins().top()
path = QPainterPath()
path.addRoundedRect(1, pt, w - 2, h - pt - 1, 8, 8)
path.addPolygon(
QPolygonF([QPointF(20, pt), QPointF(27, 1), QPointF(34, pt)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
pos = target.mapToGlobal(QPoint(0, target.height()))
x = pos.x() - tip.layout().contentsMargins().left()
y = pos.y() - tip.layout().contentsMargins().top()
return QPoint(x, y)
class TopRightTailTeachingTipManager(TopTailTeachingTipManager):
""" Top right tail teaching tip manager """
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pt = tip.hBoxLayout.contentsMargins().top()
path = QPainterPath()
path.addRoundedRect(1, pt, w - 2, h - pt - 1, 8, 8)
path.addPolygon(
QPolygonF([QPointF(w - 20, pt), QPointF(w - 27, 1), QPointF(w - 34, pt)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
pos = target.mapToGlobal(QPoint(target.width(), target.height()))
x = pos.x() - tip.sizeHint().width() + tip.layout().contentsMargins().left()
y = pos.y() - tip.layout().contentsMargins().top()
return QPoint(x, y)
class BottomLeftTailTeachingTipManager(BottomTailTeachingTipManager):
""" Bottom left tail teaching tip manager """
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pb = tip.hBoxLayout.contentsMargins().bottom()
path = QPainterPath()
path.addRoundedRect(1, 1, w - 2, h - pb - 1, 8, 8)
path.addPolygon(
QPolygonF([QPointF(20, h - pb), QPointF(27, h - 1), QPointF(34, h - pb)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
pos = target.mapToGlobal(QPoint())
x = pos.x() - tip.layout().contentsMargins().left()
y = pos.y() - tip.sizeHint().height() + tip.layout().contentsMargins().bottom()
return QPoint(x, y)
class BottomRightTailTeachingTipManager(BottomTailTeachingTipManager):
""" Bottom right tail teaching tip manager """
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pb = tip.hBoxLayout.contentsMargins().bottom()
path = QPainterPath()
path.addRoundedRect(1, 1, w - 2, h - pb - 1, 8, 8)
path.addPolygon(
QPolygonF([QPointF(w - 20, h - pb), QPointF(w - 27, h - 1), QPointF(w - 34, h - pb)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
pos = target.mapToGlobal(QPoint(target.width(), 0))
x = pos.x() - tip.sizeHint().width() + tip.layout().contentsMargins().left()
y = pos.y() - tip.sizeHint().height() + tip.layout().contentsMargins().bottom()
return QPoint(x, y)
class LeftTopTailTeachingTipManager(LeftTailTeachingTipManager):
""" Left top tail teaching tip manager """
def imagePosition(self):
return ImagePosition.BOTTOM
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pl = 8
path = QPainterPath()
path.addRoundedRect(pl, 1, w - pl - 2, h - 2, 8, 8)
path.addPolygon(
QPolygonF([QPointF(pl, 10), QPointF(1, 17), QPointF(pl, 24)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
m = tip.layout().contentsMargins()
pos = target.mapToGlobal(QPoint(target.width(), 0))
x = pos.x() - m.left()
y = pos.y() - m.top()
return QPoint(x, y)
class LeftBottomTailTeachingTipManager(LeftTailTeachingTipManager):
""" Left bottom tail teaching tip manager """
def imagePosition(self):
return ImagePosition.TOP
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pl = 9
path = QPainterPath()
path.addRoundedRect(pl, 1, w - pl - 1, h - 2, 8, 8)
path.addPolygon(
QPolygonF([QPointF(pl, h - 10), QPointF(1, h - 17), QPointF(pl, h - 24)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
m = tip.layout().contentsMargins()
pos = target.mapToGlobal(QPoint(target.width(), target.height()))
x = pos.x() - m.left()
y = pos.y() - tip.sizeHint().height() + m.bottom()
return QPoint(x, y)
class RightTopTailTeachingTipManager(RightTailTeachingTipManager):
""" Right top tail teaching tip manager """
def imagePosition(self):
return ImagePosition.BOTTOM
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pr = 8
path = QPainterPath()
path.addRoundedRect(1, 1, w - pr - 1, h - 2, 8, 8)
path.addPolygon(
QPolygonF([QPointF(w - pr, 10), QPointF(w - 1, 17), QPointF(w - pr, 24)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
m = tip.layout().contentsMargins()
pos = target.mapToGlobal(QPoint(0, 0))
x = pos.x() - tip.sizeHint().width() + m.right()
y = pos.y() - m.top()
return QPoint(x, y)
class RightBottomTailTeachingTipManager(RightTailTeachingTipManager):
""" Right bottom tail teaching tip manager """
def imagePosition(self):
return ImagePosition.TOP
def draw(self, tip, painter):
w, h = tip.width(), tip.height()
pr = 8
path = QPainterPath()
path.addRoundedRect(1, 1, w - pr - 1, h - 2, 8, 8)
path.addPolygon(
QPolygonF([QPointF(w - pr, h-10), QPointF(w - 1, h-17), QPointF(w - pr, h-24)]))
painter.drawPath(path.simplified())
def _pos(self, tip: TeachingTip):
target = tip.target
m = tip.layout().contentsMargins()
pos = target.mapToGlobal(QPoint(0, target.height()))
x = pos.x() - tip.sizeHint().width() + m.right()
y = pos.y() - tip.sizeHint().height() + m.bottom()
return QPoint(x, y)