""" LFMRIWindow - main application window for the unified LF-MRI GUI. Single nav-bar design: tab buttons on the left. The native QMenuBar and QTabWidget tab-bar are both hidden; a QToolBar provides a unified, flat navigation strip. """ from __future__ import annotations import os from PySide6.QtCore import Qt, QSize, QTimer from PySide6.QtWidgets import ( QApplication, QButtonGroup, QFrame, QLabel, QMainWindow, QPushButton, QSizePolicy, QStatusBar, QTabWidget, QToolBar, QWidget, ) from src import i18n, theme from src.tabs.fid_tab import FidTab from src.tabs.scanner_tab import ScannerTab from src.tabs.scanning_tab import ScanningTab from src.tabs.seq_interp_tab import SeqInterpTab from src.tabs.spectroscopy_tab import SpectroscopyTab _TAB_NAV_KEYS = [ "tab_nav_scanning", "tab_nav_sequence", "tab_nav_scanner", "tab_nav_spectro", "tab_nav_fid", ] _SPEC_TAB_IDX = 3 _NAV_H = 38 # -- dynamic CSS helpers ------------------------------------------------------- def _nav_bg(dark: bool) -> str: return "#0f0f1e" if dark else "#f0f0f8" def _nav_toolbar_css(dark: bool) -> str: bg = _nav_bg(dark) border = "#1e1e38" if dark else "#d0d0e8" return ( f"QToolBar {{ background: {bg}; border: none; " f"border-bottom: 1px solid {border}; spacing: 0px; padding: 0px; }}" ) def _tab_btn_css(dark: bool) -> str: inactive = "#7777aa" if dark else "#666688" active = "#ffffff" if dark else "#1a1a2e" accent = "#f0c040" if dark else "#c09000" hover_bg = "#17172e" if dark else "#e0e0f4" hover_txt = "#aaaacc" if dark else "#333355" return f""" QPushButton {{ background: transparent; color: {inactive}; border: none; border-bottom: 2px solid transparent; padding: 0px 20px; font-size: 12px; min-height: 36px; }} QPushButton:checked {{ color: {active}; border-bottom: 2px solid {accent}; }} QPushButton:hover:!checked {{ color: {hover_txt}; background: {hover_bg}; }} """ def _lang_btn_css(dark: bool) -> str: inactive = "#555577" if dark else "#666688" active = "#f0c040" if dark else "#c09000" hover = "#aaaacc" if dark else "#1a1a2e" return f""" QPushButton {{ background: transparent; color: {inactive}; border: 1px solid transparent; border-radius: 3px; padding: 0px 7px; font-size: 11px; font-weight: bold; min-height: 22px; max-height: 22px; }} QPushButton:checked {{ color: {active}; border-color: {active}; }} QPushButton:hover:!checked {{ color: {hover}; }} """ def _spec_btn_blink_css(dark: bool) -> str: active_txt = "#ffffff" if dark else "#1a1a2e" accent = "#f0c040" if dark else "#c09000" hover_bg = "#17172e" if dark else "#e0e0f4" return f""" QPushButton {{ background: transparent; color: #e65100; border: none; border-bottom: 2px solid #e65100; padding: 0px 20px; font-size: 12px; min-height: 36px; }} QPushButton:checked {{ color: {active_txt}; border-bottom: 2px solid {accent}; }} QPushButton:hover:!checked {{ color: #ff7733; background: {hover_bg}; }} """ class LFMRIWindow(QMainWindow): """Unified LF-MRI application window.""" def __init__( self, hw_config_path: str | None = None, output_dir: str | None = None, seq_file: str | None = None, orchestrator_url: str = "http://localhost:1717", seq_interp_url: str = "http://localhost:7475", spectroscopy_url: str = "http://localhost:8002", ) -> None: super().__init__() self.setWindowTitle("LF-MRI System") self.setMinimumSize(960, 640) self._hw_config_path = hw_config_path self._output_dir = output_dir self._seq_tab = SeqInterpTab( hw_config_path=hw_config_path, output_dir=output_dir, seq_interp_url=seq_interp_url, ) self._scanner_tab = ScannerTab( hw_config_path=hw_config_path, orchestrator_url=orchestrator_url, seq_interp_url=seq_interp_url, spectroscopy_url=spectroscopy_url, ) self._fid_tab = FidTab( hw_config_path=hw_config_path, output_dir=output_dir, ) self._scanning_tab = ScanningTab() self._scanning_tab.set_orchestrator_url(orchestrator_url) self._spectroscopy_tab = SpectroscopyTab(spectroscopy_url=spectroscopy_url) self._tabs = QTabWidget() self._tabs.tabBar().hide() self._tabs.setDocumentMode(True) self._tabs.addTab(self._scanning_tab, i18n.tr(_TAB_NAV_KEYS[0])) self._tabs.addTab(self._seq_tab, i18n.tr(_TAB_NAV_KEYS[1])) self._tabs.addTab(self._scanner_tab, i18n.tr(_TAB_NAV_KEYS[2])) self._tabs.addTab(self._spectroscopy_tab, i18n.tr(_TAB_NAV_KEYS[3])) self._tabs.addTab(self._fid_tab, i18n.tr(_TAB_NAV_KEYS[4])) self._tabs.currentChanged.connect(self._on_tab_changed) self.setCentralWidget(self._tabs) self._fid_tab.fid_seq_generated.connect(self._on_fid_generated) self._seq_tab.ready_for_scan.connect(self._on_ready_for_scan) self._scanning_tab.scan_job_started.connect(self._scanner_tab.attach_job) self._scanning_tab.raw_data_ready.connect(self._on_scan_raw_data_ready) self._scanner_tab.scan_result_ready.connect(self._spectroscopy_tab.receive_scan_data) self._spec_blink_on: bool = False self._spec_blink_timer = QTimer(self) self._spec_blink_timer.setInterval(700) self._spec_blink_timer.timeout.connect(self._tick_spec_blink) self.menuBar().hide() self._build_nav_bar() self._build_status_bar() self._size_and_center() # Apply default dark theme to the whole application self._apply_theme(theme.is_dark()) if seq_file and os.path.isfile(seq_file): self._seq_tab.load_seq_file(os.path.abspath(seq_file)) # ------------------------------------------------------------------ # # Nav bar # # ------------------------------------------------------------------ # def _build_nav_bar(self) -> None: self._nav_toolbar = QToolBar("Navigation", self) tb = self._nav_toolbar tb.setMovable(False) tb.setFloatable(False) tb.setIconSize(QSize(0, 0)) tb.setFixedHeight(_NAV_H) self.addToolBar(Qt.TopToolBarArea, tb) self._nav_logo_lbl = QLabel(" LF-MRI ") tb.addWidget(self._nav_logo_lbl) self._nav_sep1 = _VSep(tb) tb.addWidget(self._nav_sep1) self._nav_btn_group = QButtonGroup(self) self._nav_btn_group.setExclusive(True) self._nav_tab_buttons: list[QPushButton] = [] for i, key in enumerate(_TAB_NAV_KEYS): btn = QPushButton(i18n.tr(key)) btn.setCheckable(True) btn.setFixedHeight(_NAV_H) btn.setCursor(Qt.PointingHandCursor) self._nav_btn_group.addButton(btn, i) tb.addWidget(btn) self._nav_tab_buttons.append(btn) btn.clicked.connect(lambda _checked, idx=i: self._switch_tab(idx)) self._nav_tab_buttons[0].setChecked(True) self._nav_spacer = QWidget() self._nav_spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) tb.addWidget(self._nav_spacer) # Language toggle (EN / RU) self._lang_btn_group = QButtonGroup(self) self._lang_btn_group.setExclusive(True) self._btn_lang_en = QPushButton("EN") self._btn_lang_en.setCheckable(True) self._btn_lang_en.setChecked(True) self._btn_lang_en.setCursor(Qt.PointingHandCursor) self._btn_lang_ru = QPushButton("RU") self._btn_lang_ru.setCheckable(True) self._btn_lang_ru.setCursor(Qt.PointingHandCursor) self._lang_btn_group.addButton(self._btn_lang_en) self._lang_btn_group.addButton(self._btn_lang_ru) self._btn_lang_en.clicked.connect(lambda: self._on_language_change("en")) self._btn_lang_ru.clicked.connect(lambda: self._on_language_change("ru")) tb.addWidget(self._btn_lang_en) tb.addWidget(self._btn_lang_ru) # Theme toggle (☽ dark / ☀ light) self._nav_sep2 = _VSep(tb) tb.addWidget(self._nav_sep2) self._theme_btn_group = QButtonGroup(self) self._theme_btn_group.setExclusive(True) self._btn_theme_dark = QPushButton("☽") self._btn_theme_light = QPushButton("☀") self._btn_theme_dark.setCheckable(True) self._btn_theme_light.setCheckable(True) self._btn_theme_dark.setChecked(True) # default dark self._btn_theme_dark.setToolTip("Dark theme") self._btn_theme_light.setToolTip("Light theme") self._btn_theme_dark.setCursor(Qt.PointingHandCursor) self._btn_theme_light.setCursor(Qt.PointingHandCursor) self._theme_btn_group.addButton(self._btn_theme_dark) self._theme_btn_group.addButton(self._btn_theme_light) self._btn_theme_dark.clicked.connect(lambda: self._on_theme_toggle(True)) self._btn_theme_light.clicked.connect(lambda: self._on_theme_toggle(False)) tb.addWidget(self._btn_theme_dark) tb.addWidget(self._btn_theme_light) # ------------------------------------------------------------------ # # Theme # # ------------------------------------------------------------------ # def _on_theme_toggle(self, dark: bool) -> None: theme.set_dark(dark) self._apply_theme(dark) def _apply_theme(self, dark: bool) -> None: app = QApplication.instance() if app is not None: app.setStyleSheet(theme.make_app_stylesheet()) bg = _nav_bg(dark) border = "#1e1e38" if dark else "#d0d0e8" sep_clr = "#1e1e38" if dark else "#d0d0e8" logo_clr = "#444466" if dark else "#555577" tab_css = _tab_btn_css(dark) lang_css = _lang_btn_css(dark) self._nav_toolbar.setStyleSheet(_nav_toolbar_css(dark)) self._nav_sep1.setStyleSheet(f"background: {sep_clr}; border: none;") self._nav_sep2.setStyleSheet(f"background: {sep_clr}; border: none;") self._nav_logo_lbl.setStyleSheet( f"color: {logo_clr}; font-weight: bold; font-size: 11px; " f"background: {bg}; padding: 0 4px;" ) self._nav_spacer.setStyleSheet(f"background: {bg};") for btn in self._nav_tab_buttons: btn.setStyleSheet(tab_css) self._btn_lang_en.setStyleSheet(lang_css) self._btn_lang_ru.setStyleSheet(lang_css) self._btn_theme_dark.setStyleSheet(lang_css) self._btn_theme_light.setStyleSheet(lang_css) sb_bg = "#0c0c1a" if dark else "#dce0f0" sb_color = "#555577" if dark else "#787faa" self.statusBar().setStyleSheet( f"QStatusBar {{ background: {sb_bg}; color: {sb_color}; font-size: 11px; }}" ) # Propagate to all tabs for tab in ( self._scanning_tab, self._seq_tab, self._scanner_tab, self._spectroscopy_tab, self._fid_tab, ): if hasattr(tab, "apply_theme"): tab.apply_theme() # ------------------------------------------------------------------ # # Navigation # # ------------------------------------------------------------------ # def _switch_tab(self, index: int) -> None: self._tabs.setCurrentIndex(index) self._nav_tab_buttons[index].setChecked(True) def _build_status_bar(self) -> None: self.setStatusBar(QStatusBar()) self.statusBar().showMessage( f"{i18n.tr('active_tab')}: {i18n.tr(_TAB_NAV_KEYS[0])}" ) def _size_and_center(self) -> None: screen = QApplication.primaryScreen() if screen is not None: ag = screen.availableGeometry() w = min(1600, max(960, int(ag.width() * 0.92))) h = min(940, max(640, int(ag.height() * 0.90))) self.resize(w, h) self.move( ag.x() + (ag.width() - w) // 2, ag.y() + (ag.height() - h) // 2, ) else: self.resize(1440, 860) def _on_tab_changed(self, index: int) -> None: key = _TAB_NAV_KEYS[index] if 0 <= index < len(_TAB_NAV_KEYS) else "-" self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}") if 0 <= index < len(self._nav_tab_buttons): self._nav_tab_buttons[index].setChecked(True) if index == _SPEC_TAB_IDX: self._stop_spec_blink() def _on_language_change(self, lang: str) -> None: i18n.set_language(lang) self.retranslate_ui() def retranslate_ui(self) -> None: for i, key in enumerate(_TAB_NAV_KEYS): self._nav_tab_buttons[i].setText(i18n.tr(key)) cur = self._tabs.currentIndex() key = _TAB_NAV_KEYS[cur] if 0 <= cur < len(_TAB_NAV_KEYS) else "-" self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}") for tab in ( self._scanning_tab, self._seq_tab, self._scanner_tab, self._spectroscopy_tab, self._fid_tab, ): if hasattr(tab, "retranslate_ui"): tab.retranslate_ui() # ------------------------------------------------------------------ # # Cross-tab signals # # ------------------------------------------------------------------ # def _on_fid_generated(self, path: str) -> None: self._seq_tab.load_seq_file(path) self._switch_tab(1) def _on_ready_for_scan(self, info: dict) -> None: self._scanner_tab.apply_seq_info(info) self._scanning_tab.apply_seq_info(info) self._switch_tab(2) def _on_scan_raw_data_ready(self, json_path: str) -> None: self._spectroscopy_tab.receive_scan_data(json_path) if self._tabs.currentIndex() != _SPEC_TAB_IDX: self._spec_blink_on = False self._spec_blink_timer.start() # ------------------------------------------------------------------ # # Spectroscopy blink # # ------------------------------------------------------------------ # def _tick_spec_blink(self) -> None: self._spec_blink_on = not self._spec_blink_on btn = self._nav_tab_buttons[_SPEC_TAB_IDX] btn.setStyleSheet( _spec_btn_blink_css(theme.is_dark()) if self._spec_blink_on else _tab_btn_css(theme.is_dark()) ) def _stop_spec_blink(self) -> None: self._spec_blink_timer.stop() self._nav_tab_buttons[_SPEC_TAB_IDX].setStyleSheet( _tab_btn_css(theme.is_dark()) ) class _VSep(QFrame): """Thin vertical separator for the nav toolbar.""" def __init__(self, parent=None) -> None: super().__init__(parent) self.setFrameShape(QFrame.VLine) self.setFixedWidth(1) self.setStyleSheet("background: #1e1e38; border: none;")