"""Settings panel -- IDE configuration editor with grouped settings."""
from typing import TYPE_CHECKING
from simvx.core import PointerFilter, Property, Signal
from simvx.core.input import Pointer, PointerEvent, PointerPhase, ScrollEvent
from simvx.core.math.types import Vec2
from simvx.core.ui.core import Control
from simvx.core.ui.theme import get_theme
from simvx.core.ui.widgets import TextEdit
if TYPE_CHECKING:
from ..config import Config
from ..state import State
_HEADER_H = 28.0
_ROW_H = 28.0
_SECTION_H = 32.0
_PAD = 12.0
[docs]
class SettingsPanel(Control):
"""IDE settings editor with grouped settings and auto-save."""
pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")
def __init__(self, state: State, config: Config, **kwargs):
super().__init__(**kwargs)
self.name = "Settings"
self._state = state
self._config = config
self.settings_changed = Signal()
self._scroll_y: float = 0.0
self._rows: list[dict] = [] # Each: {"type": "section"|"bool"|"int"|"choice"|"str", ...}
# The int row whose value is open for typing, and the field it is typed into.
self._editing_row: dict | None = None
# The int row whose number is pressed: the field opens on the release over it.
self._pressed_number: dict | None = None
self._edit = self.add_child(TextEdit(name="Value", visible=False))
self._edit.text_submitted.connect(self._commit_edit)
self._edit.focus_exited.connect(self._commit_edit)
self._build_rows()
def _build_rows(self):
"""Build the settings row definitions from current config."""
c = self._config
self._rows = [
# Font & Editor
{"type": "section", "label": "Editor"},
{"type": "int", "label": "Font Size", "attr": "font_size", "min": 8, "max": 32, "value": c.font_size},
{"type": "int", "label": "Tab Size", "attr": "tab_size", "min": 1, "max": 8, "value": c.tab_size},
{"type": "bool", "label": "Insert Spaces", "attr": "insert_spaces", "value": c.insert_spaces},
{"type": "bool", "label": "Line Numbers", "attr": "show_line_numbers", "value": c.show_line_numbers},
{"type": "bool", "label": "Minimap", "attr": "show_minimap", "value": c.show_minimap},
{"type": "bool", "label": "Code Folding", "attr": "show_code_folding", "value": c.show_code_folding},
{"type": "bool", "label": "Indent Guides", "attr": "show_indent_guides", "value": c.show_indent_guides},
{"type": "bool", "label": "Auto Save", "attr": "auto_save", "value": c.auto_save},
{"type": "bool", "label": "Format on Save", "attr": "format_on_save", "value": c.format_on_save},
# Theme
{"type": "section", "label": "Appearance"},
{
"type": "choice",
"label": "Theme",
"attr": "theme_preset",
"options": ["dark", "abyss", "midnight", "light", "monokai", "solarised_dark", "nord"],
"value": c.theme_preset,
},
# LSP
{"type": "section", "label": "Language Server"},
{"type": "bool", "label": "LSP Enabled", "attr": "lsp_enabled", "value": c.lsp_enabled},
{"type": "str", "label": "LSP Command", "attr": "lsp_command", "value": c.lsp_command},
# Linting
{"type": "section", "label": "Linting"},
{"type": "bool", "label": "Lint Enabled", "attr": "lint_enabled", "value": c.lint_enabled},
{"type": "bool", "label": "Lint on Save", "attr": "lint_on_save", "value": c.lint_on_save},
]
def _set_value(self, attr: str, value):
"""Set a config value and save."""
if attr == "theme_preset":
self._config.apply_theme(value)
else:
setattr(self._config, attr, value)
self._config.save()
self.settings_changed.emit(attr, value)
def _start_edit(self, row: dict):
"""Open *row*'s value for typing."""
self._editing_row = row
self._edit.text = str(row["value"])
self._edit.cursor_pos = len(self._edit.text)
self._edit.visible = True
self._place_edit()
self._edit.grab_focus()
def _commit_edit(self, *_):
"""Commit the typed value, clamped to the row's range; text that is not a number keeps the old value."""
row = self._editing_row
if row is None:
return
self._editing_row = None
self._edit.visible = False
self._edit.release_focus()
try:
val = int(self._edit.text)
except ValueError:
return
row["value"] = max(row["min"], min(row["max"], val))
self._set_value(row["attr"], row["value"])
def _place_edit(self):
"""Put the field over the value of the row being edited."""
x, y, w, _ = self._local_rect()
cy = y + _HEADER_H - self._scroll_y + _PAD
for row in self._rows:
if row is self._editing_row:
self._edit.position = Vec2(x + w * 0.6 + 16, cy + 2)
self._edit.size = Vec2(60, _ROW_H - 4)
return
cy += _SECTION_H if row["type"] == "section" else _ROW_H
[docs]
def on_update(self, dt: float):
if self._editing_row is not None:
self._place_edit()
def _hit(self, position) -> tuple[dict, str] | None:
"""The row under *position*, and for an int row whether it is on "less", "number" or "more"."""
x, y, w, _ = self._local_rect()
px, py = self.to_local(position)
content_y = y + _HEADER_H - self._scroll_y + _PAD
for row in self._rows:
row_h = _SECTION_H if row["type"] == "section" else _ROW_H
if content_y <= py < content_y + row_h:
if row["type"] != "int":
return row, ""
# Three zones: [<] [value] [>], the number approximated by its digit count.
val_x = x + w * 0.6
gt_start = val_x + 16 + len(str(row["value"])) * 8 + 16
if px < val_x + 16:
return row, "less"
return row, "more" if px >= gt_start else "number"
content_y += row_h
return None
# -- Draw ------------------------------------------------------------------
[docs]
def on_draw(self, renderer):
theme = get_theme()
x, y, w, h = self._local_rect()
renderer.rect((x, y), (w, h), fill=theme.panel_bg)
scale = 1.0 # 14px base: readable even at small panel sizes
# Header
renderer.rect((x, y), (w, _HEADER_H), fill=theme.header_bg)
renderer.text("Settings", (x + 8, y + (_HEADER_H - 14) / 2), fill=theme.text, scale=scale)
renderer.line((x, y + _HEADER_H), (x + w, y + _HEADER_H), stroke=theme.border)
# Content
renderer.push_clip(x, y + _HEADER_H, w, h - _HEADER_H)
cy = y + _HEADER_H - self._scroll_y + _PAD
for row in self._rows:
if row["type"] == "section":
if cy + _SECTION_H > y + _HEADER_H and cy < y + h:
renderer.text(row["label"], (x + _PAD, cy + 8), fill=theme.accent, scale=scale)
renderer.line(
(x + _PAD, cy + _SECTION_H - 2), (x + w - _PAD, cy + _SECTION_H - 2), stroke=theme.border
)
cy += _SECTION_H
continue
if cy + _ROW_H > y + _HEADER_H and cy < y + h:
# Label
renderer.text(row["label"], (x + _PAD + 8, cy + 5), fill=theme.text, scale=scale)
# Value controls
val_x = x + w * 0.6
if row["type"] == "bool":
indicator = "[x]" if row["value"] else "[ ]"
colour = theme.accent if row["value"] else theme.text_dim
renderer.text(indicator, (val_x, cy + 5), fill=colour, scale=scale)
elif row["type"] == "int":
btn_colour = theme.accent
if self._editing_row is row:
# The value field draws itself between the arrows.
renderer.text("<", (val_x, cy + 5), fill=btn_colour, scale=scale)
renderer.text(">", (val_x + 16 + 64, cy + 5), fill=btn_colour, scale=scale)
else:
# Normal mode: show < value >
renderer.text("<", (val_x, cy + 5), fill=btn_colour, scale=scale)
num_text = str(row["value"])
num_x = val_x + renderer.text_width("< ", scale)
renderer.text(num_text, (num_x, cy + 5), fill=theme.text, scale=scale)
gt_x = num_x + renderer.text_width(num_text + " ", scale)
renderer.text(">", (gt_x, cy + 5), fill=btn_colour, scale=scale)
elif row["type"] == "choice":
renderer.text(str(row["value"]), (val_x, cy + 5), fill=theme.accent, scale=scale)
elif row["type"] == "str":
display = str(row["value"])
if len(display) > 25:
display = display[:22] + "..."
renderer.text(display, (val_x, cy + 5), fill=theme.text_dim, scale=scale)
cy += _ROW_H
renderer.pop_clip()