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