diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index dcc40ce0..06d0c67f 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -33,14 +33,20 @@ from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic from aare.gui.threads.axis_video_thread import VideoThread -from aare.gui.tutorials.tutorial_manager import TutorialManager, TutorialStep + +from aare.gui.tutorials.tutorial_actions import TutorialActionExecutor +from aare.gui.tutorials.tutorial_manager import TutorialManager +from aare.gui.tutorials.tutorial_runtime import DictionaryTextResolver, TutorialEventBus +from aare.gui.tutorials.tutorial_targets import MainWindowTutorialTargetResolver +from aare.gui.tutorials.tutorial_registration import register_tutorials +from aare.gui.tutorials.tutroial_texts import MANUAL_MOUNT_TUTORIAL + from aare.gui.tutorials.controls_help_dialog import ControlsHelpDialog from aare.gui.threads.camera_thread import SampleCameraThread from aare.gui.threads.prediction_subscriber import PredictionSubscriber from aare.gui.threads.daq_worker import DAQWorker from aare.gui.threads.jfjoch_viewer import JFJochDBusClient -from aare.gui.tutorials.tutorial_registration import register_tutorials from aare.gui.widgets.alert_banner import AlertBanner from aare.gui.widgets.camera_image import SampleCameraImageLabel from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea @@ -72,7 +78,8 @@ class MainWindow(QMainWindow): self._cleanup_done = False # Tutorial manager (define tutorials after widgets exist) - self.tutorial_manager = TutorialManager(self) + self._tutorial_event_bus = TutorialEventBus(self) + self._tutorial_text_resolver = DictionaryTextResolver(MANUAL_MOUNT_TUTORIAL) self.viewer = JFJochDBusClient() @@ -278,6 +285,16 @@ class MainWindow(QMainWindow): self._restore_window_state() # Define tutorials now that the UI exists + self._tutorial_target_resolver = MainWindowTutorialTargetResolver(self) + self._tutorial_action_executor = TutorialActionExecutor(self) + + self.tutorial_manager = TutorialManager( + self, + target_resolver=self._tutorial_target_resolver, + text_resolver=self._tutorial_text_resolver, + action_executor=self._tutorial_action_executor, + event_bus=self._tutorial_event_bus, + ) self.status_bar = StatusBar(self.__decoded_token, parent=self) self.setStatusBar(self.status_bar) @@ -488,10 +505,10 @@ class MainWindow(QMainWindow): self._pred_is_preferred = age_s <= self._pred_preferred_timeout_s def start_text_tutorial(self) -> None: - self.tutorial_manager.start("intro_text") + self.tutorial_manager.start("manual_workflow_demo") def start_interactive_tutorial(self) -> None: - self.tutorial_manager.start("intro_interactive") + self.tutorial_manager.start("manual_workflow_demo") def create_menu_bar(self): """Create a menu bar with File->Quit and Help->About.""" diff --git a/src/aare/gui/tutorials/tutorial_actions.py b/src/aare/gui/tutorials/tutorial_actions.py new file mode 100644 index 00000000..229299dd --- /dev/null +++ b/src/aare/gui/tutorials/tutorial_actions.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +from aare.gui.tutorials.tutorial_models import TutorialAction, TutorialContext + + +def _set_nested_value(data: dict[str, Any], path: str, value: Any) -> None: + current = data + parts = path.split(".") + for part in parts[:-1]: + if part not in current or not isinstance(current[part], dict): + current[part] = {} + current = current[part] + current[parts[-1]] = value + + +class TutorialActionExecutor: + def __init__(self, window): + self.window = window + + def execute_action(self, action: TutorialAction, context: TutorialContext) -> None: + action_id = action.action_id + params = action.params + + if action_id == "enter_demo_mode": + context.demo_mode_enabled = True + _set_nested_value(context.state, "demo.enabled", True) + return + + if action_id == "exit_demo_mode": + context.demo_mode_enabled = False + _set_nested_value(context.state, "demo.enabled", False) + return + + if action_id == "set_context_state": + path = str(params["path"]) + value = params.get("value") + _set_nested_value(context.state, path, value) + return + + if action_id == "set_demo_sample": + sample_name = str(params.get("sample_name", "demo_sample")) + _set_nested_value(context.state, "demo.sample_name", sample_name) + _set_nested_value(context.state, "demo.selected_sample", None) + _set_nested_value(context.state, "demo.sample_mounted", False) + return + + if action_id == "mark_demo_sample_selected": + sample_name = str(params.get("sample_name", "demo_sample")) + _set_nested_value(context.state, "demo.selected_sample", sample_name) + return + + if action_id == "simulate_demo_mount": + sample_name = str(params.get("sample_name", "demo_sample")) + _set_nested_value(context.state, "demo.selected_sample", sample_name) + _set_nested_value(context.state, "demo.sample_mounted", True) + return + + raise ValueError(f"Unknown tutorial action: {action_id}") diff --git a/src/aare/gui/tutorials/tutorial_manager.py b/src/aare/gui/tutorials/tutorial_manager.py index ea9bb43f..e9ba22c6 100644 --- a/src/aare/gui/tutorials/tutorial_manager.py +++ b/src/aare/gui/tutorials/tutorial_manager.py @@ -1,56 +1,83 @@ from __future__ import annotations + +import time from dataclasses import dataclass -from typing import List, Dict, Callable, Optional +from typing import Any from PySide6.QtCore import ( - Qt, - QRect, + QEasingCurve, + QObject, QPoint, QPropertyAnimation, - QObject, - Signal, - QEasingCurve, Property, + QRect, + Qt, QTimer, + Signal, ) -from PySide6.QtGui import ( - QColor, QPainter, QPen +from PySide6.QtGui import QColor, QPainter, QPen +from PySide6.QtWidgets import QLabel, QPushButton, QWidget + +from aare.gui.tutorials.tutorial_models import ( + StepFlow, + StepStatus, + TutorialContext, + TutorialScenario, + TutorialStepDefinition, ) -from PySide6.QtWidgets import ( - QWidget, QLabel, QPushButton +from aare.gui.tutorials.tutorial_runtime import ( + CompletionEvaluator, + ResolvedTutorialTarget, + TutorialActionExecutor, + TutorialEvent, + TutorialEventBus, + TutorialTargetResolver, + TutorialTextResolver, + build_runtime_state_for_scenario, + ensure_step_runtime_state, ) -@dataclass -class TutorialStep: - widget: QWidget - text: str - wait_for_click: bool = False # If true, wait for user clicking the highlight area +def _now_ms() -> int: + return int(time.time() * 1000) - # New: auto-advance when condition becomes True - wait_for_condition: Optional[Callable[[], bool]] = None - condition_poll_ms: int = 200 - condition_timeout_ms: Optional[int] = None # None = no timeout - on_step_start: Optional[Callable] = None - on_step_end: Optional[Callable] = None +@dataclass(slots=True) +class OverlayStepViewModel: + title: str + body: str + hint: str | None = None + rect: QRect | None = None + can_go_next: bool = True + can_go_back: bool = False + can_skip: bool = False + next_label: str = "Next" + waiting_for_target_click: bool = False class TutorialOverlay(QWidget): - """Visual layer: draws highlight, handles animations, shows callouts.""" + """ + Presentation-only overlay. - animation_finished = Signal() # Visual-only - proceed_requested = Signal() # Next / Finish / click highlight - cancelled = Signal() # End tutorial + It knows how to: + - dim the app + - highlight a target rect + - show tutorial copy + - expose buttons/signals - def __init__(self, parent=None): + It does NOT know anything about tutorial scenarios or completion rules. + """ + + next_requested = Signal() + back_requested = Signal() + skip_requested = Signal() + cancelled = Signal() + highlight_clicked = Signal() + + def __init__(self, parent: QWidget | None = None): super().__init__(parent) self.setAttribute(Qt.WA_TransparentForMouseEvents, False) - - # IMPORTANT: keep this as a CHILD widget of the main window. - # Using Qt.Tool turns it into a top-level tool window and breaks geometry alignment. self.setWindowFlags(Qt.FramelessWindowHint) - self.setAttribute(Qt.WA_TranslucentBackground, True) self.setFocusPolicy(Qt.StrongFocus) @@ -58,44 +85,50 @@ class TutorialOverlay(QWidget): self.target_rect = QRect() self.opacity = 1.0 self._waiting_for_click = False - - self._highlight_padding_px = 6 # makes highlight cover frames/margins better - - self._dummy = 0.0 # Qt-animatable backing field + self._highlight_padding_px = 6 + self._dummy = 0.0 self.anim = QPropertyAnimation(self, b"dummy") self.anim.valueChanged.connect(self.update) - self.anim.finished.connect(self.animation_finished.emit) - # Callout bubble self.callout = QLabel(self) self.callout.setStyleSheet(""" background: white; color: black; - padding: 10px; + padding: 16px; border: 2px solid #555; - border-radius: 8px; - font-size: 13px; + border-radius: 10px; + font-size: 16px; """) self.callout.setWordWrap(True) self.callout.hide() button_style = """ QPushButton { - font-size: 14px; + font-size: 15px; font-weight: 600; padding: 10px 16px; } """ - # Next/Finish button + self.back_button = QPushButton("Back", self) + self.back_button.setStyleSheet(button_style) + self.back_button.setMinimumHeight(44) + self.back_button.clicked.connect(self.back_requested.emit) + self.back_button.hide() + + self.skip_button = QPushButton("Skip", self) + self.skip_button.setStyleSheet(button_style) + self.skip_button.setMinimumHeight(44) + self.skip_button.clicked.connect(self.skip_requested.emit) + self.skip_button.hide() + self.next_button = QPushButton("Next", self) self.next_button.setStyleSheet(button_style) self.next_button.setMinimumHeight(44) - self.next_button.clicked.connect(self.proceed_requested.emit) + self.next_button.clicked.connect(self.next_requested.emit) self.next_button.hide() - # Always-available "End tutorial" button (top-right) self.end_button = QPushButton("End tutorial", self) self.end_button.setStyleSheet(button_style) self.end_button.setMinimumHeight(44) @@ -110,61 +143,41 @@ class TutorialOverlay(QWidget): def set_dummy(self, value: float) -> None: self._dummy = float(value) t = self._dummy - self.current_rect = self._interpolate_rect(self.current_rect, self.target_rect, t) self.opacity = t self.update() dummy = Property(float, get_dummy, set_dummy) - def showEvent(self, event): + def showEvent(self, event) -> None: super().showEvent(event) if self.parentWidget() is not None: self.setGeometry(self.parentWidget().rect()) self._position_end_button() - def resizeEvent(self, event): + def resizeEvent(self, event) -> None: super().resizeEvent(event) if self.parentWidget() is not None: self.setGeometry(self.parentWidget().rect()) self._position_end_button() + self._reposition_action_buttons() - def _position_end_button(self) -> None: - self.end_button.adjustSize() - margin = 10 - x = max(margin, self.width() - self.end_button.width() - margin) - y = margin - self.end_button.move(x, y) - self.end_button.setVisible(True) - - def keyPressEvent(self, event): + def keyPressEvent(self, event) -> None: if event.key() == Qt.Key_Escape: self.cancelled.emit() event.accept() return super().keyPressEvent(event) - def mousePressEvent(self, event): + def mousePressEvent(self, event) -> None: if self._waiting_for_click and self.current_rect.isValid(): if self.current_rect.contains(event.pos()): - self.proceed_requested.emit() + self.highlight_clicked.emit() event.accept() return super().mousePressEvent(event) - def animate_to(self, rect: QRect, duration=400): - """Fade old highlight out, fade new one in.""" - # Clamp target rect inside overlay so it stays visible - self.target_rect = rect.intersected(self.rect()) - - self.anim.stop() - self.anim.setStartValue(0.0) - self.anim.setEndValue(1.0) - self.anim.setDuration(duration) - self.anim.setEasingCurve(QEasingCurve.InOutQuad) - self.anim.start() - - def paintEvent(self, event): + def paintEvent(self, event) -> None: painter = QPainter(self) painter.fillRect(self.rect(), QColor(0, 0, 0, int(150 * self.opacity))) @@ -174,122 +187,213 @@ class TutorialOverlay(QWidget): painter.setBrush(Qt.NoBrush) painter.drawRoundedRect(self.current_rect, 8, 8) - def _interpolate_rect(self, r1: QRect, r2: QRect, t: float) -> QRect: + def animate_to(self, rect: QRect, duration: int = 300) -> None: + self.target_rect = rect.intersected(self.rect()) + self.anim.stop() + self.anim.setStartValue(0.0) + self.anim.setEndValue(1.0) + self.anim.setDuration(duration) + self.anim.setEasingCurve(QEasingCurve.InOutQuad) + self.anim.start() + + def set_step_view(self, view: OverlayStepViewModel) -> None: + rect = view.rect if view.rect is not None else QRect() + if rect.isValid(): + pad = int(self._highlight_padding_px) + rect = QRect(rect) + rect.adjust(-pad, -pad, pad, pad) + rect = rect.intersected(self.rect()) + + self.current_rect = rect + self.target_rect = rect + + text_parts = [] + if view.title.strip(): + text_parts.append(f"{view.title}") + if view.body.strip(): + text_parts.append(view.body) + if view.hint: + text_parts.append(f"{view.hint}") + + self.callout.setText("

