# 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)