app_window.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  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. from PySide6.QtCore import Qt, QSize, QTimer
  10. from PySide6.QtWidgets import (
  11. QApplication,
  12. QButtonGroup,
  13. QFrame,
  14. QLabel,
  15. QMainWindow,
  16. QPushButton,
  17. QSizePolicy,
  18. QStatusBar,
  19. QTabWidget,
  20. QToolBar,
  21. QWidget,
  22. )
  23. from src import i18n, theme
  24. from src.tabs.fid_tab import FidTab
  25. from src.tabs.scanner_tab import ScannerTab
  26. from src.tabs.scanning_tab import ScanningTab
  27. from src.tabs.seq_interp_tab import SeqInterpTab
  28. from src.tabs.spectroscopy_tab import SpectroscopyTab
  29. _TAB_NAV_KEYS = [
  30. "tab_nav_scanning",
  31. "tab_nav_sequence",
  32. "tab_nav_scanner",
  33. "tab_nav_spectro",
  34. "tab_nav_fid",
  35. ]
  36. _SPEC_TAB_IDX = 3
  37. _NAV_H = 38
  38. # -- dynamic CSS helpers -------------------------------------------------------
  39. def _nav_bg(dark: bool) -> str:
  40. return "#0f0f1e" if dark else "#f0f0f8"
  41. def _nav_toolbar_css(dark: bool) -> str:
  42. bg = _nav_bg(dark)
  43. border = "#1e1e38" if dark else "#d0d0e8"
  44. return (
  45. f"QToolBar {{ background: {bg}; border: none; "
  46. f"border-bottom: 1px solid {border}; spacing: 0px; padding: 0px; }}"
  47. )
  48. def _tab_btn_css(dark: bool) -> str:
  49. inactive = "#7777aa" if dark else "#666688"
  50. active = "#ffffff" if dark else "#1a1a2e"
  51. accent = "#f0c040" if dark else "#c09000"
  52. hover_bg = "#17172e" if dark else "#e0e0f4"
  53. hover_txt = "#aaaacc" if dark else "#333355"
  54. return f"""
  55. QPushButton {{
  56. background: transparent;
  57. color: {inactive};
  58. border: none;
  59. border-bottom: 2px solid transparent;
  60. padding: 0px 20px;
  61. font-size: 12px;
  62. min-height: 36px;
  63. }}
  64. QPushButton:checked {{
  65. color: {active};
  66. border-bottom: 2px solid {accent};
  67. }}
  68. QPushButton:hover:!checked {{
  69. color: {hover_txt};
  70. background: {hover_bg};
  71. }}
  72. """
  73. def _lang_btn_css(dark: bool) -> str:
  74. inactive = "#555577" if dark else "#666688"
  75. active = "#f0c040" if dark else "#c09000"
  76. hover = "#aaaacc" if dark else "#1a1a2e"
  77. return f"""
  78. QPushButton {{
  79. background: transparent;
  80. color: {inactive};
  81. border: 1px solid transparent;
  82. border-radius: 3px;
  83. padding: 0px 7px;
  84. font-size: 11px;
  85. font-weight: bold;
  86. min-height: 22px;
  87. max-height: 22px;
  88. }}
  89. QPushButton:checked {{
  90. color: {active};
  91. border-color: {active};
  92. }}
  93. QPushButton:hover:!checked {{
  94. color: {hover};
  95. }}
  96. """
  97. def _spec_btn_blink_css(dark: bool) -> str:
  98. active_txt = "#ffffff" if dark else "#1a1a2e"
  99. accent = "#f0c040" if dark else "#c09000"
  100. hover_bg = "#17172e" if dark else "#e0e0f4"
  101. return f"""
  102. QPushButton {{
  103. background: transparent;
  104. color: #e65100;
  105. border: none;
  106. border-bottom: 2px solid #e65100;
  107. padding: 0px 20px;
  108. font-size: 12px;
  109. min-height: 36px;
  110. }}
  111. QPushButton:checked {{
  112. color: {active_txt};
  113. border-bottom: 2px solid {accent};
  114. }}
  115. QPushButton:hover:!checked {{
  116. color: #ff7733;
  117. background: {hover_bg};
  118. }}
  119. """
  120. class LFMRIWindow(QMainWindow):
  121. """Unified LF-MRI application window."""
  122. def __init__(
  123. self,
  124. hw_config_path: str | None = None,
  125. output_dir: str | None = None,
  126. seq_file: str | None = None,
  127. orchestrator_url: str = "http://localhost:1717",
  128. seq_interp_url: str = "http://localhost:7475",
  129. spectroscopy_url: str = "http://localhost:8002",
  130. ) -> None:
  131. super().__init__()
  132. self.setWindowTitle("LF-MRI System")
  133. self.setMinimumSize(960, 640)
  134. self._hw_config_path = hw_config_path
  135. self._output_dir = output_dir
  136. self._seq_tab = SeqInterpTab(
  137. hw_config_path=hw_config_path,
  138. output_dir=output_dir,
  139. seq_interp_url=seq_interp_url,
  140. )
  141. self._scanner_tab = ScannerTab(
  142. hw_config_path=hw_config_path,
  143. orchestrator_url=orchestrator_url,
  144. seq_interp_url=seq_interp_url,
  145. spectroscopy_url=spectroscopy_url,
  146. )
  147. self._fid_tab = FidTab(
  148. hw_config_path=hw_config_path,
  149. output_dir=output_dir,
  150. )
  151. self._scanning_tab = ScanningTab()
  152. self._scanning_tab.set_orchestrator_url(orchestrator_url)
  153. self._spectroscopy_tab = SpectroscopyTab(spectroscopy_url=spectroscopy_url)
  154. self._tabs = QTabWidget()
  155. self._tabs.tabBar().hide()
  156. self._tabs.setDocumentMode(True)
  157. self._tabs.addTab(self._scanning_tab, i18n.tr(_TAB_NAV_KEYS[0]))
  158. self._tabs.addTab(self._seq_tab, i18n.tr(_TAB_NAV_KEYS[1]))
  159. self._tabs.addTab(self._scanner_tab, i18n.tr(_TAB_NAV_KEYS[2]))
  160. self._tabs.addTab(self._spectroscopy_tab, i18n.tr(_TAB_NAV_KEYS[3]))
  161. self._tabs.addTab(self._fid_tab, i18n.tr(_TAB_NAV_KEYS[4]))
  162. self._tabs.currentChanged.connect(self._on_tab_changed)
  163. self.setCentralWidget(self._tabs)
  164. self._fid_tab.fid_seq_generated.connect(self._on_fid_generated)
  165. self._seq_tab.ready_for_scan.connect(self._on_ready_for_scan)
  166. self._scanning_tab.scan_job_started.connect(self._scanner_tab.attach_job)
  167. self._scanning_tab.raw_data_ready.connect(self._on_scan_raw_data_ready)
  168. self._scanner_tab.scan_result_ready.connect(self._spectroscopy_tab.receive_scan_data)
  169. self._spec_blink_on: bool = False
  170. self._spec_blink_timer = QTimer(self)
  171. self._spec_blink_timer.setInterval(700)
  172. self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
  173. self.menuBar().hide()
  174. self._build_nav_bar()
  175. self._build_status_bar()
  176. self._size_and_center()
  177. # Apply default dark theme to the whole application
  178. self._apply_theme(theme.is_dark())
  179. if seq_file and os.path.isfile(seq_file):
  180. self._seq_tab.load_seq_file(os.path.abspath(seq_file))
  181. # ------------------------------------------------------------------ #
  182. # Nav bar #
  183. # ------------------------------------------------------------------ #
  184. def _build_nav_bar(self) -> None:
  185. self._nav_toolbar = QToolBar("Navigation", self)
  186. tb = self._nav_toolbar
  187. tb.setMovable(False)
  188. tb.setFloatable(False)
  189. tb.setIconSize(QSize(0, 0))
  190. tb.setFixedHeight(_NAV_H)
  191. self.addToolBar(Qt.TopToolBarArea, tb)
  192. self._nav_logo_lbl = QLabel(" LF-MRI ")
  193. tb.addWidget(self._nav_logo_lbl)
  194. self._nav_sep1 = _VSep(tb)
  195. tb.addWidget(self._nav_sep1)
  196. self._nav_btn_group = QButtonGroup(self)
  197. self._nav_btn_group.setExclusive(True)
  198. self._nav_tab_buttons: list[QPushButton] = []
  199. for i, key in enumerate(_TAB_NAV_KEYS):
  200. btn = QPushButton(i18n.tr(key))
  201. btn.setCheckable(True)
  202. btn.setFixedHeight(_NAV_H)
  203. btn.setCursor(Qt.PointingHandCursor)
  204. self._nav_btn_group.addButton(btn, i)
  205. tb.addWidget(btn)
  206. self._nav_tab_buttons.append(btn)
  207. btn.clicked.connect(lambda _checked, idx=i: self._switch_tab(idx))
  208. self._nav_tab_buttons[0].setChecked(True)
  209. self._nav_spacer = QWidget()
  210. self._nav_spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
  211. tb.addWidget(self._nav_spacer)
  212. # Language toggle (EN / RU)
  213. self._lang_btn_group = QButtonGroup(self)
  214. self._lang_btn_group.setExclusive(True)
  215. self._btn_lang_en = QPushButton("EN")
  216. self._btn_lang_en.setCheckable(True)
  217. self._btn_lang_en.setChecked(True)
  218. self._btn_lang_en.setCursor(Qt.PointingHandCursor)
  219. self._btn_lang_ru = QPushButton("RU")
  220. self._btn_lang_ru.setCheckable(True)
  221. self._btn_lang_ru.setCursor(Qt.PointingHandCursor)
  222. self._lang_btn_group.addButton(self._btn_lang_en)
  223. self._lang_btn_group.addButton(self._btn_lang_ru)
  224. self._btn_lang_en.clicked.connect(lambda: self._on_language_change("en"))
  225. self._btn_lang_ru.clicked.connect(lambda: self._on_language_change("ru"))
  226. tb.addWidget(self._btn_lang_en)
  227. tb.addWidget(self._btn_lang_ru)
  228. # Theme toggle (☽ dark / ☀ light)
  229. self._nav_sep2 = _VSep(tb)
  230. tb.addWidget(self._nav_sep2)
  231. self._theme_btn_group = QButtonGroup(self)
  232. self._theme_btn_group.setExclusive(True)
  233. self._btn_theme_dark = QPushButton("☽")
  234. self._btn_theme_light = QPushButton("☀")
  235. self._btn_theme_dark.setCheckable(True)
  236. self._btn_theme_light.setCheckable(True)
  237. self._btn_theme_dark.setChecked(True) # default dark
  238. self._btn_theme_dark.setToolTip("Dark theme")
  239. self._btn_theme_light.setToolTip("Light theme")
  240. self._btn_theme_dark.setCursor(Qt.PointingHandCursor)
  241. self._btn_theme_light.setCursor(Qt.PointingHandCursor)
  242. self._theme_btn_group.addButton(self._btn_theme_dark)
  243. self._theme_btn_group.addButton(self._btn_theme_light)
  244. self._btn_theme_dark.clicked.connect(lambda: self._on_theme_toggle(True))
  245. self._btn_theme_light.clicked.connect(lambda: self._on_theme_toggle(False))
  246. tb.addWidget(self._btn_theme_dark)
  247. tb.addWidget(self._btn_theme_light)
  248. # ------------------------------------------------------------------ #
  249. # Theme #
  250. # ------------------------------------------------------------------ #
  251. def _on_theme_toggle(self, dark: bool) -> None:
  252. theme.set_dark(dark)
  253. self._apply_theme(dark)
  254. def _apply_theme(self, dark: bool) -> None:
  255. app = QApplication.instance()
  256. if app is not None:
  257. app.setStyleSheet(theme.make_app_stylesheet())
  258. bg = _nav_bg(dark)
  259. border = "#1e1e38" if dark else "#d0d0e8"
  260. sep_clr = "#1e1e38" if dark else "#d0d0e8"
  261. logo_clr = "#444466" if dark else "#555577"
  262. tab_css = _tab_btn_css(dark)
  263. lang_css = _lang_btn_css(dark)
  264. self._nav_toolbar.setStyleSheet(_nav_toolbar_css(dark))
  265. self._nav_sep1.setStyleSheet(f"background: {sep_clr}; border: none;")
  266. self._nav_sep2.setStyleSheet(f"background: {sep_clr}; border: none;")
  267. self._nav_logo_lbl.setStyleSheet(
  268. f"color: {logo_clr}; font-weight: bold; font-size: 11px; "
  269. f"background: {bg}; padding: 0 4px;"
  270. )
  271. self._nav_spacer.setStyleSheet(f"background: {bg};")
  272. for btn in self._nav_tab_buttons:
  273. btn.setStyleSheet(tab_css)
  274. self._btn_lang_en.setStyleSheet(lang_css)
  275. self._btn_lang_ru.setStyleSheet(lang_css)
  276. self._btn_theme_dark.setStyleSheet(lang_css)
  277. self._btn_theme_light.setStyleSheet(lang_css)
  278. sb_bg = "#0c0c1a" if dark else "#dce0f0"
  279. sb_color = "#555577" if dark else "#787faa"
  280. self.statusBar().setStyleSheet(
  281. f"QStatusBar {{ background: {sb_bg}; color: {sb_color}; font-size: 11px; }}"
  282. )
  283. # Propagate to all tabs
  284. for tab in (
  285. self._scanning_tab,
  286. self._seq_tab,
  287. self._scanner_tab,
  288. self._spectroscopy_tab,
  289. self._fid_tab,
  290. ):
  291. if hasattr(tab, "apply_theme"):
  292. tab.apply_theme()
  293. # ------------------------------------------------------------------ #
  294. # Navigation #
  295. # ------------------------------------------------------------------ #
  296. def _switch_tab(self, index: int) -> None:
  297. self._tabs.setCurrentIndex(index)
  298. self._nav_tab_buttons[index].setChecked(True)
  299. def _build_status_bar(self) -> None:
  300. self.setStatusBar(QStatusBar())
  301. self.statusBar().showMessage(
  302. f"{i18n.tr('active_tab')}: {i18n.tr(_TAB_NAV_KEYS[0])}"
  303. )
  304. def _size_and_center(self) -> None:
  305. screen = QApplication.primaryScreen()
  306. if screen is not None:
  307. ag = screen.availableGeometry()
  308. w = min(1600, max(960, int(ag.width() * 0.92)))
  309. h = min(940, max(640, int(ag.height() * 0.90)))
  310. self.resize(w, h)
  311. self.move(
  312. ag.x() + (ag.width() - w) // 2,
  313. ag.y() + (ag.height() - h) // 2,
  314. )
  315. else:
  316. self.resize(1440, 860)
  317. def _on_tab_changed(self, index: int) -> None:
  318. key = _TAB_NAV_KEYS[index] if 0 <= index < len(_TAB_NAV_KEYS) else "-"
  319. self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}")
  320. if 0 <= index < len(self._nav_tab_buttons):
  321. self._nav_tab_buttons[index].setChecked(True)
  322. if index == _SPEC_TAB_IDX:
  323. self._stop_spec_blink()
  324. def _on_language_change(self, lang: str) -> None:
  325. i18n.set_language(lang)
  326. self.retranslate_ui()
  327. def retranslate_ui(self) -> None:
  328. for i, key in enumerate(_TAB_NAV_KEYS):
  329. self._nav_tab_buttons[i].setText(i18n.tr(key))
  330. cur = self._tabs.currentIndex()
  331. key = _TAB_NAV_KEYS[cur] if 0 <= cur < len(_TAB_NAV_KEYS) else "-"
  332. self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}")
  333. for tab in (
  334. self._scanning_tab,
  335. self._seq_tab,
  336. self._scanner_tab,
  337. self._spectroscopy_tab,
  338. self._fid_tab,
  339. ):
  340. if hasattr(tab, "retranslate_ui"):
  341. tab.retranslate_ui()
  342. # ------------------------------------------------------------------ #
  343. # Cross-tab signals #
  344. # ------------------------------------------------------------------ #
  345. def _on_fid_generated(self, path: str) -> None:
  346. self._seq_tab.load_seq_file(path)
  347. self._switch_tab(1)
  348. def _on_ready_for_scan(self, info: dict) -> None:
  349. self._scanner_tab.apply_seq_info(info)
  350. self._scanning_tab.apply_seq_info(info)
  351. self._switch_tab(2)
  352. def _on_scan_raw_data_ready(self, json_path: str) -> None:
  353. self._spectroscopy_tab.receive_scan_data(json_path)
  354. if self._tabs.currentIndex() != _SPEC_TAB_IDX:
  355. self._spec_blink_on = False
  356. self._spec_blink_timer.start()
  357. # ------------------------------------------------------------------ #
  358. # Spectroscopy blink #
  359. # ------------------------------------------------------------------ #
  360. def _tick_spec_blink(self) -> None:
  361. self._spec_blink_on = not self._spec_blink_on
  362. btn = self._nav_tab_buttons[_SPEC_TAB_IDX]
  363. btn.setStyleSheet(
  364. _spec_btn_blink_css(theme.is_dark())
  365. if self._spec_blink_on
  366. else _tab_btn_css(theme.is_dark())
  367. )
  368. def _stop_spec_blink(self) -> None:
  369. self._spec_blink_timer.stop()
  370. self._nav_tab_buttons[_SPEC_TAB_IDX].setStyleSheet(
  371. _tab_btn_css(theme.is_dark())
  372. )
  373. class _VSep(QFrame):
  374. """Thin vertical separator for the nav toolbar."""
  375. def __init__(self, parent=None) -> None:
  376. super().__init__(parent)
  377. self.setFrameShape(QFrame.VLine)
  378. self.setFixedWidth(1)
  379. self.setStyleSheet("background: #1e1e38; border: none;")