1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-09 18:27:52 +01:00

feat: add guided tour docs to device-manager-view

This commit is contained in:
2026-01-23 16:03:07 +01:00
committed by wyzula-jan
parent 83489b7519
commit fcb43066e4
5 changed files with 224 additions and 120 deletions

View File

@@ -1,5 +1,6 @@
"""Module for Device Manager View."""
from qtpy.QtCore import QRect
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
@@ -47,30 +48,95 @@ class DeviceManagerView(ViewBase):
step_ids = []
dm_widget = self.device_manager_widget
# The device_manager_widget is not yet initialized, so we will register
# tour steps for its uninitialized state.
# Register Load Current Config button
def get_load_current():
main_app.set_current("device_manager")
if dm_widget._initialized is True:
return (None, None)
return (dm_widget.button_load_current_config, None)
step_id = guided_tour.register_widget(
widget=get_load_current,
title="Load Current Config",
text="Load the current device configuration from the BEC server. This will display all available devices and their current status.",
text="Load the current device configuration from the BEC server.",
)
step_ids.append(step_id)
# Register Load Config From File button
def get_load_file():
main_app.set_current("device_manager")
if dm_widget._initialized is True:
return (None, None)
return (dm_widget.button_load_config_from_file, None)
step_id = guided_tour.register_widget(
widget=get_load_file,
title="Load Config From File",
text="Load a device configuration from a YAML file on disk. Useful for testing or working offline.",
text="Load a device configuration from a YAML file on disk.",
)
step_ids.append(step_id)
## Register steps for the initialized state
# Register main device table
def get_device_table():
main_app.set_current("device_manager")
if dm_widget._initialized is False:
return (None, None)
return (dm_widget.device_manager_display.device_table_view, None)
step_id = guided_tour.register_widget(
widget=get_device_table,
title="Device Table",
text="This table displays the config that is prepared to be uploaded to the BEC server. It allows users to review and modify device config settings, and also validate them before uploading to the BEC server.",
)
step_ids.append(step_id)
col_text_mapping = {
0: "Shows if a device configuration is valid. Automatically validated when adding a new device.",
1: "Shows if a device is connectable. Validated on demand.",
2: "Device name, unique across all devices within a config.",
3: "Device class used to initialize the device on the BEC server.",
4: "Defines how BEC treats readings of the device during scans. The options are 'monitored', 'baseline', ' async', 'continuous' or 'on_demand'.",
5: "Defines how BEC reacts if a device readback fails. Options are 'raise', 'retry', or 'buffer'.",
6: "User-defined tags associated with the device.",
7: "A brief description of the device.",
8: "Device is enabled when the configuration is loaded.",
9: "Device is set to read-only.",
10: "This flag allows to configure if the 'trigger' method of the device is called during scans.",
}
# We have at least one device registered
def get_device_table_row(column: int):
main_app.set_current("device_manager")
if dm_widget._initialized is False:
return (None, None)
table = dm_widget.device_manager_display.device_table_view.table
header = table.horizontalHeader()
x = header.sectionViewportPosition(column)
table.horizontalScrollBar().setValue(x)
# Recompute after scrolling
x = header.sectionViewportPosition(column)
w = header.sectionSize(column)
h = header.height()
rect = QRect(x, 0, w, h)
top_left = header.viewport().mapTo(main_app, rect.topLeft())
return (QRect(top_left, rect.size()), col_text_mapping.get(column, ""))
for col, text in col_text_mapping.items():
step_id = guided_tour.register_widget(
widget=lambda col=col: get_device_table_row(col),
title=f"{dm_widget.device_manager_display.device_table_view.table.horizontalHeaderItem(col).text()}",
text=text,
)
step_ids.append(step_id)
if not step_ids:
return None
return ViewTourSteps(view_title="Device Manager", step_ids=step_ids)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import List
from pydantic import BaseModel
from qtpy.QtCore import QEventLoop, Qt, QTimer
from qtpy.QtCore import QEventLoop
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
@@ -23,42 +23,6 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im
from bec_widgets.widgets.plots.waveform.waveform import Waveform
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
"""
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
Works for horizontal or vertical splitters and sets matching stretch factors.
"""
def apply():
n = splitter.count()
if n == 0:
return
w = list(weights[:n]) + [1] * max(0, n - len(weights))
w = [max(0.0, float(x)) for x in w]
tot_w = sum(w)
if tot_w <= 0:
w = [1.0] * n
tot_w = float(n)
total_px = (
splitter.width()
if splitter.orientation() == Qt.Orientation.Horizontal
else splitter.height()
)
if total_px < 2:
QTimer.singleShot(0, apply)
return
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
diff = total_px - sum(sizes)
if diff != 0:
idx = max(range(n), key=lambda i: w[i])
sizes[idx] = max(1, sizes[idx] + diff)
splitter.setSizes(sizes)
for i, wi in enumerate(w):
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
QTimer.singleShot(0, apply)
class ViewTourSteps(BaseModel):
"""Model representing tour steps for a view.
@@ -68,7 +32,7 @@ class ViewTourSteps(BaseModel):
"""
view_title: str
step_ids: list[str]
step_ids: List[str]
class ViewBase(QWidget):
@@ -142,68 +106,6 @@ class ViewBase(QWidget):
"""
return None
####### Default view has to be done with setting up splitters ########
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Orientation.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Orientation.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(self, *, horizontal=None, vertical=None):
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
####################################################################################################
# Example views for demonstration/testing purposes

View File

@@ -45,9 +45,9 @@ class TourStep(TypedDict):
widget_ref: (
louie.saferef.BoundMethodWeakref
| weakref.ReferenceType[
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
]
| Callable[[], tuple[QWidget | QAction, str | None]]
| Callable[[], tuple[QWidget | QAction | QRect, str | None]]
| None
)
text: str
@@ -274,7 +274,9 @@ class GuidedTour(QObject):
def register_widget(
self,
*,
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
widget: (
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
),
text: str = "",
title: str = "",
) -> str:
@@ -282,7 +284,7 @@ class GuidedTour(QObject):
Register a widget with help text for tours.
Args:
widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text.
widget (QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]): The target widget or a callable that returns the widget and its help text.
text (str): The help text for the widget. This will be shown during the tour.
title (str, optional): A title for the widget (defaults to its class name or action text).
@@ -305,6 +307,9 @@ class GuidedTour(QObject):
widget_ref = _resolve_toolbar_button
default_title = getattr(widget, "tooltip", "Toolbar Menu")
elif isinstance(widget, QRect):
widget_ref = saferef.safe_ref(widget)
default_title = "Area"
else:
widget_ref = saferef.safe_ref(widget)
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
@@ -515,7 +520,7 @@ class GuidedTour(QObject):
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | QRect | None, str]:
"""
Resolve the target widget/action for the given step.
@@ -523,7 +528,7 @@ class GuidedTour(QObject):
step(TourStep): The tour step to resolve.
Returns:
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
tuple[QWidget | QAction | QRect | None, str]: The resolved target, optional QRect, and the step text.
"""
widget_ref = step.get("widget_ref")
step_text = step.get("text", "")
@@ -536,7 +541,7 @@ class GuidedTour(QObject):
if target is None:
return None, step_text
if callable(target) and not isinstance(target, (QWidget, QAction)):
if callable(target) and not isinstance(target, (QWidget, QAction, QRect)):
result = target()
if isinstance(result, tuple):
target, alt_text = result
@@ -550,7 +555,7 @@ class GuidedTour(QObject):
def _get_highlight_rect(
self,
main_window: QWidget,
target: QWidget | QAction,
target: QWidget | QAction | QRect,
step_title: str,
direction: Literal["next"] | Literal["prev"] = "next",
) -> QRect | None:
@@ -565,6 +570,8 @@ class GuidedTour(QObject):
Returns:
QRect | None: The rectangle to highlight, or None if not found/visible.
"""
if isinstance(target, QRect):
return target
if isinstance(target, QAction):
rect = self._action_highlight_rect(target)
if rect is None:
@@ -588,7 +595,12 @@ class GuidedTour(QObject):
return QRect(top_left, rect.size())
if isinstance(target, QTableWidgetItem):
# NOTE: On header items (which are also QTableWidgetItems), this does not work,
# Header items are just used as data containers by Qt, thus, we have to directly
# pass the QRect through the method (+ make sure the appropriate header section
# is visible). This can be handled in the callable method.)
table = target.tableWidget()
if self._visible_check:
if not table.isVisible():
self._advance_past_invalid_step(
@@ -597,13 +609,16 @@ class GuidedTour(QObject):
direction=direction,
)
return None
table.scrollToItem(target, QAbstractItemView.ScrollHint.PositionAtCenter)
rect = table.visualItemRect(target)
top_left = table.viewport().mapTo(main_window, rect.topLeft())
return QRect(top_left, rect.size())
# Table item
if table.item(target.row(), target.column()) == target:
table.scrollToItem(target, QAbstractItemView.ScrollHint.PositionAtCenter)
rect = table.visualItemRect(target)
top_left = table.viewport().mapTo(main_window, rect.topLeft())
return QRect(top_left, rect.size())
self._advance_past_invalid_step(
step_title, reason=f"Unsupported step target type: {type(target)}"
step_title, reason=f"Unsupported step target type: {type(target)}", direction=direction
)
return None

