mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-29 10:28:07 +03:00
271 lines
8.9 KiB
Python
271 lines
8.9 KiB
Python
# 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)
|