diff --git a/examples/navigation/navigation_indicator_animation/demo.py b/examples/navigation/navigation_indicator_animation/demo.py new file mode 100644 index 00000000..d3b970bf --- /dev/null +++ b/examples/navigation/navigation_indicator_animation/demo.py @@ -0,0 +1,127 @@ +# 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): + # 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') + 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_() \ 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 new file mode 100644 index 00000000..83fcac60 --- /dev/null +++ b/qfluentwidgets/components/navigation/navigation_indicator.py @@ -0,0 +1,233 @@ +# coding:utf-8 +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.style_sheet import themeColor, isDarkTheme +from ...common.color import autoFallbackThemeColor + + +class NavigationIndicator(QWidget): + """ Navigation indicator """ + + def __init__(self, parent=None): + super().__init__(parent) + 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._isIndicatorAnimationEnabled = True + 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 setIndicatorAnimationEnabled(self, enabled: bool): + """ set whether indicator animation is enabled """ + self._isIndicatorAnimationEnabled = enabled + + def getOpacity(self): + return self._opacity + + def setOpacity(self, opacity): + self._opacity = opacity + self.update() + + def getGeometry(self): + return QRectF(self.geometry()) + + def setGeometry(self, geometry: QRectF): + self._geometry = geometry + super().setGeometry(geometry.toRect()) + + def getPos(self): + return QPointF(super().pos()) + + def setPos(self, pos: QPointF): + self._geometry.moveTopLeft(pos) + self.move(pos.toPoint()) + + def getLength(self): + return self.width() if self.isHorizontal else self.height() + + def setLength(self, length): + if self.isHorizontal: + self._geometry.setWidth(length) + self.resize(int(length), self.height()) + else: + 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) + + # Draw filling the widget + painter.drawRoundedRect(self.rect(), 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 + + # 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 + 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() + + 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() + + 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_panel.py b/qfluentwidgets/components/navigation/navigation_panel.py index 150118da..00116ea9 100644 --- a/qfluentwidgets/components/navigation/navigation_panel.py +++ b/qfluentwidgets/components/navigation/navigation_panel.py @@ -2,12 +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 NavigationIndicator from ..widgets.acrylic_label import AcrylicBrush from ..widgets.scroll_area import ScrollArea from ..widgets.tool_tip import ToolTipFilter @@ -86,6 +87,10 @@ 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._crossFadeDistanceThreshold = 350 + self.expandAni = QPropertyAnimation(self, b'geometry', self) self.expandWidth = 322 self.minimumExpandWidth = 1008 @@ -477,8 +482,13 @@ 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() self._setWidgetCompacted(False) self.expandAni.setProperty('expand', True) self.menuButton.setToolTip(self.tr('Close Navigation')) @@ -518,6 +528,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 @@ -553,8 +568,121 @@ def setCurrentItem(self, routeKey: str): if routeKey not in self.items: return + 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 > self._crossFadeDistanceThreshold: + useCrossFade = True + + 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) + 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 _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() + return QRectF(pos.x() + rect.x(), pos.y() + rect.y(), rect.width(), rect.height()) def _onWidgetClicked(self): widget = self.sender() # type: NavigationWidget @@ -650,7 +778,10 @@ 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) + self._updateTreeParentIndicators() 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 c18ce18f..1cdf2577 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 """ @@ -180,8 +195,9 @@ def paintEvent(self, e): 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) + 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) @@ -366,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 @@ -489,6 +506,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) @@ -730,8 +761,10 @@ def __init__(self, tree: NavigationTreeWidget, parent=None): self.treeWidget = tree self.treeChildren = [] + self._selectedItem = None self.vBoxLayout = QVBoxLayout(self.view) + self.indicator = NavigationIndicator(self.view) self.setWidget(self.view) self.setWidgetResizable(True) @@ -758,17 +791,68 @@ def _initNode(self, root: NavigationTreeWidget): c.nodeDepth -= 1 c.setCompacted(False) + # 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(): c.clicked.connect(self.window().fadeOut) self._initNode(c) + 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 + + 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())