Source code for simvx.editor.welcome

"""WelcomeScreen — Entry point for the SimVX editor.

Shows recent projects, template selection, and new/open project actions.
Emits ``project_opened`` or ``skipped`` to transition to the editor.
"""


from __future__ import annotations

import importlib.metadata
import logging
from datetime import UTC, datetime
from pathlib import Path

from simvx.core import (
    Button,
    FileDialog,
    Label,
    Node,
    Panel,
    ScrollContainer,
    Signal,
    TextEdit,
    VBoxContainer,
    Vec2,
)
from simvx.core.ui.menu import MenuItem, PopupMenu
from simvx.core.ui.theme import get_theme

from .project_registry import ProjectRegistry, RecentProject
from .templates import PROJECT_TEMPLATES, generate_project
from .theme import ACCENT_ACTIVE

log = logging.getLogger(__name__)

# Layout
_HEADER_H = 72.0
_SIDEBAR_W = 260.0
_PAD = 14.0
_CARD_H = 48.0
_CARD_GAP = 4.0
_TEMPLATE_CARD_H = 48.0
_OVERLAY_W = 440.0
_OVERLAY_H = 260.0


def _get_version() -> str:
    try:
        return "v" + importlib.metadata.version("simvx-editor")
    except importlib.metadata.PackageNotFoundError:
        return "v0.1.0-dev"


class _HoverPanel(Panel):
    """Panel with hover highlight and child clipping."""

    def __init__(self, normal_bg=None, hover_bg=None, **kwargs):
        super().__init__(**kwargs)
        t = get_theme()
        self._normal_bg = normal_bg or t.panel_bg
        self._hover_bg = hover_bg or t.hover_bg
        self.bg_colour = self._normal_bg
        self.mouse_filter = True

    def _draw_recursive(self, renderer):
        if not self.visible:
            return
        self.bg_colour = self._hover_bg if self.mouse_over else self._normal_bg
        self.draw(renderer)
        x, y, w, h = self.get_global_rect()
        renderer.push_clip(int(x), int(y), int(w), int(h))
        for child in self.children.safe_iter():
            child._draw_recursive(renderer)
        renderer.pop_clip()


class _Divider(Panel):
    """Thin vertical or horizontal divider line."""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.bg_colour = get_theme().border
        self.border_width = 0


