Source code for simvx.editor.project_settings_dialog

"""Project Settings dialog — modal UI for editing ``simvx.toml``.

Launched from Edit > Project Settings... Exposes the runtime configuration
stored in :class:`ProjectSettings` (project name, display, physics,
audio, rendering, autoloads, editor plugins) as editable widgets. Input
actions live in a separate :class:`InputMapDialog` reachable via an
``Open Input Map...`` button.

Follows the :mod:`simvx.editor.export_dialog` pattern: a modal :class:`Panel`
overlay with a centred inner panel. Edits are applied to a local settings
copy and only flushed to disk when the user clicks Save.
"""

import copy
import logging
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import (
    Button,
    CheckBox,
    DropDown,
    HBoxContainer,
    Label,
    Panel,
    ScrollContainer,
    Signal,
    Slider,
    SpinBox,
    TextEdit,
    VBoxContainer,
    Vec2,
)
from simvx.core.project import (
    ProjectSettings,
    find_project,
    load_project,
    save_project,
)

if TYPE_CHECKING:
    from .state import State

log = logging.getLogger(__name__)

__all__ = ["ProjectSettingsDialog"]

_STRETCH_MODES = ["viewport", "canvas_items", "disabled"]
_STRETCH_ASPECTS = ["keep", "expand", "ignore"]
_BACKENDS = ["vulkan", "sdl3"]
_MSAA_VALUES = [0, 2, 4, 8]
_MSAA_LABELS = [str(v) for v in _MSAA_VALUES]

def _copy_settings(s: ProjectSettings) -> ProjectSettings:
    """Create a deep copy of a ProjectSettings for edit isolation."""
    data = copy.deepcopy(s.to_dict())
    clone = ProjectSettings(data)
    clone.project_path = s.project_path
    clone.autoloads = dict(s.autoloads)
    return clone

