Source code for simvx.editor.panels.export_panel

"""Export Panel -- Editor UI for managing export presets and building game packages.

Wraps the ``simvx.core.export`` module (ExportPreset, ProjectExporter) with
a two-pane editor: preset list on the left, preset property editor on the right.
Includes validation feedback, size estimation, and a progress dialog for builds.

Layout:
    +---------------------------------------------------------------+
    | Export Project                            [Validate] [Export]  |
    |---------------------------------------------------------------|
    | Presets          | Preset Properties                          |
    | [+ MyGame-Linux] |   Name      [MyGame-Linux            ]    |
    | [ ] MyGame-Win   |   Platform  [ linux                  v]   |
    |                  |   Entry     [ main.py            ] [...]   |
    | [+] [Dup] [-]   |   Version   [ 0.1.0                   ]   |
    |                  |   Include   [**/*.py, assets/**/*      ]   |
    |                  |   Exclude   [                          ]   |
    |                  |   Icon      [                     ] [...]   |
    |                  |   Wheel     [ ]                            |
    |                  |   Description [A SimVX game.           ]   |
    |                  |-------------------------------------------|
    |                  |   Size estimate: ~42.5 KB                  |
    |                  |   Status: Ready                            |
    +---------------------------------------------------------------+
"""


from __future__ import annotations

import logging
import threading
from pathlib import Path

from simvx.core import (
    Control,
    Signal,
    Vec2,
)
from simvx.core.export import PLATFORMS, ExportError, ExportMode, ExportPreset, ProjectExporter
from simvx.core.ui.theme import get_theme

log = logging.getLogger(__name__)

__all__ = ["ExportPanel", "ExportProgressDialog"]

# ============================================================================
# Theme-derived colour functions (call at draw time for live theme support)
# ============================================================================


def _BG():
    return get_theme().bg


def _HEADER_BG():
    return get_theme().bg_darker


def _LIST_BG():
    return get_theme().bg


def _LIST_SEL():
    return get_theme().selection_bg


def _LIST_HOVER():
    return get_theme().hover_bg


def _EDITOR_BG():
    return get_theme().bg_light


def _ROW_BG():
    return get_theme().bg_light


def _ROW_ALT():
    return get_theme().bg_lighter


def _TEXT():
    return get_theme().text


def _TEXT_DIM():
    return get_theme().text_dim


def _ACCENT():
    return get_theme().accent


def _SUCCESS():
    return get_theme().success


def _ERROR():
    return get_theme().error


def _WARNING():
    return get_theme().warning


def _BTN():
    return get_theme().btn_bg


def _BTN_HOVER():
    return get_theme().btn_hover


def _BTN_ACCENT():
    return get_theme().accent


def _SEPARATOR():
    return get_theme().border

_HEADER_HEIGHT = 32.0
_ROW_HEIGHT = 26.0
_PADDING = 6.0
_LABEL_WIDTH = 90.0
_LIST_WIDTH_RATIO = 0.28
_FONT = 11.0 / 14.0
_SMALL_FONT = 10.0 / 14.0

_PLATFORM_OPTIONS = sorted(PLATFORMS)
_EXPORT_MODE_OPTIONS = ["Folder", "Wheel"]


# ============================================================================
# ExportProgressDialog
# ============================================================================


