plugin_custom_view.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. # -*- coding: utf-8 -*-
  2. """
  3. Plugin SubView: Benutzerdefinierter Tab.
  4. Generischer View fuer plugin-spezifische Inhalte, die vom
  5. Plugin ueber get_config_tool_tabs() definiert werden.
  6. Unterstuetzte View-Typen:
  7. 1. Schema-basiert: Formular aus tab_data["schema"]["fields"]
  8. 2. Interactive List: Liste + Detail-Panel (tab_data["view_type"] = "interactive_list")
  9. 3. Info-Modus: Statischer Text (tab_data["description"])
  10. """
  11. from __future__ import annotations
  12. from typing import Any
  13. from textual.app import ComposeResult
  14. from textual.containers import Vertical, Horizontal
  15. from textual.widgets import Static, Button, Input, Switch, Select, ListView, ListItem, Label, DataTable
  16. from trixy_core.tui.views.base import MainView
  17. class PluginCustomView(MainView):
  18. """Dynamisch erstellter Tab fuer plugin-spezifische Inhalte."""
  19. def __init__(
  20. self,
  21. tab_title: str,
  22. tab_id: str,
  23. tab_data: dict | None = None,
  24. **kwargs,
  25. ) -> None:
  26. super().__init__(**kwargs)
  27. self.TITLE = tab_title
  28. self.VIEW_ID = tab_id
  29. self._plugin_data: dict = {}
  30. self._tab_data: dict = tab_data or {}
  31. self._field_ids: list[str] = []
  32. self._view_type = self._tab_data.get("view_type", "")
  33. self._has_schema: bool = bool(
  34. self._tab_data.get("schema", {}).get("fields")
  35. )
  36. # Interactive List State
  37. self._list_items: list[dict] = []
  38. self._list_extra: dict = {} # z.B. satellites
  39. self._selected_item: dict | None = None
  40. def set_plugin(self, plugin_data: dict) -> None:
  41. """Setzt die Plugin-Basisdaten."""
  42. self._plugin_data = plugin_data
  43. def compose(self) -> ComposeResult:
  44. with Vertical(classes="section-box"):
  45. yield Static(
  46. f"[bold]{self.TITLE}[/bold]", classes="section-header"
  47. )
  48. if self._view_type == "table":
  49. yield DataTable(id=f"{self.VIEW_ID}-table", zebra_stripes=True)
  50. yield Static("", id=f"{self.VIEW_ID}-status")
  51. elif self._view_type == "interactive_list":
  52. yield ListView(id=f"{self.VIEW_ID}-list")
  53. yield Static("", id=f"{self.VIEW_ID}-separator")
  54. yield Vertical(id=f"{self.VIEW_ID}-detail", classes="plugin-detail-panel")
  55. yield Button("Speichern", id=f"{self.VIEW_ID}-save", variant="success")
  56. yield Static("", id=f"{self.VIEW_ID}-status")
  57. elif self._has_schema:
  58. yield Vertical(id=f"{self.VIEW_ID}-form", classes="plugin-form-container")
  59. # Action-Buttons
  60. actions = self._tab_data.get("schema", {}).get("actions", [])
  61. for action in actions:
  62. yield Button(
  63. action.get("label", action.get("id", "Aktion")),
  64. id=f"{self.VIEW_ID}-action-{action['id']}",
  65. variant=action.get("variant", "default"),
  66. )
  67. yield Button("Speichern", id=f"{self.VIEW_ID}-save", variant="success")
  68. yield Static("", id=f"{self.VIEW_ID}-status")
  69. else:
  70. desc = self._tab_data.get("description", "")
  71. yield Static(
  72. f"[dim]{desc}[/dim]" if desc else
  73. "[dim]Plugin-spezifische Inhalte[/dim]",
  74. id=f"{self.VIEW_ID}-content",
  75. )
  76. async def on_activate(self) -> None:
  77. """Aktiviert den View."""
  78. if self._view_type == "table":
  79. await self._load_table_data()
  80. elif self._view_type == "interactive_list":
  81. await self._load_list_data()
  82. elif self._has_schema:
  83. await self._refresh_config()
  84. await self._build_form()
  85. # ========================================================================
  86. # Table View
  87. # ========================================================================
  88. async def _load_table_data(self) -> None:
  89. """Laedt Daten und befuellt die DataTable."""
  90. from trixy_core.utils.debug import pdebug
  91. if not self.connection or not self._plugin_data:
  92. self._set_status("[red]Keine Verbindung[/red]")
  93. return
  94. plugin_name = self._plugin_data.get("name", "")
  95. load_action = self._tab_data.get("load_action", "")
  96. if not plugin_name or not load_action:
  97. return
  98. self._set_status("[yellow]Lade Daten...[/yellow]")
  99. try:
  100. result = await self.connection.plugin_action(
  101. action=load_action, plugin_name=plugin_name,
  102. )
  103. if not result or not result.success:
  104. self._set_status(f"[red]{result.error if result else 'Keine Antwort'}[/red]")
  105. return
  106. rows = result.result_data.get("rows", [])
  107. columns = self._tab_data.get("columns", [])
  108. table = self.query_one(f"#{self.VIEW_ID}-table", DataTable)
  109. table.clear(columns=True)
  110. # Spalten hinzufuegen
  111. for col in columns:
  112. table.add_column(
  113. col.get("label", col.get("key", "")),
  114. key=col.get("key", ""),
  115. width=col.get("width"),
  116. )
  117. # Zeilen hinzufuegen (mit Rich Markup)
  118. from rich.text import Text
  119. for row in rows:
  120. cells = []
  121. for col in columns:
  122. val = row.get(col.get("key", ""), "")
  123. cells.append(Text.from_markup(str(val)))
  124. table.add_row(*cells)
  125. self._set_status(f"[green]{len(rows)} Geraete[/green]")
  126. except Exception as e:
  127. self._set_status(f"[red]Fehler: {e}[/red]")
  128. # ========================================================================
  129. # Interactive List
  130. # ========================================================================
  131. async def _load_list_data(self) -> None:
  132. """Laedt Daten fuer die interaktive Liste vom Plugin."""
  133. from trixy_core.utils.debug import pdebug
  134. if not self.connection:
  135. pdebug(f"[PluginCustomView] {self.VIEW_ID}: Keine Connection")
  136. self._set_status("[red]Keine Verbindung[/red]")
  137. return
  138. if not self._plugin_data:
  139. pdebug(f"[PluginCustomView] {self.VIEW_ID}: Keine plugin_data")
  140. self._set_status("[red]Keine Plugin-Daten[/red]")
  141. return
  142. plugin_name = self._plugin_data.get("name", "")
  143. load_action = self._tab_data.get("load_action", "")
  144. pdebug(f"[PluginCustomView] {self.VIEW_ID}: Lade {load_action} fuer {plugin_name}")
  145. if not plugin_name or not load_action:
  146. self._set_status("[red]Kein Plugin oder Aktion[/red]")
  147. return
  148. self._set_status("[yellow]Lade Daten...[/yellow]")
  149. try:
  150. result = await self.connection.plugin_action(
  151. action=load_action,
  152. plugin_name=plugin_name,
  153. )
  154. pdebug(f"[PluginCustomView] Result type={type(result).__name__}, "
  155. f"success={getattr(result, 'success', '?')}, "
  156. f"result_data type={type(getattr(result, 'result_data', None)).__name__}, "
  157. f"result_data keys={list(getattr(result, 'result_data', {}).keys()) if result else 'None'}, "
  158. f"all attrs={[a for a in dir(result) if not a.startswith('_')]}")
  159. if not result or not result.success:
  160. error = result.error if result else "Keine Antwort"
  161. pdebug(f"[PluginCustomView] Fehler: {error}")
  162. self._set_status(f"[red]{error}[/red]")
  163. return
  164. list_key = self._tab_data.get("list_key", "items")
  165. rd = result.result_data
  166. self._list_items = rd.get(list_key, [])
  167. # Zusaetzliche Daten (z.B. Satellite-Liste)
  168. for key, value in result.result_data.items():
  169. if key != list_key:
  170. self._list_extra[key] = value
  171. await self._populate_list()
  172. self._set_status(f"[green]{len(self._list_items)} Eintraege[/green]")
  173. except Exception as e:
  174. pdebug(f"[PluginCustomView] Exception: {e}")
  175. self._set_status(f"[red]Fehler: {e}[/red]")
  176. async def _populate_list(self) -> None:
  177. """Befuellt die ListView mit Eintraegen."""
  178. try:
  179. list_view = self.query_one(f"#{self.VIEW_ID}-list", ListView)
  180. except Exception:
  181. return
  182. await list_view.clear()
  183. label_field = self._tab_data.get("list_label_field", "name")
  184. subtitle_field = self._tab_data.get("list_subtitle_field", "")
  185. subtitle_suffix = self._tab_data.get("list_subtitle_suffix", "")
  186. for item in self._list_items:
  187. label = str(item.get(label_field, ""))
  188. if subtitle_field and item.get(subtitle_field) is not None:
  189. label += f" [dim]({item[subtitle_field]}{subtitle_suffix})[/dim]"
  190. list_item = ListItem(Label(label))
  191. await list_view.append(list_item)
  192. async def on_list_view_selected(self, event: ListView.Selected) -> None:
  193. """Wird aufgerufen wenn ein Listen-Eintrag ausgewaehlt wird."""
  194. if self._view_type != "interactive_list":
  195. return
  196. index = event.list_view.index
  197. if index is None or index >= len(self._list_items):
  198. return
  199. self._selected_item = self._list_items[index]
  200. await self._build_detail_panel()
  201. async def _build_detail_panel(self) -> None:
  202. """Baut das Detail-Panel fuer den ausgewaehlten Eintrag."""
  203. try:
  204. detail = self.query_one(f"#{self.VIEW_ID}-detail", Vertical)
  205. except Exception:
  206. return
  207. # Alte Widgets entfernen
  208. for child in list(detail.children):
  209. await child.remove()
  210. self._field_ids = []
  211. if not self._selected_item:
  212. return
  213. item = self._selected_item
  214. detail_fields = self._tab_data.get("detail_fields", [])
  215. # Raumname als Header
  216. name = item.get("name", "")
  217. await detail.mount(Static(f"[bold]{name}[/bold]", classes="plugin-detail-header"))
  218. for field_def in detail_fields:
  219. fname = field_def.get("name", "")
  220. flabel = field_def.get("label", fname)
  221. ftype = field_def.get("type", "text")
  222. fhelp = field_def.get("help", "")
  223. value_from = field_def.get("value_from", fname)
  224. field_id = f"{self.VIEW_ID}-detail-{fname}"
  225. self._field_ids.append(field_id)
  226. await detail.mount(Static(flabel, classes="plugin-field-label"))
  227. if ftype == "readonly":
  228. raw_value = item.get(value_from, item.get(fname, ""))
  229. if isinstance(raw_value, list):
  230. raw_value = ", ".join(str(v) for v in raw_value)
  231. await detail.mount(Static(f" {raw_value or '—'}"))
  232. continue
  233. elif ftype == "text":
  234. raw_value = item.get(value_from, item.get(fname, ""))
  235. if isinstance(raw_value, list):
  236. raw_value = ", ".join(str(v) for v in raw_value)
  237. widget = Input(
  238. id=field_id,
  239. value=str(raw_value) if raw_value else "",
  240. placeholder=field_def.get("placeholder", ""),
  241. )
  242. await detail.mount(widget)
  243. elif ftype == "select":
  244. choices_from = field_def.get("choices_from", "")
  245. choices_data = self._list_extra.get(choices_from, [])
  246. current = item.get(fname, "")
  247. # Optionen aufbauen: [("Label", "value"), ...]
  248. options: list[tuple[str, str]] = [("— Kein Satellite —", "")]
  249. valid_values: set[str] = {""}
  250. for choice in choices_data:
  251. if isinstance(choice, dict):
  252. c_id = choice.get("id", "")
  253. c_alias = choice.get("alias", "")
  254. c_room = choice.get("room_id", "")
  255. label = c_alias or c_id
  256. if c_room:
  257. label += f" ({c_room})"
  258. val = c_alias or c_room or c_id
  259. options.append((label, val))
  260. valid_values.add(val)
  261. else:
  262. options.append((str(choice), str(choice)))
  263. valid_values.add(str(choice))
  264. # Fallback: Wenn current nicht in Options, auf leer setzen
  265. if current not in valid_values:
  266. current = ""
  267. widget = Select(
  268. id=field_id,
  269. options=options,
  270. value=current if current else "",
  271. )
  272. await detail.mount(widget)
  273. if fhelp:
  274. await detail.mount(
  275. Static(f"[dim]{fhelp}[/dim]", classes="plugin-field-help")
  276. )
  277. # ========================================================================
  278. # Schema-basiertes Formular
  279. # ========================================================================
  280. async def _refresh_config(self) -> None:
  281. """Laedt die aktuelle Plugin-Config vom Server."""
  282. if not self.connection or not self._plugin_data:
  283. return
  284. name = self._plugin_data.get("name", "")
  285. if not name:
  286. return
  287. try:
  288. detail = await self.connection.request_plugin_detail(name)
  289. if detail and not detail.error and detail.config:
  290. self._plugin_data["config"] = detail.config
  291. except Exception:
  292. pass
  293. async def _build_form(self) -> None:
  294. """Baut das Formular aus dem Schema dynamisch auf."""
  295. form_id = f"{self.VIEW_ID}-form"
  296. try:
  297. container = self.query_one(f"#{form_id}", Vertical)
  298. except Exception:
  299. return
  300. for child in list(container.children):
  301. await child.remove()
  302. self._field_ids = []
  303. schema = self._tab_data.get("schema", {})
  304. fields = schema.get("fields", [])
  305. config_section = schema.get("config_section", "")
  306. config = self._plugin_data.get("config", {})
  307. if config_section and isinstance(config.get(config_section), dict):
  308. section_config = config[config_section]
  309. else:
  310. section_config = config
  311. for field_def in fields:
  312. name = field_def.get("name", "")
  313. label = field_def.get("label", name)
  314. field_type = field_def.get("type", "text")
  315. help_text = field_def.get("help", "")
  316. default = field_def.get("default")
  317. current_value = section_config.get(name, default)
  318. field_id = f"{self.VIEW_ID}-field-{name}"
  319. self._field_ids.append(field_id)
  320. await container.mount(Static(label, classes="plugin-field-label"))
  321. if field_type == "checkbox":
  322. widget = Switch(id=field_id, value=bool(current_value))
  323. elif field_type == "number":
  324. is_float = field_def.get("is_float", False)
  325. input_type = "number" if is_float else "integer"
  326. val_str = str(current_value) if current_value is not None else ""
  327. widget = Input(id=field_id, value=val_str,
  328. placeholder=str(default) if default is not None else "",
  329. type=input_type)
  330. elif field_type == "password":
  331. widget = Input(id=field_id,
  332. value=str(current_value) if current_value else "",
  333. password=True, placeholder=field_def.get("placeholder", ""))
  334. elif field_type == "select":
  335. choices = field_def.get("choices", [])
  336. options = [(label_text, value) for value, label_text in choices]
  337. widget = Select(id=field_id, options=options,
  338. value=current_value if current_value is not None else Select.BLANK)
  339. elif field_type == "readonly":
  340. widget = Static(f"[dim]{current_value or ''}[/dim]", id=field_id)
  341. else:
  342. widget = Input(id=field_id,
  343. value=str(current_value) if current_value is not None else "",
  344. placeholder=field_def.get("placeholder", ""))
  345. await container.mount(widget)
  346. if help_text:
  347. await container.mount(
  348. Static(f"[dim]{help_text}[/dim]", classes="plugin-field-help"))
  349. def _collect_values(self) -> dict[str, Any]:
  350. """Sammelt die Formularwerte."""
  351. schema = self._tab_data.get("schema", {})
  352. fields = schema.get("fields", [])
  353. result: dict[str, Any] = {}
  354. for field_def in fields:
  355. name = field_def.get("name", "")
  356. field_type = field_def.get("type", "text")
  357. field_id = f"{self.VIEW_ID}-field-{name}"
  358. if field_type == "readonly":
  359. continue
  360. try:
  361. if field_type == "checkbox":
  362. widget = self.query_one(f"#{field_id}", Switch)
  363. result[name] = widget.value
  364. elif field_type == "number":
  365. widget = self.query_one(f"#{field_id}", Input)
  366. is_float = field_def.get("is_float", False)
  367. if is_float:
  368. result[name] = float(widget.value) if widget.value else 0.0
  369. else:
  370. result[name] = int(widget.value) if widget.value else 0
  371. elif field_type == "select":
  372. widget = self.query_one(f"#{field_id}", Select)
  373. result[name] = widget.value if widget.value != Select.BLANK else ""
  374. else:
  375. widget = self.query_one(f"#{field_id}", Input)
  376. result[name] = widget.value
  377. except Exception:
  378. pass
  379. return result
  380. def _collect_detail_values(self) -> dict[str, Any]:
  381. """Sammelt die Detail-Panel Werte."""
  382. detail_fields = self._tab_data.get("detail_fields", [])
  383. result: dict[str, Any] = {}
  384. for field_def in detail_fields:
  385. fname = field_def.get("name", "")
  386. ftype = field_def.get("type", "text")
  387. field_id = f"{self.VIEW_ID}-detail-{fname}"
  388. try:
  389. if ftype == "select":
  390. widget = self.query_one(f"#{field_id}", Select)
  391. result[fname] = widget.value if widget.value != Select.BLANK else ""
  392. else:
  393. widget = self.query_one(f"#{field_id}", Input)
  394. result[fname] = widget.value
  395. except Exception:
  396. pass
  397. return result
  398. # ========================================================================
  399. # Button-Handler
  400. # ========================================================================
  401. async def on_button_pressed(self, event: Button.Pressed) -> None:
  402. """Button Handler."""
  403. btn_id = event.button.id or ""
  404. action_prefix = f"{self.VIEW_ID}-action-"
  405. if btn_id == f"{self.VIEW_ID}-save":
  406. if self._view_type == "interactive_list":
  407. await self._handle_save_detail()
  408. else:
  409. await self._handle_save()
  410. elif btn_id.startswith(action_prefix):
  411. action_id = btn_id[len(action_prefix):]
  412. await self._handle_action(action_id)
  413. async def _handle_save_detail(self) -> None:
  414. """Speichert Detail-Panel Werte ueber Plugin-Action."""
  415. if not self._selected_item:
  416. self._set_status("[red]Kein Eintrag ausgewaehlt[/red]")
  417. return
  418. plugin_name = self._plugin_data.get("name", "")
  419. save_action = self._tab_data.get("save_action", "")
  420. if not plugin_name or not save_action:
  421. return
  422. values = self._collect_detail_values()
  423. values["room_name"] = self._selected_item.get("name", "")
  424. try:
  425. self._set_status("[yellow]Speichern...[/yellow]")
  426. result = await self.connection.plugin_action(
  427. action=save_action,
  428. plugin_name=plugin_name,
  429. params=values,
  430. )
  431. if result and result.success:
  432. self._set_status(f"[green]{result.message}[/green]")
  433. # Liste neu laden
  434. await self._load_list_data()
  435. elif result:
  436. self._set_status(f"[red]{result.error}[/red]")
  437. except Exception as e:
  438. self._set_status(f"[red]Fehler: {e}[/red]")
  439. async def _handle_action(self, action_id: str) -> None:
  440. """Fuehrt eine plugin-spezifische Aktion aus."""
  441. if not self.connection or not self._plugin_data:
  442. self._set_status("[red]Keine Verbindung[/red]")
  443. return
  444. plugin_name = self._plugin_data.get("name", "")
  445. if not plugin_name:
  446. return
  447. schema = self._tab_data.get("schema", {})
  448. actions = schema.get("actions", [])
  449. action_def = next((a for a in actions if a.get("id") == action_id), None)
  450. params: dict[str, Any] = {}
  451. if action_def and action_def.get("collect_fields"):
  452. params = self._collect_values()
  453. try:
  454. self._set_status("[yellow]Wird geprueft...[/yellow]")
  455. result = await self.connection.plugin_action(
  456. action=action_id, plugin_name=plugin_name, params=params,
  457. )
  458. if result and result.success:
  459. self._set_status(f"[green]{result.message}[/green]")
  460. elif result and result.error:
  461. self._set_status(f"[red]{result.error}[/red]")
  462. else:
  463. self._set_status("[red]Keine Antwort vom Server[/red]")
  464. except Exception as e:
  465. self._set_status(f"[red]Fehler: {e}[/red]")
  466. async def _handle_save(self) -> None:
  467. """Speichert Schema-Formular Werte."""
  468. if not self.connection or not self._plugin_data:
  469. self._set_status("[red]Keine Verbindung[/red]")
  470. return
  471. plugin_name = self._plugin_data.get("name", "")
  472. if not plugin_name:
  473. return
  474. values = self._collect_values()
  475. schema = self._tab_data.get("schema", {})
  476. config_section = schema.get("config_section", "")
  477. try:
  478. self._set_status("[yellow]Speichern...[/yellow]")
  479. for key, value in values.items():
  480. path = f"{config_section}.{key}" if config_section else key
  481. result = await self.connection.write_plugin_config(
  482. plugin_name=plugin_name, key_path=path, value=value,
  483. )
  484. if not result or not result.success:
  485. error = result.error if result else "Unbekannt"
  486. self._set_status(f"[red]Fehler bei {key}: {error}[/red]")
  487. return
  488. self._set_status("[green]Einstellungen gespeichert[/green]")
  489. except Exception as e:
  490. self._set_status(f"[red]Fehler: {e}[/red]")
  491. # ========================================================================
  492. # Hilfsmethoden
  493. # ========================================================================
  494. def _set_status(self, text: str) -> None:
  495. """Setzt die Statuszeile."""
  496. status_id = f"{self.VIEW_ID}-status"
  497. try:
  498. status = self.query_one(f"#{status_id}", Static)
  499. status.update(text)
  500. except Exception:
  501. pass