From 70a14dd79682c26b1da7e6b5570e0dc16e153934 Mon Sep 17 00:00:00 2001 From: JustKanade Date: Tue, 18 Nov 2025 22:01:50 +0800 Subject: [PATCH 1/4] feat(navigation): add WinUI indicator transition animation --- .../navigation_indicator_animation/demo.py | 128 +++++++++ .../navigation/navigation_indicator.py | 248 ++++++++++++++++++ .../navigation/navigation_interface.py | 28 ++ .../components/navigation/navigation_panel.py | 53 ++++ .../navigation/navigation_widget.py | 46 +++- 5 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 examples/navigation/navigation_indicator_animation/demo.py create mode 100644 qfluentwidgets/components/navigation/navigation_indicator.py diff --git a/examples/navigation/navigation_indicator_animation/demo.py b/examples/navigation/navigation_indicator_animation/demo.py new file mode 100644 index 00000000..781e5268 --- /dev/null +++ b/examples/navigation/navigation_indicator_animation/demo.py @@ -0,0 +1,128 @@ +# coding:utf-8 +import sys +from pathlib import Path +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QApplication, QFrame, QStackedWidget, QHBoxLayout + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent)) + +from qfluentwidgets import (NavigationInterface, NavigationItemPosition, SubtitleLabel, + setFont, NavigationItemHeader, FluentIcon as FIF) +from qframelesswindow import FramelessWindow, StandardTitleBar + + +class Widget(QFrame): + + def __init__(self, text: str, parent=None): + super().__init__(parent=parent) + self.label = SubtitleLabel(text, self) + self.hBoxLayout = QHBoxLayout(self) + + setFont(self.label, 24) + self.label.setAlignment(Qt.AlignCenter) + self.hBoxLayout.addWidget(self.label, 1, Qt.AlignCenter) + self.setObjectName(text.replace(' ', '-')) + + +class Window(FramelessWindow): + + def __init__(self): + super().__init__() + self.setTitleBar(StandardTitleBar(self)) + self.setWindowTitle('Navigation Indicator Animation') + + self.hBoxLayout = QHBoxLayout(self) + self.navigationInterface = NavigationInterface(self, showMenuButton=True) + self.stackWidget = QStackedWidget(self) + + # create sub interface + self.homeInterface = Widget('Home Interface', self) + self.searchInterface = Widget('Search Interface', self) + self.musicInterface = Widget('Music Interface', self) + self.videoInterface = Widget('Video Interface', self) + self.albumInterface = Widget('Album Interface', self) + self.albumInterface1 = Widget('Album 1 Interface', self) + self.albumInterface2 = Widget('Album 2 Interface', self) + self.albumInterface1_1 = Widget('Album 1-1 Interface', self) + self.folderInterface = Widget('Folder Interface', self) + self.settingInterface = Widget('Setting Interface', self) + + # initialize layout + self.initLayout() + + # add items to navigation interface + self.initNavigation() + + self.initWindow() + + def initLayout(self): + self.hBoxLayout.setSpacing(0) + self.hBoxLayout.setContentsMargins(0, self.titleBar.height(), 0, 0) + self.hBoxLayout.addWidget(self.navigationInterface) + self.hBoxLayout.addWidget(self.stackWidget) + self.hBoxLayout.setStretchFactor(self.stackWidget, 1) + + def initNavigation(self): + # enable smooth indicator animation when switching between items + # the animation direction is determined automatically based on item positions + self.navigationInterface.setIndicatorAnimationEnabled(True) + + # you can also customize the animation duration (default: 300ms) + # self.navigationInterface.setIndicatorAnimationDuration(300) + + # add items to top + self.addSubInterface(self.homeInterface, FIF.HOME, 'Home') + self.addSubInterface(self.searchInterface, FIF.SEARCH, 'Search') + self.addSubInterface(self.musicInterface, FIF.MUSIC, 'Music library') + self.addSubInterface(self.videoInterface, FIF.VIDEO, 'Video library') + + self.navigationInterface.addSeparator() + + # add navigation header + self.navigationInterface.addWidget( + routeKey='Header', + widget=NavigationItemHeader('Library'), + position=NavigationItemPosition.SCROLL + ) + + # add items to scroll area with tree menu + self.addSubInterface(self.albumInterface, FIF.ALBUM, 'Albums', NavigationItemPosition.SCROLL) + self.addSubInterface(self.albumInterface1, FIF.ALBUM, 'Album 1', parent=self.albumInterface) + self.addSubInterface(self.albumInterface1_1, FIF.ALBUM, 'Album 1.1', parent=self.albumInterface1) + self.addSubInterface(self.albumInterface2, FIF.ALBUM, 'Album 2', parent=self.albumInterface) + + self.addSubInterface(self.folderInterface, FIF.FOLDER, 'Folder library', NavigationItemPosition.SCROLL) + + # add items to bottom + self.addSubInterface(self.settingInterface, FIF.SETTING, 'Settings', NavigationItemPosition.BOTTOM) + + self.navigationInterface.setCurrentItem(self.homeInterface.objectName()) + + def initWindow(self): + self.resize(900, 600) + desktop = QApplication.desktop().availableGeometry() + w, h = desktop.width(), desktop.height() + self.move(w//2 - self.width()//2, h//2 - self.height()//2) + + def addSubInterface(self, interface, icon, text: str, position=NavigationItemPosition.TOP, parent=None): + """ add sub interface """ + self.stackWidget.addWidget(interface) + self.navigationInterface.addItem( + routeKey=interface.objectName(), + icon=icon, + text=text, + onClick=lambda: self.switchTo(interface), + position=position, + tooltip=text, + parentRouteKey=parent.objectName() if parent else None + ) + + def switchTo(self, widget): + self.stackWidget.setCurrentWidget(widget) + + +if __name__ == '__main__': + app = QApplication(sys.argv) + w = Window() + w.show() + app.exec_() diff --git a/qfluentwidgets/components/navigation/navigation_indicator.py b/qfluentwidgets/components/navigation/navigation_indicator.py new file mode 100644 index 00000000..adb62fea --- /dev/null +++ b/qfluentwidgets/components/navigation/navigation_indicator.py @@ -0,0 +1,248 @@ +# coding:utf-8 +from typing import Optional +from PyQt5.QtCore import QObject, QPropertyAnimation, QEasingCurve, pyqtProperty, pyqtSignal, QRect, Qt +from PyQt5.QtGui import QPainter, QColor +from PyQt5.QtWidgets import QWidget + +from ...common.config import isDarkTheme +from ...common.style_sheet import themeColor + + +class NavigationIndicatorAnimator(QObject): + """ Navigation indicator transition animator """ + + animationFinished = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self._isEnabled = True + self._duration = 300 + + # animation properties + self._lastSelectMarkTop = 10.0 + self._lastSelectMarkBottom = 10.0 + self._selectMarkTop = 10.0 + self._selectMarkBottom = 10.0 + + # control flag for display state + self._isSelectMarkDisplay = True + + # tracking widgets + self._lastSelectedWidget = None # type: Optional[QWidget] + self._currentWidget = None # type: Optional[QWidget] + + # animations - moving up + self._lastSelectMarkTopAni = QPropertyAnimation(self, b'lastSelectMarkTop', self) + self._selectMarkBottomAni = QPropertyAnimation(self, b'selectMarkBottom', self) + + # animations - moving down + self._lastSelectMarkBottomAni = QPropertyAnimation(self, b'lastSelectMarkBottom', self) + self._selectMarkTopAni = QPropertyAnimation(self, b'selectMarkTop', self) + + self._initAnimations() + + def _initAnimations(self): + """ initialize animations """ + # moving up animations + self._lastSelectMarkTopAni.setDuration(self._duration) + self._lastSelectMarkTopAni.setEasingCurve(QEasingCurve.InOutSine) + self._lastSelectMarkTopAni.valueChanged.connect(lambda: self.parent().update() if self.parent() else None) + + self._selectMarkBottomAni.setDuration(self._duration) + self._selectMarkBottomAni.setEasingCurve(QEasingCurve.InOutSine) + self._selectMarkBottomAni.valueChanged.connect(lambda: self.parent().update() if self.parent() else None) + + # moving down animations + self._lastSelectMarkBottomAni.setDuration(self._duration) + self._lastSelectMarkBottomAni.setEasingCurve(QEasingCurve.InOutSine) + self._lastSelectMarkBottomAni.valueChanged.connect(lambda: self.parent().update() if self.parent() else None) + + self._selectMarkTopAni.setDuration(self._duration) + self._selectMarkTopAni.setEasingCurve(QEasingCurve.InOutSine) + self._selectMarkTopAni.valueChanged.connect(lambda: self.parent().update() if self.parent() else None) + + # chain animations + self._lastSelectMarkTopAni.finished.connect(self._onLastTopAnimationFinished) + self._lastSelectMarkBottomAni.finished.connect(self._onLastBottomAnimationFinished) + + def _onLastTopAnimationFinished(self): + """ handle first phase animation finished, start second phase expansion """ + self._isSelectMarkDisplay = True + self._lastSelectedWidget = None + self._selectMarkBottomAni.setStartValue(0) + self._selectMarkBottomAni.setEndValue(10) + self._selectMarkBottomAni.start() + self.animationFinished.emit() + + def _onLastBottomAnimationFinished(self): + """ handle first phase animation finished, start second phase expansion """ + self._isSelectMarkDisplay = True + self._lastSelectedWidget = None + self._selectMarkTopAni.setStartValue(0) + self._selectMarkTopAni.setEndValue(10) + self._selectMarkTopAni.start() + self.animationFinished.emit() + + def isEnabled(self): + """ check if animation is enabled """ + return self._isEnabled + + def setEnabled(self, enabled: bool): + """ set animation enabled state """ + self._isEnabled = enabled + + def duration(self): + """ get animation duration """ + return self._duration + + def setDuration(self, duration: int): + """ set animation duration """ + self._duration = max(0, duration) + for ani in [self._lastSelectMarkTopAni, self._lastSelectMarkBottomAni, + self._selectMarkTopAni, self._selectMarkBottomAni]: + ani.setDuration(self._duration) + + def animateTo(self, widget: QWidget): + """ animate indicator to target widget """ + if not self._isEnabled or not widget: + self._currentWidget = widget + if self.parent(): + self.parent().update() + return + + # don't animate if clicking the same item + if widget == self._currentWidget: + return + + # stop running animations + self._stopAnimations() + + # save last selected widget + self._lastSelectedWidget = self._currentWidget + + # reset animation values + self._lastSelectMarkTop = 10 + self._lastSelectMarkBottom = 10 + self._selectMarkTop = 10 + self._selectMarkBottom = 10 + + if not self._lastSelectedWidget: + # no previous widget, just show current + self._currentWidget = widget + if self.parent(): + self.parent().update() + return + + # update current widget + self._currentWidget = widget + + # determine animation direction + if self._isMovingDown(): + # moving down + self._lastSelectMarkBottomAni.setStartValue(10) + self._lastSelectMarkBottomAni.setEndValue(0) + self._lastSelectMarkBottomAni.start() + # stop other animations + self._lastSelectMarkTopAni.stop() + self._selectMarkTopAni.stop() + # hide select mark until animation finishes + self._isSelectMarkDisplay = False + else: + # moving up + self._lastSelectMarkTopAni.setStartValue(10) + self._lastSelectMarkTopAni.setEndValue(0) + self._lastSelectMarkTopAni.start() + # stop other animations + self._lastSelectMarkBottomAni.stop() + self._selectMarkBottomAni.stop() + # hide select mark until animation finishes + self._isSelectMarkDisplay = False + + def _isMovingDown(self): + """ check if indicator is moving down """ + if not self._lastSelectedWidget or not self._currentWidget: + return True + + # Get global positions to compare + prev_pos = self._lastSelectedWidget.mapToGlobal(self._lastSelectedWidget.rect().topLeft()) + curr_pos = self._currentWidget.mapToGlobal(self._currentWidget.rect().topLeft()) + + return curr_pos.y() > prev_pos.y() + + def _stopAnimations(self): + """ stop all running animations """ + for ani in [self._lastSelectMarkTopAni, self._lastSelectMarkBottomAni, + self._selectMarkTopAni, self._selectMarkBottomAni]: + if ani.state() == QPropertyAnimation.Running: + ani.stop() + + def drawIndicator(self, painter: QPainter, widget: QWidget, rect: QRect, color: QColor, leftMargin: int = 0): + """ draw indicator for widget """ + if not widget: + return + + painter.setPen(Qt.NoPen) + painter.setBrush(color) + + # calculate indicator x position based on left margin + indicatorX = rect.x() + leftMargin + 3 + + # draw current selected indicator + if self._isSelectMarkDisplay and self._currentWidget == widget: + indicatorRect = QRect(indicatorX, + rect.y() + int(self._selectMarkTop), + 3, + rect.height() - int(self._selectMarkTop) - int(self._selectMarkBottom)) + painter.drawRoundedRect(indicatorRect, 1.5, 1.5) + + # draw last selected indicator during animation + if self._lastSelectedWidget == widget: + indicatorRect = QRect(indicatorX, + rect.y() + int(self._lastSelectMarkTop), + 3, + rect.height() - int(self._lastSelectMarkTop) - int(self._lastSelectMarkBottom)) + painter.drawRoundedRect(indicatorRect, 1.5, 1.5) + + def reset(self): + """ reset animator state """ + self._stopAnimations() + self._lastSelectedWidget = None + self._currentWidget = None + self._lastSelectMarkTop = 10 + self._lastSelectMarkBottom = 10 + self._selectMarkTop = 10 + self._selectMarkBottom = 10 + self._isSelectMarkDisplay = True + + # properties + @pyqtProperty(float) + def lastSelectMarkTop(self): + return self._lastSelectMarkTop + + @lastSelectMarkTop.setter + def lastSelectMarkTop(self, value): + self._lastSelectMarkTop = value + + @pyqtProperty(float) + def lastSelectMarkBottom(self): + return self._lastSelectMarkBottom + + @lastSelectMarkBottom.setter + def lastSelectMarkBottom(self, value): + self._lastSelectMarkBottom = value + + @pyqtProperty(float) + def selectMarkTop(self): + return self._selectMarkTop + + @selectMarkTop.setter + def selectMarkTop(self, value): + self._selectMarkTop = value + + @pyqtProperty(float) + def selectMarkBottom(self): + return self._selectMarkBottom + + @selectMarkBottom.setter + def selectMarkBottom(self, value): + self._selectMarkBottom = value diff --git a/qfluentwidgets/components/navigation/navigation_interface.py b/qfluentwidgets/components/navigation/navigation_interface.py index a031c77d..42264e15 100644 --- a/qfluentwidgets/components/navigation/navigation_interface.py +++ b/qfluentwidgets/components/navigation/navigation_interface.py @@ -288,6 +288,34 @@ def setAcrylicEnabled(self, isEnabled: bool): """ set whether the acrylic background effect is enabled """ self.panel.setAcrylicEnabled(isEnabled) + def setIndicatorAnimationEnabled(self, isEnabled: bool): + """ set whether the indicator animation is enabled + + Parameters + ---------- + isEnabled: bool + whether to enable indicator animation + """ + self.panel.setIndicatorAnimationEnabled(isEnabled) + + def setIndicatorAnimationDuration(self, duration: int): + """ set the duration of indicator animation + + Parameters + ---------- + duration: int + animation duration in milliseconds (default: 300) + """ + self.panel.setIndicatorAnimationDuration(duration) + + def isIndicatorAnimationEnabled(self): + """ whether the indicator animation is enabled """ + return self.panel.isIndicatorAnimationEnabled() + + def indicatorAnimationDuration(self): + """ get the duration of indicator animation """ + return self.panel.indicatorAnimationDuration() + def widget(self, routeKey: str): return self.panel.widget(routeKey) diff --git a/qfluentwidgets/components/navigation/navigation_panel.py b/qfluentwidgets/components/navigation/navigation_panel.py index 150118da..3fc0cd5d 100644 --- a/qfluentwidgets/components/navigation/navigation_panel.py +++ b/qfluentwidgets/components/navigation/navigation_panel.py @@ -8,6 +8,7 @@ from .navigation_widget import (NavigationTreeWidgetBase, NavigationToolButton, NavigationWidget, NavigationSeparator, NavigationTreeWidget, NavigationFlyoutMenu, NavigationItemHeader) +from .navigation_indicator import NavigationIndicatorAnimator from ..widgets.acrylic_label import AcrylicBrush from ..widgets.scroll_area import ScrollArea from ..widgets.tool_tip import ToolTipFilter @@ -69,8 +70,11 @@ def __init__(self, parent=None, isMinimalEnabled=False): self._isReturnButtonVisible = False self._isCollapsible = True self._isAcrylicEnabled = False + self._indicatorAnimationEnabled = True + self._indicatorAnimationDuration = 300 self.acrylicBrush = AcrylicBrush(self, 30) + self.indicatorAnimator = NavigationIndicatorAnimator(self) self.scrollArea = ScrollArea(self) self.scrollWidget = QWidget() @@ -387,6 +391,15 @@ def _registerWidget(self, routeKey: str, parentRouteKey: str, widget: Navigation widget.setProperty('parentRouteKey', parentRouteKey) self.items[routeKey] = NavigationItem(routeKey, parentRouteKey, widget) + # set indicator animator for selectable widgets + if widget.isSelectable: + # for NavigationTreeWidget, set animator on its itemWidget + if isinstance(widget, NavigationTreeWidget): + if hasattr(widget.itemWidget, '_indicatorAnimator'): + widget.itemWidget._indicatorAnimator = self.indicatorAnimator + elif hasattr(widget, '_indicatorAnimator'): + widget._indicatorAnimator = self.indicatorAnimator + if self.displayMode in [NavigationDisplayMode.EXPAND, NavigationDisplayMode.MENU]: widget.setCompacted(False) @@ -477,6 +490,37 @@ def isAcrylicEnabled(self): """ whether the acrylic effect is enabled """ return self._isAcrylicEnabled + def setIndicatorAnimationEnabled(self, isEnabled: bool): + """ set whether the indicator animation is enabled + + Parameters + ---------- + isEnabled: bool + whether to enable indicator animation + """ + self._indicatorAnimationEnabled = isEnabled + self.indicatorAnimator.setEnabled(isEnabled) + self.update() + + def setIndicatorAnimationDuration(self, duration: int): + """ set the duration of indicator animation + + Parameters + ---------- + duration: int + animation duration in milliseconds + """ + self._indicatorAnimationDuration = duration + self.indicatorAnimator.setDuration(duration) + + def isIndicatorAnimationEnabled(self): + """ whether the indicator animation is enabled """ + return self._indicatorAnimationEnabled + + def indicatorAnimationDuration(self): + """ get the duration of indicator animation """ + return self._indicatorAnimationDuration + def expand(self, useAni=True): """ expand navigation panel """ self._setWidgetCompacted(False) @@ -553,6 +597,15 @@ def setCurrentItem(self, routeKey: str): if routeKey not in self.items: return + # trigger indicator animation + targetWidget = self.items[routeKey].widget + if self._indicatorAnimationEnabled and targetWidget.isSelectable: + # for NavigationTreeWidget, animate to its itemWidget + if isinstance(targetWidget, NavigationTreeWidget): + self.indicatorAnimator.animateTo(targetWidget.itemWidget) + else: + self.indicatorAnimator.animateTo(targetWidget) + for k, item in self.items.items(): item.widget.setSelected(k == routeKey) diff --git a/qfluentwidgets/components/navigation/navigation_widget.py b/qfluentwidgets/components/navigation/navigation_widget.py index c18ce18f..73ac72c2 100644 --- a/qfluentwidgets/components/navigation/navigation_widget.py +++ b/qfluentwidgets/components/navigation/navigation_widget.py @@ -130,6 +130,7 @@ def __init__(self, icon: Union[str, QIcon, FIF], text: str, isSelectable: bool, self._text = text self.lightIndicatorColor = QColor() self.darkIndicatorColor = QColor() + self._indicatorAnimator = None # will be set by panel setFont(self) @@ -175,16 +176,31 @@ def paintEvent(self, e): pl, pr = m.left(), m.right() globalRect = QRect(self.mapToGlobal(QPoint()), self.size()) - if self._canDrawIndicator(): - painter.setBrush(QColor(c, c, c, 6 if self.isEnter else 10)) - painter.drawRoundedRect(self.rect(), 5, 5) - - # draw indicator - painter.setBrush(autoFallbackThemeColor(self.lightIndicatorColor, self.darkIndicatorColor)) - painter.drawRoundedRect(pl, 10, 3, 16, 1.5, 1.5) - elif self.isEnter and self.isEnabled() and globalRect.contains(QCursor.pos()): - painter.setBrush(QColor(c, c, c, 10)) - painter.drawRoundedRect(self.rect(), 5, 5) + # check if we should draw indicator through animator + if self._indicatorAnimator: + # animator handles all indicator drawing + if self._canDrawIndicator() or self._indicatorAnimator._lastSelectedWidget == self: + painter.setBrush(QColor(c, c, c, 6 if self.isEnter else 10)) + painter.drawRoundedRect(self.rect(), 5, 5) + elif self.isEnter and self.isEnabled() and globalRect.contains(QCursor.pos()): + painter.setBrush(QColor(c, c, c, 10)) + painter.drawRoundedRect(self.rect(), 5, 5) + + # let animator handle indicator drawing + color = autoFallbackThemeColor(self.lightIndicatorColor, self.darkIndicatorColor) + self._indicatorAnimator.drawIndicator(painter, self, self.rect(), color, pl) + else: + # fallback to original static behavior + if self._canDrawIndicator(): + painter.setBrush(QColor(c, c, c, 6 if self.isEnter else 10)) + painter.drawRoundedRect(self.rect(), 5, 5) + + # draw static indicator + painter.setBrush(autoFallbackThemeColor(self.lightIndicatorColor, self.darkIndicatorColor)) + painter.drawRoundedRect(pl, 10, 3, 16, 1.5, 1.5) + elif self.isEnter and self.isEnabled() and globalRect.contains(QCursor.pos()): + painter.setBrush(QColor(c, c, c, 10)) + painter.drawRoundedRect(self.rect(), 5, 5) drawIcon(self._icon, painter, QRectF(11.5+pl, 10, 16, 16)) @@ -730,6 +746,10 @@ def __init__(self, tree: NavigationTreeWidget, parent=None): self.treeWidget = tree self.treeChildren = [] + + # create indicator animator for flyout menu + from .navigation_indicator import NavigationIndicatorAnimator + self.indicatorAnimator = NavigationIndicatorAnimator(self) self.vBoxLayout = QVBoxLayout(self.view) @@ -757,8 +777,14 @@ def _initNode(self, root: NavigationTreeWidget): for c in root.treeChildren: c.nodeDepth -= 1 c.setCompacted(False) + + # use shared indicator animator for flyout menu + c.itemWidget._indicatorAnimator = self.indicatorAnimator if c.isLeaf(): + # trigger animation and close flyout when leaf is clicked + widget = c.itemWidget + c.clicked.connect(lambda w=widget: self.indicatorAnimator.animateTo(w)) c.clicked.connect(self.window().fadeOut) self._initNode(c) From f8bffcec5c74cc7e5abe83bde561f73fc8b65871 Mon Sep 17 00:00:00 2001 From: JustKanade Date: Sat, 22 Nov 2025 21:39:48 +0800 Subject: [PATCH 2/4] refactor: improve navigation indicator animation --- .../navigation_indicator_animation/demo.py | 8 +- .../components/navigation/__init__.py | 1 + .../navigation/navigation_indicator.py | 429 ++++++++---------- .../navigation/navigation_interface.py | 28 -- .../components/navigation/navigation_panel.py | 161 ++++--- .../navigation/navigation_widget.py | 121 +++-- 6 files changed, 396 insertions(+), 352 deletions(-) diff --git a/examples/navigation/navigation_indicator_animation/demo.py b/examples/navigation/navigation_indicator_animation/demo.py index 781e5268..1a5e2f27 100644 --- a/examples/navigation/navigation_indicator_animation/demo.py +++ b/examples/navigation/navigation_indicator_animation/demo.py @@ -63,12 +63,6 @@ def initLayout(self): self.hBoxLayout.setStretchFactor(self.stackWidget, 1) def initNavigation(self): - # enable smooth indicator animation when switching between items - # the animation direction is determined automatically based on item positions - self.navigationInterface.setIndicatorAnimationEnabled(True) - - # you can also customize the animation duration (default: 300ms) - # self.navigationInterface.setIndicatorAnimationDuration(300) # add items to top self.addSubInterface(self.homeInterface, FIF.HOME, 'Home') @@ -125,4 +119,4 @@ def switchTo(self, widget): app = QApplication(sys.argv) w = Window() w.show() - app.exec_() + app.exec_() \ No newline at end of file diff --git a/qfluentwidgets/components/navigation/__init__.py b/qfluentwidgets/components/navigation/__init__.py index edbdb634..f79e8ddb 100644 --- a/qfluentwidgets/components/navigation/__init__.py +++ b/qfluentwidgets/components/navigation/__init__.py @@ -1,5 +1,6 @@ from .navigation_widget import (NavigationWidget, NavigationPushButton, NavigationSeparator, NavigationToolButton, NavigationTreeWidget, NavigationTreeWidgetBase, NavigationAvatarWidget, NavigationItemHeader) +from .navigation_indicator import NavigationIndicator from .navigation_panel import NavigationPanel, NavigationItemPosition, NavigationDisplayMode from .navigation_interface import NavigationInterface from .navigation_bar import NavigationBarPushButton, NavigationBar diff --git a/qfluentwidgets/components/navigation/navigation_indicator.py b/qfluentwidgets/components/navigation/navigation_indicator.py index adb62fea..9a8a4b5e 100644 --- a/qfluentwidgets/components/navigation/navigation_indicator.py +++ b/qfluentwidgets/components/navigation/navigation_indicator.py @@ -1,248 +1,221 @@ # coding:utf-8 -from typing import Optional -from PyQt5.QtCore import QObject, QPropertyAnimation, QEasingCurve, pyqtProperty, pyqtSignal, QRect, Qt -from PyQt5.QtGui import QPainter, QColor +from PyQt5.QtCore import (Qt, QPropertyAnimation, QRectF, QEasingCurve, pyqtProperty, + QPointF, QTimer, QAbstractAnimation, QParallelAnimationGroup, + QSequentialAnimationGroup, QSize) +from PyQt5.QtGui import QColor, QPainter, QBrush from PyQt5.QtWidgets import QWidget -from ...common.config import isDarkTheme -from ...common.style_sheet import themeColor +from ...common.style_sheet import themeColor, isDarkTheme +from ...common.color import autoFallbackThemeColor -class NavigationIndicatorAnimator(QObject): - """ Navigation indicator transition animator """ +class NavigationIndicator(QWidget): + """ Navigation indicator """ - animationFinished = pyqtSignal() - def __init__(self, parent=None): super().__init__(parent) - self._isEnabled = True - self._duration = 300 - - # animation properties - self._lastSelectMarkTop = 10.0 - self._lastSelectMarkBottom = 10.0 - self._selectMarkTop = 10.0 - self._selectMarkBottom = 10.0 - - # control flag for display state - self._isSelectMarkDisplay = True - - # tracking widgets - self._lastSelectedWidget = None # type: Optional[QWidget] - self._currentWidget = None # type: Optional[QWidget] - - # animations - moving up - self._lastSelectMarkTopAni = QPropertyAnimation(self, b'lastSelectMarkTop', self) - self._selectMarkBottomAni = QPropertyAnimation(self, b'selectMarkBottom', self) - - # animations - moving down - self._lastSelectMarkBottomAni = QPropertyAnimation(self, b'lastSelectMarkBottom', self) - self._selectMarkTopAni = QPropertyAnimation(self, b'selectMarkTop', self) - - self._initAnimations() - - def _initAnimations(self): - """ initialize animations """ - # moving up animations - self._lastSelectMarkTopAni.setDuration(self._duration) - self._lastSelectMarkTopAni.setEasingCurve(QEasingCurve.InOutSine) - self._lastSelectMarkTopAni.valueChanged.connect(lambda: self.parent().update() if self.parent() else None) - - self._selectMarkBottomAni.setDuration(self._duration) - self._selectMarkBottomAni.setEasingCurve(QEasingCurve.InOutSine) - self._selectMarkBottomAni.valueChanged.connect(lambda: self.parent().update() if self.parent() else None) - - # moving down animations - self._lastSelectMarkBottomAni.setDuration(self._duration) - self._lastSelectMarkBottomAni.setEasingCurve(QEasingCurve.InOutSine) - self._lastSelectMarkBottomAni.valueChanged.connect(lambda: self.parent().update() if self.parent() else None) - - self._selectMarkTopAni.setDuration(self._duration) - self._selectMarkTopAni.setEasingCurve(QEasingCurve.InOutSine) - self._selectMarkTopAni.valueChanged.connect(lambda: self.parent().update() if self.parent() else None) - - # chain animations - self._lastSelectMarkTopAni.finished.connect(self._onLastTopAnimationFinished) - self._lastSelectMarkBottomAni.finished.connect(self._onLastBottomAnimationFinished) - - def _onLastTopAnimationFinished(self): - """ handle first phase animation finished, start second phase expansion """ - self._isSelectMarkDisplay = True - self._lastSelectedWidget = None - self._selectMarkBottomAni.setStartValue(0) - self._selectMarkBottomAni.setEndValue(10) - self._selectMarkBottomAni.start() - self.animationFinished.emit() - - def _onLastBottomAnimationFinished(self): - """ handle first phase animation finished, start second phase expansion """ - self._isSelectMarkDisplay = True - self._lastSelectedWidget = None - self._selectMarkTopAni.setStartValue(0) - self._selectMarkTopAni.setEndValue(10) - self._selectMarkTopAni.start() - self.animationFinished.emit() - - def isEnabled(self): - """ check if animation is enabled """ - return self._isEnabled - - def setEnabled(self, enabled: bool): - """ set animation enabled state """ - self._isEnabled = enabled - - def duration(self): - """ get animation duration """ - return self._duration - - def setDuration(self, duration: int): - """ set animation duration """ - self._duration = max(0, duration) - for ani in [self._lastSelectMarkTopAni, self._lastSelectMarkBottomAni, - self._selectMarkTopAni, self._selectMarkBottomAni]: - ani.setDuration(self._duration) - - def animateTo(self, widget: QWidget): - """ animate indicator to target widget """ - if not self._isEnabled or not widget: - self._currentWidget = widget - if self.parent(): - self.parent().update() - return + self.resize(3, 16) + self.setAttribute(Qt.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WA_TranslucentBackground) + self.hide() + + self._opacity = 0.0 + self._geometry = QRectF(0, 0, 3, 16) + self.lightColor = themeColor() + self.darkColor = themeColor() + self.isHorizontal = False + + self.aniGroup = QParallelAnimationGroup(self) + self.aniGroup.finished.connect(self.hide) + + def setIndicatorColor(self, light, dark): + self.lightColor = QColor(light) + self.darkColor = QColor(dark) + self.update() + + def getOpacity(self): + return self._opacity + + def setOpacity(self, opacity): + self._opacity = opacity + self.update() + + def getGeometry(self): + return QRectF(self.geometry()) - # don't animate if clicking the same item - if widget == self._currentWidget: - return - - # stop running animations - self._stopAnimations() + def setGeometry(self, geometry: QRectF): + self._geometry = geometry + super().setGeometry(geometry.toRect()) - # save last selected widget - self._lastSelectedWidget = self._currentWidget + def getPos(self): + return QPointF(super().pos()) - # reset animation values - self._lastSelectMarkTop = 10 - self._lastSelectMarkBottom = 10 - self._selectMarkTop = 10 - self._selectMarkBottom = 10 + def setPos(self, pos: QPointF): + self._geometry.moveTopLeft(pos) + self.move(pos.toPoint()) - if not self._lastSelectedWidget: - # no previous widget, just show current - self._currentWidget = widget - if self.parent(): - self.parent().update() - return - - # update current widget - self._currentWidget = widget + def getLength(self): + return self.width() if self.isHorizontal else self.height() - # determine animation direction - if self._isMovingDown(): - # moving down - self._lastSelectMarkBottomAni.setStartValue(10) - self._lastSelectMarkBottomAni.setEndValue(0) - self._lastSelectMarkBottomAni.start() - # stop other animations - self._lastSelectMarkTopAni.stop() - self._selectMarkTopAni.stop() - # hide select mark until animation finishes - self._isSelectMarkDisplay = False + def setLength(self, length): + if self.isHorizontal: + self._geometry.setWidth(length) + self.resize(int(length), self.height()) else: - # moving up - self._lastSelectMarkTopAni.setStartValue(10) - self._lastSelectMarkTopAni.setEndValue(0) - self._lastSelectMarkTopAni.start() - # stop other animations - self._lastSelectMarkBottomAni.stop() - self._selectMarkBottomAni.stop() - # hide select mark until animation finishes - self._isSelectMarkDisplay = False - - def _isMovingDown(self): - """ check if indicator is moving down """ - if not self._lastSelectedWidget or not self._currentWidget: - return True - - # Get global positions to compare - prev_pos = self._lastSelectedWidget.mapToGlobal(self._lastSelectedWidget.rect().topLeft()) - curr_pos = self._currentWidget.mapToGlobal(self._currentWidget.rect().topLeft()) - - return curr_pos.y() > prev_pos.y() - - def _stopAnimations(self): - """ stop all running animations """ - for ani in [self._lastSelectMarkTopAni, self._lastSelectMarkBottomAni, - self._selectMarkTopAni, self._selectMarkBottomAni]: - if ani.state() == QPropertyAnimation.Running: - ani.stop() - - def drawIndicator(self, painter: QPainter, widget: QWidget, rect: QRect, color: QColor, leftMargin: int = 0): - """ draw indicator for widget """ - if not widget: - return - + self._geometry.setHeight(length) + self.resize(self.width(), int(length)) + + opacity = pyqtProperty(float, getOpacity, setOpacity) + geometry = pyqtProperty(QRectF, getGeometry, setGeometry) + pos = pyqtProperty(QPointF, getPos, setPos) + length = pyqtProperty(float, getLength, setLength) + + def paintEvent(self, e): + painter = QPainter(self) + painter.setRenderHints(QPainter.Antialiasing) painter.setPen(Qt.NoPen) + painter.setOpacity(self._opacity) + + color = autoFallbackThemeColor(self.lightColor, self.darkColor) painter.setBrush(color) - # calculate indicator x position based on left margin - indicatorX = rect.x() + leftMargin + 3 + # Draw filling the widget + painter.drawRoundedRect(self.rect(), 1.5, 1.5) - # draw current selected indicator - if self._isSelectMarkDisplay and self._currentWidget == widget: - indicatorRect = QRect(indicatorX, - rect.y() + int(self._selectMarkTop), - 3, - rect.height() - int(self._selectMarkTop) - int(self._selectMarkBottom)) - painter.drawRoundedRect(indicatorRect, 1.5, 1.5) + def stopAnimation(self): + self.aniGroup.stop() + self.aniGroup.clear() + + def animate(self, startRect: QRectF, endRect: QRectF, isHorizontal=False, useCrossFade=False): + self.stopAnimation() + self.isHorizontal = isHorizontal + + # Determine if same level + if isHorizontal: + sameLevel = abs(startRect.y() - endRect.y()) < 1 + dim = startRect.width() + start = startRect.x() + end = endRect.x() + else: + sameLevel = abs(startRect.x() - endRect.x()) < 1 + dim = startRect.height() + start = startRect.y() + end = endRect.y() - # draw last selected indicator during animation - if self._lastSelectedWidget == widget: - indicatorRect = QRect(indicatorX, - rect.y() + int(self._lastSelectMarkTop), - 3, - rect.height() - int(self._lastSelectMarkTop) - int(self._lastSelectMarkBottom)) - painter.drawRoundedRect(indicatorRect, 1.5, 1.5) - - def reset(self): - """ reset animator state """ - self._stopAnimations() - self._lastSelectedWidget = None - self._currentWidget = None - self._lastSelectMarkTop = 10 - self._lastSelectMarkBottom = 10 - self._selectMarkTop = 10 - self._selectMarkBottom = 10 - self._isSelectMarkDisplay = True - - # properties - @pyqtProperty(float) - def lastSelectMarkTop(self): - return self._lastSelectMarkTop - - @lastSelectMarkTop.setter - def lastSelectMarkTop(self, value): - self._lastSelectMarkTop = value - - @pyqtProperty(float) - def lastSelectMarkBottom(self): - return self._lastSelectMarkBottom - - @lastSelectMarkBottom.setter - def lastSelectMarkBottom(self, value): - self._lastSelectMarkBottom = value - - @pyqtProperty(float) - def selectMarkTop(self): - return self._selectMarkTop - - @selectMarkTop.setter - def selectMarkTop(self, value): - self._selectMarkTop = value - - @pyqtProperty(float) - def selectMarkBottom(self): - return self._selectMarkBottom + if sameLevel and not useCrossFade: + self._startSlideAnimation(startRect, endRect, start, end, dim) + else: + self._startCrossFadeAnimation(startRect, endRect) + + def _startSlideAnimation(self, startRect, endRect, from_, to, dimension): + """ Animate the indicator using WinUI 3 squash and stretch logic + + Key algorithm: + 1. middleScale = abs(to - from) / dimension + (from < to ? endScale : beginScale) + 2. At 33% progress, the indicator stretches to cover the distance between two items + """ + self.setGeometry(startRect) + self.setOpacity(1) + self.show() + + dist = abs(to - from_) + midLength = dist + dimension + isForward = to > from_ + + s1 = QSequentialAnimationGroup() + s2 = QSequentialAnimationGroup() + + posAni1 = QPropertyAnimation(self, b"pos") + posAni1.setDuration(200) + posAni2 = QPropertyAnimation(self, b"pos") + posAni2.setDuration(400) + + lenAni1 = QPropertyAnimation(self, b"length") + lenAni1.setDuration(200) + lenAni2 = QPropertyAnimation(self, b"length") + lenAni2.setDuration(400) + + startPos = startRect.topLeft() + endPos = endRect.topLeft() + + if isForward: + # 0->0.33: Head moves to B (len increases), Pos stays at A + posAni1.setStartValue(startPos) + posAni1.setEndValue(startPos) + lenAni1.setStartValue(dimension) + lenAni1.setEndValue(midLength) + + # 0.33->1.0: Tail moves to B (len decreases), Pos moves to B + posAni2.setStartValue(startPos) + posAni2.setEndValue(endPos) + lenAni2.setStartValue(midLength) + lenAni2.setEndValue(dimension) + + else: + # 0->0.33: Head moves to A (len increases), Pos moves to B + # Note: For backward, "Head" is top. Top moves from A to B. + posAni1.setStartValue(startPos) + posAni1.setEndValue(endPos) + lenAni1.setStartValue(dimension) + lenAni1.setEndValue(midLength) + + # 0.33->1.0: Tail moves to B (len decreases), Pos stays at B + posAni2.setStartValue(endPos) + posAni2.setEndValue(endPos) + lenAni2.setStartValue(midLength) + lenAni2.setEndValue(dimension) + + # Curves + curve1 = QEasingCurve(QEasingCurve.BezierSpline) + curve1.addCubicBezierSegment(QPointF(0.9, 0.1), QPointF(1.0, 0.2), QPointF(1.0, 1.0)) + + curve2 = QEasingCurve(QEasingCurve.BezierSpline) + curve2.addCubicBezierSegment(QPointF(0.1, 0.9), QPointF(0.2, 1.0), QPointF(1.0, 1.0)) + + posAni1.setEasingCurve(curve1) + lenAni1.setEasingCurve(curve1) + posAni2.setEasingCurve(curve2) + lenAni2.setEasingCurve(curve2) + + s1.addAnimation(posAni1) + s1.addAnimation(posAni2) + s2.addAnimation(lenAni1) + s2.addAnimation(lenAni2) + + self.aniGroup.addAnimation(s1) + self.aniGroup.addAnimation(s2) + self.aniGroup.start() + + def _startCrossFadeAnimation(self, startRect, endRect): + self.setGeometry(endRect) + self.setOpacity(1) + self.show() + + # Determine growth direction based on relative position + # WinUI 3 logic: Grow from top/bottom edge depending on direction + isNextBelow = endRect.y() > startRect.y() if not self.isHorizontal else endRect.x() > startRect.x() - @selectMarkBottom.setter - def selectMarkBottom(self, value): - self._selectMarkBottom = value + if self.isHorizontal: + dim = endRect.width() + startGeo = QRectF(endRect.x() + (0 if isNextBelow else dim), endRect.y(), 0, endRect.height()) + else: + dim = endRect.height() + startGeo = QRectF(endRect.x(), endRect.y() + (0 if isNextBelow else dim), endRect.width(), 0) + + self.setGeometry(startGeo) + + lenAni = QPropertyAnimation(self, b"length") + lenAni.setDuration(600) + lenAni.setStartValue(0) + lenAni.setEndValue(dim) + lenAni.setEasingCurve(QEasingCurve.OutQuint) + + posAni = QPropertyAnimation(self, b"pos") + posAni.setDuration(600) + posAni.setStartValue(startGeo.topLeft()) + posAni.setEndValue(endRect.topLeft()) + posAni.setEasingCurve(QEasingCurve.OutQuint) + + self.aniGroup.addAnimation(lenAni) + self.aniGroup.addAnimation(posAni) + self.aniGroup.start() diff --git a/qfluentwidgets/components/navigation/navigation_interface.py b/qfluentwidgets/components/navigation/navigation_interface.py index 42264e15..a031c77d 100644 --- a/qfluentwidgets/components/navigation/navigation_interface.py +++ b/qfluentwidgets/components/navigation/navigation_interface.py @@ -288,34 +288,6 @@ def setAcrylicEnabled(self, isEnabled: bool): """ set whether the acrylic background effect is enabled """ self.panel.setAcrylicEnabled(isEnabled) - def setIndicatorAnimationEnabled(self, isEnabled: bool): - """ set whether the indicator animation is enabled - - Parameters - ---------- - isEnabled: bool - whether to enable indicator animation - """ - self.panel.setIndicatorAnimationEnabled(isEnabled) - - def setIndicatorAnimationDuration(self, duration: int): - """ set the duration of indicator animation - - Parameters - ---------- - duration: int - animation duration in milliseconds (default: 300) - """ - self.panel.setIndicatorAnimationDuration(duration) - - def isIndicatorAnimationEnabled(self): - """ whether the indicator animation is enabled """ - return self.panel.isIndicatorAnimationEnabled() - - def indicatorAnimationDuration(self): - """ get the duration of indicator animation """ - return self.panel.indicatorAnimationDuration() - def widget(self, routeKey: str): return self.panel.widget(routeKey) diff --git a/qfluentwidgets/components/navigation/navigation_panel.py b/qfluentwidgets/components/navigation/navigation_panel.py index 3fc0cd5d..b7a17de8 100644 --- a/qfluentwidgets/components/navigation/navigation_panel.py +++ b/qfluentwidgets/components/navigation/navigation_panel.py @@ -2,13 +2,13 @@ from enum import Enum from typing import Dict, Union -from PyQt5.QtCore import Qt, QPropertyAnimation, QRect, QSize, QEvent, QEasingCurve, pyqtSignal, QPoint +from PyQt5.QtCore import Qt, QPropertyAnimation, QRect, QSize, QEvent, QEasingCurve, pyqtSignal, QPoint, QRectF from PyQt5.QtGui import QResizeEvent, QIcon, QColor, QPainterPath from PyQt5.QtWidgets import QWidget, QVBoxLayout, QFrame, QApplication, QHBoxLayout from .navigation_widget import (NavigationTreeWidgetBase, NavigationToolButton, NavigationWidget, NavigationSeparator, NavigationTreeWidget, NavigationFlyoutMenu, NavigationItemHeader) -from .navigation_indicator import NavigationIndicatorAnimator +from .navigation_indicator import NavigationIndicator from ..widgets.acrylic_label import AcrylicBrush from ..widgets.scroll_area import ScrollArea from ..widgets.tool_tip import ToolTipFilter @@ -70,11 +70,8 @@ def __init__(self, parent=None, isMinimalEnabled=False): self._isReturnButtonVisible = False self._isCollapsible = True self._isAcrylicEnabled = False - self._indicatorAnimationEnabled = True - self._indicatorAnimationDuration = 300 self.acrylicBrush = AcrylicBrush(self, 30) - self.indicatorAnimator = NavigationIndicatorAnimator(self) self.scrollArea = ScrollArea(self) self.scrollWidget = QWidget() @@ -90,6 +87,9 @@ def __init__(self, parent=None, isMinimalEnabled=False): self.items = {} # type: Dict[str, NavigationItem] self.history = qrouter + self.indicator = NavigationIndicator(self) + self._pendingIndicatorWidget = None # type: NavigationWidget + self.expandAni = QPropertyAnimation(self, b'geometry', self) self.expandWidth = 322 self.minimumExpandWidth = 1008 @@ -391,15 +391,6 @@ def _registerWidget(self, routeKey: str, parentRouteKey: str, widget: Navigation widget.setProperty('parentRouteKey', parentRouteKey) self.items[routeKey] = NavigationItem(routeKey, parentRouteKey, widget) - # set indicator animator for selectable widgets - if widget.isSelectable: - # for NavigationTreeWidget, set animator on its itemWidget - if isinstance(widget, NavigationTreeWidget): - if hasattr(widget.itemWidget, '_indicatorAnimator'): - widget.itemWidget._indicatorAnimator = self.indicatorAnimator - elif hasattr(widget, '_indicatorAnimator'): - widget._indicatorAnimator = self.indicatorAnimator - if self.displayMode in [NavigationDisplayMode.EXPAND, NavigationDisplayMode.MENU]: widget.setCompacted(False) @@ -490,39 +481,9 @@ def isAcrylicEnabled(self): """ whether the acrylic effect is enabled """ return self._isAcrylicEnabled - def setIndicatorAnimationEnabled(self, isEnabled: bool): - """ set whether the indicator animation is enabled - - Parameters - ---------- - isEnabled: bool - whether to enable indicator animation - """ - self._indicatorAnimationEnabled = isEnabled - self.indicatorAnimator.setEnabled(isEnabled) - self.update() - - def setIndicatorAnimationDuration(self, duration: int): - """ set the duration of indicator animation - - Parameters - ---------- - duration: int - animation duration in milliseconds - """ - self._indicatorAnimationDuration = duration - self.indicatorAnimator.setDuration(duration) - - def isIndicatorAnimationEnabled(self): - """ whether the indicator animation is enabled """ - return self._indicatorAnimationEnabled - - def indicatorAnimationDuration(self): - """ get the duration of indicator animation """ - return self._indicatorAnimationDuration - def expand(self, useAni=True): """ expand navigation panel """ + self._stopIndicatorAnimation() self._setWidgetCompacted(False) self.expandAni.setProperty('expand', True) self.menuButton.setToolTip(self.tr('Close Navigation')) @@ -562,6 +523,11 @@ def expand(self, useAni=True): def collapse(self): """ collapse navigation panel """ + # only stop animation if the target item is a child item + target = self._pendingIndicatorWidget + if target and target.property('parentRouteKey'): + self._stopIndicatorAnimation() + if self.expandAni.state() == QPropertyAnimation.Running: return @@ -597,17 +563,102 @@ def setCurrentItem(self, routeKey: str): if routeKey not in self.items: return - # trigger indicator animation - targetWidget = self.items[routeKey].widget - if self._indicatorAnimationEnabled and targetWidget.isSelectable: - # for NavigationTreeWidget, animate to its itemWidget - if isinstance(targetWidget, NavigationTreeWidget): - self.indicatorAnimator.animateTo(targetWidget.itemWidget) - else: - self.indicatorAnimator.animateTo(targetWidget) + newItem = self.items[routeKey].widget + # Find previous selected item + prevItem = None for k, item in self.items.items(): - item.widget.setSelected(k == routeKey) + if item.widget.isSelected: + prevItem = item.widget + break + + if newItem == prevItem: + return + + # If target is same as pending animation target, do nothing + if self._pendingIndicatorWidget == newItem: + return + + # Determine start widget for animation (proxy for hidden child) + startWidget = prevItem + if prevItem and not prevItem.isVisible(): + p = prevItem + while p: + if isinstance(p, NavigationWidget) and p.isVisible(): + startWidget = p + break + p = p.parent() + + if prevItem: + prevItem.setSelected(False) + prevItem.setIndicatorVisible(False) + + if startWidget and startWidget != prevItem: + startWidget.update() + startWidget.isPressed = False + startWidget.isEnter = False + + newItem.setSelected(True) + newItem.setIndicatorVisible(False) + + if startWidget and startWidget.isVisible() and newItem.isVisible() and self.isVisible(): + # Check if we need CrossFade (level change, using proxy, or large distance) + useCrossFade = False + if startWidget != prevItem: + useCrossFade = True + elif hasattr(prevItem, 'nodeDepth') and hasattr(newItem, 'nodeDepth'): + if prevItem.nodeDepth != newItem.nodeDepth: + useCrossFade = True + + # Check distance for large gaps + if not useCrossFade: + startRect = self._getIndicatorRect(startWidget) + endRect = self._getIndicatorRect(newItem) + + dist = abs(startRect.y() - endRect.y()) + + if dist > 200: + useCrossFade = True + + self._startIndicatorAnimation(startWidget, newItem, useCrossFade) + else: + newItem.setIndicatorVisible(True) + + def _startIndicatorAnimation(self, prevItem: NavigationWidget, newItem: NavigationWidget, useCrossFade=False): + startRect = self._getIndicatorRect(prevItem) + endRect = self._getIndicatorRect(newItem) + + self.indicator.setIndicatorColor(newItem.lightIndicatorColor, newItem.darkIndicatorColor) + + try: + self.indicator.aniGroup.finished.disconnect(self._onIndicatorFinished) + except: + pass + + self.indicator.aniGroup.finished.connect(self._onIndicatorFinished) + self._pendingIndicatorWidget = newItem + + self.indicator.raise_() + self.indicator.animate(startRect, endRect, False, useCrossFade) + + def _onIndicatorFinished(self): + if self._pendingIndicatorWidget: + self._pendingIndicatorWidget.setIndicatorVisible(True) + self._pendingIndicatorWidget = None + + def _stopIndicatorAnimation(self): + self.indicator.stopAnimation() + self.indicator.hide() + self._onIndicatorFinished() + try: + self.indicator.aniGroup.finished.disconnect(self._onIndicatorFinished) + except: + pass + + def _getIndicatorRect(self, widget: NavigationWidget): + pos = widget.mapTo(self, QPoint(0, 0)) + rect = widget.indicatorRect() + return QRectF(pos.x() + rect.x(), pos.y() + rect.y(), rect.width(), rect.height()) def _onWidgetClicked(self): widget = self.sender() # type: NavigationWidget @@ -703,6 +754,8 @@ def _onExpandAniFinished(self): self.setProperty('menu', False) self.setStyle(QApplication.style()) + # Stop indicator animation to prevent misalignment when header collapses + self._stopIndicatorAnimation() self._setWidgetCompacted(True) if not self._parent.isWindow(): diff --git a/qfluentwidgets/components/navigation/navigation_widget.py b/qfluentwidgets/components/navigation/navigation_widget.py index 73ac72c2..0c84e1cf 100644 --- a/qfluentwidgets/components/navigation/navigation_widget.py +++ b/qfluentwidgets/components/navigation/navigation_widget.py @@ -13,6 +13,7 @@ from ...common.icon import FluentIcon as FIF from ...common.color import autoFallbackThemeColor from ...common.font import setFont +from .navigation_indicator import NavigationIndicator from ..widgets.scroll_area import ScrollArea from ..widgets.label import AvatarWidget from ..widgets.info_badge import InfoBadgeManager, InfoBadgePosition @@ -38,6 +39,7 @@ def __init__(self, isSelectable: bool, parent=None): # text color self.lightTextColor = QColor(0, 0, 0) self.darkTextColor = QColor(255, 255, 255) + self._isIndicatorVisible = True self.setFixedSize(40, 36) @@ -110,6 +112,19 @@ def setTextColor(self, light, dark): self.setLightTextColor(light) self.setDarkTextColor(dark) + def setIndicatorVisible(self, isVisible: bool): + """ set whether to show the selection indicator """ + self._isIndicatorVisible = isVisible + self.update() + + def indicatorRect(self): + """ get the indicator geometry """ + m = self._margins() + return QRectF(m.left(), 10, 3, 16) + + def _margins(self): + return QMargins(0, 0, 0, 0) + class NavigationPushButton(NavigationWidget): """ Navigation push button """ @@ -130,7 +145,6 @@ def __init__(self, icon: Union[str, QIcon, FIF], text: str, isSelectable: bool, self._text = text self.lightIndicatorColor = QColor() self.darkIndicatorColor = QColor() - self._indicatorAnimator = None # will be set by panel setFont(self) @@ -176,31 +190,17 @@ def paintEvent(self, e): pl, pr = m.left(), m.right() globalRect = QRect(self.mapToGlobal(QPoint()), self.size()) - # check if we should draw indicator through animator - if self._indicatorAnimator: - # animator handles all indicator drawing - if self._canDrawIndicator() or self._indicatorAnimator._lastSelectedWidget == self: - painter.setBrush(QColor(c, c, c, 6 if self.isEnter else 10)) - painter.drawRoundedRect(self.rect(), 5, 5) - elif self.isEnter and self.isEnabled() and globalRect.contains(QCursor.pos()): - painter.setBrush(QColor(c, c, c, 10)) - painter.drawRoundedRect(self.rect(), 5, 5) - - # let animator handle indicator drawing - color = autoFallbackThemeColor(self.lightIndicatorColor, self.darkIndicatorColor) - self._indicatorAnimator.drawIndicator(painter, self, self.rect(), color, pl) - else: - # fallback to original static behavior - if self._canDrawIndicator(): - painter.setBrush(QColor(c, c, c, 6 if self.isEnter else 10)) - painter.drawRoundedRect(self.rect(), 5, 5) - - # draw static indicator + if self._canDrawIndicator(): + painter.setBrush(QColor(c, c, c, 6 if self.isEnter else 10)) + painter.drawRoundedRect(self.rect(), 5, 5) + + # draw indicator + if self._isIndicatorVisible: painter.setBrush(autoFallbackThemeColor(self.lightIndicatorColor, self.darkIndicatorColor)) painter.drawRoundedRect(pl, 10, 3, 16, 1.5, 1.5) - elif self.isEnter and self.isEnabled() and globalRect.contains(QCursor.pos()): - painter.setBrush(QColor(c, c, c, 10)) - painter.drawRoundedRect(self.rect(), 5, 5) + elif self.isEnter and self.isEnabled() and globalRect.contains(QCursor.pos()): + painter.setBrush(QColor(c, c, c, 10)) + painter.drawRoundedRect(self.rect(), 5, 5) drawIcon(self._icon, painter, QRectF(11.5+pl, 10, 16, 16)) @@ -505,6 +505,20 @@ def __initWidget(self): self.expandAni.valueChanged.connect(self.expanded) self.expandAni.finished.connect(self.parentWidget().layout().invalidate) + def indicatorRect(self): + return self.itemWidget.indicatorRect() + + def setIndicatorVisible(self, isVisible: bool): + self.itemWidget.setIndicatorVisible(isVisible) + + @property + def lightIndicatorColor(self): + return self.itemWidget.lightIndicatorColor + + @property + def darkIndicatorColor(self): + return self.itemWidget.darkIndicatorColor + def addChild(self, child): self.insertChild(-1, child) @@ -746,12 +760,10 @@ def __init__(self, tree: NavigationTreeWidget, parent=None): self.treeWidget = tree self.treeChildren = [] - - # create indicator animator for flyout menu - from .navigation_indicator import NavigationIndicatorAnimator - self.indicatorAnimator = NavigationIndicatorAnimator(self) + self._selectedItem = None self.vBoxLayout = QVBoxLayout(self.view) + self.indicator = NavigationIndicator(self.view) self.setWidget(self.view) self.setWidgetResizable(True) @@ -777,24 +789,63 @@ def _initNode(self, root: NavigationTreeWidget): for c in root.treeChildren: c.nodeDepth -= 1 c.setCompacted(False) - - # use shared indicator animator for flyout menu - c.itemWidget._indicatorAnimator = self.indicatorAnimator + + # Connect clicked signal for animation + c.clicked.connect(self._onItemClicked) + + # Handle initial selection + if c.itemWidget.isSelected: + self._selectedItem = c + c.setIndicatorVisible(False) + self.indicator.setIndicatorColor(c.lightIndicatorColor, c.darkIndicatorColor) + self.indicator.setGeometry(self._getIndicatorRect(c)) + self.indicator.setOpacity(1) + self.indicator.show() if c.isLeaf(): - # trigger animation and close flyout when leaf is clicked - widget = c.itemWidget - c.clicked.connect(lambda w=widget: self.indicatorAnimator.animateTo(w)) c.clicked.connect(self.window().fadeOut) self._initNode(c) + def _onItemClicked(self): + sender = self.sender() # type: NavigationTreeWidget + if sender == self._selectedItem: + return + + if self._selectedItem: + self._selectedItem.setIndicatorVisible(False) + self._selectedItem.setSelected(False) + + self._updateIndicator(self._selectedItem, sender) + self._selectedItem = sender + sender.setSelected(True) + sender.setIndicatorVisible(False) + + def _updateIndicator(self, prev, current): + startRect = self._getIndicatorRect(prev) if prev else self._getIndicatorRect(current) + endRect = self._getIndicatorRect(current) + + if not prev: + # Initial appear animation + self.indicator.setGeometry(endRect) + self.indicator.setOpacity(1) + self.indicator.show() + # Use CrossFade from 0 height + self.indicator.animate(startRect, endRect, useCrossFade=True) + else: + self.indicator.setIndicatorColor(current.lightIndicatorColor, current.darkIndicatorColor) + self.indicator.animate(startRect, endRect, useCrossFade=True) + + def _getIndicatorRect(self, widget: NavigationTreeWidget): + rect = widget.indicatorRect() + pos = widget.mapTo(self.view, QPoint(0, 0)) + return QRectF(pos.x() + rect.x(), pos.y() + rect.y(), rect.width(), rect.height()) + def _adjustViewSize(self, emit=True): w = self._suitableWidth() # adjust the width of node for node in self.visibleTreeNodes(): - node.setFixedWidth(w - 10) node.itemWidget.setFixedWidth(w - 10) self.view.setFixedSize(w, self.view.sizeHint().height()) From 205dc779ae6ce2a69411ef6ed99005cdfd943476 Mon Sep 17 00:00:00 2001 From: JustKanade Date: Sun, 23 Nov 2025 18:59:11 +0800 Subject: [PATCH 3/4] feat(navigation): add configurable indicator animation and cross-fade threshold --- .../navigation_indicator_animation/demo.py | 5 +++++ .../navigation/navigation_indicator.py | 12 +++++++++++ .../components/navigation/navigation_panel.py | 20 ++++++++++++++++++- .../navigation/navigation_widget.py | 9 ++++++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/examples/navigation/navigation_indicator_animation/demo.py b/examples/navigation/navigation_indicator_animation/demo.py index 1a5e2f27..d3b970bf 100644 --- a/examples/navigation/navigation_indicator_animation/demo.py +++ b/examples/navigation/navigation_indicator_animation/demo.py @@ -63,6 +63,11 @@ def initLayout(self): self.hBoxLayout.setStretchFactor(self.stackWidget, 1) def initNavigation(self): + # When vertical distance between items exceeds this threshold, CrossFade animation will be used + self.navigationInterface.panel.setCrossFadeDistanceThreshold(200) + + # Set to False to disable smooth animations and instantly show indicator + self.navigationInterface.panel.indicator.setIndicatorAnimationEnabled(True) # add items to top self.addSubInterface(self.homeInterface, FIF.HOME, 'Home') diff --git a/qfluentwidgets/components/navigation/navigation_indicator.py b/qfluentwidgets/components/navigation/navigation_indicator.py index 9a8a4b5e..83fcac60 100644 --- a/qfluentwidgets/components/navigation/navigation_indicator.py +++ b/qfluentwidgets/components/navigation/navigation_indicator.py @@ -21,6 +21,7 @@ def __init__(self, parent=None): self._opacity = 0.0 self._geometry = QRectF(0, 0, 3, 16) + self._isIndicatorAnimationEnabled = True self.lightColor = themeColor() self.darkColor = themeColor() self.isHorizontal = False @@ -33,6 +34,10 @@ def setIndicatorColor(self, light, dark): self.darkColor = QColor(dark) self.update() + def setIndicatorAnimationEnabled(self, enabled: bool): + """ set whether indicator animation is enabled """ + self._isIndicatorAnimationEnabled = enabled + def getOpacity(self): return self._opacity @@ -90,6 +95,13 @@ def animate(self, startRect: QRectF, endRect: QRectF, isHorizontal=False, useCro self.stopAnimation() self.isHorizontal = isHorizontal + # If animation is disabled, directly set final state + if not self._isIndicatorAnimationEnabled: + self.setGeometry(endRect) + self.setOpacity(1) + self.show() + return + # Determine if same level if isHorizontal: sameLevel = abs(startRect.y() - endRect.y()) < 1 diff --git a/qfluentwidgets/components/navigation/navigation_panel.py b/qfluentwidgets/components/navigation/navigation_panel.py index b7a17de8..6375338c 100644 --- a/qfluentwidgets/components/navigation/navigation_panel.py +++ b/qfluentwidgets/components/navigation/navigation_panel.py @@ -89,6 +89,7 @@ def __init__(self, parent=None, isMinimalEnabled=False): self.indicator = NavigationIndicator(self) self._pendingIndicatorWidget = None # type: NavigationWidget + self._crossFadeDistanceThreshold = 350 self.expandAni = QPropertyAnimation(self, b'geometry', self) self.expandWidth = 322 @@ -481,6 +482,10 @@ def isAcrylicEnabled(self): """ whether the acrylic effect is enabled """ return self._isAcrylicEnabled + def setCrossFadeDistanceThreshold(self, threshold: int): + """ set the distance threshold for triggering CrossFade animation """ + self._crossFadeDistanceThreshold = max(0, threshold) + def expand(self, useAni=True): """ expand navigation panel """ self._stopIndicatorAnimation() @@ -617,7 +622,7 @@ def setCurrentItem(self, routeKey: str): dist = abs(startRect.y() - endRect.y()) - if dist > 200: + if dist > self._crossFadeDistanceThreshold: useCrossFade = True self._startIndicatorAnimation(startWidget, newItem, useCrossFade) @@ -757,6 +762,19 @@ def _onExpandAniFinished(self): # Stop indicator animation to prevent misalignment when header collapses self._stopIndicatorAnimation() self._setWidgetCompacted(True) + + # Ensure parent node shows indicator if child is selected + for item in self.items.values(): + w = item.widget + if w.isSelected and not w.isVisible() and item.parentRouteKey: + # Find visible parent (tree root) to show indicator + parent = w.parent() + while parent and isinstance(parent, NavigationWidget): + if parent.isVisible() and isinstance(parent, NavigationTreeWidgetBase): + parent.setIndicatorVisible(True) + parent.update() + break + parent = parent.parent() if not self._parent.isWindow(): self.setParent(self._parent) diff --git a/qfluentwidgets/components/navigation/navigation_widget.py b/qfluentwidgets/components/navigation/navigation_widget.py index 0c84e1cf..1cdf2577 100644 --- a/qfluentwidgets/components/navigation/navigation_widget.py +++ b/qfluentwidgets/components/navigation/navigation_widget.py @@ -382,8 +382,9 @@ def _canDrawIndicator(self): if p.isLeaf() or p.isSelected: return p.isSelected + # Check if any child is selected, regardless of visibility for child in p.treeChildren: - if child.itemWidget._canDrawIndicator() and not child.isVisible(): + if child.itemWidget._canDrawIndicator(): return True return False @@ -809,6 +810,12 @@ def _initNode(self, root: NavigationTreeWidget): def _onItemClicked(self): sender = self.sender() # type: NavigationTreeWidget + + # Only handle selection for leaf nodes (nodes without children) + # Parent nodes should only expand/collapse without changing selection + if not sender.isLeaf(): + return + if sender == self._selectedItem: return From 4abca9dc16b9c2cccd1a58f62aff92137c6c49aa Mon Sep 17 00:00:00 2001 From: JustKanade Date: Sat, 29 Nov 2025 14:27:01 +0800 Subject: [PATCH 4/4] refactor(navigation): extract tree parent indicator --- .../components/navigation/navigation_panel.py | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/qfluentwidgets/components/navigation/navigation_panel.py b/qfluentwidgets/components/navigation/navigation_panel.py index 6375338c..00116ea9 100644 --- a/qfluentwidgets/components/navigation/navigation_panel.py +++ b/qfluentwidgets/components/navigation/navigation_panel.py @@ -628,6 +628,9 @@ def setCurrentItem(self, routeKey: str): self._startIndicatorAnimation(startWidget, newItem, useCrossFade) else: newItem.setIndicatorVisible(True) + # Ensure tree parent shows indicator in compact mode + if not newItem.isVisible(): + self._updateTreeParentIndicators() def _startIndicatorAnimation(self, prevItem: NavigationWidget, newItem: NavigationWidget, useCrossFade=False): startRect = self._getIndicatorRect(prevItem) @@ -660,6 +663,22 @@ def _stopIndicatorAnimation(self): except: pass + def _updateTreeParentIndicators(self): + """ ensure tree parent shows indicator when child is selected in compact mode """ + for item in self.items.values(): + w = item.widget + if not (w.isSelected and not w.isVisible() and item.parentRouteKey): + continue + + # Use treeParent to traverse tree structure + parent = getattr(w, 'treeParent', None) + while parent: + if parent.isVisible() and isinstance(parent, NavigationTreeWidgetBase): + parent.setIndicatorVisible(True) + parent.update() + break + parent = getattr(parent, 'treeParent', None) + def _getIndicatorRect(self, widget: NavigationWidget): pos = widget.mapTo(self, QPoint(0, 0)) rect = widget.indicatorRect() @@ -762,19 +781,7 @@ def _onExpandAniFinished(self): # Stop indicator animation to prevent misalignment when header collapses self._stopIndicatorAnimation() self._setWidgetCompacted(True) - - # Ensure parent node shows indicator if child is selected - for item in self.items.values(): - w = item.widget - if w.isSelected and not w.isVisible() and item.parentRouteKey: - # Find visible parent (tree root) to show indicator - parent = w.parent() - while parent and isinstance(parent, NavigationWidget): - if parent.isVisible() and isinstance(parent, NavigationTreeWidgetBase): - parent.setIndicatorVisible(True) - parent.update() - break - parent = parent.parent() + self._updateTreeParentIndicators() if not self._parent.isWindow(): self.setParent(self._parent)