Introduce page-based navigation

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 16f731fcca
commit f2f5c6bcf2
13 changed files with 1463 additions and 1030 deletions
+270
View File
@@ -0,0 +1,270 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Provide extensible collapsible navigation for application pages."""
from __future__ import annotations
from Furious.Frozenlib import APP, Mixins
from Furious.Qt import AppStyleSheet, bootstrapIcon, bootstrapIconWhite
from Furious.Qt import gettext as _
from PySide6 import QtCore
from PySide6.QtWidgets import *
from dataclasses import dataclass
__all__ = ['NavigationView']
@dataclass
class _NavigationPage:
"""Describe one registered page and its navigation control."""
id: str
title: str
iconFileName: str
widget: QWidget
button: QToolButton
translatable: bool = True
class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
"""Route registered pages through a collapsible left navigation rail."""
pageChanged = QtCore.Signal(str)
expandedChanged = QtCore.Signal(bool)
CollapsedWidth = 56
ExpandedWidth = 220
IconSize = QtCore.QSize(20, 20)
def __init__(self, parent=None):
"""Initialize an empty page registry and its navigation controls."""
super().__init__(parent)
self._pages: dict[str, _NavigationPage] = {}
self._currentPageId = ''
self._expanded = False
self.setObjectName('NavigationView')
self.navigationPanel = QFrame(parent=self)
self.navigationPanel.setObjectName('NavigationPanel')
self.toggleButton = QToolButton(parent=self.navigationPanel)
self.toggleButton.setObjectName('NavigationToggleButton')
self.toggleButton.setToolTip(_('Expand Navigation'))
self.toggleButton.setIconSize(self.IconSize)
self.toggleButton.clicked.connect(self.toggleExpanded)
self.navigationLayout = QVBoxLayout(self.navigationPanel)
self.navigationLayout.setContentsMargins(6, 8, 6, 8)
self.navigationLayout.setSpacing(4)
self.navigationLayout.addWidget(self.toggleButton)
self.pageButtonLayout = QVBoxLayout()
self.pageButtonLayout.setContentsMargins(0, 6, 0, 0)
self.pageButtonLayout.setSpacing(4)
self.navigationLayout.addLayout(self.pageButtonLayout)
self.navigationLayout.addStretch()
self.pageButtonGroup = QButtonGroup(self)
self.pageButtonGroup.setExclusive(True)
self.pageStack = QStackedWidget(parent=self)
self.pageStack.setObjectName('ApplicationPageStack')
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(0)
self._layout.addWidget(self.navigationPanel)
self._layout.addWidget(self.pageStack, 1)
self.setExpanded(False)
self.setIconByTheme(APP().theme())
def addPage(
self,
pageId: str,
widget: QWidget,
title: str,
iconFileName: str,
*,
translatable: bool = True,
):
"""Register a page and create its navigation control."""
if not isinstance(pageId, str) or not pageId:
raise ValueError('pageId must be a non-empty string')
if pageId in self._pages:
raise ValueError(f'page is already registered: {pageId}')
if not isinstance(widget, QWidget):
raise TypeError('widget must be a QWidget')
button = QToolButton(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.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
button.clicked.connect(
lambda _checked=False, selectedPageId=pageId: self.setCurrentPage(
selectedPageId
)
)
page = _NavigationPage(
pageId,
title,
iconFileName,
widget,
button,
translatable,
)
self._pages[pageId] = page
self.pageButtonGroup.addButton(button)
self.pageButtonLayout.addWidget(button)
self.pageStack.addWidget(widget)
self._updatePageText(page)
self._updatePageIcon(page, APP().theme())
if not self._currentPageId:
self.setCurrentPage(pageId)
def page(self, pageId: str) -> QWidget | None:
"""Return the registered widget for *pageId*."""
page = self._pages.get(pageId)
return page.widget if page is not None else None
def currentPageId(self) -> str:
"""Return the identifier of the visible page."""
return self._currentPageId
@QtCore.Slot(str)
def setCurrentPage(self, pageId: str):
"""Show the page registered under *pageId*."""
page = self._pages.get(pageId)
if page is None:
return
changed = pageId != self._currentPageId
self._currentPageId = pageId
page.button.setChecked(True)
self.pageStack.setCurrentWidget(page.widget)
if changed:
self.pageChanged.emit(pageId)
def isExpanded(self) -> bool:
"""Return whether labels are visible beside navigation icons."""
return self._expanded
@QtCore.Slot()
def toggleExpanded(self):
"""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."""
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')
)
self.toggleButton.setProperty('expanded', expanded)
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)
self._updatePageText(page)
if changed:
self.expandedChanged.emit(expanded)
@staticmethod
def _refreshWidgetStyle(widget: QWidget):
"""Re-polish a widget after a dynamic property changes."""
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
def _updatePageText(self, page: _NavigationPage):
"""Apply translated navigation text and compact-mode tooltips."""
text = _(page.title) if page.translatable else page.title
page.button.setText(text)
page.button.setToolTip('' if self._expanded else text)
@staticmethod
def _iconFactory(theme: str):
"""Return the icon factory appropriate for *theme*."""
return bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon
def _updatePageIcon(self, page: _NavigationPage, theme: str):
"""Apply one page icon for the active theme."""
page.button.setIcon(self._iconFactory(theme)(page.iconFileName))
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))
for page in self._pages.values():
self._updatePageIcon(page, theme)
def themeChangedCallback(self, theme: str):
"""Refresh icons after an application theme change."""
self.setIconByTheme(theme)
def retranslate(self):
"""Refresh navigation labels and expansion affordances."""
self.toggleButton.setToolTip(
_('Collapse Navigation') if self._expanded else _('Expand Navigation')
)
for page in self._pages.values():
self._updatePageText(page)