| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598 |
- """
- 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
- import threading
- from PySide6.QtCore import Qt, QObject, QSize, QTimer, Signal
- from PySide6.QtWidgets import (
- QApplication,
- QButtonGroup,
- QFrame,
- QLabel,
- QMainWindow,
- QMessageBox,
- QPushButton,
- QSizePolicy,
- QStatusBar,
- QTabWidget,
- QToolBar,
- QWidget,
- )
- from src import i18n, theme
- from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
- 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 _mode_btn_css(dark: bool, mode: str) -> str:
- if mode == "real":
- color = "#2ecc71"
- border = "#27ae60"
- else:
- color = "#f39c12"
- border = "#e67e00"
- return f"""
- QPushButton {{
- background: transparent;
- color: {color};
- border: 1px solid {border};
- border-radius: 3px;
- padding: 0px 10px;
- font-size: 11px;
- font-weight: bold;
- min-height: 22px;
- max-height: 22px;
- letter-spacing: 0.5px;
- }}
- QPushButton:hover {{
- background: {color}22;
- }}
- QPushButton:disabled {{
- color: #555577;
- border-color: #333355;
- }}
- """
- class _ModeSignalBridge(QObject):
- """Carries results of background mode HTTP calls back to the Qt main thread."""
- mode_fetched = Signal(str) # mode string on successful GET /mode
- mode_set = Signal(str) # mode string on successful POST /mode
- mode_error = Signal(str) # error message
- 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",
- reconstructor_url: str = "http://localhost:8081",
- spectrometer_url: str = "http://localhost:8000",
- ) -> 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._orchestrator_url = orchestrator_url.rstrip("/")
- 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,
- reconstructor_url=reconstructor_url,
- spectrometer_url=spectrometer_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)
- # Mode selector state
- self._current_mode: str = "plug" # updated by background fetch after startup
- self._mode_bridge = _ModeSignalBridge(self)
- self.menuBar().hide()
- self._build_nav_bar()
- # Connect mode signals after nav bar is built (_mode_btn exists)
- self._mode_bridge.mode_fetched.connect(self._on_mode_fetched)
- self._mode_bridge.mode_set.connect(self._on_mode_set)
- self._mode_bridge.mode_error.connect(self._on_mode_error)
- self._build_status_bar()
- self._size_and_center()
- # Apply default dark theme to the whole application
- self._apply_theme(theme.is_dark())
- # Fetch current mode from orchestrator once the event loop is running
- QTimer.singleShot(600, self._start_fetch_mode)
- 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)
- # Mode indicator / switcher
- self._mode_btn = QPushButton("…")
- self._mode_btn.setFixedHeight(22)
- self._mode_btn.setCursor(Qt.PointingHandCursor)
- self._mode_btn.setToolTip("Click to switch operating mode")
- self._mode_btn.clicked.connect(self._on_mode_btn_clicked)
- tb.addWidget(self._mode_btn)
- self._nav_sep_mode = _VSep(tb)
- tb.addWidget(self._nav_sep_mode)
- # 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) — plain geometric symbols, no emoji variant
- 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_sep_mode.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)
- self._update_mode_btn()
- 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))
- self._update_mode_btn()
- 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())
- )
- # ------------------------------------------------------------------ #
- # Mode selector #
- # ------------------------------------------------------------------ #
- def _update_mode_btn(self) -> None:
- """Refresh mode button label and colour from _current_mode."""
- text = i18n.tr(f"mode_{self._current_mode}")
- self._mode_btn.setText(text)
- self._mode_btn.setStyleSheet(_mode_btn_css(theme.is_dark(), self._current_mode))
- # -- background fetch (startup) ----------------------------------------
- def _start_fetch_mode(self) -> None:
- threading.Thread(
- target=self._fetch_mode_bg,
- daemon=True,
- name="mode-fetch",
- ).start()
- def _fetch_mode_bg(self) -> None:
- try:
- client = OrchestratorClient(self._orchestrator_url)
- mode = client.get_mode()
- self._mode_bridge.mode_fetched.emit(mode)
- except OrchestratorError:
- pass # Orchestrator may not be up yet — keep the default label
- # -- slots (main thread) -----------------------------------------------
- def _on_mode_fetched(self, mode: str) -> None:
- self._current_mode = mode
- self._update_mode_btn()
- self._scanner_tab.refresh_scenarios()
- def _on_mode_set(self, mode: str) -> None:
- self._current_mode = mode
- self._mode_btn.setEnabled(True)
- self._update_mode_btn()
- self._scanner_tab.refresh_scenarios()
- def _on_mode_error(self, msg: str) -> None:
- self._mode_btn.setEnabled(True)
- QMessageBox.warning(
- self,
- i18n.tr("mode_error"),
- msg or "Unknown error — check that the orchestrator is running.",
- )
- # -- click handler (main thread) ---------------------------------------
- def _on_mode_btn_clicked(self) -> None:
- target = "real" if self._current_mode == "plug" else "plug"
- confirm_key = f"mode_confirm_to_{target}"
- reply = QMessageBox.question(
- self,
- i18n.tr("mode_confirm_title"),
- i18n.tr(confirm_key),
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.No,
- )
- if reply != QMessageBox.StandardButton.Yes:
- return
- self._mode_btn.setEnabled(False)
- threading.Thread(
- target=self._set_mode_bg,
- args=(target,),
- daemon=True,
- name="mode-set",
- ).start()
- def _set_mode_bg(self, mode: str) -> None:
- try:
- client = OrchestratorClient(self._orchestrator_url)
- result = client.set_mode(mode)
- self._mode_bridge.mode_set.emit(result)
- except OrchestratorError as exc:
- status = f" [HTTP {exc.status_code}]" if exc.status_code else ""
- self._mode_bridge.mode_error.emit(f"{exc}{status}")
- except Exception as exc:
- self._mode_bridge.mode_error.emit(f"{type(exc).__name__}: {exc}")
- 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;")
|