From 8f59781107b5c0226c43465deb6a9851d9a03eec Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 26 Feb 2026 17:19:08 +0100 Subject: [PATCH] Tutorials: added the option to build tutorials for the GUI. actual tutorials are a WIP. --- src/aare/gui/main_window.py | 28 +- src/aare/gui/tutorials/tutorial_manager.py | 404 ++++++++++++++++++ .../gui/tutorials/tutorial_registration.py | 53 +++ 3 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/aare/gui/tutorials/tutorial_manager.py create mode 100644 src/aare/gui/tutorials/tutorial_registration.py diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 348f68cf..afaf2967 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -28,10 +28,13 @@ 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.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.camera_image import SampleCameraImageLabel from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea from aare.gui.widgets.status_bar import StatusBar @@ -56,6 +59,8 @@ class MainWindow(QMainWindow): self.__base_url = base_url self.__token = token self.__mounting = False + # Tutorial manager (define tutorials after widgets exist) + self.tutorial_manager = TutorialManager(self) self.viewer = JFJochDBusClient() @@ -214,6 +219,8 @@ class MainWindow(QMainWindow): self.setWindowTitle("AareGUI") self.create_menu_bar() + # Define tutorials now that the UI exists + self.status_bar = StatusBar(self.__decoded_token, parent=self) self.setStatusBar(self.status_bar) @@ -267,7 +274,8 @@ class MainWindow(QMainWindow): # Prediction subscriber thread if pred_zmq_addr is not None: logger.debug(f"Starting prediction subscriber thread {pred_zmq_addr}") - self.prediction_thread = PredictionSubscriber(pred_zmq_url=pred_zmq_addr, topic='detections') + self.prediction_thread = PredictionSubscriber(pred_zmq_url=pred_zmq_addr, topic="detections") + self.prediction_thread.image.connect(self.sample_camera.update_pixmap) self.prediction_thread.prediction.connect(self.sample_camera.update_detections) self.prediction_thread.start() else: @@ -380,6 +388,14 @@ class MainWindow(QMainWindow): self.daq.fluorimeter_spectrum_update.connect(self.fluor_panel.update_plot) self.daq.fluorimeter_spectrum_update.connect(lambda: self.fluor_panel_dock.setVisible(True)) + register_tutorials(self, self.tutorial_manager) + + def start_text_tutorial(self) -> None: + self.tutorial_manager.start("intro_text") + + def start_interactive_tutorial(self) -> None: + self.tutorial_manager.start("intro_interactive") + def create_menu_bar(self): """Create a menu bar with File->Quit and Help->About.""" # Main menu bar @@ -444,6 +460,16 @@ class MainWindow(QMainWindow): about_action.triggered.connect(self.show_about_dialog) help_menu.addAction(about_action) + help_menu.addSeparator() + + start_text_tutorial_action = QAction("Start Tutorial (Text)", self) + start_text_tutorial_action.triggered.connect(self.start_text_tutorial) + help_menu.addAction(start_text_tutorial_action) + + start_interactive_tutorial_action = QAction("Start Tutorial (Interactive)", self) + start_interactive_tutorial_action.triggered.connect(self.start_interactive_tutorial) + help_menu.addAction(start_interactive_tutorial_action) + def show_about_dialog(self): QMessageBox.about( self, diff --git a/src/aare/gui/tutorials/tutorial_manager.py b/src/aare/gui/tutorials/tutorial_manager.py new file mode 100644 index 00000000..ea9bb43f --- /dev/null +++ b/src/aare/gui/tutorials/tutorial_manager.py @@ -0,0 +1,404 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import List, Dict, Callable, Optional + +from PySide6.QtCore import ( + Qt, + QRect, + QPoint, + QPropertyAnimation, + QObject, + Signal, + QEasingCurve, + Property, + QTimer, +) +from PySide6.QtGui import ( + QColor, QPainter, QPen +) +from PySide6.QtWidgets import ( + QWidget, QLabel, QPushButton +) + + +@dataclass +class TutorialStep: + widget: QWidget + text: str + wait_for_click: bool = False # If true, wait for user clicking the highlight area + + # 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 + + +class TutorialOverlay(QWidget): + """Visual layer: draws highlight, handles animations, shows callouts.""" + + animation_finished = Signal() # Visual-only + proceed_requested = Signal() # Next / Finish / click highlight + cancelled = Signal() # End tutorial + + def __init__(self, parent=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) + + self.current_rect = QRect() + 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.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; + border: 2px solid #555; + border-radius: 8px; + font-size: 13px; + """) + self.callout.setWordWrap(True) + self.callout.hide() + + button_style = """ + QPushButton { + font-size: 14px; + font-weight: 600; + padding: 10px 16px; + } + """ + + # Next/Finish button + 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.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) + self.end_button.clicked.connect(self.cancelled.emit) + self.end_button.hide() + + self.hide() + + def get_dummy(self) -> float: + return float(self._dummy) + + 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): + super().showEvent(event) + if self.parentWidget() is not None: + self.setGeometry(self.parentWidget().rect()) + self._position_end_button() + + def resizeEvent(self, event): + super().resizeEvent(event) + if self.parentWidget() is not None: + self.setGeometry(self.parentWidget().rect()) + self._position_end_button() + + 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): + if event.key() == Qt.Key_Escape: + self.cancelled.emit() + event.accept() + return + super().keyPressEvent(event) + + def mousePressEvent(self, event): + if self._waiting_for_click and self.current_rect.isValid(): + if self.current_rect.contains(event.pos()): + self.proceed_requested.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): + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(0, 0, 0, int(150 * self.opacity))) + + if self.current_rect.isValid(): + pen = QPen(Qt.yellow, 4) + painter.setPen(pen) + painter.setBrush(Qt.NoBrush) + painter.drawRoundedRect(self.current_rect, 8, 8) + + def _interpolate_rect(self, 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.""" + + def __init__(self, parent_window): + super().__init__() + 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._timeout_timer = QTimer(self) + self._timeout_timer.setSingleShot(True) + self._timeout_timer.timeout.connect(self.stop) + + self._condition_poll_timer = QTimer(self) + self._condition_poll_timer.setSingleShot(False) + self._condition_poll_timer.timeout.connect(self._check_condition_and_advance) + + self._condition_timeout_timer = QTimer(self) + self._condition_timeout_timer.setSingleShot(True) + self._condition_timeout_timer.timeout.connect(self._condition_timed_out) + + self.overlay.proceed_requested.connect(self._next_step_logic) + self.overlay.cancelled.connect(self.stop) + + def add_tutorial(self, name: str, steps: List[TutorialStep]): + self.tutorials[name] = steps + + def start(self, name: str, timeout_ms: int | None = None): + if name not in self.tutorials: + print("Tutorial not found:", name) + return + + self.current_tutorial = self.tutorials[name] + self.index = -1 + + self.overlay.setGeometry(self.window.rect()) + self.overlay.show() + self.overlay.raise_() + 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._stop_condition_wait() + self._next_step_logic() + + def stop(self): + self._timeout_timer.stop() + self._stop_condition_wait() + 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() + + 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() + return + + step = self.current_tutorial[self.index] + cond = step.wait_for_condition + if cond is None: + self._stop_condition_wait() + return + + 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 + + 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: + 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() + + # Next step + self.index += 1 + if self.index >= len(self.current_tutorial): + self.stop() + 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)) + 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 diff --git a/src/aare/gui/tutorials/tutorial_registration.py b/src/aare/gui/tutorials/tutorial_registration.py new file mode 100644 index 00000000..b296c53f --- /dev/null +++ b/src/aare/gui/tutorials/tutorial_registration.py @@ -0,0 +1,53 @@ +# 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 + + +def register_tutorials(window, tutorial_manager: TutorialManager) -> None: + """ + Register all GUI tutorials. + + 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)