".join(text_parts)) + self.callout.setMaximumWidth(460) + self.callout.adjustSize() + self._position_callout(rect) + + self._waiting_for_click = bool(view.waiting_for_target_click) + + self.back_button.setVisible(True) + self.back_button.setEnabled(view.can_go_back) + + self.skip_button.setVisible(view.can_skip) + self.skip_button.setEnabled(view.can_skip) + + self.next_button.setVisible(True) + self.next_button.setEnabled(view.can_go_next) + self.next_button.setText(view.next_label) + + self.callout.show() + self._position_end_button() + self._reposition_action_buttons() + self.update() + + def clear_view(self) -> None: + self.current_rect = QRect() + self.target_rect = QRect() + self.callout.hide() + self.back_button.hide() + self.skip_button.hide() + self.next_button.hide() + self.end_button.hide() + self._waiting_for_click = False + self.update() + + def _position_end_button(self) -> None: + self.end_button.adjustSize() + margin = 10 + x = max(margin, self.width() - self.end_button.width() - margin) + y = margin + self.end_button.move(x, y) + self.end_button.setVisible(True) + + def _position_callout(self, rect: QRect) -> None: + margin = 10 + + if rect.isValid(): + bubble_x = rect.x() + bubble_y_above = rect.y() - self.callout.height() - 12 + bubble_y_below = rect.y() + rect.height() + 12 + bubble_y = bubble_y_above if bubble_y_above >= margin else bubble_y_below + else: + bubble_x = margin + bubble_y = margin + 48 + + bubble_x = min( + max(margin, bubble_x), + max(margin, self.width() - self.callout.width() - margin), + ) + bubble_y = min( + max(margin, bubble_y), + max(margin, self.height() - self.callout.height() - margin - 60), + ) + + self.callout.move(bubble_x, bubble_y) + + def _reposition_action_buttons(self) -> None: + buttons = [btn for btn in (self.back_button, self.skip_button, self.next_button) if btn.isVisible()] + if not buttons: + return + + margin = 10 + spacing = 8 + y = self.callout.y() + self.callout.height() + 10 + + widths = [] + total_width = 0 + for btn in buttons: + btn.adjustSize() + widths.append(btn.width()) + total_width += btn.width() + + total_width += spacing * (len(buttons) - 1) + x = min( + max(margin, self.callout.x() + self.callout.width() - total_width), + max(margin, self.width() - total_width - margin), + ) + y = min( + max(margin, y), + max(margin, self.height() - max(btn.height() for btn in buttons) - margin), + ) + + for btn in buttons: + btn.move(x, y) + x += btn.width() + spacing + + @staticmethod + def _interpolate_rect(r1: QRect, r2: QRect, t: float) -> QRect: x = r1.x() + (r2.x() - r1.x()) * t y = r1.y() + (r2.y() - r1.y()) * t w = r1.width() + (r2.width() - r1.width()) * t h = r1.height() + (r2.height() - r1.height()) * t return QRect(int(x), int(y), int(w), int(h)) - def show_step(self, step: TutorialStep): - widget = step.widget - - # Map the widget's *rect* (not just size) to global -> overlay coords - top_left_global = widget.mapToGlobal(widget.rect().topLeft()) - bottom_right_global = widget.mapToGlobal(widget.rect().bottomRight()) - - top_left = self.mapFromGlobal(top_left_global) - bottom_right = self.mapFromGlobal(bottom_right_global) - - rect = QRect(top_left, bottom_right).normalized() - - # Pad highlight so it better covers frames/margins - pad = int(self._highlight_padding_px) - rect.adjust(-pad, -pad, pad, pad) - - rect = rect.intersected(self.rect()) - - self.current_rect = rect - self.target_rect = rect - - self.callout.setText(step.text) - self.callout.adjustSize() - - margin = 10 - - bubble_x = rect.x() - bubble_y_above = rect.y() - self.callout.height() - 12 - bubble_y_below = rect.y() + rect.height() + 12 - bubble_y = bubble_y_above if bubble_y_above >= margin else bubble_y_below - - bubble_x = min(max(margin, bubble_x), max(margin, self.width() - self.callout.width() - margin)) - bubble_y = min(max(margin, bubble_y), max(margin, self.height() - self.callout.height() - margin)) - - self.callout.move(bubble_x, bubble_y) - self.callout.show() - - self._reposition_next_button() - self.update() - - def _reposition_next_button(self) -> None: - if not self.next_button.isVisible(): - return - - self.next_button.adjustSize() - margin = 10 - - x = self.callout.x() + self.callout.width() - self.next_button.width() - y = self.callout.y() + self.callout.height() + 10 - - x = min(max(margin, x), max(margin, self.width() - self.next_button.width() - margin)) - y = min(max(margin, y), max(margin, self.height() - self.next_button.height() - margin)) - - self.next_button.move(x, y) - - def set_waiting_for_click(self, waiting: bool): - self._waiting_for_click = waiting - - def set_next_enabled(self, enabled: bool, label: str = "Next"): - self.next_button.setText(label) - self.next_button.setVisible(enabled) - self.next_button.setEnabled(enabled) - self._reposition_next_button() - - def set_next_visible_but_disabled(self, label: str) -> None: - """Show Next/Finish button but disabled (useful while waiting for a condition).""" - self.next_button.setText(label) - self.next_button.setVisible(True) - self.next_button.setEnabled(False) - self._reposition_next_button() - class TutorialManager(QObject): - """Handles multiple tutorials, step sequencing, waiting for clicks/conditions.""" + """ + Scenario-driven tutorial controller. - def __init__(self, parent_window): - super().__init__() + This replaces the old step model with: + - declarative scenarios + - runtime context/state + - target resolution + - text resolution + - action execution + - event-driven completion + """ + + tutorial_started = Signal(str) + tutorial_stopped = Signal(str) + tutorial_completed = Signal(str) + step_changed = Signal(str, str) # scenario_id, step_id + + def __init__( + self, + parent_window: QWidget, + target_resolver: TutorialTargetResolver, + text_resolver: TutorialTextResolver, + action_executor: TutorialActionExecutor, + event_bus: TutorialEventBus | None = None, + parent: QObject | None = None, + ): + super().__init__(parent) self.window = parent_window self.overlay = TutorialOverlay(parent_window) - self.tutorials: Dict[str, List[TutorialStep]] = {} - self.current_tutorial: Optional[List[TutorialStep]] = None - self.index = -1 + self.target_resolver = target_resolver + self.text_resolver = text_resolver + self.action_executor = action_executor + self.event_bus = event_bus or TutorialEventBus(self) + self.completion_evaluator = CompletionEvaluator(self.event_bus) - self._timeout_timer = QTimer(self) - self._timeout_timer.setSingleShot(True) - self._timeout_timer.timeout.connect(self.stop) + self.scenarios: dict[str, TutorialScenario] = {} - self._condition_poll_timer = QTimer(self) - self._condition_poll_timer.setSingleShot(False) - self._condition_poll_timer.timeout.connect(self._check_condition_and_advance) + self.current_scenario: TutorialScenario | None = None + self.context: TutorialContext | None = None + self.runtime_state = None + self._current_resolved_target: ResolvedTutorialTarget | None = None - self._condition_timeout_timer = QTimer(self) - self._condition_timeout_timer.setSingleShot(True) - self._condition_timeout_timer.timeout.connect(self._condition_timed_out) + self._step_timeout_timer = QTimer(self) + self._step_timeout_timer.setSingleShot(True) + self._step_timeout_timer.timeout.connect(self._on_step_timeout) - self.overlay.proceed_requested.connect(self._next_step_logic) + self.overlay.next_requested.connect(self._on_next_requested) + self.overlay.back_requested.connect(self._on_back_requested) + self.overlay.skip_requested.connect(self._on_skip_requested) self.overlay.cancelled.connect(self.stop) + self.overlay.highlight_clicked.connect(self._on_highlight_clicked) - def add_tutorial(self, name: str, steps: List[TutorialStep]): - self.tutorials[name] = steps + self.event_bus.event_emitted.connect(self._on_tutorial_event) - def start(self, name: str, timeout_ms: int | None = None): - if name not in self.tutorials: - print("Tutorial not found:", name) - return + def add_scenario(self, scenario: TutorialScenario) -> None: + self.scenarios[scenario.id] = scenario - self.current_tutorial = self.tutorials[name] - self.index = -1 + def start(self, scenario_id: str, language: str = "en") -> None: + scenario = self.scenarios.get(scenario_id) + if scenario is None: + raise ValueError(f"Tutorial scenario not found: {scenario_id}") + + if self.current_scenario is not None: + self.stop() + + self.current_scenario = scenario + self.context = TutorialContext( + scenario_id=scenario.id, + language=language, + ) + self.runtime_state = build_runtime_state_for_scenario(scenario) + self.runtime_state.started_at_ms = _now_ms() + self.runtime_state.active_step_index = -1 + self._current_resolved_target = None self.overlay.setGeometry(self.window.rect()) self.overlay.show() @@ -297,108 +401,293 @@ class TutorialManager(QObject): self.overlay.activateWindow() self.overlay.setFocus() - self._timeout_timer.stop() - if timeout_ms is not None and timeout_ms > 0: - self._timeout_timer.start(int(timeout_ms)) + self.tutorial_started.emit(scenario.id) + self._advance_to_index(0) - self._stop_condition_wait() - self._next_step_logic() + def stop(self) -> None: + scenario_id = self.current_scenario.id if self.current_scenario else None - def stop(self): - self._timeout_timer.stop() - self._stop_condition_wait() + self._step_timeout_timer.stop() + + if self.current_scenario and self.context: + current_step = self.get_current_step() + if current_step is not None: + self._run_cleanup_actions(current_step) + + if self.runtime_state is not None: + self.runtime_state.stopped_at_ms = _now_ms() + + self.overlay.clear_view() self.overlay.hide() - self.current_tutorial = None - self.index = -1 - def _stop_condition_wait(self) -> None: - self._condition_poll_timer.stop() - self._condition_timeout_timer.stop() + self.current_scenario = None + self.context = None + self.runtime_state = None + self._current_resolved_target = None - def _check_condition_and_advance(self) -> None: - """Poll the current step condition; advance when satisfied.""" - if not self.current_tutorial: - self._stop_condition_wait() - return - if not (0 <= self.index < len(self.current_tutorial)): - self._stop_condition_wait() + if scenario_id is not None: + self.tutorial_stopped.emit(scenario_id) + + def emit_event(self, name: str, payload: dict[str, Any] | None = None) -> TutorialEvent: + event = self.event_bus.emit_event(name, payload or {}); + if self.context is not None: + self.context.event_log.append(event) + return event + + def get_current_step(self) -> TutorialStep | None: + if self.current_scenario is None or self.runtime_state is None: + return None + + index = self.runtime_state.active_step_index + if not (0 <= index < len(self.current_scenario.steps)): + return None + return self.current_scenario.steps[index] + + def _advance_to_index(self, new_index: int) -> None: + if self.current_scenario is None or self.context is None or self.runtime_state is None: return - step = self.current_tutorial[self.index] - cond = step.wait_for_condition - if cond is None: - self._stop_condition_wait() + previous_step = self.get_current_step() + if previous_step is not None: + self._run_cleanup_actions(previous_step) + + if new_index >= len(self.current_scenario.steps): + scenario_id = self.current_scenario.id + self.runtime_state.completed_at_ms = _now_ms() + self.overlay.clear_view() + self.overlay.hide() + self.current_scenario = None + self.context = None + self.runtime_state = None + self._current_resolved_target = None + self.tutorial_completed.emit(scenario_id) + self.tutorial_stopped.emit(scenario_id) return + if new_index < 0: + new_index = 0 + + self.runtime_state.active_step_index = new_index + step = self.current_scenario.steps[new_index] + + self.context.current_step_index = new_index + self.context.current_step_id = step.id + + step_state = ensure_step_runtime_state(self.runtime_state, step.id) + step_state.status = StepStatus.ACTIVE + if step_state.started_at_ms is None: + step_state.started_at_ms = _now_ms() + + self._run_setup_actions(step) + self._enter_current_step() + + def _enter_current_step(self) -> None: + if self.current_scenario is None or self.context is None or self.runtime_state is None: + return + + step = self.get_current_step() + if step is None: + return + + self._step_timeout_timer.stop() + self._current_resolved_target = self._resolve_step_target(step) + + title = self.text_resolver.resolve_text(step.title, self.context.language) + body = self.text_resolver.resolve_text(step.body, self.context.language) + hint = ( + self.text_resolver.resolve_text(step.hint, self.context.language) + if step.hint is not None + else None + ) + + rect = self._resolved_target_rect(self._current_resolved_target) + + if rect is not None and rect.isValid(): + self.overlay.animate_to(rect) + + is_first = self.runtime_state.active_step_index <= 0 + is_last = self.runtime_state.active_step_index == (len(self.current_scenario.steps) - 1) + + waiting_for_target_click = bool( + step.completion is not None and step.completion.kind.value == "target_clicked" + ) + can_go_next = step.flow == StepFlow.NEXT_ONLY + next_label = "Finish" if is_last and can_go_next else "Next" + + view = OverlayStepViewModel( + title=title, + body=body, + hint=hint, + rect=rect, + can_go_next=can_go_next, + can_go_back=self.current_scenario.allow_back and not is_first, + can_skip=self.current_scenario.allow_skip and step.skippable, + next_label=next_label, + waiting_for_target_click=waiting_for_target_click, + ) + self.overlay.set_step_view(view) + + if step.timeout_ms is not None and step.timeout_ms > 0: + self._step_timeout_timer.start(int(step.timeout_ms)) + + self.step_changed.emit(self.current_scenario.id, step.id) + self._try_auto_advance_current_step() + + def _resolve_step_target(self, step: TutorialStep) -> ResolvedTutorialTarget | None: + if step.target is None or self.context is None: + return None try: - ok = bool(cond()) - except Exception as exc: - # Don't crash the GUI; just stop waiting and let user end tutorial. - print(f"Tutorial condition raised exception: {exc}") - self._stop_condition_wait() - self.overlay.set_next_enabled(True, label="Next") + return self.target_resolver.resolve_target(step.target, self.context) + except Exception: + return None + + def _resolved_target_rect(self, resolved: ResolvedTutorialTarget | None) -> QRect | None: + if resolved is None: + return None + + if resolved.widget is not None: + widget = resolved.widget + top_left_global = widget.mapToGlobal(widget.rect().topLeft()) + bottom_right_global = widget.mapToGlobal(widget.rect().bottomRight()) + top_left = self.overlay.mapFromGlobal(top_left_global) + bottom_right = self.overlay.mapFromGlobal(bottom_right_global) + return QRect(top_left, bottom_right).normalized().intersected(self.overlay.rect()) + + if resolved.rect is not None: + return QRect(resolved.rect).intersected(self.overlay.rect()) + + return None + + def _run_setup_actions(self, step: TutorialStep) -> None: + if self.context is None: + return + for action in step.setup_actions: + self.action_executor.execute_action(action, self.context) + + def _run_cleanup_actions(self, step: TutorialStep) -> None: + if self.context is None: + return + for action in step.cleanup_actions: + self.action_executor.execute_action(action, self.context) + + def _mark_current_step_completed(self) -> None: + if self.current_scenario is None or self.context is None or self.runtime_state is None: return - if ok: - self._stop_condition_wait() - self._next_step_logic() - - def _condition_timed_out(self) -> None: - """If a condition times out, re-enable Next so user can proceed manually.""" - self._condition_poll_timer.stop() - self.overlay.set_next_enabled(True, label="Next") - - def _next_step_logic(self): - if not self.current_tutorial: + step = self.get_current_step() + if step is None: return - # End previous step hook - if 0 <= self.index < len(self.current_tutorial): - prev = self.current_tutorial[self.index] - if prev.on_step_end: - prev.on_step_end() + step_state = ensure_step_runtime_state(self.runtime_state, step.id) + step_state.status = StepStatus.COMPLETED + step_state.completed_at_ms = _now_ms() - # Next step - self.index += 1 - if self.index >= len(self.current_tutorial): - self.stop() + if step.id not in self.context.completed_step_ids: + self.context.completed_step_ids.append(step.id) + + def _mark_current_step_skipped(self) -> None: + if self.current_scenario is None or self.context is None or self.runtime_state is None: return - step = self.current_tutorial[self.index] - - if step.on_step_start: - step.on_step_start() - - widget = step.widget - global_pos = widget.mapToGlobal(QPoint(0, 0)) - pos = self.overlay.mapFromGlobal(global_pos) - rect = QRect(pos, widget.size()).intersected(self.overlay.rect()) - - self.overlay.animate_to(rect) - self.overlay.show_step(step) - - is_last = (self.index == len(self.current_tutorial) - 1) - - # Interaction gating priority: - # 1) wait_for_condition (auto-advance) - # 2) wait_for_click (click highlight) - # 3) manual Next/Finish - self._stop_condition_wait() - - if step.wait_for_condition is not None: - # show disabled button to communicate "waiting..." - self.overlay.set_waiting_for_click(False) - self.overlay.set_next_visible_but_disabled("Waiting…") - - poll_ms = max(50, int(step.condition_poll_ms)) - self._condition_poll_timer.start(poll_ms) - - if step.condition_timeout_ms is not None and step.condition_timeout_ms > 0: - self._condition_timeout_timer.start(int(step.condition_timeout_ms)) + step = self.get_current_step() + if step is None: return - self.overlay.set_waiting_for_click(bool(step.wait_for_click)) - if step.wait_for_click: - self.overlay.set_next_enabled(False) - else: - self.overlay.set_next_enabled(True, label=("Finish" if is_last else "Next")) \ No newline at end of file + step_state = ensure_step_runtime_state(self.runtime_state, step.id) + step_state.status = StepStatus.SKIPPED + step_state.skipped_at_ms = _now_ms() + + def _mark_current_step_failed(self, message: str | None = None) -> None: + if self.current_scenario is None or self.runtime_state is None: + return + + step = self.get_current_step() + if step is None: + return + + step_state = ensure_step_runtime_state(self.runtime_state, step.id) + step_state.status = StepStatus.FAILED + step_state.failed_at_ms = _now_ms() + step_state.last_error = message + + def _is_current_step_complete(self, *, target_clicked: bool = False) -> bool: + if self.context is None: + return False + + step = self.get_current_step() + if step is None: + return False + + if step.flow == StepFlow.NEXT_ONLY: + return False + + return self.completion_evaluator.is_step_complete( + step, + self.context, + target_clicked=target_clicked, + ) + + def _try_auto_advance_current_step(self, *, target_clicked: bool = False) -> None: + step = self.get_current_step() + if step is None: + return + + if step.flow == StepFlow.NEXT_ONLY: + return + + if self._is_current_step_complete(target_clicked=target_clicked): + self._mark_current_step_completed() + assert self.runtime_state is not None + self._advance_to_index(self.runtime_state.active_step_index + 1) + + def _on_next_requested(self) -> None: + step = self.get_current_step() + if step is None or self.runtime_state is None: + return + + if step.flow == StepFlow.NEXT_ONLY: + self._mark_current_step_completed() + self._advance_to_index(self.runtime_state.active_step_index + 1) + return + + if self._is_current_step_complete(): + self._mark_current_step_completed() + self._advance_to_index(self.runtime_state.active_step_index + 1) + + def _on_back_requested(self) -> None: + if self.current_scenario is None or self.runtime_state is None: + return + if not self.current_scenario.allow_back: + return + self._advance_to_index(self.runtime_state.active_step_index - 1) + + def _on_skip_requested(self) -> None: + step = self.get_current_step() + if step is None or self.current_scenario is None or self.runtime_state is None: + return + if not self.current_scenario.allow_skip or not step.skippable: + return + self._mark_current_step_skipped() + self._advance_to_index(self.runtime_state.active_step_index + 1) + + def _on_highlight_clicked(self) -> None: + self._try_auto_advance_current_step(target_clicked=True) + + def _on_tutorial_event(self, event: TutorialEvent) -> None: + if self.current_scenario is None or self.context is None: + return + self._try_auto_advance_current_step() + + def _on_step_timeout(self) -> None: + step = self.get_current_step() + if step is None: + return + + self._mark_current_step_failed("Step timed out.") + + if step.skippable and self.current_scenario is not None and self.current_scenario.allow_skip: + if self.runtime_state is not None: + self._advance_to_index(self.runtime_state.active_step_index + 1) + return + + self.overlay.next_button.setEnabled(True) \ No newline at end of file diff --git a/src/aare/gui/tutorials/tutorial_models.py b/src/aare/gui/tutorials/tutorial_models.py new file mode 100644 index 00000000..34da9c50 --- /dev/null +++ b/src/aare/gui/tutorials/tutorial_models.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class TutorialMode(str, Enum): + LINEAR = "linear" + GUIDED = "guided" + SANDBOX = "sandbox" + + +class StepKind(str, Enum): + INFO = "info" + ACTION = "action" + CONFIRMATION = "confirmation" + + +class StepFlow(str, Enum): + NEXT_ONLY = "next_only" + AUTO_ADVANCE_ON_COMPLETE = "auto_advance_on_complete" + REQUIRE_COMPLETE_OR_SKIP = "require_complete_or_skip" + + +class StepStatus(str, Enum): + PENDING = "pending" + ACTIVE = "active" + COMPLETED = "completed" + SKIPPED = "skipped" + FAILED = "failed" + + +class TargetKind(str, Enum): + WIDGET = "widget" + REGION = "region" + DYNAMIC = "dynamic" + NONE = "none" + + +class CompletionKind(str, Enum): + MANUAL_NEXT = "manual_next" + TARGET_CLICKED = "target_clicked" + EVENT_OCCURRED = "event_occurred" + STATE_MATCH = "state_match" + ALL_OF = "all_of" + ANY_OF = "any_of" + + +@dataclass(slots=True) +class CompletionRule: + kind: CompletionKind + value: Any = None + children: list["CompletionRule"] = field(default_factory=list) + description: TutorialTextRef | None = None + + +@dataclass(slots=True) +class TutorialTextRef: + key: str + args: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class TutorialAction: + action_id: str + params: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class TutorialEvent: + name: str + payload: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class TutorialTarget: + kind: TargetKind + target_id: str + anchor: str | None = None + + +@dataclass(slots=True) +class TutorialStepState: + step_id: str + status: StepStatus = StepStatus.PENDING + started_at_ms: int | None = None + completed_at_ms: int | None = None + last_error: str | None = None + + +@dataclass(slots=True) +class TutorialStepDefinition: + id: str + kind: StepKind + title: TutorialTextRef + body: TutorialTextRef + + target: TutorialTarget | None = None + completion: CompletionRule | None = None + hint: TutorialTextRef | None = None + + flow: StepFlow = StepFlow.NEXT_ONLY + optional: bool = False + skippable: bool = True + timeout_ms: int | None = None + + success_message: TutorialTextRef | None = None + timeout_message: TutorialTextRef | None = None + error_message: TutorialTextRef | None = None + + setup_actions: list[TutorialAction] = field(default_factory=list) + cleanup_actions: list[TutorialAction] = field(default_factory=list) + + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class TutorialScenario: + id: str + title: TutorialTextRef + description: TutorialTextRef + mode: TutorialMode + steps: list["TutorialStepDefinition"] + + version: str = "1.0" + tags: set[str] = field(default_factory=set) + + requires_demo_context: bool = False + allow_skip: bool = True + allow_back: bool = True + estimated_minutes: int | None = None + + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class TutorialContext: + scenario_id: str + language: str = "en" + + current_step_id: str | None = None + current_step_index: int = -1 + + demo_mode_enabled: bool = False + completed_step_ids: list[str] = field(default_factory=list) + + state: dict[str, Any] = field(default_factory=dict) + event_log: list["TutorialEvent"] = field(default_factory=list) + + metadata: dict[str, Any] = field(default_factory=dict) \ No newline at end of file diff --git a/src/aare/gui/tutorials/tutorial_registration.py b/src/aare/gui/tutorials/tutorial_registration.py index b296c53f..dad3a13c 100644 --- a/src/aare/gui/tutorials/tutorial_registration.py +++ b/src/aare/gui/tutorials/tutorial_registration.py @@ -1,53 +1,168 @@ -# src/aare/gui/tutorials/tutorial_definitions.py from __future__ import annotations -from aare.common.models import BeamlineStateEnum -from aare.gui.tutorials.tutorial_manager import TutorialManager, TutorialStep +from aare.gui.tutorials.tutorial_models import ( + CompletionKind, + CompletionRule, + StepFlow, + StepKind, + TargetKind, + TutorialAction, + TutorialMode, + TutorialScenario, + TutorialStepDefinition, + TutorialTarget, + TutorialTextRef, +) -def register_tutorials(window, tutorial_manager: TutorialManager) -> None: - """ - Register all GUI tutorials. +def register_tutorials(window, tutorial_manager) -> None: + manual_workflow_demo = TutorialScenario( + id="manual_workflow_demo", + title=TutorialTextRef("tutorial.manual.title"), + description=TutorialTextRef("tutorial.manual.description"), + mode=TutorialMode.SANDBOX, + requires_demo_context=True, + allow_skip=True, + allow_back=True, + steps=[ + TutorialStepDefinition( + id="welcome", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.welcome.title"), + body=TutorialTextRef("tutorial.manual.welcome.body"), + flow=StepFlow.NEXT_ONLY, + setup_actions=[ + TutorialAction("enter_demo_mode"), + TutorialAction("set_demo_sample", {"sample_name": "demo_sample"}), + ], + ), + TutorialStepDefinition( + id="sample_list", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.sample_list.title"), + body=TutorialTextRef( + "tutorial.manual.sample_list.body", + {"sample_name": "demo_sample"}, + ), + target=TutorialTarget(TargetKind.WIDGET, "tell_samples"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="select_demo_sample", + kind=StepKind.ACTION, + title=TutorialTextRef("tutorial.manual.select_sample.title"), + body=TutorialTextRef( + "tutorial.manual.select_sample.body", + {"sample_name": "demo_sample"}, + ), + target=TutorialTarget(TargetKind.WIDGET, "tell_samples"), + completion=CompletionRule( + kind=CompletionKind.STATE_MATCH, + value={"path": "demo.selected_sample", "equals": "demo_sample"}, + ), + flow=StepFlow.REQUIRE_COMPLETE_OR_SKIP, + skippable=True, + setup_actions=[ + TutorialAction("mark_demo_sample_selected", {"sample_name": "demo_sample"}), + ], + ), + TutorialStepDefinition( + id="mount_demo_sample", + kind=StepKind.ACTION, + title=TutorialTextRef("tutorial.manual.mount.title"), + body=TutorialTextRef( + "tutorial.manual.mount.body", + {"sample_name": "demo_sample"}, + ), + hint=TutorialTextRef("tutorial.manual.mount.hint"), + target=TutorialTarget(TargetKind.WIDGET, "tell_samples"), + completion=CompletionRule( + kind=CompletionKind.STATE_MATCH, + value={"path": "demo.sample_mounted", "equals": True}, + ), + flow=StepFlow.REQUIRE_COMPLETE_OR_SKIP, + skippable=True, + setup_actions=[ + TutorialAction("simulate_demo_mount", {"sample_name": "demo_sample"}), + ], + ), + TutorialStepDefinition( + id="sample_camera_intro", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.sample_camera.title"), + body=TutorialTextRef("tutorial.manual.sample_camera.body"), + hint=TutorialTextRef("tutorial.manual.sample_camera.hint"), + target=TutorialTarget(TargetKind.WIDGET, "sample_camera"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="beamline_controls_intro", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.beamline_controls.title"), + body=TutorialTextRef("tutorial.manual.beamline_controls.body"), + target=TutorialTarget(TargetKind.WIDGET, "beamline_controls"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="auto_loop_center", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.auto_loop_center.title"), + body=TutorialTextRef("tutorial.manual.auto_loop_center.body"), + target=TutorialTarget(TargetKind.WIDGET, "beamline_loop_center_auto"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="ml_box", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.ml_box.title"), + body=TutorialTextRef("tutorial.manual.ml_box.body"), + target=TutorialTarget(TargetKind.WIDGET, "beamline_loop_center_box"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="raster_parameters", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.raster_parameters.title"), + body=TutorialTextRef("tutorial.manual.raster_parameters.body"), + target=TutorialTarget(TargetKind.WIDGET, "raster_parameters"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="raster_canvas", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.raster_canvas.title"), + body=TutorialTextRef("tutorial.manual.raster_canvas.body"), + target=TutorialTarget(TargetKind.WIDGET, "raster_canvas"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="rotation_parameters", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.rotation_parameters.title"), + body=TutorialTextRef("tutorial.manual.rotation_parameters.body"), + target=TutorialTarget(TargetKind.WIDGET, "rotation_parameters"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="simple_collection", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.simple_collection.title"), + body=TutorialTextRef("tutorial.manual.simple_collection.body"), + target=TutorialTarget(TargetKind.WIDGET, "simple_collection_parameters"), + flow=StepFlow.NEXT_ONLY, + ), + TutorialStepDefinition( + id="file_names", + kind=StepKind.INFO, + title=TutorialTextRef("tutorial.manual.file_names.title"), + body=TutorialTextRef("tutorial.manual.file_names.body"), + target=TutorialTarget(TargetKind.WIDGET, "file_path_panel"), + flow=StepFlow.NEXT_ONLY, + cleanup_actions=[ + TutorialAction("exit_demo_mode"), + ], + ), + ], + ) - IMPORTANT: - - Do NOT import MainWindow here (avoids import loops). - - Use widgets that already exist on `window`. - """ - - steps_text = [ - TutorialStep( - widget=window.data_collection, - text="This panel contains controls for configuring data collection.\n\nClick Next to continue." - ), - TutorialStep( - widget=window.video_tab, - text="This area shows camera and beamline views.\n\nClick Next to continue." - ), - TutorialStep( - widget=window.beamline, - text="These panels contain beamline controls.\n\nClick Next to finish." - ), - ] - - steps_interactive = [ - TutorialStep( - widget=window.data_collection, - text="Interactive tutorial: click inside the highlighted area to continue.", - wait_for_click=True - ), - TutorialStep( - widget=window.video_tab, - text="Now click inside the highlighted video tab area to continue.", - wait_for_click=True - ), - TutorialStep( - widget=window.status_bar, - text="Waiting for the beamline to return to SampleAlignment...", - wait_for_condition=lambda: window.status_bar.last_status.state == BeamlineStateEnum.SampleAlignment, - condition_poll_ms=200, - condition_timeout_ms=30_000, - ) - ] - - tutorial_manager.add_tutorial("intro_text", steps_text) - tutorial_manager.add_tutorial("intro_interactive", steps_interactive) + tutorial_manager.add_scenario(manual_workflow_demo) \ No newline at end of file diff --git a/src/aare/gui/tutorials/tutorial_runtime.py b/src/aare/gui/tutorials/tutorial_runtime.py new file mode 100644 index 00000000..12ad7df8 --- /dev/null +++ b/src/aare/gui/tutorials/tutorial_runtime.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from PySide6.QtCore import QObject, QRect, Signal +from PySide6.QtWidgets import QWidget + +from aare.gui.tutorials.tutorial_models import ( + CompletionKind, + CompletionRule, + StepStatus, + TutorialAction, + TutorialContext, + TutorialEvent, + TutorialScenario, + TutorialStepDefinition, + TutorialTarget, + TutorialTextRef, +) + + +@dataclass(slots=True) +class ResolvedTutorialTarget: + """ + Runtime representation of a tutorial target. + + A target may resolve to: + - a live widget + - a concrete rectangle + - both + """ + target_id: str + widget: QWidget | None = None + rect: QRect | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class TutorialStepRuntimeState: + step_id: str + status: StepStatus = StepStatus.PENDING + started_at_ms: int | None = None + completed_at_ms: int | None = None + skipped_at_ms: int | None = None + failed_at_ms: int | None = None + last_error: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class TutorialScenarioRuntimeState: + scenario_id: str + active_step_index: int = -1 + step_states: dict[str, TutorialStepRuntimeState] = field(default_factory=dict) + started_at_ms: int | None = None + completed_at_ms: int | None = None + stopped_at_ms: int | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class TutorialTargetResolver(Protocol): + def resolve_target( + self, + target: TutorialTarget, + context: TutorialContext, + ) -> ResolvedTutorialTarget | None: + """ + Resolve a declarative TutorialTarget into a live runtime target. + """ + ... + + +@runtime_checkable +class TutorialTextResolver(Protocol): + def resolve_text(self, text: TutorialTextRef, language: str) -> str: + """ + Resolve a TutorialTextRef into display text for the given language. + """ + ... + + +@runtime_checkable +class TutorialActionExecutor(Protocol): + def execute_action( + self, + action: TutorialAction, + context: TutorialContext, + ) -> None: + """ + Execute a model-defined tutorial action. + """ + ... + + +class TutorialEventBus(QObject): + """ + Minimal event bus for tutorial-relevant GUI/application events. + """ + + event_emitted = Signal(object) + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._events: list[TutorialEvent] = [] + + def emit_event(self, name: str, payload: dict[str, Any] | None = None) -> TutorialEvent: + event = TutorialEvent(name=name, payload=payload or {}) + self._events.append(event) + self.event_emitted.emit(event) + return event + + def events(self) -> list[TutorialEvent]: + return list(self._events) + + def clear(self) -> None: + self._events.clear() + + def has_event(self, name: str, **expected_payload: Any) -> bool: + for event in self._events: + if event.name != name: + continue + if all(event.payload.get(k) == v for k, v in expected_payload.items()): + return True + return False + + def last_event(self, name: str) -> TutorialEvent | None: + for event in reversed(self._events): + if event.name == name: + return event + return None + + +class DictionaryTextResolver: + """ + Simple resolver backed by in-memory dictionaries. + + translations example: + { + "en": { + "tutorial.ui.next": "Next", + "tutorial.manual.mount.body": "Right-click on {sample_name}.", + } + } + """ + + def __init__(self, translations: dict[str, dict[str, str]] | None = None): + self._translations = translations or {} + + def resolve_text(self, text: TutorialTextRef, language: str) -> str: + template = self._translations.get(language, {}).get(text.key, text.key) + try: + return template.format(**text.args) + except Exception: + return template + + +class NoOpActionExecutor: + """ + Safe default action executor that intentionally does nothing. + Useful while wiring the system up incrementally. + """ + + def execute_action(self, action: TutorialAction, context: TutorialContext) -> None: + return + + +class DictTargetResolver: + """ + Simple target resolver for direct target-id -> widget mapping. + + This is a good first implementation for the GUI. + """ + + def __init__(self, widget_map: dict[str, QWidget]): + self._widget_map = dict(widget_map) + + def resolve_target( + self, + target: TutorialTarget, + context: TutorialContext, + ) -> ResolvedTutorialTarget | None: + widget = self._widget_map.get(target.target_id) + if widget is None: + return None + return ResolvedTutorialTarget( + target_id=target.target_id, + widget=widget, + rect=widget.rect(), + ) + + +class CompletionEvaluator: + """ + Evaluates a CompletionRule using tutorial context and event history. + """ + + def __init__(self, event_bus: TutorialEventBus): + self._event_bus = event_bus + + def is_step_complete( + self, + step: TutorialStep, + context: TutorialContext, + *, + target_clicked: bool = False, + ) -> bool: + if step.completion is None: + return True + return self.evaluate(step.completion, context, target_clicked=target_clicked) + + def evaluate( + self, + rule: CompletionRule, + context: TutorialContext, + *, + target_clicked: bool = False, + ) -> bool: + match rule.kind: + case CompletionKind.MANUAL_NEXT: + return False + + case CompletionKind.TARGET_CLICKED: + return bool(target_clicked) + + case CompletionKind.EVENT_OCCURRED: + return self._evaluate_event_rule(rule) + + case CompletionKind.STATE_MATCH: + return self._evaluate_state_rule(rule, context) + + case CompletionKind.ALL_OF: + return all( + self.evaluate(child, context, target_clicked=target_clicked) + for child in rule.children + ) + + case CompletionKind.ANY_OF: + return any( + self.evaluate(child, context, target_clicked=target_clicked) + for child in rule.children + ) + + return False + + def _evaluate_event_rule(self, rule: CompletionRule) -> bool: + if not isinstance(rule.value, dict): + return False + + event_name = rule.value.get("event_name") + if not event_name: + return False + + expected_payload = dict(rule.value.get("payload") or {}) + return self._event_bus.has_event(str(event_name), **expected_payload) + + def _evaluate_state_rule(self, rule: CompletionRule, context: TutorialContext) -> bool: + if not isinstance(rule.value, dict): + return False + + path = rule.value.get("path") + expected = rule.value.get("equals") + + if not path or not isinstance(path, str): + return False + + actual = get_nested_value(context.state, path) + return actual == expected + + +def get_nested_value(data: dict[str, Any], path: str, default: Any = None) -> Any: + """ + Resolve dotted paths like 'demo.raster.params_valid' inside nested dictionaries. + """ + current: Any = data + for part in path.split("."): + if not isinstance(current, dict): + return default + if part not in current: + return default + current = current[part] + return current + + +def ensure_step_runtime_state( + runtime_state: TutorialScenarioRuntimeState, + step_id: str, +) -> TutorialStepRuntimeState: + state = runtime_state.step_states.get(step_id) + if state is None: + state = TutorialStepRuntimeState(step_id=step_id) + runtime_state.step_states[step_id] = state + return state + + +def build_runtime_state_for_scenario( + scenario: TutorialScenario, +) -> TutorialScenarioRuntimeState: + runtime_state = TutorialScenarioRuntimeState(scenario_id=scenario.id) + for step in scenario.steps: + runtime_state.step_states[step.id] = TutorialStepRuntimeState(step_id=step.id) + return runtime_state diff --git a/src/aare/gui/tutorials/tutorial_targets.py b/src/aare/gui/tutorials/tutorial_targets.py new file mode 100644 index 00000000..43f2afa4 --- /dev/null +++ b/src/aare/gui/tutorials/tutorial_targets.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QWidget + +from aare.gui.tutorials.tutorial_models import TutorialContext, TutorialTarget +from aare.gui.tutorials.tutorial_runtime import ResolvedTutorialTarget + + +class MainWindowTutorialTargetResolver: + def __init__(self, window: QWidget): + self.window = window + + def resolve_target( + self, + target: TutorialTarget, + context: TutorialContext, + ) -> ResolvedTutorialTarget | None: + widget_map: dict[str, QWidget | None] = { + "tell_samples": getattr(self.window, "tell_samples", None), + "sample_camera": getattr(self.window, "sample_camera", None), + "beamline_controls": getattr(self.window, "beamline", None), + "beamline_loop_center": getattr(getattr(self.window, "beamline", None), "loopctr", None), + "beamline_loop_center_auto": getattr(getattr(getattr(self.window, "beamline", None), "loopctr", None), "find_tip", None), + "beamline_loop_center_box": getattr(getattr(getattr(self.window, "beamline", None), "loopctr", None), "bounding_box", None), + "raster_panel": getattr(getattr(self.window, "data_collection", None), "raster", None), + "raster_parameters": getattr(getattr(self.window, "data_collection", None), "raster", None), + "raster_canvas": getattr(self.window, "sample_camera", None), + "rotation_parameters": getattr(getattr(self.window, "data_collection", None), "screening", None), + "simple_collection_parameters": getattr(getattr(self.window, "data_collection", None), "simple", None), + "file_path_panel": getattr(getattr(self.window, "data_collection", None), "file_path_panel", None), + } + + widget = widget_map.get(target.target_id) + if widget is None: + return None + + return ResolvedTutorialTarget( + target_id=target.target_id, + widget=widget, + rect=widget.rect(), + ) diff --git a/src/aare/gui/tutorials/tutroial_texts.py b/src/aare/gui/tutorials/tutroial_texts.py new file mode 100644 index 00000000..caadd138 --- /dev/null +++ b/src/aare/gui/tutorials/tutroial_texts.py @@ -0,0 +1,35 @@ + +MANUAL_MOUNT_TUTORIAL = { + "en": { + "tutorial.manual.title": "Manual workflow demo", + "tutorial.manual.description": "A guided walkthrough of the manual data-collection workflow.", + "tutorial.manual.welcome.title": "Welcome", + "tutorial.manual.welcome.body": "This tutorial is a safe guided demo of the manual workflow. It is intended to teach the interface and does not need to operate real hardware.", + "tutorial.manual.sample_list.title": "Sample list", + "tutorial.manual.sample_list.body": "This is the sample list. For this tutorial we will use the demo sample '{sample_name}'.", + "tutorial.manual.select_sample.title": "Select a sample", + "tutorial.manual.select_sample.body": "Select '{sample_name}' in the sample list. In normal use, this chooses the sample you want to work with.", + "tutorial.manual.mount.title": "Mount the sample", + "tutorial.manual.mount.body": "To mount a sample, right-click the selected sample and choose the mount action.", + "tutorial.manual.mount.hint": "In this tutorial, the mount step is simulated so you can learn the workflow safely.", + "tutorial.manual.sample_camera.title": "Sample camera", + "tutorial.manual.sample_camera.body": "This is the main sample camera view. The orange square marks the beam position. Left-clicking on the sample moves the smargon so that point is brought to the beam.", + "tutorial.manual.sample_camera.hint": "You can also use the mouse wheel here to rotate omega in 90° steps.", + "tutorial.manual.beamline_controls.title": "Beamline controls", + "tutorial.manual.beamline_controls.body": "Additional controls for motion, lighting, zoom, and alignment are available in this panel.", + "tutorial.manual.auto_loop_center.title": "Auto loop centering", + "tutorial.manual.auto_loop_center.body": "This button runs automatic loop centering. It can help bring the sample into position automatically.", + "tutorial.manual.ml_box.title": "ML bounding box", + "tutorial.manual.ml_box.body": "This button uses the ML box detection to generate a raster region automatically.", + "tutorial.manual.raster_parameters.title": "Raster parameters", + "tutorial.manual.raster_parameters.body": "Here you can change raster collection parameters such as exposure, transmission, grid spacing, and other scan settings.", + "tutorial.manual.raster_canvas.title": "Drawing a raster", + "tutorial.manual.raster_canvas.body": "You can draw a raster grid in the sample camera view using the mouse. This lets you define the region to screen.", + "tutorial.manual.rotation_parameters.title": "Rotation parameters", + "tutorial.manual.rotation_parameters.body": "This panel contains rotation scan parameters. You can adjust values, reload parameters from the database, or override them with user-defined values.", + "tutorial.manual.simple_collection.title": "Simple data collection", + "tutorial.manual.simple_collection.body": "This panel contains simplified collection parameters for quick data collection setup.", + "tutorial.manual.file_names.title": "File names and paths", + "tutorial.manual.file_names.body": "Here you can review the file naming and output path settings before collection.", + } + } \ No newline at end of file