app.py 28 KB

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