Source code for simvx.ide.panels.settings_panel

"""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()
[docs] def on_gui_input(self, event): # Claimed, so the wheel moves this list rather than the container the # panel is docked in. if isinstance(event, ScrollEvent): if event.scroll[1] > 0: self._scroll_y = max(0, self._scroll_y - 30) return True total_h = sum(_SECTION_H if r["type"] == "section" else _ROW_H for r in self._rows) + _PAD * 2 max_scroll = max(0, total_h - self.size.y + _HEADER_H) self._scroll_y = min(max_scroll, self._scroll_y + 30) return True if not isinstance(event, PointerEvent) or event.button not in Pointer.PRIMARY: return None hit = self._hit(event.position) # The field opens on the release over the number that was pressed, not # on the press: the router gives focus to the control that took a # press after its handler has run, so a field focused on the press # loses focus to this panel at once. if event.phase is PointerPhase.UP: pressed, self._pressed_number = self._pressed_number, None if pressed is not None and hit is not None and hit[0] is pressed and hit[1] == "number": self._start_edit(pressed) return True return None if event.phase is not PointerPhase.DOWN: return None if self._editing_row is not None: self._commit_edit() if hit is None: return None row, zone = hit if row["type"] == "bool": row["value"] = not row["value"] self._set_value(row["attr"], row["value"]) elif row["type"] == "int": if zone == "less": row["value"] = max(row["min"], row["value"] - 1) self._set_value(row["attr"], row["value"]) elif zone == "more": row["value"] = min(row["max"], row["value"] + 1) self._set_value(row["attr"], row["value"]) else: self._pressed_number = row elif row["type"] == "choice": opts = row["options"] idx = opts.index(row["value"]) if row["value"] in opts else 0 row["value"] = opts[(idx + 1) % len(opts)] self._set_value(row["attr"], row["value"]) return True
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()