feat(main_app): redesign the navigation panel as a rail with a drawer in QWidget and QML

This commit is contained in:
Claude committed 2026-10-09 22:27:35 +00:00
1 parent 703513b175
commit 5d0098d889
21 files changed
+2941 -18

No files matched your search

+43 -16
View File
@@ -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)
+26
View File
@@ -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",
]
+233
View File
@@ -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
+83
View File
@@ -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
}
+10
View File
@@ -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
+16 -1
View File
@@ -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"
+227
View File
@@ -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