app.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. # -*- coding: utf-8 -*-
  2. """
  3. Trixy-TUI Hauptanwendung.
  4. Textual-basierte Benutzeroberflaeche fuer das Config-Tool.
  5. """
  6. from __future__ import annotations
  7. from pathlib import Path
  8. from typing import TYPE_CHECKING
  9. from textual.app import App, ComposeResult
  10. from textual.binding import Binding
  11. from textual.containers import Container
  12. from trixy_core.tui.menu_context import MenuContext, MenuBinding
  13. from trixy_core.tui.widgets.header import TrixyHeader
  14. from trixy_core.tui.widgets.footer import TrixyFooter
  15. from trixy_core.tui.views.base import MainView
  16. if TYPE_CHECKING:
  17. from trixy_core.config_app import ConfigConnection
  18. # View-IDs der Satellite-SubViews
  19. SAT_SUBVIEW_IDS = ("sat_info", "sat_connection", "sat_conversation", "sat_config", "sat_updates")
  20. # View-IDs der Plugin-SubViews (Basis)
  21. PLUGIN_SUBVIEW_IDS = ("plugin_info", "plugin_config")
  22. # View-IDs der Schedule-SubViews
  23. SCHED_SUBVIEW_IDS = ("sched_info", "sched_trigger", "sched_conditions", "sched_actions")
  24. # View-IDs der Trainer-SubViews
  25. TRAINER_SUBVIEW_IDS = (
  26. "trainer_info", "trainer_settings", "trainer_dataset",
  27. "trainer_optional", "trainer_validate", "trainer_training",
  28. )
  29. class TrixyTUI(App):
  30. """
  31. Textual-App fuer die Trixy Remote-Verwaltung.
  32. Verwaltet Views, F-Tasten-Bindungen und die Verbindung
  33. zur laufenden Trixy-Instanz. Unterstuetzt zwei Modi:
  34. - Haupt-Navigation (F1=Health, F2=Config, F3=Satellites, F9=Log)
  35. - Satellite-Kontext (F1=Info, F2=Connection, F3=Conversation, F4=Config, F5=Updates)
  36. """
  37. CSS_PATH = [
  38. Path(__file__).parent / "css" / "base.tcss",
  39. Path(__file__).parent / "css" / "health.tcss",
  40. Path(__file__).parent / "css" / "config.tcss",
  41. Path(__file__).parent / "css" / "satellites.tcss",
  42. Path(__file__).parent / "css" / "plugins.tcss",
  43. Path(__file__).parent / "css" / "popup.tcss",
  44. Path(__file__).parent / "css" / "schedule.tcss",
  45. Path(__file__).parent / "css" / "trainer.tcss",
  46. ]
  47. BINDINGS = [
  48. Binding("f1", "fkey('f1')", "F1", show=False),
  49. Binding("f2", "fkey('f2')", "F2", show=False),
  50. Binding("f3", "fkey('f3')", "F3", show=False),
  51. Binding("f4", "fkey('f4')", "F4", show=False),
  52. Binding("f5", "fkey('f5')", "F5", show=False),
  53. Binding("f6", "fkey('f6')", "F6", show=False),
  54. Binding("f7", "fkey('f7')", "F7", show=False),
  55. Binding("f9", "fkey('f9')", "F9", show=False),
  56. Binding("f10", "back", "Zurueck", show=False),
  57. Binding("pageup", "scroll_up_page", "PageUp", show=False),
  58. Binding("pagedown", "scroll_down_page", "PageDown", show=False),
  59. Binding("q", "quit", "Beenden", show=False),
  60. ]
  61. def __init__(
  62. self,
  63. views: list[MainView] | None = None,
  64. title: str = "Trixy Config",
  65. connection_info: str = "",
  66. **kwargs,
  67. ) -> None:
  68. super().__init__(**kwargs)
  69. self.scroll_sensitivity_y = 1.0
  70. self._views = views or []
  71. self._view_map: dict[str, MainView] = {}
  72. self._active_view: MainView | None = None
  73. self._connection: ConfigConnection | None = None
  74. self._connection_info = connection_info
  75. self._app_title = title
  76. # Satellite-Kontext
  77. self._in_satellite_context: bool = False
  78. self._satellite_data: dict = {}
  79. self._parent_view_id: str = ""
  80. # Plugin-Kontext
  81. self._in_plugin_context: bool = False
  82. self._plugin_data: dict = {}
  83. self._plugin_custom_views: list[MainView] = []
  84. # Schedule-Kontext
  85. self._in_schedule_context: bool = False
  86. self._schedule_data: dict = {}
  87. # Trainer-Kontext
  88. self._in_trainer_context: bool = False
  89. self._trainer_data: dict = {}
  90. # MenuContexte: Haupt, Satellite, Plugin, Schedule und Trainer
  91. self._main_menu = MenuContext()
  92. self._sat_menu = MenuContext()
  93. self._plugin_menu = MenuContext()
  94. self._sched_menu = MenuContext()
  95. self._trainer_menu = MenuContext()
  96. # Views registrieren
  97. for view in self._views:
  98. self._view_map[view.VIEW_ID] = view
  99. # Haupt-MenuContext aufbauen
  100. main_key_map = {
  101. "health": "f1",
  102. "config": "f2",
  103. "satellites": "f3",
  104. "plugins": "f4",
  105. "schedule": "f5",
  106. "trainer": "f7",
  107. "log": "f9",
  108. }
  109. for view in self._views:
  110. key = main_key_map.get(view.VIEW_ID, "")
  111. if key:
  112. self._main_menu.add(MenuBinding(
  113. key=key, label=view.TITLE, view_id=view.VIEW_ID,
  114. ))
  115. # Satellite-MenuContext aufbauen
  116. sat_key_map = {
  117. "sat_info": "f1",
  118. "sat_connection": "f2",
  119. "sat_conversation": "f3",
  120. "sat_config": "f4",
  121. "sat_updates": "f5",
  122. }
  123. for view in self._views:
  124. key = sat_key_map.get(view.VIEW_ID, "")
  125. if key:
  126. self._sat_menu.add(MenuBinding(
  127. key=key, label=view.TITLE, view_id=view.VIEW_ID,
  128. ))
  129. # F10 Zurueck im Satellite-Kontext
  130. self._sat_menu.add(MenuBinding(key="f10", label="Zurueck", view_id=""))
  131. # Plugin-MenuContext aufbauen
  132. plugin_key_map = {
  133. "plugin_info": "f1",
  134. "plugin_config": "f2",
  135. }
  136. for view in self._views:
  137. key = plugin_key_map.get(view.VIEW_ID, "")
  138. if key:
  139. self._plugin_menu.add(MenuBinding(
  140. key=key, label=view.TITLE, view_id=view.VIEW_ID,
  141. ))
  142. self._plugin_fkey_map: dict[str, str] = {}
  143. for vid, key in plugin_key_map.items():
  144. if vid in self._view_map:
  145. self._plugin_fkey_map[key] = vid
  146. # F10 Zurueck im Plugin-Kontext
  147. self._plugin_menu.add(MenuBinding(key="f10", label="Zurueck", view_id=""))
  148. # Schedule-MenuContext aufbauen
  149. sched_key_map = {
  150. "sched_info": "f1",
  151. "sched_trigger": "f2",
  152. "sched_conditions": "f3",
  153. "sched_actions": "f4",
  154. }
  155. for view in self._views:
  156. key = sched_key_map.get(view.VIEW_ID, "")
  157. if key:
  158. self._sched_menu.add(MenuBinding(
  159. key=key, label=view.TITLE, view_id=view.VIEW_ID,
  160. ))
  161. self._sched_fkey_map: dict[str, str] = {}
  162. for vid, key in sched_key_map.items():
  163. if vid in self._view_map:
  164. self._sched_fkey_map[key] = vid
  165. # F10 Zurueck im Schedule-Kontext
  166. self._sched_menu.add(MenuBinding(key="f10", label="Zurueck", view_id=""))
  167. # Trainer-MenuContext aufbauen
  168. trainer_key_map = {
  169. "trainer_info": "f1",
  170. "trainer_settings": "f2",
  171. "trainer_dataset": "f3",
  172. "trainer_optional": "f4",
  173. "trainer_validate": "f5",
  174. "trainer_training": "f6",
  175. }
  176. for view in self._views:
  177. key = trainer_key_map.get(view.VIEW_ID, "")
  178. if key:
  179. self._trainer_menu.add(MenuBinding(
  180. key=key, label=view.TITLE, view_id=view.VIEW_ID,
  181. ))
  182. self._trainer_fkey_map: dict[str, str] = {}
  183. for vid, key in trainer_key_map.items():
  184. if vid in self._view_map:
  185. self._trainer_fkey_map[key] = vid
  186. # F10 Zurueck im Trainer-Kontext
  187. self._trainer_menu.add(MenuBinding(key="f10", label="Zurueck", view_id=""))
  188. # F10 Zurueck im Haupt-Menue (nur anzeigen, Aktion in action_back)
  189. self._main_menu.add(MenuBinding(key="f10", label="Zurueck", view_id=""))
  190. # F-Tasten-Zuordnungen
  191. self._main_fkey_map: dict[str, str] = {}
  192. for vid, key in main_key_map.items():
  193. if vid in self._view_map:
  194. self._main_fkey_map[key] = vid
  195. self._sat_fkey_map: dict[str, str] = {}
  196. for vid, key in sat_key_map.items():
  197. if vid in self._view_map:
  198. self._sat_fkey_map[key] = vid
  199. # Client-Modus: Satellite SubViews direkt als Haupt-Navigation
  200. self._client_satellite_mode = self._detect_client_satellite_mode()
  201. if self._client_satellite_mode:
  202. # Im Client-Modus sind die Sat-SubViews die Haupt-Navigation
  203. self._main_fkey_map = dict(self._sat_fkey_map)
  204. self._main_menu = MenuContext()
  205. for view in self._views:
  206. key = sat_key_map.get(view.VIEW_ID, "")
  207. if key:
  208. self._main_menu.add(MenuBinding(
  209. key=key, label=view.TITLE, view_id=view.VIEW_ID,
  210. ))
  211. # Auch Health/Config/Log behalten wenn vorhanden
  212. if "health" in self._view_map:
  213. self._main_fkey_map["f9"] = "health"
  214. self._main_menu.add(MenuBinding(key="f9", label="Health", view_id="health"))
  215. def _detect_client_satellite_mode(self) -> bool:
  216. """Erkennt ob wir im Client-Modus sind (SubViews direkt anzeigen)."""
  217. has_sat_subviews = any(vid in self._view_map for vid in SAT_SUBVIEW_IDS)
  218. has_satellites_list = "satellites" in self._view_map
  219. return has_sat_subviews and not has_satellites_list
  220. @property
  221. def connection(self) -> "ConfigConnection | None":
  222. """Aktive Verbindung."""
  223. return self._connection
  224. def set_connection(self, connection: "ConfigConnection") -> None:
  225. """Setzt die Netzwerkverbindung und propagiert an alle Views."""
  226. self._connection = connection
  227. for view in self._views:
  228. view.connection = connection
  229. def compose(self) -> ComposeResult:
  230. """Baut das UI-Layout auf."""
  231. yield TrixyHeader(id="header")
  232. with Container(id="content-area"):
  233. for view in self._views:
  234. view.display = False
  235. yield view
  236. yield TrixyFooter(menu_context=self._main_menu, id="footer")
  237. async def on_mount(self) -> None:
  238. """Initialisiert die TUI nach dem Mounten."""
  239. header = self.query_one("#header", TrixyHeader)
  240. header.connection_info = self._connection_info
  241. # Ersten View aktivieren
  242. if self._client_satellite_mode:
  243. # Client-Modus: Ersten Sat-SubView aktivieren
  244. for vid in SAT_SUBVIEW_IDS:
  245. if vid in self._view_map:
  246. await self._activate_view(vid)
  247. break
  248. elif self._views:
  249. await self._activate_view(self._views[0].VIEW_ID)
  250. # Periodische Aktualisierung starten
  251. self.set_interval(2.0, self._periodic_refresh)
  252. async def action_fkey(self, key: str) -> None:
  253. """Verarbeitet F-Tasten-Druecke kontextabhaengig."""
  254. if self._in_satellite_context:
  255. # Satellite-Kontext: Sat-SubViews wechseln
  256. view_id = self._sat_fkey_map.get(key)
  257. if view_id and view_id in self._view_map:
  258. await self._activate_view(view_id)
  259. self._update_header_satellite()
  260. elif self._in_plugin_context:
  261. # Plugin-Kontext: Plugin-SubViews wechseln
  262. view_id = self._plugin_fkey_map.get(key)
  263. if view_id and view_id in self._view_map:
  264. await self._activate_view(view_id)
  265. self._update_header_plugin()
  266. elif self._in_schedule_context:
  267. # Schedule-Kontext: Schedule-SubViews wechseln
  268. view_id = self._sched_fkey_map.get(key)
  269. if view_id and view_id in self._view_map:
  270. await self._activate_view(view_id)
  271. self._update_header_schedule()
  272. elif self._in_trainer_context:
  273. # Trainer-Kontext: Trainer-SubViews wechseln
  274. view_id = self._trainer_fkey_map.get(key)
  275. if view_id and view_id in self._view_map:
  276. await self._activate_view(view_id)
  277. self._update_header_trainer()
  278. else:
  279. # Haupt-Navigation
  280. view_id = self._main_fkey_map.get(key)
  281. if view_id and view_id in self._view_map:
  282. self._parent_view_id = ""
  283. await self._activate_view(view_id)
  284. async def action_back(self) -> None:
  285. """F10: Zurueck — verlaesst Satellite/Plugin-Kontext oder navigiert zurueck."""
  286. if self._in_satellite_context:
  287. await self._leave_satellite_context()
  288. elif self._in_plugin_context:
  289. await self._leave_plugin_context()
  290. elif self._in_schedule_context:
  291. await self._leave_schedule_context()
  292. elif self._in_trainer_context:
  293. await self._leave_trainer_context()
  294. elif self._parent_view_id and self._parent_view_id in self._view_map:
  295. parent_id = self._parent_view_id
  296. self._parent_view_id = ""
  297. await self._activate_view(parent_id)
  298. async def open_satellite_detail(self, sat_data: dict) -> None:
  299. """Oeffnet die Satellite-SubViews fuer einen ausgewaehlten Satellite."""
  300. await self._enter_satellite_context(sat_data)
  301. async def _enter_satellite_context(self, sat_data: dict) -> None:
  302. """Wechselt in den Satellite-Kontext mit Sub-Navigation."""
  303. self._in_satellite_context = True
  304. self._satellite_data = sat_data
  305. self._parent_view_id = "satellites"
  306. # Satellite-Daten an alle SubViews propagieren
  307. for vid in SAT_SUBVIEW_IDS:
  308. view = self._view_map.get(vid)
  309. if view and hasattr(view, "set_satellite"):
  310. view.set_satellite(sat_data)
  311. # Footer auf Satellite-Menue umschalten
  312. footer = self.query_one("#footer", TrixyFooter)
  313. footer.update_menu(self._sat_menu)
  314. # Ersten Satellite-SubView aktivieren
  315. for vid in SAT_SUBVIEW_IDS:
  316. if vid in self._view_map:
  317. await self._activate_view(vid)
  318. break
  319. self._update_header_satellite()
  320. async def _leave_satellite_context(self) -> None:
  321. """Verlaesst den Satellite-Kontext und kehrt zur Satellite-Liste zurueck."""
  322. self._in_satellite_context = False
  323. self._satellite_data = {}
  324. # Footer auf Haupt-Menue zurueckschalten
  325. footer = self.query_one("#footer", TrixyFooter)
  326. footer.update_menu(self._main_menu)
  327. # Zurueck zur Satellite-Liste
  328. if "satellites" in self._view_map:
  329. await self._activate_view("satellites")
  330. elif self._parent_view_id and self._parent_view_id in self._view_map:
  331. await self._activate_view(self._parent_view_id)
  332. self._parent_view_id = ""
  333. # ── Plugin-Kontext ──
  334. async def open_plugin_detail(self, plugin_data: dict) -> None:
  335. """Oeffnet die Plugin-SubViews fuer ein ausgewaehltes Plugin."""
  336. await self._enter_plugin_context(plugin_data)
  337. async def _enter_plugin_context(self, plugin_data: dict) -> None:
  338. """Wechselt in den Plugin-Kontext mit Sub-Navigation."""
  339. self._in_plugin_context = True
  340. self._plugin_data = plugin_data
  341. self._parent_view_id = "plugins"
  342. # Plugin-Daten an alle SubViews propagieren
  343. for vid in PLUGIN_SUBVIEW_IDS:
  344. view = self._view_map.get(vid)
  345. if view and hasattr(view, "set_plugin"):
  346. view.set_plugin(plugin_data)
  347. # Benutzerdefinierte Tabs hinzufuegen (aus custom_tabs der Detail-Daten)
  348. await self._setup_plugin_custom_tabs(plugin_data)
  349. # Footer auf Plugin-Menue umschalten
  350. footer = self.query_one("#footer", TrixyFooter)
  351. footer.update_menu(self._plugin_menu)
  352. # Ersten Plugin-SubView aktivieren
  353. for vid in PLUGIN_SUBVIEW_IDS:
  354. if vid in self._view_map:
  355. await self._activate_view(vid)
  356. break
  357. self._update_header_plugin()
  358. async def _setup_plugin_custom_tabs(self, plugin_data: dict) -> None:
  359. """Richtet benutzerdefinierte Plugin-Tabs ein."""
  360. # Alte Custom-Views entfernen
  361. for view in self._plugin_custom_views:
  362. vid = view.VIEW_ID
  363. self._view_map.pop(vid, None)
  364. self._plugin_menu.remove(vid)
  365. self._plugin_fkey_map = {
  366. k: v for k, v in self._plugin_fkey_map.items() if v != vid
  367. }
  368. self._plugin_custom_views.clear()
  369. # Wenn custom_tabs vorhanden, Detail anfordern
  370. if not self.connection or not plugin_data.get("name"):
  371. return
  372. try:
  373. detail = await self.connection.request_plugin_detail(plugin_data["name"])
  374. except Exception:
  375. return
  376. if not detail or not detail.custom_tabs:
  377. return
  378. # Custom-Tabs erstellen (F3, F4, F5, ...)
  379. from trixy_core.tui.views.plugin_custom_view import PluginCustomView
  380. fkey_start = 3 # F3 aufwaerts
  381. content_area = self.query_one("#content-area")
  382. for i, tab in enumerate(detail.custom_tabs):
  383. fkey_num = fkey_start + i
  384. if fkey_num > 8: # Max F8 (F9=Log, F10=Zurueck)
  385. break
  386. tab_title = tab.get("title", f"Tab {fkey_num}")
  387. tab_id = f"plugin_custom_{fkey_num}"
  388. fkey = f"f{fkey_num}"
  389. custom_view = PluginCustomView(
  390. tab_title=tab_title,
  391. tab_id=tab_id,
  392. tab_data=tab,
  393. connection=self._connection,
  394. )
  395. custom_view.set_plugin(plugin_data)
  396. custom_view.display = False
  397. # Dynamisch in den Content-Bereich mounten
  398. await content_area.mount(custom_view)
  399. self._view_map[tab_id] = custom_view
  400. self._plugin_custom_views.append(custom_view)
  401. self._plugin_menu.add(MenuBinding(
  402. key=fkey, label=tab_title, view_id=tab_id,
  403. ))
  404. self._plugin_fkey_map[fkey] = tab_id
  405. async def _leave_plugin_context(self) -> None:
  406. """Verlaesst den Plugin-Kontext und kehrt zur Plugin-Liste zurueck."""
  407. self._in_plugin_context = False
  408. self._plugin_data = {}
  409. # Custom-Views entfernen und unmounten
  410. for view in self._plugin_custom_views:
  411. vid = view.VIEW_ID
  412. self._view_map.pop(vid, None)
  413. self._plugin_menu.remove(vid)
  414. self._plugin_fkey_map = {
  415. k: v for k, v in self._plugin_fkey_map.items() if v != vid
  416. }
  417. try:
  418. await view.remove()
  419. except Exception:
  420. pass
  421. self._plugin_custom_views.clear()
  422. # Footer auf Haupt-Menue zurueckschalten
  423. footer = self.query_one("#footer", TrixyFooter)
  424. footer.update_menu(self._main_menu)
  425. # Zurueck zur Plugin-Liste
  426. if "plugins" in self._view_map:
  427. await self._activate_view("plugins")
  428. self._parent_view_id = ""
  429. def _update_header_plugin(self) -> None:
  430. """Aktualisiert den Header im Plugin-Kontext."""
  431. header = self.query_one("#header", TrixyHeader)
  432. name = self._plugin_data.get("name", "Plugin")
  433. if self._active_view:
  434. header.subtitle = f"Plugins > {name} > {self._active_view.TITLE}"
  435. else:
  436. header.subtitle = f"Plugins > {name}"
  437. # ── Schedule-Kontext ──
  438. async def open_schedule_detail(self, job_data: dict) -> None:
  439. """Oeffnet die Schedule-SubViews fuer einen ausgewaehlten Job."""
  440. await self._enter_schedule_context(job_data)
  441. async def _enter_schedule_context(self, job_data: dict) -> None:
  442. """Wechselt in den Schedule-Kontext mit Sub-Navigation."""
  443. self._in_schedule_context = True
  444. self._schedule_data = job_data
  445. self._parent_view_id = "schedule"
  446. # FormMeta laden (Trigger/Condition/Action-Typen mit Formular-Feldern)
  447. form_meta = None
  448. if self._connection:
  449. try:
  450. form_meta = await self._connection.request_schedule_form_meta()
  451. except Exception:
  452. pass
  453. # Job-Daten und FormMeta an alle SubViews propagieren
  454. for vid in SCHED_SUBVIEW_IDS:
  455. view = self._view_map.get(vid)
  456. if view and hasattr(view, "set_job"):
  457. view.set_job(job_data)
  458. if form_meta and view and hasattr(view, "set_form_meta"):
  459. if vid == "sched_trigger":
  460. view.set_form_meta(form_meta.triggers if hasattr(form_meta, "triggers") else {})
  461. elif vid == "sched_conditions":
  462. view.set_form_meta(form_meta.conditions if hasattr(form_meta, "conditions") else {})
  463. elif vid == "sched_actions":
  464. view.set_form_meta(form_meta.actions if hasattr(form_meta, "actions") else {})
  465. # Footer auf Schedule-Menue umschalten
  466. footer = self.query_one("#footer", TrixyFooter)
  467. footer.update_menu(self._sched_menu)
  468. # Ersten Schedule-SubView aktivieren
  469. for vid in SCHED_SUBVIEW_IDS:
  470. if vid in self._view_map:
  471. await self._activate_view(vid)
  472. break
  473. self._update_header_schedule()
  474. async def _leave_schedule_context(self) -> None:
  475. """Verlaesst den Schedule-Kontext und kehrt zur Schedule-Liste zurueck."""
  476. self._in_schedule_context = False
  477. self._schedule_data = {}
  478. # Footer auf Haupt-Menue zurueckschalten
  479. footer = self.query_one("#footer", TrixyFooter)
  480. footer.update_menu(self._main_menu)
  481. # Zurueck zur Schedule-Liste
  482. if "schedule" in self._view_map:
  483. await self._activate_view("schedule")
  484. self._parent_view_id = ""
  485. async def save_schedule_job(self) -> tuple[bool, str]:
  486. """Sammelt Daten aus allen Schedule-SubViews und speichert den Job."""
  487. if not self._connection:
  488. return False, "Keine Verbindung"
  489. # Daten aus allen Sub-Views sammeln
  490. info_view = self._view_map.get("sched_info")
  491. trigger_view = self._view_map.get("sched_trigger")
  492. cond_view = self._view_map.get("sched_conditions")
  493. action_view = self._view_map.get("sched_actions")
  494. info_data = info_view.collect_data() if info_view and hasattr(info_view, "collect_data") else {}
  495. trigger_data = trigger_view.collect_data() if trigger_view and hasattr(trigger_view, "collect_data") else {}
  496. conditions_data = cond_view.collect_data() if cond_view and hasattr(cond_view, "collect_data") else []
  497. actions_data = action_view.collect_data() if action_view and hasattr(action_view, "collect_data") else []
  498. job_id = self._schedule_data.get("job_id", "")
  499. name = info_data.get("name", "")
  500. description = info_data.get("description", "")
  501. config = info_data.get("config", {})
  502. config["enabled"] = info_data.get("enabled", True)
  503. if not name:
  504. return False, "Name ist erforderlich"
  505. try:
  506. result = await self._connection.save_schedule_job(
  507. job_id=job_id,
  508. name=name,
  509. description=description,
  510. trigger=trigger_data,
  511. conditions=conditions_data,
  512. actions=actions_data,
  513. config=config,
  514. )
  515. if result and result.success:
  516. # Job-ID aktualisieren (bei neuem Job)
  517. if result.job_id:
  518. self._schedule_data["job_id"] = result.job_id
  519. self._schedule_data["name"] = name
  520. return True, f"Job gespeichert (ID: {result.job_id})"
  521. error = result.error if result else "Unbekannter Fehler"
  522. return False, f"Fehler: {error}"
  523. except Exception as e:
  524. return False, f"Fehler: {e}"
  525. def _update_header_schedule(self) -> None:
  526. """Aktualisiert den Header im Schedule-Kontext."""
  527. header = self.query_one("#header", TrixyHeader)
  528. name = self._schedule_data.get("name", "Job")
  529. if self._active_view:
  530. header.subtitle = f"Schedule > {name} > {self._active_view.TITLE}"
  531. else:
  532. header.subtitle = f"Schedule > {name}"
  533. # ── Trainer-Kontext ──
  534. async def open_trainer_detail(self, trainer_data: dict) -> None:
  535. """Oeffnet die Trainer-SubViews fuer einen ausgewaehlten Trainer."""
  536. await self._enter_trainer_context(trainer_data)
  537. async def _enter_trainer_context(self, trainer_data: dict) -> None:
  538. """Wechselt in den Trainer-Kontext mit Sub-Navigation."""
  539. self._in_trainer_context = True
  540. self._trainer_data = trainer_data
  541. self._parent_view_id = "trainer"
  542. # Trainer-Daten an alle SubViews propagieren
  543. for vid in TRAINER_SUBVIEW_IDS:
  544. view = self._view_map.get(vid)
  545. if view and hasattr(view, "set_trainer"):
  546. view.set_trainer(trainer_data)
  547. # Optional-Tab ausblenden falls kein Schema
  548. # (wird beim Laden in on_activate geprueft)
  549. # Footer auf Trainer-Menue umschalten
  550. footer = self.query_one("#footer", TrixyFooter)
  551. footer.update_menu(self._trainer_menu)
  552. # Ersten Trainer-SubView aktivieren
  553. for vid in TRAINER_SUBVIEW_IDS:
  554. if vid in self._view_map:
  555. await self._activate_view(vid)
  556. break
  557. self._update_header_trainer()
  558. async def _leave_trainer_context(self) -> None:
  559. """Verlaesst den Trainer-Kontext und kehrt zur Trainer-Liste zurueck."""
  560. self._in_trainer_context = False
  561. self._trainer_data = {}
  562. # Footer auf Haupt-Menue zurueckschalten
  563. footer = self.query_one("#footer", TrixyFooter)
  564. footer.update_menu(self._main_menu)
  565. # Zurueck zur Trainer-Liste
  566. if "trainer" in self._view_map:
  567. await self._activate_view("trainer")
  568. self._parent_view_id = ""
  569. def _update_header_trainer(self) -> None:
  570. """Aktualisiert den Header im Trainer-Kontext."""
  571. header = self.query_one("#header", TrixyHeader)
  572. name = self._trainer_data.get("name", "Trainer")
  573. if self._active_view:
  574. header.subtitle = f"Trainer > {name} > {self._active_view.TITLE}"
  575. else:
  576. header.subtitle = f"Trainer > {name}"
  577. def _update_header_satellite(self) -> None:
  578. """Aktualisiert den Header im Satellite-Kontext."""
  579. header = self.query_one("#header", TrixyHeader)
  580. alias = self._satellite_data.get("alias", "Satellite")
  581. if self._active_view:
  582. header.subtitle = f"Satellites > {alias} > {self._active_view.TITLE}"
  583. else:
  584. header.subtitle = f"Satellites > {alias}"
  585. async def _activate_view(self, view_id: str) -> None:
  586. """Aktiviert einen View und deaktiviert den aktuellen."""
  587. new_view = self._view_map.get(view_id)
  588. if not new_view or new_view is self._active_view:
  589. return
  590. # Alten View deaktivieren
  591. if self._active_view:
  592. await self._active_view.on_deactivate()
  593. self._active_view.display = False
  594. # Neuen View aktivieren
  595. new_view.display = True
  596. self._active_view = new_view
  597. await new_view.on_activate()
  598. # Header aktualisieren (nur wenn nicht in Sub-Kontext)
  599. if not self._in_satellite_context and not self._in_plugin_context and not self._in_schedule_context and not self._in_trainer_context:
  600. header = self.query_one("#header", TrixyHeader)
  601. header.subtitle = new_view.TITLE
  602. def action_scroll_up_page(self) -> None:
  603. """PageUp: Scrollt 5 Zeilen nach oben."""
  604. if self._active_view:
  605. self._active_view.scroll_relative(y=-5, animate=False)
  606. def action_scroll_down_page(self) -> None:
  607. """PageDown: Scrollt 5 Zeilen nach unten."""
  608. if self._active_view:
  609. self._active_view.scroll_relative(y=5, animate=False)
  610. async def _periodic_refresh(self) -> None:
  611. """Aktualisiert den aktiven View periodisch."""
  612. if self._active_view and self._connection:
  613. try:
  614. await self._active_view.refresh_data()
  615. except Exception:
  616. pass