# coding:utf-8 from enum import Enum from typing import Union import weakref from PySide6.QtCore import (Qt, QEvent, QSize, QRectF, QObject, QPropertyAnimation, QEasingCurve, QTimer, Signal, QParallelAnimationGroup, QPoint) from PySide6.QtGui import QPainter, QIcon, QColor from PySide6.QtWidgets import (QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout, QToolButton, QGraphicsOpacityEffect) from ...common.auto_wrap import TextWrap from ...common.style_sheet import FluentStyleSheet, themeColor from ...common.icon import FluentIconBase, Theme, isDarkTheme, writeSvg, drawSvgIcon, drawIcon from ...common.icon import FluentIcon as FIF from .button import TransparentToolButton class InfoBarIcon(FluentIconBase, Enum): """ Info bar icon """ INFORMATION = "Info" SUCCESS = "Success" WARNING = "Warning" ERROR = "Error" def path(self, theme=Theme.AUTO): if theme == Theme.AUTO: color = "dark" if isDarkTheme() else "light" else: color = theme.value.lower() return f':/qfluentwidgets/images/info_bar/{self.value}_{color}.svg' class InfoBarPosition(Enum): """ Info bar position """ TOP = 0 BOTTOM = 1 TOP_LEFT = 2 TOP_RIGHT = 3 BOTTOM_LEFT = 4 BOTTOM_RIGHT = 5 NONE = 6 class InfoIconWidget(QWidget): """ Icon widget """ def __init__(self, icon: InfoBarIcon, parent=None): super().__init__(parent=parent) self.setFixedSize(36, 36) self.icon = icon def paintEvent(self, e): painter = QPainter(self) painter.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform) rect = QRectF(10, 10, 15, 15) if self.icon != InfoBarIcon.INFORMATION: drawIcon(self.icon, painter, rect) else: drawIcon(self.icon, painter, rect, indexes=[0], fill=themeColor().name()) class InfoBar(QFrame): """ Information bar """ closedSignal = Signal() def __init__(self, icon: Union[InfoBarIcon, FluentIconBase, QIcon, str], title: str, content: str, orient=Qt.Horizontal, isClosable=True, duration=1000, position=InfoBarPosition.TOP_RIGHT, parent=None): """ Parameters ---------- icon: InfoBarIcon | FluentIconBase | QIcon | str the icon of info bar title: str the title of info bar content: str the content of info bar orient: Qt.Orientation the layout direction of info bar, use `Qt.Horizontal` for short content isClosable: bool whether to show the close button duraction: int the time for info bar to display in milliseconds. If duration is less than zero, info bar will never disappear. parent: QWidget parent widget """ super().__init__(parent=parent) self.title = title self.content = content self.orient = orient self.icon = icon self.duration = duration self.isClosable = isClosable self.position = position self.titleLabel = QLabel(self) self.contentLabel = QLabel(self) self.closeButton = TransparentToolButton(FIF.CLOSE, self) self.iconWidget = InfoIconWidget(icon) self.hBoxLayout = QHBoxLayout(self) self.textLayout = QHBoxLayout() if self.orient == Qt.Horizontal else QVBoxLayout() self.widgetLayout = QHBoxLayout() if self.orient == Qt.Horizontal else QVBoxLayout() self.opacityEffect = QGraphicsOpacityEffect(self) self.opacityAni = QPropertyAnimation( self.opacityEffect, b'opacity', self) self.lightBackgroundColor = None self.darkBackgroundColor = None self.__initWidget() def __initWidget(self): self.opacityEffect.setOpacity(1) self.setGraphicsEffect(self.opacityEffect) self.closeButton.setFixedSize(36, 36) self.closeButton.setIconSize(QSize(12, 12)) self.closeButton.setCursor(Qt.PointingHandCursor) self.closeButton.setVisible(self.isClosable) self.__setQss() self.__initLayout() self.closeButton.clicked.connect(self.close) def __initLayout(self): self.hBoxLayout.setContentsMargins(6, 6, 6, 6) self.hBoxLayout.setSizeConstraint(QVBoxLayout.SetMinimumSize) self.textLayout.setSizeConstraint(QHBoxLayout.SetMinimumSize) self.textLayout.setAlignment(Qt.AlignTop) self.textLayout.setContentsMargins(1, 8, 0, 8) self.hBoxLayout.setSpacing(0) self.textLayout.setSpacing(5) # add icon to layout self.hBoxLayout.addWidget(self.iconWidget, 0, Qt.AlignTop | Qt.AlignLeft) # add title to layout self.textLayout.addWidget(self.titleLabel, 1, Qt.AlignTop) self.titleLabel.setVisible(bool(self.title)) # add content label to layout if self.orient == Qt.Horizontal: self.textLayout.addSpacing(7) self.textLayout.addWidget(self.contentLabel, 1, Qt.AlignTop) self.contentLabel.setVisible(bool(self.content)) self.hBoxLayout.addLayout(self.textLayout) # add widget layout if self.orient == Qt.Horizontal: self.hBoxLayout.addLayout(self.widgetLayout) self.widgetLayout.setSpacing(10) else: self.textLayout.addLayout(self.widgetLayout) # add close button to layout self.hBoxLayout.addSpacing(12) self.hBoxLayout.addWidget(self.closeButton, 0, Qt.AlignTop | Qt.AlignLeft) self._adjustText() def __setQss(self): self.titleLabel.setObjectName('titleLabel') self.contentLabel.setObjectName('contentLabel') if isinstance(self.icon, Enum): self.setProperty('type', self.icon.value) FluentStyleSheet.INFO_BAR.apply(self) def __fadeOut(self): """ fade out """ self.opacityAni.setDuration(200) self.opacityAni.setStartValue(1) self.opacityAni.setEndValue(0) self.opacityAni.finished.connect(self.close) self.opacityAni.start() def _adjustText(self): w = 900 if not self.parent() else (self.parent().width() - 50) # adjust title chars = max(min(w / 10, 120), 30) self.titleLabel.setText(TextWrap.wrap(self.title, chars, False)[0]) # adjust content chars = max(min(w / 9, 120), 30) self.contentLabel.setText(TextWrap.wrap(self.content, chars, False)[0]) self.adjustSize() def addWidget(self, widget: QWidget, stretch=0): """ add widget to info bar """ self.widgetLayout.addSpacing(6) align = Qt.AlignTop if self.orient == Qt.Vertical else Qt.AlignVCenter self.widgetLayout.addWidget(widget, stretch, Qt.AlignLeft | align) def setCustomBackgroundColor(self, light, dark): """ set the custom background color Parameters ---------- light, dark: str | Qt.GlobalColor | QColor background color in light/dark theme mode """ self.lightBackgroundColor = QColor(light) self.darkBackgroundColor = QColor(dark) self.update() def eventFilter(self, obj, e: QEvent): if obj is self.parent(): if e.type() in [QEvent.Resize, QEvent.WindowStateChange]: self._adjustText() return super().eventFilter(obj, e) def closeEvent(self, e): self.closedSignal.emit() self.deleteLater() def showEvent(self, e): self._adjustText() super().showEvent(e) if self.duration >= 0: QTimer.singleShot(self.duration, self.__fadeOut) if self.position != InfoBarPosition.NONE: manager = InfoBarManager.make(self.position) manager.add(self) if self.parent(): self.parent().installEventFilter(self) def paintEvent(self, e): super().paintEvent(e) if self.lightBackgroundColor is None: return painter = QPainter(self) painter.setRenderHints(QPainter.Antialiasing) painter.setPen(Qt.NoPen) if isDarkTheme(): painter.setBrush(self.darkBackgroundColor) else: painter.setBrush(self.lightBackgroundColor) rect = self.rect().adjusted(1, 1, -1, -1) painter.drawRoundedRect(rect, 6, 6) @classmethod def new(cls, icon, title, content, orient=Qt.Horizontal, isClosable=True, duration=1000, position=InfoBarPosition.TOP_RIGHT, parent=None): w = InfoBar(icon, title, content, orient, isClosable, duration, position, parent) w.show() return w @classmethod def info(cls, title, content, orient=Qt.Horizontal, isClosable=True, duration=1000, position=InfoBarPosition.TOP_RIGHT, parent=None): return cls.new(InfoBarIcon.INFORMATION, title, content, orient, isClosable, duration, position, parent) @classmethod def success(cls, title, content, orient=Qt.Horizontal, isClosable=True, duration=1000, position=InfoBarPosition.TOP_RIGHT, parent=None): return cls.new(InfoBarIcon.SUCCESS, title, content, orient, isClosable, duration, position, parent) @classmethod def warning(cls, title, content, orient=Qt.Horizontal, isClosable=True, duration=1000, position=InfoBarPosition.TOP_RIGHT, parent=None): return cls.new(InfoBarIcon.WARNING, title, content, orient, isClosable, duration, position, parent) @classmethod def error(cls, title, content, orient=Qt.Horizontal, isClosable=True, duration=1000, position=InfoBarPosition.TOP_RIGHT, parent=None): return cls.new(InfoBarIcon.ERROR, title, content, orient, isClosable, duration, position, parent) class InfoBarManager(QObject): """ Info bar manager """ _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super(InfoBarManager, cls).__new__( cls, *args, **kwargs) cls._instance.__initialized = False return cls._instance def __init__(self): if self.__initialized: return super().__init__() self.spacing = 16 self.margin = 24 self.infoBars = weakref.WeakKeyDictionary() self.aniGroups = weakref.WeakKeyDictionary() self.slideAnis = [] self.dropAnis = [] self.__initialized = True def add(self, infoBar: InfoBar): """ add info bar """ p = infoBar.parent() # type:QWidget if not p: return if p not in self.infoBars: p.installEventFilter(self) self.infoBars[p] = [] self.aniGroups[p] = QParallelAnimationGroup(self) if infoBar in self.infoBars[p]: return # add drop animation if self.infoBars[p]: dropAni = QPropertyAnimation(infoBar, b'pos') dropAni.setDuration(200) self.aniGroups[p].addAnimation(dropAni) self.dropAnis.append(dropAni) infoBar.setProperty('dropAni', dropAni) # add slide animation self.infoBars[p].append(infoBar) slideAni = self._createSlideAni(infoBar) self.slideAnis.append(slideAni) infoBar.setProperty('slideAni', slideAni) infoBar.closedSignal.connect(lambda: self.remove(infoBar)) slideAni.start() def remove(self, infoBar: InfoBar): """ remove info bar """ p = infoBar.parent() if p not in self.infoBars: return if infoBar not in self.infoBars[p]: return self.infoBars[p].remove(infoBar) # remove drop animation dropAni = infoBar.property('dropAni') # type: QPropertyAnimation if dropAni: self.aniGroups[p].removeAnimation(dropAni) self.dropAnis.remove(dropAni) # remove slider animation slideAni = infoBar.property('slideAni') if slideAni: self.slideAnis.remove(slideAni) # adjust the position of the remaining info bars self._updateDropAni(p) self.aniGroups[p].start() def _createSlideAni(self, infoBar: InfoBar): slideAni = QPropertyAnimation(infoBar, b'pos') slideAni.setEasingCurve(QEasingCurve.OutQuad) slideAni.setDuration(200) slideAni.setStartValue(self._slideStartPos(infoBar)) slideAni.setEndValue(self._pos(infoBar)) return slideAni def _updateDropAni(self, parent): for bar in self.infoBars[parent]: ani = bar.property('dropAni') if not ani: continue ani.setStartValue(bar.pos()) ani.setEndValue(self._pos(bar)) def _pos(self, infoBar: InfoBar, parentSize=None) -> QPoint: raise NotImplementedError def _slideStartPos(self, infoBar: InfoBar) -> QPoint: raise NotImplementedError def eventFilter(self, obj, e: QEvent): if obj not in self.infoBars: return False if e.type() in [QEvent.Resize, QEvent.WindowStateChange]: size = e.size() if e.type() == QEvent.Resize else None for bar in self.infoBars[obj]: bar.move(self._pos(bar, size)) return super().eventFilter(obj, e) @staticmethod def make(position: InfoBarPosition): """ mask info bar manager according to the display position """ managers = { InfoBarPosition.TOP: TopInfoBarManager, InfoBarPosition.BOTTOM: BottomInfoBarManager, InfoBarPosition.TOP_RIGHT: TopRightInfoBarManager, InfoBarPosition.BOTTOM_RIGHT: BottomRightInfoBarManager, InfoBarPosition.TOP_LEFT: TopLeftInfoBarManager, InfoBarPosition.BOTTOM_LEFT: BottomLeftInfoBarManager, } if position not in managers: raise ValueError(f'`{position}` is an invalid info bar position.') return managers[position]() class TopInfoBarManager(InfoBarManager): """ Top position info bar manager """ def _pos(self, infoBar: InfoBar, parentSize=None): p = infoBar.parent() parentSize = parentSize or p.size() x = (infoBar.parent().width() - infoBar.width()) // 2 y = self.margin index = self.infoBars[p].index(infoBar) for bar in self.infoBars[p][0:index]: y += (bar.height() + self.spacing) return QPoint(x, y) def _slideStartPos(self, infoBar: InfoBar): pos = self._pos(infoBar) return QPoint(pos.x(), pos.y() - 16) class TopRightInfoBarManager(InfoBarManager): """ Top right position info bar manager """ def _pos(self, infoBar: InfoBar, parentSize=None): p = infoBar.parent() parentSize = parentSize or p.size() x = parentSize.width() - infoBar.width() - self.margin y = self.margin index = self.infoBars[p].index(infoBar) for bar in self.infoBars[p][0:index]: y += (bar.height() + self.spacing) return QPoint(x, y) def _slideStartPos(self, infoBar: InfoBar): return QPoint(infoBar.parent().width(), self._pos(infoBar).y()) class BottomRightInfoBarManager(InfoBarManager): """ Bottom right position info bar manager """ def _pos(self, infoBar: InfoBar, parentSize=None) -> QPoint: p = infoBar.parent() parentSize = parentSize or p.size() x = parentSize.width() - infoBar.width() - self.margin y = parentSize.height() - infoBar.height() - self.margin index = self.infoBars[p].index(infoBar) for bar in self.infoBars[p][0:index]: y -= (bar.height() + self.spacing) return QPoint(x, y) def _slideStartPos(self, infoBar: InfoBar): return QPoint(infoBar.parent().width(), self._pos(infoBar).y()) class TopLeftInfoBarManager(InfoBarManager): """ Top left position info bar manager """ def _pos(self, infoBar: InfoBar, parentSize=None) -> QPoint: p = infoBar.parent() parentSize = parentSize or p.size() y = self.margin index = self.infoBars[p].index(infoBar) for bar in self.infoBars[p][0:index]: y += (bar.height() + self.spacing) return QPoint(self.margin, y) def _slideStartPos(self, infoBar: InfoBar): return QPoint(-infoBar.width(), self._pos(infoBar).y()) class BottomLeftInfoBarManager(InfoBarManager): """ Bottom left position info bar manager """ def _pos(self, infoBar: InfoBar, parentSize: QSize = None) -> QPoint: p = infoBar.parent() parentSize = parentSize or p.size() y = parentSize.height() - infoBar.height() - self.margin index = self.infoBars[p].index(infoBar) for bar in self.infoBars[p][0:index]: y -= (bar.height() + self.spacing) return QPoint(self.margin, y) def _slideStartPos(self, infoBar: InfoBar): return QPoint(-infoBar.width(), self._pos(infoBar).y()) class BottomInfoBarManager(InfoBarManager): """ Bottom position info bar manager """ def _pos(self, infoBar: InfoBar, parentSize: QSize = None) -> QPoint: p = infoBar.parent() parentSize = parentSize or p.size() x = (parentSize.width() - infoBar.width()) // 2 y = parentSize.height() - infoBar.height() - self.margin index = self.infoBars[p].index(infoBar) for bar in self.infoBars[p][0:index]: y -= (bar.height() + self.spacing) return QPoint(x, y) def _slideStartPos(self, infoBar: InfoBar): pos = self._pos(infoBar) return QPoint(pos.x(), pos.y() + 16)