"""Settings panel -- IDE configuration editor with grouped settings."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from simvx.core import Signal
from simvx.core.ui.core import Control
from simvx.core.ui.theme import get_theme
if TYPE_CHECKING:
from ..config import IDEConfig
from ..state import IDEState
log = logging.getLogger(__name__)
_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."""
def __init__(self, state: IDEState, config: IDEConfig, **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", ...}
# Inline int editing state
self._editing_row: dict | None = None
self._edit_text: str = ""
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 _commit_edit(self):
"""Commit the current inline int edit."""
if self._editing_row is not None:
try:
val = int(self._edit_text) if self._edit_text else self._editing_row["value"]
val = max(self._editing_row["min"], min(self._editing_row["max"], val))
self._editing_row["value"] = val
self._set_value(self._editing_row["attr"], val)
except ValueError:
pass
self._editing_row = None
self._edit_text = ""
def _on_gui_input(self, event):
if event.key == "scroll_up":
self._scroll_y = max(0, self._scroll_y - 30)
return
if event.key == "scroll_down":
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
# Keyboard input for inline int editing
if self._editing_row is not None and not event.button:
if event.key == "enter" and event.pressed:
self._commit_edit()
return
if event.key == "escape" and event.pressed:
self._editing_row = None
self._edit_text = ""
return
if event.key == "backspace" and event.pressed:
self._edit_text = self._edit_text[:-1]
return
if event.char and event.char.isdigit():
self._edit_text += event.char
return
return # consume other keyboard events while editing
# Handle clicks — commit any active edit first
if event.button == 1 and event.pressed and self._editing_row is not None:
self._commit_edit()
# Handle clicks on boolean toggles, int steppers, and choice buttons
if event.button == 1 and event.pressed:
x, y, w, h = self.get_global_rect()
py = event.position.y if hasattr(event.position, "y") else event.position[1]
px = event.position.x if hasattr(event.position, "x") else event.position[0]
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"] == "bool":
new_val = not row["value"]
row["value"] = new_val
self._set_value(row["attr"], new_val)
elif row["type"] == "int":
val_x = x + w * 0.6
# Three zones: [<] [value] [>]
lt_end = val_x + 16 # "<" button zone
# ">" starts after "< NN " text
num_w = len(str(row["value"])) * 8 + 16 # approx text width
gt_start = val_x + 16 + num_w
if px < lt_end:
row["value"] = max(row["min"], row["value"] - 1)
self._set_value(row["attr"], row["value"])
elif px >= gt_start:
row["value"] = min(row["max"], row["value"] + 1)
self._set_value(row["attr"], row["value"])
else:
# Click on the number — enter edit mode
self._editing_row = row
self._edit_text = str(row["value"])
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
content_y += row_h
# -- Draw ------------------------------------------------------------------
[docs]
def draw(self, renderer):
theme = get_theme()
x, y, w, h = self.get_global_rect()
renderer.draw_filled_rect(x, y, w, h, theme.panel_bg)
scale = 1.0 # 14px base — readable even at small panel sizes
# Header
renderer.draw_filled_rect(x, y, w, _HEADER_H, theme.header_bg)
renderer.draw_text_coloured("Settings", x + 8, y + (_HEADER_H - 14) / 2, scale, theme.text)
renderer.draw_line_coloured(x, y + _HEADER_H, x + w, y + _HEADER_H, 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.draw_text_coloured(row["label"], x + _PAD, cy + 8, scale, theme.accent)
renderer.draw_line_coloured(
x + _PAD, cy + _SECTION_H - 2, x + w - _PAD, cy + _SECTION_H - 2,
theme.border,
)
cy += _SECTION_H
continue
if cy + _ROW_H > y + _HEADER_H and cy < y + h:
# Label
renderer.draw_text_coloured(row["label"], x + _PAD + 8, cy + 5, scale, theme.text)
# 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.draw_text_coloured(indicator, val_x, cy + 5, scale, colour)
elif row["type"] == "int":
btn_colour = theme.accent
if self._editing_row is row:
# Editing mode — show input with cursor
renderer.draw_text_coloured("<", val_x, cy + 5, scale, btn_colour)
input_x = val_x + renderer.text_width("< ", scale)
# Input background
renderer.draw_filled_rect(input_x - 2, cy + 2, 60, _ROW_H - 4, theme.bg_input)
renderer.draw_rect_coloured(input_x - 2, cy + 2, 60, _ROW_H - 4, btn_colour)
renderer.draw_text_coloured(self._edit_text, input_x + 2, cy + 5, scale, theme.text)
gt_x = input_x + 64
renderer.draw_text_coloured(">", gt_x, cy + 5, scale, btn_colour)
else:
# Normal mode — show < value >
renderer.draw_text_coloured("<", val_x, cy + 5, scale, btn_colour)
num_text = str(row["value"])
num_x = val_x + renderer.text_width("< ", scale)
renderer.draw_text_coloured(num_text, num_x, cy + 5, scale, theme.text)
gt_x = num_x + renderer.text_width(num_text + " ", scale)
renderer.draw_text_coloured(">", gt_x, cy + 5, scale, btn_colour)
elif row["type"] == "choice":
renderer.draw_text_coloured(str(row["value"]), val_x, cy + 5, scale, theme.accent)
elif row["type"] == "str":
display = str(row["value"])
if len(display) > 25:
display = display[:22] + "..."
renderer.draw_text_coloured(display, val_x, cy + 5, scale, theme.text_dim)
cy += _ROW_H
renderer.pop_clip()