mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 16:21:27 +01:00
feat(main_app): redesign the navigation panel as a rail with a drawer in QWidget and QML
This commit is contained in:
21 files changed
+2941
-18
No files matched your search
@@ -5,8 +5,7 @@ from qtpy.QtCore import QTimer
|
||||
from qtpy.QtGui import QAction # type: ignore
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.nav_common import NavEntry, create_side_bar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.admin_view.admin_view import AdminView
|
||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||
@@ -36,7 +35,7 @@ class BECMainApp(BECMainWindow):
|
||||
self,
|
||||
parent=None,
|
||||
*args,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
anim_duration: int | None = None,
|
||||
show_examples: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -46,7 +45,9 @@ class BECMainApp(BECMainWindow):
|
||||
self._launcher_ready_notified = False
|
||||
|
||||
# --- Compose central UI (sidebar + stack)
|
||||
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
|
||||
self.sidebar = create_side_bar(
|
||||
parent=self, anim_duration=anim_duration, remember_state=True
|
||||
)
|
||||
self.stack = QStackedWidget(self)
|
||||
|
||||
container = QWidget(self)
|
||||
@@ -87,27 +88,40 @@ class BECMainApp(BECMainWindow):
|
||||
self.admin_view = AdminView(self)
|
||||
startup_profiler.mark("AdminView")
|
||||
|
||||
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
|
||||
self.add_view(
|
||||
icon="widgets",
|
||||
title="Dock Area",
|
||||
view_id="dock_area",
|
||||
widget=self.dock_area,
|
||||
mini_text="Docks",
|
||||
subtitle="Plots, controls and workspaces",
|
||||
)
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
view_id="device_manager",
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
mini_text="Devices",
|
||||
subtitle="Device configuration",
|
||||
)
|
||||
if is_experimental_features_enabled():
|
||||
self.developer_view = DeveloperView(self)
|
||||
self.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
view_id="developer_view",
|
||||
widget=self.developer_view,
|
||||
mini_text="IDE",
|
||||
subtitle="Scripts and macros",
|
||||
exclusive=True,
|
||||
)
|
||||
self.add_view(
|
||||
icon="admin_panel_settings",
|
||||
title="Admin View",
|
||||
view_id="admin_view",
|
||||
widget=self.admin_view,
|
||||
mini_text="Admin",
|
||||
subtitle="Experiment and services",
|
||||
from_top=False,
|
||||
)
|
||||
|
||||
@@ -161,7 +175,8 @@ class BECMainApp(BECMainWindow):
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
subtitle: str | None = None,
|
||||
) -> NavigationItem | NavEntry:
|
||||
"""
|
||||
Register a view in the stack and create a matching nav item in the sidebar.
|
||||
|
||||
@@ -176,9 +191,10 @@ class BECMainApp(BECMainWindow):
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
subtitle(str, optional): One-line description shown in the open navigation panel.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
NavigationItem | NavEntry: The created navigation entry.
|
||||
|
||||
|
||||
"""
|
||||
@@ -192,6 +208,7 @@ class BECMainApp(BECMainWindow):
|
||||
from_top=from_top,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
subtitle=subtitle,
|
||||
)
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
@@ -255,9 +272,9 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
# Register the sidebar toggle button
|
||||
toggle_step = self.guided_tour.register_widget(
|
||||
widget=self.sidebar.toggle,
|
||||
title="Sidebar Toggle",
|
||||
text="Click this button to expand or collapse the sidebar. When expanded, you can see full navigation item titles and section names.",
|
||||
widget=self._sidebar_tour_target("toggle"),
|
||||
title="Navigation",
|
||||
text="Click this button (or press Ctrl+B) to open the navigation panel with the full names of all views. Pin it there to keep it open. Ctrl+1, Ctrl+2, ... switch views directly.",
|
||||
)
|
||||
tour_steps.append(toggle_step)
|
||||
|
||||
@@ -265,7 +282,7 @@ class BECMainApp(BECMainWindow):
|
||||
sidebar_dock_area = self.sidebar.components.get("dock_area")
|
||||
if sidebar_dock_area:
|
||||
dock_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_dock_area,
|
||||
widget=self._sidebar_tour_target("dock_area"),
|
||||
title="Dock Area View",
|
||||
text="Click here to access the Dock Area view, where you can manage and arrange your dockable panels.",
|
||||
)
|
||||
@@ -274,7 +291,7 @@ class BECMainApp(BECMainWindow):
|
||||
sidebar_device_manager = self.sidebar.components.get("device_manager")
|
||||
if sidebar_device_manager:
|
||||
device_manager_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_device_manager,
|
||||
widget=self._sidebar_tour_target("device_manager"),
|
||||
title="Device Manager View",
|
||||
text="Click here to open the Device Manager view, where you can view and manage device configs.",
|
||||
)
|
||||
@@ -283,7 +300,7 @@ class BECMainApp(BECMainWindow):
|
||||
sidebar_developer_view = self.sidebar.components.get("developer_view")
|
||||
if sidebar_developer_view:
|
||||
developer_view_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_developer_view,
|
||||
widget=self._sidebar_tour_target("developer_view"),
|
||||
title="Developer View",
|
||||
text="Click here to access the Developer view to write scripts and macros.",
|
||||
)
|
||||
@@ -293,7 +310,7 @@ class BECMainApp(BECMainWindow):
|
||||
dark_mode_item = self.sidebar.components.get("dark_mode")
|
||||
if dark_mode_item:
|
||||
dark_mode_step = self.guided_tour.register_widget(
|
||||
widget=dark_mode_item,
|
||||
widget=self._sidebar_tour_target("dark_mode"),
|
||||
title="Theme Toggle",
|
||||
text="Switch between light and dark themes. The theme preference is saved and will be applied when you restart the application.",
|
||||
)
|
||||
@@ -349,7 +366,7 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
# Use the view's title for the navigation button
|
||||
nav_step = self.guided_tour.register_widget(
|
||||
widget=nav_item,
|
||||
widget=self._sidebar_tour_target(view_id),
|
||||
title=view_tour.view_title,
|
||||
text=f"Let's explore the features of the {view_tour.view_title}.",
|
||||
)
|
||||
@@ -360,6 +377,14 @@ class BECMainApp(BECMainWindow):
|
||||
if tour_steps:
|
||||
self.guided_tour.create_tour(tour_steps)
|
||||
|
||||
def _sidebar_tour_target(self, entry_id: str):
|
||||
"""Return what the guided tour highlights for a navigation entry (or ``"toggle"``)."""
|
||||
if hasattr(self.sidebar, "tour_target"):
|
||||
return self.sidebar.tour_target(entry_id)
|
||||
if entry_id == "toggle":
|
||||
return self.sidebar.toggle
|
||||
return self.sidebar.components.get(entry_id)
|
||||
|
||||
def start_guided_tour(self):
|
||||
"""
|
||||
Public method to start the guided tour.
|
||||
@@ -392,6 +417,8 @@ class BECMainApp(BECMainWindow):
|
||||
help_menu.addAction(tour_action)
|
||||
|
||||
def cleanup(self):
|
||||
if hasattr(self.sidebar, "cleanup"):
|
||||
self.sidebar.cleanup()
|
||||
for view_id, idx in self._view_index.items():
|
||||
view = self.stack.widget(idx)
|
||||
view.close()
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
"""Shared model and behaviour of the main app's navigation panel.
|
||||
|
||||
The panel has two states that use the same geometry:
|
||||
|
||||
* the **rail** (56 px): one row per view with an icon and a short label below it,
|
||||
* the **drawer** (256 px): the same rows with the full title, a subtitle and the shortcut.
|
||||
|
||||
The drawer either **pushes** the views (default, ``BEC_NAV_DRAWER=push``) or **floats** over
|
||||
them (``BEC_NAV_DRAWER=float``; a pin docks it). In both modes the surface animates above the
|
||||
views and the layout changes once, at the end of opening or the start of closing, so the views
|
||||
never reflow frame by frame. Rows keep their height and their icon position in both states, so
|
||||
nothing jumps while the surface widens; labels only fade.
|
||||
|
||||
:class:`NavPanelBase` holds the entries, the state machine (open, pinned, animation), the
|
||||
keyboard shortcuts and the public API that :class:`~bec_widgets.applications.main_app.BECMainApp`
|
||||
and the legacy ``SideBar`` share. The QWidget and QML versions only render it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from qtpy.QtCore import (
|
||||
Property,
|
||||
QEasingCurve,
|
||||
QEvent,
|
||||
QObject,
|
||||
QPoint,
|
||||
QRect,
|
||||
QSettings,
|
||||
Qt,
|
||||
QTimer,
|
||||
QVariantAnimation,
|
||||
Signal,
|
||||
)
|
||||
from qtpy.QtGui import QColor, QKeySequence, QPainter, QShortcut
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from bec_widgets import SafeSlot
|
||||
|
||||
RAIL_WIDTH = 56
|
||||
DRAWER_WIDTH = 256
|
||||
NAV_ANIMATION_DURATION = 180 # ms
|
||||
TOGGLE_SHORTCUT = "Ctrl+B"
|
||||
SETTINGS_KEY = "navigation/pinned"
|
||||
NAV_PANEL_ENV = "BEC_NAV_PANEL"
|
||||
NAV_DRAWER_ENV = "BEC_NAV_DRAWER"
|
||||
|
||||
# Row heights shared by both versions so they line up pixel for pixel.
|
||||
HEADER_HEIGHT = 52
|
||||
ITEM_HEIGHT = 56
|
||||
SECTION_HEIGHT = 28
|
||||
SEPARATOR_HEIGHT = 13
|
||||
|
||||
|
||||
@dataclass
|
||||
class NavEntry:
|
||||
"""One row of the navigation panel."""
|
||||
|
||||
id: str
|
||||
kind: str # "item", "action", "section" or "separator"
|
||||
title: str = ""
|
||||
mini_text: str = ""
|
||||
icon: str = ""
|
||||
subtitle: str = ""
|
||||
group: str = "top" # "top" scrolls with the views, "bottom" stays at the bottom
|
||||
toggleable: bool = True
|
||||
exclusive: bool = True
|
||||
active: bool = False
|
||||
shortcut: str = ""
|
||||
callback: Callable[[], None] | None = field(default=None, repr=False)
|
||||
|
||||
@property
|
||||
def focusable(self) -> bool:
|
||||
"""Whether keyboard focus can land on this row."""
|
||||
return self.kind in ("item", "action")
|
||||
|
||||
@property
|
||||
def is_view(self) -> bool:
|
||||
"""Whether the row selects a view (and therefore gets a Ctrl+<n> shortcut)."""
|
||||
return self.kind == "item" and self.toggleable and self.exclusive
|
||||
|
||||
# legacy NavigationItem compatibility, used by the command palette
|
||||
@property
|
||||
def _icon_name(self) -> str:
|
||||
return self.icon
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Return whether the row is the selected one."""
|
||||
return self.active
|
||||
|
||||
|
||||
def nav_panel_flavour() -> str:
|
||||
"""Return the navigation panel chosen with ``BEC_NAV_PANEL`` (qwidget, qml or legacy)."""
|
||||
flavour = os.environ.get(NAV_PANEL_ENV, "qwidget").strip().lower()
|
||||
return flavour if flavour in ("qwidget", "qml", "legacy") else "qwidget"
|
||||
|
||||
|
||||
def create_side_bar(parent: QWidget | None = None, anim_duration: int | None = None, **kwargs):
|
||||
"""
|
||||
Create the navigation panel of the main app.
|
||||
|
||||
``BEC_NAV_PANEL=qwidget`` (default) and ``BEC_NAV_PANEL=qml`` select the redesigned panel,
|
||||
``BEC_NAV_PANEL=legacy`` the original expanding side bar.
|
||||
|
||||
Args:
|
||||
parent(QWidget | None): Parent widget.
|
||||
anim_duration(int | None): Animation duration in ms; ``None`` keeps the panel's default.
|
||||
**kwargs: Passed to the panel.
|
||||
|
||||
Returns:
|
||||
The panel widget.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
flavour = nav_panel_flavour()
|
||||
if anim_duration is not None:
|
||||
kwargs["anim_duration"] = anim_duration
|
||||
if flavour == "legacy":
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
|
||||
kwargs.pop("remember_state", None)
|
||||
return SideBar(parent=parent, **kwargs)
|
||||
if flavour == "qml":
|
||||
from bec_widgets.applications.navigation_centre.nav_panel_qml import NavPanelQml
|
||||
|
||||
return NavPanelQml(parent=parent, **kwargs)
|
||||
from bec_widgets.applications.navigation_centre.nav_panel_qwidget import NavPanelQWidget
|
||||
|
||||
return NavPanelQWidget(parent=parent, **kwargs)
|
||||
|
||||
|
||||
class _ToggleProxy(QObject):
|
||||
"""Stand-in for the legacy ``SideBar.toggle`` button where the button lives in QML."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def click(self):
|
||||
"""Toggle the drawer, like clicking the menu button."""
|
||||
self.clicked.emit()
|
||||
|
||||
|
||||
class _Scrim(QWidget):
|
||||
"""Dims the views behind the floating drawer; a click on it closes the drawer."""
|
||||
|
||||
def __init__(self, panel: "NavPanelBase"):
|
||||
super().__init__(panel)
|
||||
self.panel = panel
|
||||
self.setObjectName("NavPanelScrim")
|
||||
self.hide()
|
||||
|
||||
def mousePressEvent(self, event): # pylint: disable=invalid-name
|
||||
event.accept()
|
||||
self.panel.set_expanded(False, restore_focus=False)
|
||||
|
||||
def wheelEvent(self, event): # pylint: disable=invalid-name
|
||||
event.accept()
|
||||
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), QColor(0, 0, 0, int(40 * self.panel.progress)))
|
||||
|
||||
|
||||
class NavPanelBase(QWidget):
|
||||
"""
|
||||
Navigation panel of the main app: the slot in the layout plus the floating surface.
|
||||
|
||||
Subclasses implement the ``_surface_*`` hooks and ``_create_surface``.
|
||||
|
||||
Signals:
|
||||
view_selected(str): A row was activated; carries its id.
|
||||
toggled(bool): The drawer opened (True) or closed (False).
|
||||
pinned_changed(bool): The drawer was pinned open or released.
|
||||
"""
|
||||
|
||||
view_selected = Signal(str)
|
||||
toggled = Signal(bool)
|
||||
pinned_changed = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
title: str = "Navigation",
|
||||
collapsed_width: int = RAIL_WIDTH,
|
||||
expanded_width: int = DRAWER_WIDTH,
|
||||
anim_duration: int = NAV_ANIMATION_DURATION,
|
||||
*,
|
||||
remember_state: bool = False,
|
||||
install_shortcuts: bool = True,
|
||||
drawer_mode: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("NavPanel")
|
||||
self._title = title
|
||||
self._rail_width = collapsed_width
|
||||
self._drawer_width = expanded_width
|
||||
self._remember_state = remember_state
|
||||
self._install_shortcuts = install_shortcuts
|
||||
mode = (drawer_mode or os.environ.get(NAV_DRAWER_ENV, "push")).strip().lower()
|
||||
self._push_mode = mode != "float"
|
||||
|
||||
self.entries: list[NavEntry] = []
|
||||
self.components: dict[str, NavEntry | QWidget] = {}
|
||||
self._active_id: str | None = None
|
||||
self._open = False
|
||||
self._pinned = False
|
||||
self._progress = 0.0
|
||||
self._restore_focus: QWidget | None = None
|
||||
self._shortcuts: list[QShortcut] = []
|
||||
self._shortcut_window: QWidget | None = None
|
||||
self._scrim = _Scrim(self)
|
||||
self._theme_entries: set[str] = set()
|
||||
|
||||
self.toggle = _ToggleProxy(self)
|
||||
self.toggle.clicked.connect(self.on_expand)
|
||||
|
||||
self._anim = QVariantAnimation(self)
|
||||
self._anim.setDuration(max(0, int(anim_duration)))
|
||||
self._anim.setEasingCurve(QEasingCurve.OutCubic)
|
||||
self._anim.valueChanged.connect(self._on_anim_value)
|
||||
self._anim.finished.connect(self._on_anim_finished)
|
||||
|
||||
self.setFixedWidth(self._rail_width)
|
||||
self.surface: QWidget = self._create_surface()
|
||||
self.surface.setParent(self)
|
||||
self._surface_host: QWidget | None = None
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"):
|
||||
app.theme.theme_changed.connect(self._on_theme_changed)
|
||||
|
||||
if self._remember_state and self._settings().value(SETTINGS_KEY, False, type=bool):
|
||||
self.set_pinned(True)
|
||||
|
||||
# ------------------------------------------------------------------ subclass hooks
|
||||
def _create_surface(self) -> QWidget: # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
def _surface_entries_changed(self) -> None:
|
||||
"""The list of entries, their titles or their shortcuts changed."""
|
||||
|
||||
def _surface_state_changed(self) -> None:
|
||||
"""Active row, pinned or open state changed."""
|
||||
|
||||
def _surface_progress(self, progress: float) -> None:
|
||||
"""The drawer is ``progress`` (0 rail .. 1 drawer) open."""
|
||||
|
||||
def _surface_theme_changed(self) -> None:
|
||||
"""The application theme changed."""
|
||||
|
||||
def focus_row(self, entry_id: str | None) -> None:
|
||||
"""Move keyboard focus onto the row ``entry_id`` (or the first row)."""
|
||||
|
||||
def _surface_has_focus(self) -> bool:
|
||||
return self.surface.isAncestorOf(QApplication.focusWidget()) or self.surface.hasFocus()
|
||||
|
||||
def tour_target(self, entry_id: str):
|
||||
"""Return what the guided tour should highlight for ``entry_id`` (or ``"toggle"``)."""
|
||||
del entry_id
|
||||
return self.surface
|
||||
|
||||
# ------------------------------------------------------------------ entries
|
||||
def add_section( # pylint: disable=redefined-builtin
|
||||
self, title: str, id: str, position: int | None = None
|
||||
) -> NavEntry:
|
||||
"""
|
||||
Add a section header. In the rail it shows as a divider, in the drawer as a heading.
|
||||
|
||||
Args:
|
||||
title(str): Section title.
|
||||
id(str): Unique id.
|
||||
position(int, optional): Index among the top rows; appended when omitted.
|
||||
|
||||
Returns:
|
||||
NavEntry: The created entry.
|
||||
"""
|
||||
return self._add_entry(NavEntry(id=id, kind="section", title=title), position)
|
||||
|
||||
def add_separator(self, *, from_top: bool = True, position: int | None = None) -> NavEntry:
|
||||
"""
|
||||
Add a divider line.
|
||||
|
||||
Args:
|
||||
from_top(bool): Add to the top rows (True) or the bottom rows (False).
|
||||
position(int, optional): Index within the group; appended when omitted.
|
||||
|
||||
Returns:
|
||||
NavEntry: The created entry.
|
||||
"""
|
||||
sep_id = f"separator_{sum(1 for e in self.entries if e.kind == 'separator')}"
|
||||
entry = NavEntry(id=sep_id, kind="separator", group="top" if from_top else "bottom")
|
||||
return self._add_entry(entry, position)
|
||||
|
||||
def add_item( # pylint: disable=redefined-builtin
|
||||
self,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
*,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
subtitle: str | None = None,
|
||||
) -> NavEntry:
|
||||
"""
|
||||
Add a navigation row.
|
||||
|
||||
Args:
|
||||
icon(str): Material icon name.
|
||||
title(str): Full title shown in the drawer and the tooltip.
|
||||
id(str): Unique id, emitted with ``view_selected``.
|
||||
mini_text(str, optional): Short label under the icon in the rail; defaults to title.
|
||||
position(int, optional): Index within the group; appended when omitted.
|
||||
from_top(bool): Add to the top rows (True) or the bottom rows (False).
|
||||
toggleable(bool): Whether the row stays selected (a view) or acts once (an action).
|
||||
exclusive(bool): Whether selecting the row deselects the other exclusive rows.
|
||||
subtitle(str, optional): One-line description shown in the drawer.
|
||||
|
||||
Returns:
|
||||
NavEntry: The created entry.
|
||||
"""
|
||||
entry = NavEntry(
|
||||
id=id,
|
||||
kind="item" if toggleable else "action",
|
||||
title=title,
|
||||
mini_text=mini_text or title,
|
||||
icon=icon,
|
||||
subtitle=subtitle or "",
|
||||
group="top" if from_top else "bottom",
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
)
|
||||
return self._add_entry(entry, position)
|
||||
|
||||
def add_dark_mode_item( # pylint: disable=redefined-builtin
|
||||
self, id: str = "dark_mode", position: int | None = None
|
||||
) -> NavEntry:
|
||||
"""
|
||||
Add the bottom row that switches between the light and the dark theme.
|
||||
|
||||
Args:
|
||||
id(str): Unique id.
|
||||
position(int, optional): Index within the bottom rows; appended when omitted.
|
||||
|
||||
Returns:
|
||||
NavEntry: The created entry.
|
||||
"""
|
||||
entry = NavEntry(
|
||||
id=id,
|
||||
kind="action",
|
||||
group="bottom",
|
||||
toggleable=False,
|
||||
exclusive=False,
|
||||
callback=self._toggle_theme,
|
||||
)
|
||||
self._sync_theme_entry(entry)
|
||||
self._theme_entries.add(id)
|
||||
return self._add_entry(entry, position)
|
||||
|
||||
def _add_entry(self, entry: NavEntry, position: int | None) -> NavEntry:
|
||||
if entry.id in self.components:
|
||||
self.remove_entry(entry.id)
|
||||
group = [e for e in self.entries if e.group == entry.group]
|
||||
if position is None or position >= len(group):
|
||||
anchor = group[-1] if group else None
|
||||
index = (self.entries.index(anchor) + 1) if anchor else len(self.entries)
|
||||
if anchor is None and entry.group == "top":
|
||||
index = 0
|
||||
else:
|
||||
index = self.entries.index(group[max(0, position)])
|
||||
self.entries.insert(index, entry)
|
||||
self.components[entry.id] = entry
|
||||
self._renumber_shortcuts()
|
||||
self._surface_entries_changed()
|
||||
return entry
|
||||
|
||||
def remove_entry(self, entry_id: str) -> None:
|
||||
"""Remove the row ``entry_id``."""
|
||||
entry = self.components.pop(entry_id, None)
|
||||
if entry is None:
|
||||
return
|
||||
self.entries.remove(entry)
|
||||
if self._active_id == entry_id:
|
||||
self._active_id = None
|
||||
self._renumber_shortcuts()
|
||||
self._surface_entries_changed()
|
||||
|
||||
def ordered_entries(self) -> list[NavEntry]:
|
||||
"""Entries in display order: the top group, then the bottom group."""
|
||||
return [e for e in self.entries if e.group == "top"] + [
|
||||
e for e in self.entries if e.group == "bottom"
|
||||
]
|
||||
|
||||
def view_entries(self) -> list[NavEntry]:
|
||||
"""Rows that select a view, in display order; the first nine get Ctrl+1..9."""
|
||||
return [e for e in self.ordered_entries() if e.is_view]
|
||||
|
||||
def _renumber_shortcuts(self) -> None:
|
||||
for entry in self.entries:
|
||||
entry.shortcut = ""
|
||||
for number, entry in enumerate(self.view_entries()[:9], start=1):
|
||||
entry.shortcut = QKeySequence(f"Ctrl+{number}").toString(
|
||||
QKeySequence.SequenceFormat.NativeText
|
||||
)
|
||||
self._sync_shortcuts()
|
||||
|
||||
# ------------------------------------------------------------------ activation
|
||||
def activate_item(self, target_id: str, *, emit_signal: bool = True):
|
||||
"""
|
||||
Select the row ``target_id`` like a click would.
|
||||
|
||||
Args:
|
||||
target_id(str): Row id.
|
||||
emit_signal(bool): Emit ``view_selected`` (False only restores the highlight).
|
||||
"""
|
||||
target = self.components.get(target_id)
|
||||
if not isinstance(target, NavEntry) or not target.focusable:
|
||||
return
|
||||
if target.callback is not None and emit_signal:
|
||||
target.callback()
|
||||
if not target.toggleable:
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
return
|
||||
if target.exclusive:
|
||||
for entry in self.entries:
|
||||
if entry.kind == "item" and entry.exclusive:
|
||||
entry.active = entry is target
|
||||
else:
|
||||
target.active = not target.active
|
||||
self._active_id = target_id
|
||||
self._surface_state_changed()
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
|
||||
def activate_from_user(self, target_id: str) -> None:
|
||||
"""Activate a row on a click or a key press; closes the floating drawer after a view."""
|
||||
target = self.components.get(target_id)
|
||||
self.activate_item(target_id)
|
||||
if (
|
||||
isinstance(target, NavEntry)
|
||||
and target.kind == "item"
|
||||
and self._open
|
||||
and not self._pinned
|
||||
):
|
||||
self.set_expanded(False, restore_focus=False)
|
||||
|
||||
@property
|
||||
def active_id(self) -> str | None:
|
||||
"""Id of the selected view row."""
|
||||
for entry in self.entries:
|
||||
if entry.kind == "item" and entry.exclusive and entry.active:
|
||||
return entry.id
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ open / pin state
|
||||
def _get_is_expanded(self) -> bool:
|
||||
return self._open
|
||||
|
||||
# a plain Qt property: readable from Python (a SafeProperty is not) and from Qt
|
||||
is_expanded = Property(bool, _get_is_expanded, notify=toggled)
|
||||
|
||||
@property
|
||||
def progress(self) -> float:
|
||||
"""How far the drawer is open, 0 (rail) to 1 (drawer)."""
|
||||
return self._progress
|
||||
|
||||
@property
|
||||
def pinned(self) -> bool:
|
||||
"""Whether the drawer is docked open next to the views."""
|
||||
return self._pinned
|
||||
|
||||
@property
|
||||
def push_mode(self) -> bool:
|
||||
"""Whether the open drawer pushes the views (True) or floats over them (False)."""
|
||||
return self._push_mode
|
||||
|
||||
@property
|
||||
def rail_width(self) -> int:
|
||||
"""Width of the collapsed rail."""
|
||||
return self._rail_width
|
||||
|
||||
@property
|
||||
def drawer_width(self) -> int:
|
||||
"""Width of the open drawer."""
|
||||
return self._drawer_width
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
"""Heading of the open drawer."""
|
||||
return self._title
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def on_expand(self, *_args):
|
||||
"""Toggle the drawer (legacy name of :meth:`toggle_expanded`)."""
|
||||
self.toggle_expanded()
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_expanded(self):
|
||||
"""Open the drawer when it is closed, close it otherwise."""
|
||||
self.set_expanded(not self._open)
|
||||
|
||||
def set_expanded(self, expanded: bool, *, restore_focus: bool = True) -> None:
|
||||
"""
|
||||
Open or close the drawer.
|
||||
|
||||
Args:
|
||||
expanded(bool): True opens the drawer.
|
||||
restore_focus(bool): When closing, give focus back to where it was before.
|
||||
"""
|
||||
expanded = bool(expanded)
|
||||
if expanded == self._open:
|
||||
return
|
||||
if self._push_mode:
|
||||
# in push mode the open drawer is always docked next to the views
|
||||
if expanded:
|
||||
self._remember_focus()
|
||||
self.set_pinned(True)
|
||||
else:
|
||||
self._close_focus(restore_focus)
|
||||
self.set_pinned(False)
|
||||
return
|
||||
if not expanded and self._pinned:
|
||||
self.set_pinned(False, collapse=False)
|
||||
self._open = expanded
|
||||
if expanded:
|
||||
self._remember_focus()
|
||||
else:
|
||||
self._close_focus(restore_focus)
|
||||
self._animate_to(1.0 if expanded else 0.0)
|
||||
self._surface_state_changed()
|
||||
self.toggled.emit(expanded)
|
||||
|
||||
def _remember_focus(self) -> None:
|
||||
focus = QApplication.focusWidget()
|
||||
if focus is not None and not self.surface.isAncestorOf(focus):
|
||||
self._restore_focus = focus
|
||||
|
||||
def _close_focus(self, restore_focus: bool) -> None:
|
||||
if restore_focus and self._surface_has_focus() and self._restore_focus is not None:
|
||||
try:
|
||||
self._restore_focus.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
except RuntimeError:
|
||||
pass
|
||||
self._restore_focus = None
|
||||
|
||||
def set_pinned(self, pinned: bool, *, collapse: bool = True) -> None:
|
||||
"""
|
||||
Dock the drawer open next to the views (True) or let it float and close again (False).
|
||||
|
||||
Args:
|
||||
pinned(bool): Pin state.
|
||||
collapse(bool): When unpinning, also close the drawer.
|
||||
"""
|
||||
pinned = bool(pinned)
|
||||
if pinned == self._pinned:
|
||||
return
|
||||
self._pinned = pinned
|
||||
if self._remember_state:
|
||||
self._settings().setValue(SETTINGS_KEY, pinned)
|
||||
if pinned:
|
||||
# the views make room once the drawer is fully open (see _on_anim_finished)
|
||||
if self._open and self._progress >= 1.0:
|
||||
self.setFixedWidth(self._drawer_width)
|
||||
if not self._open:
|
||||
self._open = True
|
||||
self._animate_to(1.0)
|
||||
self.toggled.emit(True)
|
||||
else:
|
||||
# the views take the space back at once; the drawer then shrinks over them
|
||||
self.setFixedWidth(self._rail_width)
|
||||
if self._open and (collapse or self._push_mode):
|
||||
self._open = False
|
||||
self._animate_to(0.0)
|
||||
self.toggled.emit(False)
|
||||
self._place_surface()
|
||||
self._surface_state_changed()
|
||||
self.pinned_changed.emit(pinned)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_pinned(self):
|
||||
"""Pin the drawer open, or release it."""
|
||||
self.set_pinned(not self._pinned)
|
||||
|
||||
def _animate_to(self, target: float) -> None:
|
||||
self._anim.stop()
|
||||
if self._anim.duration() <= 0 or not self.isVisible():
|
||||
self._on_anim_value(target)
|
||||
self._on_anim_finished()
|
||||
return
|
||||
self._anim.setStartValue(float(self._progress))
|
||||
self._anim.setEndValue(float(target))
|
||||
self._anim.start()
|
||||
|
||||
def _on_anim_value(self, value) -> None:
|
||||
self._progress = float(value)
|
||||
self._place_surface()
|
||||
self._surface_progress(self._progress)
|
||||
|
||||
def _on_anim_finished(self) -> None:
|
||||
if self._pinned and self._open and self._progress >= 1.0:
|
||||
self.setFixedWidth(self._drawer_width)
|
||||
self._place_surface()
|
||||
self._surface_state_changed()
|
||||
|
||||
# ------------------------------------------------------------------ floating surface
|
||||
def surface_width(self) -> int:
|
||||
"""Current width of the floating surface."""
|
||||
span = self._drawer_width - self._rail_width
|
||||
return int(round(self._rail_width + span * self._progress))
|
||||
|
||||
def _place_surface(self) -> None:
|
||||
host = self.parentWidget()
|
||||
if host is not None and self.surface.parentWidget() is not host:
|
||||
self.surface.setParent(host)
|
||||
self._scrim.setParent(host)
|
||||
self.surface.show()
|
||||
if self._surface_host is not None:
|
||||
self._surface_host.removeEventFilter(self)
|
||||
host.installEventFilter(self)
|
||||
self._surface_host = host
|
||||
top_left = self.pos() if host is not None else QPoint(0, 0)
|
||||
self.surface.setGeometry(
|
||||
QRect(top_left.x(), top_left.y(), self.surface_width(), self.height())
|
||||
)
|
||||
self.surface.setVisible(self.isVisible())
|
||||
floating = self.isVisible() and not self._pinned and self._progress > 0.0
|
||||
if host is not None and floating:
|
||||
self._scrim.setGeometry(host.rect())
|
||||
self._scrim.setVisible(True)
|
||||
self._scrim.raise_()
|
||||
self._scrim.update()
|
||||
else:
|
||||
self._scrim.setVisible(False)
|
||||
self.surface.raise_()
|
||||
|
||||
def eventFilter(self, watched, event): # pylint: disable=invalid-name
|
||||
if watched is self._surface_host and event.type() in (
|
||||
QEvent.Type.Resize,
|
||||
QEvent.Type.ChildAdded,
|
||||
):
|
||||
QTimer.singleShot(0, self._safe_place)
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _safe_place(self):
|
||||
try:
|
||||
self._place_surface()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def resizeEvent(self, event): # pylint: disable=invalid-name
|
||||
super().resizeEvent(event)
|
||||
self._place_surface()
|
||||
|
||||
def moveEvent(self, event): # pylint: disable=invalid-name
|
||||
super().moveEvent(event)
|
||||
self._place_surface()
|
||||
|
||||
def showEvent(self, event): # pylint: disable=invalid-name
|
||||
super().showEvent(event)
|
||||
self._place_surface()
|
||||
self._sync_shortcuts()
|
||||
|
||||
def hideEvent(self, event): # pylint: disable=invalid-name
|
||||
super().hideEvent(event)
|
||||
self.surface.setVisible(False)
|
||||
self._scrim.setVisible(False)
|
||||
|
||||
# ------------------------------------------------------------------ keyboard
|
||||
def _sync_shortcuts(self) -> None:
|
||||
if not self._install_shortcuts:
|
||||
return
|
||||
window = self.window()
|
||||
if window is self or window is None:
|
||||
return
|
||||
if self._shortcut_window is not window:
|
||||
for shortcut in self._shortcuts:
|
||||
shortcut.setEnabled(False)
|
||||
shortcut.deleteLater()
|
||||
self._shortcuts = []
|
||||
self._shortcut_window = window
|
||||
toggle = QShortcut(QKeySequence(TOGGLE_SHORTCUT), window)
|
||||
toggle.activated.connect(self._toggle_from_keyboard)
|
||||
self._shortcuts.append(toggle)
|
||||
for number in range(1, 10):
|
||||
shortcut = QShortcut(QKeySequence(f"Ctrl+{number}"), window)
|
||||
shortcut.activated.connect(lambda n=number: self._activate_number(n))
|
||||
self._shortcuts.append(shortcut)
|
||||
views = self.view_entries()
|
||||
for number, shortcut in enumerate(self._shortcuts[1:], start=1):
|
||||
shortcut.setEnabled(number <= len(views))
|
||||
|
||||
def _toggle_from_keyboard(self):
|
||||
opening = not self._open
|
||||
self.toggle_expanded()
|
||||
if opening:
|
||||
self.focus_row(self.active_id)
|
||||
|
||||
def _activate_number(self, number: int) -> None:
|
||||
views = self.view_entries()
|
||||
if 1 <= number <= len(views):
|
||||
self.activate_from_user(views[number - 1].id)
|
||||
|
||||
def move_focus(self, current_id: str | None, step: int) -> str | None:
|
||||
"""
|
||||
Return the id of the focusable row ``step`` rows away from ``current_id``.
|
||||
|
||||
``step`` of ``-10**6`` / ``10**6`` jumps to the first / last row.
|
||||
"""
|
||||
rows = [e.id for e in self.ordered_entries() if e.focusable]
|
||||
if not rows:
|
||||
return None
|
||||
if current_id not in rows:
|
||||
return rows[0] if step > 0 else rows[-1]
|
||||
index = max(0, min(len(rows) - 1, rows.index(current_id) + step))
|
||||
return rows[index]
|
||||
|
||||
def handle_escape(self) -> None:
|
||||
"""Esc inside the panel: close the floating drawer, or hand focus back to the view."""
|
||||
if self._open and not self._pinned:
|
||||
self.set_expanded(False)
|
||||
return
|
||||
target = self._restore_focus
|
||||
if target is None:
|
||||
return
|
||||
try:
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def tooltip_for(self, entry: NavEntry) -> str:
|
||||
"""Tooltip of a row in the rail."""
|
||||
parts = [entry.title]
|
||||
if entry.shortcut:
|
||||
parts.append(f"({entry.shortcut})")
|
||||
text = " ".join(parts)
|
||||
if entry.subtitle:
|
||||
text = f"{text}\n{entry.subtitle}"
|
||||
return text
|
||||
|
||||
def toggle_tooltip(self) -> str:
|
||||
"""Tooltip of the menu button."""
|
||||
key = QKeySequence(TOGGLE_SHORTCUT).toString(QKeySequence.SequenceFormat.NativeText)
|
||||
if self._pinned:
|
||||
return f"Collapse navigation ({key})"
|
||||
return f"{'Close' if self._open else 'Expand'} navigation ({key})"
|
||||
|
||||
# ------------------------------------------------------------------ theme
|
||||
def _toggle_theme(self) -> None:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
apply_theme("light" if self._is_dark() else "dark")
|
||||
|
||||
@staticmethod
|
||||
def _is_dark() -> bool:
|
||||
app = QApplication.instance()
|
||||
return getattr(getattr(app, "theme", None), "theme", "dark") == "dark"
|
||||
|
||||
def _sync_theme_entry(self, entry: NavEntry) -> None:
|
||||
dark = self._is_dark()
|
||||
entry.title = "Light theme" if dark else "Dark theme"
|
||||
entry.mini_text = "Light" if dark else "Dark"
|
||||
entry.icon = "light_mode" if dark else "dark_mode"
|
||||
entry.subtitle = "Switch the colour theme"
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_theme_changed(self, *_args) -> None:
|
||||
for entry in self.entries:
|
||||
if entry.id in self._theme_entries:
|
||||
self._sync_theme_entry(entry)
|
||||
self._surface_entries_changed()
|
||||
self._surface_theme_changed()
|
||||
|
||||
@staticmethod
|
||||
def _settings() -> QSettings:
|
||||
return QSettings("bec", "bec_app")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Stop the animation and release the global event filter and shortcuts."""
|
||||
self._anim.stop()
|
||||
if self._surface_host is not None:
|
||||
try:
|
||||
self._surface_host.removeEventFilter(self)
|
||||
except RuntimeError:
|
||||
pass
|
||||
for shortcut in self._shortcuts:
|
||||
shortcut.setEnabled(False)
|
||||
self._shortcuts = []
|
||||
@@ -0,0 +1,218 @@
|
||||
"""QML rendering of the main app's navigation panel; see :mod:`.nav_common`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from qtpy.QtCore import Property, QObject, QPoint, QRect, Qt, Signal, Slot
|
||||
from qtpy.QtQuickWidgets import QQuickWidget
|
||||
from qtpy.QtWidgets import QToolTip, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.nav_common import (
|
||||
HEADER_HEIGHT,
|
||||
ITEM_HEIGHT,
|
||||
SECTION_HEIGHT,
|
||||
SEPARATOR_HEIGHT,
|
||||
NavEntry,
|
||||
NavPanelBase,
|
||||
)
|
||||
from bec_widgets.utils.quick.host import ThemeTokens, create_quick_widget, release_quick_widget
|
||||
|
||||
QML_FILE = Path(__file__).parent / "qml" / "NavPanelView.qml"
|
||||
_HEIGHTS = {"section": SECTION_HEIGHT, "separator": SEPARATOR_HEIGHT}
|
||||
|
||||
|
||||
def _row(entry: NavEntry) -> dict:
|
||||
return {
|
||||
"id": entry.id,
|
||||
"kind": entry.kind,
|
||||
"title": entry.title,
|
||||
"miniText": entry.mini_text,
|
||||
"icon": entry.icon,
|
||||
"subtitle": entry.subtitle,
|
||||
"shortcut": entry.shortcut,
|
||||
"focusable": entry.focusable,
|
||||
"height": _HEIGHTS.get(entry.kind, ITEM_HEIGHT),
|
||||
}
|
||||
|
||||
|
||||
class NavQmlBackend(QObject):
|
||||
"""State of :class:`NavPanelQml` exposed to ``NavPanelView.qml``."""
|
||||
|
||||
entriesChanged = Signal()
|
||||
stateChanged = Signal()
|
||||
progressChanged = Signal()
|
||||
focusRequested = Signal(str)
|
||||
|
||||
def __init__(self, panel: "NavPanelQml"):
|
||||
super().__init__(panel)
|
||||
self.panel = panel
|
||||
self._top: list[dict] = []
|
||||
self._bottom: list[dict] = []
|
||||
self._keyboard_focus = False
|
||||
|
||||
def refresh_entries(self) -> None:
|
||||
"""Rebuild the row lists from the panel entries."""
|
||||
self._top = [_row(e) for e in self.panel.ordered_entries() if e.group == "top"]
|
||||
self._bottom = [_row(e) for e in self.panel.ordered_entries() if e.group == "bottom"]
|
||||
self.entriesChanged.emit()
|
||||
|
||||
# pylint: disable=missing-function-docstring,invalid-name
|
||||
def _active_ids(self) -> list[str]:
|
||||
return [e.id for e in self.panel.entries if e.active and e.kind == "item"]
|
||||
|
||||
def set_keyboard_focus(self, value: bool) -> None:
|
||||
"""Show focus rings (keyboard navigation) or hide them (mouse use)."""
|
||||
if value != self._keyboard_focus:
|
||||
self._keyboard_focus = value
|
||||
self.stateChanged.emit()
|
||||
|
||||
def _get_top(self) -> list[dict]:
|
||||
return self._top
|
||||
|
||||
def _get_bottom(self) -> list[dict]:
|
||||
return self._bottom
|
||||
|
||||
def _get_keyboard_focus(self) -> bool:
|
||||
return self._keyboard_focus
|
||||
|
||||
topEntries = Property("QVariantList", _get_top, notify=entriesChanged)
|
||||
bottomEntries = Property("QVariantList", _get_bottom, notify=entriesChanged)
|
||||
activeIds = Property("QStringList", _active_ids, notify=stateChanged)
|
||||
pinned = Property(bool, lambda self: self.panel.pinned, notify=stateChanged)
|
||||
opened = Property(bool, lambda self: self.panel.is_expanded, notify=stateChanged)
|
||||
keyboardFocus = Property(bool, _get_keyboard_focus, notify=stateChanged)
|
||||
toggleTooltip = Property(str, lambda self: self.panel.toggle_tooltip(), notify=stateChanged)
|
||||
progress = Property(float, lambda self: self.panel.progress, notify=progressChanged)
|
||||
title = Property(str, lambda self: self.panel.title, constant=True)
|
||||
railWidth = Property(int, lambda self: self.panel.rail_width, constant=True)
|
||||
drawerWidth = Property(int, lambda self: self.panel.drawer_width, constant=True)
|
||||
headerHeight = Property(int, lambda self: HEADER_HEIGHT, constant=True)
|
||||
pinAvailable = Property(bool, lambda self: not self.panel.push_mode, constant=True)
|
||||
|
||||
@Slot(str)
|
||||
def activate(self, entry_id: str) -> None:
|
||||
self.panel.activate_from_user(entry_id)
|
||||
|
||||
@Slot(str)
|
||||
def activateFromMouse(self, entry_id: str) -> None:
|
||||
self.set_keyboard_focus(False)
|
||||
self.panel.activate_from_user(entry_id)
|
||||
|
||||
@Slot()
|
||||
def toggleExpanded(self) -> None:
|
||||
self.set_keyboard_focus(False)
|
||||
self.panel.toggle_expanded()
|
||||
|
||||
@Slot(bool)
|
||||
def setPinned(self, pinned: bool) -> None:
|
||||
self.panel.set_pinned(pinned)
|
||||
|
||||
@Slot()
|
||||
def escape(self) -> None:
|
||||
self.panel.handle_escape()
|
||||
|
||||
@Slot()
|
||||
def expand(self) -> None:
|
||||
self.panel.set_expanded(True)
|
||||
|
||||
@Slot()
|
||||
def collapse(self) -> None:
|
||||
if not self.panel.pinned:
|
||||
self.panel.set_expanded(False, restore_focus=False)
|
||||
|
||||
@Slot(str, int, result=str)
|
||||
def moveFocus(self, entry_id: str, step: int) -> str:
|
||||
self.set_keyboard_focus(True)
|
||||
return self.panel.move_focus(entry_id or None, step) or ""
|
||||
|
||||
@Slot(str, float, float)
|
||||
def showTooltip(self, entry_id: str, x: float, y: float) -> None:
|
||||
entry = self.panel.components.get(entry_id)
|
||||
if not isinstance(entry, NavEntry) or self.panel.progress >= 0.5:
|
||||
return
|
||||
pos = self.panel.surface.mapToGlobal(QPoint(int(x), int(y)))
|
||||
QToolTip.showText(pos, self.panel.tooltip_for(entry), self.panel.surface)
|
||||
|
||||
@Slot(str, float, float)
|
||||
def showText(self, text: str, x: float, y: float) -> None:
|
||||
pos = self.panel.surface.mapToGlobal(QPoint(int(x), int(y)))
|
||||
QToolTip.showText(pos, text, self.panel.surface)
|
||||
|
||||
@Slot()
|
||||
def hideTooltip(self) -> None:
|
||||
QToolTip.hideText()
|
||||
|
||||
|
||||
class NavPanelQml(NavPanelBase):
|
||||
"""Navigation panel of the main app rendered with Qt Quick."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, **kwargs):
|
||||
self.backend: NavQmlBackend | None = None
|
||||
self.view: QQuickWidget | None = None
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.backend.refresh_entries()
|
||||
|
||||
def _create_surface(self) -> QWidget:
|
||||
self.backend = NavQmlBackend(self)
|
||||
self.view = create_quick_widget(self, QML_FILE, {"backend": self.backend})
|
||||
self.view.setObjectName("NavPanelSurface")
|
||||
self.view.setClearColor(ThemeTokens().card)
|
||||
self.view.setFocusPolicy(Qt.FocusPolicy.TabFocus)
|
||||
return self.view
|
||||
|
||||
def _surface_entries_changed(self) -> None:
|
||||
if self.backend is not None:
|
||||
self.backend.refresh_entries()
|
||||
|
||||
def _surface_state_changed(self) -> None:
|
||||
if self.backend is not None:
|
||||
self.backend.stateChanged.emit()
|
||||
|
||||
def _surface_progress(self, progress: float) -> None:
|
||||
self.backend.progressChanged.emit()
|
||||
|
||||
def _surface_theme_changed(self) -> None:
|
||||
self.view.setClearColor(ThemeTokens().card)
|
||||
|
||||
def focus_row(self, entry_id: str | None) -> None:
|
||||
entry_id = entry_id or self.move_focus(None, 1)
|
||||
if not entry_id:
|
||||
return
|
||||
self.backend.set_keyboard_focus(True)
|
||||
self.view.setFocus(Qt.FocusReason.TabFocusReason)
|
||||
self.backend.focusRequested.emit(entry_id)
|
||||
|
||||
def row_rect(self, entry_id: str) -> QRect | None:
|
||||
"""Geometry of the row ``entry_id`` in surface coordinates (rows have fixed heights)."""
|
||||
width = self.surface_width()
|
||||
y = HEADER_HEIGHT
|
||||
for entry in [e for e in self.ordered_entries() if e.group == "top"]:
|
||||
height = _HEIGHTS.get(entry.kind, ITEM_HEIGHT)
|
||||
if entry.id == entry_id:
|
||||
return QRect(0, y, width, height)
|
||||
y += height
|
||||
bottom = [e for e in self.ordered_entries() if e.group == "bottom"]
|
||||
y = self.surface.height() - 6 - sum(_HEIGHTS.get(e.kind, ITEM_HEIGHT) for e in bottom)
|
||||
for entry in bottom:
|
||||
height = _HEIGHTS.get(entry.kind, ITEM_HEIGHT)
|
||||
if entry.id == entry_id:
|
||||
return QRect(0, y, width, height)
|
||||
y += height
|
||||
return None
|
||||
|
||||
def tour_target(self, entry_id: str):
|
||||
def target():
|
||||
if entry_id == "toggle":
|
||||
rect = QRect(8, (HEADER_HEIGHT - 40) // 2, 40, 40)
|
||||
else:
|
||||
rect = self.row_rect(entry_id) or QRect(QPoint(0, 0), self.surface.size())
|
||||
window = self.window()
|
||||
top_left = self.surface.mapTo(window, rect.topLeft())
|
||||
return QRect(top_left, rect.size()), None
|
||||
|
||||
return target
|
||||
|
||||
def cleanup(self) -> None:
|
||||
super().cleanup()
|
||||
release_quick_widget(self.view)
|
||||
@@ -0,0 +1,567 @@
|
||||
"""QWidget rendering of the main app's navigation panel; see :mod:`.nav_common`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QEvent, QRectF, QSize, Qt
|
||||
from qtpy.QtGui import QColor, QFont, QPainter, QPen, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QFrame,
|
||||
QScrollArea,
|
||||
QToolButton,
|
||||
QToolTip,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.applications.navigation_centre.nav_common import (
|
||||
HEADER_HEIGHT,
|
||||
ITEM_HEIGHT,
|
||||
SECTION_HEIGHT,
|
||||
SEPARATOR_HEIGHT,
|
||||
NavEntry,
|
||||
NavPanelBase,
|
||||
)
|
||||
from bec_widgets.utils.quick.host import ThemeTokens
|
||||
|
||||
_ICON_CACHE: dict[tuple, QPixmap] = {}
|
||||
# focus rings only show for keyboard focus, like CSS :focus-visible
|
||||
_KEYBOARD_REASONS = (
|
||||
Qt.FocusReason.TabFocusReason,
|
||||
Qt.FocusReason.BacktabFocusReason,
|
||||
Qt.FocusReason.ShortcutFocusReason,
|
||||
)
|
||||
|
||||
|
||||
def _icon(name: str, color: QColor, filled: bool, size: int) -> QPixmap:
|
||||
key = (name, color.name(), filled, size)
|
||||
pixmap = _ICON_CACHE.get(key)
|
||||
if pixmap is None:
|
||||
try:
|
||||
pixmap = material_icon(name, size=QSize(size * 2, size * 2), color=color, filled=filled)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# not every symbol has a filled variant
|
||||
pixmap = material_icon(name, size=QSize(size * 2, size * 2), color=color)
|
||||
_ICON_CACHE[key] = pixmap
|
||||
return pixmap
|
||||
|
||||
|
||||
def _lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def _with_alpha(color: QColor, alpha: float) -> QColor:
|
||||
out = QColor(color)
|
||||
out.setAlphaF(max(0.0, min(1.0, alpha)) * color.alphaF())
|
||||
return out
|
||||
|
||||
|
||||
def _font(px: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
|
||||
font = QFont()
|
||||
font.setPixelSize(px)
|
||||
font.setWeight(weight)
|
||||
return font
|
||||
|
||||
|
||||
def label_alpha(progress: float) -> float:
|
||||
"""Opacity of the drawer texts: they appear in the second half of the animation."""
|
||||
return max(0.0, (progress - 0.35) / 0.65)
|
||||
|
||||
|
||||
def mini_alpha(progress: float) -> float:
|
||||
"""Opacity of the rail labels: they leave in the first half of the animation."""
|
||||
return max(0.0, 1.0 - progress * 2.0)
|
||||
|
||||
|
||||
class _NavRow(QAbstractButton):
|
||||
"""One painted row: item, action, section heading or separator."""
|
||||
|
||||
def __init__(self, panel: "NavPanelQWidget", entry: NavEntry, parent: QWidget):
|
||||
super().__init__(parent)
|
||||
self.panel = panel
|
||||
self.entry = entry
|
||||
self._hover = False
|
||||
self.focus_visible = False
|
||||
height = {"section": SECTION_HEIGHT, "separator": SEPARATOR_HEIGHT}.get(
|
||||
entry.kind, ITEM_HEIGHT
|
||||
)
|
||||
self.setFixedHeight(height)
|
||||
self.setObjectName(f"NavRow_{entry.id}")
|
||||
if entry.focusable:
|
||||
self.setFocusPolicy(Qt.FocusPolicy.TabFocus)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.clicked.connect(lambda: self.panel.activate_from_user(self.entry.id))
|
||||
else:
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.setEnabled(False)
|
||||
self.sync()
|
||||
|
||||
def sync(self) -> None:
|
||||
"""Refresh accessible texts after the entry changed."""
|
||||
name = self.entry.title
|
||||
if self.entry.active:
|
||||
name = f"{name} (current view)"
|
||||
self.setAccessibleName(name)
|
||||
self.setAccessibleDescription(self.entry.subtitle)
|
||||
self.update()
|
||||
|
||||
# --- events
|
||||
def enterEvent(self, event): # pylint: disable=invalid-name
|
||||
self._hover = True
|
||||
self.update()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event): # pylint: disable=invalid-name
|
||||
self._hover = False
|
||||
self.update()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def event(self, event):
|
||||
if event.type() == QEvent.Type.ToolTip and self.entry.focusable:
|
||||
if self.panel.progress < 0.5:
|
||||
QToolTip.showText(event.globalPos(), self.panel.tooltip_for(self.entry), self)
|
||||
else:
|
||||
QToolTip.hideText()
|
||||
return True
|
||||
return super().event(event)
|
||||
|
||||
def focusInEvent(self, event): # pylint: disable=invalid-name
|
||||
self.focus_visible = event.reason() in _KEYBOARD_REASONS
|
||||
super().focusInEvent(event)
|
||||
self.panel.surface_widget.ensure_visible(self)
|
||||
|
||||
def focusOutEvent(self, event): # pylint: disable=invalid-name
|
||||
self.focus_visible = False
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def keyPressEvent(self, event): # pylint: disable=invalid-name
|
||||
key = event.key()
|
||||
steps = {
|
||||
Qt.Key.Key_Up: -1,
|
||||
Qt.Key.Key_Down: 1,
|
||||
Qt.Key.Key_Home: -(10**6),
|
||||
Qt.Key.Key_End: 10**6,
|
||||
}
|
||||
if key in steps:
|
||||
self.panel.focus_row(self.panel.move_focus(self.entry.id, steps[key]))
|
||||
elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
self.click()
|
||||
elif key == Qt.Key.Key_Escape:
|
||||
self.panel.handle_escape()
|
||||
elif key == Qt.Key.Key_Right and not self.panel.is_expanded:
|
||||
self.panel.set_expanded(True)
|
||||
elif key == Qt.Key.Key_Left and self.panel.is_expanded and not self.panel.pinned:
|
||||
self.panel.set_expanded(False, restore_focus=False)
|
||||
self.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def sizeHint(self): # pylint: disable=invalid-name
|
||||
return QSize(self.panel.rail_width, self.height())
|
||||
|
||||
# --- painting
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
tokens = self.panel.tokens
|
||||
p = self.panel.progress
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
kind = self.entry.kind
|
||||
if kind == "separator":
|
||||
painter.setPen(QPen(tokens.border, 1))
|
||||
painter.drawLine(10, self.height() // 2, self.width() - 10, self.height() // 2)
|
||||
return
|
||||
if kind == "section":
|
||||
self._paint_section(painter, tokens, p)
|
||||
return
|
||||
self._paint_item(painter, tokens, p)
|
||||
|
||||
def _paint_section(self, painter: QPainter, tokens: ThemeTokens, p: float) -> None:
|
||||
mid = self.height() // 2
|
||||
rail = self.panel.rail_width
|
||||
painter.setPen(QPen(_with_alpha(tokens.border, 1.0 - p), 1))
|
||||
painter.drawLine(14, mid, rail - 14, mid)
|
||||
painter.setPen(_with_alpha(tokens.fg_subtle, label_alpha(p)))
|
||||
painter.setFont(_font(11, QFont.Weight.DemiBold))
|
||||
text_rect = QRectF(16, 0, self.panel.drawer_width - 32, self.height())
|
||||
painter.drawText(
|
||||
text_rect,
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
self.entry.title.upper(),
|
||||
)
|
||||
|
||||
def _paint_item(self, painter: QPainter, tokens: ThemeTokens, p: float) -> None:
|
||||
entry = self.entry
|
||||
rail = self.panel.rail_width
|
||||
drawer = self.panel.drawer_width
|
||||
active = entry.active and entry.kind == "item"
|
||||
|
||||
# highlight: a pill behind the icon in the rail, the whole row in the drawer
|
||||
pill = QRectF(
|
||||
8, _lerp(5, 2, p), _lerp(rail - 16, self.width() - 16, p), _lerp(28, ITEM_HEIGHT - 4, p)
|
||||
)
|
||||
radius = _lerp(14, 8, p)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
if active:
|
||||
painter.setBrush(tokens.primary)
|
||||
painter.drawRoundedRect(pill, radius, radius)
|
||||
elif self._hover or self.isDown():
|
||||
painter.setBrush(tokens.pressed if self.isDown() else tokens.hover)
|
||||
painter.drawRoundedRect(pill, radius, radius)
|
||||
if self.hasFocus() and self.focus_visible:
|
||||
ring = tokens.on_primary if active else tokens.primary
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(ring, 2))
|
||||
painter.drawRoundedRect(pill.adjusted(1, 1, -1, -1), radius - 1, radius - 1)
|
||||
|
||||
# icon
|
||||
icon_size = 22
|
||||
icon_color = tokens.on_primary if active else tokens.fg
|
||||
icon_y = _lerp(8, (ITEM_HEIGHT - icon_size) / 2, p)
|
||||
pixmap = _icon(entry.icon, icon_color, active, icon_size)
|
||||
painter.drawPixmap(
|
||||
QRectF((rail - icon_size) / 2, icon_y, icon_size, icon_size),
|
||||
pixmap,
|
||||
QRectF(pixmap.rect()),
|
||||
)
|
||||
|
||||
# rail label under the icon
|
||||
alpha = mini_alpha(p)
|
||||
if alpha > 0:
|
||||
weight = QFont.Weight.DemiBold if active else QFont.Weight.Normal
|
||||
painter.setFont(_font(10, weight))
|
||||
painter.setPen(_with_alpha(tokens.fg if active else tokens.fg_muted, alpha))
|
||||
metrics = painter.fontMetrics()
|
||||
text = metrics.elidedText(entry.mini_text, Qt.TextElideMode.ElideRight, rail - 4)
|
||||
painter.drawText(QRectF(2, 35, rail - 4, 16), Qt.AlignmentFlag.AlignCenter, text)
|
||||
|
||||
# drawer texts, laid out for the full drawer width so they never reflow
|
||||
alpha = label_alpha(p)
|
||||
if alpha <= 0:
|
||||
return
|
||||
text_color = tokens.on_primary if active else tokens.fg
|
||||
sub_color = _with_alpha(tokens.on_primary, 0.78) if active else tokens.fg_muted
|
||||
right = drawer - 16
|
||||
if entry.shortcut and (self._hover or (self.hasFocus() and self.focus_visible)):
|
||||
painter.setFont(_font(11))
|
||||
painter.setPen(_with_alpha(sub_color, alpha))
|
||||
short_w = painter.fontMetrics().horizontalAdvance(entry.shortcut)
|
||||
painter.drawText(
|
||||
QRectF(right - short_w, 0, short_w, ITEM_HEIGHT),
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||
entry.shortcut,
|
||||
)
|
||||
right -= short_w + 8
|
||||
left = rail
|
||||
width = right - left
|
||||
painter.setFont(_font(13, QFont.Weight.DemiBold if active else QFont.Weight.Medium))
|
||||
painter.setPen(_with_alpha(text_color, alpha))
|
||||
title = painter.fontMetrics().elidedText(entry.title, Qt.TextElideMode.ElideRight, width)
|
||||
if entry.subtitle:
|
||||
painter.drawText(
|
||||
QRectF(left, 9, width, 20),
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
title,
|
||||
)
|
||||
painter.setFont(_font(11))
|
||||
painter.setPen(_with_alpha(sub_color, alpha))
|
||||
sub = painter.fontMetrics().elidedText(
|
||||
entry.subtitle, Qt.TextElideMode.ElideRight, width
|
||||
)
|
||||
painter.drawText(
|
||||
QRectF(left, 28, width, 18),
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
sub,
|
||||
)
|
||||
else:
|
||||
painter.drawText(
|
||||
QRectF(left, 0, width, ITEM_HEIGHT),
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
title,
|
||||
)
|
||||
|
||||
|
||||
class _HeaderButton(QToolButton):
|
||||
"""Round icon button of the panel header (menu and pin)."""
|
||||
|
||||
def __init__(self, parent: QWidget, size: int):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(size, size)
|
||||
self.setAutoRaise(True)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.TabFocus)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setIconSize(QSize(22, 22))
|
||||
self._hover = False
|
||||
self.focus_visible = False
|
||||
|
||||
def focusInEvent(self, event): # pylint: disable=invalid-name
|
||||
self.focus_visible = event.reason() in _KEYBOARD_REASONS
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event): # pylint: disable=invalid-name
|
||||
self.focus_visible = False
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def enterEvent(self, event): # pylint: disable=invalid-name
|
||||
self._hover = True
|
||||
self.update()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event): # pylint: disable=invalid-name
|
||||
self._hover = False
|
||||
self.update()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
tokens = ThemeTokens() if not hasattr(self.parent(), "tokens") else self.parent().tokens
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(self.rect()).adjusted(1, 1, -1, -1)
|
||||
if self.isDown() or self._hover or self.isChecked():
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(tokens.pressed if self.isDown() or self.isChecked() else tokens.hover)
|
||||
painter.drawRoundedRect(rect, 8, 8)
|
||||
if self.hasFocus() and self.focus_visible:
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(tokens.primary, 2))
|
||||
painter.drawRoundedRect(rect, 8, 8)
|
||||
size = self.iconSize()
|
||||
pixmap = self.icon().pixmap(size)
|
||||
painter.drawPixmap(
|
||||
int((self.width() - size.width()) / 2), int((self.height() - size.height()) / 2), pixmap
|
||||
)
|
||||
|
||||
|
||||
class _NavHeader(QWidget):
|
||||
"""Top row: menu button (always at the same place), drawer title and pin button."""
|
||||
|
||||
def __init__(self, panel: "NavPanelQWidget", parent: QWidget):
|
||||
super().__init__(parent)
|
||||
self.panel = panel
|
||||
self.setFixedHeight(HEADER_HEIGHT)
|
||||
self.menu_button = _HeaderButton(self, 40)
|
||||
self.menu_button.setObjectName("NavMenuButton")
|
||||
self.menu_button.setAccessibleName("Navigation menu")
|
||||
self.menu_button.move(8, (HEADER_HEIGHT - 40) // 2)
|
||||
self.menu_button.clicked.connect(panel.toggle_expanded)
|
||||
self.pin_button = _HeaderButton(self, 32)
|
||||
self.pin_button.setObjectName("NavPinButton")
|
||||
self.pin_button.setCheckable(True)
|
||||
self.pin_button.setIconSize(QSize(18, 18))
|
||||
self.pin_button.setAccessibleName("Keep navigation open")
|
||||
self.pin_button.move(panel.drawer_width - 8 - 32, (HEADER_HEIGHT - 32) // 2)
|
||||
self.pin_button.clicked.connect(panel.set_pinned)
|
||||
|
||||
@property
|
||||
def tokens(self) -> ThemeTokens:
|
||||
"""Theme tokens of the panel."""
|
||||
return self.panel.tokens
|
||||
|
||||
def sync(self) -> None:
|
||||
"""Refresh icons, tooltips and the pin state."""
|
||||
tokens = self.panel.tokens
|
||||
opened = self.panel.progress > 0.5
|
||||
self.menu_button.setIcon(
|
||||
material_icon(
|
||||
"menu_open" if opened else "menu",
|
||||
size=QSize(44, 44),
|
||||
color=tokens.fg,
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
self.menu_button.setToolTip(self.panel.toggle_tooltip())
|
||||
pinned = self.panel.pinned
|
||||
self.pin_button.setChecked(pinned)
|
||||
self.pin_button.setIcon(
|
||||
material_icon(
|
||||
"keep",
|
||||
size=QSize(36, 36),
|
||||
color=tokens.fg if pinned else tokens.fg_muted,
|
||||
filled=pinned,
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
self.pin_button.setToolTip(
|
||||
"Unpin: close the panel after choosing a view"
|
||||
if pinned
|
||||
else "Pin: keep the panel open next to the views"
|
||||
)
|
||||
# the pin is only reachable while the drawer is open
|
||||
self.pin_button.setVisible(self.panel.progress > 0.0 and not self.panel.push_mode)
|
||||
self.pin_button.setFocusPolicy(
|
||||
Qt.FocusPolicy.TabFocus
|
||||
if opened and not self.panel.push_mode
|
||||
else Qt.FocusPolicy.NoFocus
|
||||
)
|
||||
self.update()
|
||||
|
||||
def keyPressEvent(self, event): # pylint: disable=invalid-name
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self.panel.handle_escape()
|
||||
elif event.key() == Qt.Key.Key_Down:
|
||||
self.panel.focus_row(self.panel.move_focus(None, 1))
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
p = self.panel.progress
|
||||
alpha = label_alpha(p)
|
||||
if alpha <= 0:
|
||||
return
|
||||
tokens = self.panel.tokens
|
||||
painter = QPainter(self)
|
||||
painter.setFont(_font(14, QFont.Weight.DemiBold))
|
||||
painter.setPen(_with_alpha(tokens.fg, alpha))
|
||||
painter.drawText(
|
||||
QRectF(60, 0, 120, HEADER_HEIGHT),
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
self.panel.title,
|
||||
)
|
||||
|
||||
|
||||
class _NavSurface(QFrame):
|
||||
"""The floating surface: header, scrolling top rows and fixed bottom rows."""
|
||||
|
||||
def __init__(self, panel: "NavPanelQWidget"):
|
||||
super().__init__(None)
|
||||
self.panel = panel
|
||||
self.setObjectName("NavPanelSurface")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 1, 6)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.header = _NavHeader(panel, self)
|
||||
layout.addWidget(self.header)
|
||||
|
||||
self.scroll = QScrollArea(self)
|
||||
self.scroll.setObjectName("NavPanelScroll")
|
||||
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.scroll.setWidgetResizable(True)
|
||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.scroll.viewport().setAutoFillBackground(False)
|
||||
self.top_box = QWidget()
|
||||
self.top_box.setAutoFillBackground(False)
|
||||
self.top_layout = QVBoxLayout(self.top_box)
|
||||
self.top_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.top_layout.setSpacing(0)
|
||||
self.scroll.setWidget(self.top_box)
|
||||
layout.addWidget(self.scroll, 1)
|
||||
|
||||
self.bottom_box = QWidget(self)
|
||||
self.bottom_layout = QVBoxLayout(self.bottom_box)
|
||||
self.bottom_layout.setContentsMargins(0, 6, 0, 0)
|
||||
self.bottom_layout.setSpacing(0)
|
||||
layout.addWidget(self.bottom_box)
|
||||
|
||||
self.rows: dict[str, _NavRow] = {}
|
||||
for name, widget in (
|
||||
("NavPanelScroll", self.scroll),
|
||||
("NavPanelViewport", self.scroll.viewport()),
|
||||
("NavPanelTop", self.top_box),
|
||||
("NavPanelBottom", self.bottom_box),
|
||||
):
|
||||
widget.setObjectName(name)
|
||||
self.setStyleSheet(
|
||||
"#NavPanelScroll, #NavPanelViewport, #NavPanelTop, #NavPanelBottom"
|
||||
" { background: transparent; border: none; }"
|
||||
"#NavPanelScroll QScrollBar:vertical { width: 6px; background: transparent; }"
|
||||
)
|
||||
|
||||
@property
|
||||
def tokens(self) -> ThemeTokens:
|
||||
"""Theme tokens of the panel."""
|
||||
return self.panel.tokens
|
||||
|
||||
def rebuild(self) -> None:
|
||||
"""Recreate the rows from the panel entries."""
|
||||
focused = next((rid for rid, row in self.rows.items() if row.hasFocus()), None)
|
||||
for row in self.rows.values():
|
||||
row.hide()
|
||||
row.deleteLater()
|
||||
self.rows = {}
|
||||
for layout in (self.top_layout, self.bottom_layout):
|
||||
while layout.count():
|
||||
layout.takeAt(0)
|
||||
for entry in self.panel.ordered_entries():
|
||||
box = self.top_box if entry.group == "top" else self.bottom_box
|
||||
row = _NavRow(self.panel, entry, box)
|
||||
(self.top_layout if entry.group == "top" else self.bottom_layout).addWidget(row)
|
||||
self.rows[entry.id] = row
|
||||
self.top_layout.addStretch(1)
|
||||
self.bottom_box.setVisible(self.bottom_layout.count() > 0)
|
||||
self.sync()
|
||||
if focused in self.rows:
|
||||
self.rows[focused].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def sync(self) -> None:
|
||||
"""Repaint everything for the current progress and state."""
|
||||
for row in self.rows.values():
|
||||
row.sync()
|
||||
self.header.sync()
|
||||
self.update()
|
||||
|
||||
def ensure_visible(self, row: QWidget) -> None:
|
||||
"""Scroll ``row`` into view when it sits in the scrolling area."""
|
||||
if row.parentWidget() is self.top_box:
|
||||
self.scroll.ensureWidgetVisible(row, 0, 4)
|
||||
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
tokens = self.tokens
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), tokens.card)
|
||||
painter.setPen(QPen(tokens.border, 1))
|
||||
painter.drawLine(self.width() - 1, 0, self.width() - 1, self.height())
|
||||
if self.bottom_box.isVisible():
|
||||
y = self.bottom_box.y()
|
||||
painter.drawLine(10, y, self.width() - 11, y)
|
||||
|
||||
|
||||
class NavPanelQWidget(NavPanelBase):
|
||||
"""Navigation panel of the main app rendered with QWidgets."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, **kwargs):
|
||||
self.tokens = ThemeTokens()
|
||||
self.surface_widget: _NavSurface | None = None
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
# legacy attribute: the button that expands and collapses the panel
|
||||
self.toggle = self.surface_widget.header.menu_button
|
||||
self.surface_widget.sync()
|
||||
|
||||
def _create_surface(self) -> QWidget:
|
||||
self.surface_widget = _NavSurface(self)
|
||||
return self.surface_widget
|
||||
|
||||
def _surface_entries_changed(self) -> None:
|
||||
self.surface_widget.rebuild()
|
||||
|
||||
def _surface_state_changed(self) -> None:
|
||||
self.surface_widget.sync()
|
||||
|
||||
def _surface_progress(self, progress: float) -> None:
|
||||
self.surface_widget.sync()
|
||||
|
||||
def _surface_theme_changed(self) -> None:
|
||||
self.tokens = ThemeTokens()
|
||||
_ICON_CACHE.clear()
|
||||
self.surface_widget.sync()
|
||||
|
||||
def focus_row(self, entry_id: str | None) -> None:
|
||||
entry_id = entry_id or self.move_focus(None, 1)
|
||||
row = self.surface_widget.rows.get(entry_id) if entry_id else None
|
||||
if row is not None:
|
||||
row.setFocus(Qt.FocusReason.TabFocusReason)
|
||||
|
||||
def row_widget(self, entry_id: str) -> QWidget | None:
|
||||
"""Return the row widget of ``entry_id``."""
|
||||
return self.surface_widget.rows.get(entry_id)
|
||||
|
||||
def tour_target(self, entry_id: str):
|
||||
if entry_id == "toggle":
|
||||
return self.surface_widget.header.menu_button
|
||||
return lambda: (self.surface_widget.rows.get(entry_id) or self.surface, None)
|
||||
@@ -0,0 +1,325 @@
|
||||
import QtQuick
|
||||
import BecUi
|
||||
|
||||
// Navigation panel of the main app. The rows keep their geometry in the rail (56 px) and in the
|
||||
// drawer (256 px); the surface only widens over the views and the labels fade.
|
||||
Item {
|
||||
id: root
|
||||
required property var backend
|
||||
|
||||
readonly property real p: backend.progress
|
||||
readonly property int railW: backend.railWidth
|
||||
readonly property int drawerW: backend.drawerWidth
|
||||
|
||||
clip: true
|
||||
|
||||
function lerp(a, b, t) { return a + (b - a) * t }
|
||||
function labelAlpha() { return Math.max(0, (root.p - 0.35) / 0.65) }
|
||||
function miniAlpha() { return Math.max(0, 1 - root.p * 2) }
|
||||
|
||||
function focusRow(id) {
|
||||
const reps = [topRepeater, bottomRepeater]
|
||||
for (let r = 0; r < reps.length; ++r) {
|
||||
for (let i = 0; i < reps[r].count; ++i) {
|
||||
const item = reps[r].itemAt(i)
|
||||
if (item && item.entry.id === id) {
|
||||
item.forceActiveFocus(Qt.TabFocusReason)
|
||||
if (r === 0) flick.ensureVisible(item)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.backend
|
||||
function onFocusRequested(id) { root.focusRow(id) }
|
||||
}
|
||||
|
||||
Rectangle { anchors.fill: parent; color: theme.card }
|
||||
Rectangle {
|
||||
width: 1; height: parent.height; anchors.right: parent.right
|
||||
color: theme.border
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ header
|
||||
component HeaderButton: Item {
|
||||
id: hb
|
||||
property string icon: ""
|
||||
property int iconSize: 22
|
||||
property bool checked: false
|
||||
property bool filled: false
|
||||
property bool muted: false
|
||||
property string tip: ""
|
||||
signal clicked()
|
||||
activeFocusOnTab: true
|
||||
Keys.onPressed: (event) => {
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|
||||
|| event.key === Qt.Key_Space) { hb.clicked(); event.accepted = true }
|
||||
else if (event.key === Qt.Key_Escape) { root.backend.escape(); event.accepted = true }
|
||||
else if (event.key === Qt.Key_Down) {
|
||||
root.focusRow(root.backend.moveFocus("", 1)); event.accepted = true
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent; anchors.margins: 1; radius: 8
|
||||
color: hbMouse.pressed || hb.checked ? theme.pressed
|
||||
: hbMouse.containsMouse ? theme.hover : "transparent"
|
||||
border.width: hb.activeFocus && root.backend.keyboardFocus ? 2 : 0
|
||||
border.color: theme.primary
|
||||
}
|
||||
Icon {
|
||||
anchors.centerIn: parent
|
||||
name: hb.icon; size: hb.iconSize; filled: hb.filled
|
||||
color: hb.muted ? theme.fgMuted : theme.fg
|
||||
}
|
||||
MouseArea {
|
||||
id: hbMouse
|
||||
anchors.fill: parent; hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: hb.clicked()
|
||||
onEntered: hbTip.restart()
|
||||
onExited: { hbTip.stop(); root.backend.hideTooltip() }
|
||||
}
|
||||
Timer {
|
||||
id: hbTip; interval: 700
|
||||
onTriggered: {
|
||||
const pt = hb.mapToItem(root, hb.width, hb.height / 2)
|
||||
root.backend.showText(hb.tip, pt.x, pt.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: header
|
||||
width: parent.width; height: root.backend.headerHeight
|
||||
|
||||
HeaderButton {
|
||||
objectName: "NavMenuButton"
|
||||
x: 8; y: (header.height - 40) / 2; width: 40; height: 40
|
||||
icon: root.p > 0.5 ? "menu_open" : "menu"
|
||||
tip: root.backend.toggleTooltip
|
||||
onClicked: root.backend.toggleExpanded()
|
||||
}
|
||||
Text {
|
||||
x: 60; width: 120; height: header.height
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: root.backend.title
|
||||
color: theme.fg
|
||||
opacity: root.labelAlpha()
|
||||
visible: opacity > 0
|
||||
font.pixelSize: 14; font.weight: Font.DemiBold
|
||||
}
|
||||
HeaderButton {
|
||||
objectName: "NavPinButton"
|
||||
x: root.drawerW - 8 - 32; y: (header.height - 32) / 2; width: 32; height: 32
|
||||
visible: root.p > 0 && root.backend.pinAvailable
|
||||
activeFocusOnTab: root.p > 0.5 && root.backend.pinAvailable
|
||||
icon: "keep"; iconSize: 18
|
||||
filled: root.backend.pinned; checked: root.backend.pinned
|
||||
muted: !root.backend.pinned
|
||||
tip: root.backend.pinned ? "Unpin: close the panel after choosing a view"
|
||||
: "Pin: keep the panel open next to the views"
|
||||
onClicked: root.backend.setPinned(!root.backend.pinned)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rows
|
||||
component NavRow: Item {
|
||||
id: row
|
||||
required property var modelData
|
||||
readonly property var entry: modelData
|
||||
readonly property bool active: root.backend.activeIds.indexOf(entry.id) >= 0
|
||||
readonly property bool hovered: mouse.containsMouse
|
||||
readonly property bool focusVisible: activeFocus && root.backend.keyboardFocus
|
||||
readonly property bool isItem: entry.kind === "item" || entry.kind === "action"
|
||||
readonly property color textColor: active ? theme.onPrimary : theme.fg
|
||||
readonly property color subColor: active ? Qt.rgba(theme.onPrimary.r, theme.onPrimary.g,
|
||||
theme.onPrimary.b, 0.78)
|
||||
: theme.fgMuted
|
||||
readonly property bool showShortcut: entry.shortcut !== "" && (hovered || focusVisible)
|
||||
|
||||
width: parent ? parent.width : root.width
|
||||
height: entry.height
|
||||
activeFocusOnTab: entry.focusable
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: entry.title + (active ? " (current view)" : "")
|
||||
Accessible.description: entry.subtitle
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
const steps = { [Qt.Key_Up]: -1, [Qt.Key_Down]: 1,
|
||||
[Qt.Key_Home]: -1000000, [Qt.Key_End]: 1000000 }
|
||||
if (event.key in steps) {
|
||||
root.focusRow(root.backend.moveFocus(entry.id, steps[event.key]))
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|
||||
|| event.key === Qt.Key_Space) {
|
||||
root.backend.activate(entry.id)
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
root.backend.escape()
|
||||
} else if (event.key === Qt.Key_Right && !root.backend.opened) {
|
||||
root.backend.expand()
|
||||
} else if (event.key === Qt.Key_Left && root.backend.opened) {
|
||||
root.backend.collapse()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
event.accepted = true
|
||||
}
|
||||
|
||||
// separator
|
||||
Rectangle {
|
||||
visible: row.entry.kind === "separator"
|
||||
x: 10; width: row.width - 20; height: 1; y: Math.floor(row.height / 2)
|
||||
color: theme.border
|
||||
}
|
||||
|
||||
// section: a short divider in the rail, a heading in the drawer
|
||||
Rectangle {
|
||||
visible: row.entry.kind === "section"
|
||||
x: 14; width: root.railW - 28; height: 1; y: Math.floor(row.height / 2)
|
||||
color: theme.border; opacity: 1 - root.p
|
||||
}
|
||||
Text {
|
||||
visible: row.entry.kind === "section" && opacity > 0
|
||||
x: 16; width: root.drawerW - 32; height: row.height
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: row.entry.title.toUpperCase()
|
||||
color: theme.fgSubtle; opacity: root.labelAlpha()
|
||||
font.pixelSize: 11; font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
// item highlight: a pill behind the icon in the rail, the whole row in the drawer
|
||||
Rectangle {
|
||||
visible: row.isItem
|
||||
x: 8
|
||||
y: root.lerp(5, 2, root.p)
|
||||
width: root.lerp(root.railW - 16, row.width - 16, root.p)
|
||||
height: root.lerp(28, row.height - 4, root.p)
|
||||
radius: root.lerp(14, 8, root.p)
|
||||
color: row.active && row.entry.kind === "item" ? theme.primary
|
||||
: mouse.pressed ? theme.pressed
|
||||
: row.hovered ? theme.hover : "transparent"
|
||||
border.width: row.focusVisible ? 2 : 0
|
||||
border.color: row.active ? theme.onPrimary : theme.primary
|
||||
}
|
||||
Icon {
|
||||
visible: row.isItem
|
||||
x: (root.railW - 22) / 2
|
||||
y: root.lerp(8, (row.height - 22) / 2, root.p)
|
||||
size: 22
|
||||
name: row.entry.icon
|
||||
filled: row.active
|
||||
color: row.active ? theme.onPrimary : theme.fg
|
||||
}
|
||||
Text {
|
||||
visible: row.isItem && opacity > 0
|
||||
x: 2; y: 35; width: root.railW - 4; height: 16
|
||||
horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
text: row.entry.miniText
|
||||
color: row.active ? theme.fg : theme.fgMuted
|
||||
opacity: root.miniAlpha()
|
||||
font.pixelSize: 10; font.weight: row.active ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
Text {
|
||||
id: shortcutText
|
||||
visible: row.isItem && row.showShortcut && opacity > 0
|
||||
x: root.drawerW - 16 - implicitWidth; height: row.height
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: row.entry.shortcut
|
||||
color: row.subColor; opacity: root.labelAlpha()
|
||||
font.pixelSize: 11
|
||||
}
|
||||
Item {
|
||||
visible: row.isItem && root.labelAlpha() > 0
|
||||
opacity: root.labelAlpha()
|
||||
x: root.railW
|
||||
width: root.drawerW - 16 - root.railW
|
||||
- (row.showShortcut ? shortcutText.implicitWidth + 8 : 0)
|
||||
height: row.height
|
||||
Text {
|
||||
y: row.entry.subtitle !== "" ? 9 : 0
|
||||
width: parent.width; height: row.entry.subtitle !== "" ? 20 : row.height
|
||||
verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight
|
||||
text: row.entry.title; color: row.textColor
|
||||
font.pixelSize: 13
|
||||
font.weight: row.active ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
Text {
|
||||
visible: row.entry.subtitle !== ""
|
||||
y: 28; width: parent.width; height: 18
|
||||
verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight
|
||||
text: row.entry.subtitle; color: row.subColor
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
enabled: row.isItem
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.backend.activateFromMouse(row.entry.id)
|
||||
onEntered: tipTimer.restart()
|
||||
onExited: { tipTimer.stop(); root.backend.hideTooltip() }
|
||||
}
|
||||
Timer {
|
||||
id: tipTimer; interval: 700
|
||||
onTriggered: {
|
||||
const pt = row.mapToItem(root, root.railW, row.height / 2)
|
||||
root.backend.showTooltip(row.entry.id, pt.x, pt.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
y: header.height
|
||||
width: parent.width - 1
|
||||
height: bottomBox.y - y
|
||||
contentHeight: topColumn.height
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
interactive: contentHeight > height
|
||||
|
||||
function ensureVisible(item) {
|
||||
if (item.y < contentY) contentY = item.y
|
||||
else if (item.y + item.height > contentY + height)
|
||||
contentY = item.y + item.height - height
|
||||
}
|
||||
|
||||
Column {
|
||||
id: topColumn
|
||||
width: flick.width
|
||||
Repeater {
|
||||
id: topRepeater
|
||||
model: root.backend.topEntries
|
||||
delegate: NavRow {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: bottomBox
|
||||
visible: bottomRepeater.count > 0
|
||||
width: parent.width - 1
|
||||
height: visible ? bottomColumn.height + 6 : 0
|
||||
y: root.height - 6 - height
|
||||
|
||||
Rectangle {
|
||||
x: 10; width: parent.width - 20; height: 1
|
||||
color: theme.border
|
||||
}
|
||||
Column {
|
||||
id: bottomColumn
|
||||
y: 6
|
||||
width: parent.width
|
||||
Repeater {
|
||||
id: bottomRepeater
|
||||
model: root.backend.bottomEntries
|
||||
delegate: NavRow {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,11 @@ class SideBar(QScrollArea):
|
||||
self.title_label.setGraphicsEffect(self.title_fx)
|
||||
self.title_fx.setOpacity(0.0)
|
||||
self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
# Let the title shrink with the bar; otherwise its width pushes the toggle button out of
|
||||
# the visible area at the start of the expand animation.
|
||||
self.title_label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Preferred
|
||||
)
|
||||
|
||||
self.toggle = QToolButton(self)
|
||||
self.toggle.setCheckable(False)
|
||||
@@ -227,7 +232,10 @@ class SideBar(QScrollArea):
|
||||
|
||||
"""
|
||||
header = SectionHeader(self, title, anim_duration=self._anim_duration)
|
||||
position = position if position is not None else self.content_layout.count() - 1
|
||||
# Sections belong to the top group: append them above the spacer, not after the
|
||||
# bottom items (which put a later section header above the bottom items).
|
||||
spacer_index = self.content_layout.indexOf(self._bottom_spacer)
|
||||
position = spacer_index if position is None else min(position, spacer_index)
|
||||
self.content_layout.insertWidget(position, header)
|
||||
for anim in header.animations:
|
||||
self.group.addAnimation(anim)
|
||||
@@ -257,6 +265,7 @@ class SideBar(QScrollArea):
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
subtitle: str | None = None,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Add a navigation item to the side bar.
|
||||
@@ -270,6 +279,7 @@ class SideBar(QScrollArea):
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
subtitle(str, optional): Description, shown as the tooltip.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
@@ -283,6 +293,7 @@ class SideBar(QScrollArea):
|
||||
exclusive=exclusive,
|
||||
anim_duration=self._anim_duration,
|
||||
)
|
||||
item.setToolTip(f"{title}\n{subtitle}" if subtitle else title)
|
||||
self._insert_nav_item(item, from_top=from_top, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Shared plumbing for widgets that render their content with Qt Quick (QML).
|
||||
|
||||
The QML ports keep the usual ``BECWidget`` shell and public API and only swap the rendering
|
||||
layer: a :class:`~qtpy.QtQuickWidgets.QQuickWidget` is embedded into the existing widget and
|
||||
driven by a small Python backend object.
|
||||
"""
|
||||
|
||||
from bec_widgets.utils.quick.host import (
|
||||
QML_IMPORT_PATH,
|
||||
MaterialIconProvider,
|
||||
QmlTheme,
|
||||
create_quick_widget,
|
||||
quick_engine,
|
||||
release_quick_widget,
|
||||
)
|
||||
from bec_widgets.utils.quick.list_model import DictListModel
|
||||
|
||||
__all__ = [
|
||||
"QML_IMPORT_PATH",
|
||||
"DictListModel",
|
||||
"MaterialIconProvider",
|
||||
"QmlTheme",
|
||||
"create_quick_widget",
|
||||
"quick_engine",
|
||||
"release_quick_widget",
|
||||
]
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Hosting helpers for QML views embedded into BEC widgets.
|
||||
|
||||
All QML views share one :class:`QQmlEngine` per application. The engine carries the
|
||||
application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.theme`` from
|
||||
``bec_qthemes``), an ``image://material/<name>`` provider for Material icons and the import path
|
||||
of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial
|
||||
properties of the root item, typically a single ``backend`` QObject.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, QObject, QSize, Qt, QUrl, Signal
|
||||
from qtpy.QtGui import QColor, QPixmap
|
||||
from qtpy.QtQml import QQmlEngine
|
||||
from qtpy.QtQuick import QQuickImageProvider
|
||||
from qtpy.QtQuickWidgets import QQuickWidget
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
QML_IMPORT_PATH = Path(__file__).parent / "qml"
|
||||
|
||||
_DARK_FALLBACK = {
|
||||
"PRIMARY": "#3b82f6",
|
||||
"ON_PRIMARY": "#ffffff",
|
||||
"BG": "#0f1115",
|
||||
"CARD_BG": "#171a21",
|
||||
"FIELD_BG": "#10131a",
|
||||
"BORDER": "#2a2f3a",
|
||||
"FG": "#e8ebf1",
|
||||
"ACCENT_DEFAULT": "#8ab4f7",
|
||||
"ACCENT_HIGHLIGHT": "#B53565",
|
||||
"ACCENT_WARNING": "#EAC435",
|
||||
"ACCENT_EMERGENCY": "#CC181E",
|
||||
"ACCENT_SUCCESS": "#2CA58D",
|
||||
}
|
||||
|
||||
|
||||
def _blend(base: QColor, top: QColor, amount: float) -> QColor:
|
||||
"""Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top)."""
|
||||
return QColor.fromRgbF(
|
||||
base.redF() + (top.redF() - base.redF()) * amount,
|
||||
base.greenF() + (top.greenF() - base.greenF()) * amount,
|
||||
base.blueF() + (top.blueF() - base.blueF()) * amount,
|
||||
1.0,
|
||||
)
|
||||
|
||||
|
||||
class ThemeTokens:
|
||||
"""Resolved colour tokens of the active BEC theme, shared by the QML and QWidget ports."""
|
||||
|
||||
def __init__(self):
|
||||
app = QApplication.instance()
|
||||
theme = getattr(app, "theme", None) if app is not None else None
|
||||
name = getattr(theme, "theme", "dark") if theme is not None else "dark"
|
||||
|
||||
def color(key: str) -> QColor:
|
||||
fallback = _DARK_FALLBACK.get(key, "#808080")
|
||||
if theme is None:
|
||||
return QColor(fallback)
|
||||
return QColor(theme.color(key, fallback))
|
||||
|
||||
self.name: str = str(name)
|
||||
self.bg = color("BG")
|
||||
self.card = color("CARD_BG")
|
||||
self.field = color("FIELD_BG")
|
||||
self.border = color("BORDER")
|
||||
self.fg = color("FG")
|
||||
self.primary = color("PRIMARY")
|
||||
self.on_primary = color("ON_PRIMARY")
|
||||
self.accent = color("ACCENT_DEFAULT")
|
||||
self.highlight = color("ACCENT_HIGHLIGHT")
|
||||
self.warning = color("ACCENT_WARNING")
|
||||
self.danger = color("ACCENT_EMERGENCY")
|
||||
self.success = color("ACCENT_SUCCESS")
|
||||
self.dark = self.bg.lightnessF() < 0.5
|
||||
self.fg_muted = _blend(self.card, self.fg, 0.62)
|
||||
self.fg_subtle = _blend(self.card, self.fg, 0.4)
|
||||
self.hover = _blend(self.card, self.fg, 0.06)
|
||||
self.pressed = _blend(self.card, self.fg, 0.12)
|
||||
self.track = _blend(self.card, self.fg, 0.1)
|
||||
|
||||
def soft(self, color: QColor, amount: float = 0.16) -> QColor:
|
||||
"""Tint ``color`` onto the card background, e.g. for chips and banners."""
|
||||
return _blend(self.card, color, amount)
|
||||
|
||||
|
||||
class QmlTheme(QObject):
|
||||
"""Expose the BEC theme colours to QML as ``theme.<token>`` and follow theme changes."""
|
||||
|
||||
changed = Signal()
|
||||
|
||||
def __init__(self, parent: QObject | None = None):
|
||||
super().__init__(parent)
|
||||
self._tokens = ThemeTokens()
|
||||
self._connected_theme = None
|
||||
self._connect_theme()
|
||||
|
||||
def _connect_theme(self) -> None:
|
||||
app = QApplication.instance()
|
||||
theme = getattr(app, "theme", None) if app is not None else None
|
||||
if theme is None or theme is self._connected_theme:
|
||||
return
|
||||
theme.theme_changed.connect(self.refresh)
|
||||
self._connected_theme = theme
|
||||
|
||||
def refresh(self, *_args) -> None:
|
||||
"""Re-read the tokens from the application theme."""
|
||||
self._connect_theme()
|
||||
self._tokens = ThemeTokens()
|
||||
self.changed.emit()
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
def _get(self, name: str) -> QColor:
|
||||
return getattr(self._tokens, name)
|
||||
|
||||
name = Property(str, lambda self: self._tokens.name, notify=changed)
|
||||
dark = Property(bool, lambda self: self._tokens.dark, notify=changed)
|
||||
bg = Property(QColor, lambda self: self._get("bg"), notify=changed)
|
||||
card = Property(QColor, lambda self: self._get("card"), notify=changed)
|
||||
field = Property(QColor, lambda self: self._get("field"), notify=changed)
|
||||
border = Property(QColor, lambda self: self._get("border"), notify=changed)
|
||||
fg = Property(QColor, lambda self: self._get("fg"), notify=changed)
|
||||
fgMuted = Property(QColor, lambda self: self._get("fg_muted"), notify=changed)
|
||||
fgSubtle = Property(QColor, lambda self: self._get("fg_subtle"), notify=changed)
|
||||
hover = Property(QColor, lambda self: self._get("hover"), notify=changed)
|
||||
pressed = Property(QColor, lambda self: self._get("pressed"), notify=changed)
|
||||
track = Property(QColor, lambda self: self._get("track"), notify=changed)
|
||||
primary = Property(QColor, lambda self: self._get("primary"), notify=changed)
|
||||
onPrimary = Property(QColor, lambda self: self._get("on_primary"), notify=changed)
|
||||
accent = Property(QColor, lambda self: self._get("accent"), notify=changed)
|
||||
highlight = Property(QColor, lambda self: self._get("highlight"), notify=changed)
|
||||
warning = Property(QColor, lambda self: self._get("warning"), notify=changed)
|
||||
danger = Property(QColor, lambda self: self._get("danger"), notify=changed)
|
||||
success = Property(QColor, lambda self: self._get("success"), notify=changed)
|
||||
|
||||
|
||||
class MaterialIconProvider(QQuickImageProvider):
|
||||
"""Serve Material icons to QML as ``image://material/<name>?color=%23rrggbb&filled=1``."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(QQuickImageProvider.ImageType.Pixmap)
|
||||
|
||||
def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap:
|
||||
name, _, query = icon_id.partition("?")
|
||||
options = parse_qs(query)
|
||||
color = options.get("color", [None])[0]
|
||||
filled = options.get("filled", ["0"])[0] in ("1", "true")
|
||||
target = requested_size if requested_size.isValid() else QSize(24, 24)
|
||||
if target.width() <= 0 or target.height() <= 0:
|
||||
target = QSize(24, 24)
|
||||
pixmap = material_icon(name, size=target, color=color, filled=filled)
|
||||
if size is not None:
|
||||
size.setWidth(pixmap.width())
|
||||
size.setHeight(pixmap.height())
|
||||
return pixmap
|
||||
|
||||
|
||||
_ENGINE: QQmlEngine | None = None
|
||||
_THEME: QmlTheme | None = None
|
||||
|
||||
|
||||
def quick_engine() -> QQmlEngine:
|
||||
"""Return the application-wide QML engine used by all BEC QML views."""
|
||||
global _ENGINE, _THEME # pylint: disable=global-statement
|
||||
if _ENGINE is not None:
|
||||
if _THEME._connected_theme is None: # pylint: disable=protected-access
|
||||
# the theme was applied after the engine was created
|
||||
_THEME.refresh()
|
||||
return _ENGINE
|
||||
app = QApplication.instance()
|
||||
_ENGINE = QQmlEngine(app)
|
||||
_ENGINE.addImportPath(str(QML_IMPORT_PATH))
|
||||
_ENGINE.addImageProvider("material", MaterialIconProvider())
|
||||
_THEME = QmlTheme(_ENGINE)
|
||||
_ENGINE.rootContext().setContextProperty("theme", _THEME)
|
||||
_ENGINE.warnings.connect(
|
||||
lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings]
|
||||
)
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(_drop_engine)
|
||||
return _ENGINE
|
||||
|
||||
|
||||
def _drop_engine() -> None:
|
||||
global _ENGINE, _THEME # pylint: disable=global-statement
|
||||
_ENGINE = None
|
||||
_THEME = None
|
||||
|
||||
|
||||
def create_quick_widget(
|
||||
parent: QWidget, qml_file: str | Path, properties: dict | None = None
|
||||
) -> QQuickWidget:
|
||||
"""Create a QQuickWidget showing ``qml_file`` on the shared engine.
|
||||
|
||||
Args:
|
||||
parent(QWidget): Parent widget, usually the BEC widget shell.
|
||||
qml_file(str | Path): Path of the root QML file.
|
||||
properties(dict | None): Initial properties of the root item, e.g. ``{"backend": obj}``.
|
||||
|
||||
Returns:
|
||||
QQuickWidget: The view, already loaded. Load errors are logged.
|
||||
"""
|
||||
view = QQuickWidget(quick_engine(), parent)
|
||||
view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView)
|
||||
view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False)
|
||||
view.setClearColor(parent.palette().window().color())
|
||||
if properties:
|
||||
view.setInitialProperties(properties)
|
||||
view.setSource(QUrl.fromLocalFile(str(qml_file)))
|
||||
for error in view.errors():
|
||||
logger.error(f"QML error in {qml_file}: {error.toString()}")
|
||||
return view
|
||||
|
||||
|
||||
def release_quick_widget(view: QQuickWidget | None) -> None:
|
||||
"""Unload the QML scene of ``view`` before its backend objects are destroyed.
|
||||
|
||||
Without this, bindings of a still-loaded scene are re-evaluated against already deleted
|
||||
backends during teardown and log ``TypeError`` warnings.
|
||||
"""
|
||||
if view is None:
|
||||
return
|
||||
try:
|
||||
view.setSource(QUrl())
|
||||
except RuntimeError:
|
||||
# the C++ object is already gone
|
||||
return
|
||||
@@ -0,0 +1,83 @@
|
||||
"""A small list model that feeds lists of dictionaries to QML views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
from qtpy.QtCore import Property, QAbstractListModel, QByteArray, QModelIndex, Qt, Signal
|
||||
|
||||
|
||||
class DictListModel(QAbstractListModel):
|
||||
"""List model whose rows are plain dictionaries with a fixed set of keys.
|
||||
|
||||
Each key becomes a QML role. :meth:`set_items` updates rows in place when the row count is
|
||||
unchanged, so QML delegates are kept alive and ``Behavior`` animations run on value changes
|
||||
instead of the delegates being rebuilt.
|
||||
|
||||
Args:
|
||||
roles(Sequence[str]): Keys of the row dictionaries exposed as roles.
|
||||
parent: Parent QObject.
|
||||
"""
|
||||
|
||||
count_changed = Signal()
|
||||
|
||||
def __init__(self, roles: Sequence[str], parent=None):
|
||||
super().__init__(parent)
|
||||
self._keys = list(roles)
|
||||
self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(self._keys)}
|
||||
self._items: list[dict[str, Any]] = []
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def roleNames(self) -> dict[int, QByteArray]:
|
||||
return {role: QByteArray(key.encode()) for role, key in self._roles.items()}
|
||||
|
||||
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
if parent.isValid():
|
||||
return 0
|
||||
return len(self._items)
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid() or not 0 <= index.row() < len(self._items):
|
||||
return None
|
||||
key = self._roles.get(role)
|
||||
if key is None:
|
||||
return None
|
||||
return self._items[index.row()].get(key)
|
||||
|
||||
@property
|
||||
def items(self) -> list[dict[str, Any]]:
|
||||
"""Copy of the current rows."""
|
||||
return [dict(item) for item in self._items]
|
||||
|
||||
def set_items(self, items: Sequence[dict[str, Any]]) -> None:
|
||||
"""Replace the rows, reusing existing rows where possible.
|
||||
|
||||
Args:
|
||||
items(Sequence[dict]): New rows.
|
||||
"""
|
||||
items = [dict(item) for item in items]
|
||||
old_count = len(self._items)
|
||||
new_count = len(items)
|
||||
common = min(old_count, new_count)
|
||||
for row in range(common):
|
||||
changed = [
|
||||
role
|
||||
for role, key in self._roles.items()
|
||||
if self._items[row].get(key) != items[row].get(key)
|
||||
]
|
||||
self._items[row] = items[row]
|
||||
if changed:
|
||||
index = self.index(row, 0)
|
||||
self.dataChanged.emit(index, index, changed)
|
||||
if new_count > old_count:
|
||||
self.beginInsertRows(QModelIndex(), old_count, new_count - 1)
|
||||
self._items.extend(items[old_count:])
|
||||
self.endInsertRows()
|
||||
elif new_count < old_count:
|
||||
self.beginRemoveRows(QModelIndex(), new_count, old_count - 1)
|
||||
del self._items[new_count:]
|
||||
self.endRemoveRows()
|
||||
if new_count != old_count:
|
||||
self.count_changed.emit()
|
||||
|
||||
count = Property(int, lambda self: len(self._items), notify=count_changed)
|
||||
@@ -0,0 +1,53 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Rounded surface with an optional title row. Children go into the content column.
|
||||
Rectangle {
|
||||
id: root
|
||||
property string title: ""
|
||||
property string subtitle: ""
|
||||
default property alias content: column.data
|
||||
property alias headerExtras: extras.data
|
||||
property int padding: 12
|
||||
property alias spacing: column.spacing
|
||||
|
||||
color: theme.card
|
||||
radius: 10
|
||||
border.color: theme.border
|
||||
border.width: 1
|
||||
implicitHeight: outer.implicitHeight + 2 * padding
|
||||
implicitWidth: outer.implicitWidth + 2 * padding
|
||||
|
||||
ColumnLayout {
|
||||
id: outer
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.padding
|
||||
spacing: 10
|
||||
RowLayout {
|
||||
visible: root.title !== "" || extras.children.length > 0
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Text {
|
||||
text: root.title
|
||||
color: theme.fg
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: root.subtitle
|
||||
visible: text !== ""
|
||||
color: theme.fgSubtle
|
||||
font.pixelSize: 12
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item { Layout.fillWidth: root.subtitle === "" }
|
||||
Row { id: extras; spacing: 4 }
|
||||
}
|
||||
ColumnLayout {
|
||||
id: column
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import QtQuick
|
||||
|
||||
// Background used by all input controls: field colour, border, focus and error states.
|
||||
Rectangle {
|
||||
property bool focused: false
|
||||
property bool invalid: false
|
||||
property bool hovered: false
|
||||
radius: 6
|
||||
color: theme.field
|
||||
border.width: focused || invalid ? 2 : 1
|
||||
border.color: invalid ? theme.danger : focused ? theme.primary : hovered ? theme.fgSubtle : theme.border
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import QtQuick
|
||||
|
||||
// Material icon served by the "material" image provider, tinted with the given colour.
|
||||
Item {
|
||||
id: root
|
||||
property string name: ""
|
||||
property color color: theme.fg
|
||||
property bool filled: false
|
||||
property int size: 18
|
||||
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
|
||||
Image {
|
||||
anchors.centerIn: parent
|
||||
width: root.size
|
||||
height: root.size
|
||||
sourceSize.width: root.size * 2
|
||||
sourceSize.height: root.size * 2
|
||||
smooth: true
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: root.name === "" ? "" : "image://material/" + root.name + "?color="
|
||||
+ encodeURIComponent(root.color.toString()) + (root.filled ? "&filled=1" : "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Flat, square icon button with hover and pressed feedback and a tooltip.
|
||||
AbstractButton {
|
||||
id: root
|
||||
property string iconName: ""
|
||||
property color iconColor: theme.fgMuted
|
||||
property string tip: ""
|
||||
property int iconSize: 18
|
||||
|
||||
implicitWidth: 30
|
||||
implicitHeight: 30
|
||||
hoverEnabled: true
|
||||
focusPolicy: Qt.TabFocus
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
Accessible.name: tip
|
||||
|
||||
background: Rectangle {
|
||||
radius: 6
|
||||
color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent")
|
||||
border.width: root.visualFocus ? 1 : 0
|
||||
border.color: theme.primary
|
||||
}
|
||||
contentItem: Item {
|
||||
Icon {
|
||||
anchors.centerIn: parent
|
||||
name: root.iconName
|
||||
size: root.iconSize
|
||||
color: root.checked ? theme.primary : root.iconColor
|
||||
}
|
||||
}
|
||||
ToolTip.visible: tip !== "" && hovered
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: tip
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Single-line text input with optional unit suffix and error state.
|
||||
TextField {
|
||||
id: root
|
||||
property bool invalid: false
|
||||
property string suffix: ""
|
||||
|
||||
implicitHeight: 32
|
||||
implicitWidth: 120
|
||||
color: theme.fg
|
||||
placeholderTextColor: theme.fgSubtle
|
||||
selectionColor: theme.primary
|
||||
selectedTextColor: theme.onPrimary
|
||||
font.pixelSize: 13
|
||||
leftPadding: 9
|
||||
rightPadding: suffixLabel.visible ? suffixLabel.implicitWidth + 14 : 9
|
||||
selectByMouse: true
|
||||
hoverEnabled: true
|
||||
opacity: enabled ? 1.0 : 0.5
|
||||
|
||||
background: FieldFrame {
|
||||
focused: root.activeFocus
|
||||
invalid: root.invalid
|
||||
hovered: root.hovered
|
||||
}
|
||||
Text {
|
||||
id: suffixLabel
|
||||
visible: root.suffix !== ""
|
||||
text: root.suffix
|
||||
color: theme.fgSubtle
|
||||
font.pixelSize: 12
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Combo box styled like the input fields. With editable: true it filters while typing.
|
||||
ComboBox {
|
||||
id: root
|
||||
property bool invalid: false
|
||||
|
||||
implicitHeight: 32
|
||||
implicitWidth: 140
|
||||
font.pixelSize: 13
|
||||
hoverEnabled: true
|
||||
opacity: enabled ? 1.0 : 0.5
|
||||
|
||||
background: FieldFrame {
|
||||
focused: root.activeFocus || root.popup.visible
|
||||
invalid: root.invalid
|
||||
hovered: root.hovered
|
||||
}
|
||||
contentItem: TextField {
|
||||
leftPadding: 9
|
||||
rightPadding: 4
|
||||
text: root.editable ? root.editText : root.displayText
|
||||
readOnly: !root.editable
|
||||
enabled: root.enabled
|
||||
color: theme.fg
|
||||
selectionColor: theme.primary
|
||||
selectedTextColor: theme.onPrimary
|
||||
font: root.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
background: null
|
||||
selectByMouse: true
|
||||
// forward clicks to the combo when it is not editable
|
||||
TapHandler { enabled: !root.editable; onTapped: root.popup.visible ? root.popup.close() : root.popup.open() }
|
||||
}
|
||||
indicator: Icon {
|
||||
x: root.width - width - 6
|
||||
y: (root.height - height) / 2
|
||||
name: "expand_more"
|
||||
size: 18
|
||||
color: theme.fgMuted
|
||||
}
|
||||
delegate: ItemDelegate {
|
||||
id: option
|
||||
required property int index
|
||||
required property var modelData
|
||||
width: ListView.view ? ListView.view.width : root.width
|
||||
height: 30
|
||||
highlighted: root.highlightedIndex === index
|
||||
contentItem: Text {
|
||||
text: option.modelData === "" ? "None" : option.modelData
|
||||
color: option.modelData === "" ? theme.fgSubtle : theme.fg
|
||||
font.pixelSize: 13
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: option.highlighted ? theme.hover : "transparent"
|
||||
Rectangle {
|
||||
width: 3; height: parent.height - 10; radius: 1.5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.primary
|
||||
visible: root.currentIndex === option.index
|
||||
}
|
||||
}
|
||||
}
|
||||
popup: Popup {
|
||||
y: root.height + 2
|
||||
width: root.width
|
||||
implicitHeight: Math.min(contentItem.implicitHeight + 8, 280)
|
||||
padding: 4
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: root.popup.visible ? root.delegateModel : null
|
||||
currentIndex: root.highlightedIndex
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
}
|
||||
background: Rectangle {
|
||||
color: theme.card
|
||||
border.color: theme.border
|
||||
radius: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import QtQuick
|
||||
|
||||
// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving".
|
||||
Rectangle {
|
||||
id: root
|
||||
property string text: ""
|
||||
property color tone: theme.fgMuted
|
||||
property bool pulse: false
|
||||
|
||||
implicitHeight: 22
|
||||
implicitWidth: label.implicitWidth + 26
|
||||
radius: height / 2
|
||||
color: Qt.rgba(tone.r, tone.g, tone.b, 0.16)
|
||||
|
||||
Rectangle {
|
||||
id: dot
|
||||
width: 7; height: 7; radius: 3.5
|
||||
color: root.tone
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
SequentialAnimation on opacity {
|
||||
running: root.pulse
|
||||
loops: Animation.Infinite
|
||||
alwaysRunToEnd: true
|
||||
NumberAnimation { to: 0.25; duration: 550; easing.type: Easing.InOutQuad }
|
||||
NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: label
|
||||
anchors.left: dot.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.text
|
||||
color: root.tone
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Toggle switch for boolean parameters.
|
||||
Switch {
|
||||
id: root
|
||||
implicitHeight: 32
|
||||
hoverEnabled: true
|
||||
padding: 0
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 38
|
||||
implicitHeight: 22
|
||||
x: root.leftPadding
|
||||
y: (root.height - height) / 2
|
||||
radius: 11
|
||||
color: root.checked ? theme.primary : theme.track
|
||||
border.color: root.visualFocus ? theme.primary : (root.checked ? theme.primary : theme.border)
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
Rectangle {
|
||||
x: root.checked ? parent.width - width - 3 : 3
|
||||
y: 3
|
||||
width: 16; height: 16; radius: 8
|
||||
color: root.checked ? theme.onPrimary : theme.fgMuted
|
||||
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
leftPadding: root.indicator.width + 8
|
||||
text: root.text
|
||||
color: theme.fg
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost".
|
||||
AbstractButton {
|
||||
id: root
|
||||
property string variant: "neutral"
|
||||
property string iconName: ""
|
||||
property string tip: ""
|
||||
property bool busy: false
|
||||
|
||||
readonly property color baseColor: variant === "primary" ? theme.primary
|
||||
: variant === "danger" ? theme.danger
|
||||
: variant === "success" ? theme.success
|
||||
: "transparent"
|
||||
readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success"
|
||||
readonly property color labelColor: solid ? "white" : theme.fg
|
||||
|
||||
implicitHeight: 32
|
||||
implicitWidth: Math.max(64, row.implicitWidth + 24)
|
||||
hoverEnabled: true
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
Accessible.name: text
|
||||
|
||||
background: Rectangle {
|
||||
radius: 6
|
||||
color: root.solid ? (root.down ? Qt.darker(root.baseColor, 1.25) : root.hovered ? Qt.lighter(root.baseColor, 1.1) : root.baseColor)
|
||||
: (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent")
|
||||
border.width: root.variant === "neutral" || root.visualFocus ? 1 : 0
|
||||
border.color: root.visualFocus ? theme.primary : theme.border
|
||||
Behavior on color { ColorAnimation { duration: 90 } }
|
||||
}
|
||||
contentItem: Item {
|
||||
implicitWidth: row.implicitWidth
|
||||
implicitHeight: row.implicitHeight
|
||||
RowLayout {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
BusyIndicator {
|
||||
visible: root.busy
|
||||
running: root.busy
|
||||
Layout.preferredWidth: 16
|
||||
Layout.preferredHeight: 16
|
||||
}
|
||||
Icon {
|
||||
visible: root.iconName !== "" && !root.busy
|
||||
name: root.iconName
|
||||
color: root.labelColor
|
||||
size: 16
|
||||
}
|
||||
Text {
|
||||
text: root.text
|
||||
color: root.labelColor
|
||||
font.pixelSize: 13
|
||||
font.weight: root.solid ? Font.DemiBold : Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolTip.visible: tip !== "" && hovered
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: tip
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
module BecUi
|
||||
Icon 1.0 Icon.qml
|
||||
IconButton 1.0 IconButton.qml
|
||||
TextButton 1.0 TextButton.qml
|
||||
StatusPill 1.0 StatusPill.qml
|
||||
Card 1.0 Card.qml
|
||||
FieldFrame 1.0 FieldFrame.qml
|
||||
InputField 1.0 InputField.qml
|
||||
SelectField 1.0 SelectField.qml
|
||||
SwitchField 1.0 SwitchField.qml
|
||||
@@ -67,7 +67,7 @@ def app_with_spies(qtbot, mocked_client):
|
||||
app.add_view(icon="widgets", title="Veto View", view_id="vv", widget=vv, mini_text="vv")
|
||||
|
||||
# Start from dock_area (default) to avoid extra enter/exit counts on spies
|
||||
assert app.stack.currentIndex() == app._view_index["Docks"]
|
||||
assert app.stack.currentIndex() == app._view_index["dock_area"]
|
||||
return app, v1, v2, vv
|
||||
|
||||
|
||||
@@ -305,3 +305,18 @@ def test_guided_tour_can_start_and_stop(app_with_spies, qtbot):
|
||||
|
||||
# Check that tour is stopped
|
||||
assert not app.guided_tour._active
|
||||
|
||||
|
||||
def test_builtin_views_use_stable_ids(app_with_spies, qtbot):
|
||||
"""Views call set_current("device_manager") etc.; the ids must match the registered views."""
|
||||
app, _, _, _ = app_with_spies
|
||||
for view_id in ("dock_area", "device_manager", "admin_view"):
|
||||
assert view_id in app._view_index
|
||||
app.set_current("device_manager")
|
||||
qtbot.wait(50)
|
||||
assert app.stack.currentIndex() == app._view_index["device_manager"]
|
||||
app.set_current("dock_area")
|
||||
qtbot.wait(50)
|
||||
assert app.stack.currentIndex() == app._view_index["dock_area"]
|
||||
if hasattr(app.sidebar, "active_id"):
|
||||
assert app.sidebar.active_id == "dock_area"
|
||||
@@ -0,0 +1,227 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.nav_common import (
|
||||
NAV_PANEL_ENV,
|
||||
NavPanelBase,
|
||||
create_side_bar,
|
||||
)
|
||||
from bec_widgets.applications.navigation_centre.nav_panel_qml import NavPanelQml
|
||||
from bec_widgets.applications.navigation_centre.nav_panel_qwidget import NavPanelQWidget
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
ANIM = 40 # ms
|
||||
|
||||
|
||||
@pytest.fixture(params=["qwidget", "qml"])
|
||||
def panel_window(qtbot, request):
|
||||
window = QWidget()
|
||||
layout = QHBoxLayout(window)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
cls = NavPanelQWidget if request.param == "qwidget" else NavPanelQml
|
||||
panel = cls(parent=window, anim_duration=ANIM, drawer_mode="float")
|
||||
content = QLabel("content", window)
|
||||
content.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
layout.addWidget(panel)
|
||||
layout.addWidget(content, 1)
|
||||
panel.add_section("Apps", "apps")
|
||||
panel.add_item("widgets", "Dock Area", "dock", mini_text="Docks", subtitle="Plots")
|
||||
panel.add_item("display_settings", "Device Manager", "dm", mini_text="Devices")
|
||||
panel.add_item("admin_panel_settings", "Admin", "admin", from_top=False)
|
||||
panel.add_dark_mode_item()
|
||||
panel.activate_item("dock")
|
||||
qtbot.addWidget(window)
|
||||
window.resize(900, 600)
|
||||
window.show()
|
||||
qtbot.waitExposed(window)
|
||||
yield window, panel, content
|
||||
panel.cleanup()
|
||||
|
||||
|
||||
def _settle(qtbot):
|
||||
qtbot.wait(ANIM + 120)
|
||||
|
||||
|
||||
def test_entries_order_and_shortcuts(panel_window):
|
||||
_, panel, _ = panel_window
|
||||
assert [e.id for e in panel.ordered_entries()] == ["apps", "dock", "dm", "admin", "dark_mode"]
|
||||
assert [e.id for e in panel.view_entries()] == ["dock", "dm", "admin"]
|
||||
assert panel.components["dock"].shortcut.endswith("1")
|
||||
assert panel.components["admin"].shortcut.endswith("3")
|
||||
assert panel.components["dark_mode"].shortcut == ""
|
||||
|
||||
|
||||
def test_exclusive_and_non_exclusive_selection(panel_window, qtbot):
|
||||
_, panel, _ = panel_window
|
||||
panel.add_item("widgets", "Extra", "extra", exclusive=False)
|
||||
with qtbot.waitSignal(panel.view_selected) as blocker:
|
||||
panel.activate_item("dm")
|
||||
assert blocker.args == ["dm"]
|
||||
assert panel.active_id == "dm"
|
||||
assert not panel.components["dock"].active
|
||||
panel.activate_item("extra")
|
||||
assert panel.components["extra"].active
|
||||
assert panel.active_id == "dm"
|
||||
panel.activate_item("dock", emit_signal=False)
|
||||
assert panel.active_id == "dock"
|
||||
|
||||
|
||||
def test_opening_floats_over_content_without_reflow(panel_window, qtbot):
|
||||
_, panel, content = panel_window
|
||||
content_width = content.width()
|
||||
with qtbot.waitSignal(panel.toggled) as blocker:
|
||||
panel.toggle.click()
|
||||
assert blocker.args == [True]
|
||||
_settle(qtbot)
|
||||
assert panel.is_expanded
|
||||
assert panel.progress == 1.0
|
||||
assert panel.surface.width() == panel.drawer_width
|
||||
assert panel.width() == panel.rail_width
|
||||
assert content.width() == content_width
|
||||
assert panel._scrim.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", [NavPanelQWidget, NavPanelQml])
|
||||
def test_push_mode_reflows_once_and_stays_open(qtbot, cls):
|
||||
window = QWidget()
|
||||
layout = QHBoxLayout(window)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
panel = cls(parent=window, anim_duration=ANIM, drawer_mode="push")
|
||||
content = QLabel("content", window)
|
||||
layout.addWidget(panel)
|
||||
layout.addWidget(content, 1)
|
||||
panel.add_item("widgets", "Dock Area", "dock")
|
||||
panel.add_item("display_settings", "Device Manager", "dm")
|
||||
qtbot.addWidget(window)
|
||||
window.resize(900, 600)
|
||||
window.show()
|
||||
qtbot.waitExposed(window)
|
||||
widths = []
|
||||
panel.toggle_expanded()
|
||||
for _ in range(4):
|
||||
qtbot.wait(ANIM // 4)
|
||||
widths.append(panel.width())
|
||||
_settle(qtbot)
|
||||
# the slot in the layout keeps the rail width while the drawer opens, then makes room once
|
||||
assert widths[0] == panel.rail_width
|
||||
assert panel.width() == panel.drawer_width and panel.pinned
|
||||
assert not panel._scrim.isVisible()
|
||||
panel.activate_from_user("dm")
|
||||
assert panel.is_expanded
|
||||
panel.toggle_expanded()
|
||||
assert panel.width() == panel.rail_width
|
||||
_settle(qtbot)
|
||||
assert not panel.is_expanded and panel.surface.width() == panel.rail_width
|
||||
panel.cleanup()
|
||||
|
||||
|
||||
def test_click_on_scrim_closes(panel_window, qtbot):
|
||||
_, panel, _ = panel_window
|
||||
panel.set_expanded(True)
|
||||
_settle(qtbot)
|
||||
qtbot.mouseClick(panel._scrim, Qt.MouseButton.LeftButton, pos=QPoint(600, 300))
|
||||
_settle(qtbot)
|
||||
assert not panel.is_expanded
|
||||
assert panel.surface.width() == panel.rail_width
|
||||
assert not panel._scrim.isVisible()
|
||||
|
||||
|
||||
def test_choosing_a_view_closes_floating_drawer_but_not_pinned(panel_window, qtbot):
|
||||
_, panel, _ = panel_window
|
||||
panel.set_expanded(True)
|
||||
panel.activate_from_user("dm")
|
||||
assert not panel.is_expanded
|
||||
panel.set_pinned(True)
|
||||
_settle(qtbot)
|
||||
assert panel.is_expanded and panel.width() == panel.drawer_width
|
||||
assert not panel._scrim.isVisible()
|
||||
panel.activate_from_user("dock")
|
||||
assert panel.is_expanded
|
||||
panel.set_pinned(False)
|
||||
_settle(qtbot)
|
||||
assert not panel.is_expanded and panel.width() == panel.rail_width
|
||||
|
||||
|
||||
def test_rapid_toggling_ends_in_the_last_state(panel_window, qtbot):
|
||||
_, panel, _ = panel_window
|
||||
for _ in range(5):
|
||||
panel.toggle_expanded()
|
||||
qtbot.wait(5)
|
||||
_settle(qtbot)
|
||||
assert panel.is_expanded
|
||||
assert panel.surface.width() == panel.drawer_width
|
||||
panel.toggle_expanded()
|
||||
_settle(qtbot)
|
||||
assert panel.surface.width() == panel.rail_width
|
||||
|
||||
|
||||
def test_keyboard_shortcuts(panel_window, qtbot):
|
||||
window, panel, content = panel_window
|
||||
window.activateWindow()
|
||||
content.setFocus()
|
||||
with qtbot.waitSignal(panel.view_selected):
|
||||
qtbot.keyClick(window, Qt.Key.Key_2, Qt.KeyboardModifier.ControlModifier)
|
||||
assert panel.active_id == "dm"
|
||||
qtbot.keyClick(window, Qt.Key.Key_B, Qt.KeyboardModifier.ControlModifier)
|
||||
_settle(qtbot)
|
||||
assert panel.is_expanded
|
||||
assert panel._surface_has_focus()
|
||||
panel.handle_escape()
|
||||
_settle(qtbot)
|
||||
assert not panel.is_expanded
|
||||
|
||||
|
||||
def test_move_focus_wraps_to_ends(panel_window):
|
||||
_, panel, _ = panel_window
|
||||
assert panel.move_focus(None, 1) == "dock"
|
||||
assert panel.move_focus("dock", 1) == "dm"
|
||||
assert panel.move_focus("dm", 1) == "admin"
|
||||
assert panel.move_focus("dock", -1) == "dock"
|
||||
assert panel.move_focus("dock", 10**6) == "dark_mode"
|
||||
|
||||
|
||||
def test_theme_entry_follows_theme(panel_window, qtbot):
|
||||
_, panel, _ = panel_window
|
||||
apply_theme("dark")
|
||||
qtbot.wait(20)
|
||||
assert panel.components["dark_mode"].title == "Light theme"
|
||||
apply_theme("light")
|
||||
qtbot.wait(20)
|
||||
assert panel.components["dark_mode"].title == "Dark theme"
|
||||
apply_theme("dark")
|
||||
|
||||
|
||||
def test_qwidget_rows_have_tooltips_and_accessible_names(qtbot):
|
||||
panel = NavPanelQWidget(anim_duration=0)
|
||||
qtbot.addWidget(panel)
|
||||
panel.add_item("widgets", "Dock Area", "dock", subtitle="Plots")
|
||||
panel.activate_item("dock")
|
||||
row = panel.row_widget("dock")
|
||||
assert row.accessibleName() == "Dock Area (current view)"
|
||||
assert "Dock Area" in panel.tooltip_for(panel.components["dock"])
|
||||
assert "Plots" in panel.tooltip_for(panel.components["dock"])
|
||||
panel.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flavour, cls", [("qwidget", NavPanelQWidget), ("qml", NavPanelQml), ("legacy", SideBar)]
|
||||
)
|
||||
def test_factory_follows_env(monkeypatch, qtbot, flavour, cls):
|
||||
monkeypatch.setenv(NAV_PANEL_ENV, flavour)
|
||||
panel = create_side_bar(anim_duration=0, remember_state=True)
|
||||
qtbot.addWidget(panel)
|
||||
assert isinstance(panel, cls)
|
||||
if isinstance(panel, NavPanelBase):
|
||||
panel.cleanup()
|
||||
|
||||
|
||||
def test_legacy_section_is_added_above_bottom_items(qtbot):
|
||||
sidebar = SideBar(anim_duration=0)
|
||||
qtbot.addWidget(sidebar)
|
||||
bottom = sidebar.add_item(icon="widgets", title="Bottom", id="bottom", from_top=False)
|
||||
section = sidebar.add_section("Late section", id="late")
|
||||
spacer = sidebar.content_layout.indexOf(sidebar._bottom_spacer)
|
||||
assert sidebar.content_layout.indexOf(section) < spacer
|
||||
assert sidebar.content_layout.indexOf(bottom) > spacer
|
||||
Reference in new issue
Block a user