Files
LorenEteval_Furious/Furious/Widget/NavigationView.py
T
2026-08-12 12:20:50 +08:00

271 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)