Browse Source

Merge branch 'claude/vigorous-kepler-cd2967' into dev

# Conflicts:
#	apps/gui/src/app_window.py
#	apps/gui/src/tabs/scanning_tab.py
spacexerq 3 weeks ago
parent
commit
9275f62e1b

+ 81 - 66
apps/gui/src/app_window.py

@@ -9,7 +9,8 @@ from __future__ import annotations
 
 import os
 
-from PySide6.QtCore import Qt, QSize, QTimer
+from PySide6.QtCore import Qt, QSize
+from src import i18n
 from PySide6.QtWidgets import (
     QApplication,
     QButtonGroup,
@@ -30,8 +31,13 @@ from src.tabs.scanning_tab import ScanningTab
 from src.tabs.seq_interp_tab import SeqInterpTab
 from src.tabs.spectroscopy_tab import SpectroscopyTab
 
-_TAB_NAMES = ["Scanning", "Sequence", "Scanner", "Spectroscopy", "FID"]
-_SPEC_TAB_IDX = 3  # index of the Spectroscopy tab
+_TAB_NAV_KEYS = [
+    "tab_nav_sequence",
+    "tab_nav_scanner",
+    "tab_nav_fid",
+    "tab_nav_scanning",
+    "tab_nav_spectro",
+]
 
 _NAV_BG = "#0f0f1e"
 _NAV_H = 38
@@ -54,6 +60,27 @@ QPushButton:hover:!checked {
     background: #17172e;
 }
 """
+_LANG_BTN_CSS = f"""
+QPushButton {{
+    background: transparent;
+    color: #555577;
+    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: #f0c040;
+    border-color: #f0c040;
+}}
+QPushButton:hover:!checked {{
+    color: #aaaacc;
+}}
+"""
+
 _NAV_TOOLBAR_CSS = f"""
 QToolBar {{
     background: {_NAV_BG};
@@ -64,27 +91,6 @@ QToolBar {{
 }}
 """
 
-# Stylesheet applied to the Spectroscopy nav button while data-arrival blink is ON
-_SPEC_BTN_BLINK_CSS = """
-QPushButton {
-    background: transparent;
-    color: #e65100;
-    border: none;
-    border-bottom: 2px solid #e65100;
-    padding: 0px 20px;
-    font-size: 12px;
-    min-height: 36px;
-}
-QPushButton:checked {
-    color: #ffffff;
-    border-bottom: 2px solid #f0c040;
-}
-QPushButton:hover:!checked {
-    color: #ff7733;
-    background: #17172e;
-}
-"""
-
 
 class LFMRIWindow(QMainWindow):
     """Unified LF-MRI application window."""
@@ -113,8 +119,6 @@ class LFMRIWindow(QMainWindow):
         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,
@@ -127,24 +131,16 @@ class LFMRIWindow(QMainWindow):
         self._tabs = QTabWidget()
         self._tabs.tabBar().hide()
         self._tabs.setDocumentMode(True)
-        self._tabs.addTab(self._scanning_tab, _TAB_NAMES[0])
-        self._tabs.addTab(self._seq_tab, _TAB_NAMES[1])
-        self._tabs.addTab(self._scanner_tab, _TAB_NAMES[2])
-        self._tabs.addTab(self._spectroscopy_tab, _TAB_NAMES[3])
-        self._tabs.addTab(self._fid_tab, _TAB_NAMES[4])
+        self._tabs.addTab(self._seq_tab,          i18n.tr(_TAB_NAV_KEYS[0]))
+        self._tabs.addTab(self._scanner_tab,       i18n.tr(_TAB_NAV_KEYS[1]))
+        self._tabs.addTab(self._fid_tab,           i18n.tr(_TAB_NAV_KEYS[2]))
+        self._tabs.addTab(self._scanning_tab,      i18n.tr(_TAB_NAV_KEYS[3]))
+        self._tabs.addTab(self._spectroscopy_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.raw_data_ready.connect(self._on_scan_raw_data_ready)
-        self._scanning_tab.scan_job_started.connect(self._scanner_tab.attach_job)
-
-        # -- blink timer (off by default) ----------------------------------
-        self._spec_blink_on: bool = False
-        self._spec_blink_timer = QTimer(self)
-        self._spec_blink_timer.setInterval(700)   # slow orange blink: 700 ms
-        self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
 
         self.menuBar().hide()
         self._build_nav_bar()
@@ -177,8 +173,8 @@ class LFMRIWindow(QMainWindow):
         self._nav_btn_group.setExclusive(True)
         self._nav_tab_buttons: list[QPushButton] = []
 
-        for i, name in enumerate(_TAB_NAMES):
-            btn = QPushButton(name)
+        for i, key in enumerate(_TAB_NAV_KEYS):
+            btn = QPushButton(i18n.tr(key))
             btn.setCheckable(True)
             btn.setStyleSheet(_TAB_BTN_CSS)
             btn.setFixedHeight(_NAV_H)
@@ -195,6 +191,25 @@ class LFMRIWindow(QMainWindow):
         spacer.setStyleSheet(f"background: {_NAV_BG};")
         tb.addWidget(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.setStyleSheet(_LANG_BTN_CSS)
+        self._btn_lang_en.setCursor(Qt.PointingHandCursor)
+        self._btn_lang_ru = QPushButton("RU")
+        self._btn_lang_ru.setCheckable(True)
+        self._btn_lang_ru.setStyleSheet(_LANG_BTN_CSS)
+        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)
+
     def _switch_tab(self, index: int) -> None:
         self._tabs.setCurrentIndex(index)
         self._nav_tab_buttons[index].setChecked(True)
@@ -205,7 +220,7 @@ class LFMRIWindow(QMainWindow):
             "QStatusBar { background: #0c0c1a; color: #555577; font-size: 11px; }"
         )
         self.setStatusBar(sb)
-        sb.showMessage(f"Active: {_TAB_NAMES[0]}")
+        sb.showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(_TAB_NAV_KEYS[0])}")
 
     def _size_and_center(self) -> None:
         screen = QApplication.primaryScreen()
@@ -222,39 +237,39 @@ class LFMRIWindow(QMainWindow):
             self.resize(1440, 860)
 
     def _on_tab_changed(self, index: int) -> None:
-        name = _TAB_NAMES[index] if 0 <= index < len(_TAB_NAMES) else "-"
-        self.statusBar().showMessage(f"Active: {name}")
+        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._seq_tab,
+            self._scanner_tab,
+            self._fid_tab,
+            self._scanning_tab,
+            self._spectroscopy_tab,
+        ):
+            if hasattr(tab, "retranslate_ui"):
+                tab.retranslate_ui()
 
     def _on_fid_generated(self, path: str) -> None:
         self._seq_tab.load_seq_file(path)
-        self._switch_tab(1)
+        self._switch_tab(0)
 
     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:
-        """Raw measurement data arrived — push to spectroscopy tab and start blink."""
-        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()
-
-    # -- nav button blink (Spectroscopy tab) --------------------------------
-
-    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 if self._spec_blink_on else _TAB_BTN_CSS)
-
-    def _stop_spec_blink(self) -> None:
-        self._spec_blink_timer.stop()
-        self._nav_tab_buttons[_SPEC_TAB_IDX].setStyleSheet(_TAB_BTN_CSS)
+        self._switch_tab(1)
 
 
 class _VSep(QFrame):

+ 14 - 6
apps/gui/src/gui/controls_panel.py

@@ -7,6 +7,7 @@ import json
 
 from PySide6.QtCore import Signal
 from src.gui.scheme_panel import system_is_dark
+from src import i18n
 from PySide6.QtWidgets import (
     QWidget, QVBoxLayout, QGroupBox, QFormLayout,
     QDoubleSpinBox, QPushButton, QGridLayout, QFileDialog,
@@ -36,7 +37,7 @@ class DelayControlsPanel(QWidget):
         outer = QVBoxLayout(self)
         outer.setContentsMargins(4, 4, 4, 4)
 
-        grp = QGroupBox("Hardware Delays / Rasters")
+        self._grp = QGroupBox(i18n.tr("grp_hw_delays"))
         form = QFormLayout(grp)
         form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 
@@ -54,13 +55,13 @@ class DelayControlsPanel(QWidget):
             form.addRow(f"{label}:", sb)
             self._spinboxes[attr] = (sb, scale)
 
-        outer.addWidget(grp)
+        outer.addWidget(self._grp)
 
         # 2x2 grid: prevents text overflow on narrow left panel (min 220 px)
-        self.btn_apply  = QPushButton("Apply && Rerun")
-        self.btn_reset  = QPushButton("Reset")
-        self.btn_reload = QPushButton("Reload Config")
-        self.btn_save   = QPushButton("Save HW Config")
+        self.btn_apply  = QPushButton(i18n.tr("btn_apply_rerun"))
+        self.btn_reset  = QPushButton(i18n.tr("btn_reset"))
+        self.btn_reload = QPushButton(i18n.tr("btn_reload_config"))
+        self.btn_save   = QPushButton(i18n.tr("btn_save_hw"))
         grid = QGridLayout()
         grid.setSpacing(4)
         grid.addWidget(self.btn_apply,  0, 0)
@@ -79,6 +80,13 @@ class DelayControlsPanel(QWidget):
     # Public API
     # ------------------------------------------------------------------
 
+    def retranslate_ui(self) -> None:
+        self._grp.setTitle(i18n.tr("grp_hw_delays"))
+        self.btn_apply .setText(i18n.tr("btn_apply_rerun"))
+        self.btn_reset .setText(i18n.tr("btn_reset"))
+        self.btn_reload.setText(i18n.tr("btn_reload_config"))
+        self.btn_save  .setText(i18n.tr("btn_save_hw"))
+
     def load_from_hw(self, hw) -> None:
         for attr, (sb, scale) in self._spinboxes.items():
             val = getattr(hw, attr, 0.0)

+ 156 - 0
apps/gui/src/i18n.py

@@ -0,0 +1,156 @@
+"""Simple two-language localization (EN / RU) for the LF-MRI GUI."""
+from __future__ import annotations
+
+_lang: str = "en"
+
+_T: dict[str, dict[str, str]] = {
+    # --- nav bar ---
+    "tab_nav_sequence":   {"en": "Sequence",      "ru": "Последовательность"},
+    "tab_nav_scanner":    {"en": "Scanner",        "ru": "Сканнер"},
+    "tab_nav_fid":        {"en": "FID",            "ru": "FID"},
+    "tab_nav_scanning":   {"en": "Scanning",       "ru": "Сканирование"},
+    "tab_nav_spectro":    {"en": "Spectroscopy",   "ru": "Спектроскопия"},
+    "active_tab":         {"en": "Active",         "ru": "Активно"},
+
+    # --- scanner_tab ---
+    "url_label":          {"en": "Orchestrator URL:", "ru": "URL оркестратора:"},
+    "btn_connect":        {"en": "Connect",            "ru": "Подключить"},
+    "offline":            {"en": "● Offline",          "ru": "● Не подключён"},
+    "online":             {"en": "● Online",           "ru": "● Подключён"},
+    "conn_error":         {"en": "● Error",            "ru": "● Ошибка"},
+    "grp_scenario":       {"en": "Scenario",           "ru": "Сценарий"},
+    "btn_refresh":        {"en": "Refresh",            "ru": "Обновить"},
+    "grp_seq_info":       {"en": "Sequence Info",      "ru": "Информация о посл-ти"},
+    "no_seq_loaded":      {"en": "No sequence loaded", "ru": "Посл-ть не загружена"},
+    "grp_job":            {"en": "Job",                "ru": "Задание"},
+    "no_job":             {"en": "- no job -",         "ru": "- нет задания -"},
+    "btn_load_scenario":  {"en": "Load Scenario",      "ru": "Загрузить сценарий"},
+    "btn_run_all":        {"en": "Run All",            "ru": "Запустить всё"},
+    "btn_next_step":      {"en": "Next Step",          "ru": "Следующий шаг"},
+    "btn_abort":          {"en": "Abort",              "ru": "Прервать"},
+    "col_step":           {"en": "Step",               "ru": "Шаг"},
+    "col_status":         {"en": "Status",             "ru": "Статус"},
+    "col_result":         {"en": "Result",             "ru": "Результат"},
+    "tab_step_result":    {"en": "Step Result",        "ru": "Результат шага"},
+    "tab_seq_info_view":  {"en": "Sequence Info",      "ru": "Инф. о посл-ти"},
+    "tab_log":            {"en": "Log",                "ru": "Журнал"},
+
+    # --- seq_interp_tab ---
+    "btn_load_seq":       {"en": " Load .seq",         "ru": " Загрузить .seq"},
+    "btn_hw_config":      {"en": " HW Config",         "ru": " Конфиг ЖО"},
+    "btn_out_dir":        {"en": " Output Dir",        "ru": " Папка вывода"},
+    "btn_run":            {"en": "Run",                "ru": "Запустить"},
+    "btn_export":         {"en": " Export",            "ru": " Экспорт"},
+    "btn_fit_all":        {"en": " Fit All",           "ru": " Вписать всё"},
+    "blocks_closed":      {"en": " Blocks ▾",          "ru": " Блоки ▾"},
+    "blocks_open":        {"en": " Blocks ▴",          "ru": " Блоки ▴"},
+    "btn_send_scanner":   {"en": " Send to Scanner",   "ru": " На сканнер"},
+    "grp_seq_metadata":   {"en": "Sequence Metadata",  "ru": "Метаданные посл-ти"},
+    "grp_warnings":       {"en": "Warnings",           "ru": "Предупреждения"},
+    "status_ready":       {"en": "Ready",              "ru": "Готово"},
+    "no_file_selected":   {"en": "No file selected",   "ru": "Файл не выбран"},
+
+    # --- fid_tab ---
+    "btn_generate":       {"en": "Generate",                       "ru": "Генерировать"},
+    "btn_save_seq":       {"en": " Save .seq",                     "ru": " Сохранить .seq"},
+    "btn_load_seq_tab":   {"en": "→ Load in Sequence Tab",         "ru": "→ В Sequence Tab"},
+    "grp_fid_params":     {"en": "FID Parameters",                 "ru": "Параметры FID"},
+    "grp_pulse_type":     {"en": "Pulse type",                     "ru": "Тип импульса"},
+    "grp_out_filename":   {"en": "Output filename (no extension)", "ru": "Имя файла (без расширения)"},
+    "fid_status_init":    {
+        "en": "Ready - set parameters and click Generate",
+        "ru": "Готово - задайте параметры и нажмите Генерировать",
+    },
+
+    # --- scanning_tab ---
+    "protocols_header":   {"en": "Protocols",         "ru": "Протоколы"},
+    "tab_main":           {"en": "Main",              "ru": "Основные"},
+    "tab_contrast":       {"en": "Contrast",          "ru": "Контраст"},
+    "tab_resolution":     {"en": "Resolution",        "ru": "Разрешение"},
+    "tab_system":         {"en": "System",            "ru": "Система"},
+    "tab_geometry":       {"en": "Geometry",          "ru": "Геометрия"},
+    "grp_orientation":    {"en": "Orientation",       "ru": "Ориентация"},
+    "grp_rotation":       {"en": "Rotation",          "ru": "Поворот"},
+    "grp_rot_matrix":     {"en": "Rotation Matrix",   "ru": "Матрица поворота"},
+    "no_data":            {"en": "No data",           "ru": "Нет данных"},
+    "btn_scan":           {"en": "Scan",              "ru": "Сканировать"},
+    "btn_stop_scan":      {"en": "Stop",              "ru": "Стоп"},
+    "scanning_status":    {"en": "Scanning...",       "ru": "Сканирование..."},
+    "ready_to_scan":      {"en": "Ready to scan",     "ru": "Готово к сканированию"},
+    "dlg_no_data_title":  {"en": "No data",           "ru": "Нет данных"},
+    "dlg_no_seq_msg":     {
+        "en": "First load and export a sequence\nin the \"Sequence\" tab.",
+        "ru": "Сначала загрузите и экспортируйте последовательность\nво вкладке \"Sequence\".",
+    },
+    "done_status":        {"en": "Done",              "ru": "Готово"},
+    "error_prefix":       {"en": "Error",             "ru": "Ошибка"},
+
+    # --- spectroscopy_tab ---
+    "btn_load_json":      {"en": "Load JSON...",      "ru": "Загрузить JSON..."},
+    "btn_analyze":        {"en": "Analyze",           "ru": "Анализировать"},
+    "btn_batch":          {"en": "Batch...",          "ru": "Пакетный..."},
+    "btn_export_sp":      {"en": "Export...",         "ru": "Экспорт..."},
+    "btn_clear":          {"en": "Clear",             "ru": "Очистить"},
+    "grp_nmr_params":     {"en": "NMR Parameters",   "ru": "Параметры ЯМР"},
+    "grp_metadata":       {"en": "Metadata",         "ru": "Метаданные"},
+    "grp_metrics":        {"en": "Metrics",          "ru": "Метрики"},
+    "spec_status_init":   {
+        "en": "Click 'Load JSON...' to open a hardware JSON file and start NMR analysis",
+        "ru": "Нажмите 'Загрузить JSON...' для открытия JSON-файла и запуска анализа ЯМР",
+    },
+    "batch_title":        {"en": "Batch NMR Analysis", "ru": "Пакетный анализ ЯМР"},
+    "folder_label":       {"en": "Folder:",             "ru": "Папка:"},
+    "btn_browse":         {"en": "Browse...",           "ru": "Обзор..."},
+    "cb_use_current":     {
+        "en": "Use current parameters from the Spectroscopy tab",
+        "ru": "Использовать текущие параметры вкладки Spectroscopy",
+    },
+    "btn_run_batch":      {"en": "Run Batch",           "ru": "Запустить пакет"},
+    "btn_export_csv":     {"en": "Export CSV...",       "ru": "Экспорт CSV..."},
+    "btn_close":          {"en": "Close",              "ru": "Закрыть"},
+    "batch_status_init":  {
+        "en": "Select a folder and click Run Batch.",
+        "ru": "Выберите папку и нажмите Запустить пакет.",
+    },
+
+    # --- controls_panel ---
+    "grp_hw_delays":      {"en": "Hardware Delays / Rasters", "ru": "Задержки / Растры ЖО"},
+    "btn_apply_rerun":    {"en": "Apply && Rerun",    "ru": "Применить && Перезапуск"},
+    "btn_reset":          {"en": "Reset",             "ru": "Сброс"},
+    "btn_reload_config":  {"en": "Reload Config",     "ru": "Перезагрузить конфиг"},
+    "btn_save_hw":        {"en": "Save HW Config",    "ru": "Сохранить конфиг ЖО"},
+}
+
+_TAB_NAV_KEYS = [
+    "tab_nav_sequence",
+    "tab_nav_scanner",
+    "tab_nav_fid",
+    "tab_nav_scanning",
+    "tab_nav_spectro",
+]
+
+_PARAM_TABS_KEYS = [
+    "tab_main",
+    "tab_contrast",
+    "tab_resolution",
+    "tab_system",
+    "tab_geometry",
+]
+
+
+def tr(key: str) -> str:
+    """Return translated string for *key* in the current language."""
+    entry = _T.get(key)
+    if entry is None:
+        return key
+    return entry.get(_lang, entry.get("en", key))
+
+
+def current_language() -> str:
+    return _lang
+
+
+def set_language(lang: str) -> None:
+    global _lang
+    if lang in ("en", "ru"):
+        _lang = lang

+ 5 - 4
apps/gui/src/tabs/fid_tab.py

@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
 
 from src.gui.plot_panel import PlotPanel
 from src.gui.workers import LoadInterpWorker
+from src import i18n
 
 _GAMMA = 42.576e6   # Hz/T, proton
 _G_AMP_MAX_MT_M = 5.0
@@ -137,11 +138,11 @@ class FidTab(QWidget):
             f.setFixedWidth(2)
             return f
 
-        self._btn_generate = btn("Run Generate", "Generate FID sequence and visualise", self._generate)
+        self._btn_generate    = btn(i18n.tr("btn_generate"),    "Generate FID sequence and visualise", self._generate)
         lay.addWidget(sep())
-        self._btn_save = btn(" Save .seq", "Save .seq + .json to output directory",
-                             self._save, enabled=False)
-        self._btn_load_seq_tab = btn("-> Load in Sequence Tab",
+        self._btn_save        = btn(i18n.tr("btn_save_seq"),    "Save .seq + .json to output directory",
+                                    self._save, enabled=False)
+        self._btn_load_seq_tab = btn(i18n.tr("btn_load_seq_tab"),
                                      "Send this .seq to the Sequence interpreter tab",
                                      self._load_in_seq_tab, enabled=False)
         lay.addStretch()

+ 54 - 26
apps/gui/src/tabs/scanner_tab.py

@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
 
 from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
 from src.gui.workers import OrchestratorWorker
+from src import i18n
 
 
 _STATUS_COLORS = {
@@ -70,6 +71,7 @@ class ScannerTab(QWidget):
         self._client = OrchestratorClient(orchestrator_url)
         self._job_id: str | None = None
         self._seq_info: dict | None = None
+        self._conn_state: str = "offline"
 
         # Active workers - kept alive while running
         self._run_worker: OrchestratorWorker | None = None
@@ -132,6 +134,25 @@ class ScannerTab(QWidget):
         self._seq_info_label.setText("\n".join(summary_lines) if summary_lines else "-")
         self._append_log("Sequence info received from Sequence tab.")
 
+    def retranslate_ui(self) -> None:
+        self._url_label.setText(i18n.tr("url_label"))
+        self._btn_connect.setText(i18n.tr("btn_connect"))
+        self._status_label.setText(i18n.tr(self._conn_state))
+        self._scenario_grp.setTitle(i18n.tr("grp_scenario"))
+        self._btn_refresh.setText(i18n.tr("btn_refresh"))
+        self._seq_grp.setTitle(i18n.tr("grp_seq_info"))
+        self._job_grp.setTitle(i18n.tr("grp_job"))
+        self._btn_load.setText(i18n.tr("btn_load_scenario"))
+        self._btn_run_all.setText(i18n.tr("btn_run_all"))
+        self._btn_next.setText(i18n.tr("btn_next_step"))
+        self._btn_abort.setText(i18n.tr("btn_abort"))
+        self._steps_table.setHorizontalHeaderLabels([
+            i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
+        ])
+        self._bottom_tabs.setTabText(0, i18n.tr("tab_step_result"))
+        self._bottom_tabs.setTabText(1, i18n.tr("tab_seq_info_view"))
+        self._bottom_tabs.setTabText(2, i18n.tr("tab_log"))
+
     # ================================================================== #
     #  Layout builders                                                     #
     # ================================================================== #
@@ -261,17 +282,18 @@ class ScannerTab(QWidget):
         lay = QHBoxLayout(bar)
         lay.setContentsMargins(0, 0, 0, 0)
 
-        lay.addWidget(QLabel("Orchestrator URL:"))
+        self._url_label = QLabel(i18n.tr("url_label"))
+        lay.addWidget(self._url_label)
         self._url_edit = QLineEdit(self._client.base_url)
         self._url_edit.setMaximumWidth(260)
         lay.addWidget(self._url_edit)
 
-        self._btn_connect = QPushButton("Connect")
+        self._btn_connect = QPushButton(i18n.tr("btn_connect"))
         self._btn_connect.setFixedWidth(80)
         self._btn_connect.clicked.connect(self._on_connect)
         lay.addWidget(self._btn_connect)
 
-        self._status_label = QLabel("o Offline")
+        self._status_label = QLabel(i18n.tr("offline"))
         self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
         lay.addWidget(self._status_label)
 
@@ -293,55 +315,55 @@ class ScannerTab(QWidget):
         lay.setSpacing(8)
 
         # Scenario selector
-        scenario_grp = QGroupBox("Scenario")
-        sg_lay = QVBoxLayout(scenario_grp)
+        self._scenario_grp = QGroupBox(i18n.tr("grp_scenario"))
+        sg_lay = QVBoxLayout(self._scenario_grp)
         self._scenario_combo = QComboBox()
         self._scenario_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
         sg_lay.addWidget(self._scenario_combo)
-        self._btn_refresh = QPushButton("Refresh Refresh")
+        self._btn_refresh = QPushButton(i18n.tr("btn_refresh"))
         self._btn_refresh.clicked.connect(self._on_refresh_scenarios)
         sg_lay.addWidget(self._btn_refresh)
-        lay.addWidget(scenario_grp)
+        lay.addWidget(self._scenario_grp)
 
         # Sequence summary
-        seq_grp = QGroupBox("Sequence Info")
-        seq_lay = QVBoxLayout(seq_grp)
-        self._seq_info_label = QLabel("No sequence loaded")
+        self._seq_grp = QGroupBox(i18n.tr("grp_seq_info"))
+        seq_lay = QVBoxLayout(self._seq_grp)
+        self._seq_info_label = QLabel(i18n.tr("no_seq_loaded"))
         self._seq_info_label.setWordWrap(True)
         self._seq_info_label.setStyleSheet("color: #666688; font-style: italic;")
         self._seq_info_label.setFont(QFont("Courier New", 8))
         seq_lay.addWidget(self._seq_info_label)
-        lay.addWidget(seq_grp)
+        lay.addWidget(self._seq_grp)
 
         # Job info
-        job_grp = QGroupBox("Job")
-        job_lay = QVBoxLayout(job_grp)
-        self._job_label = QLabel("- no job -")
+        self._job_grp = QGroupBox(i18n.tr("grp_job"))
+        job_lay = QVBoxLayout(self._job_grp)
+        self._job_label = QLabel(i18n.tr("no_job"))
         self._job_label.setFont(QFont("Courier New", 8))
         self._job_label.setWordWrap(True)
         self._job_label.setStyleSheet("color: #666688;")
         job_lay.addWidget(self._job_label)
-        lay.addWidget(job_grp)
+        lay.addWidget(self._job_grp)
 
         # Control buttons
-        self._btn_load = QPushButton("Load Scenario")
+        self._btn_load = QPushButton(i18n.tr("btn_load_scenario"))
         self._btn_load.setMinimumHeight(30)
         self._btn_load.clicked.connect(self._on_load_scenario)
         lay.addWidget(self._btn_load)
 
-        self._btn_run_all = QPushButton("Run Run All")
+        self._btn_run_all = QPushButton(i18n.tr("btn_run_all"))
         self._btn_run_all.setMinimumHeight(30)
         self._btn_run_all.setEnabled(False)
         self._btn_run_all.clicked.connect(self._on_run_all)
         lay.addWidget(self._btn_run_all)
 
-        self._btn_next = QPushButton("Next Next Step")
+        self._btn_next = QPushButton(i18n.tr("btn_next_step"))
         self._btn_next.setMinimumHeight(30)
         self._btn_next.setEnabled(False)
         self._btn_next.clicked.connect(self._on_next_step)
         lay.addWidget(self._btn_next)
 
-        self._btn_abort = QPushButton("Stop Abort")
+        self._btn_abort = QPushButton(i18n.tr("btn_abort"))
         self._btn_abort.setMinimumHeight(30)
         self._btn_abort.setEnabled(False)
         self._btn_abort.clicked.connect(self._on_abort)
@@ -363,7 +385,9 @@ class ScannerTab(QWidget):
 
         # Steps table
         self._steps_table = QTableWidget(0, 3)
-        self._steps_table.setHorizontalHeaderLabels(["Step", "Status", "Result"])
+        self._steps_table.setHorizontalHeaderLabels([
+            i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
+        ])
         self._steps_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
         self._steps_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
         self._steps_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@@ -382,17 +406,18 @@ class ScannerTab(QWidget):
         self._step_result_view = QTextEdit()
         self._step_result_view.setReadOnly(True)
         self._step_result_view.setFont(QFont("Courier New", 9))
-        bottom_tabs.addTab(self._step_result_view, "Step Result")
+        self._bottom_tabs = bottom_tabs
+        bottom_tabs.addTab(self._step_result_view, i18n.tr("tab_step_result"))
 
         self._seq_info_view = QTextEdit()
         self._seq_info_view.setReadOnly(True)
         self._seq_info_view.setFont(QFont("Courier New", 9))
-        bottom_tabs.addTab(self._seq_info_view, "Sequence Info")
+        bottom_tabs.addTab(self._seq_info_view, i18n.tr("tab_seq_info_view"))
 
         self._log_view = QTextEdit()
         self._log_view.setReadOnly(True)
         self._log_view.setFont(QFont("Courier New", 9))
-        bottom_tabs.addTab(self._log_view, "Log")
+        bottom_tabs.addTab(self._log_view, i18n.tr("tab_log"))
 
         lay.addWidget(bottom_tabs, stretch=1)
         return panel
@@ -419,19 +444,22 @@ class ScannerTab(QWidget):
         self._btn_connect.setEnabled(True)
         self._conn_progress.setVisible(False)
         if ok:
-            self._status_label.setText("o Online")
+            self._conn_state = "online"
+            self._status_label.setText(i18n.tr("online"))
             self._status_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
             self._append_log(f"Connected to orchestrator: {self._client.base_url}")
             self._on_refresh_scenarios()
         else:
-            self._status_label.setText("o Offline")
+            self._conn_state = "offline"
+            self._status_label.setText(i18n.tr("offline"))
             self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
             self._append_log("Orchestrator not reachable.")
 
     def _on_healthcheck_error(self, msg: str) -> None:
         self._btn_connect.setEnabled(True)
         self._conn_progress.setVisible(False)
-        self._status_label.setText("o Error")
+        self._conn_state = "conn_error"
+        self._status_label.setText(i18n.tr("conn_error"))
         self._status_label.setStyleSheet("color: #c62828; font-weight: bold;")
         self._append_log(f"Connect error: {msg}")
 

+ 116 - 401
apps/gui/src/tabs/scanning_tab.py

@@ -13,7 +13,6 @@ from __future__ import annotations
 
 import math
 import json
-import os
 import numpy as np
 
 from PySide6.QtCore import Qt, QThread, QTimer, Signal
@@ -22,11 +21,19 @@ from PySide6.QtGui import (
 )
 from PySide6.QtCore import QPointF
 from PySide6.QtWidgets import (
-    QButtonGroup, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
-    QGridLayout, QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
-    QPushButton, QSplitter, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
+    QButtonGroup, QDoubleSpinBox, QFormLayout, QGridLayout,
+    QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
+    QPushButton, QSplitter, QTabWidget, QVBoxLayout, QWidget,
 )
 
+try:
+    import httpx as _httpx
+    _HAS_HTTPX = True
+except ImportError:
+    _HAS_HTTPX = False
+
+from src import i18n
+
 # -- colour palette -------------------------------------------------------------
 _BG_DARK      = "#1a1a2e"
 _PANEL_BG     = "#2a2a2a"
@@ -318,11 +325,11 @@ class ProtocolListWidget(QWidget):
         lay.setContentsMargins(4, 8, 4, 4)
         lay.setSpacing(4)
 
-        header = QLabel("Протоколы")
-        header.setStyleSheet(
+        self._header_lbl = QLabel(i18n.tr("protocols_header"))
+        self._header_lbl.setStyleSheet(
             "color: #aaaaaa; font-weight: bold; font-size: 11px; background: transparent;"
         )
-        lay.addWidget(header)
+        lay.addWidget(self._header_lbl)
 
         self._list = QListWidget()
         self._list.setStyleSheet(f"""
@@ -351,121 +358,66 @@ class ProtocolListWidget(QWidget):
         self._list.currentTextChanged.connect(self.protocol_selected)
         lay.addWidget(self._list, stretch=1)
 
+    def retranslate_ui(self) -> None:
+        self._header_lbl.setText(i18n.tr("protocols_header"))
 
-# ==============================================================================
-class _ScanPipelineWorker(QThread):
-    """
-    Thin worker: sends everything to the orchestrator via POST /scan/ and
-    polls GET /scenario/{job_id} until done.
 
-    The orchestrator is responsible for:
-      - Forwarding the .seq file to seq-interp for interpretation
-      - Running the full measurement scenario (spectrometer, reconstructor, …)
+# ==============================================================================
+class _ScanWorker(QThread):
+    """Fire-and-forget: load scenario and run_all via orchestrator REST."""
 
-    The GUI never calls seq-interp or any other microservice directly.
-    """
+    finished = Signal(str)   # job_id or success message
+    error    = Signal(str)
 
-    progress       = Signal(str)
-    job_started    = Signal(str)   # job_id received from orchestrator
-    finished       = Signal(str)
-    error          = Signal(str)
-    raw_data_ready = Signal(str)   # absolute path to temp JSON file
-
-    def __init__(
-        self,
-        seq_file_path: str | None,
-        seq_info: dict | None,
-        orchestrator_url: str,
-        scenario_id: str = "full_pipeline",
-        protocol: str = "",
-        parent=None,
-    ) -> None:
+    def __init__(self, url: str, info: dict, parent=None) -> None:
         super().__init__(parent)
-        self._seq_file    = seq_file_path
-        self._seq_info    = dict(seq_info) if seq_info else {}
-        self._scenario_id = scenario_id
-        self._protocol    = protocol
-        from src.clients.orchestrator_client import OrchestratorClient
-        self._orch = OrchestratorClient(orchestrator_url)
-
-    # -- main run -----------------------------------------------------------
+        self._url  = url.rstrip("/")
+        self._info = info
 
     def run(self) -> None:
         try:
-            self._run_pipeline()
+            if not _HAS_HTTPX:
+                import urllib.request, urllib.error
+                self._run_urllib()
+            else:
+                self._run_httpx()
         except Exception as exc:
-            if not self.isInterruptionRequested():
-                self.error.emit(str(exc))
-
-    def _run_pipeline(self) -> None:
-        # 1. Quick orchestrator reachability check
-        self.progress.emit("Проверка оркестратора...")
-        if not self._orch.healthcheck():
-            raise RuntimeError("Оркестратор недоступен — проверьте, что сервис запущен")
-
-        # 2. Submit the scan job — orchestrator handles interpretation + pipeline
-        self.progress.emit(
-            f"Отправка задания в оркестратор "
-            f"[сценарий: {self._scenario_id}, ИП: {self._protocol or '—'}]…"
-        )
-        job_id = self._orch.scan(
-            seq_file_path=self._seq_file or None,
-            seq_info=self._seq_info or None,
-            scenario_id=self._scenario_id,
-            protocol=self._protocol,
-        )
-        self.job_started.emit(job_id)
-        self.progress.emit(f"  Job {job_id[:8]}… запущен, ожидание результата…")
-
-        # 3. Poll until done (orchestrator runs everything in background)
-        def _on_status(status: str) -> None:
-            self.progress.emit(f"  [{status}]")
-
-        final = self._orch.poll_scan(
-            job_id,
-            timeout=300.0,
-            poll_interval=2.0,
-            progress_cb=_on_status,
-            interrupted_fn=self.isInterruptionRequested,
+            self.error.emit(str(exc))
+
+    def _run_httpx(self) -> None:
+        payload = {"param_overrides": {"start_measurement": {"info": self._info}}}
+        with _httpx.Client(timeout=15) as client:
+            r = client.post(
+                f"{self._url}/scenario/load/full_pipeline",
+                json=payload,
+            )
+            r.raise_for_status()
+            job_id = r.json().get("job_id", "?")
+            r2 = client.post(f"{self._url}/scenario/{job_id}/run_all")
+            r2.raise_for_status()
+            self.finished.emit(f"job_id={job_id}")
+
+    def _run_urllib(self) -> None:
+        import urllib.request
+        payload = {"param_overrides": {"start_measurement": {"info": self._info}}}
+        data    = json.dumps(payload).encode()
+        headers = {"Content-Type": "application/json"}
+
+        req  = urllib.request.Request(
+            f"{self._url}/scenario/load/full_pipeline",
+            data=data, headers=headers, method="POST",
         )
+        with urllib.request.urlopen(req, timeout=15) as resp:
+            body   = json.loads(resp.read())
+            job_id = body.get("job_id", "?")
 
-        # 4. Extract raw measurement data from step results
-        steps    = final.get("steps", [])
-        meas_id  = None
-        raw_data = None
-        for step in steps:
-            name = step.get("name")
-            res  = step.get("result") or {}
-            if name == "start_measurement":
-                meas_id = res.get("measurement_id")
-            if name == "fetch_data":
-                raw_data = res.get("data")
-
-        self.progress.emit(f"  Сканирование завершено (meas_id={meas_id})")
-
-        # 5. Persist raw data for Spectroscopy tab
-        is_stub = str(meas_id) in ("", "None", "meas_stub") or meas_id is None
-        if raw_data and not is_stub:
-            raw_path = self._save_raw(raw_data, meas_id)
-            if raw_path:
-                self.raw_data_ready.emit(raw_path)
-
-        self.finished.emit(f"job завершён (meas_id={meas_id})")
-
-    # -- raw data persistence -----------------------------------------------
-
-    def _save_raw(self, data, meas_id) -> str | None:
-        import tempfile, time as _t
-        fname = f"scan_raw_{meas_id}_{int(_t.time())}.json"
-        fpath = os.path.join(tempfile.gettempdir(), fname)
-        try:
-            with open(fpath, "w", encoding="utf-8") as fh:
-                json.dump(data, fh)
-            self.progress.emit(f"  Данные сохранены: {fname}")
-            return fpath
-        except Exception as exc:
-            self.progress.emit(f"  Не удалось сохранить данные: {exc}")
-            return None
+        req2 = urllib.request.Request(
+            f"{self._url}/scenario/{job_id}/run_all",
+            data=b"{}", headers=headers, method="POST",
+        )
+        with urllib.request.urlopen(req2, timeout=15):
+            pass
+        self.finished.emit(f"job_id={job_id}")
 
 
 # ==============================================================================
@@ -479,22 +431,17 @@ class ScanningTab(QWidget):
     "Геометрия" holds orientation presets + Rx/Ry/Rz spinboxes + live 3x3 matrix.
     """
 
-    scan_job_started = Signal(str)  # job_id — forwarded to ScannerTab for queue monitoring
-    raw_data_ready   = Signal(str)  # absolute path to temp JSON file
-
     def __init__(self, parent: QWidget | None = None) -> None:
         super().__init__(parent)
         self.setStyleSheet(f"background: {_BG_DARK};")
 
-        self._viewers:          list[MriViewerWidget]      = []
-        self._scan_tick:        int                        = 0
-        self._seq_info:         dict | None                = None
-        self._seq_file_path:    str | None                 = None
-        self._orchestrator_url: str                        = "http://localhost:1717"
-        self._scan_worker:      _ScanPipelineWorker | None = None
-        self._active_protocol:  str                        = _PROTOCOLS[0]
-        self._scenario_id:      str                        = "full_pipeline"
-        self._slice_offset:     list[float]                = [0.0, 0.0, 0.0]
+        self._viewers:          list[MriViewerWidget] = []
+        self._scan_tick:        int                   = 0
+        self._seq_info:         dict | None           = None
+        self._orchestrator_url: str                   = "http://localhost:1717"
+        self._scan_worker:      _ScanWorker | None    = None
+        self._active_protocol:  str                   = _PROTOCOLS[0]
+        self._slice_offset:     list[float]           = [0.0, 0.0, 0.0]
 
         root = QVBoxLayout(self)
         root.setContentsMargins(0, 0, 0, 0)
@@ -516,18 +463,29 @@ class ScanningTab(QWidget):
     # -- public API ---------------------------------------------------------
 
     def apply_seq_info(self, info_dict: dict) -> None:
-        """Receive exported sequence info from SeqInterpTab and auto-start scan."""
+        """Receive exported sequence info from SeqInterpTab."""
         self._seq_info = dict(info_dict)
-        self._seq_file_path = None
         self._update_scan_ready_state()
-        label = info_dict.get("infostr") or "sequence"
-        self._log(f"Получены параметры последовательности: {label}", "INFO")
-        if not self._btn_scan.isChecked():
-            self._btn_scan.setChecked(True)
 
     def set_orchestrator_url(self, url: str) -> None:
         self._orchestrator_url = url
 
+    def retranslate_ui(self) -> None:
+        self._protocol_list.retranslate_ui()
+        # param tabs: 0-3 = placeholders, 4 = geometry
+        _keys = ["tab_main", "tab_contrast", "tab_resolution", "tab_system", "tab_geometry"]
+        for idx, key in enumerate(_keys):
+            self._param_tabs.setTabText(idx, i18n.tr(key))
+        self._preset_group.setTitle(i18n.tr("grp_orientation"))
+        self._rot_group.setTitle(i18n.tr("grp_rotation"))
+        self._matrix_group.setTitle(i18n.tr("grp_rot_matrix"))
+        # scan button: keep text in sync with current scan state
+        if self._scan_timer.isActive():
+            self._btn_scan.setText(i18n.tr("btn_stop_scan"))
+        else:
+            self._btn_scan.setText(i18n.tr("btn_scan"))
+        self._update_scan_ready_state()
+
     # -- layout builders ----------------------------------------------------
 
     def _build_upper_area(self) -> QWidget:
@@ -547,100 +505,9 @@ class ScanningTab(QWidget):
         return container
 
     def _build_protocol_panel(self) -> QWidget:
-        container = QWidget()
-        container.setStyleSheet(f"background: {_PANEL_BG};")
-        lay = QVBoxLayout(container)
-        lay.setContentsMargins(0, 0, 0, 0)
-        lay.setSpacing(0)
-
         self._protocol_list = ProtocolListWidget()
         self._protocol_list.protocol_selected.connect(self._on_protocol_selected)
-        lay.addWidget(self._protocol_list, stretch=1)
-
-        sep = QFrame()
-        sep.setFrameShape(QFrame.HLine)
-        sep.setFixedHeight(1)
-        sep.setStyleSheet("background: #3a3a4a; border: none;")
-        lay.addWidget(sep)
-
-        # -- Scenario selector ------------------------------------------------
-        scenario_container = QWidget()
-        scenario_container.setStyleSheet(f"background: {_PANEL_BG};")
-        sc_lay = QVBoxLayout(scenario_container)
-        sc_lay.setContentsMargins(6, 6, 6, 4)
-        sc_lay.setSpacing(4)
-
-        sc_header = QLabel("Сценарий оркестратора")
-        sc_header.setStyleSheet(
-            "color: #7777aa; font-size: 10px; font-weight: bold; background: transparent;"
-        )
-        sc_lay.addWidget(sc_header)
-
-        sc_row = QHBoxLayout()
-        sc_row.setSpacing(4)
-
-        _combo_style = (
-            "QComboBox {"
-            f"  background: {_BTN_BG}; color: #ccccee;"
-            "  border: 1px solid #444466; border-radius: 3px;"
-            "  font-size: 11px; padding: 3px 6px;"
-            "}"
-            "QComboBox::drop-down { border: none; width: 16px; }"
-            "QComboBox QAbstractItemView {"
-            f"  background: {_BTN_BG}; color: #ccccee; border: 1px solid #444466;"
-            "  selection-background-color: #e65100;"
-            "}"
-        )
-        self._scenario_combo = QComboBox()
-        self._scenario_combo.setStyleSheet(_combo_style)
-        self._scenario_combo.setToolTip("Тип сценария, который будет запущен в оркестраторе")
-        self._scenario_combo.addItem("full_pipeline")   # default
-        self._scenario_combo.currentTextChanged.connect(self._on_scenario_selected)
-        sc_row.addWidget(self._scenario_combo, stretch=1)
-
-        btn_refresh_sc = QPushButton("↻")
-        btn_refresh_sc.setFixedSize(24, 24)
-        btn_refresh_sc.setToolTip("Получить список сценариев из оркестратора")
-        btn_refresh_sc.setStyleSheet(
-            f"QPushButton {{ background: {_BTN_BG}; color: #7777aa;"
-            "  border: 1px solid #444466; border-radius: 3px; font-size: 13px; }}"
-            "QPushButton:hover { color: #ffffff; background: #303050; }"
-        )
-        btn_refresh_sc.clicked.connect(self._on_refresh_scenarios)
-        sc_row.addWidget(btn_refresh_sc)
-
-        sc_lay.addLayout(sc_row)
-        lay.addWidget(scenario_container)
-
-        sep2 = QFrame()
-        sep2.setFrameShape(QFrame.HLine)
-        sep2.setFixedHeight(1)
-        sep2.setStyleSheet("background: #3a3a4a; border: none;")
-        lay.addWidget(sep2)
-
-        # -- .seq file loader -------------------------------------------------
-        btn_load = QPushButton("Загрузить .seq…")
-        btn_load.setToolTip("Выбрать готовый .seq файл для запуска полного пайплайна")
-        btn_load.setStyleSheet(
-            "QPushButton {"
-            f"  background: {_BTN_BG}; color: #aaaacc;"
-            "  border: 1px solid #444466; border-radius: 3px;"
-            "  font-size: 11px; padding: 5px 8px; margin: 6px 6px 2px 6px;"
-            "}"
-            "QPushButton:hover { background: #303050; color: #ffffff; }"
-        )
-        btn_load.clicked.connect(self._on_load_seq_clicked)
-        lay.addWidget(btn_load)
-
-        self._lbl_seq_file = QLabel("Файл не выбран")
-        self._lbl_seq_file.setWordWrap(True)
-        self._lbl_seq_file.setStyleSheet(
-            "color: #555577; font-size: 10px; background: transparent;"
-            "padding: 0 8px 6px 8px;"
-        )
-        lay.addWidget(self._lbl_seq_file)
-
-        return container
+        return self._protocol_list
 
     def _build_image_grid(self) -> QWidget:
         container = QWidget()
@@ -679,19 +546,15 @@ class ScanningTab(QWidget):
             QTabBar::tab:hover:!selected { background: #222240; }
         """)
 
-        placeholder_tabs = ["Основные", "Контраст", "Разрешение", "Система"]
-        for name in placeholder_tabs:
-            w = QLabel(f"[ {name} - TODO ]")
+        _placeholder_keys = ["tab_main", "tab_contrast", "tab_resolution", "tab_system"]
+        for key in _placeholder_keys:
+            w = QLabel(f"[ {i18n.tr(key)} — TODO ]")
             w.setAlignment(Qt.AlignCenter)
             w.setStyleSheet("color: #555577; background: #16162a;")
-            self._param_tabs.addTab(w, name)
+            self._param_tabs.addTab(w, i18n.tr(key))
 
         geo_tab = self._build_geometry_tab()
-        self._param_tabs.addTab(geo_tab, "Геометрия")
-
-        log_tab = self._build_log_tab()
-        self._param_tabs.addTab(log_tab, "Лог")
-
+        self._param_tabs.addTab(geo_tab, i18n.tr("tab_geometry"))
         self._param_tabs.setCurrentWidget(geo_tab)
 
         outer.addWidget(self._param_tabs, stretch=1)
@@ -703,12 +566,12 @@ class ScanningTab(QWidget):
         action_lay.setContentsMargins(12, 6, 12, 6)
         action_lay.setSpacing(12)
 
-        self._status_label = QLabel("Нет данных")
+        self._status_label = QLabel(i18n.tr("no_data"))
         self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
         action_lay.addWidget(self._status_label)
         action_lay.addStretch()
 
-        self._btn_scan = QPushButton("Сканировать")
+        self._btn_scan = QPushButton(i18n.tr("btn_scan"))
         self._btn_scan.setCheckable(True)
         self._btn_scan.setMinimumWidth(140)
         self._btn_scan.setStyleSheet(
@@ -736,7 +599,8 @@ class ScanningTab(QWidget):
         lay.setSpacing(16)
 
         # -- orientation presets --------------------------------------------
-        preset_group = QGroupBox("Ориентация")
+        self._preset_group = QGroupBox(i18n.tr("grp_orientation"))
+        preset_group = self._preset_group
         preset_group.setStyleSheet(self._group_style())
         preset_lay = QVBoxLayout(preset_group)
         preset_lay.setSpacing(4)
@@ -756,7 +620,8 @@ class ScanningTab(QWidget):
         lay.addWidget(preset_group)
 
         # -- rotation angles ------------------------------------------------
-        rot_group = QGroupBox("Поворот")
+        self._rot_group = QGroupBox(i18n.tr("grp_rotation"))
+        rot_group = self._rot_group
         rot_group.setStyleSheet(self._group_style())
         form = QFormLayout(rot_group)
         form.setSpacing(6)
@@ -790,7 +655,8 @@ class ScanningTab(QWidget):
         lay.addWidget(rot_group)
 
         # -- rotation matrix display ----------------------------------------
-        matrix_group = QGroupBox("Матрица поворота")
+        self._matrix_group = QGroupBox(i18n.tr("grp_rot_matrix"))
+        matrix_group = self._matrix_group
         matrix_group.setStyleSheet(self._group_style())
         matrix_lay = QVBoxLayout(matrix_group)
         matrix_lay.setContentsMargins(8, 4, 8, 4)
@@ -805,58 +671,6 @@ class ScanningTab(QWidget):
         lay.addStretch()
         return w
 
-    def _build_log_tab(self) -> QWidget:
-        w = QWidget()
-        w.setStyleSheet("background: #16162a;")
-        lay = QVBoxLayout(w)
-        lay.setContentsMargins(6, 6, 6, 4)
-        lay.setSpacing(4)
-
-        self._log_view = QTextEdit()
-        self._log_view.setReadOnly(True)
-        self._log_view.setFont(QFont("Courier New", 9))
-        self._log_view.setStyleSheet(
-            "QTextEdit {"
-            "  background: #0e0e1c; color: #aaaacc;"
-            "  border: 1px solid #2a2a4a; border-radius: 3px;"
-            "}"
-        )
-        lay.addWidget(self._log_view, stretch=1)
-
-        btn_clear = QPushButton("Очистить")
-        btn_clear.setFixedWidth(90)
-        btn_clear.setStyleSheet(
-            "QPushButton {"
-            f"  background: {_BTN_BG}; color: #777799;"
-            "  border: 1px solid #333355; border-radius: 3px;"
-            "  font-size: 10px; padding: 3px 8px;"
-            "}"
-            "QPushButton:hover { color: #aaaacc; }"
-        )
-        btn_clear.clicked.connect(self._log_view.clear)
-        lay.addWidget(btn_clear, alignment=Qt.AlignRight)
-
-        return w
-
-    # level: "INFO" | "WARN" | "ERR"
-    _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
-
-    def _log(self, msg: str, level: str = "INFO") -> None:
-        """Append [LEVEL] HH:MM:SS  message to the log and auto-scroll."""
-        if not hasattr(self, "_log_view"):
-            return
-        from datetime import datetime
-        ts    = datetime.now().strftime("%H:%M:%S")
-        color = self._LOG_COLORS.get(level, "#aaaacc")
-        line  = (
-            f"<span style='color:#555577'>{ts}</span>"
-            f"  <span style='color:{color}'>[{level}]</span>"
-            f"  {msg}"
-        )
-        self._log_view.append(line)
-        sb = self._log_view.verticalScrollBar()
-        sb.setValue(sb.maximum())
-
     @staticmethod
     def _group_style() -> str:
         return (
@@ -970,154 +784,55 @@ class ScanningTab(QWidget):
             btn.setChecked(name == matched)
             btn.blockSignals(False)
 
-    # -- .seq file loading --------------------------------------------------
-
-    def _on_load_seq_clicked(self) -> None:
-        path, _ = QFileDialog.getOpenFileName(
-            self,
-            "Выбрать .seq файл",
-            os.path.join(os.path.dirname(__file__), os.pardir, os.pardir),
-            "Pulseq файлы (*.seq);;Все файлы (*)",
-        )
-        if not path:
-            return
-        self._seq_file_path = path
-        self._seq_info = None
-        fname = os.path.basename(path)
-        self._lbl_seq_file.setText(fname)
-        self._lbl_seq_file.setStyleSheet(
-            "color: #e65100; font-size: 10px; background: transparent;"
-            "padding: 0 8px 6px 8px;"
-        )
-        self._log(f"Загружен файл: {fname}", "INFO")
-        self._update_scan_ready_state()
-
     # -- scan initiation ----------------------------------------------------
 
     def _on_scan_toggled(self, checked: bool) -> None:
         if checked:
-            if self._scan_worker is not None and self._scan_worker.isRunning():
-                return
-            if self._seq_info is None and self._seq_file_path is None:
+            if self._seq_info is None:
                 QMessageBox.warning(
-                    self, "Нет данных",
-                    "Загрузите .seq файл кнопкой «Загрузить .seq…»\n"
-                    "или экспортируйте последовательность во вкладке «Sequence»."
+                    self, i18n.tr("dlg_no_data_title"),
+                    i18n.tr("dlg_no_seq_msg"),
                 )
                 self._btn_scan.setChecked(False)
                 return
-            info = dict(self._seq_info) if self._seq_info else {}
-            if info:
-                info["rotation_matrix"] = self._compute_rotation_matrix()
-                info["slice_position"]  = list(self._slice_offset)
-            self._scan_worker = _ScanPipelineWorker(
-                seq_file_path=self._seq_file_path,
-                seq_info=info if info else None,
-                orchestrator_url=self._orchestrator_url,
-                scenario_id=self._scenario_id,
-                protocol=self._active_protocol,
-                parent=self,
-            )
-            self._scan_worker.job_started.connect(self.scan_job_started)
+            info = dict(self._seq_info)
+            info["rotation_matrix"] = self._compute_rotation_matrix()
+            info["slice_position"]  = list(self._slice_offset)
+            self._scan_worker = _ScanWorker(self._orchestrator_url, info, parent=self)
             self._scan_worker.finished.connect(self._on_scan_done)
             self._scan_worker.error.connect(self._on_scan_error)
-            self._scan_worker.progress.connect(self._on_scan_progress)
-            self._scan_worker.raw_data_ready.connect(self._on_raw_data_ready_log)
-            self._scan_worker.raw_data_ready.connect(self.raw_data_ready)
             self._scan_worker.start()
             self._scan_timer.start()
-            self._btn_scan.setText("Stop  Стоп")
-            self._status_label.setText("Инициализация пайплайна…")
+            self._btn_scan.setText(i18n.tr("btn_stop_scan"))
+            self._status_label.setText(i18n.tr("scanning_status"))
             self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
-            self._log("--- Запуск пайплайна сканирования ---", "INFO")
             for v in self._viewers:
                 v.set_scanning(True)
-            # Switch to Log tab so the user can follow progress
-            self._param_tabs.setCurrentIndex(
-                self._param_tabs.indexOf(self._log_view.parent())
-            )
         else:
             self._scan_timer.stop()
-            if self._scan_worker and self._scan_worker.isRunning():
-                self._scan_worker.requestInterruption()
-            self._btn_scan.setText("Run  Сканировать")
+            self._btn_scan.setText(i18n.tr("btn_scan"))
             for v in self._viewers:
                 v.set_scanning(False)
             self._update_scan_ready_state()
 
-    def _on_raw_data_ready_log(self, path: str) -> None:
-        self._log(f"Данные отправлены во вкладку Spectroscopy: {os.path.basename(path)}", "INFO")
-
-    def _on_scan_progress(self, msg: str) -> None:
-        self._status_label.setText(msg[:80])
-        self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
-        self._log(msg, "INFO")
-
     def _on_scan_done(self, msg: str) -> None:
-        self._scan_timer.stop()
-        self._status_label.setText("Готово")
+        self._status_label.setText(f"{i18n.tr('done_status')} ({msg})")
         self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
-        self._log(msg, "INFO")
-        for v in self._viewers:
-            v.set_scanning(False)
         self._btn_scan.setChecked(False)
 
     def _on_scan_error(self, err: str) -> None:
-        self._scan_timer.stop()
-        self._status_label.setText(f"Ошибка: {err[:70]}")
+        self._status_label.setText(f"{i18n.tr('error_prefix')}: {err[:60]}")
         self._status_label.setStyleSheet("color: #ee4444; font-size: 11px;")
-        self._log(err, "ERR")
-        for v in self._viewers:
-            v.set_scanning(False)
         self._btn_scan.setChecked(False)
 
     def _update_scan_ready_state(self) -> None:
-        if self._seq_file_path:
-            fname = os.path.basename(self._seq_file_path)
-            self._status_label.setText(f"Файл: {fname}")
-            self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
-        elif self._seq_info is not None:
-            self._status_label.setText("Готово к сканированию")
+        if self._seq_info is not None:
+            self._status_label.setText(i18n.tr("ready_to_scan"))
             self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
         else:
-            self._status_label.setText("Нет данных")
+            self._status_label.setText(i18n.tr("no_data"))
             self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
 
-    # -- scenario selection -------------------------------------------------
-
-    def _on_scenario_selected(self, name: str) -> None:
-        if name:
-            self._scenario_id = name
-
-    def _on_refresh_scenarios(self) -> None:
-        """Fetch available scenario IDs from the orchestrator."""
-        import httpx
-        try:
-            r = httpx.get(
-                f"{self._orchestrator_url}/scenario/list",
-                timeout=5.0,
-            )
-            if r.is_success:
-                scenarios: list[str] = r.json().get("scenarios", [])
-                if scenarios:
-                    current = self._scenario_combo.currentText()
-                    self._scenario_combo.blockSignals(True)
-                    self._scenario_combo.clear()
-                    for s in scenarios:
-                        self._scenario_combo.addItem(s)
-                    # restore previous selection if still available
-                    idx = self._scenario_combo.findText(current)
-                    self._scenario_combo.setCurrentIndex(max(idx, 0))
-                    self._scenario_combo.blockSignals(False)
-                    self._scenario_id = self._scenario_combo.currentText()
-                    self._log(f"Сценарии загружены: {scenarios}", "INFO")
-                else:
-                    self._log("Оркестратор вернул пустой список сценариев", "WARN")
-            else:
-                self._log(f"Ошибка загрузки сценариев: HTTP {r.status_code}", "WARN")
-        except Exception as exc:
-            self._log(f"Не удалось подключиться к оркестратору: {exc}", "WARN")
-
     # -- protocol selection -------------------------------------------------
 
     def _on_protocol_selected(self, name: str) -> None:

+ 33 - 18
apps/gui/src/tabs/seq_interp_tab.py

@@ -33,6 +33,7 @@ from src.gui.workers import (
     SeqInterpHttpWorker,
 )
 from src.clients.seq_interp_client import SeqInterpClient
+from src import i18n
 
 # -- loading-state style maps (light / dark) -----------------------------------
 
@@ -118,6 +119,20 @@ class SeqInterpTab(QWidget):
         self._btn_run.setEnabled(True)
         self._log(f"Sequence loaded: {path}")
 
+    def retranslate_ui(self) -> None:
+        self._btn_load_seq .setText(i18n.tr("btn_load_seq"))
+        self._btn_load_hw  .setText(i18n.tr("btn_hw_config"))
+        self._btn_out_dir  .setText(i18n.tr("btn_out_dir"))
+        self._btn_run      .setText(i18n.tr("btn_run"))
+        self._btn_export   .setText(i18n.tr("btn_export"))
+        self._btn_fit      .setText(i18n.tr("btn_fit_all"))
+        blocks_visible = self._table_container.isVisible()
+        self._btn_blocks.setText(i18n.tr("blocks_open") if blocks_visible else i18n.tr("blocks_closed"))
+        self._btn_send_scan.setText(i18n.tr("btn_send_scanner"))
+        self._meta_grp.setTitle(i18n.tr("grp_seq_metadata"))
+        self._warn_grp.setTitle(i18n.tr("grp_warnings"))
+        self._controls.retranslate_ui()
+
     # ================================================================== #
     #  Button bar                                                          #
     # ================================================================== #
@@ -149,18 +164,18 @@ class SeqInterpTab(QWidget):
             lay.addWidget(b)
             return b
 
-        self._btn_load_seq = btn(" Load .seq",  "Open Pulseq .seq file",             self._load_seq)
-        self._btn_load_hw  = btn(" HW Config",   "Load hardware constraints JSON",     self._load_hw_config)
-        self._btn_out_dir  = btn(" Output Dir",  "Choose output directory",            self._choose_output_dir)
+        self._btn_load_seq  = btn(i18n.tr("btn_load_seq"),    "Open Pulseq .seq file",             self._load_seq)
+        self._btn_load_hw   = btn(i18n.tr("btn_hw_config"),   "Load hardware constraints JSON",     self._load_hw_config)
+        self._btn_out_dir   = btn(i18n.tr("btn_out_dir"),     "Choose output directory",            self._choose_output_dir)
         lay.addWidget(sep())
-        self._btn_run      = btn("Run Run",          "Run interpretation pipeline",        self._run,    enabled=False)
-        self._btn_export   = btn(" Export",      "Export all artifacts to output dir", self._export, enabled=False)
+        self._btn_run       = btn(i18n.tr("btn_run"),         "Run interpretation pipeline",        self._run,    enabled=False)
+        self._btn_export    = btn(i18n.tr("btn_export"),      "Export all artifacts to output dir", self._export, enabled=False)
         lay.addWidget(sep())
-        self._btn_fit      = btn(" Fit All",     "Fit all plots to data",              lambda: self._plots.fit_all())
-        self._btn_blocks   = btn(" Blocks v",    "Toggle block table open/closed",     self._toggle_table)
+        self._btn_fit       = btn(i18n.tr("btn_fit_all"),     "Fit all plots to data",              lambda: self._plots.fit_all())
+        self._btn_blocks    = btn(i18n.tr("blocks_closed"),   "Toggle block table open/closed",     self._toggle_table)
         lay.addWidget(sep())
         self._btn_send_scan = btn(
-            " Send to Scanner",
+            i18n.tr("btn_send_scanner"),
             "Send exported sequence info to Scanner tab",
             self._send_to_scanner,
             enabled=False,
@@ -185,7 +200,7 @@ class SeqInterpTab(QWidget):
         )
         lay = QHBoxLayout(bar)
         lay.setContentsMargins(8, 2, 8, 2)
-        self._seq_status = QLabel("  o No file selected")
+        self._seq_status = QLabel(f"  o  {i18n.tr('no_file_selected')}")
         self._seq_status.setFont(QFont("Arial", 9))
         self._seq_status.setStyleSheet("color: #9e9e9e;")
         lay.addWidget(self._seq_status)
@@ -226,8 +241,8 @@ class SeqInterpTab(QWidget):
         lay.setContentsMargins(4, 4, 4, 4)
         lay.setSpacing(6)
 
-        meta_grp = QGroupBox("Sequence Metadata")
-        meta_form = QFormLayout(meta_grp)
+        self._meta_grp = QGroupBox(i18n.tr("grp_seq_metadata"))
+        meta_form = QFormLayout(self._meta_grp)
         meta_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         self._meta_labels: dict[str, QLabel] = {}
         for key in [
@@ -240,13 +255,13 @@ class SeqInterpTab(QWidget):
             lbl.setFont(QFont("Courier New", 9))
             meta_form.addRow(QLabel(f"{key}:"), lbl)
             self._meta_labels[key] = lbl
-        lay.addWidget(meta_grp)
+        lay.addWidget(self._meta_grp)
 
         self._controls = DelayControlsPanel()
         lay.addWidget(self._controls)
 
-        warn_grp = QGroupBox("Warnings")
-        warn_lay = QVBoxLayout(warn_grp)
+        self._warn_grp = QGroupBox(i18n.tr("grp_warnings"))
+        warn_lay = QVBoxLayout(self._warn_grp)
         self._warn_list = QListWidget()
         self._warn_list.setFont(QFont("Arial", 9))
         self._warn_list.setMaximumHeight(110)
@@ -255,7 +270,7 @@ class SeqInterpTab(QWidget):
             "QListWidget::item { padding: 2px; }"
         )
         warn_lay.addWidget(self._warn_list)
-        lay.addWidget(warn_grp)
+        lay.addWidget(self._warn_grp)
 
         lay.addStretch()
 
@@ -297,7 +312,7 @@ class SeqInterpTab(QWidget):
         bar.setFixedHeight(22)
         lay = QHBoxLayout(bar)
         lay.setContentsMargins(6, 0, 6, 0)
-        self._status_lbl = QLabel("Ready")
+        self._status_lbl = QLabel(i18n.tr("status_ready"))
         self._status_lbl.setFont(QFont("Arial", 8))
         lay.addWidget(self._status_lbl)
         lay.addStretch()
@@ -374,7 +389,7 @@ class SeqInterpTab(QWidget):
     def _toggle_table(self) -> None:
         visible = not self._table_container.isVisible()
         self._table_container.setVisible(visible)
-        self._btn_blocks.setText(" Blocks ^" if visible else " Blocks v")
+        self._btn_blocks.setText(i18n.tr("blocks_open") if visible else i18n.tr("blocks_closed"))
         if visible:
             sizes = self._centre_vsplit.sizes()
             if sizes[2] < 100:
@@ -634,7 +649,7 @@ class SeqInterpTab(QWidget):
                        detail: str = "") -> None:
         _state_map = _STATE_DARK if system_is_dark() else _STATE_LIGHT
         color, icon = _state_map.get(state, ("#9e9e9e", "o"))
-        text = f"  {icon}  {name}" if name else f"  {icon}  No file selected"
+        text = f"  {icon}  {name}" if name else f"  {icon}  {i18n.tr('no_file_selected')}"
         if detail:
             text += f"  -  {detail}"
         self._seq_status.setStyleSheet(f"color: {color}; font-weight: bold;")

+ 34 - 27
apps/gui/src/tabs/spectroscopy_tab.py

@@ -38,6 +38,7 @@ from PySide6.QtWidgets import (
 from src.clients.spectroscopy_client import SpectroscopyClient, SpectroscopyError
 from src.gui.workers import OrchestratorWorker
 from src.gui.scheme_panel import system_is_dark
+from src import i18n
 
 
 # -- Colour palette -------------------------------------------------------------
@@ -172,13 +173,13 @@ class SpectroscopyTab(QWidget):
         lay.setContentsMargins(6, 4, 6, 4)
         lay.setSpacing(6)
 
-        self._btn_load = QPushButton("Load JSON...")
+        self._btn_load = QPushButton(i18n.tr("btn_load_json"))
         self._btn_load.setToolTip("Load a hardware JSON file and run NMR analysis")
         self._btn_load.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_load.clicked.connect(self._load_json)
         lay.addWidget(self._btn_load)
 
-        self._btn_analyze = QPushButton("Analyze")
+        self._btn_analyze = QPushButton(i18n.tr("btn_analyze"))
         self._btn_analyze.setToolTip(
             "Re-run analysis with the current parameters\n"
             "(same file, updated settings)"
@@ -190,7 +191,7 @@ class SpectroscopyTab(QWidget):
 
         lay.addWidget(_vsep())
 
-        self._btn_batch = QPushButton("Batch...")
+        self._btn_batch = QPushButton(i18n.tr("btn_batch"))
         self._btn_batch.setToolTip("Process a whole folder of hardware JSON files")
         self._btn_batch.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_batch.clicked.connect(self._open_batch_dialog)
@@ -198,14 +199,14 @@ class SpectroscopyTab(QWidget):
 
         lay.addWidget(_vsep())
 
-        self._btn_export = QPushButton("Export...")
+        self._btn_export = QPushButton(i18n.tr("btn_export_sp"))
         self._btn_export.setToolTip("Save analysis results (.mat / .csv / .npz)")
         self._btn_export.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_export.setEnabled(False)
         self._btn_export.clicked.connect(self._export_results)
         lay.addWidget(self._btn_export)
 
-        self._btn_clear = QPushButton("Clear")
+        self._btn_clear = QPushButton(i18n.tr("btn_clear"))
         self._btn_clear.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_clear.clicked.connect(self._clear_plots)
         lay.addWidget(self._btn_clear)
@@ -242,8 +243,8 @@ class SpectroscopyTab(QWidget):
         lay.setSpacing(8)
 
         # -- NMR Parameters -----------------------------------------------
-        nmr_grp = QGroupBox("NMR Parameters")
-        nmr_frm = QFormLayout(nmr_grp)
+        self._nmr_grp = QGroupBox(i18n.tr("grp_nmr_params"))
+        nmr_frm = QFormLayout(self._nmr_grp)
         nmr_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 
         self._sb_fc = QDoubleSpinBox()
@@ -305,11 +306,11 @@ class SpectroscopyTab(QWidget):
         nmr_frm.addRow("Avg num:",     self._sb_avg)
         nmr_frm.addRow("Data num:",    self._sb_dnum)
         nmr_frm.addRow("Channel:",     self._sb_ch)
-        lay.addWidget(nmr_grp)
+        lay.addWidget(self._nmr_grp)
 
         # -- Metadata ------------------------------------------------------
-        meta_grp = QGroupBox("Metadata")
-        meta_frm = QFormLayout(meta_grp)
+        self._meta_grp = QGroupBox(i18n.tr("grp_metadata"))
+        meta_frm = QFormLayout(self._meta_grp)
         meta_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         self._lbl_n   = QLabel("-")
         self._lbl_fs  = QLabel("-")
@@ -325,11 +326,11 @@ class SpectroscopyTab(QWidget):
         meta_frm.addRow("Avg range:", self._lbl_avg_range)
         meta_frm.addRow("Data range:", self._lbl_data_range)
         meta_frm.addRow("Channel range:", self._lbl_ch_range)
-        lay.addWidget(meta_grp)
+        lay.addWidget(self._meta_grp)
 
         # -- Metrics -------------------------------------------------------
-        met_grp = QGroupBox("Metrics")
-        met_frm = QFormLayout(met_grp)
+        self._met_grp = QGroupBox(i18n.tr("grp_metrics"))
+        met_frm = QFormLayout(self._met_grp)
         met_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         mono9 = QFont("Courier New", 9)
         self._lbl_peak_f   = QLabel("-"); self._lbl_peak_f.setFont(mono9)
@@ -338,7 +339,7 @@ class SpectroscopyTab(QWidget):
         met_frm.addRow("Peak freq:",  self._lbl_peak_f)
         met_frm.addRow("FWHM:",       self._lbl_fwhm)
         met_frm.addRow("Amplitude:",  self._lbl_peak_amp)
-        lay.addWidget(met_grp)
+        lay.addWidget(self._met_grp)
 
         lay.addStretch()
 
@@ -550,14 +551,22 @@ class SpectroscopyTab(QWidget):
         bar.setFixedHeight(22)
         lay = QHBoxLayout(bar)
         lay.setContentsMargins(6, 0, 6, 0)
-        self._status_lbl = QLabel(
-            "Click 'Load JSON...' to open a hardware JSON file and start NMR analysis"
-        )
+        self._status_lbl = QLabel(i18n.tr("spec_status_init"))
         self._status_lbl.setFont(QFont("Arial", 8))
         lay.addWidget(self._status_lbl)
         lay.addStretch()
         return bar
 
+    def retranslate_ui(self) -> None:
+        self._btn_load   .setText(i18n.tr("btn_load_json"))
+        self._btn_analyze.setText(i18n.tr("btn_analyze"))
+        self._btn_batch  .setText(i18n.tr("btn_batch"))
+        self._btn_export .setText(i18n.tr("btn_export_sp"))
+        self._btn_clear  .setText(i18n.tr("btn_clear"))
+        self._nmr_grp .setTitle(i18n.tr("grp_nmr_params"))
+        self._meta_grp.setTitle(i18n.tr("grp_metadata"))
+        self._met_grp .setTitle(i18n.tr("grp_metrics"))
+
     # -- Actions -----------------------------------------------------------
 
     def _load_json(self) -> None:
@@ -952,7 +961,7 @@ class BatchDialog(QDialog):
         parent: QWidget | None = None,
     ) -> None:
         super().__init__(parent)
-        self.setWindowTitle("Batch NMR Analysis")
+        self.setWindowTitle(i18n.tr("batch_title"))
         self.setMinimumSize(720, 540)
 
         self._client         = client
@@ -974,19 +983,17 @@ class BatchDialog(QDialog):
 
         # Folder picker
         fold_row = QHBoxLayout()
-        fold_row.addWidget(QLabel("Folder:"))
+        fold_row.addWidget(QLabel(i18n.tr("folder_label")))
         self._le_folder = QLineEdit()
         self._le_folder.setPlaceholderText("Path to folder containing *.json files")
         fold_row.addWidget(self._le_folder, stretch=1)
-        btn_browse = QPushButton("Browse...")
+        btn_browse = QPushButton(i18n.tr("btn_browse"))
         btn_browse.clicked.connect(self._browse_folder)
         fold_row.addWidget(btn_browse)
         lay.addLayout(fold_row)
 
         # Params toggle
-        self._cb_use_current = QCheckBox(
-            "Use current parameters from the Spectroscopy tab"
-        )
+        self._cb_use_current = QCheckBox(i18n.tr("cb_use_current"))
         self._cb_use_current.setChecked(True)
         self._cb_use_current.toggled.connect(self._on_use_current_toggled)
         lay.addWidget(self._cb_use_current)
@@ -997,7 +1004,7 @@ class BatchDialog(QDialog):
 
         # Run row
         run_row = QHBoxLayout()
-        self._btn_run = QPushButton("Run Batch")
+        self._btn_run = QPushButton(i18n.tr("btn_run_batch"))
         self._btn_run.setFixedWidth(100)
         self._btn_run.clicked.connect(self._run_batch)
         run_row.addWidget(self._btn_run)
@@ -1025,18 +1032,18 @@ class BatchDialog(QDialog):
 
         # Bottom buttons
         bot_row = QHBoxLayout()
-        self._btn_csv = QPushButton("Export CSV...")
+        self._btn_csv = QPushButton(i18n.tr("btn_export_csv"))
         self._btn_csv.setEnabled(False)
         self._btn_csv.clicked.connect(self._export_csv)
         bot_row.addWidget(self._btn_csv)
         bot_row.addStretch()
-        btn_close = QPushButton("Close")
+        btn_close = QPushButton(i18n.tr("btn_close"))
         btn_close.clicked.connect(self.accept)
         bot_row.addWidget(btn_close)
         lay.addLayout(bot_row)
 
         # Status line
-        self._status_lbl = QLabel("Select a folder and click Run Batch.")
+        self._status_lbl = QLabel(i18n.tr("batch_status_init"))
         lay.addWidget(self._status_lbl)
 
     def _build_batch_params(self) -> QWidget: