app_window.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. """
  2. LFMRIWindow - main application window for the unified LF-MRI GUI.
  3. Single nav-bar design: tab buttons on the left.
  4. The native QMenuBar and QTabWidget tab-bar are both hidden; a QToolBar
  5. provides a unified, flat navigation strip.
  6. """
  7. from __future__ import annotations
  8. import os
  9. import threading
  10. from PySide6.QtCore import Qt, QObject, QSize, QTimer, Signal
  11. from PySide6.QtWidgets import (
  12. QApplication,
  13. QButtonGroup,
  14. QFrame,
  15. QLabel,
  16. QMainWindow,
  17. QMessageBox,
  18. QPushButton,
  19. QSizePolicy,
  20. QStatusBar,
  21. QTabWidget,
  22. QToolBar,
  23. QWidget,
  24. )
  25. from src import i18n, theme
  26. from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
  27. from src.tabs.fid_tab import FidTab
  28. from src.tabs.scanner_tab import ScannerTab
  29. from src.tabs.scanning_tab import ScanningTab
  30. from src.tabs.seq_interp_tab import SeqInterpTab
  31. from src.tabs.spectroscopy_tab import SpectroscopyTab
  32. _TAB_NAV_KEYS = [
  33. "tab_nav_scanning",
  34. "tab_nav_sequence",
  35. "tab_nav_scanner",
  36. "tab_nav_spectro",
  37. "tab_nav_fid",
  38. ]
  39. _SPEC_TAB_IDX = 3
  40. _NAV_H = 38
  41. # -- dynamic CSS helpers -------------------------------------------------------
  42. def _nav_bg(dark: bool) -> str:
  43. return "#0f0f1e" if dark else "#f0f0f8"
  44. def _nav_toolbar_css(dark: bool) -> str:
  45. bg = _nav_bg(dark)
  46. border = "#1e1e38" if dark else "#d0d0e8"
  47. return (
  48. f"QToolBar {{ background: {bg}; border: none; "
  49. f"border-bottom: 1px solid {border}; spacing: 0px; padding: 0px; }}"
  50. )
  51. def _tab_btn_css(dark: bool) -> str:
  52. inactive = "#7777aa" if dark else "#666688"
  53. active = "#ffffff" if dark else "#1a1a2e"
  54. accent = "#f0c040" if dark else "#c09000"
  55. hover_bg = "#17172e" if dark else "#e0e0f4"
  56. hover_txt = "#aaaacc" if dark else "#333355"
  57. return f"""
  58. QPushButton {{
  59. background: transparent;
  60. color: {inactive};
  61. border: none;
  62. border-bottom: 2px solid transparent;
  63. padding: 0px 20px;
  64. font-size: 12px;
  65. min-height: 36px;
  66. }}
  67. QPushButton:checked {{
  68. color: {active};
  69. border-bottom: 2px solid {accent};
  70. }}
  71. QPushButton:hover:!checked {{
  72. color: {hover_txt};
  73. background: {hover_bg};
  74. }}
  75. """
  76. def _lang_btn_css(dark: bool) -> str:
  77. inactive = "#555577" if dark else "#666688"
  78. active = "#f0c040" if dark else "#c09000"
  79. hover = "#aaaacc" if dark else "#1a1a2e"
  80. return f"""
  81. QPushButton {{
  82. background: transparent;
  83. color: {inactive};
  84. border: 1px solid transparent;
  85. border-radius: 3px;
  86. padding: 0px 7px;
  87. font-size: 11px;
  88. font-weight: bold;
  89. min-height: 22px;
  90. max-height: 22px;
  91. }}
  92. QPushButton:checked {{
  93. color: {active};
  94. border-color: {active};
  95. }}
  96. QPushButton:hover:!checked {{
  97. color: {hover};
  98. }}
  99. """
  100. def _mode_btn_css(dark: bool, mode: str) -> str:
  101. if mode == "real":
  102. color = "#2ecc71"
  103. border = "#27ae60"
  104. else:
  105. color = "#f39c12"
  106. border = "#e67e00"
  107. return f"""
  108. QPushButton {{
  109. background: transparent;
  110. color: {color};
  111. border: 1px solid {border};
  112. border-radius: 3px;
  113. padding: 0px 10px;
  114. font-size: 11px;
  115. font-weight: bold;
  116. min-height: 22px;
  117. max-height: 22px;
  118. letter-spacing: 0.5px;
  119. }}
  120. QPushButton:hover {{
  121. background: {color}22;
  122. }}
  123. QPushButton:disabled {{
  124. color: #555577;
  125. border-color: #333355;
  126. }}
  127. """
  128. class _ModeSignalBridge(QObject):
  129. """Carries results of background mode HTTP calls back to the Qt main thread."""
  130. mode_fetched = Signal(str) # mode string on successful GET /mode
  131. mode_set = Signal(str) # mode string on successful POST /mode
  132. mode_error = Signal(str) # error message
  133. def _spec_btn_blink_css(dark: bool) -> str:
  134. active_txt = "#ffffff" if dark else "#1a1a2e"
  135. accent = "#f0c040" if dark else "#c09000"
  136. hover_bg = "#17172e" if dark else "#e0e0f4"
  137. return f"""
  138. QPushButton {{
  139. background: transparent;
  140. color: #e65100;
  141. border: none;
  142. border-bottom: 2px solid #e65100;
  143. padding: 0px 20px;
  144. font-size: 12px;
  145. min-height: 36px;
  146. }}
  147. QPushButton:checked {{
  148. color: {active_txt};
  149. border-bottom: 2px solid {accent};
  150. }}
  151. QPushButton:hover:!checked {{
  152. color: #ff7733;
  153. background: {hover_bg};
  154. }}
  155. """
  156. class LFMRIWindow(QMainWindow):
  157. """Unified LF-MRI application window."""
  158. def __init__(
  159. self,
  160. hw_config_path: str | None = None,
  161. output_dir: str | None = None,
  162. seq_file: str | None = None,
  163. orchestrator_url: str = "http://localhost:1717",
  164. seq_interp_url: str = "http://localhost:7475",
  165. spectroscopy_url: str = "http://localhost:8002",
  166. reconstructor_url: str = "http://localhost:8081",
  167. spectrometer_url: str = "http://localhost:8000",
  168. ) -> None:
  169. super().__init__()
  170. self.setWindowTitle("LF-MRI System")
  171. self.setMinimumSize(960, 640)
  172. self._hw_config_path = hw_config_path
  173. self._output_dir = output_dir
  174. self._orchestrator_url = orchestrator_url.rstrip("/")
  175. self._seq_tab = SeqInterpTab(
  176. hw_config_path=hw_config_path,
  177. output_dir=output_dir,
  178. seq_interp_url=seq_interp_url,
  179. )
  180. self._scanner_tab = ScannerTab(
  181. hw_config_path=hw_config_path,
  182. orchestrator_url=orchestrator_url,
  183. seq_interp_url=seq_interp_url,
  184. spectroscopy_url=spectroscopy_url,
  185. reconstructor_url=reconstructor_url,
  186. spectrometer_url=spectrometer_url,
  187. )
  188. self._fid_tab = FidTab(
  189. hw_config_path=hw_config_path,
  190. output_dir=output_dir,
  191. )
  192. self._scanning_tab = ScanningTab()
  193. self._scanning_tab.set_orchestrator_url(orchestrator_url)
  194. self._spectroscopy_tab = SpectroscopyTab(spectroscopy_url=spectroscopy_url)
  195. self._tabs = QTabWidget()
  196. self._tabs.tabBar().hide()
  197. self._tabs.setDocumentMode(True)
  198. self._tabs.addTab(self._scanning_tab, i18n.tr(_TAB_NAV_KEYS[0]))
  199. self._tabs.addTab(self._seq_tab, i18n.tr(_TAB_NAV_KEYS[1]))
  200. self._tabs.addTab(self._scanner_tab, i18n.tr(_TAB_NAV_KEYS[2]))
  201. self._tabs.addTab(self._spectroscopy_tab, i18n.tr(_TAB_NAV_KEYS[3]))
  202. self._tabs.addTab(self._fid_tab, i18n.tr(_TAB_NAV_KEYS[4]))
  203. self._tabs.currentChanged.connect(self._on_tab_changed)
  204. self.setCentralWidget(self._tabs)
  205. self._fid_tab.fid_seq_generated.connect(self._on_fid_generated)
  206. self._seq_tab.ready_for_scan.connect(self._on_ready_for_scan)
  207. self._scanning_tab.scan_job_started.connect(self._scanner_tab.attach_job)
  208. self._scanning_tab.raw_data_ready.connect(self._on_scan_raw_data_ready)
  209. self._scanner_tab.scan_result_ready.connect(self._spectroscopy_tab.receive_scan_data)
  210. self._spec_blink_on: bool = False
  211. self._spec_blink_timer = QTimer(self)
  212. self._spec_blink_timer.setInterval(700)
  213. self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
  214. # Mode selector state
  215. self._current_mode: str = "plug" # updated by background fetch after startup
  216. self._mode_bridge = _ModeSignalBridge(self)
  217. self.menuBar().hide()
  218. self._build_nav_bar()
  219. # Connect mode signals after nav bar is built (_mode_btn exists)
  220. self._mode_bridge.mode_fetched.connect(self._on_mode_fetched)
  221. self._mode_bridge.mode_set.connect(self._on_mode_set)
  222. self._mode_bridge.mode_error.connect(self._on_mode_error)
  223. self._build_status_bar()
  224. self._size_and_center()
  225. # Apply default dark theme to the whole application
  226. self._apply_theme(theme.is_dark())
  227. # Fetch current mode from orchestrator once the event loop is running
  228. QTimer.singleShot(600, self._start_fetch_mode)
  229. if seq_file and os.path.isfile(seq_file):
  230. self._seq_tab.load_seq_file(os.path.abspath(seq_file))
  231. # ------------------------------------------------------------------ #
  232. # Nav bar #
  233. # ------------------------------------------------------------------ #
  234. def _build_nav_bar(self) -> None:
  235. self._nav_toolbar = QToolBar("Navigation", self)
  236. tb = self._nav_toolbar
  237. tb.setMovable(False)
  238. tb.setFloatable(False)
  239. tb.setIconSize(QSize(0, 0))
  240. tb.setFixedHeight(_NAV_H)
  241. self.addToolBar(Qt.TopToolBarArea, tb)
  242. self._nav_logo_lbl = QLabel(" LF-MRI ")
  243. tb.addWidget(self._nav_logo_lbl)
  244. self._nav_sep1 = _VSep(tb)
  245. tb.addWidget(self._nav_sep1)
  246. self._nav_btn_group = QButtonGroup(self)
  247. self._nav_btn_group.setExclusive(True)
  248. self._nav_tab_buttons: list[QPushButton] = []
  249. for i, key in enumerate(_TAB_NAV_KEYS):
  250. btn = QPushButton(i18n.tr(key))
  251. btn.setCheckable(True)
  252. btn.setFixedHeight(_NAV_H)
  253. btn.setCursor(Qt.PointingHandCursor)
  254. self._nav_btn_group.addButton(btn, i)
  255. tb.addWidget(btn)
  256. self._nav_tab_buttons.append(btn)
  257. btn.clicked.connect(lambda _checked, idx=i: self._switch_tab(idx))
  258. self._nav_tab_buttons[0].setChecked(True)
  259. self._nav_spacer = QWidget()
  260. self._nav_spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
  261. tb.addWidget(self._nav_spacer)
  262. # Mode indicator / switcher
  263. self._mode_btn = QPushButton("…")
  264. self._mode_btn.setFixedHeight(22)
  265. self._mode_btn.setCursor(Qt.PointingHandCursor)
  266. self._mode_btn.setToolTip("Click to switch operating mode")
  267. self._mode_btn.clicked.connect(self._on_mode_btn_clicked)
  268. tb.addWidget(self._mode_btn)
  269. self._nav_sep_mode = _VSep(tb)
  270. tb.addWidget(self._nav_sep_mode)
  271. # Language toggle (EN / RU)
  272. self._lang_btn_group = QButtonGroup(self)
  273. self._lang_btn_group.setExclusive(True)
  274. self._btn_lang_en = QPushButton("EN")
  275. self._btn_lang_en.setCheckable(True)
  276. self._btn_lang_en.setChecked(True)
  277. self._btn_lang_en.setCursor(Qt.PointingHandCursor)
  278. self._btn_lang_ru = QPushButton("RU")
  279. self._btn_lang_ru.setCheckable(True)
  280. self._btn_lang_ru.setCursor(Qt.PointingHandCursor)
  281. self._lang_btn_group.addButton(self._btn_lang_en)
  282. self._lang_btn_group.addButton(self._btn_lang_ru)
  283. self._btn_lang_en.clicked.connect(lambda: self._on_language_change("en"))
  284. self._btn_lang_ru.clicked.connect(lambda: self._on_language_change("ru"))
  285. tb.addWidget(self._btn_lang_en)
  286. tb.addWidget(self._btn_lang_ru)
  287. # Theme toggle (◑ dark / ◐ light) — plain geometric symbols, no emoji variant
  288. self._nav_sep2 = _VSep(tb)
  289. tb.addWidget(self._nav_sep2)
  290. self._theme_btn_group = QButtonGroup(self)
  291. self._theme_btn_group.setExclusive(True)
  292. self._btn_theme_dark = QPushButton("◑")
  293. self._btn_theme_light = QPushButton("◐")
  294. self._btn_theme_dark.setCheckable(True)
  295. self._btn_theme_light.setCheckable(True)
  296. self._btn_theme_dark.setChecked(True) # default dark
  297. self._btn_theme_dark.setToolTip("Dark theme")
  298. self._btn_theme_light.setToolTip("Light theme")
  299. self._btn_theme_dark.setCursor(Qt.PointingHandCursor)
  300. self._btn_theme_light.setCursor(Qt.PointingHandCursor)
  301. self._theme_btn_group.addButton(self._btn_theme_dark)
  302. self._theme_btn_group.addButton(self._btn_theme_light)
  303. self._btn_theme_dark.clicked.connect(lambda: self._on_theme_toggle(True))
  304. self._btn_theme_light.clicked.connect(lambda: self._on_theme_toggle(False))
  305. tb.addWidget(self._btn_theme_dark)
  306. tb.addWidget(self._btn_theme_light)
  307. # ------------------------------------------------------------------ #
  308. # Theme #
  309. # ------------------------------------------------------------------ #
  310. def _on_theme_toggle(self, dark: bool) -> None:
  311. theme.set_dark(dark)
  312. self._apply_theme(dark)
  313. def _apply_theme(self, dark: bool) -> None:
  314. app = QApplication.instance()
  315. if app is not None:
  316. app.setStyleSheet(theme.make_app_stylesheet())
  317. bg = _nav_bg(dark)
  318. border = "#1e1e38" if dark else "#d0d0e8"
  319. sep_clr = "#1e1e38" if dark else "#d0d0e8"
  320. logo_clr = "#444466" if dark else "#555577"
  321. tab_css = _tab_btn_css(dark)
  322. lang_css = _lang_btn_css(dark)
  323. self._nav_toolbar.setStyleSheet(_nav_toolbar_css(dark))
  324. self._nav_sep1.setStyleSheet(f"background: {sep_clr}; border: none;")
  325. self._nav_sep2.setStyleSheet(f"background: {sep_clr}; border: none;")
  326. self._nav_sep_mode.setStyleSheet(f"background: {sep_clr}; border: none;")
  327. self._nav_logo_lbl.setStyleSheet(
  328. f"color: {logo_clr}; font-weight: bold; font-size: 11px; "
  329. f"background: {bg}; padding: 0 4px;"
  330. )
  331. self._nav_spacer.setStyleSheet(f"background: {bg};")
  332. for btn in self._nav_tab_buttons:
  333. btn.setStyleSheet(tab_css)
  334. self._btn_lang_en.setStyleSheet(lang_css)
  335. self._btn_lang_ru.setStyleSheet(lang_css)
  336. self._btn_theme_dark.setStyleSheet(lang_css)
  337. self._btn_theme_light.setStyleSheet(lang_css)
  338. self._update_mode_btn()
  339. sb_bg = "#0c0c1a" if dark else "#dce0f0"
  340. sb_color = "#555577" if dark else "#787faa"
  341. self.statusBar().setStyleSheet(
  342. f"QStatusBar {{ background: {sb_bg}; color: {sb_color}; font-size: 11px; }}"
  343. )
  344. # Propagate to all tabs
  345. for tab in (
  346. self._scanning_tab,
  347. self._seq_tab,
  348. self._scanner_tab,
  349. self._spectroscopy_tab,
  350. self._fid_tab,
  351. ):
  352. if hasattr(tab, "apply_theme"):
  353. tab.apply_theme()
  354. # ------------------------------------------------------------------ #
  355. # Navigation #
  356. # ------------------------------------------------------------------ #
  357. def _switch_tab(self, index: int) -> None:
  358. self._tabs.setCurrentIndex(index)
  359. self._nav_tab_buttons[index].setChecked(True)
  360. def _build_status_bar(self) -> None:
  361. self.setStatusBar(QStatusBar())
  362. self.statusBar().showMessage(
  363. f"{i18n.tr('active_tab')}: {i18n.tr(_TAB_NAV_KEYS[0])}"
  364. )
  365. def _size_and_center(self) -> None:
  366. screen = QApplication.primaryScreen()
  367. if screen is not None:
  368. ag = screen.availableGeometry()
  369. w = min(1600, max(960, int(ag.width() * 0.92)))
  370. h = min(940, max(640, int(ag.height() * 0.90)))
  371. self.resize(w, h)
  372. self.move(
  373. ag.x() + (ag.width() - w) // 2,
  374. ag.y() + (ag.height() - h) // 2,
  375. )
  376. else:
  377. self.resize(1440, 860)
  378. def _on_tab_changed(self, index: int) -> None:
  379. key = _TAB_NAV_KEYS[index] if 0 <= index < len(_TAB_NAV_KEYS) else "-"
  380. self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}")
  381. if 0 <= index < len(self._nav_tab_buttons):
  382. self._nav_tab_buttons[index].setChecked(True)
  383. if index == _SPEC_TAB_IDX:
  384. self._stop_spec_blink()
  385. def _on_language_change(self, lang: str) -> None:
  386. i18n.set_language(lang)
  387. self.retranslate_ui()
  388. def retranslate_ui(self) -> None:
  389. for i, key in enumerate(_TAB_NAV_KEYS):
  390. self._nav_tab_buttons[i].setText(i18n.tr(key))
  391. self._update_mode_btn()
  392. cur = self._tabs.currentIndex()
  393. key = _TAB_NAV_KEYS[cur] if 0 <= cur < len(_TAB_NAV_KEYS) else "-"
  394. self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}")
  395. for tab in (
  396. self._scanning_tab,
  397. self._seq_tab,
  398. self._scanner_tab,
  399. self._spectroscopy_tab,
  400. self._fid_tab,
  401. ):
  402. if hasattr(tab, "retranslate_ui"):
  403. tab.retranslate_ui()
  404. # ------------------------------------------------------------------ #
  405. # Cross-tab signals #
  406. # ------------------------------------------------------------------ #
  407. def _on_fid_generated(self, path: str) -> None:
  408. self._seq_tab.load_seq_file(path)
  409. self._switch_tab(1)
  410. def _on_ready_for_scan(self, info: dict) -> None:
  411. self._scanner_tab.apply_seq_info(info)
  412. self._scanning_tab.apply_seq_info(info)
  413. self._switch_tab(2)
  414. def _on_scan_raw_data_ready(self, json_path: str) -> None:
  415. self._spectroscopy_tab.receive_scan_data(json_path)
  416. if self._tabs.currentIndex() != _SPEC_TAB_IDX:
  417. self._spec_blink_on = False
  418. self._spec_blink_timer.start()
  419. # ------------------------------------------------------------------ #
  420. # Spectroscopy blink #
  421. # ------------------------------------------------------------------ #
  422. def _tick_spec_blink(self) -> None:
  423. self._spec_blink_on = not self._spec_blink_on
  424. btn = self._nav_tab_buttons[_SPEC_TAB_IDX]
  425. btn.setStyleSheet(
  426. _spec_btn_blink_css(theme.is_dark())
  427. if self._spec_blink_on
  428. else _tab_btn_css(theme.is_dark())
  429. )
  430. def _stop_spec_blink(self) -> None:
  431. self._spec_blink_timer.stop()
  432. self._nav_tab_buttons[_SPEC_TAB_IDX].setStyleSheet(
  433. _tab_btn_css(theme.is_dark())
  434. )
  435. # ------------------------------------------------------------------ #
  436. # Mode selector #
  437. # ------------------------------------------------------------------ #
  438. def _update_mode_btn(self) -> None:
  439. """Refresh mode button label and colour from _current_mode."""
  440. text = i18n.tr(f"mode_{self._current_mode}")
  441. self._mode_btn.setText(text)
  442. self._mode_btn.setStyleSheet(_mode_btn_css(theme.is_dark(), self._current_mode))
  443. # -- background fetch (startup) ----------------------------------------
  444. def _start_fetch_mode(self) -> None:
  445. threading.Thread(
  446. target=self._fetch_mode_bg,
  447. daemon=True,
  448. name="mode-fetch",
  449. ).start()
  450. def _fetch_mode_bg(self) -> None:
  451. try:
  452. client = OrchestratorClient(self._orchestrator_url)
  453. mode = client.get_mode()
  454. self._mode_bridge.mode_fetched.emit(mode)
  455. except OrchestratorError:
  456. pass # Orchestrator may not be up yet — keep the default label
  457. # -- slots (main thread) -----------------------------------------------
  458. def _on_mode_fetched(self, mode: str) -> None:
  459. self._current_mode = mode
  460. self._update_mode_btn()
  461. self._scanner_tab.refresh_scenarios()
  462. def _on_mode_set(self, mode: str) -> None:
  463. self._current_mode = mode
  464. self._mode_btn.setEnabled(True)
  465. self._update_mode_btn()
  466. self._scanner_tab.refresh_scenarios()
  467. def _on_mode_error(self, msg: str) -> None:
  468. self._mode_btn.setEnabled(True)
  469. QMessageBox.warning(
  470. self,
  471. i18n.tr("mode_error"),
  472. msg or "Unknown error — check that the orchestrator is running.",
  473. )
  474. # -- click handler (main thread) ---------------------------------------
  475. def _on_mode_btn_clicked(self) -> None:
  476. target = "real" if self._current_mode == "plug" else "plug"
  477. confirm_key = f"mode_confirm_to_{target}"
  478. reply = QMessageBox.question(
  479. self,
  480. i18n.tr("mode_confirm_title"),
  481. i18n.tr(confirm_key),
  482. QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
  483. QMessageBox.StandardButton.No,
  484. )
  485. if reply != QMessageBox.StandardButton.Yes:
  486. return
  487. self._mode_btn.setEnabled(False)
  488. threading.Thread(
  489. target=self._set_mode_bg,
  490. args=(target,),
  491. daemon=True,
  492. name="mode-set",
  493. ).start()
  494. def _set_mode_bg(self, mode: str) -> None:
  495. try:
  496. client = OrchestratorClient(self._orchestrator_url)
  497. result = client.set_mode(mode)
  498. self._mode_bridge.mode_set.emit(result)
  499. except OrchestratorError as exc:
  500. status = f" [HTTP {exc.status_code}]" if exc.status_code else ""
  501. self._mode_bridge.mode_error.emit(f"{exc}{status}")
  502. except Exception as exc:
  503. self._mode_bridge.mode_error.emit(f"{type(exc).__name__}: {exc}")
  504. class _VSep(QFrame):
  505. """Thin vertical separator for the nav toolbar."""
  506. def __init__(self, parent=None) -> None:
  507. super().__init__(parent)
  508. self.setFrameShape(QFrame.VLine)
  509. self.setFixedWidth(1)
  510. self.setStyleSheet("background: #1e1e38; border: none;")