# -*- coding: utf-8 -*- """ Typ-spezifische Config-Editoren. ConfigEditorPanel zeigt dynamisch den passenden Editor-Widget basierend auf FieldMeta an: Select, Switch, Input, SelectionList, Static. """ from __future__ import annotations import re from typing import Any from textual import on from textual.app import ComposeResult from textual.containers import Vertical, Horizontal from textual.message import Message from textual.widgets import Static, Input, Button, Select, Switch, SelectionList from trixy_core.tui.config.field_registry import EditorType, FieldMeta class _BoolSwitchRow(Horizontal): """Switch mit An/Aus-Label als zusammengehoeriges Widget.""" def __init__(self, value: bool, **kwargs) -> None: super().__init__(classes="editor-switch-row", **kwargs) self._bool_val = value def compose(self) -> ComposeResult: yield Switch(value=self._bool_val, id="editor-widget") yield Static("An" if self._bool_val else "Aus", id="editor-switch-label") class ConfigEditorPanel(Vertical): """ Dynamisches Editor-Panel fuer Config-Felder. Wechselt das angezeigte Widget je nach Feld-Typ und validiert Eingaben vor dem Speichern. """ class Submitted(Message): """Wird emittiert wenn der Benutzer speichern klickt.""" def __init__( self, path: str, validated_value: Any, error: str | None, requires_restart: bool, ) -> None: super().__init__() self.path = path self.validated_value = validated_value self.error = error self.requires_restart = requires_restart DEFAULT_CSS = """ ConfigEditorPanel { height: auto; max-height: 14; padding: 0 1; } ConfigEditorPanel #editor-label { height: 1; margin-bottom: 1; color: $text; text-style: bold; } ConfigEditorPanel #editor-hint { height: 1; color: $text-muted; margin-bottom: 1; } ConfigEditorPanel #editor-container { height: auto; max-height: 8; } ConfigEditorPanel #editor-error { height: 1; color: $error; } ConfigEditorPanel #editor-save-btn { margin-top: 1; min-width: 16; } ConfigEditorPanel .editor-switch-row { height: 3; align: left middle; } ConfigEditorPanel .editor-switch-row Static { width: auto; margin-left: 1; } """ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._current_path: str = "" self._current_meta: FieldMeta | None = None self._current_value: Any = None def compose(self) -> ComposeResult: yield Static("Feld auswaehlen...", id="editor-label") yield Static("", id="editor-hint") yield Vertical(id="editor-container") yield Static("", id="editor-error") yield Button("Speichern", variant="primary", id="editor-save-btn", disabled=True) async def show_editor(self, path: str, current_value: Any, meta: FieldMeta) -> None: """ Zeigt den passenden Editor fuer das Feld an. Args: path: Punkt-separierter Config-Pfad current_value: Aktueller Wert des Feldes meta: FieldMeta mit Editor-Informationen """ self._current_path = path self._current_meta = meta self._current_value = current_value # Label aktualisieren label = self.query_one("#editor-label", Static) restart_hint = " [yellow](Neustart erforderlich)[/yellow]" if meta.requires_restart else "" label.update(f"[bold]{path}[/bold]{restart_hint}") # Hint aktualisieren hint = self.query_one("#editor-hint", Static) hint.update(meta.hint) # Fehler zuruecksetzen self.query_one("#editor-error", Static).update("") # Altes Editor-Widget entfernen und neues mounten container = self.query_one("#editor-container", Vertical) await container.remove_children() widget = self._create_editor_widget(meta, current_value) await container.mount(widget) # Speichern-Button aktivieren (ausser bei readonly) save_btn = self.query_one("#editor-save-btn", Button) save_btn.disabled = (meta.editor == EditorType.READONLY) def _create_editor_widget(self, meta: FieldMeta, value: Any) -> Static | Input | Select | Vertical: """Erzeugt das passende Editor-Widget.""" editor = meta.editor if editor == EditorType.SELECT: return self._create_select(meta, value) elif editor == EditorType.BOOL: return self._create_bool_switch(value) elif editor in (EditorType.NUMBER_INT, EditorType.NUMBER_FLOAT): return self._create_number_input(meta, value) elif editor in (EditorType.TEXT, EditorType.IP_ADDRESS, EditorType.PATH): return self._create_text_input(meta, value) elif editor == EditorType.LIST_SELECT: return self._create_list_select(meta, value) elif editor == EditorType.LIST_TEXT: return self._create_list_text_input(value) elif editor == EditorType.READONLY: return self._create_readonly(value) else: return self._create_text_input(meta, value) def _create_select(self, meta: FieldMeta, value: Any) -> Select: """Erzeugt ein Select-Dropdown.""" choices = meta.choices or [] options = [(display, val) for val, display in choices] current = str(value) return Select( options, value=current if any(v == current for _, v in options) else Select.BLANK, id="editor-widget", allow_blank=False, ) def _create_bool_switch(self, value: Any) -> _BoolSwitchRow: """Erzeugt einen An/Aus-Switch.""" bool_val = bool(value) if not isinstance(value, bool) else value return _BoolSwitchRow(value=bool_val) def _create_number_input(self, meta: FieldMeta, value: Any) -> Input: """Erzeugt ein Zahlen-Eingabefeld mit Bereichshinweis.""" parts = [] if meta.min_value is not None: parts.append(f"Min: {meta.min_value}") if meta.max_value is not None: parts.append(f"Max: {meta.max_value}") placeholder = ", ".join(parts) if parts else meta.placeholder return Input( value=str(value), placeholder=placeholder, id="editor-widget", ) def _create_text_input(self, meta: FieldMeta, value: Any) -> Input: """Erzeugt ein Text-Eingabefeld.""" str_val = str(value) if value is not None else "" # None als leeren String anzeigen if str_val == "None": str_val = "" return Input( value=str_val, placeholder=meta.placeholder, id="editor-widget", ) def _create_list_select(self, meta: FieldMeta, value: Any) -> SelectionList: """Erzeugt eine Multi-Select-Liste.""" choices = meta.choices or [] current_list = value if isinstance(value, list) else [] selections = [] for val, display in choices: selected = val in current_list selections.append((display, val, selected)) return SelectionList(*selections, id="editor-widget") def _create_list_text_input(self, value: Any) -> Input: """Erzeugt ein Eingabefeld fuer kommaseparierte Listen.""" if isinstance(value, list): str_val = ", ".join(str(v) for v in value) else: str_val = str(value) if value else "" return Input( value=str_val, placeholder="Werte kommasepariert eingeben", id="editor-widget", ) def _create_readonly(self, value: Any) -> Static: """Erzeugt eine schreibgeschuetzte Anzeige.""" if isinstance(value, dict): lines = [f" {k}: {v}" for k, v in value.items()] display = "\n".join(lines) if lines else "(leer)" elif isinstance(value, list): display = ", ".join(str(v) for v in value) if value else "(leer)" else: display = str(value) return Static(display, id="editor-widget") def get_value(self) -> tuple[Any, str | None]: """ Liest und validiert den aktuellen Editor-Wert. Returns: Tuple von (validierter_wert, fehlermeldung_oder_none) """ if not self._current_meta: return None, "Kein Feld ausgewaehlt" meta = self._current_meta editor = meta.editor if editor == EditorType.READONLY: return self._current_value, None if editor == EditorType.SELECT: return self._validate_select() elif editor == EditorType.BOOL: return self._validate_bool() elif editor == EditorType.NUMBER_INT: return self._validate_number_int(meta) elif editor == EditorType.NUMBER_FLOAT: return self._validate_number_float(meta) elif editor == EditorType.IP_ADDRESS: return self._validate_ip_address() elif editor == EditorType.LIST_SELECT: return self._validate_list_select() elif editor == EditorType.LIST_TEXT: return self._validate_list_text() else: # TEXT, PATH — keine spezielle Validierung return self._validate_text() def _validate_select(self) -> tuple[Any, str | None]: """Validiert Select-Widget.""" try: widget = self.query_one("#editor-widget", Select) value = widget.value if value == Select.BLANK: return None, "Bitte einen Wert auswaehlen" return str(value), None except Exception: return None, "Select-Widget nicht gefunden" def _validate_bool(self) -> tuple[Any, str | None]: """Validiert Switch-Widget.""" try: widget = self.query_one("#editor-widget", Switch) return widget.value, None except Exception: return None, "Switch-Widget nicht gefunden" def _validate_number_int(self, meta: FieldMeta) -> tuple[Any, str | None]: """Validiert Integer-Eingabe.""" try: widget = self.query_one("#editor-widget", Input) raw = widget.value.strip() except Exception: return None, "Input-Widget nicht gefunden" try: val = int(raw) except ValueError: return None, f"Ungueltige Ganzzahl: '{raw}'" if meta.min_value is not None and val < meta.min_value: return None, f"Minimum: {int(meta.min_value)}" if meta.max_value is not None and val > meta.max_value: return None, f"Maximum: {int(meta.max_value)}" return val, None def _validate_number_float(self, meta: FieldMeta) -> tuple[Any, str | None]: """Validiert Float-Eingabe.""" try: widget = self.query_one("#editor-widget", Input) raw = widget.value.strip() except Exception: return None, "Input-Widget nicht gefunden" try: val = float(raw) except ValueError: return None, f"Ungueltige Zahl: '{raw}'" if meta.min_value is not None and val < meta.min_value: return None, f"Minimum: {meta.min_value}" if meta.max_value is not None and val > meta.max_value: return None, f"Maximum: {meta.max_value}" return val, None def _validate_ip_address(self) -> tuple[Any, str | None]: """Validiert IP-Adresse.""" try: widget = self.query_one("#editor-widget", Input) raw = widget.value.strip() except Exception: return None, "Input-Widget nicht gefunden" if not raw: return None, "IP-Adresse darf nicht leer sein" # Hostname/localhost erlauben if raw == "localhost": return raw, None # IPv4-Validierung pattern = r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$" match = re.match(pattern, raw) if not match: return None, f"Ungueltige IP-Adresse: '{raw}'" for octet_str in match.groups(): octet = int(octet_str) if octet < 0 or octet > 255: return None, f"Oktett ausserhalb 0-255: {octet}" return raw, None def _validate_list_select(self) -> tuple[Any, str | None]: """Validiert Multi-Select-Liste.""" try: widget = self.query_one("#editor-widget", SelectionList) selected = list(widget.selected) return selected, None except Exception: return None, "SelectionList nicht gefunden" def _validate_list_text(self) -> tuple[Any, str | None]: """Validiert kommaseparierte Liste.""" try: widget = self.query_one("#editor-widget", Input) raw = widget.value.strip() except Exception: return None, "Input-Widget nicht gefunden" if not raw: return [], None items = [item.strip() for item in raw.split(",") if item.strip()] return items, None def _validate_text(self) -> tuple[Any, str | None]: """Validiert Text-Eingabe.""" try: widget = self.query_one("#editor-widget", Input) return widget.value, None except Exception: return None, "Input-Widget nicht gefunden" # --- Event-Handler --- @on(Switch.Changed) def _on_switch_changed(self, event: Switch.Changed) -> None: """Aktualisiert An/Aus-Label bei Switch-Aenderung.""" try: label = self.query_one("#editor-switch-label", Static) label.update("An" if event.value else "Aus") except Exception: pass @on(Button.Pressed, "#editor-save-btn") def _on_save_pressed(self, event: Button.Pressed) -> None: """Validiert und emittiert Submitted-Message.""" event.stop() value, error = self.get_value() # Fehler anzeigen error_label = self.query_one("#editor-error", Static) if error: error_label.update(f"[red]{error}[/red]") else: error_label.update("") requires_restart = self._current_meta.requires_restart if self._current_meta else False self.post_message( self.Submitted( path=self._current_path, validated_value=value, error=error, requires_restart=requires_restart, ) )