| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429 |
- # -*- 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,
- )
- )
|