From 5d0098d889097a535f246730ede1e7eb93ed1a84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:21:18 +0000 Subject: [PATCH] feat(main_app): redesign the navigation panel as a rail with a drawer in QWidget and QML --- bec_widgets/applications/main_app.py | 59 +- .../navigation_centre/nav_common.py | 793 ++++++++++++++++++ .../navigation_centre/nav_panel_qml.py | 218 +++++ .../navigation_centre/nav_panel_qwidget.py | 567 +++++++++++++ .../navigation_centre/qml/NavPanelView.qml | 325 +++++++ .../navigation_centre/side_bar.py | 13 +- bec_widgets/utils/quick/__init__.py | 26 + bec_widgets/utils/quick/host.py | 233 +++++ bec_widgets/utils/quick/list_model.py | 83 ++ bec_widgets/utils/quick/qml/BecUi/Card.qml | 53 ++ .../utils/quick/qml/BecUi/FieldFrame.qml | 12 + bec_widgets/utils/quick/qml/BecUi/Icon.qml | 25 + .../utils/quick/qml/BecUi/IconButton.qml | 36 + .../utils/quick/qml/BecUi/InputField.qml | 38 + .../utils/quick/qml/BecUi/SelectField.qml | 85 ++ .../utils/quick/qml/BecUi/StatusPill.qml | 40 + .../utils/quick/qml/BecUi/SwitchField.qml | 34 + .../utils/quick/qml/BecUi/TextButton.qml | 65 ++ bec_widgets/utils/quick/qml/BecUi/qmldir | 10 + tests/unit_tests/test_main_app.py | 17 +- tests/unit_tests/test_nav_panel.py | 227 +++++ 21 files changed, 2941 insertions(+), 18 deletions(-) create mode 100644 bec_widgets/applications/navigation_centre/nav_common.py create mode 100644 bec_widgets/applications/navigation_centre/nav_panel_qml.py create mode 100644 bec_widgets/applications/navigation_centre/nav_panel_qwidget.py create mode 100644 bec_widgets/applications/navigation_centre/qml/NavPanelView.qml create mode 100644 bec_widgets/utils/quick/__init__.py create mode 100644 bec_widgets/utils/quick/host.py create mode 100644 bec_widgets/utils/quick/list_model.py create mode 100644 bec_widgets/utils/quick/qml/BecUi/Card.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/Icon.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/IconButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/InputField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SelectField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/StatusPill.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SwitchField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/TextButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/qmldir create mode 100644 tests/unit_tests/test_nav_panel.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index e238599d..570d56e3 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -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() diff --git a/bec_widgets/applications/navigation_centre/nav_common.py b/bec_widgets/applications/navigation_centre/nav_common.py new file mode 100644 index 00000000..8f66eb8f --- /dev/null +++ b/bec_widgets/applications/navigation_centre/nav_common.py @@ -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+ 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 = [] diff --git a/bec_widgets/applications/navigation_centre/nav_panel_qml.py b/bec_widgets/applications/navigation_centre/nav_panel_qml.py new file mode 100644 index 00000000..5a979085 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/nav_panel_qml.py @@ -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) diff --git a/bec_widgets/applications/navigation_centre/nav_panel_qwidget.py b/bec_widgets/applications/navigation_centre/nav_panel_qwidget.py new file mode 100644 index 00000000..31b24c39 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/nav_panel_qwidget.py @@ -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) diff --git a/bec_widgets/applications/navigation_centre/qml/NavPanelView.qml b/bec_widgets/applications/navigation_centre/qml/NavPanelView.qml new file mode 100644 index 00000000..e62870b0 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/qml/NavPanelView.qml @@ -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 {} + } + } + } +} diff --git a/bec_widgets/applications/navigation_centre/side_bar.py b/bec_widgets/applications/navigation_centre/side_bar.py index 6354cafe..7d7208ab 100644 --- a/bec_widgets/applications/navigation_centre/side_bar.py +++ b/bec_widgets/applications/navigation_centre/side_bar.py @@ -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) diff --git a/bec_widgets/utils/quick/__init__.py b/bec_widgets/utils/quick/__init__.py new file mode 100644 index 00000000..dcc683f1 --- /dev/null +++ b/bec_widgets/utils/quick/__init__.py @@ -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", +] diff --git a/bec_widgets/utils/quick/host.py b/bec_widgets/utils/quick/host.py new file mode 100644 index 00000000..0df3b172 --- /dev/null +++ b/bec_widgets/utils/quick/host.py @@ -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/`` 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.`` 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/?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 diff --git a/bec_widgets/utils/quick/list_model.py b/bec_widgets/utils/quick/list_model.py new file mode 100644 index 00000000..6351b93d --- /dev/null +++ b/bec_widgets/utils/quick/list_model.py @@ -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) diff --git a/bec_widgets/utils/quick/qml/BecUi/Card.qml b/bec_widgets/utils/quick/qml/BecUi/Card.qml new file mode 100644 index 00000000..eadf5d56 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -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 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml new file mode 100644 index 00000000..aad88d6a --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml @@ -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 +} diff --git a/bec_widgets/utils/quick/qml/BecUi/Icon.qml b/bec_widgets/utils/quick/qml/BecUi/Icon.qml new file mode 100644 index 00000000..d9fbedc4 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Icon.qml @@ -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" : "") + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/IconButton.qml b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml new file mode 100644 index 00000000..9603aa81 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml @@ -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 +} diff --git a/bec_widgets/utils/quick/qml/BecUi/InputField.qml b/bec_widgets/utils/quick/qml/BecUi/InputField.qml new file mode 100644 index 00000000..0ed06d5f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/InputField.qml @@ -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 + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml new file mode 100644 index 00000000..d2149dce --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -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 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml new file mode 100644 index 00000000..7901c832 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml @@ -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 + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml new file mode 100644 index 00000000..a21f3221 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml @@ -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 + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/TextButton.qml b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml new file mode 100644 index 00000000..45b069dc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml @@ -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 +} diff --git a/bec_widgets/utils/quick/qml/BecUi/qmldir b/bec_widgets/utils/quick/qml/BecUi/qmldir new file mode 100644 index 00000000..160907bc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/qmldir @@ -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 diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py index ebdeb33c..0b9f3d6f 100644 --- a/tests/unit_tests/test_main_app.py +++ b/tests/unit_tests/test_main_app.py @@ -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" diff --git a/tests/unit_tests/test_nav_panel.py b/tests/unit_tests/test_nav_panel.py new file mode 100644 index 00000000..e82af82b --- /dev/null +++ b/tests/unit_tests/test_nav_panel.py @@ -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