Tutorials: added the option to build tutorials for the GUI. actual tutorials are a WIP.
This commit is contained in:
@@ -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"))
|
||||
Reference in New Issue
Block a user