Source code for simvx.editor.export_dialog

"""Export dialog -- modal UI for launching project exports.

Launched from File > Export Project... (Ctrl+E). Swaps between
target-specific sub-forms (Desktop / Web / Standalone Exe / Nuitka /
Android), persists settings in ``simvx.toml``, and drives
:func:`simvx.editor.export_controller.run_export`.
"""

import logging
import shutil
import sys
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import (
    Button,
    CheckBox,
    DropDown,
    HBoxContainer,
    Label,
    Panel,
    ProgressBar,
    Signal,
    TextEdit,
    VBoxContainer,
    Vec2,
)
from simvx.core.project import (
    ProjectSettings,
    find_project,
    load_project,
    save_project,
)
from simvx.web.export import DEFAULT_PYODIDE_VERSION

from . import export_controller
from .export_controller import (
    ExportProgress,
    ExportRequest,
    ExportTarget,
    detect_host_os,
    load_request_from_toml,
    save_request_to_toml,
)

if TYPE_CHECKING:
    from .state import State

log = logging.getLogger(__name__)

__all__ = ["ExportDialog"]

# Display labels for targets (dropdown order mirrors ExportTarget enum logical order)
_TARGETS: list[tuple[ExportTarget, str]] = [
    (ExportTarget.DESKTOP_WHEEL, "Desktop Wheel"),
    (ExportTarget.DESKTOP_FOLDER, "Desktop Folder"),
    (ExportTarget.WEB_HTML, "Web HTML"),
    (ExportTarget.STANDALONE_EXE, "Standalone Executable"),
    (ExportTarget.STANDALONE_NUITKA, "Nuitka (coming soon)"),
    (ExportTarget.ANDROID_APK, "Android APK"),
]

def _labeled_field(label: str, widget, label_w: float = 130.0) -> HBoxContainer:
    """Build an inline label + widget row."""
    row = HBoxContainer()
    row.separation = 8
    lbl = Label(label)
    lbl.size = Vec2(label_w, 28)
    lbl.text_colour = (0.75, 0.75, 0.78, 1.0)
    row.add_child(lbl)
    widget.size = Vec2(260, 28)
    row.add_child(widget)
    return row

