Tutorials second draft - added models, split functions across multiple scripts. WIP

This commit is contained in:
2026-03-10 15:40:10 +01:00
parent b3ca9dbc30
commit 52bf82d098
8 changed files with 1316 additions and 305 deletions
+541 -252
View File
@@ -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"<b>{view.title}</b>")
if view.body.strip():
text_parts.append(view.body)
if view.hint:
text_parts.append(f"<span style='color:#555;'><i>{view.hint}</i></span>")
self.callout.setText("<br><br>".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"))
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)