diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index e189f8f1..1f8dfe6d 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -3,17 +3,20 @@ from types import NoneType, SimpleNamespace from typing import Optional from bec_lib.endpoints import MessageEndpoints +from bec_qthemes import material_icon from pydantic import BaseModel, Field -from qtpy.QtCore import Qt, Signal +from qtpy.QtCore import QSignalBlocker, Qt, Signal from qtpy.QtGui import QColor from qtpy.QtWidgets import ( QApplication, QComboBox, + QDialog, QGroupBox, QHBoxLayout, QLabel, QPushButton, QSizePolicy, + QToolButton, QVBoxLayout, QWidget, ) @@ -23,8 +26,11 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton +from bec_widgets.widgets.control.scan_control.scan_docstring import render_scan_tooltip_html from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter +from bec_widgets.widgets.control.scan_control.scan_info_dialog import ScanInfoDialog +from bec_widgets.widgets.control.scan_control.scan_selection_dialog import ScanSelectionDialog from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata @@ -49,6 +55,7 @@ class ScanControl(BECWidget, QWidget): PLUGIN = True ICON_NAME = "tune" ARG_BOX_POSITION: int = 2 + SUPPORTED_SCAN_BASE_CLASSES = {"ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase", "ScanBaseV4"} scan_started = Signal() scan_selected = Signal(str) @@ -87,7 +94,8 @@ class ScanControl(BECWidget, QWidget): # Widget Default Parameters self.config.default_scan = default_scan - self.config.allowed_scans = allowed_scans + if allowed_scans is not None: + self.config.allowed_scans = allowed_scans self._scan_metadata: dict | None = None self._metadata_form = ScanMetadata(parent=self) @@ -96,7 +104,9 @@ class ScanControl(BECWidget, QWidget): self._hide_scan_control_buttons = False self._hide_metadata = False self._hide_scan_selection_combobox = False + self._hide_scan_selector_settings_button = False self._scan_info_adapter = ScanInfoAdapter() + self._scan_info_dialog: ScanInfoDialog | None = None # Create and set main layout self._init_UI() @@ -119,8 +129,21 @@ class ScanControl(BECWidget, QWidget): scan_selection_layout = QHBoxLayout() self.comboBox_scan_selection_label = QLabel("Scan:", self.scan_selection_group) self.comboBox_scan_selection = QComboBox(self.scan_selection_group) + self.scan_info_button = QToolButton(self.scan_selection_group) + self.scan_info_button.setIcon(material_icon("info", size=(20, 20), convert_to_pixmap=False)) + self.scan_info_button.setAutoRaise(True) + self.scan_info_button.setToolTip("Show information about the selected scan") + self.scan_info_button.setAccessibleName("Scan information") + self.scan_selector_settings_button = QToolButton(self.scan_selection_group) + self.scan_selector_settings_button.setAutoRaise(True) + self.scan_selector_settings_button.setIcon( + material_icon("filter_list", size=(20, 20), convert_to_pixmap=False) + ) + self.scan_selector_settings_button.setToolTip("Choose scans shown in the selector") scan_selection_layout.addWidget(self.comboBox_scan_selection_label, 0) scan_selection_layout.addWidget(self.comboBox_scan_selection, 1) + scan_selection_layout.addWidget(self.scan_info_button, 0) + scan_selection_layout.addWidget(self.scan_selector_settings_button, 0) self.scan_selection_group.layout().addLayout(scan_selection_layout) # Button to reload the last scan parameters on demand. @@ -154,6 +177,8 @@ class ScanControl(BECWidget, QWidget): # Connect signals self.comboBox_scan_selection.view().pressed.connect(self.save_current_scan_parameters) self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selection_changed) + self.scan_info_button.clicked.connect(self.show_selected_scan_info) + self.scan_selector_settings_button.clicked.connect(self.show_scan_selector_settings) self.button_run_scan.clicked.connect(self.run_scan) self.scan_selected.connect(self.scan_select) @@ -178,31 +203,138 @@ class ScanControl(BECWidget, QWidget): self._metadata_form.form_data_cleared.connect(self.update_scan_metadata) self._metadata_form.validate_form() + def _scan_docstring(self, scan_name: str) -> str | None: + scan_info = self.available_scans.get(scan_name, {}) + docstring = scan_info.get("doc") if isinstance(scan_info, dict) else None + return docstring if isinstance(docstring, str) else None + + @SafeSlot() + @SafeSlot(bool) + def show_selected_scan_info(self, *_args) -> None: + """Show documentation for the currently selected scan without blocking the GUI.""" + self.show_scan_info(self.comboBox_scan_selection.currentText()) + + @SafeSlot(str) + def show_scan_info(self, scan_name: str) -> None: + """Show documentation for a specific scan.""" + if self._scan_info_dialog is None: + self._scan_info_dialog = ScanInfoDialog(self) + self._scan_info_dialog.show_scan(scan_name, self._scan_docstring(scan_name)) + def populate_scans(self): """Populates the scan selection combo box with available scans from BEC session.""" self.available_scans = self.client.connector.get( MessageEndpoints.available_scans() ).resource - if self.config.allowed_scans is None: - supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase", "ScanBaseV4"] + self._update_scan_selector() - def _is_scan_supported(scan_name): - scan_info = self.available_scans[scan_name] - return ( - scan_info.get("base_class") in supported_scans - and self._scan_info_adapter.has_scan_ui_config(scan_info) - and not scan_name.startswith("_") - ) - - allowed_scans = filter(_is_scan_supported, self.available_scans.keys()) + def _supported_scan_names(self) -> list[str]: + """Return available scans that can be rendered by this widget.""" + return [ + scan_name + for scan_name, scan_info in self.available_scans.items() + if scan_info.get("base_class") in self.SUPPORTED_SCAN_BASE_CLASSES + and self._scan_info_adapter.has_scan_ui_config(scan_info) + and not scan_name.startswith("_") + ] + def _update_scan_selector(self) -> None: + """Apply the configured scan filter while preserving the current selection.""" + current_scan = self.comboBox_scan_selection.currentText() + if current_scan: + self.save_current_scan_parameters() + allowed_scans = self.allowed_scans + if allowed_scans is None: + visible_scans = self._supported_scan_names() else: - allowed_scans = self.config.allowed_scans - self.comboBox_scan_selection.addItems(allowed_scans) + # An explicit filter overrides the support filter and keeps the caller's order; + # entries not currently available stay in the filter and reappear once published. + visible_scans = [scan for scan in allowed_scans if scan in self.available_scans] + + with QSignalBlocker(self.comboBox_scan_selection): + self.comboBox_scan_selection.clear() + self.comboBox_scan_selection.addItems(visible_scans) + for index, scan_name in enumerate(visible_scans): + self.comboBox_scan_selection.setItemData( + index, + render_scan_tooltip_html(scan_name, self._scan_docstring(scan_name)), + Qt.ItemDataRole.ToolTipRole, + ) + if current_scan in visible_scans: + self.comboBox_scan_selection.setCurrentText(current_scan) + + self.scan_info_button.setEnabled(bool(visible_scans)) + self._update_run_button_state() + self._update_selected_scan_tooltip() + if self.comboBox_scan_selection.currentText() != current_scan: + self.on_scan_selection_changed(self.comboBox_scan_selection.currentIndex()) + + def _update_run_button_state(self) -> None: + """Start requires a selected scan and valid metadata.""" + has_scan = bool(self.comboBox_scan_selection.currentText()) + self.button_run_scan.setEnabled(has_scan and self._scan_metadata is not None) + + def _update_selected_scan_tooltip(self) -> None: + """Mirror the selected item's documentation tooltip on the closed combo box.""" + index = self.comboBox_scan_selection.currentIndex() + tooltip = self.comboBox_scan_selection.itemData(index, Qt.ItemDataRole.ToolTipRole) + self.comboBox_scan_selection.setToolTip(tooltip or "") + + @SafeSlot() + @SafeSlot(bool) + def show_scan_selector_settings(self, *_): + """Open the scan filter dialog and apply accepted changes.""" + scan_names = self._supported_scan_names() + allowed_scans = self.allowed_scans + if allowed_scans is not None: + # Keep configured entries visible in the dialog even when currently unsupported. + scan_names += [scan for scan in allowed_scans if scan not in scan_names] + dialog = ScanSelectionDialog( + scan_names=scan_names, + selected_scans=scan_names if allowed_scans is None else allowed_scans, + scan_docs={scan_name: self._scan_docstring(scan_name) for scan_name in scan_names}, + parent=self, + ) + try: + selected_scans = ( + dialog.selected_scans() if dialog.exec() == QDialog.DialogCode.Accepted else None + ) + finally: + dialog.deleteLater() + if selected_scans is not None: + # Everything checked means "no filter", so scans added later show up as well. + self.allowed_scans = None if selected_scans == scan_names else selected_scans + + @SafeProperty(list) + def allowed_scans(self) -> list[str] | None: + """Scan filter for the selector; None shows every supported scan, including future ones.""" + allowed_scans = getattr(self.config, "allowed_scans", None) + return None if allowed_scans is None else list(allowed_scans) + + @allowed_scans.setter + def allowed_scans(self, scan_names: list[str] | str | None): + """Set the scans displayed in the selector; None clears the filter.""" + if isinstance(scan_names, str): + scan_names = [scan_names] + if scan_names is not None: + scan_names = list(dict.fromkeys(scan_names)) + self.config.allowed_scans = scan_names + self._update_scan_selector() + + @SafeProperty(bool) + def hide_scan_selector_settings_button(self) -> bool: + """Whether the button for configuring visible scans is hidden.""" + return self._hide_scan_selector_settings_button + + @hide_scan_selector_settings_button.setter + def hide_scan_selector_settings_button(self, hide: bool): + self._hide_scan_selector_settings_button = bool(hide) + self.scan_selector_settings_button.setVisible(not self._hide_scan_selector_settings_button) def on_scan_selection_changed(self, index: int): """Callback for scan selection combo box""" selected_scan_name = self.comboBox_scan_selection.currentText() + self._update_selected_scan_tooltip() self.scan_selected.emit(selected_scan_name) self.restore_scan_parameters(selected_scan_name) @@ -539,23 +671,27 @@ class ScanControl(BECWidget, QWidget): @SafeSlot(NoneType) def update_scan_metadata(self, md: dict | None): self._scan_metadata = md - if md is None: - self.button_run_scan.setEnabled(False) - else: - self.button_run_scan.setEnabled(True) + self._update_run_button_state() @SafeSlot(popup_error=True) def run_scan(self): """Starts the selected scan with the given parameters.""" + scan_name = self.comboBox_scan_selection.currentText() + if not scan_name: + return args, kwargs = self.get_scan_parameters() self.scan_args.emit(args) - scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText()) + scan_function = getattr(self.scans, scan_name) if callable(scan_function): self.scan_started.emit() scan_function(*args, **kwargs) def cleanup(self): """Cleanup the scan control widget.""" + if self._scan_info_dialog is not None: + self._scan_info_dialog.close() + self._scan_info_dialog.deleteLater() + self._scan_info_dialog = None super().cleanup() diff --git a/bec_widgets/widgets/control/scan_control/scan_docstring.py b/bec_widgets/widgets/control/scan_control/scan_docstring.py new file mode 100644 index 00000000..6b1e3e8d --- /dev/null +++ b/bec_widgets/widgets/control/scan_control/scan_docstring.py @@ -0,0 +1,176 @@ +"""Rendering helpers for scan documentation.""" + +from __future__ import annotations + +import html +import inspect +import re +import textwrap + +_SECTION_TITLES = { + "args": "Arguments", + "arguments": "Arguments", + "parameters": "Arguments", + "keyword args": "Keyword arguments", + "keyword arguments": "Keyword arguments", + "kwargs": "Keyword arguments", + "attributes": "Attributes", + "returns": "Returns", + "yields": "Yields", + "raises": "Raises", + "examples": "Examples", + "example": "Examples", + "notes": "Notes", + "note": "Notes", + "warnings": "Warnings", + "warning": "Warnings", + "see also": "See also", +} +_FIELD_SECTIONS = {"Arguments", "Keyword arguments", "Attributes", "Returns", "Yields", "Raises"} +_TYPED_FIELD_PATTERN = re.compile(r"^(.+?)\s+\((.+?)\)\s*:\s*(.*)$") +_FIELD_PATTERN = re.compile(r"^([^:]+)\s*:\s*(.*)$") +_TOOLTIP_SUMMARY_LIMIT = 320 +_TOOLTIP_ARGUMENT_LIMIT = 8 + + +def _split_blocks(docstring: str) -> list[tuple[str | None, list[str]]]: + """Split a docstring into titled sections and untitled prose blocks, in order.""" + blocks: list[tuple[str | None, list[str]]] = [(None, [])] + + for line in inspect.cleandoc(docstring).splitlines(): + stripped = line.strip() + unindented = line == line.lstrip() + title = _SECTION_TITLES.get(stripped.removesuffix(":").lower()) + if title is not None and unindented: + blocks.append((title, [])) + continue + if blocks[-1][0] is not None and stripped and unindented: + # Unindented prose ends the indented section body and belongs to the surrounding text. + blocks.append((None, [])) + blocks[-1][1].append(line) + + return blocks + + +def _paragraph_text(lines: list[str]) -> str: + text = textwrap.dedent("\n".join(lines)).strip() + if not text: + return "" + first_paragraph = re.split(r"\n\s*\n", text, maxsplit=1)[0] + return " ".join(line.strip() for line in first_paragraph.splitlines()) + + +def _paragraphs_to_html(lines: list[str]) -> str: + text = textwrap.dedent("\n".join(lines)).strip() + if not text: + return "" + + paragraphs = re.split(r"\n\s*\n", text) + return "".join( + f"

