Polish navigation controls

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:50 +08:00
parent eb32952c01
commit e7e36f01ce
6 changed files with 301 additions and 50 deletions
+22 -7
View File
@@ -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']};
+169
View File
@@ -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."""
+4
View File
@@ -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',
+94 -32
View File
@@ -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)
+8 -8
View File
@@ -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):
+4 -3
View File
@@ -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'))