[docs] class ProjectSettingsDialog(Panel): """Modal Project Settings editor dialog.""" DIALOG_W = 620.0 DIALOG_H = 640.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.saved = Signal() self._settings: ProjectSettings | None = None self._saved_snapshot: ProjectSettings | None = None self._dirty: bool = False # UI refs self._inner: Panel | None = None self._project_label: Label | None = None self._message_label: Label | None = None self._confirm_overlay: Panel | None = None # Widget refs self._name_edit: TextEdit | None = None self._main_edit: TextEdit | None = None self._width_spin: SpinBox | None = None self._height_spin: SpinBox | None = None self._vsync_cb: CheckBox | None = None self._fullscreen_cb: CheckBox | None = None self._stretch_mode_dd: DropDown | None = None self._stretch_aspect_dd: DropDown | None = None self._fps_spin: SpinBox | None = None self._gravity_edit: TextEdit | None = None self._master_vol_slider: Slider | None = None self._backend_dd: DropDown | None = None self._msaa_dd: DropDown | None = None self._input_action_count_label: Label | None = None self._autoloads_list: VBoxContainer | None = None self._autoload_name_edit: TextEdit | None = None self._autoload_path_edit: TextEdit | None = None self._plugins_edit: TextEdit | None = None self._build() # ------------------------------------------------------------------ # Build UI # ------------------------------------------------------------------ def _build(self): inner = Panel(name="ProjectSettingsDialogInner") 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 root_vbox = VBoxContainer(name="ProjectSettingsVBox") root_vbox.separation = 8 root_vbox.position = Vec2(16, 16) root_vbox.size = Vec2(self.DIALOG_W - 32, self.DIALOG_H - 32) inner.add_child(root_vbox) title = Label("Project Settings", 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) root_vbox.add_child(title) self._project_label = Label("(no project)", name="ProjectPathLabel") 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) root_vbox.add_child(self._project_label) # Scrollable content scroll = ScrollContainer(name="SettingsScroll") scroll.size = Vec2(self.DIALOG_W - 32, 440) root_vbox.add_child(scroll) content = VBoxContainer(name="SettingsContent") content.separation = 6 content.size = Vec2(self.DIALOG_W - 56, 900) scroll.add_child(content) self._build_project_section(content) self._build_display_section(content) self._build_physics_section(content) self._build_audio_section(content) self._build_rendering_section(content) self._build_input_section(content) self._build_autoloads_section(content) self._build_editor_section(content) 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, 20) root_vbox.add_child(self._message_label) btns = HBoxContainer(name="ButtonRow") btns.separation = 8 btns.size = Vec2(self.DIALOG_W - 32, 36) save_btn = Button("Save", name="SaveBtn") save_btn.size = Vec2(100, 32) save_btn.bg_colour = (0.20, 0.57, 0.92, 1.0) save_btn.pressed.connect(self._on_save) btns.add_child(save_btn) revert_btn = Button("Revert", name="RevertBtn") revert_btn.size = Vec2(100, 32) revert_btn.pressed.connect(self._on_revert) btns.add_child(revert_btn) close_btn = Button("Close", name="CloseBtn") close_btn.size = Vec2(100, 32) close_btn.bg_colour = (0.30, 0.30, 0.33, 1.0) close_btn.pressed.connect(self._on_close) btns.add_child(close_btn) root_vbox.add_child(btns) # Confirm overlay (shown when closing with unsaved changes) self._confirm_overlay = self._build_confirm_overlay() inner.add_child(self._confirm_overlay) self.add_child(inner) # ---- Section builders ------------------------------------------------- def _section_header(self, content: VBoxContainer, label: str): hdr = Label(label, name=f"Section_{label}") hdr.font_size = 13.0 hdr.text_colour = (0.85, 0.85, 0.90, 1.0) hdr.size = Vec2(self.DIALOG_W - 56, 22) content.add_child(hdr) def _row(self, content: VBoxContainer, label: str, widget, width: float = 260.0, row_name: str | None = None): row = HBoxContainer(name=row_name or f"Row_{label}") row.separation = 8 row.size = Vec2(self.DIALOG_W - 56, 30) lbl = Label(label) lbl.size = Vec2(160, 28) lbl.text_colour = (0.75, 0.75, 0.78, 1.0) row.add_child(lbl) widget.size = Vec2(width, 28) row.add_child(widget) content.add_child(row) def _build_project_section(self, content: VBoxContainer): self._section_header(content, "Project") self._name_edit = TextEdit(name="NameEdit") self._name_edit.text_changed.connect(lambda _=None: self._mark_dirty()) self._row(content, "Name", self._name_edit) self._main_edit = TextEdit(name="MainEdit") self._main_edit.text_changed.connect(lambda _=None: self._mark_dirty()) self._row(content, "Main entry", self._main_edit) def _build_display_section(self, content: VBoxContainer): self._section_header(content, "Display") self._width_spin = SpinBox(min_val=1, max_val=7680, value=1280, step=16, name="WidthSpin") self._width_spin.value_changed.connect(lambda _=None: self._mark_dirty()) self._row(content, "Width", self._width_spin, width=180) self._height_spin = SpinBox(min_val=1, max_val=4320, value=720, step=16, name="HeightSpin") self._height_spin.value_changed.connect(lambda _=None: self._mark_dirty()) self._row(content, "Height", self._height_spin, width=180) self._vsync_cb = CheckBox("VSync", name="VSyncCB") self._vsync_cb.toggled.connect(lambda _=None: self._mark_dirty()) self._row(content, "VSync", self._vsync_cb, width=180) self._fullscreen_cb = CheckBox("Fullscreen", name="FullscreenCB") self._fullscreen_cb.toggled.connect(lambda _=None: self._mark_dirty()) self._row(content, "Fullscreen", self._fullscreen_cb, width=180) self._stretch_mode_dd = DropDown(items=list(_STRETCH_MODES), selected=0, name="StretchModeDD") self._stretch_mode_dd.item_selected.connect(lambda _=None: self._mark_dirty()) self._row(content, "Stretch mode", self._stretch_mode_dd, width=220) self._stretch_aspect_dd = DropDown(items=list(_STRETCH_ASPECTS), selected=0, name="StretchAspectDD") self._stretch_aspect_dd.item_selected.connect(lambda _=None: self._mark_dirty()) self._row(content, "Stretch aspect", self._stretch_aspect_dd, width=220) def _build_physics_section(self, content: VBoxContainer): self._section_header(content, "Physics") self._fps_spin = SpinBox(min_val=1, max_val=480, value=60, step=1, name="FpsSpin") self._fps_spin.value_changed.connect(lambda _=None: self._mark_dirty()) self._row(content, "FPS", self._fps_spin, width=180) self._gravity_edit = TextEdit(name="GravityEdit") self._gravity_edit.text_changed.connect(lambda _=None: self._mark_dirty()) self._row(content, "Gravity", self._gravity_edit, width=180) def _build_audio_section(self, content: VBoxContainer): self._section_header(content, "Audio") self._master_vol_slider = Slider(min_val=0.0, max_val=1.0, value=1.0, name="MasterVolumeSlider") self._master_vol_slider.step = 0.05 self._master_vol_slider.value_changed.connect(lambda _=None: self._mark_dirty()) self._row(content, "Master volume", self._master_vol_slider, width=260) def _build_rendering_section(self, content: VBoxContainer): self._section_header(content, "Rendering") self._backend_dd = DropDown(items=list(_BACKENDS), selected=0, name="BackendDD") self._backend_dd.item_selected.connect(lambda _=None: self._mark_dirty()) self._row(content, "Backend", self._backend_dd, width=180) self._msaa_dd = DropDown(items=list(_MSAA_LABELS), selected=0, name="MsaaDD") self._msaa_dd.item_selected.connect(lambda _=None: self._mark_dirty()) self._row(content, "MSAA", self._msaa_dd, width=100) def _build_input_section(self, content: VBoxContainer): self._section_header(content, "Input") row = HBoxContainer(name="InputRow") row.separation = 8 row.size = Vec2(self.DIALOG_W - 56, 32) self._input_action_count_label = Label("0 actions defined", name="InputActionCount") self._input_action_count_label.size = Vec2(200, 28) self._input_action_count_label.text_colour = (0.75, 0.75, 0.78, 1.0) row.add_child(self._input_action_count_label) open_btn = Button("Open Input Map...", name="OpenInputMapBtn") open_btn.size = Vec2(160, 28) open_btn.pressed.connect(self._on_open_input_map) row.add_child(open_btn) content.add_child(row) def _build_autoloads_section(self, content: VBoxContainer): self._section_header(content, "Autoloads") self._autoloads_list = VBoxContainer(name="AutoloadsList") self._autoloads_list.separation = 3 self._autoloads_list.size = Vec2(self.DIALOG_W - 56, 10) # grows on populate content.add_child(self._autoloads_list) add_row = HBoxContainer(name="AddAutoloadRow") add_row.separation = 6 add_row.size = Vec2(self.DIALOG_W - 56, 32) self._autoload_name_edit = TextEdit(name="AutoloadNameEdit") self._autoload_name_edit.size = Vec2(160, 28) add_row.add_child(self._autoload_name_edit) self._autoload_path_edit = TextEdit(name="AutoloadPathEdit") self._autoload_path_edit.size = Vec2(300, 28) add_row.add_child(self._autoload_path_edit) add_btn = Button("+ Add", name="AddAutoloadBtn") add_btn.size = Vec2(80, 28) add_btn.pressed.connect(self._on_add_autoload) add_row.add_child(add_btn) content.add_child(add_row) def _build_editor_section(self, content: VBoxContainer): self._section_header(content, "Editor") self._plugins_edit = TextEdit(name="PluginsEdit") self._plugins_edit.text_changed.connect(lambda _=None: self._mark_dirty()) self._row(content, "Plugins (comma sep)", self._plugins_edit, width=380) def _build_confirm_overlay(self) -> Panel: overlay = Panel(name="ConfirmOverlay") overlay.bg_colour = (0.0, 0.0, 0.0, 0.6) overlay.border_width = 0 overlay.size = Vec2(self.DIALOG_W, self.DIALOG_H) overlay.visible = False overlay.z_index = 1 inner = Panel(name="ConfirmInner") inner.bg_colour = (0.18, 0.18, 0.20, 1.0) inner.border_colour = (0.35, 0.35, 0.40, 1.0) inner.border_width = 1.0 inner.size = Vec2(320, 140) inner.position = Vec2((self.DIALOG_W - 320) / 2, (self.DIALOG_H - 140) / 2) overlay.add_child(inner) vbox = VBoxContainer() vbox.separation = 12 vbox.position = Vec2(16, 16) vbox.size = Vec2(288, 108) inner.add_child(vbox) msg = Label("Discard unsaved changes?", name="ConfirmMsg") msg.font_size = 13.0 msg.text_colour = (0.92, 0.92, 0.94, 1.0) msg.size = Vec2(288, 22) vbox.add_child(msg) btns = HBoxContainer() btns.separation = 8 btns.size = Vec2(288, 34) discard_btn = Button("Discard", name="ConfirmDiscardBtn") discard_btn.size = Vec2(130, 32) discard_btn.pressed.connect(self._on_confirm_discard) btns.add_child(discard_btn) cancel_btn = Button("Cancel", name="ConfirmCancelBtn") cancel_btn.size = Vec2(130, 32) cancel_btn.pressed.connect(self._on_confirm_cancel) btns.add_child(cancel_btn) vbox.add_child(btns) return overlay # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def show_dialog(self, parent_size: Vec2 | None = None): """Show and centre the dialog, populating 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._reload_from_disk() self._confirm_overlay.visible = False self._message_label.text = "" self.queue_redraw()
[docs] def hide_dialog(self): self.visible = False self._confirm_overlay.visible = False self.closed.emit()
# ------------------------------------------------------------------ # Load / save # ------------------------------------------------------------------ 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 _reload_from_disk(self): loaded = self._load_settings() self._settings = _copy_settings(loaded) self._saved_snapshot = _copy_settings(loaded) self._populate_widgets() self._dirty = False def _populate_widgets(self): if self._settings is None: return s = self._settings project = self._project_dir() self._project_label.text = f"Project: {s.name} ({project})" self._name_edit.text = s.name self._main_edit.text = s.main self._width_spin.value = int(s.display.get("width", 1280)) self._height_spin.value = int(s.display.get("height", 720)) self._vsync_cb.checked = bool(s.display.get("vsync", True)) self._fullscreen_cb.checked = bool(s.display.get("fullscreen", False)) self._stretch_mode_dd.selected_index = _index_or(_STRETCH_MODES, s.display.get("stretch_mode", "viewport"), 0) self._stretch_aspect_dd.selected_index = _index_or(_STRETCH_ASPECTS, s.display.get("stretch_aspect", "keep"), 0) self._fps_spin.value = int(s.physics.get("fps", 60)) self._gravity_edit.text = str(s.physics.get("gravity", 9.8)) self._master_vol_slider.value = float(s.audio.get("master_volume", 1.0)) self._backend_dd.selected_index = _index_or(_BACKENDS, s.rendering.get("backend", "vulkan"), 0) msaa_value = int(s.rendering.get("msaa", 0)) self._msaa_dd.selected_index = _MSAA_VALUES.index(msaa_value) if msaa_value in _MSAA_VALUES else 0 self._refresh_input_count() self._refresh_autoloads_list() plugins = s.editor.get("plugins", []) self._plugins_edit.text = ",".join(plugins) if isinstance(plugins, list) else "" def _refresh_input_count(self): if self._settings is None: return n = len(self._settings.input) self._input_action_count_label.text = f"{n} action{'s' if n != 1 else ''} defined" def _refresh_autoloads_list(self): if self._settings is None: return for child in list(self._autoloads_list.children): self._autoloads_list.remove_child(child) total_h = 0.0 for name, path in sorted(self._settings.autoloads.items()): row = HBoxContainer(name=f"Autoload_{name}") row.separation = 6 row.size = Vec2(self.DIALOG_W - 56, 28) name_lbl = Label(name) name_lbl.size = Vec2(160, 28) name_lbl.text_colour = (0.85, 0.85, 0.88, 1.0) row.add_child(name_lbl) path_lbl = Label(path) path_lbl.size = Vec2(300, 28) path_lbl.text_colour = (0.6, 0.75, 0.95, 1.0) row.add_child(path_lbl) del_btn = Button("x", name=f"RemoveAutoload_{name}") del_btn.size = Vec2(30, 28) del_btn.bg_colour = (0.45, 0.20, 0.20, 1.0) del_btn.pressed.connect(lambda n=name: self._on_remove_autoload(n)) row.add_child(del_btn) self._autoloads_list.add_child(row) total_h += 30 self._autoloads_list.size = Vec2(self.DIALOG_W - 56, max(10.0, total_h)) def _on_add_autoload(self): if self._settings is None: return name = self._autoload_name_edit.text.strip() path = self._autoload_path_edit.text.strip() if not name or not path: self._set_error("Autoload name and path are required.") return if name in self._settings.autoloads: self._set_error(f"Autoload {name!r} already defined.") return self._settings.autoloads[name] = path self._autoload_name_edit.text = "" self._autoload_path_edit.text = "" self._clear_error() self._mark_dirty() self._refresh_autoloads_list() def _on_remove_autoload(self, name: str): if self._settings is None: return if name in self._settings.autoloads: del self._settings.autoloads[name] self._mark_dirty() self._refresh_autoloads_list() # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def _collect_widgets_to_settings(self) -> ProjectSettings | None: """Read UI values into the local settings object and return it.""" if self._settings is None: return None s = self._settings s.name = self._name_edit.text.strip() or "Untitled" s.main = self._main_edit.text.strip() s.display["width"] = int(self._width_spin.value) s.display["height"] = int(self._height_spin.value) s.display["vsync"] = bool(self._vsync_cb.checked) s.display["fullscreen"] = bool(self._fullscreen_cb.checked) s.display["stretch_mode"] = _STRETCH_MODES[self._stretch_mode_dd.selected_index] s.display["stretch_aspect"] = _STRETCH_ASPECTS[self._stretch_aspect_dd.selected_index] s.physics["fps"] = int(self._fps_spin.value) try: s.physics["gravity"] = float(self._gravity_edit.text) except ValueError: raise ValueError("[physics] gravity: not a valid number") from None s.audio["master_volume"] = float(self._master_vol_slider.value) s.rendering["backend"] = _BACKENDS[self._backend_dd.selected_index] s.rendering["msaa"] = _MSAA_VALUES[self._msaa_dd.selected_index] plugin_text = self._plugins_edit.text.strip() s.editor["plugins"] = [p.strip() for p in plugin_text.split(",") if p.strip()] if plugin_text else [] return s def _on_save(self): if self._settings is None: return try: s = self._collect_widgets_to_settings() except ValueError as exc: self._set_error(str(exc)) return if s is None: return try: s.validate() except Exception as exc: self._set_error(str(exc)) return try: save_project(s, self._toml_path()) except Exception as exc: log.exception("Failed to save simvx.toml") self._set_error(f"Save failed: {exc}") return self._saved_snapshot = _copy_settings(s) self._dirty = False self._message_label.text_colour = (0.45, 0.85, 0.45, 1.0) self._message_label.text = "Saved." self.saved.emit() def _on_revert(self): if self._saved_snapshot is None: return self._settings = _copy_settings(self._saved_snapshot) self._populate_widgets() self._dirty = False self._clear_error() def _on_close(self): if self._dirty: self._confirm_overlay.visible = True return self.hide_dialog() def _on_confirm_discard(self): self._confirm_overlay.visible = False self._dirty = False self.hide_dialog() def _on_confirm_cancel(self): self._confirm_overlay.visible = False def _on_open_input_map(self): if self.state is not None and hasattr(self.state, "input_map_requested"): self.state.input_map_requested.emit() # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _mark_dirty(self): self._dirty = True def _set_error(self, msg: str): self._message_label.text_colour = (0.9, 0.4, 0.4, 1.0) self._message_label.text = msg def _clear_error(self): self._message_label.text = "" # ------------------------------------------------------------------ # Input # ------------------------------------------------------------------ def _on_gui_input(self, event): if hasattr(event, "key") and event.key == "escape" and event.pressed: if self._confirm_overlay.visible: self._on_confirm_cancel() else: self._on_close() return super()._on_gui_input(event)
def _index_or(items: list[str], value: str, default: int) -> int: try: return items.index(value) except ValueError: return default