| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444 |
- """
- 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;")
|