mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-13 01:38:33 +03:00
Introduce page-based navigation
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user