Source code for simvx.editor.preferences_dialog

"""Preferences dialog — floating modal overlay for editor settings.

Accessible from Edit > Preferences or via the command palette.
Settings are organised into collapsible sections (Appearance, 3D Viewport,
Snapping, Auto-save) and apply immediately with live preview.
"""


from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from simvx.core import Vec2
from simvx.core.ui.core import Control, UIInputEvent

if TYPE_CHECKING:
    from .preferences import EditorPreferences

log = logging.getLogger(__name__)

# Layout constants
_DIALOG_W = 500.0
_DIALOG_H = 450.0
_TITLE_H = 36.0
_SECTION_H = 28.0
_ROW_H = 30.0
_PAD = 12.0
_LABEL_W = 180.0
_WIDGET_W = 160.0
_CLOSE_BTN_SIZE = 24.0

# Colours
_OVERLAY_BG = (0.0, 0.0, 0.0, 0.55)
_DIALOG_BG = (0.16, 0.16, 0.18, 1.0)
_DIALOG_BORDER = (0.35, 0.35, 0.40, 1.0)
_TITLE_BG = (0.12, 0.12, 0.14, 1.0)
_TITLE_TEXT = (0.95, 0.95, 0.97, 1.0)
_SECTION_BG = (0.20, 0.20, 0.23, 1.0)
_SECTION_TEXT = (0.80, 0.80, 0.85, 1.0)
_SECTION_ARROW = (0.60, 0.60, 0.65, 1.0)
_LABEL_TEXT = (0.75, 0.75, 0.78, 1.0)
_HINT_TEXT = (0.50, 0.50, 0.53, 1.0)
_CLOSE_HOVER = (0.85, 0.25, 0.25, 1.0)
_CLOSE_NORMAL = (0.55, 0.55, 0.58, 1.0)
_FONT_SIZE = 13.0


