"""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
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# 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
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()