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

659 lines
23 KiB
Python

# coding:utf-8
from enum import Enum
from typing import Dict, Union
from PySide6.QtCore import Qt, QPropertyAnimation, QRect, QSize, QEvent, QEasingCurve, Signal, QPoint
from PySide6.QtGui import QResizeEvent, QIcon, QColor, QPainterPath
from PySide6.QtWidgets import QWidget, QVBoxLayout, QFrame, QApplication, QHBoxLayout
from .navigation_widget import (NavigationTreeWidgetBase, NavigationToolButton, NavigationWidget, NavigationSeparator,
NavigationTreeWidget, NavigationFlyoutMenu)
from ..widgets.acrylic_label import AcrylicBrush
from ..widgets.scroll_area import ScrollArea
from ..widgets.tool_tip import ToolTipFilter
from ..widgets.scroll_bar import ScrollBarHandleDisplayMode
from ..widgets.flyout import Flyout, FlyoutAnimationType, FlyoutViewBase, SlideRightFlyoutAnimationManager
from ..material.acrylic_flyout import AcrylicFlyout, AcrylicFlyoutViewBase
from ...common.router import qrouter
from ...common.style_sheet import FluentStyleSheet, isDarkTheme
from ...common.icon import FluentIconBase
from ...common.icon import FluentIcon as FIF
class NavigationDisplayMode(Enum):
""" Navigation display mode """
MINIMAL = 0
COMPACT = 1
EXPAND = 2
MENU = 3
class NavigationItemPosition(Enum):
""" Navigation item position """
TOP = 0
SCROLL = 1
BOTTOM = 2
class NavigationToolTipFilter(ToolTipFilter):
""" Navigation tool tip filter """
def _canShowToolTip(self) -> bool:
isVisible = super()._canShowToolTip()
parent = self.parent() # type: NavigationWidget
return isVisible and parent.isCompacted
class RouteKeyError(Exception):
""" Route key error """
class NavigationItem:
""" Navigation item """
def __init__(self, routeKey: str, parentRouteKey: str, widget: NavigationWidget):
self.routeKey = routeKey
self.parentRouteKey = parentRouteKey
self.widget = widget
class NavigationPanel(QFrame):
""" Navigation panel """
displayModeChanged = Signal(NavigationDisplayMode)
def __init__(self, parent=None, isMinimalEnabled=False):
super().__init__(parent=parent)
self._parent = parent # type: QWidget
self._isMenuButtonVisible = True
self._isReturnButtonVisible = False
self._isCollapsible = True
self._isAcrylicEnabled = False
self.acrylicBrush = AcrylicBrush(self, 30)
self.scrollArea = ScrollArea(self)
self.scrollWidget = QWidget()
self.menuButton = NavigationToolButton(FIF.MENU, self)
self.returnButton = NavigationToolButton(FIF.RETURN, self)
self.vBoxLayout = NavigationItemLayout(self)
self.topLayout = NavigationItemLayout()
self.bottomLayout = NavigationItemLayout()
self.scrollLayout = NavigationItemLayout(self.scrollWidget)
self.items = {} # type: Dict[str, NavigationItem]
self.history = qrouter
self.expandAni = QPropertyAnimation(self, b'geometry', self)
self.expandWidth = 322
self.minimumExpandWidth = 1008
self.isMinimalEnabled = isMinimalEnabled
if isMinimalEnabled:
self.displayMode = NavigationDisplayMode.MINIMAL
else:
self.displayMode = NavigationDisplayMode.COMPACT
self.__initWidget()
def __initWidget(self):
self.resize(48, self.height())
self.setAttribute(Qt.WA_StyledBackground)
self.window().installEventFilter(self)
self.returnButton.hide()
self.returnButton.setDisabled(True)
self.scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scrollArea.horizontalScrollBar().setEnabled(False)
self.scrollArea.setWidget(self.scrollWidget)
self.scrollArea.setWidgetResizable(True)
self.scrollArea.scrollDelagate.vScrollBar.setHandleDisplayMode(ScrollBarHandleDisplayMode.ON_HOVER)
self.expandAni.setEasingCurve(QEasingCurve.OutQuad)
self.expandAni.setDuration(150)
self.menuButton.clicked.connect(self.toggle)
self.expandAni.finished.connect(self._onExpandAniFinished)
self.history.emptyChanged.connect(self.returnButton.setDisabled)
self.returnButton.clicked.connect(self.history.pop)
# add tool tip
self.returnButton.installEventFilter(ToolTipFilter(self.returnButton, 1000))
self.returnButton.setToolTip(self.tr('Back'))
self.menuButton.installEventFilter(ToolTipFilter(self.menuButton, 1000))
self.menuButton.setToolTip(self.tr('Open Navigation'))
self.scrollWidget.setObjectName('scrollWidget')
self.setProperty('menu', False)
FluentStyleSheet.NAVIGATION_INTERFACE.apply(self)
FluentStyleSheet.NAVIGATION_INTERFACE.apply(self.scrollWidget)
self.__initLayout()
def __initLayout(self):
self.vBoxLayout.setContentsMargins(0, 5, 0, 5)
self.topLayout.setContentsMargins(4, 0, 4, 0)
self.bottomLayout.setContentsMargins(4, 0, 4, 0)
self.scrollLayout.setContentsMargins(4, 0, 4, 0)
self.vBoxLayout.setSpacing(4)
self.topLayout.setSpacing(4)
self.bottomLayout.setSpacing(4)
self.scrollLayout.setSpacing(4)
self.vBoxLayout.addLayout(self.topLayout, 0)
self.vBoxLayout.addWidget(self.scrollArea, 1)
self.vBoxLayout.addLayout(self.bottomLayout, 0)
self.vBoxLayout.setAlignment(Qt.AlignTop)
self.topLayout.setAlignment(Qt.AlignTop)
self.scrollLayout.setAlignment(Qt.AlignTop)
self.bottomLayout.setAlignment(Qt.AlignBottom)
self.topLayout.addWidget(self.returnButton, 0, Qt.AlignTop)
self.topLayout.addWidget(self.menuButton, 0, Qt.AlignTop)
def _updateAcrylicColor(self):
if isDarkTheme():
tintColor = QColor(32, 32, 32, 200)
luminosityColor = QColor(0, 0, 0, 0)
else:
tintColor = QColor(255, 255, 255, 180)
luminosityColor = QColor(255, 255, 255, 0)
self.acrylicBrush.tintColor = tintColor
self.acrylicBrush.luminosityColor = luminosityColor
def widget(self, routeKey: str):
if routeKey not in self.items:
raise RouteKeyError(f"`{routeKey}` is illegal.")
return self.items[routeKey].widget
def addItem(self, routeKey: str, icon: Union[str, QIcon, FluentIconBase], text: str, onClick=None, selectable=True,
position=NavigationItemPosition.TOP, tooltip: str = None, parentRouteKey: str = None):
""" add navigation item
Parameters
----------
routeKey: str
the unique name of item
icon: str | QIcon | FluentIconBase
the icon of navigation item
text: str
the text of navigation item
onClick: callable
the slot connected to item clicked signal
position: NavigationItemPosition
where the button is added
selectable: bool
whether the item is selectable
tooltip: str
the tooltip of item
parentRouteKey: str
the route key of parent item, the parent widget should be `NavigationTreeWidget`
"""
return self.insertItem(-1, routeKey, icon, text, onClick, selectable, position, tooltip, parentRouteKey)
def addWidget(self, routeKey: str, widget: NavigationWidget, onClick=None, position=NavigationItemPosition.TOP,
tooltip: str = None, parentRouteKey: str = None):
""" add custom widget
Parameters
----------
routeKey: str
the unique name of item
widget: NavigationWidget
the custom widget to be added
onClick: callable
the slot connected to item clicked signal
position: NavigationItemPosition
where the button is added
tooltip: str
the tooltip of widget
parentRouteKey: str
the route key of parent item, the parent item should be `NavigationTreeWidget`
"""
self.insertWidget(-1, routeKey, widget, onClick, position, tooltip, parentRouteKey)
def insertItem(self, index: int, routeKey: str, icon: Union[str, QIcon, FluentIconBase], text: str, onClick=None,
selectable=True, position=NavigationItemPosition.TOP, tooltip: str = None, parentRouteKey=None):
""" insert navigation tree item
Parameters
----------
index: int
the insert position of parent widget
routeKey: str
the unique name of item
icon: str | QIcon | FluentIconBase
the icon of navigation item
text: str
the text of navigation item
onClick: callable
the slot connected to item clicked signal
position: NavigationItemPosition
where the button is added
selectable: bool
whether the item is selectable
tooltip: str
the tooltip of item
parentRouteKey: str
the route key of parent item, the parent item should be `NavigationTreeWidget`
"""
if routeKey in self.items:
return
w = NavigationTreeWidget(icon, text, selectable, self)
self.insertWidget(index, routeKey, w, onClick, position, tooltip, parentRouteKey)
return w
def insertWidget(self, index: int, routeKey: str, widget: NavigationWidget, onClick=None,
position=NavigationItemPosition.TOP, tooltip: str = None, parentRouteKey: str = None):
""" insert custom widget
Parameters
----------
index: int
insert position
routeKey: str
the unique name of item
widget: NavigationWidget
the custom widget to be added
onClick: callable
the slot connected to item clicked signal
position: NavigationItemPosition
where the button is added
tooltip: str
the tooltip of widget
parentRouteKey: str
the route key of parent item, the parent item should be `NavigationTreeWidget`
"""
if routeKey in self.items:
return
self._registerWidget(routeKey, parentRouteKey, widget, onClick, tooltip)
if parentRouteKey:
self.widget(parentRouteKey).insertChild(index, widget)
else:
self._insertWidgetToLayout(index, widget, position)
def addSeparator(self, position=NavigationItemPosition.TOP):
""" add separator
Parameters
----------
position: NavigationPostion
where to add the separator
"""
self.insertSeparator(-1, position)
def insertSeparator(self, index: int, position=NavigationItemPosition.TOP):
""" add separator
Parameters
----------
index: int
insert position
position: NavigationPostion
where to add the separator
"""
separator = NavigationSeparator(self)
self._insertWidgetToLayout(index, separator, position)
def _registerWidget(self, routeKey: str, parentRouteKey: str, widget: NavigationWidget, onClick, tooltip: str):
""" register widget """
widget.clicked.connect(self._onWidgetClicked)
if onClick is not None:
widget.clicked.connect(onClick)
widget.setProperty('routeKey', routeKey)
widget.setProperty('parentRouteKey', parentRouteKey)
self.items[routeKey] = NavigationItem(routeKey, parentRouteKey, widget)
if self.displayMode in [NavigationDisplayMode.EXPAND, NavigationDisplayMode.MENU]:
widget.setCompacted(False)
if tooltip:
widget.setToolTip(tooltip)
widget.installEventFilter(NavigationToolTipFilter(widget, 1000))
def _insertWidgetToLayout(self, index: int, widget: NavigationWidget, position: NavigationItemPosition):
""" insert widget to layout """
if position == NavigationItemPosition.TOP:
widget.setParent(self)
self.topLayout.insertWidget(index, widget, 0, Qt.AlignTop)
elif position == NavigationItemPosition.SCROLL:
widget.setParent(self.scrollWidget)
self.scrollLayout.insertWidget(index, widget, 0, Qt.AlignTop)
else:
widget.setParent(self)
self.bottomLayout.insertWidget(index, widget, 0, Qt.AlignBottom)
widget.show()
def removeWidget(self, routeKey: str):
""" remove widget
Parameters
----------
routeKey: str
the unique name of item
"""
if routeKey not in self.items:
return
item = self.items.pop(routeKey)
if item.parentRouteKey is not None:
self.widget(item.parentRouteKey).removeChild(item.widget)
if isinstance(item.widget, NavigationTreeWidgetBase):
for child in item.widget.findChildren(NavigationWidget, options=Qt.FindChildrenRecursively):
key = child.property('routeKey')
if key is None:
continue
self.items.pop(key)
child.deleteLater()
self.history.remove(key)
item.widget.deleteLater()
self.history.remove(routeKey)
def setMenuButtonVisible(self, isVisible: bool):
""" set whether the menu button is visible """
self._isMenuButtonVisible = isVisible
self.menuButton.setVisible(isVisible)
def setReturnButtonVisible(self, isVisible: bool):
""" set whether the return button is visible """
self._isReturnButtonVisible = isVisible
self.returnButton.setVisible(isVisible)
def setCollapsible(self, on: bool):
self._isCollapsible = on
if not on and self.displayMode != NavigationDisplayMode.EXPAND:
self.expand(False)
def setExpandWidth(self, width: int):
""" set the maximum width """
if width <= 42:
return
self.expandWidth = width
NavigationWidget.EXPAND_WIDTH = width - 10
def setMinimumExpandWidth(self, width: int):
""" Set the minimum window width that allows panel to be expanded """
self.minimumExpandWidth = width
def setAcrylicEnabled(self, isEnabled: bool):
if isEnabled == self.isAcrylicEnabled():
return
self._isAcrylicEnabled = isEnabled
self.setProperty("transparent", self._canDrawAcrylic())
self.setStyle(QApplication.style())
self.update()
def isAcrylicEnabled(self):
""" whether the acrylic effect is enabled """
return self._isAcrylicEnabled
def expand(self, useAni=True):
""" expand navigation panel """
self._setWidgetCompacted(False)
self.expandAni.setProperty('expand', True)
self.menuButton.setToolTip(self.tr('Close Navigation'))
# determine the display mode according to the width of window
# https://learn.microsoft.com/en-us/windows/apps/design/controls/navigationview#default
expandWidth = self.minimumExpandWidth + self.expandWidth - 322
if (self.window().width() >= expandWidth and not self.isMinimalEnabled) or not self._isCollapsible:
self.displayMode = NavigationDisplayMode.EXPAND
else:
self.setProperty('menu', True)
self.setStyle(QApplication.style())
self.displayMode = NavigationDisplayMode.MENU
# grab acrylic image
if self._canDrawAcrylic():
self.acrylicBrush.grabImage(
QRect(self.mapToGlobal(QPoint()), QSize(self.expandWidth, self.height())))
if not self._parent.isWindow():
pos = self.parent().pos()
self.setParent(self.window())
self.move(pos)
self.show()
if useAni:
self.displayModeChanged.emit(self.displayMode)
self.expandAni.setStartValue(
QRect(self.pos(), QSize(48, self.height())))
self.expandAni.setEndValue(
QRect(self.pos(), QSize(self.expandWidth, self.height())))
self.expandAni.start()
else:
self.resize(self.expandWidth, self.height())
self._onExpandAniFinished()
def collapse(self):
""" collapse navigation panel """
if self.expandAni.state() == QPropertyAnimation.Running:
return
for item in self.items.values():
w = item.widget
if isinstance(w, NavigationTreeWidgetBase) and w.isRoot():
w.setExpanded(False)
self.expandAni.setStartValue(
QRect(self.pos(), QSize(self.width(), self.height())))
self.expandAni.setEndValue(
QRect(self.pos(), QSize(48, self.height())))
self.expandAni.setProperty('expand', False)
self.expandAni.start()
self.menuButton.setToolTip(self.tr('Open Navigation'))
def toggle(self):
""" toggle navigation panel """
if self.displayMode in [NavigationDisplayMode.COMPACT, NavigationDisplayMode.MINIMAL]:
self.expand()
else:
self.collapse()
def setCurrentItem(self, routeKey: str):
""" set current selected item
Parameters
----------
routeKey: str
the unique name of item
"""
if routeKey not in self.items:
return
for k, item in self.items.items():
item.widget.setSelected(k == routeKey)
def _onWidgetClicked(self):
widget = self.sender() # type: NavigationWidget
if not widget.isSelectable:
return self._showFlyoutNavigationMenu(widget)
self.setCurrentItem(widget.property('routeKey'))
isLeaf = not isinstance(widget, NavigationTreeWidgetBase) or widget.isLeaf()
if self.displayMode == NavigationDisplayMode.MENU and isLeaf:
self.collapse()
elif self.isCollapsed():
self._showFlyoutNavigationMenu(widget)
def _showFlyoutNavigationMenu(self, widget: NavigationTreeWidget):
""" show flyout navigation menu """
if not (self.isCollapsed() and isinstance(widget, NavigationTreeWidget)):
return
if not widget.isRoot() or widget.isLeaf():
return
layout = QHBoxLayout()
if self._canDrawAcrylic():
view = AcrylicFlyoutViewBase()
view.setLayout(layout)
flyout = AcrylicFlyout(view, self.window())
else:
view = FlyoutViewBase()
view.setLayout(layout)
flyout = Flyout(view, self.window())
# add navigation menu to flyout
menu = NavigationFlyoutMenu(widget, view)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(menu)
# execuse flyout animation
flyout.resize(flyout.sizeHint())
pos = SlideRightFlyoutAnimationManager(flyout).position(widget)
flyout.exec(pos, FlyoutAnimationType.SLIDE_RIGHT)
menu.expanded.connect(lambda: self._adjustFlyoutMenuSize(flyout, widget, menu))
def _adjustFlyoutMenuSize(self, flyout: Flyout, widget: NavigationTreeWidget, menu: NavigationFlyoutMenu):
flyout.view.setFixedSize(menu.size())
flyout.setFixedSize(flyout.layout().sizeHint())
manager = flyout.aniManager
pos = manager.position(widget)
rect = self.window().geometry()
w, h = flyout.sizeHint().width() + 5, flyout.sizeHint().height()
x = max(rect.left(), min(pos.x(), rect.right() - w))
y = max(rect.top() + 42, min(pos.y() - 4, rect.bottom() - h + 5))
flyout.move(x, y)
def isCollapsed(self):
return self.displayMode == NavigationDisplayMode.COMPACT
def eventFilter(self, obj, e: QEvent):
if obj is not self.window() or not self._isCollapsible:
return super().eventFilter(obj, e)
if e.type() == QEvent.MouseButtonRelease:
if not self.geometry().contains(e.pos()) and self.displayMode == NavigationDisplayMode.MENU:
self.collapse()
elif e.type() == QEvent.Resize:
w = QResizeEvent(e).size().width()
if w < self.minimumExpandWidth and self.displayMode == NavigationDisplayMode.EXPAND:
self.collapse()
elif w >= self.minimumExpandWidth and self.displayMode == NavigationDisplayMode.COMPACT and \
not self._isMenuButtonVisible:
self.expand()
return super().eventFilter(obj, e)
def _onExpandAniFinished(self):
if not self.expandAni.property('expand'):
if self.isMinimalEnabled:
self.displayMode = NavigationDisplayMode.MINIMAL
else:
self.displayMode = NavigationDisplayMode.COMPACT
self.displayModeChanged.emit(self.displayMode)
if self.displayMode == NavigationDisplayMode.MINIMAL:
self.hide()
self.setProperty('menu', False)
self.setStyle(QApplication.style())
elif self.displayMode == NavigationDisplayMode.COMPACT:
self.setProperty('menu', False)
self.setStyle(QApplication.style())
for item in self.items.values():
item.widget.setCompacted(True)
if not self._parent.isWindow():
self.setParent(self._parent)
self.move(0, 0)
self.show()
def _setWidgetCompacted(self, isCompacted: bool):
""" set whether the navigation widget is compacted """
for item in self.findChildren(NavigationWidget):
item.setCompacted(isCompacted)
def layoutMinHeight(self):
th = self.topLayout.minimumSize().height()
bh = self.bottomLayout.minimumSize().height()
sh = sum(w.height() for w in self.findChildren(NavigationSeparator))
spacing = self.topLayout.count() * self.topLayout.spacing()
spacing += self.bottomLayout.count() * self.bottomLayout.spacing()
return 36 + th + bh + sh + spacing
def _canDrawAcrylic(self):
return self.acrylicBrush.isAvailable() and self.isAcrylicEnabled()
def paintEvent(self, e):
if not self._canDrawAcrylic() or self.displayMode != NavigationDisplayMode.MENU:
return super().paintEvent(e)
path = QPainterPath()
path.setFillRule(Qt.WindingFill)
path.addRoundedRect(0, 1, self.width() - 1, self.height() - 1, 7, 7)
path.addRect(0, 1, 8, self.height() - 1)
self.acrylicBrush.setClipPath(path)
self._updateAcrylicColor()
self.acrylicBrush.paint()
super().paintEvent(e)
class NavigationItemLayout(QVBoxLayout):
""" Navigation layout """
def setGeometry(self, rect: QRect):
super().setGeometry(rect)
for i in range(self.count()):
item = self.itemAt(i)
if isinstance(item.widget(), NavigationSeparator):
geo = item.geometry()
item.widget().setGeometry(0, geo.y(), geo.width(), geo.height())