"""First-Run Hints — Contextual hints and interactive tour for new users."""
import logging
from simvx.core import Property, Signal
from simvx.core.ui.core import Control
log = logging.getLogger(__name__)
# Hint text constants
_HINT_BG = (0.12, 0.14, 0.18, 0.92)
_HINT_BORDER = (0.3, 0.5, 0.8, 0.8)
_HINT_TEXT = (0.88, 0.9, 0.94, 1.0)
_HINT_DIM = (0.5, 0.55, 0.65, 1.0)
_HINT_PADDING = 12.0
_HINT_H = 36.0
_DISMISS_DELAY = 8.0 # seconds before auto-dismiss
[docs]
class HintOverlay(Control):
"""Shows contextual hints for first-time users.
Displays a small overlay bar at the bottom of the parent with helpful
tips based on editor context (empty scene tree, first play, etc.).
Each hint is shown at most once per session.
Usage::
hints = HintOverlay()
parent.add_child(hints)
hints.show_hint("scene_tree_empty") # Shows: "Add a node with the + button or Ctrl+A"
hints.dismiss() # Manually dismiss current hint
"""
HINTS: dict[str, str] = {
"scene_tree_empty": "Add a node with the + button or Ctrl+A",
"no_project": "Create a new project with File > New Project",
"first_play": "Press F5 to play your scene",
"first_save": "Save your scene with Ctrl+S",
"script_attach": "Attach a script to a node via the Inspector",
"keyboard_nav": "Use Tab / Shift+Tab to cycle between panels",
}
enabled = Property(True, hint="Show hints to new users")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._shown_hints: set[str] = set()
self._active_hint: str | None = None
self._dismiss_timer: float = 0.0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
[docs]
def show_hint(self, key: str) -> bool:
"""Show a hint by key, if it hasn't been shown before.
Returns True if the hint was shown, False if already dismissed
or unknown key.
"""
if not self.enabled:
return False
if key in self._shown_hints:
return False
if key not in self.HINTS:
log.warning("Unknown hint key: %s", key)
return False
self._active_hint = key
self._dismiss_timer = 0.0
self.visible = True
self.queue_redraw()
return True
[docs]
def dismiss(self) -> None:
"""Dismiss the current hint, marking it as shown."""
if self._active_hint:
self._shown_hints.add(self._active_hint)
self._active_hint = None
self.visible = False
self.queue_redraw()
[docs]
@property
def active_hint(self) -> str | None:
"""Return the currently displayed hint key, or None."""
return self._active_hint
[docs]
@property
def active_hint_text(self) -> str | None:
"""Return the currently displayed hint text, or None."""
if self._active_hint:
return self.HINTS.get(self._active_hint)
return None
[docs]
@property
def shown_hints(self) -> set[str]:
"""Return the set of hint keys that have been shown/dismissed."""
return set(self._shown_hints)
[docs]
def reset(self) -> None:
"""Reset all shown hints so they can appear again."""
self._shown_hints.clear()
self._active_hint = None
self.visible = False
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
[docs]
def process(self, dt: float) -> None:
"""Auto-dismiss hints after a timeout."""
if self._active_hint:
self._dismiss_timer += dt
if self._dismiss_timer >= _DISMISS_DELAY:
self.dismiss()
# ------------------------------------------------------------------
# Input — click to dismiss
# ------------------------------------------------------------------
def _on_gui_input(self, event) -> None:
if self._active_hint and getattr(event, "pressed", False):
self.dismiss()
# ------------------------------------------------------------------
# Drawing
# ------------------------------------------------------------------
[docs]
def draw(self, renderer) -> None:
if not self._active_hint:
return
text = self.HINTS.get(self._active_hint, "")
if not text:
return
ps = self._get_parent_size()
pw, ph = ps.x, ps.y
# Position at the bottom-center of the parent
bar_w = min(pw - 40, 500.0)
bar_x = (pw - bar_w) / 2
bar_y = ph - _HINT_H - 16.0
# Background + border
renderer.draw_rect((bar_x, bar_y), (bar_w, _HINT_H), colour=_HINT_BG, filled=True)
renderer.draw_rect((bar_x, bar_y), (bar_w, _HINT_H), colour=_HINT_BORDER)
# Hint text
renderer.draw_text(text, (bar_x + _HINT_PADDING, bar_y + 10.0), colour=_HINT_TEXT, scale=0.85)
# Dismiss instruction
dismiss_text = "Click to dismiss"
renderer.draw_text(
dismiss_text, (bar_x + bar_w - 130, bar_y + 10.0), colour=_HINT_DIM, scale=0.7
)
# ---------------------------------------------------------------------------
# Tour colours
# ---------------------------------------------------------------------------
_TOUR_BG = (0.08, 0.10, 0.15, 0.95)
_TOUR_BORDER = (0.3, 0.6, 0.9, 0.9)
_TOUR_TITLE = (0.95, 0.95, 1.0, 1.0)
_TOUR_TEXT = (0.8, 0.85, 0.9, 1.0)
_TOUR_BTN = (0.2, 0.45, 0.8, 1.0)
_TOUR_BTN_HOVER = (0.3, 0.55, 0.9, 1.0)
_TOUR_STEP_DIM = (0.5, 0.55, 0.65, 1.0)
_TOUR_STEPS = [
("Scene Tree", "The Scene Tree shows your node hierarchy. Add, remove, rename, and rearrange nodes here."),
("Viewport", "The Viewport renders your scene. Use the toolbar to select, move, rotate, and scale nodes."),
("Inspector", "The Inspector shows properties of the selected node. Edit values, attach scripts, and configure nodes."),
("Code Editor", "Open Python files to edit directly. LSP provides completions and diagnostics."),
("Play Controls", "Press Play (F5) to run your scene. Pause (F7) and Stop (F6) to control execution."),
("Output Panel", "Errors, warnings, and build output appear here. Click tracebacks to jump to source."),
]
[docs]
class TourGuide(Control):
"""Interactive first-run tour that walks through editor panels.
Displays a sequence of steps with Next/Back/Skip navigation. Each step
describes a panel and its purpose. The tour is triggered on first launch
and stored in preferences once completed.
Usage::
tour = TourGuide()
parent.add_child(tour)
tour.start()
tour.tour_completed.connect(on_tour_done)
"""
tour_completed = Signal()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._step = 0
self._active = False
self._btn_rects: list[tuple[float, float, float, float, str]] = [] # x, y, w, h, action
# Must cover the full window to intercept clicks
self.mouse_filter = "stop"
[docs]
def start(self) -> None:
"""Begin the tour from step 0."""
self._step = 0
self._active = True
self.visible = True
# Size to cover parent fully so we receive input events
ps = self._get_parent_size()
self.size = ps
self.queue_redraw()
[docs]
def stop(self) -> None:
"""End the tour (skip or finish)."""
self._active = False
self.visible = False
self.tour_completed.emit()
self.queue_redraw()
[docs]
@property
def is_active(self) -> bool:
return self._active
def _next(self) -> None:
if self._step < len(_TOUR_STEPS) - 1:
self._step += 1
self.queue_redraw()
else:
self.stop()
def _back(self) -> None:
if self._step > 0:
self._step -= 1
self.queue_redraw()
def _on_gui_input(self, event) -> None:
if not self._active:
return
if not getattr(event, "pressed", False):
return
# event.position is in screen coordinates; button rects are in parent-relative coords
pos = getattr(event, "position", None)
if pos is None:
return
mx, my = float(pos[0]), float(pos[1])
# Offset by our global position to get local coords
gx, gy, _, _ = self.get_global_rect()
lx, ly = mx - gx, my - gy
for bx, by, bw, bh, action in self._btn_rects:
if bx <= lx <= bx + bw and by <= ly <= by + bh:
if action == "next":
self._next()
elif action == "back":
self._back()
elif action == "skip":
self.stop()
return
[docs]
def process(self, dt: float) -> None:
"""Keep size synced to parent so we always intercept input."""
if self._active:
ps = self._get_parent_size()
if ps[0] > 0 and ps[1] > 0:
self.size = ps
@staticmethod
def _wrap_text(renderer, text: str, max_width: float, scale: float) -> list[str]:
"""Word-wrap *text* into lines that fit within *max_width* pixels."""
words = text.split()
if not words:
return [""]
lines: list[str] = []
current = words[0]
for word in words[1:]:
candidate = current + " " + word
if renderer.text_width(candidate, scale) <= max_width:
current = candidate
else:
lines.append(current)
current = word
lines.append(current)
return lines
[docs]
def draw(self, renderer) -> None:
if not self._active or self._step >= len(_TOUR_STEPS):
return
title, text = _TOUR_STEPS[self._step]
ps = self._get_parent_size()
pw, ph = float(ps[0]), float(ps[1])
# Centre card — height adapts to wrapped body text
card_w = min(pw - 60, 480.0)
text_area_w = card_w - 32 # 16px padding each side
body_lines = self._wrap_text(renderer, text, text_area_w, 0.8)
body_line_h = 18.0
body_h = len(body_lines) * body_line_h
# header (step + title) = 58px, body, gap, buttons = 36px, bottom pad = 8px
card_h = 58.0 + body_h + 44.0
card_x = (pw - card_w) / 2
card_y = (ph - card_h) / 2
renderer.draw_rect((card_x, card_y), (card_w, card_h), colour=_TOUR_BG, filled=True)
renderer.draw_rect((card_x, card_y), (card_w, card_h), colour=_TOUR_BORDER)
# Step counter
step_text = f"Step {self._step + 1} of {len(_TOUR_STEPS)}"
renderer.draw_text(step_text, (card_x + 16, card_y + 12), colour=_TOUR_STEP_DIM, scale=0.7)
# Title
renderer.draw_text(title, (card_x + 16, card_y + 32), colour=_TOUR_TITLE, scale=1.1)
# Body text (wrapped)
body_y = card_y + 58.0
for line in body_lines:
renderer.draw_text(line, (card_x + 16, body_y), colour=_TOUR_TEXT, scale=0.8)
body_y += body_line_h
# Buttons
self._btn_rects.clear()
btn_y = card_y + card_h - 36
btn_h = 24.0
# Skip
skip_x = card_x + 16
renderer.draw_rect((skip_x, btn_y), (50, btn_h), colour=(0.3, 0.3, 0.35, 0.8), filled=True)
renderer.draw_text("Skip", (skip_x + 10, btn_y + 5), colour=_TOUR_STEP_DIM, scale=0.8)
self._btn_rects.append((skip_x, btn_y, 50, btn_h, "skip"))
# Back (if not first step)
if self._step > 0:
back_x = card_x + card_w - 130
renderer.draw_rect((back_x, btn_y), (50, btn_h), colour=_TOUR_BTN, filled=True)
renderer.draw_text("Back", (back_x + 10, btn_y + 5), colour=_TOUR_TITLE, scale=0.8)
self._btn_rects.append((back_x, btn_y, 50, btn_h, "back"))
# Next / Finish
next_x = card_x + card_w - 70
next_label = "Finish" if self._step == len(_TOUR_STEPS) - 1 else "Next"
renderer.draw_rect((next_x, btn_y), (54, btn_h), colour=_TOUR_BTN, filled=True)
renderer.draw_text(next_label, (next_x + 8, btn_y + 5), colour=_TOUR_TITLE, scale=0.8)
self._btn_rects.append((next_x, btn_y, 54, btn_h, "next"))