[docs] class ExportDialog(Panel): """Modal export dialog. Signals: closed: emitted when the dialog hides. exported: emitted with the final :class:`ExportProgress` when an export run completes. """ DIALOG_W = 620.0 DIALOG_H = 560.0 def __init__(self, state: State | None = None, **kwargs): super().__init__(**kwargs) self.state = state self.bg_colour = (0.0, 0.0, 0.0, 0.6) self.border_width = 0 self.visible = False self.z_index = 1600 self.closed = Signal() self.exported = Signal() # Target form panels keyed by target self._forms: dict[ExportTarget, Panel] = {} self._target: ExportTarget = ExportTarget.DESKTOP_FOLDER self._running: bool = False self._current_request: ExportRequest | None = None # UI refs self._inner: Panel | None = None self._target_dropdown: DropDown | None = None self._package_edit: TextEdit | None = None self._version_edit: TextEdit | None = None self._output_edit: TextEdit | None = None self._message_label: Label | None = None self._progress_row: HBoxContainer | None = None self._progress_bar: ProgressBar | None = None self._progress_label: Label | None = None self._validate_btn: Button | None = None self._export_btn: Button | None = None self._close_btn: Button | None = None # Per-form widget refs (populated in _build_* helpers) self._desktop_mode_dd: DropDown | None = None self._desktop_os_dd: DropDown | None = None self._desktop_build_wheel_cb: CheckBox | None = None self._desktop_create_zip_cb: CheckBox | None = None self._desktop_icon_edit: TextEdit | None = None self._web_width_edit: TextEdit | None = None self._web_height_edit: TextEdit | None = None self._web_responsive_cb: CheckBox | None = None self._web_pyodide_edit: TextEdit | None = None self._web_packages_edit: TextEdit | None = None self._web_root_edit: TextEdit | None = None self._web_title_edit: TextEdit | None = None self._exe_onefile_cb: CheckBox | None = None self._exe_console_cb: CheckBox | None = None self._exe_name_edit: TextEdit | None = None self._exe_host_label: Label | None = None self._exe_warning: Label | None = None self._android_package_edit: TextEdit | None = None self._android_min_sdk_edit: TextEdit | None = None self._android_mode_dd: DropDown | None = None self._android_warning: Label | None = None self._build() # ------------------------------------------------------------------ # Build # ------------------------------------------------------------------ def _build(self): inner = Panel(name="ExportDialogInner") inner.bg_colour = (0.16, 0.16, 0.18, 1.0) inner.border_colour = (0.35, 0.35, 0.40, 1.0) inner.border_width = 1.0 inner.size = Vec2(self.DIALOG_W, self.DIALOG_H) self._inner = inner vbox = VBoxContainer(name="ExportDialogVBox") vbox.separation = 8 vbox.position = Vec2(16, 16) vbox.size = Vec2(self.DIALOG_W - 32, self.DIALOG_H - 32) inner.add_child(vbox) # Title title = Label("Export Project", name="Title") title.font_size = 16.0 title.text_colour = (0.95, 0.95, 0.97, 1.0) title.size = Vec2(self.DIALOG_W - 32, 24) vbox.add_child(title) # Project name (informational) self._project_label = Label("(no project)", name="ProjectName") self._project_label.font_size = 11.0 self._project_label.text_colour = (0.55, 0.55, 0.58, 1.0) self._project_label.size = Vec2(self.DIALOG_W - 32, 18) vbox.add_child(self._project_label) # Target row target_row = HBoxContainer(name="TargetRow") target_row.separation = 8 tlbl = Label("Target:") tlbl.size = Vec2(130, 28) tlbl.text_colour = (0.75, 0.75, 0.78, 1.0) target_row.add_child(tlbl) self._target_dropdown = DropDown(items=[lbl for _, lbl in _TARGETS], selected=1, name="TargetDropDown") self._target_dropdown.size = Vec2(260, 28) self._target_dropdown.item_selected.connect(self._on_target_selected) target_row.add_child(self._target_dropdown) vbox.add_child(target_row) # Common fields common = VBoxContainer(name="CommonFields") common.separation = 6 common.size = Vec2(self.DIALOG_W - 32, 120) self._package_edit = TextEdit(name="PackageNameEdit") common.add_child(_labeled_field("Package name", self._package_edit)) self._version_edit = TextEdit(name="VersionEdit") common.add_child(_labeled_field("Version", self._version_edit)) # Output dir row with Browse button out_row = HBoxContainer(name="OutputRow") out_row.separation = 8 out_lbl = Label("Output dir") out_lbl.size = Vec2(130, 28) out_lbl.text_colour = (0.75, 0.75, 0.78, 1.0) out_row.add_child(out_lbl) self._output_edit = TextEdit(name="OutputDirEdit") self._output_edit.size = Vec2(200, 28) out_row.add_child(self._output_edit) browse = Button("Browse...", name="BrowseBtn") browse.size = Vec2(80, 28) browse.pressed.connect(self._on_browse_output) out_row.add_child(browse) common.add_child(out_row) vbox.add_child(common) # Target form holder self._form_holder = Panel(name="TargetForm") self._form_holder.bg_colour = (0.13, 0.13, 0.15, 1.0) self._form_holder.border_width = 0 self._form_holder.size = Vec2(self.DIALOG_W - 32, 220) self._build_forms() vbox.add_child(self._form_holder) # Message area self._message_label = Label("", name="MessageArea") self._message_label.font_size = 11.0 self._message_label.text_colour = (0.9, 0.4, 0.4, 1.0) self._message_label.size = Vec2(self.DIALOG_W - 32, 40) vbox.add_child(self._message_label) # Progress row self._progress_row = HBoxContainer(name="ProgressRow") self._progress_row.separation = 8 self._progress_bar = ProgressBar(0, 100, name="ExportProgressBar") self._progress_bar.size = Vec2(240, 18) self._progress_row.add_child(self._progress_bar) self._progress_label = Label("", name="ProgressLabel") self._progress_label.size = Vec2(260, 18) self._progress_label.text_colour = (0.75, 0.75, 0.78, 1.0) self._progress_row.add_child(self._progress_label) self._progress_row.visible = False self._progress_row.size = Vec2(self.DIALOG_W - 32, 24) vbox.add_child(self._progress_row) # Button row btns = HBoxContainer(name="ButtonRow") btns.separation = 8 self._validate_btn = Button("Validate", name="ValidateBtn") self._validate_btn.size = Vec2(100, 32) self._validate_btn.pressed.connect(self._on_validate) btns.add_child(self._validate_btn) self._export_btn = Button("Export", name="ExportBtn") self._export_btn.size = Vec2(100, 32) self._export_btn.bg_colour = (0.20, 0.57, 0.92, 1.0) self._export_btn.pressed.connect(self._on_export) btns.add_child(self._export_btn) self._close_btn = Button("Close", name="CloseBtn") self._close_btn.size = Vec2(100, 32) self._close_btn.bg_colour = (0.30, 0.30, 0.33, 1.0) self._close_btn.pressed.connect(self._on_close) btns.add_child(self._close_btn) vbox.add_child(btns) self.add_child(inner) self._set_target(ExportTarget.DESKTOP_FOLDER) # ---- Target-specific sub-forms ----------------------------------- def _build_forms(self): self._forms[ExportTarget.DESKTOP_WHEEL] = self._build_desktop_form() self._forms[ExportTarget.DESKTOP_FOLDER] = self._forms[ExportTarget.DESKTOP_WHEEL] self._forms[ExportTarget.WEB_HTML] = self._build_web_form() self._forms[ExportTarget.STANDALONE_EXE] = self._build_exe_form() self._forms[ExportTarget.STANDALONE_NUITKA] = self._build_nuitka_form() self._forms[ExportTarget.ANDROID_APK] = self._build_android_form() for form in set(self._forms.values()): form.visible = False self._form_holder.add_child(form) def _build_desktop_form(self) -> Panel: form = Panel(name="DesktopForm") form.border_width = 0 vbox = VBoxContainer() vbox.separation = 6 vbox.position = Vec2(8, 8) vbox.size = Vec2(self.DIALOG_W - 48, 200) self._desktop_mode_dd = DropDown(items=["Folder", "Wheel"], selected=0, name="DesktopModeDD") self._desktop_mode_dd.item_selected.connect(self._on_desktop_mode_changed) vbox.add_child(_labeled_field("Mode", self._desktop_mode_dd)) self._desktop_os_dd = DropDown(items=["linux", "windows", "macos"], selected=0, name="DesktopOsDD") vbox.add_child(_labeled_field("OS label", self._desktop_os_dd)) self._desktop_build_wheel_cb = CheckBox("Build wheel (uv build)", name="BuildWheelCheckBox") vbox.add_child(_labeled_field("", self._desktop_build_wheel_cb)) self._desktop_create_zip_cb = CheckBox("Create ZIP", name="CreateZipCheckBox") vbox.add_child(_labeled_field("", self._desktop_create_zip_cb)) self._desktop_icon_edit = TextEdit(name="IconPathEdit") vbox.add_child(_labeled_field("Icon path", self._desktop_icon_edit)) form.add_child(vbox) form.size = Vec2(self.DIALOG_W - 32, 200) return form def _build_web_form(self) -> Panel: form = Panel(name="WebForm") form.border_width = 0 vbox = VBoxContainer() vbox.separation = 6 vbox.position = Vec2(8, 8) vbox.size = Vec2(self.DIALOG_W - 48, 200) self._web_width_edit = TextEdit(name="WebWidthEdit") vbox.add_child(_labeled_field("Width", self._web_width_edit)) self._web_height_edit = TextEdit(name="WebHeightEdit") vbox.add_child(_labeled_field("Height", self._web_height_edit)) self._web_responsive_cb = CheckBox("Responsive", name="ResponsiveCheckBox") vbox.add_child(_labeled_field("", self._web_responsive_cb)) self._web_pyodide_edit = TextEdit(name="PyodideVersionEdit") vbox.add_child(_labeled_field("Pyodide version", self._web_pyodide_edit)) self._web_packages_edit = TextEdit(name="ExtraPackagesEdit") vbox.add_child(_labeled_field("Extra packages (,)", self._web_packages_edit)) self._web_root_edit = TextEdit(name="RootClassEdit") vbox.add_child(_labeled_field("Root class", self._web_root_edit)) self._web_title_edit = TextEdit(name="WebTitleEdit") vbox.add_child(_labeled_field("Title", self._web_title_edit)) info = Label("Output is a single .html file.", name="WebInfo") info.font_size = 10.0 info.text_colour = (0.55, 0.55, 0.58, 1.0) info.size = Vec2(self.DIALOG_W - 48, 18) vbox.add_child(info) form.add_child(vbox) form.size = Vec2(self.DIALOG_W - 32, 220) return form def _build_exe_form(self) -> Panel: form = Panel(name="ExeForm") form.border_width = 0 vbox = VBoxContainer() vbox.separation = 6 vbox.position = Vec2(8, 8) vbox.size = Vec2(self.DIALOG_W - 48, 200) self._exe_host_label = Label(f"Host OS: {detect_host_os()}", name="ExeHostLabel") self._exe_host_label.font_size = 12.0 self._exe_host_label.text_colour = (0.85, 0.85, 0.88, 1.0) self._exe_host_label.size = Vec2(self.DIALOG_W - 48, 20) vbox.add_child(self._exe_host_label) self._exe_onefile_cb = CheckBox("Onefile", name="ExeOnefileCheckBox", checked=True) vbox.add_child(_labeled_field("", self._exe_onefile_cb)) self._exe_console_cb = CheckBox("Console", name="ExeConsoleCheckBox") vbox.add_child(_labeled_field("", self._exe_console_cb)) self._exe_name_edit = TextEdit(name="ExeNameEdit") vbox.add_child(_labeled_field("Executable name", self._exe_name_edit)) info = Label( "PyInstaller cannot cross-compile. Build on Linux/Windows/macOS " "respectively for that platform's binary.", name="ExeInfo", ) info.font_size = 10.0 info.text_colour = (0.55, 0.55, 0.58, 1.0) info.size = Vec2(self.DIALOG_W - 48, 18) vbox.add_child(info) self._exe_warning = Label("", name="ExeWarning") self._exe_warning.font_size = 11.0 self._exe_warning.text_colour = (0.95, 0.75, 0.30, 1.0) self._exe_warning.size = Vec2(self.DIALOG_W - 48, 18) vbox.add_child(self._exe_warning) form.add_child(vbox) form.size = Vec2(self.DIALOG_W - 32, 220) return form def _build_nuitka_form(self) -> Panel: form = Panel(name="NuitkaForm") form.border_width = 0 lbl = Label("Nuitka target not yet implemented.", name="NuitkaNotice") lbl.font_size = 13.0 lbl.text_colour = (0.85, 0.45, 0.45, 1.0) lbl.position = Vec2(16, 40) lbl.size = Vec2(self.DIALOG_W - 64, 22) form.add_child(lbl) form.size = Vec2(self.DIALOG_W - 32, 220) return form def _build_android_form(self) -> Panel: form = Panel(name="AndroidForm") form.border_width = 0 vbox = VBoxContainer() vbox.separation = 6 vbox.position = Vec2(8, 8) vbox.size = Vec2(self.DIALOG_W - 48, 200) self._android_package_edit = TextEdit(name="AndroidPackageEdit") vbox.add_child(_labeled_field("Package (reverse DNS)", self._android_package_edit)) self._android_min_sdk_edit = TextEdit(name="AndroidMinSdkEdit") vbox.add_child(_labeled_field("Min SDK", self._android_min_sdk_edit)) self._android_mode_dd = DropDown(items=["debug", "release"], selected=0, name="AndroidModeDD") vbox.add_child(_labeled_field("Build mode", self._android_mode_dd)) info = Label( "Requires buildozer + Android SDK/NDK + glslc.", name="AndroidInfo", ) info.font_size = 10.0 info.text_colour = (0.55, 0.55, 0.58, 1.0) info.size = Vec2(self.DIALOG_W - 48, 18) vbox.add_child(info) self._android_warning = Label("", name="AndroidWarning") self._android_warning.font_size = 11.0 self._android_warning.text_colour = (0.95, 0.75, 0.30, 1.0) self._android_warning.size = Vec2(self.DIALOG_W - 48, 18) vbox.add_child(self._android_warning) form.add_child(vbox) form.size = Vec2(self.DIALOG_W - 32, 220) return form # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def show_dialog(self, parent_size: Vec2 | None = None): """Show and center the dialog; populate fields from simvx.toml.""" self.visible = True if parent_size: self.size = parent_size if self._inner: pw = self.size.x if self.size.x > 0 else 1280 ph = self.size.y if self.size.y > 0 else 720 self._inner.position = Vec2((pw - self.DIALOG_W) / 2, (ph - self.DIALOG_H) / 2) self._populate_from_settings() self._update_button_states() self._progress_row.visible = False self._message_label.text = "" self.queue_redraw()
[docs] def hide_dialog(self): self.visible = False self._running = False self.closed.emit()
# ------------------------------------------------------------------ # Populate / serialise # ------------------------------------------------------------------ def _project_dir(self) -> Path: if self.state is not None and getattr(self.state, "project_path", None): return Path(self.state.project_path) return Path.cwd() def _toml_path(self) -> Path: project = self._project_dir() toml = project / "simvx.toml" if toml.is_file(): return toml found = find_project(project) return found if found is not None else toml def _load_settings(self) -> ProjectSettings: toml = self._toml_path() if toml.is_file(): try: return load_project(toml) except Exception: log.exception("Failed to load %s", toml) s = ProjectSettings() s.project_path = str(toml) return s def _populate_from_settings(self): settings = self._load_settings() project = self._project_dir() self._project_label.text = f"Project: {settings.name} ({project})" req = load_request_from_toml(self._target, settings, project) self._apply_request_to_form(req) self._update_warnings() def _apply_request_to_form(self, req: ExportRequest): self._package_edit.text = req.package_name self._version_edit.text = req.version self._output_edit.text = str(req.output_dir) # Desktop self._desktop_mode_dd.selected_index = 1 if self._target == ExportTarget.DESKTOP_WHEEL else 0 os_idx = ["linux", "windows", "macos"].index(req.os_label) if req.os_label in ("linux", "windows", "macos") else 0 self._desktop_os_dd.selected_index = os_idx self._desktop_build_wheel_cb.checked = req.build_wheel self._desktop_create_zip_cb.checked = req.create_zip self._desktop_icon_edit.text = req.icon_path or "" # Web self._web_width_edit.text = str(req.web_width) self._web_height_edit.text = str(req.web_height) self._web_responsive_cb.checked = req.web_responsive self._web_pyodide_edit.text = req.pyodide_version self._web_packages_edit.text = ",".join(req.extra_packages) self._web_root_edit.text = req.root_class or "" self._web_title_edit.text = req.title # Exe self._exe_onefile_cb.checked = req.exe_onefile self._exe_console_cb.checked = req.exe_console self._exe_name_edit.text = req.exe_name self._exe_host_label.text = f"Host OS: {req.exe_host_os}" # Android self._android_package_edit.text = req.android_package self._android_min_sdk_edit.text = str(req.android_min_sdk) a_idx = 1 if req.android_mode == "release" else 0 self._android_mode_dd.selected_index = a_idx def _build_request(self) -> ExportRequest: project = self._project_dir() target = self._target # Desktop mode dropdown can override explicit target if target in (ExportTarget.DESKTOP_WHEEL, ExportTarget.DESKTOP_FOLDER): target = ( ExportTarget.DESKTOP_WHEEL if self._desktop_mode_dd.selected_index == 1 else ExportTarget.DESKTOP_FOLDER ) self._target = target extra = [s.strip() for s in self._web_packages_edit.text.split(",") if s.strip()] try: web_w = int(self._web_width_edit.text or "0") except ValueError: web_w = 0 try: web_h = int(self._web_height_edit.text or "0") except ValueError: web_h = 0 try: min_sdk = int(self._android_min_sdk_edit.text or "26") except ValueError: min_sdk = 26 out_text = self._output_edit.text.strip() or "dist" out_dir = Path(out_text) if not out_dir.is_absolute(): out_dir = project / out_dir return ExportRequest( target=target, project_dir=project, output_dir=out_dir, package_name=self._package_edit.text.strip(), version=self._version_edit.text.strip(), entry_point=(self.state.main if self.state and getattr(self.state, "main", None) else "main.py"), os_label=["linux", "windows", "macos"][self._desktop_os_dd.selected_index], build_wheel=self._desktop_build_wheel_cb.checked, create_zip=self._desktop_create_zip_cb.checked, icon_path=(self._desktop_icon_edit.text.strip() or None), web_width=web_w, web_height=web_h, web_responsive=self._web_responsive_cb.checked, pyodide_version=self._web_pyodide_edit.text.strip() or DEFAULT_PYODIDE_VERSION, extra_packages=extra, root_class=(self._web_root_edit.text.strip() or None), title=self._web_title_edit.text.strip() or "SimVX", exe_onefile=self._exe_onefile_cb.checked, exe_console=self._exe_console_cb.checked, exe_name=self._exe_name_edit.text.strip(), exe_host_os=detect_host_os(), android_package=self._android_package_edit.text.strip(), android_min_sdk=min_sdk, android_mode=["debug", "release"][self._android_mode_dd.selected_index], ) def _persist_request(self, req: ExportRequest): toml = self._toml_path() settings = self._load_settings() save_request_to_toml(req, settings) try: save_project(settings, toml) except Exception: log.exception("Failed to save %s", toml) # ------------------------------------------------------------------ # Target switching # ------------------------------------------------------------------ def _on_target_selected(self, idx: int): if 0 <= idx < len(_TARGETS): self._set_target(_TARGETS[idx][0]) self._populate_from_settings() def _set_target(self, target: ExportTarget): self._target = target # Hide all forms then show the active one for form in set(self._forms.values()): form.visible = False active_form = self._forms.get(target) if active_form is not None: active_form.visible = True # Keep dropdown in sync for i, (t, _) in enumerate(_TARGETS): if t == target: if self._target_dropdown: self._target_dropdown.selected_index = i break # Desktop: ensure mode dropdown reflects target if target == ExportTarget.DESKTOP_WHEEL and self._desktop_mode_dd: self._desktop_mode_dd.selected_index = 1 elif target == ExportTarget.DESKTOP_FOLDER and self._desktop_mode_dd: self._desktop_mode_dd.selected_index = 0 self._update_button_states() self._update_warnings() self._message_label.text = "" def _on_desktop_mode_changed(self, idx: int): self._target = ExportTarget.DESKTOP_WHEEL if idx == 1 else ExportTarget.DESKTOP_FOLDER for i, (t, _) in enumerate(_TARGETS): if t == self._target and self._target_dropdown: self._target_dropdown.selected_index = i break def _update_button_states(self): if self._export_btn: self._export_btn.disabled = ( self._running or self._target == ExportTarget.STANDALONE_NUITKA ) if self._close_btn: self._close_btn.text = "Cancel" if self._running else "Close" def _update_warnings(self): if self._exe_warning: self._exe_warning.text = ( "Warning: pyinstaller not found on PATH." if shutil.which("pyinstaller") is None else "" ) if self._android_warning: msgs: list[str] = [] if shutil.which("buildozer") is None: msgs.append("buildozer not found.") if sys.platform.startswith("win"): msgs.append("Android builds require a Linux or macOS host.") self._android_warning.text = " ".join(msgs) # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def _on_validate(self): req = self._build_request() errs = req.validate() if errs: self._message_label.text_colour = (0.9, 0.4, 0.4, 1.0) self._message_label.text = "; ".join(errs) if self._export_btn: self._export_btn.disabled = True else: self._message_label.text_colour = (0.45, 0.85, 0.45, 1.0) self._message_label.text = "Validation OK." if self._export_btn and self._target != ExportTarget.STANDALONE_NUITKA: self._export_btn.disabled = False def _on_browse_output(self): # FileDialog access via state shared dialog (open_folder mode) dlg = getattr(self.state, "_file_dialog", None) if self.state else None if dlg is None: return def _on_chosen(path): try: dlg.file_selected.disconnect(_on_chosen) except (RuntimeError, ValueError): log.debug("Signal already disconnected during export dialog teardown") if path: self._output_edit.text = str(path) dlg.file_selected.connect(_on_chosen) dlg.show(mode="open", path=self._output_edit.text or str(self._project_dir())) def _on_progress(self, progress: ExportProgress): self._progress_row.visible = True if progress.fraction < 0: # indeterminate — show bar at 50% self._progress_bar.value = 50 else: self._progress_bar.value = max(0.0, min(1.0, progress.fraction)) * 100 self._progress_label.text = progress.status self.queue_redraw() def _on_export(self): if self._running: return req = self._build_request() errs = req.validate() if errs: self._message_label.text_colour = (0.9, 0.4, 0.4, 1.0) self._message_label.text = "; ".join(errs) return # Persist config before running self._persist_request(req) self._current_request = req self._running = True self._update_button_states() self._progress_row.visible = True self._progress_bar.value = 0 self._progress_label.text = "Starting..." self._message_label.text = "" result = export_controller.run_export(req, self._on_progress) self._running = False self._update_button_states() if result.success: self._message_label.text_colour = (0.45, 0.85, 0.45, 1.0) self._message_label.text = f"Success: {result.result_path}" else: self._message_label.text_colour = (0.9, 0.4, 0.4, 1.0) self._message_label.text = f"Failed: {result.error}" self.exported.emit(result) def _on_close(self): self.hide_dialog() # ------------------------------------------------------------------ # Input # ------------------------------------------------------------------ def _on_gui_input(self, event): if hasattr(event, "key") and event.key == "escape" and event.pressed: self._on_close() return super()._on_gui_input(event)