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