{html.escape(' '.join(line.strip() for line in paragraph.splitlines()))}

" + for paragraph in paragraphs + if paragraph.strip() + ) + + +def _parse_fields(lines: list[str]) -> list[tuple[str, str | None, str]]: + text = textwrap.dedent("\n".join(lines)).strip() + if not text: + return [] + + fields: list[tuple[str, str | None, str]] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + + if raw_line == raw_line.lstrip(): + match = _TYPED_FIELD_PATTERN.match(line) + if match: + fields.append( + (match.group(1).strip(), match.group(2).strip(), match.group(3).strip()) + ) + continue + + match = _FIELD_PATTERN.match(line) + if match: + fields.append((match.group(1).strip(), None, match.group(2).strip())) + continue + + if not fields: + return [] + name, field_type, description = fields[-1] + fields[-1] = (name, field_type, " ".join(filter(None, (description, line)))) + + return fields + + +def _fields_to_html(fields: list[tuple[str, str | None, str]]) -> str: + rows = [] + for name, field_type, description in fields: + type_html = ( + f"
{html.escape(field_type)}" if field_type is not None else "" + ) + rows.append( + "" + f'{html.escape(name)}{type_html}' + f'{html.escape(description)}' + "" + ) + return '' + "".join(rows) + "
" + + +def render_scan_docstring_html(scan_name: str, docstring: str | None) -> str: + """Render a Google-style scan docstring as theme-neutral, safe HTML.""" + title = html.escape(scan_name) + if not isinstance(docstring, str) or not docstring.strip(): + return f"

{title}

No documentation is available for this scan.

" + + body = [f"

{title}

"] + for section_title, lines in _split_blocks(docstring): + if section_title is None: + body.append(_paragraphs_to_html(lines)) + continue + body.append(f"

{html.escape(section_title)}

") + if section_title == "Examples": + example = textwrap.dedent("\n".join(lines)).strip() + if example: + body.append(f"
{html.escape(example)}
") + continue + + fields = _parse_fields(lines) if section_title in _FIELD_SECTIONS else [] + body.append(_fields_to_html(fields) if fields else _paragraphs_to_html(lines)) + + return "".join(body) + + +def render_scan_tooltip_html(scan_name: str, docstring: str | None) -> str: + """Render a compact scan summary for combo-box hover tooltips.""" + title = html.escape(scan_name) + if not isinstance(docstring, str) or not docstring.strip(): + return f"{title}
No documentation is available for this scan." + + blocks = _split_blocks(docstring) + summary = _paragraph_text(blocks[0][1]) + if len(summary) > _TOOLTIP_SUMMARY_LIMIT: + summary = summary[: _TOOLTIP_SUMMARY_LIMIT - 1].rstrip() + "…" + + body = [f"{title}"] + if summary: + body.append(f"

{html.escape(summary)}

") + + parameters = [] + for section_title, lines in blocks: + if section_title in {"Arguments", "Keyword arguments"}: + parameters.extend(_parse_fields(lines)) + if parameters: + labels = [ + f"{name}: {field_type}" if field_type else name + for name, field_type, _description in parameters[:_TOOLTIP_ARGUMENT_LIMIT] + ] + if len(parameters) > _TOOLTIP_ARGUMENT_LIMIT: + labels.append("…") + body.append(f"

Parameters: {html.escape(', '.join(labels))}

") + + body.append("Use the info button for full documentation.") + return "".join(body) diff --git a/bec_widgets/widgets/control/scan_control/scan_info_dialog.py b/bec_widgets/widgets/control/scan_control/scan_info_dialog.py new file mode 100644 index 00000000..3bb07bb0 --- /dev/null +++ b/bec_widgets/widgets/control/scan_control/scan_info_dialog.py @@ -0,0 +1,53 @@ +"""Reusable dialog for displaying styled scan documentation.""" + +from qtpy.QtGui import QPalette +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QTextBrowser, QVBoxLayout, QWidget + +from bec_widgets.widgets.control.scan_control.scan_docstring import render_scan_docstring_html + + +class ScanInfoDialog(QDialog): + """Modeless, theme-aware viewer for a scan docstring.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setModal(False) + self.resize(640, 480) + + layout = QVBoxLayout(self) + self.text_browser = QTextBrowser(self) + self.text_browser.setReadOnly(True) + self.text_browser.setOpenExternalLinks(False) + layout.addWidget(self.text_browser) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self) + buttons.rejected.connect(self.close) + layout.addWidget(buttons) + + def _update_style(self) -> None: + palette = self.text_browser.palette() + accent = palette.color(QPalette.ColorRole.Link).name() + muted = palette.color(QPalette.ColorRole.PlaceholderText).name() + alternate_base = palette.color(QPalette.ColorRole.AlternateBase).name() + self.text_browser.document().setDefaultStyleSheet(f""" + h1 {{ color: {accent}; margin-bottom: 12px; }} + h2 {{ color: {accent}; margin-top: 18px; margin-bottom: 6px; }} + small {{ color: {muted}; }} + table {{ margin-left: 2px; }} + td {{ padding: 3px 8px 3px 0; }} + pre {{ + background-color: {alternate_base}; + padding: 8px; + margin: 4px 0; + white-space: pre-wrap; + }} + """) + + def show_scan(self, scan_name: str, docstring: str | None) -> None: + """Render and show documentation for ``scan_name``.""" + self.setWindowTitle(f"Scan information: {scan_name}") + self._update_style() + self.text_browser.setHtml(render_scan_docstring_html(scan_name, docstring)) + self.show() + self.raise_() + self.activateWindow() diff --git a/bec_widgets/widgets/control/scan_control/scan_selection_dialog.py b/bec_widgets/widgets/control/scan_control/scan_selection_dialog.py new file mode 100644 index 00000000..7e18f69f --- /dev/null +++ b/bec_widgets/widgets/control/scan_control/scan_selection_dialog.py @@ -0,0 +1,113 @@ +from collections.abc import Iterable, Mapping + +from bec_qthemes import material_icon +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QDialog, + QDialogButtonBox, + QHBoxLayout, + QListWidget, + QListWidgetItem, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.widgets.control.scan_control.scan_info_dialog import ScanInfoDialog + + +class _RowCheckBox(QCheckBox): + """Checkbox whose entire row-sized widget is an activation target.""" + + def hitButton(self, position) -> bool: + return self.isEnabled() and self.rect().contains(position) + + +class ScanSelectionDialog(QDialog): + """Dialog for choosing which scans are shown in a scan selector.""" + + def __init__( + self, + scan_names: Iterable[str], + selected_scans: Iterable[str], + scan_docs: Mapping[str, str | None] | None = None, + parent=None, + ) -> None: + super().__init__(parent) + self.setWindowTitle("Select available scans") + + self._scan_names = list(scan_names) + self._scan_docs = dict(scan_docs or {}) + self._scan_checkboxes: dict[str, _RowCheckBox] = {} + self._scan_info_buttons: dict[str, QToolButton] = {} + self._scan_info_dialog: ScanInfoDialog | None = None + selected = set(selected_scans) + self.scan_list = QListWidget(self) + self.scan_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + for scan_name in self._scan_names: + item = QListWidgetItem(self.scan_list) + item.setData(Qt.ItemDataRole.UserRole, scan_name) + row = QWidget(self.scan_list) + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(4) + + checkbox = _RowCheckBox(scan_name, row) + checkbox.setChecked(scan_name in selected) + checkbox.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + checkbox.setCursor(Qt.CursorShape.PointingHandCursor) + row_layout.addWidget(checkbox, 1) + + info_button = QToolButton(row) + info_button.setAutoRaise(True) + info_button.setIcon(material_icon("info", size=(18, 18), convert_to_pixmap=False)) + info_button.setToolTip(f"Show information about {scan_name}") + info_button.setAccessibleName(f"Information for {scan_name}") + info_button.clicked.connect( + lambda _checked=False, name=scan_name: self.show_scan_info(name) + ) + row_layout.addWidget(info_button, 0) + + self._scan_checkboxes[scan_name] = checkbox + self._scan_info_buttons[scan_name] = info_button + item.setSizeHint(row.sizeHint()) + self.scan_list.setItemWidget(item, row) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self + ) + buttons.setContentsMargins(4, 4, 4, 4) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + layout.addWidget(self.scan_list) + layout.addWidget(buttons) + self.resize(360, 420) + + def checkbox_for_scan(self, scan_name: str) -> QCheckBox: + """Return the checkbox belonging to ``scan_name``.""" + return self._scan_checkboxes[scan_name] + + def info_button_for_scan(self, scan_name: str) -> QToolButton: + """Return the information button belonging to ``scan_name``.""" + return self._scan_info_buttons[scan_name] + + def show_scan_info(self, scan_name: str) -> None: + """Show documentation for a scan while keeping this selector open.""" + if self._scan_info_dialog is None: + self._scan_info_dialog = ScanInfoDialog(self) + self._scan_info_dialog.show_scan(scan_name, self._scan_docs.get(scan_name)) + + def selected_scans(self) -> list[str]: + """Return checked scans in the same order as displayed.""" + selected = [] + for scan_name in self._scan_names: + if self._scan_checkboxes[scan_name].isChecked(): + selected.append(scan_name) + return selected diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index 2330106e..fa264d51 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -5,13 +5,16 @@ from unittest.mock import MagicMock, patch import pytest from bec_lib.endpoints import MessageEndpoints from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage -from qtpy.QtCore import QModelIndex, Qt +from qtpy.QtCore import QModelIndex, QPoint, Qt +from qtpy.QtWidgets import QCheckBox, QDialog, QStyle from bec_widgets.utils.forms_from_types.items import StrFormItem from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.control.scan_control import ScanControl +from bec_widgets.widgets.control.scan_control.scan_control import ScanControlConfig from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter +from bec_widgets.widgets.control.scan_control.scan_selection_dialog import ScanSelectionDialog from .client_mocks import mocked_client @@ -25,6 +28,13 @@ available_scans_message = AvailableResourceMessage( "line_scan": { "class": "LineScan", "base_class": "ScanBase", + "doc": ( + "Run a line scan.\n\n" + "Args:\n" + " device (DeviceBase | str): Device to move.\n\n" + "Examples:\n" + " >>> scans.line_scan(samx, 0, 1)" + ), "arg_input": {"device": "device", "start": "float", "stop": "float"}, "gui_config": { "scan_class_name": "LineScan", @@ -119,6 +129,7 @@ available_scans_message = AvailableResourceMessage( "grid_scan": { "class": "Scan", "base_class": "ScanBase", + "doc": "Run a grid scan over one or more devices.", "arg_input": {"device": "device", "start": "float", "stop": "float", "steps": "int"}, "gui_config": { "scan_class_name": "Scan", @@ -281,6 +292,235 @@ def test_populate_scans(scan_control, mocked_client): assert sorted(items) == sorted(expected_scans) +def test_scan_selector_items_and_combo_show_doc_tooltips(scan_control): + line_index = scan_control.comboBox_scan_selection.findText("line_scan") + line_tooltip = scan_control.comboBox_scan_selection.itemData( + line_index, Qt.ItemDataRole.ToolTipRole + ) + + assert "line_scan" in line_tooltip + assert "Run a line scan." in line_tooltip + assert "Parameters:" in line_tooltip + assert "device: DeviceBase | str" in line_tooltip + assert scan_control.comboBox_scan_selection.toolTip() == line_tooltip + + scan_control.comboBox_scan_selection.setCurrentText("grid_scan") + + assert "grid_scan" in scan_control.comboBox_scan_selection.toolTip() + assert "Run a grid scan" in scan_control.comboBox_scan_selection.toolTip() + + +def test_scan_info_button_shows_styled_selected_scan_docstring(scan_control, qtbot): + assert not scan_control.scan_info_button.icon().isNull() + assert scan_control.scan_info_button.accessibleName() == "Scan information" + + with patch.object(scan_control.client.connector, "get") as connector_get: + qtbot.mouseClick(scan_control.scan_info_button, Qt.MouseButton.LeftButton) + connector_get.assert_not_called() + + assert scan_control._scan_info_dialog.isVisible() + assert not scan_control._scan_info_dialog.isModal() + assert scan_control._scan_info_dialog.windowTitle() == "Scan information: line_scan" + plain_text = scan_control._scan_info_dialog.text_browser.toPlainText() + assert "line_scan" in plain_text + assert "Arguments" in plain_text + assert "device" in plain_text + assert "Examples" in plain_text + assert ">>> scans.line_scan" in plain_text + style = scan_control._scan_info_dialog.text_browser.document().defaultStyleSheet() + assert "h1" in style + assert "pre" in style + + +def test_scan_info_button_handles_missing_docstring(scan_control, qtbot): + scan_control.available_scans["line_scan"].pop("doc") + + qtbot.mouseClick(scan_control.scan_info_button, Qt.MouseButton.LeftButton) + + assert "No documentation is available for this scan." in ( + scan_control._scan_info_dialog.text_browser.toPlainText() + ) + + +def test_allowed_scans_property_filters_selector(scan_control): + scan_control.comboBox_scan_selection.setCurrentText("grid_scan") + + scan_control.allowed_scans = ["line_scan", "unknown_scan", "line_scan"] + + # The configured filter is kept verbatim (deduplicated) so that scans that are not + # available right now reappear once the scan server publishes them. + assert scan_control.allowed_scans == ["line_scan", "unknown_scan"] + assert scan_control.config.allowed_scans == ["line_scan", "unknown_scan"] + assert scan_control.comboBox_scan_selection.count() == 1 + assert scan_control.current_scan == "line_scan" + + +def test_allowed_scans_none_clears_filter(scan_control): + scan_control.allowed_scans = ["line_scan"] + assert scan_control.comboBox_scan_selection.count() == 1 + + scan_control.allowed_scans = None + + assert scan_control.allowed_scans is None + assert scan_control.config.allowed_scans is None + assert scan_control.comboBox_scan_selection.count() == 2 + + +def test_allowed_scans_override_support_filter(scan_control): + scan_control.allowed_scans = ["not_supported_scan_class", "line_scan"] + + items = [ + scan_control.comboBox_scan_selection.itemText(i) + for i in range(scan_control.comboBox_scan_selection.count()) + ] + + assert items == ["not_supported_scan_class", "line_scan"] + + +def test_filter_change_saves_current_scan_parameters(scan_control): + assert scan_control.current_scan == "line_scan" + + scan_control.allowed_scans = ["grid_scan"] + + assert scan_control.current_scan == "grid_scan" + assert "line_scan" in scan_control.config.scans + + +def test_empty_allowed_scans_disable_scan_info_and_run(scan_control): + scan_control.allowed_scans = [] + + assert scan_control.comboBox_scan_selection.count() == 0 + assert scan_control.comboBox_scan_selection.toolTip() == "" + assert not scan_control.scan_info_button.isEnabled() + assert not scan_control.button_run_scan.isEnabled() + # run_scan must not raise even if triggered without a selected scan + scan_control.run_scan() + + +def test_configured_allowed_scans_are_preserved(qtbot, mocked_client): + mocked_client.connector.set_and_publish( + MessageEndpoints.available_scans(), available_scans_message + ) + config = ScanControlConfig(widget_class="ScanControl", allowed_scans=["grid_scan"]) + + widget = ScanControl(client=mocked_client, config=config) + qtbot.addWidget(widget) + + assert widget.allowed_scans == ["grid_scan"] + assert widget.comboBox_scan_selection.count() == 1 + assert widget.comboBox_scan_selection.currentText() == "grid_scan" + + +def test_scan_selector_settings_dialog_applies_checked_scans(scan_control, monkeypatch, qtbot): + def select_line_scan(dialog): + labels = [dialog.checkbox_for_scan(name).text() for name in ("line_scan", "grid_scan")] + assert labels == ["line_scan", "grid_scan"] + checkbox = dialog.checkbox_for_scan("grid_scan") + assert isinstance(checkbox, QCheckBox) + checkbox.setChecked(False) + return QDialog.DialogCode.Accepted + + monkeypatch.setattr(ScanSelectionDialog, "exec", select_line_scan) + + qtbot.mouseClick(scan_control.scan_selector_settings_button, Qt.MouseButton.LeftButton) + + assert scan_control.allowed_scans == ["line_scan"] + assert scan_control.comboBox_scan_selection.count() == 1 + assert scan_control.comboBox_scan_selection.currentText() == "line_scan" + + +def test_scan_selector_settings_dialog_all_checked_clears_filter(scan_control, monkeypatch, qtbot): + scan_control.allowed_scans = ["line_scan"] + + def check_everything(dialog): + dialog.checkbox_for_scan("grid_scan").setChecked(True) + return QDialog.DialogCode.Accepted + + monkeypatch.setattr(ScanSelectionDialog, "exec", check_everything) + + qtbot.mouseClick(scan_control.scan_selector_settings_button, Qt.MouseButton.LeftButton) + + assert scan_control.allowed_scans is None + assert scan_control.comboBox_scan_selection.count() == 2 + + +def test_scan_selector_settings_dialog_is_released_after_use(scan_control, monkeypatch, qtbot): + monkeypatch.setattr(ScanSelectionDialog, "exec", lambda dialog: QDialog.DialogCode.Rejected) + with patch.object(ScanSelectionDialog, "deleteLater") as delete_later: + qtbot.mouseClick(scan_control.scan_selector_settings_button, Qt.MouseButton.LeftButton) + delete_later.assert_called_once() + + +def test_scan_selector_dialog_whole_row_click_toggles_checkbox(qtbot): + dialog = ScanSelectionDialog( + scan_names=["line_scan", "grid_scan"], selected_scans=["line_scan"] + ) + qtbot.addWidget(dialog) + dialog.show() + qtbot.waitExposed(dialog) + + checkbox = dialog.checkbox_for_scan("line_scan") + assert isinstance(checkbox, QCheckBox) + assert checkbox.isChecked() + + indicator_width = checkbox.style().pixelMetric( + QStyle.PixelMetric.PM_IndicatorWidth, widget=checkbox + ) + label_right = indicator_width + 8 + checkbox.fontMetrics().horizontalAdvance(checkbox.text()) + empty_row_x = checkbox.rect().right() - 8 + assert empty_row_x > label_right + qtbot.mouseClick( + checkbox, Qt.MouseButton.LeftButton, pos=QPoint(empty_row_x, checkbox.rect().center().y()) + ) + + assert not checkbox.isChecked() + assert dialog.selected_scans() == [] + + +def test_scan_selector_dialog_info_button_opens_docs_without_toggling(qtbot): + dialog = ScanSelectionDialog( + scan_names=["line_scan"], + selected_scans=["line_scan"], + scan_docs={"line_scan": available_scans_message.resource["line_scan"]["doc"]}, + ) + qtbot.addWidget(dialog) + dialog.setModal(True) + dialog.show() + qtbot.waitExposed(dialog) + + checkbox = dialog.checkbox_for_scan("line_scan") + info_button = dialog.info_button_for_scan("line_scan") + qtbot.mouseClick(info_button, Qt.MouseButton.LeftButton) + + assert checkbox.isChecked() + assert dialog._scan_info_dialog.parent() is dialog + assert dialog._scan_info_dialog.isVisible() + assert dialog._scan_info_dialog.windowTitle() == "Scan information: line_scan" + assert "Arguments" in dialog._scan_info_dialog.text_browser.toPlainText() + + +def test_scan_selector_settings_properties_are_profile_safe(scan_control): + exported = scan_control.export_settings() + + # "No filter" survives the round trip as None so that future scans keep appearing. + assert exported["allowed_scans"] is None + assert exported["hide_scan_selector_settings_button"] is False + + scan_control.load_settings( + {"allowed_scans": ["grid_scan"], "hide_scan_selector_settings_button": True} + ) + + assert scan_control.allowed_scans == ["grid_scan"] + assert scan_control.comboBox_scan_selection.currentText() == "grid_scan" + assert scan_control.hide_scan_selector_settings_button is True + assert scan_control.scan_selector_settings_button.isHidden() + + scan_control.load_settings({"allowed_scans": None}) + + assert scan_control.allowed_scans is None + assert scan_control.comboBox_scan_selection.count() == 2 + + def test_scan_control_uses_gui_visibility_and_signature(qtbot, mocked_client): scan_info = { "class": "AnnotatedScan", diff --git a/tests/unit_tests/test_scan_docstring.py b/tests/unit_tests/test_scan_docstring.py new file mode 100644 index 00000000..534f4c49 --- /dev/null +++ b/tests/unit_tests/test_scan_docstring.py @@ -0,0 +1,122 @@ +from bec_widgets.widgets.control.scan_control.scan_docstring import ( + render_scan_docstring_html, + render_scan_tooltip_html, +) + + +def test_render_scan_docstring_html_formats_google_sections(): + docstring = """Run a line scan over one motor. + + The motor is moved through evenly spaced positions. + + Args: + device (DeviceBase | str): Device to move. + start (float): Initial position. + Expressed in the device's configured engineering units. + Constraint: must be below the stop position. + + Returns: + ScanReport: Handle for the submitted scan. + + Raises: + ValueError: If the requested range is invalid. + + Examples: + >>> scans.line_scan(samx, -1, 1, steps=11) + """ + + rendered = render_scan_docstring_html("line_scan", docstring) + + assert "

line_scan

" in rendered + assert "

Arguments

" in rendered + assert "device" in rendered + assert "DeviceBase | str" in rendered + assert "configured engineering units" in rendered + assert "Constraint: must be below" in rendered + assert "Constraint" not in rendered + assert "

Returns

" in rendered + assert "

Raises

" in rendered + assert "

Examples

" in rendered + assert "
>>> scans.line_scan" in rendered
+
+
+def test_render_scan_tooltip_html_is_compact_and_informative():
+    docstring = """Run a line scan.
+
+    Args:
+        device (DeviceBase | str): Device to move.
+        start (float): Initial position.
+
+    Examples:
+        >>> scans.line_scan(samx, 0, 1)
+    """
+
+    rendered = render_scan_tooltip_html("line_scan", docstring)
+
+    assert "line_scan" in rendered
+    assert "Run a line scan." in rendered
+    assert "device: DeviceBase | str" in rendered
+    assert "start: float" in rendered
+    assert "Examples" not in rendered
+    assert "full documentation" in rendered
+
+
+def test_scan_docstring_renderers_escape_untrusted_content():
+    docstring = "Use  safely."
+
+    full = render_scan_docstring_html("", docstring)
+    tooltip = render_scan_tooltip_html("", docstring)
+
+    for rendered in (full, tooltip):
+        assert "<scan>" in rendered
+        assert "<script>" in rendered
+        assert "