[docs] class ExportProgressDialog(Control): """Modal dialog showing export progress with status text and progress bar. Attributes: on_cancel: Signal emitted when the user clicks Cancel. on_complete: Signal emitted when the export finishes (success: bool, message: str). """ def __init__(self, **kwargs): super().__init__(**kwargs) self.bg_colour = (0.0, 0.0, 0.0, 0.5) self.size = Vec2(400, 180) self.visible = False self.on_cancel = Signal() self.on_complete = Signal() self._progress: float = 0.0 self._status: str = "" self._finished = False self._success = False self._result_message: str = "" self._cancelled = False # ------------------------------------------------------------------ # # Public API # ------------------------------------------------------------------ #
[docs] def show_progress(self): """Show the dialog and reset state.""" self._progress = 0.0 self._status = "Preparing export..." self._finished = False self._success = False self._result_message = "" self._cancelled = False self.visible = True
[docs] def set_progress(self, fraction: float, status: str): """Update progress (0.0-1.0) and status text.""" self._progress = max(0.0, min(1.0, fraction)) self._status = status
[docs] def finish(self, success: bool, message: str): """Mark the export as finished.""" self._finished = True self._success = success self._progress = 1.0 if success else self._progress self._result_message = message self._status = message self.on_complete.emit(success, message)
[docs] def dismiss(self): """Hide the dialog.""" self.visible = False
# ------------------------------------------------------------------ # # Drawing # ------------------------------------------------------------------ #
[docs] def draw(self, renderer): if not self.visible: return gx, gy, gw, gh = self.get_global_rect() # Overlay backdrop renderer.draw_filled_rect(gx, gy, gw, gh, self.bg_colour) # Dialog box dw, dh = 380.0, 160.0 dx = gx + (gw - dw) / 2 dy = gy + (gh - dh) / 2 renderer.draw_filled_rect(dx, dy, dw, dh, (0.16, 0.16, 0.16, 1.0)) renderer.draw_rect(dx, dy, dw, dh, _SEPARATOR()) # Title title = "Export Complete" if self._finished else "Exporting..." renderer.draw_text(title, dx + _PADDING, dy + 8, _TEXT(), _FONT) renderer.draw_filled_rect(dx, dy + 28, dw, 1, _SEPARATOR()) # Status text colour = _TEXT_DIM() if self._finished: colour = _SUCCESS() if self._success else _ERROR() renderer.draw_text(self._status, dx + _PADDING, dy + 40, colour, _SMALL_FONT) # Progress bar background bar_x, bar_y, bar_w, bar_h = dx + _PADDING, dy + 65, dw - 2 * _PADDING, 16.0 renderer.draw_filled_rect(bar_x, bar_y, bar_w, bar_h, (0.10, 0.10, 0.10, 1.0)) # Progress bar fill fill_colour = _SUCCESS() if (self._finished and self._success) else _ACCENT() if self._progress > 0: renderer.draw_filled_rect(bar_x, bar_y, bar_w * self._progress, bar_h, fill_colour) # Progress percentage pct_text = f"{self._progress * 100:.0f}%" renderer.draw_text(pct_text, bar_x + bar_w / 2 - 10, bar_y + 2, _TEXT(), _SMALL_FONT) # Buttons btn_y = dy + dh - 36 if self._finished: # Close button self._draw_button(renderer, dx + dw - 80, btn_y, 70, 24, "Close", _BTN()) else: # Cancel button self._draw_button(renderer, dx + dw - 80, btn_y, 70, 24, "Cancel", _ERROR()) # Result details (if finished) if self._finished and self._result_message: detail = self._result_message[:60] renderer.draw_text(detail, dx + _PADDING, dy + 95, _TEXT_DIM(), _SMALL_FONT)
@staticmethod def _draw_button(renderer, x, y, w, h, text, bg): renderer.draw_filled_rect(x, y, w, h, bg) renderer.draw_rect(x, y, w, h, _SEPARATOR()) renderer.draw_text(text, x + 8, y + 5, _TEXT(), _SMALL_FONT) # ------------------------------------------------------------------ # # Input # ------------------------------------------------------------------ # def _on_gui_input(self, event): if not self.visible: return if not (hasattr(event, "pressed") and event.pressed and getattr(event, "button", 0) == 1): return gx, gy, gw, gh = self.get_global_rect() dw, dh = 380.0, 160.0 dx = gx + (gw - dw) / 2 dy = gy + (gh - dh) / 2 ex, ey = event.position btn_y = dy + dh - 36 btn_x = dx + dw - 80 if btn_x <= ex <= btn_x + 70 and btn_y <= ey <= btn_y + 24: if self._finished: self.dismiss() else: self._cancelled = True self.on_cancel.emit() self.finish(False, "Export cancelled by user.")
# ============================================================================ # ExportPanel # ============================================================================
[docs] class ExportPanel(Control): """Editor panel for managing export presets and building game packages. Two-pane layout: preset list (left), preset property editor (right). Wraps ``simvx.core.export.ProjectExporter`` for validation and export. Args: editor_state: The central EditorState (optional, used for project path). """ def __init__(self, editor_state=None, **kwargs): super().__init__(**kwargs) self.state = editor_state self.bg_colour = _BG() self.size = Vec2(700, 500) # Preset management self._presets: list[ExportPreset] = [ExportPreset()] self._selected_idx: int = 0 # Exporter self._exporter = ProjectExporter() # Validation self._validation_errors: list[str] = [] self._validation_warnings: list[str] = [] self._size_estimate: int | None = None # Scroll state self._scroll_y: float = 0.0 # Progress dialog self._progress_dialog = ExportProgressDialog(name="ExportProgress") self._export_thread: threading.Thread | None = None # Signals self.export_started = Signal() self.export_finished = Signal() # ==================================================================== # Preset management # ==================================================================== @property def selected_preset(self) -> ExportPreset | None: if 0 <= self._selected_idx < len(self._presets): return self._presets[self._selected_idx] return None
[docs] def add_preset(self, preset: ExportPreset | None = None): """Add a new export preset.""" p = preset or ExportPreset(name=f"Preset-{len(self._presets) + 1}") self._presets.append(p) self._selected_idx = len(self._presets) - 1 self._validate()
[docs] def duplicate_preset(self): """Duplicate the currently selected preset.""" src = self.selected_preset if not src: return from dataclasses import replace dup = replace(src, name=f"{src.name}-copy") self._presets.append(dup) self._selected_idx = len(self._presets) - 1 self._validate()
[docs] def remove_preset(self): """Remove the currently selected preset.""" if len(self._presets) <= 1: return # Keep at least one preset self._presets.pop(self._selected_idx) self._selected_idx = min(self._selected_idx, len(self._presets) - 1) self._validate()
[docs] def select_preset(self, index: int): """Select a preset by index.""" if 0 <= index < len(self._presets): self._selected_idx = index self._size_estimate = None self._validate()
# ==================================================================== # Validation / estimation # ==================================================================== def _validate(self): """Validate the selected preset and update error/warning lists.""" preset = self.selected_preset self._validation_errors.clear() self._validation_warnings.clear() if not preset: return try: project_dir = self._get_project_dir() warnings = self._exporter.validate_preset(preset, project_dir) self._validation_warnings = warnings except ExportError as exc: self._validation_errors.append(str(exc)) def _estimate_size(self): """Calculate approximate export size for the selected preset.""" preset = self.selected_preset project_dir = self._get_project_dir() if not preset or not project_dir: self._size_estimate = None return try: self._size_estimate = self._exporter.estimate_size(project_dir, preset) except ExportError: self._size_estimate = None def _get_project_dir(self) -> Path | None: """Resolve the project directory from editor state or cwd.""" if self.state and self.state.project_path: return Path(self.state.project_path) if self.state and self.state.current_scene_path: return Path(self.state.current_scene_path).parent return Path.cwd() # ==================================================================== # Export execution # ====================================================================
[docs] def start_export(self): """Begin exporting the selected preset in a background thread.""" preset = self.selected_preset project_dir = self._get_project_dir() if not preset or not project_dir: return self._validate() if self._validation_errors: return output_dir = project_dir / "dist" self._progress_dialog.show_progress() self.export_started.emit() def _run(): try: self._progress_dialog.set_progress(0.1, "Validating preset...") self._exporter.validate_preset(preset, project_dir) self._progress_dialog.set_progress(0.3, "Collecting assets...") # The exporter does collection internally, we just call export self._progress_dialog.set_progress(0.5, "Copying files and generating package...") result_path = self._exporter.export(project_dir, preset, output_dir) if preset.build_wheel: self._progress_dialog.set_progress(0.8, "Building wheel...") self._progress_dialog.finish(True, f"Exported to {result_path}") except ExportError as exc: self._progress_dialog.finish(False, str(exc)) except Exception as exc: log.exception("Export failed") self._progress_dialog.finish(False, f"Unexpected error: {exc}") self._export_thread = threading.Thread(target=_run, daemon=True) self._export_thread.start()
# ==================================================================== # Drawing # ====================================================================
[docs] def draw(self, renderer): gx, gy, gw, gh = self.get_global_rect() renderer.draw_filled_rect(gx, gy, gw, gh, _BG()) renderer.push_clip(gx, gy, gw, gh) y = gy y = self._draw_header(renderer, gx, y, gw) content_h = gh - (y - gy) list_w = gw * _LIST_WIDTH_RATIO editor_w = gw - list_w # Left: preset list self._draw_preset_list(renderer, gx, y, list_w, content_h) # Separator renderer.draw_filled_rect(gx + list_w, y, 1, content_h, _SEPARATOR()) # Right: preset editor renderer.push_clip(gx + list_w + 1, y, editor_w - 1, content_h) self._draw_preset_editor(renderer, gx + list_w + 1, y, editor_w - 1, content_h) renderer.pop_clip() # Progress dialog overlay if self._progress_dialog.visible: self._progress_dialog.size = Vec2(gw, gh) self._progress_dialog.position = Vec2(gx, gy) self._progress_dialog.draw(renderer) renderer.pop_clip()
def _draw_header(self, renderer, x, y, w) -> float: """Draw the title bar with Validate and Export buttons.""" renderer.draw_filled_rect(x, y, w, _HEADER_HEIGHT, _HEADER_BG()) renderer.draw_text("Export Project", x + _PADDING, y + 9, _TEXT(), _FONT) # Validate button vbtn_w = 65.0 vbtn_x = x + w - 2 * (vbtn_w + _PADDING) - _PADDING self._draw_button(renderer, vbtn_x, y + 4, vbtn_w, 24, "Validate", _BTN()) # Export button ebtn_x = x + w - vbtn_w - _PADDING has_errors = bool(self._validation_errors) ebtn_bg = _BTN() if has_errors else _BTN_ACCENT() self._draw_button(renderer, ebtn_x, y + 4, vbtn_w, 24, "Export", ebtn_bg) renderer.draw_filled_rect(x, y + _HEADER_HEIGHT - 1, w, 1, _SEPARATOR()) return y + _HEADER_HEIGHT def _draw_preset_list(self, renderer, x, y, w, h): """Draw the preset list panel.""" renderer.draw_filled_rect(x, y, w, h, _LIST_BG()) renderer.push_clip(x, y, w, h) cy = y + _PADDING renderer.draw_text("Presets", x + _PADDING, cy, _TEXT_DIM(), _SMALL_FONT) cy += 18 for i, preset in enumerate(self._presets): row_bg = _LIST_SEL() if i == self._selected_idx else _LIST_BG() renderer.draw_filled_rect(x + 2, cy, w - 4, _ROW_HEIGHT, row_bg) text_colour = _TEXT() if i == self._selected_idx else _TEXT_DIM() renderer.draw_text(preset.name, x + _PADDING + 4, cy + 5, text_colour, _SMALL_FONT) # Platform badge plat_text = preset.platform[:3].upper() renderer.draw_text(plat_text, x + w - 36, cy + 5, _ACCENT(), _SMALL_FONT) cy += _ROW_HEIGHT # Add / Duplicate / Remove buttons btn_y = y + h - 30 btn_w = 28.0 bx = x + _PADDING self._draw_button(renderer, bx, btn_y, btn_w, 22, "+", _BTN()) bx += btn_w + 4 self._draw_button(renderer, bx, btn_y, 36, 22, "Dup", _BTN()) bx += 40 self._draw_button(renderer, bx, btn_y, btn_w, 22, "-", _ERROR() if len(self._presets) > 1 else _BTN()) renderer.pop_clip() def _draw_preset_editor(self, renderer, x, y, w, h): """Draw the property editor for the selected preset.""" preset = self.selected_preset if not preset: renderer.draw_text("No preset selected", x + _PADDING, y + 20, _TEXT_DIM(), _FONT) return cy = y + _PADDING # Export Mode dropdown row mode_label = _EXPORT_MODE_OPTIONS[int(preset.export_mode)] cy = self._draw_field_row(renderer, x, cy, w, "Export Mode", mode_label, False) cy = self._draw_field_row(renderer, x, cy, w, "Name", preset.name, True) cy = self._draw_field_row(renderer, x, cy, w, "Platform", preset.platform, False) cy = self._draw_field_row(renderer, x, cy, w, "Entry Point", preset.entry_point, True) cy = self._draw_field_row(renderer, x, cy, w, "Version", preset.version, False) cy = self._draw_field_row(renderer, x, cy, w, "Include", ", ".join(preset.include_patterns), True) cy = self._draw_field_row(renderer, x, cy, w, "Exclude", ", ".join(preset.exclude_patterns) or "(none)", False) cy = self._draw_field_row(renderer, x, cy, w, "Icon", preset.icon_path or "(none)", True) cy = self._draw_field_row(renderer, x, cy, w, "Description", preset.description, False) # Build wheel checkbox row (only visible in wheel mode) if preset.export_mode == ExportMode.WHEEL: bg = _ROW_ALT() renderer.draw_filled_rect(x, cy, w, _ROW_HEIGHT, bg) renderer.draw_text("Build Wheel", x + _PADDING, cy + 6, _TEXT_DIM(), _SMALL_FONT) chk_text = "[x]" if preset.build_wheel else "[ ]" renderer.draw_text(chk_text, x + _LABEL_WIDTH, cy + 6, _TEXT(), _SMALL_FONT) cy += _ROW_HEIGHT # Create ZIP checkbox row (only visible in folder mode) if preset.export_mode == ExportMode.FOLDER: bg = _ROW_ALT() renderer.draw_filled_rect(x, cy, w, _ROW_HEIGHT, bg) renderer.draw_text("Create ZIP", x + _PADDING, cy + 6, _TEXT_DIM(), _SMALL_FONT) chk_text = "[x]" if preset.create_zip else "[ ]" renderer.draw_text(chk_text, x + _LABEL_WIDTH, cy + 6, _TEXT(), _SMALL_FONT) cy += _ROW_HEIGHT # Scene to Code checkbox row bg = _ROW_BG() renderer.draw_filled_rect(x, cy, w, _ROW_HEIGHT, bg) renderer.draw_text("Scene to Code", x + _PADDING, cy + 6, _TEXT_DIM(), _SMALL_FONT) chk_text = "[x]" if preset.scene_to_code else "[ ]" renderer.draw_text(chk_text, x + _LABEL_WIDTH, cy + 6, _TEXT(), _SMALL_FONT) cy += _ROW_HEIGHT # Separator renderer.draw_filled_rect(x, cy, w, 1, _SEPARATOR()) cy += _PADDING + 1 # Size estimate if self._size_estimate is not None: size_kb = self._size_estimate / 1024 if size_kb > 1024: size_text = f"~{size_kb / 1024:.1f} MB" else: size_text = f"~{size_kb:.1f} KB" renderer.draw_text(f"Estimated size: {size_text}", x + _PADDING, cy, _TEXT_DIM(), _SMALL_FONT) else: renderer.draw_text("Size: (click Validate to estimate)", x + _PADDING, cy, _TEXT_DIM(), _SMALL_FONT) cy += 16 # Package name renderer.draw_text(f"Package: {preset.package_name}", x + _PADDING, cy, _TEXT_DIM(), _SMALL_FONT) cy += 20 # Validation status if self._validation_errors: renderer.draw_text("Errors:", x + _PADDING, cy, _ERROR(), _SMALL_FONT) cy += 14 for err in self._validation_errors: renderer.draw_text(f" {err[:80]}", x + _PADDING, cy, _ERROR(), _SMALL_FONT) cy += 14 elif self._validation_warnings: renderer.draw_text("Warnings:", x + _PADDING, cy, _WARNING(), _SMALL_FONT) cy += 14 for warn in self._validation_warnings: renderer.draw_text(f" {warn[:80]}", x + _PADDING, cy, _WARNING(), _SMALL_FONT) cy += 14 else: renderer.draw_text("Status: Ready to export", x + _PADDING, cy, _SUCCESS(), _SMALL_FONT) def _draw_field_row(self, renderer, x, y, w, label: str, value: str, alt: bool) -> float: """Draw a single label-value row.""" bg = _ROW_ALT() if alt else _ROW_BG() renderer.draw_filled_rect(x, y, w, _ROW_HEIGHT, bg) renderer.draw_text(label, x + _PADDING, y + 6, _TEXT_DIM(), _SMALL_FONT) # Truncate long values to fit max_chars = int((w - _LABEL_WIDTH - _PADDING) / 6) display = value[:max_chars] + "..." if len(value) > max_chars else value renderer.draw_text(display, x + _LABEL_WIDTH, y + 6, _TEXT(), _SMALL_FONT) return y + _ROW_HEIGHT @staticmethod def _draw_button(renderer, x, y, w, h, text, bg): renderer.draw_filled_rect(x, y, w, h, bg) renderer.draw_rect(x, y, w, h, _SEPARATOR()) renderer.draw_text(text, x + 6, y + 4, _TEXT(), _SMALL_FONT) # ==================================================================== # Input handling # ==================================================================== def _on_gui_input(self, event): # Delegate to progress dialog first if visible if self._progress_dialog.visible: self._progress_dialog._on_gui_input(event) return if not (hasattr(event, "pressed") and event.pressed and getattr(event, "button", 0) == 1): # Handle scroll if hasattr(event, "delta"): _, dy = event.delta if isinstance(event.delta, tuple) else (0, event.delta) self._scroll_y = max(0.0, self._scroll_y - dy * 20.0) return gx, gy, gw, gh = self.get_global_rect() ex, ey = event.position if not (gx <= ex <= gx + gw and gy <= ey <= gy + gh): return # Header buttons vbtn_w = 65.0 header_bottom = gy + _HEADER_HEIGHT if gy + 4 <= ey <= gy + 28: # Validate button vbtn_x = gx + gw - 2 * (vbtn_w + _PADDING) - _PADDING if vbtn_x <= ex <= vbtn_x + vbtn_w: self._validate() self._estimate_size() return # Export button ebtn_x = gx + gw - vbtn_w - _PADDING if ebtn_x <= ex <= ebtn_x + vbtn_w: self.start_export() return list_w = gw * _LIST_WIDTH_RATIO # Preset list clicks if gx <= ex <= gx + list_w and ey >= header_bottom: list_top = header_bottom + _PADDING + 18 # Below "Presets" label btn_y = header_bottom + gh - _HEADER_HEIGHT - 30 # Preset item selection if list_top <= ey < btn_y: clicked_idx = int((ey - list_top) / _ROW_HEIGHT) if 0 <= clicked_idx < len(self._presets): self.select_preset(clicked_idx) return # Bottom buttons: Add / Dup / Remove if btn_y <= ey <= btn_y + 22: bx = gx + _PADDING if bx <= ex <= bx + 28: self.add_preset() return bx += 32 if bx <= ex <= bx + 36: self.duplicate_preset() return bx += 40 if bx <= ex <= bx + 28: self.remove_preset() return # Right side: build wheel checkbox toggle editor_x = gx + list_w + 1 if editor_x <= ex and ey >= header_bottom: # The build wheel row is the 9th row (0-indexed: 8) wheel_row_y = header_bottom + _PADDING + 8 * _ROW_HEIGHT if wheel_row_y <= ey <= wheel_row_y + _ROW_HEIGHT: preset = self.selected_preset if preset: preset.build_wheel = not preset.build_wheel # ==================================================================== # Serialization # ====================================================================
[docs] def get_presets_data(self) -> list[dict]: """Serialize all presets to a list of dicts for saving.""" from dataclasses import asdict result = [] for p in self._presets: d = asdict(p) d["export_mode"] = int(p.export_mode) result.append(d) return result
[docs] def load_presets_data(self, data: list[dict]): """Load presets from serialized data.""" self._presets.clear() for d in data: p = ExportPreset( name=d.get("name", "Unnamed"), platform=d.get("platform", "linux"), entry_point=d.get("entry_point", "main.py"), include_patterns=d.get("include_patterns", ["**/*.py", "assets/**/*", "scenes/**/*"]), exclude_patterns=d.get("exclude_patterns", []), icon_path=d.get("icon_path"), version=d.get("version", "0.1.0"), description=d.get("description", "A SimVX game."), custom_features=d.get("custom_features", {}), build_wheel=d.get("build_wheel", False), export_mode=ExportMode(d.get("export_mode", ExportMode.FOLDER)), create_zip=d.get("create_zip", False), scene_to_code=d.get("scene_to_code", False), ) self._presets.append(p) if not self._presets: self._presets.append(ExportPreset()) self._selected_idx = 0 self._validate()