config_editors.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. # -*- coding: utf-8 -*-
  2. """
  3. Typ-spezifische Config-Editoren.
  4. ConfigEditorPanel zeigt dynamisch den passenden Editor-Widget
  5. basierend auf FieldMeta an: Select, Switch, Input, SelectionList, Static.
  6. """
  7. from __future__ import annotations
  8. import re
  9. from typing import Any
  10. from textual import on
  11. from textual.app import ComposeResult
  12. from textual.containers import Vertical, Horizontal
  13. from textual.message import Message
  14. from textual.widgets import Static, Input, Button, Select, Switch, SelectionList
  15. from trixy_core.tui.config.field_registry import EditorType, FieldMeta
  16. class _BoolSwitchRow(Horizontal):
  17. """Switch mit An/Aus-Label als zusammengehoeriges Widget."""
  18. def __init__(self, value: bool, **kwargs) -> None:
  19. super().__init__(classes="editor-switch-row", **kwargs)
  20. self._bool_val = value
  21. def compose(self) -> ComposeResult:
  22. yield Switch(value=self._bool_val, id="editor-widget")
  23. yield Static("An" if self._bool_val else "Aus", id="editor-switch-label")
  24. class ConfigEditorPanel(Vertical):
  25. """
  26. Dynamisches Editor-Panel fuer Config-Felder.
  27. Wechselt das angezeigte Widget je nach Feld-Typ
  28. und validiert Eingaben vor dem Speichern.
  29. """
  30. class Submitted(Message):
  31. """Wird emittiert wenn der Benutzer speichern klickt."""
  32. def __init__(
  33. self,
  34. path: str,
  35. validated_value: Any,
  36. error: str | None,
  37. requires_restart: bool,
  38. ) -> None:
  39. super().__init__()
  40. self.path = path
  41. self.validated_value = validated_value
  42. self.error = error
  43. self.requires_restart = requires_restart
  44. DEFAULT_CSS = """
  45. ConfigEditorPanel {
  46. height: auto;
  47. max-height: 14;
  48. padding: 0 1;
  49. }
  50. ConfigEditorPanel #editor-label {
  51. height: 1;
  52. margin-bottom: 1;
  53. color: $text;
  54. text-style: bold;
  55. }
  56. ConfigEditorPanel #editor-hint {
  57. height: 1;
  58. color: $text-muted;
  59. margin-bottom: 1;
  60. }
  61. ConfigEditorPanel #editor-container {
  62. height: auto;
  63. max-height: 8;
  64. }
  65. ConfigEditorPanel #editor-error {
  66. height: 1;
  67. color: $error;
  68. }
  69. ConfigEditorPanel #editor-save-btn {
  70. margin-top: 1;
  71. min-width: 16;
  72. }
  73. ConfigEditorPanel .editor-switch-row {
  74. height: 3;
  75. align: left middle;
  76. }
  77. ConfigEditorPanel .editor-switch-row Static {
  78. width: auto;
  79. margin-left: 1;
  80. }
  81. """
  82. def __init__(self, **kwargs) -> None:
  83. super().__init__(**kwargs)
  84. self._current_path: str = ""
  85. self._current_meta: FieldMeta | None = None
  86. self._current_value: Any = None
  87. def compose(self) -> ComposeResult:
  88. yield Static("Feld auswaehlen...", id="editor-label")
  89. yield Static("", id="editor-hint")
  90. yield Vertical(id="editor-container")
  91. yield Static("", id="editor-error")
  92. yield Button("Speichern", variant="primary", id="editor-save-btn", disabled=True)
  93. async def show_editor(self, path: str, current_value: Any, meta: FieldMeta) -> None:
  94. """
  95. Zeigt den passenden Editor fuer das Feld an.
  96. Args:
  97. path: Punkt-separierter Config-Pfad
  98. current_value: Aktueller Wert des Feldes
  99. meta: FieldMeta mit Editor-Informationen
  100. """
  101. self._current_path = path
  102. self._current_meta = meta
  103. self._current_value = current_value
  104. # Label aktualisieren
  105. label = self.query_one("#editor-label", Static)
  106. restart_hint = " [yellow](Neustart erforderlich)[/yellow]" if meta.requires_restart else ""
  107. label.update(f"[bold]{path}[/bold]{restart_hint}")
  108. # Hint aktualisieren
  109. hint = self.query_one("#editor-hint", Static)
  110. hint.update(meta.hint)
  111. # Fehler zuruecksetzen
  112. self.query_one("#editor-error", Static).update("")
  113. # Altes Editor-Widget entfernen und neues mounten
  114. container = self.query_one("#editor-container", Vertical)
  115. await container.remove_children()
  116. widget = self._create_editor_widget(meta, current_value)
  117. await container.mount(widget)
  118. # Speichern-Button aktivieren (ausser bei readonly)
  119. save_btn = self.query_one("#editor-save-btn", Button)
  120. save_btn.disabled = (meta.editor == EditorType.READONLY)
  121. def _create_editor_widget(self, meta: FieldMeta, value: Any) -> Static | Input | Select | Vertical:
  122. """Erzeugt das passende Editor-Widget."""
  123. editor = meta.editor
  124. if editor == EditorType.SELECT:
  125. return self._create_select(meta, value)
  126. elif editor == EditorType.BOOL:
  127. return self._create_bool_switch(value)
  128. elif editor in (EditorType.NUMBER_INT, EditorType.NUMBER_FLOAT):
  129. return self._create_number_input(meta, value)
  130. elif editor in (EditorType.TEXT, EditorType.IP_ADDRESS, EditorType.PATH):
  131. return self._create_text_input(meta, value)
  132. elif editor == EditorType.LIST_SELECT:
  133. return self._create_list_select(meta, value)
  134. elif editor == EditorType.LIST_TEXT:
  135. return self._create_list_text_input(value)
  136. elif editor == EditorType.READONLY:
  137. return self._create_readonly(value)
  138. else:
  139. return self._create_text_input(meta, value)
  140. def _create_select(self, meta: FieldMeta, value: Any) -> Select:
  141. """Erzeugt ein Select-Dropdown."""
  142. choices = meta.choices or []
  143. options = [(display, val) for val, display in choices]
  144. current = str(value)
  145. return Select(
  146. options,
  147. value=current if any(v == current for _, v in options) else Select.BLANK,
  148. id="editor-widget",
  149. allow_blank=False,
  150. )
  151. def _create_bool_switch(self, value: Any) -> _BoolSwitchRow:
  152. """Erzeugt einen An/Aus-Switch."""
  153. bool_val = bool(value) if not isinstance(value, bool) else value
  154. return _BoolSwitchRow(value=bool_val)
  155. def _create_number_input(self, meta: FieldMeta, value: Any) -> Input:
  156. """Erzeugt ein Zahlen-Eingabefeld mit Bereichshinweis."""
  157. parts = []
  158. if meta.min_value is not None:
  159. parts.append(f"Min: {meta.min_value}")
  160. if meta.max_value is not None:
  161. parts.append(f"Max: {meta.max_value}")
  162. placeholder = ", ".join(parts) if parts else meta.placeholder
  163. return Input(
  164. value=str(value),
  165. placeholder=placeholder,
  166. id="editor-widget",
  167. )
  168. def _create_text_input(self, meta: FieldMeta, value: Any) -> Input:
  169. """Erzeugt ein Text-Eingabefeld."""
  170. str_val = str(value) if value is not None else ""
  171. # None als leeren String anzeigen
  172. if str_val == "None":
  173. str_val = ""
  174. return Input(
  175. value=str_val,
  176. placeholder=meta.placeholder,
  177. id="editor-widget",
  178. )
  179. def _create_list_select(self, meta: FieldMeta, value: Any) -> SelectionList:
  180. """Erzeugt eine Multi-Select-Liste."""
  181. choices = meta.choices or []
  182. current_list = value if isinstance(value, list) else []
  183. selections = []
  184. for val, display in choices:
  185. selected = val in current_list
  186. selections.append((display, val, selected))
  187. return SelectionList(*selections, id="editor-widget")
  188. def _create_list_text_input(self, value: Any) -> Input:
  189. """Erzeugt ein Eingabefeld fuer kommaseparierte Listen."""
  190. if isinstance(value, list):
  191. str_val = ", ".join(str(v) for v in value)
  192. else:
  193. str_val = str(value) if value else ""
  194. return Input(
  195. value=str_val,
  196. placeholder="Werte kommasepariert eingeben",
  197. id="editor-widget",
  198. )
  199. def _create_readonly(self, value: Any) -> Static:
  200. """Erzeugt eine schreibgeschuetzte Anzeige."""
  201. if isinstance(value, dict):
  202. lines = [f" {k}: {v}" for k, v in value.items()]
  203. display = "\n".join(lines) if lines else "(leer)"
  204. elif isinstance(value, list):
  205. display = ", ".join(str(v) for v in value) if value else "(leer)"
  206. else:
  207. display = str(value)
  208. return Static(display, id="editor-widget")
  209. def get_value(self) -> tuple[Any, str | None]:
  210. """
  211. Liest und validiert den aktuellen Editor-Wert.
  212. Returns:
  213. Tuple von (validierter_wert, fehlermeldung_oder_none)
  214. """
  215. if not self._current_meta:
  216. return None, "Kein Feld ausgewaehlt"
  217. meta = self._current_meta
  218. editor = meta.editor
  219. if editor == EditorType.READONLY:
  220. return self._current_value, None
  221. if editor == EditorType.SELECT:
  222. return self._validate_select()
  223. elif editor == EditorType.BOOL:
  224. return self._validate_bool()
  225. elif editor == EditorType.NUMBER_INT:
  226. return self._validate_number_int(meta)
  227. elif editor == EditorType.NUMBER_FLOAT:
  228. return self._validate_number_float(meta)
  229. elif editor == EditorType.IP_ADDRESS:
  230. return self._validate_ip_address()
  231. elif editor == EditorType.LIST_SELECT:
  232. return self._validate_list_select()
  233. elif editor == EditorType.LIST_TEXT:
  234. return self._validate_list_text()
  235. else:
  236. # TEXT, PATH — keine spezielle Validierung
  237. return self._validate_text()
  238. def _validate_select(self) -> tuple[Any, str | None]:
  239. """Validiert Select-Widget."""
  240. try:
  241. widget = self.query_one("#editor-widget", Select)
  242. value = widget.value
  243. if value == Select.BLANK:
  244. return None, "Bitte einen Wert auswaehlen"
  245. return str(value), None
  246. except Exception:
  247. return None, "Select-Widget nicht gefunden"
  248. def _validate_bool(self) -> tuple[Any, str | None]:
  249. """Validiert Switch-Widget."""
  250. try:
  251. widget = self.query_one("#editor-widget", Switch)
  252. return widget.value, None
  253. except Exception:
  254. return None, "Switch-Widget nicht gefunden"
  255. def _validate_number_int(self, meta: FieldMeta) -> tuple[Any, str | None]:
  256. """Validiert Integer-Eingabe."""
  257. try:
  258. widget = self.query_one("#editor-widget", Input)
  259. raw = widget.value.strip()
  260. except Exception:
  261. return None, "Input-Widget nicht gefunden"
  262. try:
  263. val = int(raw)
  264. except ValueError:
  265. return None, f"Ungueltige Ganzzahl: '{raw}'"
  266. if meta.min_value is not None and val < meta.min_value:
  267. return None, f"Minimum: {int(meta.min_value)}"
  268. if meta.max_value is not None and val > meta.max_value:
  269. return None, f"Maximum: {int(meta.max_value)}"
  270. return val, None
  271. def _validate_number_float(self, meta: FieldMeta) -> tuple[Any, str | None]:
  272. """Validiert Float-Eingabe."""
  273. try:
  274. widget = self.query_one("#editor-widget", Input)
  275. raw = widget.value.strip()
  276. except Exception:
  277. return None, "Input-Widget nicht gefunden"
  278. try:
  279. val = float(raw)
  280. except ValueError:
  281. return None, f"Ungueltige Zahl: '{raw}'"
  282. if meta.min_value is not None and val < meta.min_value:
  283. return None, f"Minimum: {meta.min_value}"
  284. if meta.max_value is not None and val > meta.max_value:
  285. return None, f"Maximum: {meta.max_value}"
  286. return val, None
  287. def _validate_ip_address(self) -> tuple[Any, str | None]:
  288. """Validiert IP-Adresse."""
  289. try:
  290. widget = self.query_one("#editor-widget", Input)
  291. raw = widget.value.strip()
  292. except Exception:
  293. return None, "Input-Widget nicht gefunden"
  294. if not raw:
  295. return None, "IP-Adresse darf nicht leer sein"
  296. # Hostname/localhost erlauben
  297. if raw == "localhost":
  298. return raw, None
  299. # IPv4-Validierung
  300. pattern = r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$"
  301. match = re.match(pattern, raw)
  302. if not match:
  303. return None, f"Ungueltige IP-Adresse: '{raw}'"
  304. for octet_str in match.groups():
  305. octet = int(octet_str)
  306. if octet < 0 or octet > 255:
  307. return None, f"Oktett ausserhalb 0-255: {octet}"
  308. return raw, None
  309. def _validate_list_select(self) -> tuple[Any, str | None]:
  310. """Validiert Multi-Select-Liste."""
  311. try:
  312. widget = self.query_one("#editor-widget", SelectionList)
  313. selected = list(widget.selected)
  314. return selected, None
  315. except Exception:
  316. return None, "SelectionList nicht gefunden"
  317. def _validate_list_text(self) -> tuple[Any, str | None]:
  318. """Validiert kommaseparierte Liste."""
  319. try:
  320. widget = self.query_one("#editor-widget", Input)
  321. raw = widget.value.strip()
  322. except Exception:
  323. return None, "Input-Widget nicht gefunden"
  324. if not raw:
  325. return [], None
  326. items = [item.strip() for item in raw.split(",") if item.strip()]
  327. return items, None
  328. def _validate_text(self) -> tuple[Any, str | None]:
  329. """Validiert Text-Eingabe."""
  330. try:
  331. widget = self.query_one("#editor-widget", Input)
  332. return widget.value, None
  333. except Exception:
  334. return None, "Input-Widget nicht gefunden"
  335. # --- Event-Handler ---
  336. @on(Switch.Changed)
  337. def _on_switch_changed(self, event: Switch.Changed) -> None:
  338. """Aktualisiert An/Aus-Label bei Switch-Aenderung."""
  339. try:
  340. label = self.query_one("#editor-switch-label", Static)
  341. label.update("An" if event.value else "Aus")
  342. except Exception:
  343. pass
  344. @on(Button.Pressed, "#editor-save-btn")
  345. def _on_save_pressed(self, event: Button.Pressed) -> None:
  346. """Validiert und emittiert Submitted-Message."""
  347. event.stop()
  348. value, error = self.get_value()
  349. # Fehler anzeigen
  350. error_label = self.query_one("#editor-error", Static)
  351. if error:
  352. error_label.update(f"[red]{error}[/red]")
  353. else:
  354. error_label.update("")
  355. requires_restart = self._current_meta.requires_restart if self._current_meta else False
  356. self.post_message(
  357. self.Submitted(
  358. path=self._current_path,
  359. validated_value=value,
  360. error=error,
  361. requires_restart=requires_restart,
  362. )
  363. )