From 5a8559f9aaa4a290981e2abef0d2f49189d8dc94 Mon Sep 17 00:00:00 2001 From: JustKanade Date: Tue, 5 May 2026 16:38:38 +0800 Subject: [PATCH 1/6] feat: refine ms_fluent_window navigation bar animations --- examples/window/ms_fluent_window/demo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/window/ms_fluent_window/demo.py b/examples/window/ms_fluent_window/demo.py index 78b5cddb0..8879a9388 100644 --- a/examples/window/ms_fluent_window/demo.py +++ b/examples/window/ms_fluent_window/demo.py @@ -1,6 +1,7 @@ # coding:utf-8 import sys - +#from pathlib import Path +#sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QIcon, QDesktopServices from PyQt5.QtWidgets import QApplication, QWidget, QHBoxLayout From f8a5042c0899b62be0db4adb8f56b69144bf190d Mon Sep 17 00:00:00 2001 From: JustKanade Date: Tue, 5 May 2026 16:39:01 +0800 Subject: [PATCH 2/6] feat: refine ms_fluent_window navigation bar animations --- examples/navigation/navigation_bar/demo.py | 7 +- qfluentwidgets/common/animation.py | 132 ++++++++-------- .../components/navigation/navigation_bar.py | 148 ++++++++++++++---- 3 files changed, 189 insertions(+), 98 deletions(-) diff --git a/examples/navigation/navigation_bar/demo.py b/examples/navigation/navigation_bar/demo.py index ffd3561aa..99ead5f39 100644 --- a/examples/navigation/navigation_bar/demo.py +++ b/examples/navigation/navigation_bar/demo.py @@ -166,8 +166,11 @@ def initNavigation(self): self.stackWidget.currentChanged.connect(self.onCurrentInterfaceChanged) self.navigationBar.setCurrentItem(self.homeInterface.objectName()) - # hide the text of button when selected - # self.navigationBar.setSelectedTextVisible(False) + # show the text of button when selected + # self.navigationBar.setSelectedTextVisible(True) + + # disable item animation + # self.navigationBar.setItemAnimationEnabled(False) # adjust the font size of button # self.navigationBar.setFont(getFont(12)) diff --git a/qfluentwidgets/common/animation.py b/qfluentwidgets/common/animation.py index 98d490740..42cdf8012 100644 --- a/qfluentwidgets/common/animation.py +++ b/qfluentwidgets/common/animation.py @@ -540,13 +540,39 @@ def __init__(self, parent=None, orient=Qt.Orientation.Horizontal): super().__init__(parent) self.orient = orient self._geometry = QRectF(0, 0, 16, 3) if self.isHorizontal() else QRectF(0, 0, 3, 16) + self._initAnimations() + + def _initAnimations(self): + stretchCurve = FluentAnimation.createBezierCurve(0.9, 0.1, 1, 0.2) + settleCurve = FluentAnimation.createBezierCurve(0.1, 0.9, 0.2, 1.0) + + self._slidePosAni1 = self._createAnimation(b"pos", 200, stretchCurve) + self._slidePosAni2 = self._createAnimation(b"pos", 400, settleCurve) + self._slideLengthAni1 = self._createAnimation(b"length", 200, stretchCurve) + self._slideLengthAni2 = self._createAnimation(b"length", 400, settleCurve) + + self._slidePosAniGroup = QSequentialAnimationGroup() + self._slideLengthAniGroup = QSequentialAnimationGroup() + self._slidePosAniGroup.addAnimation(self._slidePosAni1) + self._slidePosAniGroup.addAnimation(self._slidePosAni2) + self._slideLengthAniGroup.addAnimation(self._slideLengthAni1) + self._slideLengthAniGroup.addAnimation(self._slideLengthAni2) + + self._fadeLengthAni = self._createAnimation(b"length", 600, QEasingCurve.OutQuint) + self._fadePosAni = self._createAnimation(b"pos", 600, QEasingCurve.OutQuint) + + def _createAnimation(self, propertyName: bytes, duration: int, curve): + ani = QPropertyAnimation(self, propertyName) + ani.setDuration(duration) + ani.setEasingCurve(curve) + return ani def startAnimation(self, endRect: QRectF, useCrossFade=False): self.stopAnimation() startRect = QRectF(self.geometry) - # Determine if same level + # reuse slide when the indicator stays on the same axis if self.isHorizontal(): sameLevel = abs(startRect.y() - endRect.y()) < 1 dim = startRect.width() @@ -565,37 +591,20 @@ def startAnimation(self, endRect: QRectF, useCrossFade=False): def stopAnimation(self): self.stop() - self.clear() + self._removeAnimations() - def _startSlideAnimation(self, startRect, endRect, from_, to, dimension): - """ Animate the indicator using WinUI 3 squash and stretch logic + def _removeAnimations(self): + while self.animationCount(): + self.removeAnimation(self.animationAt(0)) - 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 - """ - posAni1 = QPropertyAnimation(self, b"pos") - posAni2 = QPropertyAnimation(self, b"pos") - posAni1.setDuration(200) - posAni2.setDuration(400) - posAni1.setEasingCurve(FluentAnimation.createBezierCurve(0.9, 0.1, 1, 0.2)) - posAni2.setEasingCurve(FluentAnimation.createBezierCurve(0.1, 0.9, 0.2, 1.0)) - - lengthAni1 = QPropertyAnimation(self, b"length") - lengthAni2 = QPropertyAnimation(self, b"length") - lengthAni1.setDuration(200) - lengthAni2.setDuration(400) - lengthAni1.setEasingCurve(FluentAnimation.createBezierCurve(0.9, 0.1, 1, 0.2)) - lengthAni2.setEasingCurve(FluentAnimation.createBezierCurve(0.1, 0.9, 0.2, 1.0)) - - posAniGroup = QSequentialAnimationGroup() - lengthAniGroup = QSequentialAnimationGroup() - posAniGroup.addAnimation(posAni1) - posAniGroup.addAnimation(posAni2) - lengthAniGroup.addAnimation(lengthAni1) - lengthAniGroup.addAnimation(lengthAni2) - self.addAnimation(posAniGroup) - self.addAnimation(lengthAniGroup) + def _setActiveAnimations(self, *animations): + self._removeAnimations() + for ani in animations: + self.addAnimation(ani) + + def _startSlideAnimation(self, startRect, endRect, from_, to, dimension): + """ Animate the indicator using WinUI squash and stretch logic """ + self._setActiveAnimations(self._slidePosAniGroup, self._slideLengthAniGroup) dist = abs(to - from_) midLength = dist + dimension @@ -605,39 +614,33 @@ def _startSlideAnimation(self, startRect, endRect, from_, to, dimension): endPos = endRect.topLeft() if isForward: - # A--B ----M---> A'--B' - # 0->0.33: B moves to M (len increases) - posAni1.setStartValue(startPos) - posAni1.setEndValue(startPos) - lengthAni1.setStartValue(dimension) - lengthAni1.setEndValue(midLength) - - # 0.33->1.0: A moves to A', B (at M) moves to B' - posAni2.setStartValue(startPos) - posAni2.setEndValue(endPos) - lengthAni2.setStartValue(midLength) - lengthAni2.setEndValue(dimension) + self._slidePosAni1.setStartValue(startPos) + self._slidePosAni1.setEndValue(startPos) + self._slideLengthAni1.setStartValue(dimension) + self._slideLengthAni1.setEndValue(midLength) + + self._slidePosAni2.setStartValue(startPos) + self._slidePosAni2.setEndValue(endPos) + self._slideLengthAni2.setStartValue(midLength) + self._slideLengthAni2.setEndValue(dimension) else: - # A'--B' <----M---- A--B - # 0->0.33: A moves to M (len increases) - posAni1.setStartValue(startPos) - posAni1.setEndValue(endPos) - lengthAni1.setStartValue(dimension) - lengthAni1.setEndValue(midLength) - - # 0.33->1.0: A (at M) moves to A', B moves to B' - posAni2.setStartValue(endPos) - posAni2.setEndValue(endPos) - lengthAni2.setStartValue(midLength) - lengthAni2.setEndValue(dimension) + self._slidePosAni1.setStartValue(startPos) + self._slidePosAni1.setEndValue(endPos) + self._slideLengthAni1.setStartValue(dimension) + self._slideLengthAni1.setEndValue(midLength) + + self._slidePosAni2.setStartValue(endPos) + self._slidePosAni2.setEndValue(endPos) + self._slideLengthAni2.setStartValue(midLength) + self._slideLengthAni2.setEndValue(dimension) self.start() def _startCrossFadeAnimation(self, startRect, endRect): + self._setActiveAnimations(self._fadeLengthAni, self._fadePosAni) self.setGeometry(endRect) - # Determine growth direction based on relative position - # WinUI 3 logic: Grow from top/bottom edge depending on direction + # grow from the edge closest to the previous item isNextBelow = endRect.y() > startRect.y() if not self.isHorizontal() else endRect.x() > startRect.x() if self.isHorizontal(): @@ -651,20 +654,11 @@ def _startCrossFadeAnimation(self, startRect, endRect): 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._fadeLengthAni.setStartValue(0) + self._fadeLengthAni.setEndValue(dim) - self.addAnimation(lenAni) - self.addAnimation(posAni) + self._fadePosAni.setStartValue(startGeo.topLeft()) + self._fadePosAni.setEndValue(endRect.topLeft()) self.start() def isHorizontal(self): diff --git a/qfluentwidgets/components/navigation/navigation_bar.py b/qfluentwidgets/components/navigation/navigation_bar.py index 472630a8e..c8bf18718 100644 --- a/qfluentwidgets/components/navigation/navigation_bar.py +++ b/qfluentwidgets/components/navigation/navigation_bar.py @@ -27,6 +27,7 @@ def __init__(self, parent=None): self.maxOffset = 6 self.setTargetObject(self) self.setPropertyName(b"offset") + self.setEasingCurve(QEasingCurve.OutCubic) def getOffset(self): return self._offset @@ -35,16 +36,28 @@ def setOffset(self, value: float): self._offset = value self.parent().update() - def slideDown(self): + def slideDown(self, useAni=True): """ slide down """ + self.stop() + if not useAni: + self.setOffset(self.maxOffset) + return + + self.setStartValue(self.offset) self.setEndValue(self.maxOffset) - self.setDuration(100) + self.setDuration(400) self.start() - def slideUp(self): + def slideUp(self, useAni=True): """ slide up """ + self.stop() + if not useAni: + self.setOffset(0) + return + + self.setStartValue(self.offset) self.setEndValue(0) - self.setDuration(100) + self.setDuration(400) self.start() offset = pyqtProperty(float, getOffset, setOffset) @@ -56,12 +69,18 @@ class NavigationBarPushButton(NavigationPushButton): def __init__(self, icon: Union[str, QIcon, FIF], text: str, isSelectable: bool, selectedIcon=None, parent=None): super().__init__(icon, text, isSelectable, parent) - self.iconAni = IconSlideAnimation(self) self._selectedIcon = selectedIcon - self._isSelectedTextVisible = True + self._isSelectedTextVisible = False + self._isItemAnimationEnabled = True + self._selectedIconOpacity = 0 self.lightSelectedColor = QColor() self.darkSelectedColor = QColor() + self.iconAni = IconSlideAnimation(self) + self.opacityAni = QPropertyAnimation(self, b"selectedIconOpacity", self) + self.opacityAni.setDuration(200) + self.opacityAni.setEasingCurve(QEasingCurve.OutQuad) + self.setFixedSize(64, 58) setFont(self, 11) @@ -82,6 +101,25 @@ def setSelectedIcon(self, icon: Union[str, QIcon, FIF]): def setSelectedTextVisible(self, isVisible): self._isSelectedTextVisible = isVisible + self._syncIconState(False) + self.update() + + def isItemAnimationEnabled(self): + return self._isItemAnimationEnabled + + def setItemAnimationEnabled(self, isEnabled: bool): + if isEnabled == self._isItemAnimationEnabled: + return + + self._isItemAnimationEnabled = isEnabled + self._syncIconState(False) + self.update() + + def getSelectedIconOpacity(self): + return self._selectedIconOpacity + + def setSelectedIconOpacity(self, opacity: float): + self._selectedIconOpacity = opacity self.update() def indicatorRect(self): @@ -117,28 +155,42 @@ def _drawBackground(self, painter: QPainter): painter.drawRoundedRect(self.rect(), 5, 5) def _drawIcon(self, painter: QPainter): - if (self.isPressed or not self.isEnter) and not (self.isSelected or self.isAboutSelected): - painter.setOpacity(0.6) - if not self.isEnabled(): - painter.setOpacity(0.4) + painter.save() - if self._isSelectedTextVisible: - rect = QRectF(22, 13, 20, 20) - else: - rect = QRectF(22, 13 + self.iconAni.offset, 20, 20) + opacity = self._iconOpacity() + selectedOpacity = self._selectedIconOpacity + normalOpacity = 1 - selectedOpacity + rect = QRectF(22, 13 + self.iconAni.offset, 20, 20) - selectedIcon = self._selectedIcon or self._icon + if normalOpacity > 0: + painter.setOpacity(opacity * normalOpacity) + drawIcon(self._icon, painter, rect) + + if selectedOpacity > 0: + painter.setOpacity(opacity * selectedOpacity) + self._drawSelectedIcon(painter, rect) - if isinstance(selectedIcon, FluentIconBase) and (self.isSelected or self.isAboutSelected): + painter.restore() + + def _drawSelectedIcon(self, painter: QPainter, rect: QRectF): + selectedIcon = self._selectedIcon or self._icon + if isinstance(selectedIcon, FluentIconBase): color = autoFallbackThemeColor(self.lightSelectedColor, self.darkSelectedColor) selectedIcon.render(painter, rect, fill=color.name()) - elif self.isSelected or self.isAboutSelected: - drawIcon(selectedIcon, painter, rect) else: - drawIcon(self._icon, painter, rect) + drawIcon(selectedIcon, painter, rect) + + def _iconOpacity(self): + if not self.isEnabled(): + return 0.4 + + if (self.isPressed or not self.isEnter) and not (self.isSelected or self.isAboutSelected): + return 0.6 + + return 1 def _drawText(self, painter: QPainter): - if self.isSelected and not self._isSelectedTextVisible: + if (self.isSelected or self.isAboutSelected) and not self._isSelectedTextVisible: return if self.isSelected or self.isAboutSelected: @@ -156,11 +208,36 @@ def setSelected(self, isSelected: bool): self.isSelected = isSelected self.isAboutSelected = False + self._syncIconState(self._isItemAnimationEnabled) + + def setAboutSelected(self, selected: bool): + if selected == self.isAboutSelected: + return - if isSelected: - self.iconAni.slideDown() + self.isAboutSelected = selected + self._syncIconState(self._isItemAnimationEnabled) + + def _syncIconState(self, useAni=True): + isSelected = self.isSelected or self.isAboutSelected + offset = self.iconAni.maxOffset if isSelected and not self._isSelectedTextVisible else 0 + opacity = 1 if isSelected else 0 + + self.opacityAni.stop() + if useAni: + self.opacityAni.setStartValue(self.selectedIconOpacity) + self.opacityAni.setEndValue(opacity) + self.opacityAni.start() else: - self.iconAni.slideUp() + self.setSelectedIconOpacity(opacity) + + if offset: + self.iconAni.slideDown(useAni) + else: + self.iconAni.slideUp(useAni) + + self.update() + + selectedIconOpacity = pyqtProperty(float, getSelectedIconOpacity, setSelectedIconOpacity) class NavigationBar(QWidget): @@ -169,7 +246,8 @@ def __init__(self, parent=None): super().__init__(parent=parent) self.indicator = NavigationIndicator(self) self._isIndicatorAnimationEnabled = True - self._isSelectedTextVisible = True + self._isSelectedTextVisible = False + self._isItemAnimationEnabled = True self.lightSelectedColor = QColor() self.darkSelectedColor = QColor() @@ -315,6 +393,7 @@ def insertItem(self, index: int, routeKey: str, icon: Union[str, QIcon, FluentIc w = NavigationBarPushButton(icon, text, selectable, selectedIcon, self) w.setSelectedColor(self.lightSelectedColor, self.darkSelectedColor) w.setSelectedTextVisible(self.isSelectedTextVisible()) + w.setItemAnimationEnabled(self.isItemAnimationEnabled()) self.insertWidget(index, routeKey, w, onClick, position) return w @@ -439,17 +518,32 @@ def setSelectedTextVisible(self, isVisible: bool): self._isSelectedTextVisible = isVisible for widget in self.buttons(): - widget.setSelectedTextVisible(isVisible) + if hasattr(widget, 'setSelectedTextVisible'): + widget.setSelectedTextVisible(isVisible) def isSelectedTextVisible(self): return self._isSelectedTextVisible + def setItemAnimationEnabled(self, isEnabled: bool): + """ set whether item animations are enabled """ + if isEnabled == self._isItemAnimationEnabled: + return + + self._isItemAnimationEnabled = isEnabled + for widget in self.buttons(): + if hasattr(widget, 'setItemAnimationEnabled'): + widget.setItemAnimationEnabled(isEnabled) + + def isItemAnimationEnabled(self): + return self._isItemAnimationEnabled + def setSelectedColor(self, light, dark): """ set the selected color of all items """ self.lightSelectedColor = QColor(light) self.darkSelectedColor = QColor(dark) for button in self.buttons(): - button.setSelectedColor(self.lightSelectedColor, self.darkSelectedColor) + if hasattr(button, 'setSelectedColor'): + button.setSelectedColor(self.lightSelectedColor, self.darkSelectedColor) def buttons(self): return [i for i in self.items.values() if isinstance(i, NavigationPushButton)] @@ -485,6 +579,6 @@ def _onIndicatorAniFinished(self): if not item: return - item.setAboutSelected(False) item.setSelected(True) + item.setAboutSelected(False) self.indicator.hide() From e7f5597c98eccd7fd362747945da5e3d7683d5bb Mon Sep 17 00:00:00 2001 From: JustKanade Date: Tue, 5 May 2026 17:18:58 +0800 Subject: [PATCH 3/6] fix: smooth navigation hover fade --- .../components/navigation/navigation_bar.py | 80 +++++++++++++++---- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/qfluentwidgets/components/navigation/navigation_bar.py b/qfluentwidgets/components/navigation/navigation_bar.py index c8bf18718..ca54502d0 100644 --- a/qfluentwidgets/components/navigation/navigation_bar.py +++ b/qfluentwidgets/components/navigation/navigation_bar.py @@ -73,13 +73,17 @@ def __init__(self, icon: Union[str, QIcon, FIF], text: str, isSelectable: bool, self._isSelectedTextVisible = False self._isItemAnimationEnabled = True self._selectedIconOpacity = 0 + self._backgroundColor = QColor(0, 0, 0, 0) self.lightSelectedColor = QColor() self.darkSelectedColor = QColor() self.iconAni = IconSlideAnimation(self) self.opacityAni = QPropertyAnimation(self, b"selectedIconOpacity", self) + self.backgroundColorAni = QPropertyAnimation(self, b"backgroundColor", self) self.opacityAni.setDuration(200) self.opacityAni.setEasingCurve(QEasingCurve.OutQuad) + self.backgroundColorAni.setDuration(150) + self.backgroundColorAni.setEasingCurve(QEasingCurve.Linear) self.setFixedSize(64, 58) setFont(self, 11) @@ -113,6 +117,7 @@ def setItemAnimationEnabled(self, isEnabled: bool): self._isItemAnimationEnabled = isEnabled self._syncIconState(False) + self._syncBackgroundState(False) self.update() def getSelectedIconOpacity(self): @@ -122,10 +127,34 @@ def setSelectedIconOpacity(self, opacity: float): self._selectedIconOpacity = opacity self.update() + def getBackgroundColor(self): + return self._backgroundColor + + def setBackgroundColor(self, color: QColor): + self._backgroundColor = QColor(color) + self.update() + def indicatorRect(self): """ get the indicator geometry """ return QRectF(0, 16, 4, 24) + def enterEvent(self, e): + self.isEnter = True + self._syncBackgroundState(self._isItemAnimationEnabled) + + def leaveEvent(self, e): + self.isEnter = False + self.isPressed = False + self._syncBackgroundState(self._isItemAnimationEnabled) + + def mousePressEvent(self, e): + super().mousePressEvent(e) + self._syncBackgroundState(self._isItemAnimationEnabled) + + def mouseReleaseEvent(self, e): + super().mouseReleaseEvent(e) + self._syncBackgroundState(self._isItemAnimationEnabled) + def paintEvent(self, e): painter = QPainter(self) painter.setRenderHints(QPainter.Antialiasing | @@ -137,22 +166,16 @@ def paintEvent(self, e): self._drawText(painter) def _drawBackground(self, painter: QPainter): - if self.isSelected or self.isAboutSelected: - painter.setBrush(QColor(255, 255, 255, 42) if isDarkTheme() else Qt.white) + if self.backgroundColor.alpha() > 0: + painter.setBrush(self.backgroundColor) painter.drawRoundedRect(self.rect(), 5, 5) - # draw indicator - if not self.isAboutSelected: - painter.setBrush(autoFallbackThemeColor(self.lightSelectedColor, self.darkSelectedColor)) - if not self.isPressed: - painter.drawRoundedRect(0, 16, 4, 24, 2, 2) - else: - painter.drawRoundedRect(0, 19, 4, 18, 2, 2) - elif self.isPressed or self.isEnter: - c = 255 if isDarkTheme() else 0 - alpha = 9 if self.isEnter else 6 - painter.setBrush(QColor(c, c, c, alpha)) - painter.drawRoundedRect(self.rect(), 5, 5) + if self.isSelected and not self.isAboutSelected: + painter.setBrush(autoFallbackThemeColor(self.lightSelectedColor, self.darkSelectedColor)) + if not self.isPressed: + painter.drawRoundedRect(0, 16, 4, 24, 2, 2) + else: + painter.drawRoundedRect(0, 19, 4, 18, 2, 2) def _drawIcon(self, painter: QPainter): painter.save() @@ -209,6 +232,7 @@ def setSelected(self, isSelected: bool): self.isSelected = isSelected self.isAboutSelected = False self._syncIconState(self._isItemAnimationEnabled) + self._syncBackgroundState(self._isItemAnimationEnabled) def setAboutSelected(self, selected: bool): if selected == self.isAboutSelected: @@ -216,6 +240,33 @@ def setAboutSelected(self, selected: bool): self.isAboutSelected = selected self._syncIconState(self._isItemAnimationEnabled) + self._syncBackgroundState(self._isItemAnimationEnabled) + + def _targetBackgroundColor(self): + if self.isSelected or self.isAboutSelected: + return QColor(255, 255, 255, 42) if isDarkTheme() else QColor(Qt.white) + + c = 255 if isDarkTheme() else 0 + if self.isPressed: + return QColor(c, c, c, 12 if isDarkTheme() else 16) + + if self.isEnter: + return QColor(c, c, c, 32 if isDarkTheme() else 24) + + return QColor(0, 0, 0, 0) + + def _syncBackgroundState(self, useAni=True): + targetColor = self._targetBackgroundColor() + + self.backgroundColorAni.stop() + if useAni: + duration = 220 if targetColor.alpha() < self.backgroundColor.alpha() else 150 + self.backgroundColorAni.setDuration(duration) + self.backgroundColorAni.setStartValue(self.backgroundColor) + self.backgroundColorAni.setEndValue(targetColor) + self.backgroundColorAni.start() + else: + self.setBackgroundColor(targetColor) def _syncIconState(self, useAni=True): isSelected = self.isSelected or self.isAboutSelected @@ -237,6 +288,7 @@ def _syncIconState(self, useAni=True): self.update() + backgroundColor = pyqtProperty(QColor, getBackgroundColor, setBackgroundColor) selectedIconOpacity = pyqtProperty(float, getSelectedIconOpacity, setSelectedIconOpacity) From 779048d875b576dea2070d54d19b436c387519f8 Mon Sep 17 00:00:00 2001 From: NeoAegis Date: Sun, 10 May 2026 17:39:05 +0800 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20remove=20commented=C2=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/window/ms_fluent_window/demo.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/window/ms_fluent_window/demo.py b/examples/window/ms_fluent_window/demo.py index 8879a9388..2095a47cd 100644 --- a/examples/window/ms_fluent_window/demo.py +++ b/examples/window/ms_fluent_window/demo.py @@ -1,7 +1,5 @@ # coding:utf-8 import sys -#from pathlib import Path -#sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QIcon, QDesktopServices from PyQt5.QtWidgets import QApplication, QWidget, QHBoxLayout From 92ee8e047efd7708b016d6dd50390a6e5ff184c7 Mon Sep 17 00:00:00 2001 From: JustKanade Date: Sun, 10 May 2026 17:48:33 +0800 Subject: [PATCH 5/6] revert: restore animation file --- qfluentwidgets/common/animation.py | 132 +++++++++++++++-------------- 1 file changed, 69 insertions(+), 63 deletions(-) diff --git a/qfluentwidgets/common/animation.py b/qfluentwidgets/common/animation.py index 42cdf8012..98d490740 100644 --- a/qfluentwidgets/common/animation.py +++ b/qfluentwidgets/common/animation.py @@ -540,39 +540,13 @@ def __init__(self, parent=None, orient=Qt.Orientation.Horizontal): super().__init__(parent) self.orient = orient self._geometry = QRectF(0, 0, 16, 3) if self.isHorizontal() else QRectF(0, 0, 3, 16) - self._initAnimations() - - def _initAnimations(self): - stretchCurve = FluentAnimation.createBezierCurve(0.9, 0.1, 1, 0.2) - settleCurve = FluentAnimation.createBezierCurve(0.1, 0.9, 0.2, 1.0) - - self._slidePosAni1 = self._createAnimation(b"pos", 200, stretchCurve) - self._slidePosAni2 = self._createAnimation(b"pos", 400, settleCurve) - self._slideLengthAni1 = self._createAnimation(b"length", 200, stretchCurve) - self._slideLengthAni2 = self._createAnimation(b"length", 400, settleCurve) - - self._slidePosAniGroup = QSequentialAnimationGroup() - self._slideLengthAniGroup = QSequentialAnimationGroup() - self._slidePosAniGroup.addAnimation(self._slidePosAni1) - self._slidePosAniGroup.addAnimation(self._slidePosAni2) - self._slideLengthAniGroup.addAnimation(self._slideLengthAni1) - self._slideLengthAniGroup.addAnimation(self._slideLengthAni2) - - self._fadeLengthAni = self._createAnimation(b"length", 600, QEasingCurve.OutQuint) - self._fadePosAni = self._createAnimation(b"pos", 600, QEasingCurve.OutQuint) - - def _createAnimation(self, propertyName: bytes, duration: int, curve): - ani = QPropertyAnimation(self, propertyName) - ani.setDuration(duration) - ani.setEasingCurve(curve) - return ani def startAnimation(self, endRect: QRectF, useCrossFade=False): self.stopAnimation() startRect = QRectF(self.geometry) - # reuse slide when the indicator stays on the same axis + # Determine if same level if self.isHorizontal(): sameLevel = abs(startRect.y() - endRect.y()) < 1 dim = startRect.width() @@ -591,20 +565,37 @@ def startAnimation(self, endRect: QRectF, useCrossFade=False): def stopAnimation(self): self.stop() - self._removeAnimations() - - def _removeAnimations(self): - while self.animationCount(): - self.removeAnimation(self.animationAt(0)) - - def _setActiveAnimations(self, *animations): - self._removeAnimations() - for ani in animations: - self.addAnimation(ani) + self.clear() def _startSlideAnimation(self, startRect, endRect, from_, to, dimension): - """ Animate the indicator using WinUI squash and stretch logic """ - self._setActiveAnimations(self._slidePosAniGroup, self._slideLengthAniGroup) + """ 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 + """ + posAni1 = QPropertyAnimation(self, b"pos") + posAni2 = QPropertyAnimation(self, b"pos") + posAni1.setDuration(200) + posAni2.setDuration(400) + posAni1.setEasingCurve(FluentAnimation.createBezierCurve(0.9, 0.1, 1, 0.2)) + posAni2.setEasingCurve(FluentAnimation.createBezierCurve(0.1, 0.9, 0.2, 1.0)) + + lengthAni1 = QPropertyAnimation(self, b"length") + lengthAni2 = QPropertyAnimation(self, b"length") + lengthAni1.setDuration(200) + lengthAni2.setDuration(400) + lengthAni1.setEasingCurve(FluentAnimation.createBezierCurve(0.9, 0.1, 1, 0.2)) + lengthAni2.setEasingCurve(FluentAnimation.createBezierCurve(0.1, 0.9, 0.2, 1.0)) + + posAniGroup = QSequentialAnimationGroup() + lengthAniGroup = QSequentialAnimationGroup() + posAniGroup.addAnimation(posAni1) + posAniGroup.addAnimation(posAni2) + lengthAniGroup.addAnimation(lengthAni1) + lengthAniGroup.addAnimation(lengthAni2) + self.addAnimation(posAniGroup) + self.addAnimation(lengthAniGroup) dist = abs(to - from_) midLength = dist + dimension @@ -614,33 +605,39 @@ def _startSlideAnimation(self, startRect, endRect, from_, to, dimension): endPos = endRect.topLeft() if isForward: - self._slidePosAni1.setStartValue(startPos) - self._slidePosAni1.setEndValue(startPos) - self._slideLengthAni1.setStartValue(dimension) - self._slideLengthAni1.setEndValue(midLength) - - self._slidePosAni2.setStartValue(startPos) - self._slidePosAni2.setEndValue(endPos) - self._slideLengthAni2.setStartValue(midLength) - self._slideLengthAni2.setEndValue(dimension) + # A--B ----M---> A'--B' + # 0->0.33: B moves to M (len increases) + posAni1.setStartValue(startPos) + posAni1.setEndValue(startPos) + lengthAni1.setStartValue(dimension) + lengthAni1.setEndValue(midLength) + + # 0.33->1.0: A moves to A', B (at M) moves to B' + posAni2.setStartValue(startPos) + posAni2.setEndValue(endPos) + lengthAni2.setStartValue(midLength) + lengthAni2.setEndValue(dimension) else: - self._slidePosAni1.setStartValue(startPos) - self._slidePosAni1.setEndValue(endPos) - self._slideLengthAni1.setStartValue(dimension) - self._slideLengthAni1.setEndValue(midLength) - - self._slidePosAni2.setStartValue(endPos) - self._slidePosAni2.setEndValue(endPos) - self._slideLengthAni2.setStartValue(midLength) - self._slideLengthAni2.setEndValue(dimension) + # A'--B' <----M---- A--B + # 0->0.33: A moves to M (len increases) + posAni1.setStartValue(startPos) + posAni1.setEndValue(endPos) + lengthAni1.setStartValue(dimension) + lengthAni1.setEndValue(midLength) + + # 0.33->1.0: A (at M) moves to A', B moves to B' + posAni2.setStartValue(endPos) + posAni2.setEndValue(endPos) + lengthAni2.setStartValue(midLength) + lengthAni2.setEndValue(dimension) self.start() def _startCrossFadeAnimation(self, startRect, endRect): - self._setActiveAnimations(self._fadeLengthAni, self._fadePosAni) self.setGeometry(endRect) - # grow from the edge closest to the previous item + # 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(): @@ -654,11 +651,20 @@ def _startCrossFadeAnimation(self, startRect, endRect): self.setGeometry(startGeo) - self._fadeLengthAni.setStartValue(0) - self._fadeLengthAni.setEndValue(dim) + 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._fadePosAni.setStartValue(startGeo.topLeft()) - self._fadePosAni.setEndValue(endRect.topLeft()) + self.addAnimation(lenAni) + self.addAnimation(posAni) self.start() def isHorizontal(self): From a597e7cf38dc092b3a8caa46a222b605fb04f05b Mon Sep 17 00:00:00 2001 From: JustKanade Date: Sun, 10 May 2026 18:22:56 +0800 Subject: [PATCH 6/6] fix: keep selected text visible by default --- examples/navigation/navigation_bar/demo.py | 4 ++-- qfluentwidgets/components/navigation/navigation_bar.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/navigation/navigation_bar/demo.py b/examples/navigation/navigation_bar/demo.py index 99ead5f39..3356a19cf 100644 --- a/examples/navigation/navigation_bar/demo.py +++ b/examples/navigation/navigation_bar/demo.py @@ -166,8 +166,8 @@ def initNavigation(self): self.stackWidget.currentChanged.connect(self.onCurrentInterfaceChanged) self.navigationBar.setCurrentItem(self.homeInterface.objectName()) - # show the text of button when selected - # self.navigationBar.setSelectedTextVisible(True) + # hide the text of button when selected + # self.navigationBar.setSelectedTextVisible(False) # disable item animation # self.navigationBar.setItemAnimationEnabled(False) diff --git a/qfluentwidgets/components/navigation/navigation_bar.py b/qfluentwidgets/components/navigation/navigation_bar.py index ca54502d0..9e52dc5b1 100644 --- a/qfluentwidgets/components/navigation/navigation_bar.py +++ b/qfluentwidgets/components/navigation/navigation_bar.py @@ -70,7 +70,7 @@ class NavigationBarPushButton(NavigationPushButton): def __init__(self, icon: Union[str, QIcon, FIF], text: str, isSelectable: bool, selectedIcon=None, parent=None): super().__init__(icon, text, isSelectable, parent) self._selectedIcon = selectedIcon - self._isSelectedTextVisible = False + self._isSelectedTextVisible = True self._isItemAnimationEnabled = True self._selectedIconOpacity = 0 self._backgroundColor = QColor(0, 0, 0, 0) @@ -298,7 +298,7 @@ def __init__(self, parent=None): super().__init__(parent=parent) self.indicator = NavigationIndicator(self) self._isIndicatorAnimationEnabled = True - self._isSelectedTextVisible = False + self._isSelectedTextVisible = True self._isItemAnimationEnabled = True self.lightSelectedColor = QColor()