Same mechanical swap for the widgets and the tutorial manager: painter QColors go through qcolor(), inline QSS literals become palette constants (baton dialog, busy overlay, status bar, camera overlays, splash screen, numeric inputs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
712 lines
24 KiB
Python
712 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from aarecommon.config.logger import setup_logger
|
|
from PySide6.QtCore import (
|
|
Property,
|
|
QEasingCurve,
|
|
QObject,
|
|
QPropertyAnimation,
|
|
QRect,
|
|
Qt,
|
|
QTimer,
|
|
Signal,
|
|
)
|
|
from PySide6.QtGui import QPainter, QPen
|
|
from PySide6.QtWidgets import QLabel, QPushButton, QWidget
|
|
|
|
from aare.gui.constants import LOGGER_NAME
|
|
from aare.gui.styles import (
|
|
DEFAULT_TEXT,
|
|
NOTE_TEXT,
|
|
SHADOW,
|
|
TUTORIAL_BORDER,
|
|
TUTORIAL_HIGHLIGHT,
|
|
WHITE,
|
|
qcolor,
|
|
)
|
|
from aare.gui.tutorials.tutorial_models import (
|
|
StepFlow,
|
|
StepStatus,
|
|
TutorialContext,
|
|
TutorialScenario,
|
|
TutorialStepDefinition,
|
|
)
|
|
from aare.gui.tutorials.tutorial_runtime import (
|
|
CompletionEvaluator,
|
|
ResolvedTutorialTarget,
|
|
TutorialActionExecutor,
|
|
TutorialEvent,
|
|
TutorialEventBus,
|
|
TutorialTargetResolver,
|
|
TutorialTextResolver,
|
|
build_runtime_state_for_scenario,
|
|
ensure_step_runtime_state,
|
|
)
|
|
|
|
logger = setup_logger(LOGGER_NAME)
|
|
|
|
|
|
def _now_ms() -> int:
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
@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):
|
|
"""
|
|
Presentation-only overlay.
|
|
|
|
It knows how to:
|
|
- dim the app
|
|
- highlight a target rect
|
|
- show tutorial copy
|
|
- expose buttons/signals
|
|
|
|
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)
|
|
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
|
|
self._dummy = 0.0
|
|
|
|
self.anim = QPropertyAnimation(self, b"dummy")
|
|
self.anim.valueChanged.connect(self.update)
|
|
|
|
self.callout = QLabel(self)
|
|
self.callout.setStyleSheet(f"""
|
|
background: {WHITE};
|
|
color: {DEFAULT_TEXT};
|
|
padding: 16px;
|
|
border: 2px solid {TUTORIAL_BORDER};
|
|
border-radius: 10px;
|
|
font-size: 16px;
|
|
""")
|
|
self.callout.setWordWrap(True)
|
|
self.callout.hide()
|
|
|
|
button_style = """
|
|
QPushButton {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
padding: 10px 16px;
|
|
}
|
|
"""
|
|
|
|
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.next_requested.emit)
|
|
self.next_button.hide()
|
|
|
|
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) -> None:
|
|
super().showEvent(event)
|
|
if self.parentWidget() is not None:
|
|
self.setGeometry(self.parentWidget().rect())
|
|
self._position_end_button()
|
|
|
|
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 keyPressEvent(self, event) -> None:
|
|
if event.key() == Qt.Key_Escape:
|
|
self.cancelled.emit()
|
|
event.accept()
|
|
return
|
|
super().keyPressEvent(event)
|
|
|
|
def mousePressEvent(self, event) -> None:
|
|
if (
|
|
self._waiting_for_click
|
|
and self.current_rect.isValid()
|
|
and self.current_rect.contains(event.pos())
|
|
):
|
|
self.highlight_clicked.emit()
|
|
event.accept()
|
|
return
|
|
super().mousePressEvent(event)
|
|
|
|
def paintEvent(self, event) -> None:
|
|
painter = QPainter(self)
|
|
painter.fillRect(self.rect(), qcolor(SHADOW, int(150 * self.opacity)))
|
|
|
|
if self.current_rect.isValid():
|
|
pen = QPen(qcolor(TUTORIAL_HIGHLIGHT), 4)
|
|
painter.setPen(pen)
|
|
painter.setBrush(Qt.NoBrush)
|
|
painter.drawRoundedRect(self.current_rect, 8, 8)
|
|
|
|
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:{NOTE_TEXT};'><i>{view.hint}</i></span>")
|
|
|
|
self.callout.setText("<br><br>".join(text_parts))
|
|
self.callout.setMaximumWidth(460)
|
|
self.callout.adjustSize()
|
|
|
|
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._position_callout(rect)
|
|
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))
|
|
|
|
|
|
class TutorialManager(QObject):
|
|
"""
|
|
Scenario-driven tutorial controller.
|
|
|
|
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.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.scenarios: dict[str, TutorialScenario] = {}
|
|
|
|
self.current_scenario: TutorialScenario | None = None
|
|
self.context: TutorialContext | None = None
|
|
self.runtime_state = None
|
|
self._current_resolved_target: ResolvedTutorialTarget | None = None
|
|
|
|
self._step_timeout_timer = QTimer(self)
|
|
self._step_timeout_timer.setSingleShot(True)
|
|
self._step_timeout_timer.timeout.connect(self._on_step_timeout)
|
|
|
|
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)
|
|
|
|
self.event_bus.event_emitted.connect(self._on_tutorial_event)
|
|
|
|
def add_scenario(self, scenario: TutorialScenario) -> None:
|
|
self.scenarios[scenario.id] = scenario
|
|
|
|
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()
|
|
self.overlay.raise_()
|
|
self.overlay.activateWindow()
|
|
self.overlay.setFocus()
|
|
|
|
self.tutorial_started.emit(scenario.id)
|
|
self._advance_to_index(0)
|
|
|
|
def stop(self) -> None:
|
|
scenario_id = self.current_scenario.id if self.current_scenario else None
|
|
|
|
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_scenario = None
|
|
self.context = None
|
|
self.runtime_state = None
|
|
self._current_resolved_target = None
|
|
|
|
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) -> TutorialStepDefinition | 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
|
|
|
|
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
|
|
|
|
new_index = max(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: TutorialStepDefinition) -> ResolvedTutorialTarget | None:
|
|
if step.target is None or self.context is None:
|
|
return None
|
|
try:
|
|
return self.target_resolver.resolve_target(step.target, self.context)
|
|
except Exception:
|
|
logger.debug("Could not resolve the tutorial step target", exc_info=True)
|
|
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: TutorialStepDefinition) -> 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: TutorialStepDefinition) -> 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
|
|
|
|
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.COMPLETED
|
|
step_state.completed_at_ms = _now_ms()
|
|
|
|
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.get_current_step()
|
|
if step is None:
|
|
return
|
|
|
|
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
|
|
|
|
target_index = self.runtime_state.active_step_index - 1
|
|
if target_index < 0:
|
|
return
|
|
|
|
self._advance_to_index(target_index)
|
|
|
|
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
|
|
) and self.runtime_state is not None:
|
|
self._advance_to_index(self.runtime_state.active_step_index + 1)
|
|
return
|
|
|
|
self.overlay.next_button.setEnabled(True)
|