View File

@@ -1,7 +1,8 @@
from unittest import mock
import pytest
from qtpy.QtWidgets import QVBoxLayout, QWidget
from qtpy.QtCore import QRect
from qtpy.QtWidgets import QAction, QVBoxLayout, QWidget
from bec_widgets.utils.guided_tour import GuidedTour
from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction
@@ -40,6 +41,10 @@ class DummyWidget(QWidget):
return True
class DummyAction(QAction):
"""A dummy action for testing purposes."""
class TestGuidedTour:
"""Test the GuidedTour class core functionality."""
@@ -403,3 +408,73 @@ class TestGuidedTour:
guided_help.start_tour()
guided_help.overlay.paintEvent(None) # Force paint event to render text
qtbot.wait(300) # Wait for rendering
def test_advanced_past_invalid_tour_step(
self, guided_help: GuidedTour, test_widget: QWidget, qtbot
):
"""Test that an invalid tour step is handled gracefully."""
widget_id_valid = guided_help.register_widget(
widget=test_widget, text="Test widget for overlay", title="OverlayWidget"
)
widget_id_invalid = guided_help.register_widget(
widget=lambda: None, text="Test2", title="something"
)
widget_id_valid2 = guided_help.register_widget(
widget=test_widget, text="Test3", title="something"
)
widget_valid = guided_help._registered_widgets[widget_id_valid]["widget_ref"]()
widget_valid2 = guided_help._registered_widgets[widget_id_valid2]["widget_ref"]()
with (
mock.patch.object(widget_valid, "isVisible", return_value=True),
mock.patch.object(widget_valid2, "isVisible", return_value=True),
):
guided_help.create_tour(
[widget_id_valid, widget_id_invalid, widget_id_valid2, "nonexistent_id"]
)
with qtbot.waitSignal(guided_help.tour_started, timeout=2000) as blocker:
guided_help.start_tour()
assert blocker.signal_triggered
with qtbot.waitSignal(guided_help.step_changed) as step_blocker:
guided_help.next_step() # Move to step 2 (invalid, should skip)
assert step_blocker.signal_triggered
assert step_blocker.args == [3, 3]
with qtbot.waitSignal(guided_help.step_changed) as step_blocker:
guided_help.prev_step() # Move back to step 1
assert step_blocker.signal_triggered
assert step_blocker.args == [1, 3]
def test_get_highlight_rect(self, guided_help: GuidedTour, test_widget: QWidget, qtbot):
"""Test that _get_highlight_rect returns a QRect for a valid widget."""
widget = DummyWidget(test_widget) # Use a dummy widget that is always visible
action = DummyAction(test_widget)
test_rect = QRect(10, 10, 100, 50)
with mock.patch.object(widget, "isVisible", return_value=True):
rect = guided_help._get_highlight_rect(guided_help.main_window, widget, "Test step")
assert isinstance(rect, QRect)
rect = guided_help._get_highlight_rect(guided_help.main_window, test_rect, "Test step")
assert isinstance(rect, QRect)
# QAction should not be available, thus skipped
with mock.patch.object(guided_help, "_advance_past_invalid_step") as mock_advance:
rect = guided_help._get_highlight_rect(
guided_help.main_window, action, "Test step", direction="next"
)
assert rect is None
mock_advance.assert_called_once()
mock_advance.reset_mock()
rect = guided_help._get_highlight_rect(
guided_help.main_window, action, "Test step", direction="prev"
)
assert rect is None
mock_advance.assert_called_once()