[docs] class PreferencesDialog(Control): """Floating modal preferences overlay for the SimVX editor. Rendered as a centred dialog with semi-transparent backdrop. Each section is collapsible; widgets modify ``EditorPreferences`` and auto-save on every change. """ def __init__(self, prefs: EditorPreferences | None = None, on_theme_changed=None, **kwargs): super().__init__(**kwargs) self.visible = False self.z_index = 1500 self._prefs = prefs self._on_theme_changed = on_theme_changed # Section collapse state self._sections_open: dict[str, bool] = { "Appearance": True, "3D Viewport": True, "Snapping": True, "Auto-save": True, } # Close button hover tracking self._close_hovered = False # Scroll offset for content that exceeds dialog height self._scroll_y = 0.0 # Internal widget state for interactive elements self._widgets_built = False self._dropdown_open = False self._dropdown_hover_index = -1 # Current theme index (derived from prefs on show) self._theme_names = ["dark", "light", "monokai"] self._theme_index = 0 # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def show_dialog(self): """Show the preferences dialog.""" self.visible = True self._sync_from_prefs() self._dropdown_open = False self._dropdown_hover_index = -1 self.set_focus() if self._tree: self._tree.push_popup(self)
[docs] def hide_dialog(self): """Hide the preferences dialog.""" if not self.visible: return self.visible = False self._dropdown_open = False self.release_focus() if self._tree: self._tree.pop_popup(self)
def _sync_from_prefs(self): """Read current preference values into local state.""" if not self._prefs: return name = self._prefs.theme_name self._theme_index = self._theme_names.index(name) if name in self._theme_names else 0 # ------------------------------------------------------------------ # Preference mutation helpers # ------------------------------------------------------------------ def _apply_and_save(self): """Persist preferences to disk.""" if self._prefs: self._prefs.save() def _set_theme(self, index: int): if not self._prefs: return self._theme_index = index self._prefs.theme_name = self._theme_names[index] self._prefs.get_theme() self._apply_and_save() if self._on_theme_changed: self._on_theme_changed() def _set_font_size(self, value: float): if not self._prefs: return self._prefs.font_size = value self._prefs.get_theme() self._apply_and_save() if self._on_theme_changed: self._on_theme_changed() def _set_show_grid(self, checked: bool): if not self._prefs: return self._prefs.show_grid = checked self._apply_and_save() def _set_grid_size(self, value: float): if not self._prefs: return self._prefs.grid_size = value self._apply_and_save() def _set_grid_subdivisions(self, value: float): if not self._prefs: return self._prefs.grid_subdivisions = int(value) self._apply_and_save() def _set_snap_enabled(self, checked: bool): if not self._prefs: return self._prefs.snap_enabled = checked self._apply_and_save() def _set_snap_size(self, value: float): if not self._prefs: return self._prefs.snap_size = value self._apply_and_save() def _set_auto_save_interval(self, value: float): if not self._prefs: return self._prefs.auto_save_interval = int(value) self._apply_and_save() # ------------------------------------------------------------------ # Geometry helpers # ------------------------------------------------------------------ def _dialog_rect(self) -> tuple[float, float, float, float]: """Return (x, y, w, h) of the centred dialog panel.""" ss = self._get_parent_size() dx = (ss.x - _DIALOG_W) / 2 dy = (ss.y - _DIALOG_H) / 2 return dx, dy, _DIALOG_W, _DIALOG_H def _close_btn_rect(self) -> tuple[float, float, float, float]: dx, dy, dw, _ = self._dialog_rect() margin = (_TITLE_H - _CLOSE_BTN_SIZE) / 2 return (dx + dw - _CLOSE_BTN_SIZE - margin, dy + margin, _CLOSE_BTN_SIZE, _CLOSE_BTN_SIZE) def _content_rows(self) -> list[dict]: """Build the list of rows to render, respecting collapse state.""" prefs = self._prefs rows: list[dict] = [] # -- Appearance -- rows.append({"type": "section", "label": "Appearance"}) if self._sections_open["Appearance"]: rows.append({ "type": "dropdown", "label": "Theme", "items": self._theme_names, "index": self._theme_index, "setter": self._set_theme, }) rows.append({ "type": "spinbox", "label": "Font Size", "min": 10, "max": 24, "step": 1, "value": prefs.font_size if prefs else 14, "setter": self._set_font_size, }) # -- 3D Viewport -- rows.append({"type": "section", "label": "3D Viewport"}) if self._sections_open["3D Viewport"]: rows.append({ "type": "checkbox", "label": "Show Grid", "checked": prefs.show_grid if prefs else True, "setter": self._set_show_grid, }) rows.append({ "type": "spinbox", "label": "Grid Size", "min": 0.1, "max": 100, "step": 0.5, "value": prefs.grid_size if prefs else 1.0, "setter": self._set_grid_size, }) rows.append({ "type": "spinbox", "label": "Grid Subdivisions", "min": 1, "max": 20, "step": 1, "value": prefs.grid_subdivisions if prefs else 4, "setter": self._set_grid_subdivisions, }) # -- Snapping -- rows.append({"type": "section", "label": "Snapping"}) if self._sections_open["Snapping"]: rows.append({ "type": "checkbox", "label": "Snap Enabled", "checked": prefs.snap_enabled if prefs else False, "setter": self._set_snap_enabled, }) rows.append({ "type": "spinbox", "label": "Snap Size", "min": 0.01, "max": 10, "step": 0.1, "value": prefs.snap_size if prefs else 0.5, "setter": self._set_snap_size, }) # -- Auto-save -- rows.append({"type": "section", "label": "Auto-save"}) if self._sections_open["Auto-save"]: rows.append({ "type": "spinbox", "label": "Auto-save Interval", "min": 0, "max": 600, "step": 30, "value": prefs.auto_save_interval if prefs else 0, "setter": self._set_auto_save_interval, "hint": "0 = disabled (seconds)", }) return rows # ------------------------------------------------------------------ # Popup protocol # ------------------------------------------------------------------
[docs] def is_popup_point_inside(self, point) -> bool: """Modal popup -- capture all clicks.""" return self.visible
[docs] def popup_input(self, event): """Route popup input; click outside dialog dismisses.""" if not self.visible: return # Always handle keyboard first if hasattr(event, "key") and event.key and event.pressed: if event.key == "escape": if self._dropdown_open: self._dropdown_open = False else: self.hide_dialog() return px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] dx, dy, dw, dh = self._dialog_rect() inside_dialog = dx <= px <= dx + dw and dy <= py <= dy + dh # Handle dropdown overlay first if open if self._dropdown_open: if event.button == 1 and event.pressed: dd_rect = self._dropdown_rect() if dd_rect: ddx, ddy, ddw, ddh = dd_rect if ddx <= px <= ddx + ddw and ddy <= py <= ddy + ddh: item_h = _ROW_H idx = int((py - ddy) / item_h) if 0 <= idx < len(self._theme_names): self._set_theme(idx) self._dropdown_open = False return self._dropdown_open = False if not inside_dialog: self.hide_dialog() return # Track hover over dropdown items if not getattr(event, "button", 0): dd_rect = self._dropdown_rect() if dd_rect: ddx, ddy, ddw, ddh = dd_rect if ddx <= px <= ddx + ddw and ddy <= py <= ddy + ddh: self._dropdown_hover_index = int((py - ddy) / _ROW_H) else: self._dropdown_hover_index = -1 return if event.button == 1 and event.pressed: if not inside_dialog: self.hide_dialog() return self._handle_click(px, py) return # Track close button hover if not getattr(event, "button", 0) and inside_dialog: cx, cy, cw, ch = self._close_btn_rect() self._close_hovered = cx <= px <= cx + cw and cy <= py <= cy + ch # Scroll if hasattr(event, "scroll_y") and event.scroll_y and inside_dialog: self._scroll_y = max(0, self._scroll_y - event.scroll_y * 20)
[docs] def dismiss_popup(self): self.hide_dialog()
# ------------------------------------------------------------------ # Click handling # ------------------------------------------------------------------ def _handle_click(self, px: float, py: float): """Process a click inside the dialog at absolute coordinates.""" dx, dy, dw, dh = self._dialog_rect() # Close button cx, cy, cw, ch = self._close_btn_rect() if cx <= px <= cx + cw and cy <= py <= cy + ch: self.hide_dialog() return # Content area content_y = dy + _TITLE_H rows = self._content_rows() row_y = content_y - self._scroll_y for row in rows: if row["type"] == "section": if row_y <= py <= row_y + _SECTION_H and dx <= px <= dx + dw: label = row["label"] self._sections_open[label] = not self._sections_open[label] return row_y += _SECTION_H else: if row_y <= py <= row_y + _ROW_H: widget_x = dx + _PAD + _LABEL_W widget_w = _WIDGET_W if row["type"] == "checkbox": # Toggle checkbox on click anywhere in the row's widget area if widget_x <= px <= widget_x + 20: current = row["checked"] row["setter"](not current) return elif row["type"] == "spinbox": btn_w = 20 spin_x = widget_x # Up/down buttons on right side of spinbox if px >= spin_x + widget_w - btn_w: if py < row_y + _ROW_H / 2: row["setter"](row["value"] + row["step"]) else: row["setter"](row["value"] - row["step"]) return elif row["type"] == "dropdown": if widget_x <= px <= widget_x + widget_w: self._dropdown_open = not self._dropdown_open self._dropdown_hover_index = -1 # Store the dropdown position for rendering self._dropdown_row_y = row_y return row_y += _ROW_H def _dropdown_rect(self) -> tuple[float, float, float, float] | None: """Return the absolute rect of the open theme dropdown list.""" if not self._dropdown_open: return None dx, dy, dw, dh = self._dialog_rect() widget_x = dx + _PAD + _LABEL_W list_y = getattr(self, "_dropdown_row_y", dy + _TITLE_H) + _ROW_H list_h = _ROW_H * len(self._theme_names) return (widget_x, list_y, _WIDGET_W, list_h) # ------------------------------------------------------------------ # Drawing # ------------------------------------------------------------------
[docs] def draw(self, renderer): pass
[docs] def draw_popup(self, renderer): if not self.visible: return ss = self._get_parent_size() sw, sh = ss.x, ss.y scale = _FONT_SIZE / 14.0 # Semi-transparent backdrop renderer.draw_filled_rect(0, 0, sw, sh, _OVERLAY_BG) dx, dy, dw, dh = self._dialog_rect() # Dialog background renderer.draw_filled_rect(dx, dy, dw, dh, _DIALOG_BG) renderer.draw_rect_coloured(dx, dy, dw, dh, _DIALOG_BORDER) # Title bar renderer.draw_filled_rect(dx, dy, dw, _TITLE_H, _TITLE_BG) renderer.draw_text_coloured("Preferences", dx + _PAD, dy + (_TITLE_H - _FONT_SIZE) / 2, scale, _TITLE_TEXT) # Close button (X) cx, cy, cw, ch = self._close_btn_rect() close_col = _CLOSE_HOVER if self._close_hovered else _CLOSE_NORMAL renderer.draw_text_coloured("X", cx + (cw - 8) / 2, cy + (ch - _FONT_SIZE) / 2, scale, close_col) # Separator under title renderer.draw_filled_rect(dx, dy + _TITLE_H, dw, 1, _DIALOG_BORDER) # Content area with clipping (manual) content_y = dy + _TITLE_H + 1 content_h = dh - _TITLE_H - 1 max_y = content_y + content_h rows = self._content_rows() row_y = content_y - self._scroll_y for row in rows: if row["type"] == "section": if row_y + _SECTION_H > content_y and row_y < max_y: self._draw_section_header(renderer, dx, row_y, dw, row["label"], scale) row_y += _SECTION_H else: if row_y + _ROW_H > content_y and row_y < max_y: self._draw_property_row(renderer, dx, row_y, dw, row, scale) row_y += _ROW_H # Draw dropdown overlay on top if open if self._dropdown_open: self._draw_dropdown_overlay(renderer, scale)
def _draw_section_header(self, renderer, dx: float, y: float, dw: float, label: str, scale: float): """Draw a collapsible section header.""" renderer.draw_filled_rect(dx + 1, y, dw - 2, _SECTION_H, _SECTION_BG) # Collapse arrow is_open = self._sections_open.get(label, True) arrow_text = "v" if is_open else ">" renderer.draw_text_coloured(arrow_text, dx + _PAD, y + (_SECTION_H - _FONT_SIZE) / 2, scale, _SECTION_ARROW) # Section label renderer.draw_text_coloured( label, dx + _PAD + 16, y + (_SECTION_H - _FONT_SIZE) / 2, scale, _SECTION_TEXT ) def _draw_property_row(self, renderer, dx: float, y: float, dw: float, row: dict, scale: float): """Draw a single property row with label and widget.""" # Label label_x = dx + _PAD + 16 # Indent under section label_y = y + (_ROW_H - _FONT_SIZE) / 2 renderer.draw_text_coloured(row["label"], label_x, label_y, scale, _LABEL_TEXT) widget_x = dx + _PAD + _LABEL_W widget_w = _WIDGET_W widget_h = _ROW_H - 6 widget_y = y + 3 if row["type"] == "checkbox": self._draw_checkbox(renderer, widget_x, widget_y, widget_h, row["checked"]) elif row["type"] == "spinbox": self._draw_spinbox(renderer, widget_x, widget_y, widget_w, widget_h, row, scale) elif row["type"] == "dropdown": self._draw_dropdown(renderer, widget_x, widget_y, widget_w, widget_h, row, scale) # Hint text if "hint" in row: hint_x = widget_x + widget_w + 8 renderer.draw_text_coloured( row["hint"], hint_x, y + (_ROW_H - _FONT_SIZE * 0.85) / 2, scale * 0.85, _HINT_TEXT, ) def _draw_checkbox(self, renderer, x: float, y: float, h: float, checked: bool): """Draw a checkbox indicator.""" box_size = 16 box_y = y + (h - box_size) / 2 renderer.draw_rect_coloured(x, box_y, box_size, box_size, (0.45, 0.45, 0.48, 1.0)) if checked: pad = 3 renderer.draw_filled_rect( x + pad, box_y + pad, box_size - pad * 2, box_size - pad * 2, (0.28, 0.58, 0.98, 1.0), ) def _draw_spinbox(self, renderer, x: float, y: float, w: float, h: float, row: dict, scale: float): """Draw a spinbox (value field + up/down buttons).""" btn_w = 20 # Value background renderer.draw_filled_rect(x, y, w - btn_w, h, (0.07, 0.07, 0.08, 1.0)) renderer.draw_rect_coloured(x, y, w - btn_w, h, (0.32, 0.32, 0.34, 1.0)) # Value text value = row["value"] step = row["step"] if step >= 1 and value == int(value): display = str(int(value)) else: display = f"{value:.2f}" tw = renderer.text_width(display, scale) text_x = x + (w - btn_w - tw) / 2 text_y = y + (h - _FONT_SIZE) / 2 renderer.draw_text_coloured(display, text_x, text_y, scale, (0.86, 0.86, 0.88, 1.0)) # Buttons btn_x = x + w - btn_w renderer.draw_filled_rect(btn_x, y, btn_w, h, (0.24, 0.24, 0.27, 1.0)) renderer.draw_rect_coloured(btn_x, y, btn_w, h, (0.32, 0.32, 0.34, 1.0)) # Divider mid_y = y + h / 2 renderer.draw_filled_rect(btn_x, mid_y, btn_w, 1, (0.32, 0.32, 0.34, 1.0)) # Arrows (small rects) arrow_w, arrow_h = 8, 4 renderer.draw_filled_rect(btn_x + (btn_w - arrow_w) / 2, y + h / 4 - arrow_h / 2, arrow_w, arrow_h, (0.65, 0.65, 0.68, 1.0)) renderer.draw_filled_rect(btn_x + (btn_w - arrow_w) / 2, y + 3 * h / 4 - arrow_h / 2, arrow_w, arrow_h, (0.65, 0.65, 0.68, 1.0)) def _draw_dropdown(self, renderer, x: float, y: float, w: float, h: float, row: dict, scale: float): """Draw a dropdown button (closed state).""" renderer.draw_filled_rect(x, y, w, h, (0.14, 0.14, 0.15, 1.0)) renderer.draw_rect_coloured(x, y, w, h, (0.32, 0.32, 0.34, 1.0)) # Selected text text = row["items"][row["index"]] if 0 <= row["index"] < len(row["items"]) else "" text_y = y + (h - _FONT_SIZE) / 2 renderer.draw_text_coloured(text, x + 6, text_y, scale, (0.86, 0.86, 0.88, 1.0)) # Arrow arrow_w, arrow_h = 8, 4 ax = x + w - arrow_w - 8 ay = y + (h - arrow_h) / 2 renderer.draw_filled_rect(ax, ay, arrow_w, arrow_h, (0.65, 0.65, 0.68, 1.0)) def _draw_dropdown_overlay(self, renderer, scale: float): """Draw the expanded dropdown list on top of everything.""" dd_rect = self._dropdown_rect() if not dd_rect: return ddx, ddy, ddw, ddh = dd_rect item_h = _ROW_H # Background renderer.draw_filled_rect(ddx, ddy, ddw, ddh, (0.11, 0.11, 0.12, 1.0)) renderer.draw_rect_coloured(ddx, ddy, ddw, ddh, (0.35, 0.35, 0.40, 1.0)) for i, name in enumerate(self._theme_names): iy = ddy + i * item_h if i == self._dropdown_hover_index: renderer.draw_filled_rect(ddx + 1, iy, ddw - 2, item_h, (0.2, 0.45, 0.8, 1.0)) elif i == self._theme_index: renderer.draw_filled_rect(ddx + 1, iy, ddw - 2, item_h, (0.18, 0.38, 0.68, 1.0)) renderer.draw_text_coloured( name, ddx + 6, iy + (item_h - _FONT_SIZE) / 2, scale, (0.86, 0.86, 0.88, 1.0) ) # ------------------------------------------------------------------ # Input # ------------------------------------------------------------------ def _on_gui_input(self, event: UIInputEvent): if not self.visible: return if hasattr(event, "key") and event.key == "escape" and event.pressed: self.hide_dialog()