[docs] class WelcomeScreen(Node): """Welcome screen shown at editor launch. Signals: project_opened(path: str) — user selected or created a project skipped() — user clicked Skip """ project_opened = Signal() skipped = Signal() def __init__(self, **kwargs): super().__init__(name="WelcomeScreen", **kwargs) self._registry = ProjectRegistry() self._selected_template: str | None = None self._template_cards: dict[str, _HoverPanel] = {} self._project_cards: list[tuple[_HoverPanel, RecentProject]] = [] self._overlay: Panel | None = None self._file_dialog: FileDialog | None = None self._error_label: Label | None = None self._name_input: TextEdit | None = None self._location_input: TextEdit | None = None self._tmpl_label: Label | None = None self._new_btn: Button | None = None self._context_menu: PopupMenu | None = None self._win_w = 1024.0 self._win_h = 680.0 self._error_clear_timer = 0.0
[docs] def ready(self): self._registry.load() self._registry.refresh() if self._tree: ss = self._tree.screen_size self._win_w = float(ss[0]) if not hasattr(ss, "x") else float(ss.x) self._win_h = float(ss[1]) if not hasattr(ss, "y") else float(ss.y) self._tree._shortcut_handler = self._handle_shortcut self._build_ui()
def _exit_tree(self): if self._tree and self._tree._shortcut_handler == self._handle_shortcut: self._tree._shortcut_handler = None super()._exit_tree()
[docs] def process(self, dt: float): if self._tree: ss = self._tree.screen_size w = float(ss.x) if hasattr(ss, "x") else float(ss[0]) h = float(ss.y) if hasattr(ss, "y") else float(ss[1]) if w != self._win_w or h != self._win_h: self._win_w, self._win_h = w, h self._resize() if self._error_clear_timer > 0: self._error_clear_timer -= dt if self._error_clear_timer <= 0 and self._error_label: self._error_label.visible = False
# ------------------------------------------------------------------ # Keyboard shortcuts # ------------------------------------------------------------------ def _handle_shortcut(self, key: str) -> bool: if self._overlay and self._overlay.visible: if key == "escape": self._on_cancel_overlay() return True if key == "enter": self._on_create_project() return True return False # ------------------------------------------------------------------ # UI construction # ------------------------------------------------------------------ def _build_ui(self): t = get_theme() w, h = self._win_w, self._win_h root = self.add_child(Panel(name="WelcomeRoot")) root.bg_colour = t.bg_darker root.border_width = 0 root.size = Vec2(w, h) self._root = root self._build_header(root, w) body_y = _HEADER_H body_h = h - _HEADER_H main_w = w - _SIDEBAR_W # Vertical divider between main and sidebar divider = root.add_child(_Divider(name="VDivider")) divider.size = Vec2(1, body_h) divider.position = Vec2(main_w, body_y) self._build_main_area(root, 0, body_y, main_w, body_h) self._build_sidebar(root, main_w + 1, body_y, _SIDEBAR_W - 1, body_h) # Default-select "Empty" template if "Empty" in self._template_cards: self._select_template("Empty") # Dialogs (hidden) self._build_new_project_overlay(root) self._file_dialog = FileDialog(name="WelcomeFileDialog") self._file_dialog.file_selected.connect(self._on_open_dir_selected) root.add_child(self._file_dialog) self._browse_dialog = FileDialog(name="BrowseDialog") self._browse_dialog.file_selected.connect(self._on_browse_location_selected) root.add_child(self._browse_dialog) self._context_menu = PopupMenu(name="ProjectContextMenu") root.add_child(self._context_menu) def _build_header(self, parent: Panel, w: float): t = get_theme() header = parent.add_child(Panel(name="Header")) header.bg_colour = t.bg_dark header.border_width = 0 header.size = Vec2(w, _HEADER_H) header.position = Vec2(0, 0) # Accent bar at top accent_bar = header.add_child(Panel(name="AccentBar")) accent_bar.bg_colour = t.accent accent_bar.border_width = 0 accent_bar.size = Vec2(w, 2) accent_bar.position = Vec2(0, 0) title = header.add_child(Label("SimVX Engine", name="Title")) title.font_size = 18.0 title.text_colour = t.text_bright title.alignment = "center" title.size = Vec2(w, 26) title.position = Vec2(0, 10) subtitle = header.add_child(Label("Pure Python Game Engine", name="Subtitle")) subtitle.font_size = 10.0 subtitle.text_colour = t.text_muted subtitle.alignment = "center" subtitle.size = Vec2(w, 14) subtitle.position = Vec2(0, 36) version = header.add_child(Label(_get_version(), name="Version")) version.font_size = 9.0 version.text_colour = t.text_muted version.alignment = "center" version.size = Vec2(w, 12) version.position = Vec2(0, 52) # Bottom border hline = header.add_child(_Divider(name="HeaderLine")) hline.size = Vec2(w, 1) hline.position = Vec2(0, _HEADER_H - 1) def _build_sidebar(self, parent: Panel, x: float, y: float, w: float, h: float): t = get_theme() sidebar = parent.add_child(Panel(name="Sidebar")) sidebar.bg_colour = t.bg_dark sidebar.border_width = 0 sidebar.size = Vec2(w, h) sidebar.position = Vec2(x, y) self._sidebar = sidebar pad = _PAD cy = pad # -- ACTIONS section -- lbl = sidebar.add_child(Label("ACTIONS", name="ActionsLabel")) lbl.font_size = 10.0 lbl.text_colour = t.text_muted lbl.position = Vec2(pad, cy) cy += 18 btn_w = w - pad * 2 self._new_btn = sidebar.add_child( Button("+ New Project", on_press=self._on_new_project, name="NewProjectBtn") ) self._new_btn.size = Vec2(btn_w, 26) self._new_btn.position = Vec2(pad, cy) self._new_btn.font_size = 12.0 self._new_btn.bg_colour = t.accent self._new_btn.hover_colour = ACCENT_ACTIVE self._new_btn.border_width = 0 cy += 32 open_btn = sidebar.add_child( Button("Open Project", on_press=self._on_open_project, name="OpenProjectBtn") ) open_btn.size = Vec2(btn_w, 26) open_btn.position = Vec2(pad, cy) open_btn.font_size = 12.0 open_btn.bg_colour = t.btn_bg open_btn.hover_colour = t.btn_hover open_btn.border_width = 0 cy += 38 # -- TEMPLATES section -- sep = sidebar.add_child(_Divider(name="TemplateSep")) sep.size = Vec2(btn_w, 1) sep.position = Vec2(pad, cy) cy += 10 lbl2 = sidebar.add_child(Label("TEMPLATES", name="TemplatesLabel")) lbl2.font_size = 10.0 lbl2.text_colour = t.text_muted lbl2.position = Vec2(pad, cy) cy += 18 card_w = btn_w for tname, tmpl in PROJECT_TEMPLATES.items(): card = sidebar.add_child(_HoverPanel(name=f"Tmpl_{tname}")) card.border_width = 0 card.corner_radius = 3 card.size = Vec2(card_w, _TEMPLATE_CARD_H) card.position = Vec2(pad, cy) name_lbl = card.add_child(Label(tname, name="TmplName")) name_lbl.font_size = 12.0 name_lbl.text_colour = t.text_bright name_lbl.position = Vec2(8, 4) name_lbl.size = Vec2(card_w - 16, 18) # Truncate description to fit card width (~5px/char at font_size 9) desc_text = tmpl.description max_chars = int((card_w - 20) / 5.0) if len(desc_text) > max_chars: desc_text = desc_text[: max_chars - 2] + ".." desc_lbl = card.add_child(Label(desc_text, name="TmplDesc")) desc_lbl.font_size = 9.0 desc_lbl.text_colour = t.text_muted desc_lbl.position = Vec2(8, 24) desc_lbl.size = Vec2(card_w - 16, 14) card._welcome_template = tname def _make_card_input(template_name): def handler(event): if event.button == 1 and event.pressed: self._select_template(template_name) return handler card._on_gui_input = _make_card_input(tname) self._template_cards[tname] = card cy += _TEMPLATE_CARD_H + _CARD_GAP # Skip at bottom skip = sidebar.add_child(Button("Skip", on_press=self._on_skip, name="SkipBtn")) skip.size = Vec2(btn_w, 24) skip.position = Vec2(pad, h - 36) skip.font_size = 11.0 skip.bg_colour = (0, 0, 0, 0) skip.hover_colour = t.btn_hover skip.text_colour = t.text_muted skip.border_width = 0 def _build_main_area(self, parent: Panel, x: float, y: float, w: float, h: float): t = get_theme() main = parent.add_child(Panel(name="MainArea")) main.bg_colour = t.bg_darker main.border_width = 0 main.size = Vec2(w, h) main.position = Vec2(x, y) self._main = main pad = _PAD # Header row header_lbl = main.add_child(Label("RECENT PROJECTS", name="RecentHeader")) header_lbl.font_size = 9.0 header_lbl.text_colour = t.text_muted header_lbl.position = Vec2(pad, pad) refresh_btn = main.add_child( Button("Refresh", on_press=self._on_refresh, name="RefreshBtn") ) refresh_btn.size = Vec2(54, 20) refresh_btn.position = Vec2(w - pad - 54, pad - 2) refresh_btn.bg_colour = t.btn_bg refresh_btn.hover_colour = t.btn_hover refresh_btn.text_colour = t.text_label refresh_btn.border_width = 0 refresh_btn.font_size = 9.0 # Scroll area scroll_y = pad + 24 self._scroll = main.add_child(ScrollContainer(name="RecentScroll")) self._scroll.size = Vec2(w - pad * 2, h - scroll_y - 40) self._scroll.position = Vec2(pad, scroll_y) self._scroll.bg_colour = t.bg_darker self._projects_vbox = self._scroll.add_child(VBoxContainer(name="ProjectsVBox")) self._projects_vbox.separation = _CARD_GAP self._projects_vbox.size = Vec2(w - pad * 2, 0) # Error label self._error_label = main.add_child(Label("", name="ErrorLabel")) self._error_label.font_size = 11.0 self._error_label.text_colour = t.error self._error_label.position = Vec2(pad, h - 32) self._error_label.visible = False self._populate_recent() def _populate_recent(self): for card, _ in self._project_cards: self._projects_vbox.remove_child(card) self._project_cards.clear() # Remove previous empty state labels for attr in ("_empty_title", "_empty_hint"): lbl = getattr(self, attr, None) if lbl and lbl.parent: lbl.parent.remove_child(lbl) if not self._registry.recent: t = get_theme() main_w = self._main.size.x main_h = self._main.size.y cy = main_h * 0.38 self._empty_title = self._main.add_child(Label("No Recent Projects", name="EmptyTitle")) self._empty_title.font_size = 13.0 self._empty_title.text_colour = t.text_label self._empty_title.alignment = "center" self._empty_title.size = Vec2(main_w, 20) self._empty_title.position = Vec2(0, cy) self._empty_hint = self._main.add_child(Label( "Create a new project or open an existing one to get started.", name="EmptyHint", )) self._empty_hint.font_size = 10.0 self._empty_hint.text_colour = t.text_muted self._empty_hint.alignment = "center" self._empty_hint.size = Vec2(main_w, 16) self._empty_hint.position = Vec2(0, cy + 22) return card_w = self._scroll.size.x - 16 for entry in self._registry.recent: card = self._make_project_card(entry, card_w) self._projects_vbox.add_child(card) self._project_cards.append((card, entry)) def _make_project_card(self, entry: RecentProject, width: float) -> _HoverPanel: t = get_theme() card = _HoverPanel(name=f"Proj_{entry.name}") card.border_width = 0 card.corner_radius = 4 card.size = Vec2(width, _CARD_H) card.clip_children = True right_col = 100 left_w = width - right_col - 20 # Row 1: name + badge name_lbl = card.add_child(Label(entry.name, name="ProjName")) name_lbl.font_size = 12.0 name_lbl.text_colour = t.text_bright name_lbl.position = Vec2(10, 4) name_lbl.size = Vec2(left_w, 18) badge = card.add_child(Label(entry.template_type, name="Badge")) badge.font_size = 9.0 badge.text_colour = t.accent badge.position = Vec2(width - right_col, 6) badge.size = Vec2(right_col - 10, 14) # Row 2: path + time display_path = entry.path.replace(str(Path.home()), "~") if len(display_path) > 55: display_path = "..." + display_path[-52:] path_lbl = card.add_child(Label(display_path, name="ProjPath")) path_lbl.font_size = 9.0 path_lbl.text_colour = t.text_muted path_lbl.position = Vec2(10, 24) path_lbl.size = Vec2(left_w, 14) time_str = _relative_time(entry.last_opened) time_lbl = card.add_child(Label(time_str, name="ProjTime")) time_lbl.font_size = 9.0 time_lbl.text_colour = t.text_muted time_lbl.position = Vec2(width - right_col, 24) time_lbl.size = Vec2(right_col - 10, 14) def _make_handler(project_entry): def handler(event): if event.button == 1 and not event.pressed: self._open_recent(project_entry) elif event.button == 3 and event.pressed: self._show_project_context_menu(project_entry, event.position) return handler card._on_gui_input = _make_handler(entry) return card def _build_new_project_overlay(self, parent: Panel): t = get_theme() w, h = self._win_w, self._win_h overlay = parent.add_child(Panel(name="NewProjectOverlay")) overlay.bg_colour = (0.0, 0.0, 0.0, 0.5) overlay.border_width = 0 overlay.size = Vec2(w, h) overlay.position = Vec2(0, 0) overlay.visible = False self._overlay = overlay dx = (w - _OVERLAY_W) / 2 dy = (h - _OVERLAY_H) / 2 dialog = overlay.add_child(Panel(name="NewProjectDialog")) dialog.bg_colour = t.bg_dark dialog.border_colour = t.border dialog.border_width = 1 dialog.corner_radius = 4 dialog.size = Vec2(_OVERLAY_W, _OVERLAY_H) dialog.position = Vec2(dx, dy) self._dialog = dialog pad = 16.0 inner_w = _OVERLAY_W - pad * 2 cy = pad title = dialog.add_child(Label("New Project", name="OverlayTitle")) title.font_size = 14.0 title.text_colour = t.text_bright title.position = Vec2(pad, cy) cy += 28 # Name row lbl_w = 60 dialog.add_child(Label("Name:", name="NameLabel")).position = Vec2(pad, cy + 3) self._name_input = dialog.add_child(TextEdit(name="NameInput")) self._name_input.text = "My Project" self._name_input.size = Vec2(inner_w - lbl_w, 24) self._name_input.position = Vec2(pad + lbl_w, cy) cy += 32 # Location row browse_w = 56 dialog.add_child(Label("Location:", name="LocLabel")).position = Vec2(pad, cy + 3) self._location_input = dialog.add_child(TextEdit(name="LocationInput")) self._location_input.text = str(Path.home() / "SimVX Projects") self._location_input.size = Vec2(inner_w - lbl_w - browse_w - 6, 24) self._location_input.position = Vec2(pad + lbl_w, cy) browse_btn = dialog.add_child( Button("Browse", on_press=self._on_browse_location, name="BrowseBtn") ) browse_btn.size = Vec2(browse_w, 24) browse_btn.position = Vec2(_OVERLAY_W - pad - browse_w, cy) browse_btn.font_size = 11.0 browse_btn.bg_colour = t.btn_bg browse_btn.hover_colour = t.btn_hover browse_btn.border_width = 0 cy += 32 # Template display dialog.add_child(Label("Template:", name="TmplDispLabel")).position = Vec2(pad, cy + 3) self._tmpl_label = dialog.add_child(Label("(none)", name="TmplDisp")) self._tmpl_label.text_colour = t.accent self._tmpl_label.position = Vec2(pad + lbl_w, cy + 3) cy += 32 # Button row btn_y = _OVERLAY_H - pad - 26 create_btn = dialog.add_child( Button("Create", on_press=self._on_create_project, name="CreateBtn") ) create_btn.size = Vec2(76, 26) create_btn.position = Vec2(_OVERLAY_W - pad - 158, btn_y) create_btn.font_size = 12.0 create_btn.bg_colour = t.accent create_btn.hover_colour = ACCENT_ACTIVE create_btn.border_width = 0 cancel_btn = dialog.add_child( Button("Cancel", on_press=self._on_cancel_overlay, name="CancelBtn") ) cancel_btn.size = Vec2(76, 26) cancel_btn.position = Vec2(_OVERLAY_W - pad - 76, btn_y) cancel_btn.font_size = 12.0 cancel_btn.bg_colour = t.btn_bg cancel_btn.hover_colour = t.btn_hover cancel_btn.border_width = 0 # ------------------------------------------------------------------ # Resize # ------------------------------------------------------------------ def _resize(self): saved_template = self._selected_template overlay_visible = self._overlay.visible if self._overlay else False name_text = self._name_input.text if self._name_input else "My Project" location_text = self._location_input.text if self._location_input else str(Path.home() / "SimVX Projects") saved_error_timer = self._error_clear_timer if hasattr(self, "_root"): self.remove_child(self._root) self._template_cards.clear() self._project_cards.clear() self._build_ui() if saved_template: self._select_template(saved_template) if overlay_visible and self._overlay: self._overlay.visible = True if self._tmpl_label and saved_template: self._tmpl_label.text = saved_template if self._name_input: self._name_input.text = name_text if self._location_input: self._location_input.text = location_text self._error_clear_timer = saved_error_timer # ------------------------------------------------------------------ # Template selection # ------------------------------------------------------------------ def _select_template(self, name: str): t = get_theme() self._selected_template = name for tname, card in self._template_cards.items(): if tname == name: card._normal_bg = t.accent card.bg_colour = t.accent # Brighten text on selected card for child in card.children.safe_iter(): if child.name == "TmplName": child.text_colour = (1.0, 1.0, 1.0, 1.0) elif child.name == "TmplDesc": child.text_colour = (0.9, 0.9, 1.0, 0.85) else: card._normal_bg = t.panel_bg card.bg_colour = t.panel_bg for child in card.children.safe_iter(): if child.name == "TmplName": child.text_colour = t.text_bright elif child.name == "TmplDesc": child.text_colour = t.text_muted # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def _on_new_project(self): if not self._selected_template: self._show_error("Select a template first.") return if self._tmpl_label: self._tmpl_label.text = self._selected_template if self._overlay: self._overlay.visible = True def _on_open_project(self): if self._file_dialog: self._file_dialog.show(mode="open_folder") def _on_skip(self): self.skipped.emit() self._transition_to_editor(None) def _on_refresh(self): self._registry.refresh() self._populate_recent() def _on_create_project(self): if not self._selected_template or not self._name_input or not self._location_input: return name = self._name_input.text.strip() location = self._location_input.text.strip() if not name: self._show_error("Project name cannot be empty.") return if not location: self._show_error("Location cannot be empty.") return try: project_dir = generate_project(self._selected_template, name, location) except Exception as exc: self._show_error(f"Failed to create project: {exc}") return self._registry.add(str(project_dir)) self._registry.save() if self._overlay: self._overlay.visible = False self.project_opened.emit(str(project_dir)) self._transition_to_editor(str(project_dir)) def _on_cancel_overlay(self): if self._overlay: self._overlay.visible = False def _on_browse_location(self): if self._browse_dialog: self._browse_dialog.show(mode="open_folder") def _on_browse_location_selected(self, path: str): if self._location_input: self._location_input.text = path def _on_open_dir_selected(self, path: str): d = Path(path) if not d.is_dir(): d = d.parent if d == Path.home(): self._show_error("No project selected.") return if not ProjectRegistry.has_project_file(d): self._show_error(f"No project.simvx found in {d}") return self._registry.add(str(d)) self._registry.save() self.project_opened.emit(str(d)) self._transition_to_editor(str(d)) def _open_recent(self, entry: RecentProject): d = Path(entry.path) if not d.is_dir(): self._show_error(f"Project directory not found: {d}") return if not ProjectRegistry.has_project_file(d): self._show_error(f"No project file found in {d}") return self._registry.add(entry.path) self._registry.save() self.project_opened.emit(entry.path) self._transition_to_editor(entry.path) # ------------------------------------------------------------------ # Context menu # ------------------------------------------------------------------ def _show_project_context_menu(self, entry: RecentProject, position): if not self._context_menu: return px = position.x if hasattr(position, "x") else position[0] py = position.y if hasattr(position, "y") else position[1] self._context_menu.items = [ MenuItem("Open", callback=lambda: self._open_recent(entry)), MenuItem(separator=True), MenuItem("Remove from Recent", callback=lambda: self._remove_recent(entry)), ] self._context_menu.show(px, py) def _remove_recent(self, entry: RecentProject): self._registry.remove(entry.path) self._registry.save() self._populate_recent() # ------------------------------------------------------------------ # Transition # ------------------------------------------------------------------ def _transition_to_editor(self, project_path: str | None): """Swap scene to EditorShell.""" if not self._tree: return platform_win = getattr(self._tree, "_platform_window", None) if platform_win: try: import glfw glfw.set_window_size(platform_win, 1600, 900) glfw.set_window_title(platform_win, "SimVX Editor") except Exception: pass from .app import EditorShell self._tree.change_scene(EditorShell(project_path=project_path)) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _show_error(self, msg: str): if self._error_label: self._error_label.text = msg self._error_label.visible = True self._error_clear_timer = 4.0
def _relative_time(iso_str: str) -> str: """Convert ISO 8601 timestamp to relative time string.""" try: dt = datetime.fromisoformat(iso_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) delta = datetime.now(UTC) - dt secs = int(delta.total_seconds()) if secs < 60: return "just now" if secs < 3600: m = secs // 60 return f"{m}m ago" if secs < 86400: h = secs // 3600 return f"{h}h ago" days = secs // 86400 return f"{days}d ago" except (ValueError, TypeError): return ""