Files
2026-08-25 11:09:02 +08:00

489 lines
16 KiB
Python
Raw Permalink 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) 2024present 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,
IconTextPushButton,
bootstrapIcon,
bootstrapIconWhite,
)
from Furious.Qt import gettext as _
from PySide6 import QtCore
from PySide6.QtWidgets import *
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
SelectionIndicatorWidth = 3
def __init__(self, parent=None, *, hasSelectionIndicator=False):
"""Initialize the icon and text presentation."""
super().__init__(
parent,
iconTextSpacing=self.IconTextSpacing,
horizontalMargin=self.HorizontalMargin,
verticalMargin=0,
iconSize=self.IconSize,
)
self.selectionIndicator = None
if hasSelectionIndicator:
# Qt does not reliably evaluate a button pseudo-state when that
# state appears on the ancestor side of a descendant QSS selector.
# Keep the visible label's selected state on the label itself so
# checked and disabled colors remain independent.
self._textLabel.setObjectName('NavigationPageButtonText')
self._textLabel.setProperty('selected', False)
self.selectionIndicator = QFrame(parent=self)
self.selectionIndicator.setObjectName('NavigationSelectionIndicator')
self.selectionIndicator.setFixedWidth(self.SelectionIndicatorWidth)
self.selectionIndicator.setAttribute(
QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents
)
self.selectionIndicator.hide()
self.toggled.connect(self._selectionChanged)
def setExpanded(self, expanded: bool):
"""Show the text label only in expanded navigation mode."""
self.setTextVisible(expanded)
@QtCore.Slot(bool)
def _selectionChanged(self, selected: bool):
"""Show the independent indicator for a selected page."""
self._textLabel.setProperty('selected', selected)
style = self._textLabel.style()
style.unpolish(self._textLabel)
style.polish(self._textLabel)
self._textLabel.update()
self.selectionIndicator.setVisible(selected)
if selected:
self.selectionIndicator.raise_()
def resizeEvent(self, event):
"""Keep the selection indicator straight along the left edge."""
super().resizeEvent(event)
if self.selectionIndicator is not None:
indicatorHeight = min(self.IconSize.height(), self.height())
indicatorTop = (self.height() - indicatorHeight) // 2
self.selectionIndicator.setGeometry(
0,
indicatorTop,
self.SelectionIndicatorWidth,
indicatorHeight,
)
@dataclass
class _NavigationPage:
"""Describe one registered page and its navigation control."""
id: str
title: str
iconFileName: str
widget: QWidget
button: _NavigationButton
translatable: bool = True
placement: str = 'top'
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 = _NavigationButton.IconSize
AnimationDuration = 150
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._navigationWidth = self.CollapsedWidth
self._outsideClickFilterInstalled = False
self.setObjectName('NavigationView')
self.navigationPanel = QFrame(parent=self)
self.navigationPanel.setObjectName('NavigationPanel')
self.toggleButton = _NavigationButton(parent=self.navigationPanel)
self.toggleButton.setObjectName('NavigationToggleButton')
self.toggleButton.setExpanded(False)
self.toggleButton.setToolTip(_('Expand Navigation'))
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.bottomPageButtonLayout = QVBoxLayout()
self.bottomPageButtonLayout.setContentsMargins(0, 0, 0, 0)
self.bottomPageButtonLayout.setSpacing(4)
self.navigationLayout.addLayout(self.bottomPageButtonLayout)
self.pageButtonGroup = QButtonGroup(self)
self.pageButtonGroup.setExclusive(True)
self.pageStack = QStackedWidget(parent=self)
self.pageStack.setObjectName('ApplicationPageStack')
# Keep the layout's navigation reservation at the compact rail width.
# The real panel is a sibling overlay, mirroring Fluent NavigationView:
# expanding it changes only the panel geometry, never the page geometry.
self.navigationRail = QFrame(parent=self)
self.navigationRail.setObjectName('NavigationRailPlaceholder')
self.navigationRail.setFixedWidth(self.CollapsedWidth)
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(0)
self._layout.addWidget(self.navigationRail)
self._layout.addWidget(self.pageStack, 1)
self.navigationPanel.raise_()
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(
self,
pageId: str,
widget: QWidget,
title: str,
iconFileName: str,
*,
translatable: bool = True,
placement: str = 'top',
):
"""Register a page, selecting the first registered page initially."""
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')
if placement not in ('top', 'bottom'):
raise ValueError("placement must be either 'top' or 'bottom'")
button = _NavigationButton(
parent=self.navigationPanel,
hasSelectionIndicator=True,
)
button.setObjectName('NavigationPageButton')
button.setProperty('pageId', pageId)
button.setCheckable(True)
button.setExpanded(self._expanded)
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,
placement,
)
self._pages[pageId] = page
self.pageButtonGroup.addButton(button)
if placement == 'bottom':
self.bottomPageButtonLayout.addWidget(button)
else:
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)
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.toggleButton.setToolTip(
_('Collapse Navigation') if expanded else _('Expand Navigation')
)
self.toggleButton.setProperty('expanded', expanded)
self._refreshWidgetStyle(self.toggleButton)
self.setIconByTheme(APP().theme())
for page in self._pages.values():
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._setOutsideClickFilterEnabled(expanded and self.isVisible())
self.expandedChanged.emit(expanded)
def _setOutsideClickFilterEnabled(self, enabled: bool):
"""Observe application clicks only while the overlay is expanded."""
application = QApplication.instance()
if application is None or enabled == self._outsideClickFilterInstalled:
return
if enabled:
application.installEventFilter(self)
else:
application.removeEventFilter(self)
self._outsideClickFilterInstalled = enabled
def _isOutsideNavigationClick(self, watched, event) -> bool:
"""Return whether *event* targets this window outside the panel."""
if not self._expanded or not isinstance(watched, QWidget):
return False
if watched.window() is not self.window():
# Popup menus, combo-box popups, message boxes, and child dialogs
# are separate top-level windows and keep their normal behavior.
return False
(
globalPosition,
panelTopLeft,
) = (
event.globalPosition().toPoint(),
self.navigationPanel.mapToGlobal(QtCore.QPoint()),
)
panelGeometry = QtCore.QRect(panelTopLeft, self.navigationPanel.size())
return not panelGeometry.contains(globalPosition)
def eventFilter(self, watched, event):
"""Animate closed after an outside press without consuming the click."""
if (
event.type() == QtCore.QEvent.Type.MouseButtonPress
and self._isOutsideNavigationClick(watched, event)
):
self.setExpanded(False)
return False
def _getNavigationWidth(self) -> int:
"""Return the width exposed to the property animation."""
return self._navigationWidth
def _setNavigationWidth(self, width: int):
"""Apply an animated overlay width without resizing the page stack."""
self._navigationWidth = int(width)
self.navigationPanel.setGeometry(
0,
0,
self._navigationWidth,
self.height(),
)
self.navigationPanel.raise_()
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)
def resizeEvent(self, event):
"""Keep the navigation overlay as tall as the page container."""
super().resizeEvent(event)
self._setNavigationWidth(self._navigationWidth)
def showEvent(self, event):
"""Resume outside-click observation when an expanded view is shown."""
super().showEvent(event)
self._setOutsideClickFilterEnabled(self._expanded)
def hideEvent(self, event):
"""Suspend the application filter while this view cannot be used."""
self._setOutsideClickFilterEnabled(False)
super().hideEvent(event)
@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)
self.toggleButton.setIcon(iconFactory('list.svg'))
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)