Source code for simvx.editor.hints

"""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"))