View File

@@ -1,4 +1,5 @@
import pytest
from qtpy.QtCore import QRect
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.main_app import BECMainApp
@@ -46,6 +47,9 @@ class SpyVetoView(SpyView):
def app_with_spies(qtbot, mocked_client):
app = BECMainApp(client=mocked_client, anim_duration=ANIM_TEST_DURATION, show_examples=False)
qtbot.addWidget(app)
# App must be shown properly to ensure visibility checks work
# Call .show() and then waitExposed
app.show()
qtbot.waitExposed(app)
app.add_section("Tests", id="tests")
@@ -163,14 +167,54 @@ def test_views_can_extend_guided_tour(app_with_spies):
assert hasattr(ide_tour, "step_ids")
assert isinstance(ide_tour.step_ids, list)
# Get all registered widgets
widgets = app.guided_tour.get_registered_widgets()
# pylint: disable=protected-access
# Test that ide_tour has valid steps and targets
for step_id in ide_tour.step_ids:
assert step_id in widgets
tour_step = widgets.get(step_id)
target, text = app.guided_tour._resolve_step_target(tour_step)
assert isinstance(text, str)
assert text != ""
if target is not None: # If step should be skipped
highlighted_rect = app.guided_tour._get_highlight_rect(app, target, tour_step["title"])
if (
highlighted_rect is not None
): # If widget is not visible, it will be skipped and return None
assert isinstance(highlighted_rect, QRect)
# Test that dm_tour has valid steps and targets, test it once
# with _initialized = True and False. This leads to different tour paths.
for init in [False, True]:
app.device_manager.device_manager_widget._initialized = init
for step_id in dm_tour.step_ids:
assert step_id in widgets
tour_step = widgets.get(step_id)
target, text = app.guided_tour._resolve_step_target(tour_step)
assert isinstance(text, str)
assert text != ""
if target is not None: # If step should be skipped
highlighted_rect = app.guided_tour._get_highlight_rect(
app, target, tour_step["title"]
)
if (
highlighted_rect is not None
): # If widget is not visible, it will be skipped and return None
assert isinstance(highlighted_rect, QRect)
def test_guided_tour_can_start_and_stop(app_with_spies, qtbot):
"""Test that the guided tour can be started and stopped."""
app, _, _, _ = app_with_spies
app: BECMainApp
# Start the tour
app.start_guided_tour()
qtbot.wait(100)
with qtbot.waitSignal(app.guided_tour.tour_started, timeout=2000) as blocker:
app.start_guided_tour()
assert blocker.signal_triggered
# Check that tour is active
assert app.guided_tour._active
@@ -178,8 +222,10 @@ def test_guided_tour_can_start_and_stop(app_with_spies, qtbot):
assert app.guided_tour.overlay.isVisible()
# Stop the tour
app.guided_tour.stop_tour()
qtbot.wait(100)
with qtbot.waitSignal(app.guided_tour.tour_finished, timeout=2000) as blocker:
app.guided_tour.stop_tour()
assert blocker.signal_triggered
# Check that tour is stopped
assert not app.guided_tour._active