From e7e36f01cef0bab236afa90f7d94fc1fe06366d4 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Mon, 10 Aug 2026 00:03:44 +0800 Subject: [PATCH] Polish navigation controls Signed-off-by: Loren Eteval --- Furious/Qt/AppStyleSheet.py | 29 ++++-- Furious/Qt/QtWidgets.py | 169 +++++++++++++++++++++++++++++++ Furious/Qt/__init__.py | 4 + Furious/Widget/NavigationView.py | 126 +++++++++++++++++------ Furious/Window/HomePage.py | 16 +-- Furious/Window/MainWindow.py | 7 +- 6 files changed, 301 insertions(+), 50 deletions(-) diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index bffb9b0..aff30b3 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -455,11 +455,11 @@ class AppStyleSheet: border-radius: 8px; }} - QToolButton#NavigationToggleButton, - QToolButton#NavigationPageButton {{ + QPushButton#NavigationToggleButton, + QPushButton#NavigationPageButton {{ min-width: 0; min-height: 38px; - padding: 4px 10px; + padding: 0; border: 1px solid transparent; border-radius: 7px; background-color: transparent; @@ -467,14 +467,14 @@ class AppStyleSheet: text-align: left; }} - QToolButton#NavigationToggleButton:hover, - QToolButton#NavigationPageButton:hover {{ + QPushButton#NavigationToggleButton:hover, + QPushButton#NavigationPageButton:hover {{ border-color: transparent; background-color: {palette['hover']}; color: {palette['text_strong']}; }} - QToolButton#NavigationPageButton:checked {{ + QPushButton#NavigationPageButton:checked {{ border: none; border-left: 3px solid {palette['accent']}; border-radius: 7px; @@ -483,10 +483,25 @@ class AppStyleSheet: font-weight: 600; }} - QToolButton#NavigationPageButton:checked:hover {{ + QPushButton#NavigationPageButton:checked:hover {{ background-color: {palette['accent_soft_hover']}; }} + QPushButton#NavigationPageButton QLabel {{ + border: none; + background-color: transparent; + color: {palette['text']}; + }} + + QPushButton#NavigationPageButton:checked QLabel {{ + color: {palette['text_strong']}; + font-weight: 600; + }} + + QPushButton#SearchButton {{ + padding: 0; + }} + QToolButton:hover {{ border-color: {palette['border']}; background-color: {palette['hover']}; diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 294bc8e..2c0c37b 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -49,12 +49,14 @@ __all__ = [ 'AppQMenu', 'AppQMenuBar', 'AppQMessageBox', + 'AppQIconTextPushButton', 'AppQPushButton', 'AppQSpinBox', 'AppQTableView', 'AppQTableWidget', 'AppQTabWidget', 'AppQToolBar', + 'IconTextPushButton', 'MBoxQuestionDelete', 'MBoxNewChangesNextTime', 'MBoxDirectRulesNotAllowed', @@ -592,6 +594,113 @@ class AppQMessageBox(Mixins.QTranslatable, Mixins.ConnectionAware, QMessageBox): self.setWindowIcon(AppHue.connectedWindowIcon()) +class IconTextPushButton(QPushButton): + """Present a push-button icon and label through an explicit layout.""" + + def __init__( + self, + *args, + iconTextSpacing=12, + horizontalMargin=13, + verticalMargin=3, + iconSize=QtCore.QSize(16, 16), + **kwargs, + ): + """Initialize layout-managed icon and text presentation.""" + icon = kwargs.pop('icon', None) + + super().__init__(*args, **kwargs) + + text = QPushButton.text(self) + + QPushButton.setText(self, '') + QPushButton.setIcon(self, QIcon()) + QPushButton.setIconSize(self, iconSize) + + self._text = '' + self._icon = QIcon() + + self._iconLabel = QLabel(parent=self) + self._iconLabel.setObjectName('IconTextPushButtonIcon') + self._iconLabel.setFixedSize(iconSize) + self._iconLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._iconLabel.setAttribute( + QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents + ) + + self._textLabel = QLabel(parent=self) + self._textLabel.setObjectName('IconTextPushButtonText') + self._textLabel.setAttribute( + QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents + ) + + self._iconTextLayout = QHBoxLayout(self) + self._iconTextLayout.setContentsMargins( + horizontalMargin, + verticalMargin, + horizontalMargin, + verticalMargin, + ) + self._iconTextLayout.setSpacing(iconTextSpacing) + self._iconTextLayout.addWidget(self._iconLabel) + self._iconTextLayout.addWidget(self._textLabel) + self._iconTextLayout.addStretch() + + self.setText(text) + + if icon is not None: + self.setIcon(icon) + + def setIcon(self, icon: QIcon): + """Render *icon* in the layout-managed icon label.""" + self._icon = icon + self._iconLabel.setPixmap(icon.pixmap(self.iconSize())) + + def icon(self) -> QIcon: + """Return the displayed icon.""" + return self._icon + + def setIconSize(self, size: QtCore.QSize): + """Resize and rerender the layout-managed icon.""" + QPushButton.setIconSize(self, size) + + if not hasattr(self, '_iconLabel'): + return + + self._iconLabel.setFixedSize(size) + self._iconLabel.setPixmap(self._icon.pixmap(size)) + + def setText(self, text: str): + """Set the visible and accessible button label.""" + self._text = text + self._textLabel.setText(text) + self.setAccessibleName(text) + self.updateGeometry() + + def text(self) -> str: + """Return the button label.""" + return self._text + + def setTextVisible(self, visible: bool): + """Set whether the text label participates in the layout.""" + self._textLabel.setVisible(visible) + self.updateGeometry() + + def iconTextSpacing(self) -> int: + """Return the space between the icon and text label.""" + return self._iconTextLayout.spacing() + + def sizeHint(self): + """Include the managed icon and text layout in the preferred size.""" + baseHint = super().sizeHint() + layoutHint = self._iconTextLayout.sizeHint() + + return QtCore.QSize( + max(baseHint.width(), layoutHint.width()), + max(baseHint.height(), layoutHint.height()), + ) + + class AppQPushButton(Mixins.QTranslatable, Mixins.ThemeAware, QPushButton): """Represent app q push button.""" @@ -662,6 +771,66 @@ class AppQPushButton(Mixins.QTranslatable, Mixins.ThemeAware, QPushButton): self.setText(_(self.text())) +class AppQIconTextPushButton( + Mixins.QTranslatable, + Mixins.ThemeAware, + IconTextPushButton, +): + """Provide a theme-aware application icon-text push button.""" + + def __init__(self, *args, **kwargs): + """Initialize a button with an optional application icon.""" + icon = kwargs.pop('icon', None) + super().__init__(*args, **kwargs) + + self.iconFileName = '' + + if icon is not None: + self.setIcon(icon) + + def setIconByTheme(self, theme): + """Apply the stored application icon for *theme*.""" + if not self.iconFileName: + return + + if AppSettings.isStateON_('DarkMode'): + IconTextPushButton.setIcon( + self, + bootstrapIconWhite(self.iconFileName), + ) + + return + + if theme == 'Dark': + if PLATFORM == 'Windows' and versionToValue( + PYSIDE6_VERSION + ) < versionToValue('6.7.0'): + icon = bootstrapIcon(self.iconFileName) + else: + icon = bootstrapIconWhite(self.iconFileName) + else: + icon = bootstrapIcon(self.iconFileName) + + IconTextPushButton.setIcon(self, icon) + + def setIcon(self, icon: AppQIcon): + """Store an application icon and apply its themed variant.""" + self.iconFileName = AppQPushButton.getIconFileName(icon.iconFileName) + + if not self.iconFileName: + IconTextPushButton.setIcon(self, icon) + else: + self.setIconByTheme(APP().theme()) + + def themeChangedCallback(self, theme): + """Refresh the icon after an application theme change.""" + self.setIconByTheme(theme) + + def retranslate(self): + """Refresh translated button text.""" + self.setText(_(self.text())) + + class AppQSpinBox(QSpinBox): """Represent app q spin box.""" diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index a0ab24f..15dc7c5 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -57,6 +57,7 @@ from .QtWidgets import ( AppQDialogButtonBox, AppQGroupBox, AppQHeaderView, + AppQIconTextPushButton, AppQLabel, AppQLineEdit, AppQListWidget, @@ -70,6 +71,7 @@ from .QtWidgets import ( AppQTableWidget, AppQTabWidget, AppQToolBar, + IconTextPushButton, MBoxDirectRulesNotAllowed, MBoxNewChangesNextTime, MBoxQuestionDelete, @@ -105,6 +107,7 @@ __all__ = [ 'AppQDialogButtonBox', 'AppQGroupBox', 'AppQHeaderView', + 'AppQIconTextPushButton', 'AppQIcon', 'AppQLabel', 'AppQLineEdit', @@ -140,6 +143,7 @@ __all__ = [ 'GuiEditorWidgetQDialog', 'GuiEditorWidgetQGroupBox', 'GuiEditorWidgetQWidget', + 'IconTextPushButton', 'LANGUAGE_TO_ABBR', 'MBoxDirectRulesNotAllowed', 'MBoxNewChangesNextTime', diff --git a/Furious/Widget/NavigationView.py b/Furious/Widget/NavigationView.py index b38a930..ca39b36 100644 --- a/Furious/Widget/NavigationView.py +++ b/Furious/Widget/NavigationView.py @@ -20,7 +20,12 @@ from __future__ import annotations from Furious.Frozenlib import APP, Mixins -from Furious.Qt import AppStyleSheet, bootstrapIcon, bootstrapIconWhite +from Furious.Qt import ( + AppStyleSheet, + IconTextPushButton, + bootstrapIcon, + bootstrapIconWhite, +) from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -31,6 +36,28 @@ from dataclasses import dataclass __all__ = ['NavigationView'] +class _NavigationButton(IconTextPushButton): + """Present one navigation item with layout-managed icon spacing.""" + + IconSize = QtCore.QSize(20, 20) + IconTextSpacing = 12 + HorizontalMargin = 12 + + def __init__(self, parent=None): + """Initialize the icon and text presentation.""" + super().__init__( + parent, + iconTextSpacing=self.IconTextSpacing, + horizontalMargin=self.HorizontalMargin, + verticalMargin=0, + iconSize=self.IconSize, + ) + + def setExpanded(self, expanded: bool): + """Show the text label only in expanded navigation mode.""" + self.setTextVisible(expanded) + + @dataclass class _NavigationPage: """Describe one registered page and its navigation control.""" @@ -39,7 +66,7 @@ class _NavigationPage: title: str iconFileName: str widget: QWidget - button: QToolButton + button: _NavigationButton translatable: bool = True @@ -51,7 +78,8 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): CollapsedWidth = 56 ExpandedWidth = 220 - IconSize = QtCore.QSize(20, 20) + IconSize = _NavigationButton.IconSize + AnimationDuration = 150 def __init__(self, parent=None): """Initialize an empty page registry and its navigation controls.""" @@ -60,16 +88,17 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): self._pages: dict[str, _NavigationPage] = {} self._currentPageId = '' self._expanded = False + self._navigationWidth = self.CollapsedWidth self.setObjectName('NavigationView') self.navigationPanel = QFrame(parent=self) self.navigationPanel.setObjectName('NavigationPanel') - self.toggleButton = QToolButton(parent=self.navigationPanel) + self.toggleButton = _NavigationButton(parent=self.navigationPanel) self.toggleButton.setObjectName('NavigationToggleButton') + self.toggleButton.setExpanded(False) self.toggleButton.setToolTip(_('Expand Navigation')) - self.toggleButton.setIconSize(self.IconSize) self.toggleButton.clicked.connect(self.toggleExpanded) self.navigationLayout = QVBoxLayout(self.navigationPanel) @@ -95,7 +124,16 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): self._layout.addWidget(self.navigationPanel) self._layout.addWidget(self.pageStack, 1) - self.setExpanded(False) + self._widthAnimation = QtCore.QPropertyAnimation( + self, + b'navigationWidth', + parent=self, + ) + self._widthAnimation.setDuration(self.AnimationDuration) + self._widthAnimation.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) + self._widthAnimation.finished.connect(self._widthAnimationFinished) + + self.setExpanded(False, animated=False) self.setIconByTheme(APP().theme()) def addPage( @@ -117,16 +155,11 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): if not isinstance(widget, QWidget): raise TypeError('widget must be a QWidget') - button = QToolButton(parent=self.navigationPanel) + button = _NavigationButton(parent=self.navigationPanel) button.setObjectName('NavigationPageButton') button.setProperty('pageId', pageId) button.setCheckable(True) - button.setIconSize(self.IconSize) - button.setToolButtonStyle( - QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon - if self._expanded - else QtCore.Qt.ToolButtonStyle.ToolButtonIconOnly - ) + button.setExpanded(self._expanded) button.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed, @@ -191,16 +224,13 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): """Toggle between the compact rail and expanded navigation menu.""" self.setExpanded(not self._expanded) - @QtCore.Slot(bool) - def setExpanded(self, expanded: bool): - """Set the navigation menu expansion state.""" + def setExpanded(self, expanded: bool, *, animated: bool = True): + """Set expansion state, optionally animating the panel width.""" expanded = bool(expanded) changed = expanded != self._expanded + self._expanded = expanded - self.navigationPanel.setFixedWidth( - self.ExpandedWidth if expanded else self.CollapsedWidth - ) self.toggleButton.setToolTip( _('Collapse Navigation') if expanded else _('Expand Navigation') ) @@ -208,19 +238,55 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): self._refreshWidgetStyle(self.toggleButton) self.setIconByTheme(APP().theme()) - style = ( - QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon - if expanded - else QtCore.Qt.ToolButtonStyle.ToolButtonIconOnly - ) - for page in self._pages.values(): - page.button.setToolButtonStyle(style) + if expanded: + page.button.setExpanded(True) + self._updatePageText(page) + targetWidth = self.ExpandedWidth if expanded else self.CollapsedWidth + + self._widthAnimation.stop() + + if changed and animated: + self._widthAnimation.setStartValue(self._navigationWidth) + self._widthAnimation.setEndValue(targetWidth) + self._widthAnimation.start() + else: + self._setNavigationWidth(targetWidth) + + if not expanded: + self._setPageButtonsExpanded(False) + if changed: self.expandedChanged.emit(expanded) + def _getNavigationWidth(self) -> int: + """Return the width exposed to the property animation.""" + return self._navigationWidth + + def _setNavigationWidth(self, width: int): + """Apply an animated width to the navigation panel.""" + self._navigationWidth = int(width) + self.navigationPanel.setFixedWidth(self._navigationWidth) + + navigationWidth = QtCore.Property( + int, + _getNavigationWidth, + _setNavigationWidth, + ) + + def _setPageButtonsExpanded(self, expanded: bool): + """Set label visibility for all registered page buttons.""" + for page in self._pages.values(): + page.button.setExpanded(expanded) + + @QtCore.Slot() + def _widthAnimationFinished(self): + """Finish label compaction after a collapse animation.""" + if not self._expanded: + self._setPageButtonsExpanded(False) + @staticmethod def _refreshWidgetStyle(widget: QWidget): """Re-polish a widget after a dynamic property changes.""" @@ -246,12 +312,8 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): def setIconByTheme(self, theme: str): """Refresh navigation icons for the active theme.""" iconFactory = self._iconFactory(theme) - toggleIcon = ( - 'layout-sidebar-inset-reverse.svg' - if self._expanded - else 'layout-sidebar-inset.svg' - ) - self.toggleButton.setIcon(iconFactory(toggleIcon)) + + self.toggleButton.setIcon(iconFactory('list.svg')) for page in self._pages.values(): self._updatePageIcon(page, theme) diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index bcec94b..baf2fe1 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -370,24 +370,24 @@ class ConnectionStatusWidget(QWidget): self._layout.addWidget(self.networkState) -class SearchButton(AppQPushButton): +class SearchButton(AppQIconTextPushButton): """Represent search button.""" def __init__(self, *args, **kwargs): """Initialize the SearchButton.""" super().__init__(*args, **kwargs) - self.setText(self.customText()) + self.setObjectName('SearchButton') + self.setSizePolicy( + QSizePolicy.Policy.Fixed, + QSizePolicy.Policy.Fixed, + ) + self.setText(_('Search')) self.setIcon(bootstrapIcon('search.svg')) - @staticmethod - def customText(): - """Return the user-facing message text for the search button.""" - return ' ' * 2 + _('Search') - def retranslate(self): """Refresh translated text for the search button.""" - self.setText(self.customText()) + self.setText(_('Search')) class HomePage(Mixins.QTranslatable, QMainWindow): diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 209a80c..7805d34 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -73,19 +73,20 @@ class MainWindow(AppQMainWindow): 'home', self.homePage, 'Home', - 'house.svg', + 'house-door.svg', ) self.navigationView.addPage( 'log', self.logPage, 'Log', - 'terminal.svg', + 'pin-angle.svg', ) self.navigationView.pageChanged.connect(self._pageChanged) self.navigationView.expandedChanged.connect(self._navigationExpandedChanged) self.navigationView.setExpanded( - AppSettings.isStateON_('AppMainWindowNavigationExpanded') + AppSettings.isStateON_('AppMainWindowNavigationExpanded'), + animated=False, ) self.navigationView.setCurrentPage( str(AppSettings.get('AppMainWindowSelectedPage'))