瀏覽代碼

major GUI update for language control

spacexerq 2 周之前
父節點
當前提交
673a75b0a4

+ 25 - 13
apps/gui/src/app_window.py

@@ -149,7 +149,7 @@ class _ModeSignalBridge(QObject):
 
 
     mode_fetched = Signal(str)  # mode string on successful GET /mode
     mode_fetched = Signal(str)  # mode string on successful GET /mode
     mode_set     = Signal(str)  # mode string on successful POST /mode
     mode_set     = Signal(str)  # mode string on successful POST /mode
-    mode_error   = Signal(str)  # error message (empty = silent startup failure)
+    mode_error   = Signal(str)  # error message
 
 
 
 
 def _spec_btn_blink_css(dark: bool) -> str:
 def _spec_btn_blink_css(dark: bool) -> str:
@@ -185,16 +185,19 @@ class LFMRIWindow(QMainWindow):
         hw_config_path: str | None = None,
         hw_config_path: str | None = None,
         output_dir: str | None = None,
         output_dir: str | None = None,
         seq_file: 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",
+        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:
     ) -> None:
         super().__init__()
         super().__init__()
         self.setWindowTitle("LF-MRI System")
         self.setWindowTitle("LF-MRI System")
         self.setMinimumSize(960, 640)
         self.setMinimumSize(960, 640)
 
 
-        self._hw_config_path = hw_config_path
-        self._output_dir = output_dir
+        self._hw_config_path  = hw_config_path
+        self._output_dir      = output_dir
+        self._orchestrator_url = orchestrator_url.rstrip("/")
 
 
         self._seq_tab = SeqInterpTab(
         self._seq_tab = SeqInterpTab(
             hw_config_path=hw_config_path,
             hw_config_path=hw_config_path,
@@ -206,6 +209,8 @@ class LFMRIWindow(QMainWindow):
             orchestrator_url=orchestrator_url,
             orchestrator_url=orchestrator_url,
             seq_interp_url=seq_interp_url,
             seq_interp_url=seq_interp_url,
             spectroscopy_url=spectroscopy_url,
             spectroscopy_url=spectroscopy_url,
+            reconstructor_url=reconstructor_url,
+            spectrometer_url=spectrometer_url,
         )
         )
         self._fid_tab = FidTab(
         self._fid_tab = FidTab(
             hw_config_path=hw_config_path,
             hw_config_path=hw_config_path,
@@ -238,7 +243,6 @@ class LFMRIWindow(QMainWindow):
         self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
         self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
 
 
         # Mode selector state
         # Mode selector state
-        self._orchestrator_url = orchestrator_url
         self._current_mode: str = "plug"   # updated by background fetch after startup
         self._current_mode: str = "plug"   # updated by background fetch after startup
         self._mode_bridge = _ModeSignalBridge(self)
         self._mode_bridge = _ModeSignalBridge(self)
 
 
@@ -329,14 +333,14 @@ class LFMRIWindow(QMainWindow):
         tb.addWidget(self._btn_lang_en)
         tb.addWidget(self._btn_lang_en)
         tb.addWidget(self._btn_lang_ru)
         tb.addWidget(self._btn_lang_ru)
 
 
-        # Theme toggle (☽ dark / ☀ light)
+        # Theme toggle (◑ dark / ◐ light) — plain geometric symbols, no emoji variant
         self._nav_sep2 = _VSep(tb)
         self._nav_sep2 = _VSep(tb)
         tb.addWidget(self._nav_sep2)
         tb.addWidget(self._nav_sep2)
 
 
         self._theme_btn_group = QButtonGroup(self)
         self._theme_btn_group = QButtonGroup(self)
         self._theme_btn_group.setExclusive(True)
         self._theme_btn_group.setExclusive(True)
-        self._btn_theme_dark  = QPushButton("")
-        self._btn_theme_light = QPushButton("")
+        self._btn_theme_dark  = QPushButton("")
+        self._btn_theme_light = QPushButton("")
         self._btn_theme_dark.setCheckable(True)
         self._btn_theme_dark.setCheckable(True)
         self._btn_theme_light.setCheckable(True)
         self._btn_theme_light.setCheckable(True)
         self._btn_theme_dark.setChecked(True)  # default dark
         self._btn_theme_dark.setChecked(True)  # default dark
@@ -534,16 +538,21 @@ class LFMRIWindow(QMainWindow):
     def _on_mode_fetched(self, mode: str) -> None:
     def _on_mode_fetched(self, mode: str) -> None:
         self._current_mode = mode
         self._current_mode = mode
         self._update_mode_btn()
         self._update_mode_btn()
+        self._scanner_tab.refresh_scenarios()
 
 
     def _on_mode_set(self, mode: str) -> None:
     def _on_mode_set(self, mode: str) -> None:
         self._current_mode = mode
         self._current_mode = mode
         self._mode_btn.setEnabled(True)
         self._mode_btn.setEnabled(True)
         self._update_mode_btn()
         self._update_mode_btn()
+        self._scanner_tab.refresh_scenarios()
 
 
     def _on_mode_error(self, msg: str) -> None:
     def _on_mode_error(self, msg: str) -> None:
         self._mode_btn.setEnabled(True)
         self._mode_btn.setEnabled(True)
-        if msg:
-            QMessageBox.warning(self, i18n.tr("mode_error"), msg)
+        QMessageBox.warning(
+            self,
+            i18n.tr("mode_error"),
+            msg or "Unknown error — check that the orchestrator is running.",
+        )
 
 
     # -- click handler (main thread) ---------------------------------------
     # -- click handler (main thread) ---------------------------------------
 
 
@@ -573,7 +582,10 @@ class LFMRIWindow(QMainWindow):
             result = client.set_mode(mode)
             result = client.set_mode(mode)
             self._mode_bridge.mode_set.emit(result)
             self._mode_bridge.mode_set.emit(result)
         except OrchestratorError as exc:
         except OrchestratorError as exc:
-            self._mode_bridge.mode_error.emit(str(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):
 class _VSep(QFrame):

+ 8 - 15
apps/gui/src/gui/controls_panel.py

@@ -7,10 +7,10 @@ import json
 
 
 from PySide6.QtCore import Signal
 from PySide6.QtCore import Signal
 from src.gui.scheme_panel import system_is_dark
 from src.gui.scheme_panel import system_is_dark
-from src import i18n
+from src.gui.tr_widgets import TrGroupBox, TrPushButton
 from PySide6.QtWidgets import (
 from PySide6.QtWidgets import (
-    QWidget, QVBoxLayout, QGroupBox, QFormLayout,
-    QDoubleSpinBox, QPushButton, QGridLayout, QFileDialog,
+    QWidget, QVBoxLayout, QFormLayout,
+    QDoubleSpinBox, QGridLayout, QFileDialog,
 )
 )
 
 
 
 
@@ -37,7 +37,7 @@ class DelayControlsPanel(QWidget):
         outer = QVBoxLayout(self)
         outer = QVBoxLayout(self)
         outer.setContentsMargins(4, 4, 4, 4)
         outer.setContentsMargins(4, 4, 4, 4)
 
 
-        self._grp = QGroupBox(i18n.tr("grp_hw_delays"))
+        self._grp = TrGroupBox("grp_hw_delays")
         form = QFormLayout(self._grp)
         form = QFormLayout(self._grp)
         form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 
 
@@ -58,10 +58,10 @@ class DelayControlsPanel(QWidget):
         outer.addWidget(self._grp)
         outer.addWidget(self._grp)
 
 
         # 2x2 grid: prevents text overflow on narrow left panel (min 220 px)
         # 2x2 grid: prevents text overflow on narrow left panel (min 220 px)
-        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"))
+        self.btn_apply  = TrPushButton("btn_apply_rerun")
+        self.btn_reset  = TrPushButton("btn_reset")
+        self.btn_reload = TrPushButton("btn_reload_config")
+        self.btn_save   = TrPushButton("btn_save_hw")
         grid = QGridLayout()
         grid = QGridLayout()
         grid.setSpacing(4)
         grid.setSpacing(4)
         grid.addWidget(self.btn_apply,  0, 0)
         grid.addWidget(self.btn_apply,  0, 0)
@@ -80,13 +80,6 @@ class DelayControlsPanel(QWidget):
     # Public API
     # 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:
     def load_from_hw(self, hw) -> None:
         for attr, (sb, scale) in self._spinboxes.items():
         for attr, (sb, scale) in self._spinboxes.items():
             val = getattr(hw, attr, 0.0)
             val = getattr(hw, attr, 0.0)

+ 39 - 17
apps/gui/src/i18n.py

@@ -2,6 +2,21 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 _lang: str = "en"
 _lang: str = "en"
+_listeners: list = []
+
+
+def add_listener(callback) -> None:
+    """Register a callable to be invoked on every language change."""
+    if callback not in _listeners:
+        _listeners.append(callback)
+
+
+def remove_listener(callback) -> None:
+    """Unregister a previously added listener (silently ignores unknown callbacks)."""
+    try:
+        _listeners.remove(callback)
+    except ValueError:
+        pass
 
 
 _T: dict[str, dict[str, str]] = {
 _T: dict[str, dict[str, str]] = {
     # --- nav bar ---
     # --- nav bar ---
@@ -25,6 +40,7 @@ _T: dict[str, dict[str, str]] = {
     "grp_job":            {"en": "Job",                "ru": "Задание"},
     "grp_job":            {"en": "Job",                "ru": "Задание"},
     "no_job":             {"en": "- no job -",         "ru": "- нет задания -"},
     "no_job":             {"en": "- no job -",         "ru": "- нет задания -"},
     "btn_load_scenario":  {"en": "Load Scenario",      "ru": "Загрузить сценарий"},
     "btn_load_scenario":  {"en": "Load Scenario",      "ru": "Загрузить сценарий"},
+    "btn_build_scenario": {"en": "Build custom…",      "ru": "Собрать вручную…"},
     "btn_run_all":        {"en": "Run All",            "ru": "Запустить всё"},
     "btn_run_all":        {"en": "Run All",            "ru": "Запустить всё"},
     "btn_next_step":      {"en": "Next Step",          "ru": "Следующий шаг"},
     "btn_next_step":      {"en": "Next Step",          "ru": "Следующий шаг"},
     "btn_abort":          {"en": "Abort",              "ru": "Прервать"},
     "btn_abort":          {"en": "Abort",              "ru": "Прервать"},
@@ -32,19 +48,20 @@ _T: dict[str, dict[str, str]] = {
     "col_status":         {"en": "Status",             "ru": "Статус"},
     "col_status":         {"en": "Status",             "ru": "Статус"},
     "col_result":         {"en": "Result",             "ru": "Результат"},
     "col_result":         {"en": "Result",             "ru": "Результат"},
     "tab_step_result":    {"en": "Step Result",        "ru": "Результат шага"},
     "tab_step_result":    {"en": "Step Result",        "ru": "Результат шага"},
+    "tab_step_params":    {"en": "Step Params",        "ru": "Параметры шага"},
     "tab_seq_info_view":  {"en": "Sequence Info",      "ru": "Инф. о посл-ти"},
     "tab_seq_info_view":  {"en": "Sequence Info",      "ru": "Инф. о посл-ти"},
     "tab_log":            {"en": "Log",                "ru": "Журнал"},
     "tab_log":            {"en": "Log",                "ru": "Журнал"},
 
 
     # --- seq_interp_tab ---
     # --- 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_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_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": " На сканнер"},
+    "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_seq_metadata":   {"en": "Sequence Metadata",  "ru": "Метаданные посл-ти"},
     "grp_warnings":       {"en": "Warnings",           "ru": "Предупреждения"},
     "grp_warnings":       {"en": "Warnings",           "ru": "Предупреждения"},
     "status_ready":       {"en": "Ready",              "ru": "Готово"},
     "status_ready":       {"en": "Ready",              "ru": "Готово"},
@@ -52,7 +69,7 @@ _T: dict[str, dict[str, str]] = {
 
 
     # --- fid_tab ---
     # --- fid_tab ---
     "btn_generate":       {"en": "Generate",                       "ru": "Генерировать"},
     "btn_generate":       {"en": "Generate",                       "ru": "Генерировать"},
-    "btn_save_seq":       {"en": " Save .seq",                     "ru": " Сохранить .seq"},
+    "btn_save_seq":       {"en": "Save .seq",                      "ru": "Сохранить .seq"},
     "btn_load_seq_tab":   {"en": "→ Load in Sequence Tab",         "ru": "→ В Sequence Tab"},
     "btn_load_seq_tab":   {"en": "→ Load in Sequence Tab",         "ru": "→ В Sequence Tab"},
     "grp_fid_params":     {"en": "FID Parameters",                 "ru": "Параметры FID"},
     "grp_fid_params":     {"en": "FID Parameters",                 "ru": "Параметры FID"},
     "grp_pulse_type":     {"en": "Pulse type",                     "ru": "Тип импульса"},
     "grp_pulse_type":     {"en": "Pulse type",                     "ru": "Тип импульса"},
@@ -98,32 +115,32 @@ _T: dict[str, dict[str, str]] = {
         "en": "Click 'Load JSON...' to open a hardware JSON file and start NMR analysis",
         "en": "Click 'Load JSON...' to open a hardware JSON file and start NMR analysis",
         "ru": "Нажмите 'Загрузить JSON...' для открытия JSON-файла и запуска анализа ЯМР",
         "ru": "Нажмите 'Загрузить JSON...' для открытия JSON-файла и запуска анализа ЯМР",
     },
     },
-    "batch_title":        {"en": "Batch NMR Analysis", "ru": "Пакетный анализ ЯМР"},
+    "batch_title":        {"en": "Batch NMR Analysis", "ru": "ЯМР анализ набора данных"},
     "folder_label":       {"en": "Folder:",             "ru": "Папка:"},
     "folder_label":       {"en": "Folder:",             "ru": "Папка:"},
     "btn_browse":         {"en": "Browse...",           "ru": "Обзор..."},
     "btn_browse":         {"en": "Browse...",           "ru": "Обзор..."},
     "cb_use_current":     {
     "cb_use_current":     {
         "en": "Use current parameters from the Spectroscopy tab",
         "en": "Use current parameters from the Spectroscopy tab",
         "ru": "Использовать текущие параметры вкладки Spectroscopy",
         "ru": "Использовать текущие параметры вкладки Spectroscopy",
     },
     },
-    "btn_run_batch":      {"en": "Run Batch",           "ru": "Запустить пакет"},
+    "btn_run_batch":      {"en": "Run Batch",           "ru": "Исследовать набор"},
     "btn_export_csv":     {"en": "Export CSV...",       "ru": "Экспорт CSV..."},
     "btn_export_csv":     {"en": "Export CSV...",       "ru": "Экспорт CSV..."},
     "btn_close":          {"en": "Close",              "ru": "Закрыть"},
     "btn_close":          {"en": "Close",              "ru": "Закрыть"},
     "batch_status_init":  {
     "batch_status_init":  {
         "en": "Select a folder and click Run Batch.",
         "en": "Select a folder and click Run Batch.",
-        "ru": "Выберите папку и нажмите Запустить пакет.",
+        "ru": "Выберите папку и нажмите Исследовать набор.",
     },
     },
 
 
     # --- mode selector ---
     # --- mode selector ---
-    "mode_plug":            {"en": "PLUG",          "ru": "ЗАГЛУШКА"},
-    "mode_real":            {"en": "REAL",          "ru": "БОЕВОЙ"},
+    "mode_plug":            {"en": "PLUG",          "ru": "ИМИТАТОР"},
+    "mode_real":            {"en": "HACK RF",        "ru": "HACK RF"},
     "mode_confirm_title":   {"en": "Switch Mode",   "ru": "Смена режима"},
     "mode_confirm_title":   {"en": "Switch Mode",   "ru": "Смена режима"},
     "mode_confirm_to_real": {
     "mode_confirm_to_real": {
-        "en": "Switch to REAL (hardware) mode?\n\nPhysical devices will be used for acquisition.",
-        "ru": "Переключиться в БОЕВОЙ режим?\n\nДля сбора данных будет использоваться реальное оборудование.",
+        "en": "Switch connection to Hack RF mode?\n\nPhysical devices will be used for acquisition.",
+        "ru": "Переключиться в режим подключения к спектрометру Hack RF?\n\nДля сбора данных будет использоваться реальное оборудование.",
     },
     },
     "mode_confirm_to_plug": {
     "mode_confirm_to_plug": {
         "en": "Switch to PLUG (stub) mode?\n\nPhysical devices will NOT be used.",
         "en": "Switch to PLUG (stub) mode?\n\nPhysical devices will NOT be used.",
-        "ru": "Переключиться в режим ЗАГЛУШКИ?\n\nРеальное оборудование использоваться не будет.",
+        "ru": "Переключиться в режим имитатора?\n\nРеальное оборудование использоваться не будет.",
     },
     },
     "mode_error":           {"en": "Mode switch failed", "ru": "Ошибка смены режима"},
     "mode_error":           {"en": "Mode switch failed", "ru": "Ошибка смены режима"},
 
 
@@ -168,3 +185,8 @@ def set_language(lang: str) -> None:
     global _lang
     global _lang
     if lang in ("en", "ru"):
     if lang in ("en", "ru"):
         _lang = lang
         _lang = lang
+        for cb in list(_listeners):
+            try:
+                cb()
+            except Exception:
+                pass

+ 19 - 9
apps/gui/src/tabs/fid_tab.py

@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
 )
 )
 
 
 from src.gui.plot_panel import PlotPanel
 from src.gui.plot_panel import PlotPanel
+from src.gui.tr_widgets import TrGroupBox, TrPushButton
 from src.gui.workers import LoadInterpWorker
 from src.gui.workers import LoadInterpWorker
 from src import i18n, theme
 from src import i18n, theme
 
 
@@ -119,6 +120,12 @@ class FidTab(QWidget):
         if hasattr(self, "_plots"):
         if hasattr(self, "_plots"):
             self._plots.apply_theme()
             self._plots.apply_theme()
 
 
+    def retranslate_ui(self) -> None:
+        # All buttons/groups auto-translate via TrPushButton/TrGroupBox.
+        # Only the status label needs manual handling (shows runtime messages).
+        if self._btn_generate.isEnabled() and not self._progress.isVisible():
+            self._status_lbl.setText(i18n.tr("fid_status_init"))
+
     def _build_toolbar(self) -> QWidget:
     def _build_toolbar(self) -> QWidget:
         bar = QWidget()
         bar = QWidget()
         bar.setObjectName("FidToolBar")
         bar.setObjectName("FidToolBar")
@@ -130,8 +137,8 @@ class FidTab(QWidget):
         lay.setContentsMargins(6, 4, 6, 4)
         lay.setContentsMargins(6, 4, 6, 4)
         lay.setSpacing(4)
         lay.setSpacing(4)
 
 
-        def btn(label: str, tip: str, slot=None, enabled: bool = True) -> QPushButton:
-            b = QPushButton(label)
+        def btn(key: str, tip: str, slot=None, enabled: bool = True) -> TrPushButton:
+            b = TrPushButton(key)
             b.setToolTip(tip)
             b.setToolTip(tip)
             b.setEnabled(enabled)
             b.setEnabled(enabled)
             b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
             b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
@@ -147,11 +154,11 @@ class FidTab(QWidget):
             f.setFixedWidth(2)
             f.setFixedWidth(2)
             return f
             return f
 
 
-        self._btn_generate    = btn(i18n.tr("btn_generate"),    "Generate FID sequence and visualise", self._generate)
+        self._btn_generate    = btn("btn_generate",    "Generate FID sequence and visualise", self._generate)
         lay.addWidget(sep())
         lay.addWidget(sep())
-        self._btn_save        = btn(i18n.tr("btn_save_seq"),    "Save .seq + .json to output directory",
+        self._btn_save        = btn("btn_save_seq",    "Save .seq + .json to output directory",
                                     self._save, enabled=False)
                                     self._save, enabled=False)
-        self._btn_load_seq_tab = btn(i18n.tr("btn_load_seq_tab"),
+        self._btn_load_seq_tab = btn("btn_load_seq_tab",
                                      "Send this .seq to the Sequence interpreter tab",
                                      "Send this .seq to the Sequence interpreter tab",
                                      self._load_in_seq_tab, enabled=False)
                                      self._load_in_seq_tab, enabled=False)
         lay.addStretch()
         lay.addStretch()
@@ -187,7 +194,8 @@ class FidTab(QWidget):
         lay.setSpacing(8)
         lay.setSpacing(8)
 
 
         # -- parameter form --------------------------------------------
         # -- parameter form --------------------------------------------
-        param_grp = QGroupBox("FID Parameters")
+        self._param_grp = TrGroupBox("grp_fid_params")
+        param_grp = self._param_grp
         form = QFormLayout(param_grp)
         form = QFormLayout(param_grp)
         form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 
 
@@ -234,7 +242,8 @@ class FidTab(QWidget):
         self._update_bwfull()
         self._update_bwfull()
 
 
         # -- pulse type ------------------------------------------------
         # -- pulse type ------------------------------------------------
-        pulse_grp = QGroupBox("Pulse type")
+        self._pulse_grp = TrGroupBox("grp_pulse_type")
+        pulse_grp = self._pulse_grp
         pulse_lay = QHBoxLayout(pulse_grp)
         pulse_lay = QHBoxLayout(pulse_grp)
         self._rb_block = QRadioButton("block")
         self._rb_block = QRadioButton("block")
         self._rb_sinc  = QRadioButton("sinc")
         self._rb_sinc  = QRadioButton("sinc")
@@ -248,7 +257,8 @@ class FidTab(QWidget):
 
 
         # -- output filename -------------------------------------------
         # -- output filename -------------------------------------------
         from PySide6.QtWidgets import QLineEdit
         from PySide6.QtWidgets import QLineEdit
-        file_grp = QGroupBox("Output filename (no extension)")
+        self._file_grp = TrGroupBox("grp_out_filename")
+        file_grp = self._file_grp
         file_lay = QVBoxLayout(file_grp)
         file_lay = QVBoxLayout(file_grp)
         self._le_filename = QLineEdit("FID_" + datetime.now().strftime("%d%m%y_%H%M"))
         self._le_filename = QLineEdit("FID_" + datetime.now().strftime("%d%m%y_%H%M"))
         file_lay.addWidget(self._le_filename)
         file_lay.addWidget(self._le_filename)
@@ -268,7 +278,7 @@ class FidTab(QWidget):
         bar.setFixedHeight(22)
         bar.setFixedHeight(22)
         lay = QHBoxLayout(bar)
         lay = QHBoxLayout(bar)
         lay.setContentsMargins(6, 0, 6, 0)
         lay.setContentsMargins(6, 0, 6, 0)
-        self._status_lbl = QLabel("Ready - set parameters and click Generate")
+        self._status_lbl = QLabel(i18n.tr("fid_status_init"))
         self._status_lbl.setFont(QFont("Arial", 8))
         self._status_lbl.setFont(QFont("Arial", 8))
         lay.addWidget(self._status_lbl)
         lay.addWidget(self._status_lbl)
         lay.addStretch()
         lay.addStretch()

+ 571 - 213
apps/gui/src/tabs/scanner_tab.py

@@ -5,51 +5,323 @@ orchestrator; this tab never connects to those services directly.
 """
 """
 from __future__ import annotations
 from __future__ import annotations
 
 
+import copy
 import json
 import json
 
 
 from PySide6.QtCore import Qt, QTimer, Signal
 from PySide6.QtCore import Qt, QTimer, Signal
 from PySide6.QtGui import QFont, QColor
 from PySide6.QtGui import QFont, QColor
 from PySide6.QtWidgets import (
 from PySide6.QtWidgets import (
     QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
     QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
-    QGroupBox, QLabel, QPushButton, QProgressBar,
+    QLabel, QPushButton, QProgressBar,
     QTextEdit, QScrollArea, QSizePolicy, QTabWidget,
     QTextEdit, QScrollArea, QSizePolicy, QTabWidget,
     QComboBox, QLineEdit, QTableWidget, QTableWidgetItem,
     QComboBox, QLineEdit, QTableWidget, QTableWidgetItem,
     QHeaderView, QAbstractItemView,
     QHeaderView, QAbstractItemView,
+    QDialog, QDialogButtonBox, QListWidget, QListWidgetItem,
+    QFrame, QToolButton, QMessageBox, QSplitter as _QSplitter,
 )
 )
 
 
 from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
 from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
+from src.gui.tr_widgets import TrGroupBox, TrLabel, TrPushButton, bind_tab_text, bind_table_headers
 from src.gui.workers import OrchestratorWorker
 from src.gui.workers import OrchestratorWorker
 from src import i18n, theme
 from src import i18n, theme
 
 
 
 
-_STATUS_COLORS = {
-    "pending": "#9e9e9e",
-    "running": "#e65100",
-    "done":    "#2e7d32",
-    "failed":  "#c62828",
+# ── Built-in step templates ───────────────────────────────────────────────────
+# Each entry: internal name → display info + default params.
+# display_en / display_ru are shown in the builder UI.
+_STEP_TEMPLATES: dict[str, dict] = {
+    "start_measurement": {
+        "display_en": "Start Measurement",
+        "display_ru": "Запуск измерения",
+        "desc_en": "Submit scan parameters to the spectrometer and trigger acquisition.",
+        "desc_ru": "Отправить параметры скана на спектрометр и запустить сбор данных.",
+        "params": {
+            "info": {
+                "infostr": "custom_signal",
+                "engine":  "DefaultEngine",
+                "iadc": {
+                    "device_model": "PS4000A",
+                    "srate":        80000000,
+                    "points":       [1000],
+                    "n_channels":   1,
+                    "averaging":    1,
+                },
+            }
+        },
+    },
+    "wait_data_ready": {
+        "display_en": "Wait Data Ready",
+        "display_ru": "Ожидание данных",
+        "desc_en": "Poll spectrometer until data from the previous measurement is available.",
+        "desc_ru": "Ожидать готовности данных от предыдущего шага измерения.",
+        "params": {},
+    },
+    "fetch_data": {
+        "display_en": "Fetch Data",
+        "display_ru": "Получить данные",
+        "desc_en": "Download raw acquisition data from the spectrometer.",
+        "desc_ru": "Скачать сырые данные с спектрометра.",
+        "params": {"format": "h5"},
+    },
+    "run_reconstruction": {
+        "display_en": "Run Reconstruction",
+        "display_ru": "Реконструкция",
+        "desc_en": "Send raw data to the reconstructor service and retrieve the image.",
+        "desc_ru": "Передать данные в реконструктор и получить изображение.",
+        "params": {
+            "sequence_name": "linear",
+            "digit":         "2d",
+            "phase_shift":   False,
+        },
+    },
 }
 }
 
 
-_POLL_INTERVAL_MS = 1500
 
 
-# Service status bar
-_SVC_POLL_INTERVAL_MS = 5_000
+# ── Scenario builder dialog ───────────────────────────────────────────────────
+
+class ScenarioBuilderDialog(QDialog):
+    """
+    Modal dialog for composing a custom scenario step-by-step.
+
+    Left column: available step templates.
+    Right column: ordered list of steps to execute.
+    Bottom: JSON params editor for the selected step.
+    """
+
+    def __init__(
+        self,
+        extra_tasks: list[str] | None = None,
+        parent: QWidget | None = None,
+    ) -> None:
+        super().__init__(parent)
+        self.setWindowTitle("Scenario Builder")
+        self.setMinimumSize(720, 520)
+        self.resize(820, 580)
+
+        # Merge server-side extra tasks into templates as "bare" entries
+        self._templates = dict(_STEP_TEMPLATES)
+        for t in (extra_tasks or []):
+            if t not in self._templates:
+                self._templates[t] = {
+                    "display_en": t,
+                    "display_ru": t,
+                    "desc_en": "(custom task — no built-in description)",
+                    "desc_ru": "(пользовательский шаг)",
+                    "params": {},
+                }
+
+        # Internal step list: list of {"name": str, "params": dict}
+        self._steps: list[dict] = []
+
+        self._build_ui()
+
+    # ── result ────────────────────────────────────────────────────────────
+
+    def get_steps(self) -> list[dict]:
+        """Return the composed step list (call after exec() == Accepted)."""
+        return copy.deepcopy(self._steps)
+
+    # ── UI ────────────────────────────────────────────────────────────────
+
+    def _build_ui(self) -> None:
+        root = QVBoxLayout(self)
+        root.setSpacing(6)
+
+        splitter = _QSplitter(Qt.Horizontal)
+
+        # Left: template palette
+        left = QWidget()
+        left.setMinimumWidth(200)
+        left.setMaximumWidth(260)
+        ll = QVBoxLayout(left)
+        ll.setContentsMargins(0, 0, 0, 0)
+        ll.setSpacing(4)
+        ll.addWidget(QLabel("<b>Available steps</b>"))
+
+        self._tmpl_list = QListWidget()
+        self._tmpl_list.setAlternatingRowColors(True)
+        for name, info in self._templates.items():
+            display = info["display_en"] if i18n.current_language() == "en" \
+                      else info["display_ru"]
+            item = QListWidgetItem(display)
+            item.setData(Qt.UserRole, name)
+            item.setToolTip(
+                info["desc_en"] if i18n.current_language() == "en"
+                else info["desc_ru"]
+            )
+            self._tmpl_list.addItem(item)
+        self._tmpl_list.itemDoubleClicked.connect(self._add_selected_template)
+        ll.addWidget(self._tmpl_list, stretch=1)
+
+        btn_add = QPushButton("+ Add →")
+        btn_add.clicked.connect(self._add_selected_template)
+        ll.addWidget(btn_add)
+        splitter.addWidget(left)
+
+        # Right: step queue
+        right = QWidget()
+        rl = QVBoxLayout(right)
+        rl.setContentsMargins(0, 0, 0, 0)
+        rl.setSpacing(4)
+        rl.addWidget(QLabel("<b>Steps to run (in order)</b>"))
+
+        self._step_list = QListWidget()
+        self._step_list.setAlternatingRowColors(True)
+        self._step_list.currentRowChanged.connect(self._on_step_selected)
+        rl.addWidget(self._step_list, stretch=1)
+
+        # Row controls: move up / move down / remove
+        ctrl_row = QHBoxLayout()
+        ctrl_row.setSpacing(4)
+        for symbol, tip, slot in [
+            ("▲", "Move up",   self._move_up),
+            ("▼", "Move down", self._move_down),
+            ("✕", "Remove",    self._remove_step),
+        ]:
+            b = QPushButton(symbol)
+            b.setFixedSize(28, 24)
+            b.setToolTip(tip)
+            b.clicked.connect(slot)
+            ctrl_row.addWidget(b)
+        ctrl_row.addStretch()
+        rl.addLayout(ctrl_row)
+
+        # Params editor (JSON)
+        rl.addWidget(QLabel("<b>Step params (JSON)</b>"))
+        self._params_edit = QTextEdit()
+        self._params_edit.setFont(QFont("Courier New", 9))
+        self._params_edit.setFixedHeight(130)
+        self._params_edit.setPlaceholderText("Select a step to edit its params")
+        self._params_edit.textChanged.connect(self._on_params_edited)
+        rl.addWidget(self._params_edit)
+
+        splitter.addWidget(right)
+        splitter.setSizes([220, 500])
+        root.addWidget(splitter, stretch=1)
+
+        # Dialog buttons
+        btns = QDialogButtonBox(
+            QDialogButtonBox.Ok | QDialogButtonBox.Cancel
+        )
+        btns.button(QDialogButtonBox.Ok).setText("Load as Job")
+        btns.accepted.connect(self._on_accept)
+        btns.rejected.connect(self.reject)
+        root.addWidget(btns)
+
+    # ── template → step queue ─────────────────────────────────────────────
+
+    def _add_selected_template(self) -> None:
+        item = self._tmpl_list.currentItem()
+        if item is None:
+            return
+        name = item.data(Qt.UserRole)
+        tpl  = self._templates.get(name, {})
+        self._steps.append({
+            "name":   name,
+            "params": copy.deepcopy(tpl.get("params", {})),
+        })
+        self._refresh_step_list()
+        self._step_list.setCurrentRow(len(self._steps) - 1)
+
+    def _refresh_step_list(self) -> None:
+        self._step_list.clear()
+        for idx, step in enumerate(self._steps):
+            name  = step["name"]
+            tpl   = self._templates.get(name, {})
+            disp  = tpl.get("display_en" if i18n.current_language() == "en"
+                            else "display_ru", name)
+            item  = QListWidgetItem(f"{idx + 1}.  {disp}  [{name}]")
+            item.setData(Qt.UserRole, idx)
+            self._step_list.addItem(item)
+
+    # ── step controls ─────────────────────────────────────────────────────
+
+    def _current_idx(self) -> int:
+        return self._step_list.currentRow()
+
+    def _move_up(self) -> None:
+        i = self._current_idx()
+        if i <= 0:
+            return
+        self._steps[i - 1], self._steps[i] = self._steps[i], self._steps[i - 1]
+        self._refresh_step_list()
+        self._step_list.setCurrentRow(i - 1)
+
+    def _move_down(self) -> None:
+        i = self._current_idx()
+        if i < 0 or i >= len(self._steps) - 1:
+            return
+        self._steps[i + 1], self._steps[i] = self._steps[i], self._steps[i + 1]
+        self._refresh_step_list()
+        self._step_list.setCurrentRow(i + 1)
+
+    def _remove_step(self) -> None:
+        i = self._current_idx()
+        if i < 0:
+            return
+        self._steps.pop(i)
+        self._refresh_step_list()
+        self._step_list.setCurrentRow(max(0, i - 1))
+
+    # ── params editor ─────────────────────────────────────────────────────
+
+    def _on_step_selected(self, row: int) -> None:
+        if row < 0 or row >= len(self._steps):
+            self._params_edit.setPlainText("")
+            return
+        params = self._steps[row].get("params", {})
+        self._params_edit.blockSignals(True)
+        self._params_edit.setPlainText(
+            json.dumps(params, indent=2, ensure_ascii=False)
+        )
+        self._params_edit.blockSignals(False)
+
+    def _on_params_edited(self) -> None:
+        i = self._current_idx()
+        if i < 0 or i >= len(self._steps):
+            return
+        try:
+            params = json.loads(self._params_edit.toPlainText() or "{}")
+            self._steps[i]["params"] = params
+            self._params_edit.setStyleSheet("")
+        except json.JSONDecodeError:
+            self._params_edit.setStyleSheet("border: 1px solid #c62828;")
+
+    # ── accept ────────────────────────────────────────────────────────────
+
+    def _on_accept(self) -> None:
+        if not self._steps:
+            QMessageBox.warning(self, "Empty scenario",
+                                "Add at least one step before loading.")
+            return
+        # Validate JSON one last time
+        try:
+            json.loads(self._params_edit.toPlainText() or "{}")
+        except json.JSONDecodeError:
+            QMessageBox.warning(self, "Invalid JSON",
+                                "Fix the params JSON before loading.")
+            return
+        self.accept()
 
 
-_DOT_ONLINE   = "● "
-_DOT_OFFLINE  = "● "
-_DOT_UNKNOWN  = "● "
 
 
-_SVC_STYLE = {
-    "online":   "color: #2e7d32; font-weight: bold; font-size: 12px;",
-    "offline":  "color: #c62828; font-weight: bold; font-size: 12px;",
-    "unknown":  "color: #757575; font-weight: bold; font-size: 12px;",
-    "checking": "color: #e65100; font-weight: bold; font-size: 12px;",
+# ── Status colors ─────────────────────────────────────────────────────────────
+
+_SVC_POLL_MS = 8_000   # service health-check interval (ms)
+
+_STATUS_COLORS = {
+    "pending":  "#9e9e9e",
+    "running":  "#e65100",
+    "done":     "#2e7d32",
+    "failed":   "#c62828",
 }
 }
 
 
+_POLL_INTERVAL_MS = 1500
+
 
 
 class ScannerTab(QWidget):
 class ScannerTab(QWidget):
     """Orchestrator-based scanner control panel."""
     """Orchestrator-based scanner control panel."""
 
 
-    scan_result_ready = Signal(str)   # path to result JSON — emitted when job finishes with output
+    scan_result_ready = Signal(str)   # emitted when job finishes with a JSON output path
 
 
     def __init__(
     def __init__(
         self,
         self,
@@ -63,36 +335,40 @@ class ScannerTab(QWidget):
     ) -> None:
     ) -> None:
         super().__init__(parent)
         super().__init__(parent)
 
 
-        self._hw_config_path = hw_config_path
-        self._orchestrator_url = orchestrator_url.rstrip("/")
-        self._seq_interp_url   = seq_interp_url.rstrip("/")
+        self._hw_config_path    = hw_config_path
+        self._orchestrator_url  = orchestrator_url.rstrip("/")
+        # Other service URLs kept for potential future use (not used after services bar removal)
+        self._seq_interp_url    = seq_interp_url.rstrip("/")
         self._reconstructor_url = reconstructor_url.rstrip("/")
         self._reconstructor_url = reconstructor_url.rstrip("/")
         self._spectroscopy_url  = spectroscopy_url.rstrip("/")
         self._spectroscopy_url  = spectroscopy_url.rstrip("/")
         self._spectrometer_url  = spectrometer_url.rstrip("/")
         self._spectrometer_url  = spectrometer_url.rstrip("/")
 
 
-        self._client = OrchestratorClient(orchestrator_url)
-        self._job_id: str | None = None
+        self._client  = OrchestratorClient(orchestrator_url)
+        self._job_id  : str | None = None
         self._seq_info: dict | None = None
         self._seq_info: dict | None = None
         self._conn_state: str = "offline"
         self._conn_state: str = "offline"
+        self._steps_data: list = []
 
 
-        # Active workers - kept alive while running
-        self._run_worker: OrchestratorWorker | None = None
-        self._poll_worker: OrchestratorWorker | None = None
+        self._run_worker  : OrchestratorWorker | None = None
+        self._poll_worker : OrchestratorWorker | None = None
 
 
         self._poll_timer = QTimer(self)
         self._poll_timer = QTimer(self)
         self._poll_timer.setInterval(_POLL_INTERVAL_MS)
         self._poll_timer.setInterval(_POLL_INTERVAL_MS)
         self._poll_timer.timeout.connect(self._poll_status)
         self._poll_timer.timeout.connect(self._poll_status)
 
 
-        # Service status polling
+        # Service health polling
         self._svc_workers: dict[str, OrchestratorWorker] = {}
         self._svc_workers: dict[str, OrchestratorWorker] = {}
         self._svc_timer = QTimer(self)
         self._svc_timer = QTimer(self)
-        self._svc_timer.setInterval(_SVC_POLL_INTERVAL_MS)
+        self._svc_timer.setInterval(_SVC_POLL_MS)
         self._svc_timer.timeout.connect(self._poll_all_services)
         self._svc_timer.timeout.connect(self._poll_all_services)
 
 
         self._build_layout()
         self._build_layout()
 
 
-        # Kick off initial check after the event loop starts
-        QTimer.singleShot(300, self._poll_all_services)
+        # Load scenarios as soon as the event loop starts
+        QTimer.singleShot(500, self._on_refresh_scenarios)
+
+        # Start service health polling shortly after startup
+        QTimer.singleShot(1200, self._poll_all_services)
         self._svc_timer.start()
         self._svc_timer.start()
 
 
     # ================================================================== #
     # ================================================================== #
@@ -103,147 +379,91 @@ class ScannerTab(QWidget):
         self._hw_config_path = path
         self._hw_config_path = path
         self._append_log(f"HW config: {path}")
         self._append_log(f"HW config: {path}")
 
 
+    def refresh_scenarios(self) -> None:
+        """Reload scenario list (called on mode switch or explicit request)."""
+        self._on_refresh_scenarios()
+
     def attach_job(self, job_id: str) -> None:
     def attach_job(self, job_id: str) -> None:
         """
         """
-        Called when ScanningTab starts a new scan job via the orchestrator.
-        Wires the job into the Scanner tab so the operator can monitor progress.
+        Called when ScanningTab starts a scan job via the orchestrator.
+        Wires the job in so the operator can monitor progress here.
         """
         """
         self._job_id = job_id
         self._job_id = job_id
-        short = job_id[:24] + "..." if len(job_id) > 24 else job_id
-        self._job_label.setText(short)
+        self._job_label.setText(self._short_id(job_id))
         self._append_log(f"Scan job received from Scanning tab: {job_id}")
         self._append_log(f"Scan job received from Scanning tab: {job_id}")
 
 
         self._steps_table.setRowCount(0)
         self._steps_table.setRowCount(0)
         self._btn_run_all.setEnabled(False)
         self._btn_run_all.setEnabled(False)
         self._btn_next.setEnabled(False)
         self._btn_next.setEnabled(False)
         self._btn_abort.setEnabled(True)
         self._btn_abort.setEnabled(True)
+        self._show_job_progress(True)
 
 
-        # Start polling so the steps table updates in real time
         self._fetch_status_once()
         self._fetch_status_once()
         self._poll_timer.start()
         self._poll_timer.start()
 
 
     def apply_seq_info(self, info_dict: dict) -> None:
     def apply_seq_info(self, info_dict: dict) -> None:
         """Receive sequence info from SeqInterpTab after export."""
         """Receive sequence info from SeqInterpTab after export."""
         self._seq_info = info_dict
         self._seq_info = info_dict
-        summary_lines = []
+        lines = []
         if "infostr" in info_dict:
         if "infostr" in info_dict:
-            summary_lines.append(info_dict["infostr"])
+            lines.append(info_dict["infostr"])
         if "time" in info_dict:
         if "time" in info_dict:
-            summary_lines.append(info_dict["time"])
+            lines.append(info_dict["time"])
         adc = info_dict.get("iadc", {})
         adc = info_dict.get("iadc", {})
         if "points" in adc:
         if "points" in adc:
-            summary_lines.append(f"ADC windows: {len(adc['points'])}")
-        self._seq_info_label.setText("\n".join(summary_lines) if summary_lines else "-")
+            lines.append(f"ADC windows: {len(adc['points'])}")
+        self._seq_info_label.setText("\n".join(lines) if lines else "-")
         self._append_log("Sequence info received from Sequence tab.")
         self._append_log("Sequence info received from Sequence tab.")
 
 
-    def apply_theme(self) -> None:
-        p = theme.palette()
-        self._services_bar.setStyleSheet(
-            f"QWidget {{ background: {p['surface']}; border-radius: 4px; }}"
-        )
-        self._svc_title_lbl.setStyleSheet(
-            f"color: {p['text_muted']}; font-size: 11px; background: transparent;"
-        )
-
-    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                                                     #
-    # ================================================================== #
+    # -- Services strip --------------------------------------------------------
 
 
-    def _build_layout(self) -> None:
-        root = QVBoxLayout(self)
-        root.setContentsMargins(6, 6, 6, 6)
-        root.setSpacing(6)
-
-        root.addWidget(self._build_services_bar())
-        root.addWidget(self._build_connection_bar())
-
-        split = QSplitter(Qt.Horizontal)
-        split.addWidget(self._build_left_panel())
-        split.addWidget(self._build_right_panel())
-        split.setSizes([280, 720])
-        root.addWidget(split, stretch=1)
-
-    # ================================================================== #
-    #  Services status bar                                                #
-    # ================================================================== #
-
-    def _build_services_bar(self) -> QWidget:
-        """Horizontal strip showing live status of every microservice."""
-        bar = QWidget()
-        p = theme.palette()
-        bar.setStyleSheet(
-            f"QWidget {{ background: {p['surface']}; border-radius: 4px; }}"
-        )
-        lay = QHBoxLayout(bar)
-        lay.setContentsMargins(8, 4, 8, 4)
+    def _build_services_strip(self) -> QWidget:
+        """Thin status bar showing health of all platform services."""
+        strip = QWidget()
+        strip.setFixedHeight(22)
+        lay = QHBoxLayout(strip)
+        lay.setContentsMargins(6, 0, 6, 0)
         lay.setSpacing(0)
         lay.setSpacing(0)
 
 
-        title = QLabel("Сервисы:")
-        title.setStyleSheet(
-            f"color: {p['text_muted']}; font-size: 11px; background: transparent;"
-        )
-        self._services_bar = bar
-        self._svc_title_lbl = title
-        lay.addWidget(title)
-        lay.addSpacing(10)
-
-        # (key, display_name, tooltip_url)
-        self._svc_defs: list[tuple[str, str, str]] = [
-            ("orchestrator",  "Orchestrator",  self._orchestrator_url),
-            ("seq_interp",    "Seq-Interp",    self._seq_interp_url),
-            ("spectrometer",  "Spectrometer",  self._spectrometer_url),
-            ("reconstructor", "Reconstructor", self._reconstructor_url),
-            ("spectroscopy",  "Spectroscopy",  self._spectroscopy_url),
+        svc_defs: list[tuple[str, str, str]] = [
+            ("orchestrator",  "Orchestrator", self._orchestrator_url),
+            ("seq_interp",    "Seq-Interp",   self._seq_interp_url),
+            ("spectrometer",  "Spectrometer", self._spectrometer_url),
+            ("reconstructor", "Reconstructor",self._reconstructor_url),
+            ("spectroscopy",  "Spectroscopy", self._spectroscopy_url),
         ]
         ]
 
 
         self._svc_labels: dict[str, QLabel] = {}
         self._svc_labels: dict[str, QLabel] = {}
-        for i, (key, name, url) in enumerate(self._svc_defs):
-            lbl = QLabel(f"{_DOT_UNKNOWN}{name}")
-            lbl.setStyleSheet(_SVC_STYLE["unknown"])
+        for i, (key, name, url) in enumerate(svc_defs):
+            lbl = QLabel(f"● {name}")
+            lbl.setStyleSheet("color: #555577; font-size: 10px;")
             lbl.setToolTip(url)
             lbl.setToolTip(url)
             self._svc_labels[key] = lbl
             self._svc_labels[key] = lbl
             lay.addWidget(lbl)
             lay.addWidget(lbl)
-            if i < len(self._svc_defs) - 1:
+            if i < len(svc_defs) - 1:
                 sep = QLabel("  |  ")
                 sep = QLabel("  |  ")
-                sep.setStyleSheet("color: #333355; background: transparent;")
+                sep.setStyleSheet("color: #2a2a44; font-size: 10px;")
                 lay.addWidget(sep)
                 lay.addWidget(sep)
 
 
         lay.addStretch()
         lay.addStretch()
 
 
         btn_refresh = QPushButton("↻")
         btn_refresh = QPushButton("↻")
-        btn_refresh.setFixedSize(22, 22)
-        btn_refresh.setToolTip("Проверить статусы сейчас")
+        btn_refresh.setFixedSize(18, 18)
+        btn_refresh.setToolTip("Refresh service status")
         btn_refresh.setStyleSheet(
         btn_refresh.setStyleSheet(
-            "QPushButton { background: #252540; color: #7777aa;"
-            "  border: 1px solid #333355; border-radius: 3px; font-size: 13px; }"
-            "QPushButton:hover { color: #ffffff; background: #303060; }"
+            "QPushButton{background:transparent;color:#555577;"
+            "border:1px solid #2a2a44;border-radius:2px;font-size:10px;}"
+            "QPushButton:hover{color:#aaaacc;border-color:#555577;}"
         )
         )
         btn_refresh.clicked.connect(self._poll_all_services)
         btn_refresh.clicked.connect(self._poll_all_services)
         lay.addWidget(btn_refresh)
         lay.addWidget(btn_refresh)
 
 
-        return bar
+        self._svc_strip = strip
+        return strip
+
+    # -- Service health polling ------------------------------------------------
 
 
     def _poll_all_services(self) -> None:
     def _poll_all_services(self) -> None:
-        """Kick off background health checks for every service."""
         import httpx
         import httpx
 
 
         def _check(url: str) -> bool:
         def _check(url: str) -> bool:
@@ -256,19 +476,15 @@ class ScannerTab(QWidget):
         checks: dict[str, str] = {
         checks: dict[str, str] = {
             "orchestrator":  f"{self._orchestrator_url}/health",
             "orchestrator":  f"{self._orchestrator_url}/health",
             "seq_interp":    f"{self._seq_interp_url}/health",
             "seq_interp":    f"{self._seq_interp_url}/health",
-            # Spectrometer accessed via orchestrator proxy to avoid CORS / auth issues
             "spectrometer":  f"{self._orchestrator_url}/spectrometer/health",
             "spectrometer":  f"{self._orchestrator_url}/spectrometer/health",
             "reconstructor": f"{self._reconstructor_url}/health",
             "reconstructor": f"{self._reconstructor_url}/health",
             "spectroscopy":  f"{self._spectroscopy_url}/health",
             "spectroscopy":  f"{self._spectroscopy_url}/health",
         }
         }
 
 
         for key, url in checks.items():
         for key, url in checks.items():
-            # Skip if a previous check for this service is still running
             existing = self._svc_workers.get(key)
             existing = self._svc_workers.get(key)
             if existing and existing.isRunning():
             if existing and existing.isRunning():
                 continue
                 continue
-            # Do NOT reset the indicator here — keep the last known state
-            # until the new result arrives to avoid flickering.
             worker = OrchestratorWorker(_check, url)
             worker = OrchestratorWorker(_check, url)
             worker.finished.connect(
             worker.finished.connect(
                 lambda ok, k=key: self._on_svc_checked(k, bool(ok))
                 lambda ok, k=key: self._on_svc_checked(k, bool(ok))
@@ -280,118 +496,196 @@ class ScannerTab(QWidget):
             self._svc_workers[key] = worker
             self._svc_workers[key] = worker
 
 
     def _on_svc_checked(self, key: str, online: bool) -> None:
     def _on_svc_checked(self, key: str, online: bool) -> None:
-        self._set_svc_state(key, "online" if online else "offline")
-
-    def _set_svc_state(self, key: str, state: str) -> None:
         lbl = self._svc_labels.get(key)
         lbl = self._svc_labels.get(key)
         if lbl is None:
         if lbl is None:
             return
             return
-        dot = _DOT_ONLINE if state == "online" else (
-              _DOT_OFFLINE if state == "offline" else _DOT_UNKNOWN)
-        # keep the display name (text after the dot)
-        name = lbl.text()[2:]   # strip old "● "
-        lbl.setText(f"{dot}{name}")
-        lbl.setStyleSheet(_SVC_STYLE.get(state, _SVC_STYLE["unknown"]))
+        # preserve display name (everything after "● ")
+        name = lbl.text()[2:]
+        if online:
+            lbl.setStyleSheet("color: #2e7d32; font-size: 10px;")
+        else:
+            lbl.setStyleSheet("color: #c62828; font-size: 10px;")
+
+    def apply_theme(self) -> None:
+        pass
+
+    def retranslate_ui(self) -> None:
+        self._status_label.setText(i18n.tr(self._conn_state))
+
+    # ================================================================== #
+    #  Layout                                                              #
+    # ================================================================== #
+
+    def _build_layout(self) -> None:
+        root = QVBoxLayout(self)
+        root.setContentsMargins(6, 6, 6, 6)
+        root.setSpacing(6)
+
+        root.addWidget(self._build_connection_bar())
+
+        split = QSplitter(Qt.Horizontal)
+        split.setChildrenCollapsible(False)
+        split.addWidget(self._build_left_panel())
+        split.addWidget(self._build_right_panel())
+        split.setSizes([260, 740])
+        root.addWidget(split, stretch=1)
+
+        root.addWidget(self._build_services_strip())
+
+    # -- Connection bar ----------------------------------------------------
 
 
     def _build_connection_bar(self) -> QWidget:
     def _build_connection_bar(self) -> QWidget:
         bar = QWidget()
         bar = QWidget()
         lay = QHBoxLayout(bar)
         lay = QHBoxLayout(bar)
         lay.setContentsMargins(0, 0, 0, 0)
         lay.setContentsMargins(0, 0, 0, 0)
+        lay.setSpacing(6)
+
+        lay.addWidget(TrLabel("url_label"))
 
 
-        self._url_label = QLabel(i18n.tr("url_label"))
-        lay.addWidget(self._url_label)
         self._url_edit = QLineEdit(self._client.base_url)
         self._url_edit = QLineEdit(self._client.base_url)
         self._url_edit.setMaximumWidth(260)
         self._url_edit.setMaximumWidth(260)
         lay.addWidget(self._url_edit)
         lay.addWidget(self._url_edit)
 
 
-        self._btn_connect = QPushButton(i18n.tr("btn_connect"))
-        self._btn_connect.setFixedWidth(80)
+        self._btn_connect = TrPushButton("btn_connect")
+        self._btn_connect.setMinimumWidth(90)
         self._btn_connect.clicked.connect(self._on_connect)
         self._btn_connect.clicked.connect(self._on_connect)
         lay.addWidget(self._btn_connect)
         lay.addWidget(self._btn_connect)
 
 
-        self._status_label = QLabel(i18n.tr("offline"))
-        self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
-        lay.addWidget(self._status_label)
-
         self._conn_progress = QProgressBar()
         self._conn_progress = QProgressBar()
         self._conn_progress.setRange(0, 0)
         self._conn_progress.setRange(0, 0)
         self._conn_progress.setFixedWidth(80)
         self._conn_progress.setFixedWidth(80)
         self._conn_progress.setVisible(False)
         self._conn_progress.setVisible(False)
         lay.addWidget(self._conn_progress)
         lay.addWidget(self._conn_progress)
 
 
+        self._status_label = QLabel(i18n.tr("offline"))
+        self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
+        lay.addWidget(self._status_label)
+
         lay.addStretch()
         lay.addStretch()
         return bar
         return bar
 
 
+    # -- Left panel --------------------------------------------------------
+
     def _build_left_panel(self) -> QWidget:
     def _build_left_panel(self) -> QWidget:
         container = QWidget()
         container = QWidget()
         container.setMinimumWidth(200)
         container.setMinimumWidth(200)
-        container.setMaximumWidth(320)
+        container.setMaximumWidth(300)
         lay = QVBoxLayout(container)
         lay = QVBoxLayout(container)
         lay.setContentsMargins(4, 4, 4, 4)
         lay.setContentsMargins(4, 4, 4, 4)
         lay.setSpacing(8)
         lay.setSpacing(8)
 
 
-        # Scenario selector
-        self._scenario_grp = QGroupBox(i18n.tr("grp_scenario"))
-        sg_lay = QVBoxLayout(self._scenario_grp)
+        lay.addWidget(self._build_scenario_group())
+        lay.addWidget(self._build_job_group())
+        lay.addWidget(self._build_seq_info_group())
+        lay.addStretch()
+
+        scroll = QScrollArea()
+        scroll.setWidget(container)
+        scroll.setWidgetResizable(True)
+        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        return scroll
+
+    def _build_scenario_group(self) -> QWidget:
+        grp = TrGroupBox("grp_scenario")
+        lay = QVBoxLayout(grp)
+        lay.setSpacing(4)
+
+        # Combo + refresh button
+        row = QHBoxLayout()
+        row.setSpacing(4)
         self._scenario_combo = QComboBox()
         self._scenario_combo = QComboBox()
         self._scenario_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
         self._scenario_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
-        sg_lay.addWidget(self._scenario_combo)
-        self._btn_refresh = QPushButton(i18n.tr("btn_refresh"))
+        row.addWidget(self._scenario_combo)
+
+        self._btn_refresh = QPushButton("↻")
+        self._btn_refresh.setFixedSize(26, 26)
+        self._btn_refresh.setToolTip(i18n.tr("btn_refresh"))
         self._btn_refresh.clicked.connect(self._on_refresh_scenarios)
         self._btn_refresh.clicked.connect(self._on_refresh_scenarios)
-        sg_lay.addWidget(self._btn_refresh)
-        lay.addWidget(self._scenario_grp)
+        row.addWidget(self._btn_refresh)
+        lay.addLayout(row)
 
 
-        # Sequence summary
-        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(self._seq_grp)
+        # Load template + build custom — side by side
+        action_row = QHBoxLayout()
+        action_row.setSpacing(4)
+
+        self._btn_load = TrPushButton("btn_load_scenario")
+        self._btn_load.setMinimumHeight(28)
+        self._btn_load.clicked.connect(self._on_load_scenario)
+        action_row.addWidget(self._btn_load)
+
+        self._btn_build = QPushButton("+")
+        self._btn_build.setFixedSize(28, 28)
+        self._btn_build.setToolTip(i18n.tr("btn_build_scenario"))
+        self._btn_build.setFont(QFont("Arial", 13))
+        self._btn_build.clicked.connect(self._on_build_scenario)
+        action_row.addWidget(self._btn_build)
+
+        lay.addLayout(action_row)
+        return grp
+
+    def _build_job_group(self) -> QWidget:
+        grp = TrGroupBox("grp_job")
+        lay = QVBoxLayout(grp)
+        lay.setSpacing(4)
 
 
-        # Job info
-        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 = QLabel(i18n.tr("no_job"))
         self._job_label.setFont(QFont("Courier New", 8))
         self._job_label.setFont(QFont("Courier New", 8))
         self._job_label.setWordWrap(True)
         self._job_label.setWordWrap(True)
         self._job_label.setStyleSheet("color: #666688;")
         self._job_label.setStyleSheet("color: #666688;")
-        job_lay.addWidget(self._job_label)
-        lay.addWidget(self._job_grp)
-
-        # Control buttons
-        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(i18n.tr("btn_run_all"))
+        lay.addWidget(self._job_label)
+
+        # Progress bar: shows X / N steps
+        self._job_progress = QProgressBar()
+        self._job_progress.setRange(0, 1)
+        self._job_progress.setValue(0)
+        self._job_progress.setTextVisible(True)
+        self._job_progress.setFormat("%v / %m")
+        self._job_progress.setFixedHeight(14)
+        self._job_progress.setVisible(False)
+        lay.addWidget(self._job_progress)
+
+        # Job overall status text (running / done / failed)
+        self._job_status_label = QLabel()
+        self._job_status_label.setFont(QFont("Courier New", 8))
+        self._job_status_label.setVisible(False)
+        lay.addWidget(self._job_status_label)
+
+        # Run All — primary execution button
+        self._btn_run_all = TrPushButton("btn_run_all")
         self._btn_run_all.setMinimumHeight(30)
         self._btn_run_all.setMinimumHeight(30)
         self._btn_run_all.setEnabled(False)
         self._btn_run_all.setEnabled(False)
         self._btn_run_all.clicked.connect(self._on_run_all)
         self._btn_run_all.clicked.connect(self._on_run_all)
         lay.addWidget(self._btn_run_all)
         lay.addWidget(self._btn_run_all)
 
 
-        self._btn_next = QPushButton(i18n.tr("btn_next_step"))
-        self._btn_next.setMinimumHeight(30)
+        # Next Step + Abort — secondary controls side by side
+        secondary = QHBoxLayout()
+        secondary.setSpacing(4)
+        self._btn_next = TrPushButton("btn_next_step")
+        self._btn_next.setMinimumHeight(26)
         self._btn_next.setEnabled(False)
         self._btn_next.setEnabled(False)
         self._btn_next.clicked.connect(self._on_next_step)
         self._btn_next.clicked.connect(self._on_next_step)
-        lay.addWidget(self._btn_next)
+        secondary.addWidget(self._btn_next)
 
 
-        self._btn_abort = QPushButton(i18n.tr("btn_abort"))
-        self._btn_abort.setMinimumHeight(30)
+        self._btn_abort = TrPushButton("btn_abort")
+        self._btn_abort.setMinimumHeight(26)
         self._btn_abort.setEnabled(False)
         self._btn_abort.setEnabled(False)
         self._btn_abort.clicked.connect(self._on_abort)
         self._btn_abort.clicked.connect(self._on_abort)
-        lay.addWidget(self._btn_abort)
+        secondary.addWidget(self._btn_abort)
+        lay.addLayout(secondary)
 
 
-        lay.addStretch()
+        return grp
 
 
-        scroll = QScrollArea()
-        scroll.setWidget(container)
-        scroll.setWidgetResizable(True)
-        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
-        return scroll
+    def _build_seq_info_group(self) -> QWidget:
+        grp = TrGroupBox("grp_seq_info")
+        lay = QVBoxLayout(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))
+        lay.addWidget(self._seq_info_label)
+        return grp
+
+    # -- Right panel -------------------------------------------------------
 
 
     def _build_right_panel(self) -> QWidget:
     def _build_right_panel(self) -> QWidget:
         panel = QWidget()
         panel = QWidget()
@@ -404,9 +698,11 @@ class ScannerTab(QWidget):
         self._steps_table.setHorizontalHeaderLabels([
         self._steps_table.setHorizontalHeaderLabels([
             i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
             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)
+        bind_table_headers(self._steps_table, ["col_step", "col_status", "col_result"])
+        hdr = self._steps_table.horizontalHeader()
+        hdr.setSectionResizeMode(0, QHeaderView.ResizeToContents)
+        hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents)
+        hdr.setSectionResizeMode(2, QHeaderView.Stretch)
         self._steps_table.verticalHeader().setVisible(False)
         self._steps_table.verticalHeader().setVisible(False)
         self._steps_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
         self._steps_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
         self._steps_table.setSelectionBehavior(QAbstractItemView.SelectRows)
         self._steps_table.setSelectionBehavior(QAbstractItemView.SelectRows)
@@ -414,26 +710,35 @@ class ScannerTab(QWidget):
         self._steps_table.currentCellChanged.connect(
         self._steps_table.currentCellChanged.connect(
             lambda row, *_: self._on_step_selected(row)
             lambda row, *_: self._on_step_selected(row)
         )
         )
-        lay.addWidget(self._steps_table, stretch=1)
+        lay.addWidget(self._steps_table, stretch=2)
 
 
-        # Bottom tabs
+        # Detail tabs
         bottom_tabs = QTabWidget()
         bottom_tabs = QTabWidget()
+        self._bottom_tabs = bottom_tabs
 
 
         self._step_result_view = QTextEdit()
         self._step_result_view = QTextEdit()
         self._step_result_view.setReadOnly(True)
         self._step_result_view.setReadOnly(True)
         self._step_result_view.setFont(QFont("Courier New", 9))
         self._step_result_view.setFont(QFont("Courier New", 9))
-        self._bottom_tabs = bottom_tabs
         bottom_tabs.addTab(self._step_result_view, i18n.tr("tab_step_result"))
         bottom_tabs.addTab(self._step_result_view, i18n.tr("tab_step_result"))
+        bind_tab_text(bottom_tabs, 0, "tab_step_result")
+
+        self._step_params_view = QTextEdit()
+        self._step_params_view.setReadOnly(True)
+        self._step_params_view.setFont(QFont("Courier New", 9))
+        bottom_tabs.addTab(self._step_params_view, i18n.tr("tab_step_params"))
+        bind_tab_text(bottom_tabs, 1, "tab_step_params")
 
 
         self._seq_info_view = QTextEdit()
         self._seq_info_view = QTextEdit()
         self._seq_info_view.setReadOnly(True)
         self._seq_info_view.setReadOnly(True)
         self._seq_info_view.setFont(QFont("Courier New", 9))
         self._seq_info_view.setFont(QFont("Courier New", 9))
         bottom_tabs.addTab(self._seq_info_view, i18n.tr("tab_seq_info_view"))
         bottom_tabs.addTab(self._seq_info_view, i18n.tr("tab_seq_info_view"))
+        bind_tab_text(bottom_tabs, 2, "tab_seq_info_view")
 
 
         self._log_view = QTextEdit()
         self._log_view = QTextEdit()
         self._log_view.setReadOnly(True)
         self._log_view.setReadOnly(True)
         self._log_view.setFont(QFont("Courier New", 9))
         self._log_view.setFont(QFont("Courier New", 9))
         bottom_tabs.addTab(self._log_view, i18n.tr("tab_log"))
         bottom_tabs.addTab(self._log_view, i18n.tr("tab_log"))
+        bind_tab_text(bottom_tabs, 3, "tab_log")
 
 
         lay.addWidget(bottom_tabs, stretch=1)
         lay.addWidget(bottom_tabs, stretch=1)
         return panel
         return panel
@@ -454,7 +759,7 @@ class ScannerTab(QWidget):
         worker.finished.connect(self._on_healthcheck_done)
         worker.finished.connect(self._on_healthcheck_done)
         worker.error.connect(self._on_healthcheck_error)
         worker.error.connect(self._on_healthcheck_error)
         worker.start()
         worker.start()
-        self._hc_worker = worker  # keep alive
+        self._hc_worker = worker
 
 
     def _on_healthcheck_done(self, ok: object) -> None:
     def _on_healthcheck_done(self, ok: object) -> None:
         self._btn_connect.setEnabled(True)
         self._btn_connect.setEnabled(True)
@@ -463,7 +768,7 @@ class ScannerTab(QWidget):
             self._conn_state = "online"
             self._conn_state = "online"
             self._status_label.setText(i18n.tr("online"))
             self._status_label.setText(i18n.tr("online"))
             self._status_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
             self._status_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
-            self._append_log(f"Connected to orchestrator: {self._client.base_url}")
+            self._append_log(f"Connected: {self._client.base_url}")
             self._on_refresh_scenarios()
             self._on_refresh_scenarios()
         else:
         else:
             self._conn_state = "offline"
             self._conn_state = "offline"
@@ -491,15 +796,37 @@ class ScannerTab(QWidget):
         self._list_worker = worker
         self._list_worker = worker
 
 
     def _on_scenarios_loaded(self, scenarios: object) -> None:
     def _on_scenarios_loaded(self, scenarios: object) -> None:
+        items = list(scenarios or [])
         self._scenario_combo.clear()
         self._scenario_combo.clear()
-        for s in (scenarios or []):
-            self._scenario_combo.addItem(s)
-        self._append_log(f"Scenarios: {list(scenarios or [])}")
+        self._scenario_combo.addItems(items)
+        self._append_log(f"Scenarios: {items}")
 
 
     # ================================================================== #
     # ================================================================== #
     #  Job control                                                         #
     #  Job control                                                         #
     # ================================================================== #
     # ================================================================== #
 
 
+    def _on_build_scenario(self) -> None:
+        """Open the scenario builder dialog; on accept, submit as a custom job."""
+        # Fetch server-side tasks in background so the dialog can show extras
+        extra: list[str] = []
+        try:
+            extra = self._client.get_task_registry()
+        except Exception:
+            pass  # offline or old server — builder still works with built-ins
+
+        dlg = ScenarioBuilderDialog(extra_tasks=extra, parent=self)
+        if dlg.exec() != QDialog.Accepted:
+            return
+
+        steps = dlg.get_steps()
+        self._append_log(f"Building custom scenario: {[s['name'] for s in steps]}")
+
+        worker = OrchestratorWorker(self._client.create_scenario, steps)
+        worker.finished.connect(self._on_scenario_loaded)
+        worker.error.connect(lambda msg: self._append_log(f"Build error: {msg}"))
+        worker.start()
+        self._load_worker = worker
+
     def _on_load_scenario(self) -> None:
     def _on_load_scenario(self) -> None:
         scenario_id = self._scenario_combo.currentText()
         scenario_id = self._scenario_combo.currentText()
         if not scenario_id:
         if not scenario_id:
@@ -521,13 +848,13 @@ class ScannerTab(QWidget):
 
 
     def _on_scenario_loaded(self, job_id: object) -> None:
     def _on_scenario_loaded(self, job_id: object) -> None:
         self._job_id = str(job_id)
         self._job_id = str(job_id)
-        self._job_label.setText(self._job_id[:24] + "..." if len(self._job_id) > 24 else self._job_id)
+        self._job_label.setText(self._short_id(self._job_id))
         self._append_log(f"Job created: {self._job_id}")
         self._append_log(f"Job created: {self._job_id}")
         self._btn_run_all.setEnabled(True)
         self._btn_run_all.setEnabled(True)
         self._btn_next.setEnabled(True)
         self._btn_next.setEnabled(True)
         self._btn_abort.setEnabled(False)
         self._btn_abort.setEnabled(False)
         self._steps_table.setRowCount(0)
         self._steps_table.setRowCount(0)
-        # Fetch initial step list
+        self._show_job_progress(True)
         self._fetch_status_once()
         self._fetch_status_once()
 
 
     def _on_run_all(self) -> None:
     def _on_run_all(self) -> None:
@@ -542,7 +869,6 @@ class ScannerTab(QWidget):
         self._run_worker.finished.connect(self._on_run_all_done)
         self._run_worker.finished.connect(self._on_run_all_done)
         self._run_worker.error.connect(self._on_worker_error)
         self._run_worker.error.connect(self._on_worker_error)
         self._run_worker.start()
         self._run_worker.start()
-
         self._poll_timer.start()
         self._poll_timer.start()
 
 
     def _on_run_all_done(self, result: object) -> None:
     def _on_run_all_done(self, result: object) -> None:
@@ -596,10 +922,10 @@ class ScannerTab(QWidget):
 
 
     def _poll_status(self) -> None:
     def _poll_status(self) -> None:
         if self._poll_worker and self._poll_worker.isRunning():
         if self._poll_worker and self._poll_worker.isRunning():
-            return  # previous poll still in flight
+            return
         self._poll_worker = OrchestratorWorker(self._client.get_status, self._job_id)
         self._poll_worker = OrchestratorWorker(self._client.get_status, self._job_id)
         self._poll_worker.finished.connect(self._on_status_received)
         self._poll_worker.finished.connect(self._on_status_received)
-        self._poll_worker.error.connect(lambda msg: None)  # silently ignore poll errors
+        self._poll_worker.error.connect(lambda msg: None)
         self._poll_worker.start()
         self._poll_worker.start()
 
 
     def _on_status_received(self, status: object) -> None:
     def _on_status_received(self, status: object) -> None:
@@ -607,8 +933,8 @@ class ScannerTab(QWidget):
             return
             return
         steps = status.get("steps", [])
         steps = status.get("steps", [])
         self._update_steps_table(steps)
         self._update_steps_table(steps)
+        self._update_job_status_display(status.get("status", ""), steps)
 
 
-        # Stop polling when the orchestrator marks the job as done or failed
         job_status = status.get("status", "")
         job_status = status.get("status", "")
         if job_status == "done" or job_status.startswith("failed"):
         if job_status == "done" or job_status.startswith("failed"):
             self._poll_timer.stop()
             self._poll_timer.stop()
@@ -618,7 +944,6 @@ class ScannerTab(QWidget):
                 self._emit_result_if_found(steps)
                 self._emit_result_if_found(steps)
 
 
     def _emit_result_if_found(self, steps: list) -> None:
     def _emit_result_if_found(self, steps: list) -> None:
-        """Scan step results for a JSON output path and emit scan_result_ready."""
         for step in steps:
         for step in steps:
             result = step.get("result") or {}
             result = step.get("result") or {}
             if not isinstance(result, dict):
             if not isinstance(result, dict):
@@ -637,12 +962,12 @@ class ScannerTab(QWidget):
         current_row = self._steps_table.currentRow()
         current_row = self._steps_table.currentRow()
         self._steps_table.setRowCount(len(steps))
         self._steps_table.setRowCount(len(steps))
         for row, step in enumerate(steps):
         for row, step in enumerate(steps):
-            name = step.get("name", "")
+            name   = step.get("name", "")
             status = step.get("status", "pending").lower()
             status = step.get("status", "pending").lower()
             result = step.get("result", "")
             result = step.get("result", "")
-            result_str = json.dumps(result, default=str)[:80] if result else ""
+            result_str = json.dumps(result, default=str)[:120] if result else ""
 
 
-            name_item = QTableWidgetItem(name)
+            name_item   = QTableWidgetItem(name)
             status_item = QTableWidgetItem(status)
             status_item = QTableWidgetItem(status)
             result_item = QTableWidgetItem(result_str)
             result_item = QTableWidgetItem(result_str)
 
 
@@ -657,18 +982,44 @@ class ScannerTab(QWidget):
         if current_row >= 0:
         if current_row >= 0:
             self._steps_table.setCurrentCell(current_row, 0)
             self._steps_table.setCurrentCell(current_row, 0)
 
 
-        # Store full step data for detail view
         self._steps_data = steps
         self._steps_data = steps
 
 
     def _on_step_selected(self, row: int) -> None:
     def _on_step_selected(self, row: int) -> None:
-        if not hasattr(self, "_steps_data") or row < 0 or row >= len(self._steps_data):
+        if row < 0 or row >= len(self._steps_data):
             return
             return
         step = self._steps_data[row]
         step = self._steps_data[row]
+
         result = step.get("result", None)
         result = step.get("result", None)
         self._step_result_view.setPlainText(
         self._step_result_view.setPlainText(
             json.dumps(result, indent=2, default=str) if result is not None else ""
             json.dumps(result, indent=2, default=str) if result is not None else ""
         )
         )
 
 
+        params = step.get("params", None)
+        self._step_params_view.setPlainText(
+            json.dumps(params, indent=2, default=str) if params else ""
+        )
+
+    # ================================================================== #
+    #  Job progress display                                                #
+    # ================================================================== #
+
+    def _show_job_progress(self, visible: bool) -> None:
+        self._job_progress.setVisible(visible)
+        self._job_status_label.setVisible(visible)
+
+    def _update_job_status_display(self, job_status: str, steps: list) -> None:
+        """Update the progress bar and status label in the job group."""
+        total = len(steps)
+        done  = sum(1 for s in steps if s.get("status", "").lower() == "done")
+
+        if total > 0:
+            self._job_progress.setMaximum(total)
+            self._job_progress.setValue(done)
+
+        color = _STATUS_COLORS.get(job_status.split(":")[0].strip(), "#9e9e9e")
+        self._job_status_label.setText(job_status)
+        self._job_status_label.setStyleSheet(f"color: {color};")
+
     # ================================================================== #
     # ================================================================== #
     #  Log                                                                 #
     #  Log                                                                 #
     # ================================================================== #
     # ================================================================== #
@@ -676,7 +1027,14 @@ class ScannerTab(QWidget):
     def _append_log(self, msg: str) -> None:
     def _append_log(self, msg: str) -> None:
         self._log_view.append(msg)
         self._log_view.append(msg)
         if self._seq_info is not None:
         if self._seq_info is not None:
-            # Also refresh seq info view with latest raw dict
             self._seq_info_view.setPlainText(
             self._seq_info_view.setPlainText(
                 json.dumps(self._seq_info, indent=2, default=str)
                 json.dumps(self._seq_info, indent=2, default=str)
             )
             )
+
+    # ================================================================== #
+    #  Helpers                                                             #
+    # ================================================================== #
+
+    @staticmethod
+    def _short_id(job_id: str, n: int = 20) -> str:
+        return job_id[:n] + "..." if len(job_id) > n else job_id

+ 22 - 24
apps/gui/src/tabs/scanning_tab.py

@@ -34,6 +34,7 @@ except ImportError:
     _HAS_HTTPX = False
     _HAS_HTTPX = False
 
 
 from src import i18n, theme
 from src import i18n, theme
+from src.gui.tr_widgets import TrGroupBox, TrLabel, TrPushButton, bind_tab_text
 
 
 # -- colour palette -------------------------------------------------------------
 # -- colour palette -------------------------------------------------------------
 _BG_DARK      = "#1a1a2e"
 _BG_DARK      = "#1a1a2e"
@@ -326,7 +327,7 @@ class ProtocolListWidget(QWidget):
         lay.setContentsMargins(4, 8, 4, 4)
         lay.setContentsMargins(4, 8, 4, 4)
         lay.setSpacing(4)
         lay.setSpacing(4)
 
 
-        self._header_lbl = QLabel(i18n.tr("protocols_header"))
+        self._header_lbl = TrLabel("protocols_header")
         self._header_lbl.setStyleSheet(
         self._header_lbl.setStyleSheet(
             "color: #aaaaaa; font-weight: bold; font-size: 11px; background: transparent;"
             "color: #aaaaaa; font-weight: bold; font-size: 11px; background: transparent;"
         )
         )
@@ -359,8 +360,7 @@ class ProtocolListWidget(QWidget):
         self._list.currentTextChanged.connect(self.protocol_selected)
         self._list.currentTextChanged.connect(self.protocol_selected)
         lay.addWidget(self._list, stretch=1)
         lay.addWidget(self._list, stretch=1)
 
 
-    def retranslate_ui(self) -> None:
-        self._header_lbl.setText(i18n.tr("protocols_header"))
+    # retranslate_ui not needed — _header_lbl uses TrLabel.
 
 
 
 
 # ==============================================================================
 # ==============================================================================
@@ -592,15 +592,10 @@ class ScanningTab(QWidget):
         self._orchestrator_url = url
         self._orchestrator_url = url
 
 
     def retranslate_ui(self) -> None:
     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
+        # Groups, tab texts, btn_clear_log auto-translate via Tr* classes and bind_tab_text.
+        # Only state-dependent strings need manual handling:
+        if not self._seq_file_path:
+            self._lbl_seq_file.setText(i18n.tr("no_file_selected"))
         if self._scan_timer.isActive():
         if self._scan_timer.isActive():
             self._btn_scan.setText(i18n.tr("btn_stop_scan"))
             self._btn_scan.setText(i18n.tr("btn_stop_scan"))
         else:
         else:
@@ -773,7 +768,7 @@ class ScanningTab(QWidget):
         btn_load.clicked.connect(self._on_load_seq_clicked)
         btn_load.clicked.connect(self._on_load_seq_clicked)
         lay.addWidget(btn_load)
         lay.addWidget(btn_load)
 
 
-        self._lbl_seq_file = QLabel("Файл не выбран")
+        self._lbl_seq_file = QLabel(i18n.tr("no_file_selected"))
         self._lbl_seq_file.setWordWrap(True)
         self._lbl_seq_file.setWordWrap(True)
         self._lbl_seq_file.setStyleSheet(
         self._lbl_seq_file.setStyleSheet(
             "color: #555577; font-size: 10px; background: transparent;"
             "color: #555577; font-size: 10px; background: transparent;"
@@ -827,17 +822,20 @@ class ScanningTab(QWidget):
         """)
         """)
 
 
         _placeholder_keys = ["tab_main", "tab_contrast", "tab_resolution", "tab_system"]
         _placeholder_keys = ["tab_main", "tab_contrast", "tab_resolution", "tab_system"]
-        for key in _placeholder_keys:
+        for idx, key in enumerate(_placeholder_keys):
             w = QLabel(f"[ {i18n.tr(key)} — TODO ]")
             w = QLabel(f"[ {i18n.tr(key)} — TODO ]")
             w.setAlignment(Qt.AlignCenter)
             w.setAlignment(Qt.AlignCenter)
             w.setStyleSheet("color: #555577; background: #16162a;")
             w.setStyleSheet("color: #555577; background: #16162a;")
             self._param_tabs.addTab(w, i18n.tr(key))
             self._param_tabs.addTab(w, i18n.tr(key))
+            bind_tab_text(self._param_tabs, idx, key)
 
 
         geo_tab = self._build_geometry_tab()
         geo_tab = self._build_geometry_tab()
         self._param_tabs.addTab(geo_tab, i18n.tr("tab_geometry"))
         self._param_tabs.addTab(geo_tab, i18n.tr("tab_geometry"))
+        bind_tab_text(self._param_tabs, 4, "tab_geometry")
 
 
         log_tab = self._build_log_tab()
         log_tab = self._build_log_tab()
-        self._param_tabs.addTab(log_tab, "Лог")
+        self._param_tabs.addTab(log_tab, i18n.tr("tab_log"))
+        bind_tab_text(self._param_tabs, 5, "tab_log")
 
 
         self._param_tabs.setCurrentWidget(geo_tab)
         self._param_tabs.setCurrentWidget(geo_tab)
 
 
@@ -899,7 +897,7 @@ class ScanningTab(QWidget):
         lay.setSpacing(16)
         lay.setSpacing(16)
 
 
         # -- orientation presets --------------------------------------------
         # -- orientation presets --------------------------------------------
-        self._preset_group = QGroupBox(i18n.tr("grp_orientation"))
+        self._preset_group = TrGroupBox("grp_orientation")
         preset_group = self._preset_group
         preset_group = self._preset_group
         preset_group.setStyleSheet(self._group_style())
         preset_group.setStyleSheet(self._group_style())
         preset_lay = QVBoxLayout(preset_group)
         preset_lay = QVBoxLayout(preset_group)
@@ -920,7 +918,7 @@ class ScanningTab(QWidget):
         lay.addWidget(preset_group)
         lay.addWidget(preset_group)
 
 
         # -- rotation angles ------------------------------------------------
         # -- rotation angles ------------------------------------------------
-        self._rot_group = QGroupBox(i18n.tr("grp_rotation"))
+        self._rot_group = TrGroupBox("grp_rotation")
         rot_group = self._rot_group
         rot_group = self._rot_group
         rot_group.setStyleSheet(self._group_style())
         rot_group.setStyleSheet(self._group_style())
         form = QFormLayout(rot_group)
         form = QFormLayout(rot_group)
@@ -955,7 +953,7 @@ class ScanningTab(QWidget):
         lay.addWidget(rot_group)
         lay.addWidget(rot_group)
 
 
         # -- rotation matrix display ----------------------------------------
         # -- rotation matrix display ----------------------------------------
-        self._matrix_group = QGroupBox(i18n.tr("grp_rot_matrix"))
+        self._matrix_group = TrGroupBox("grp_rot_matrix")
         matrix_group = self._matrix_group
         matrix_group = self._matrix_group
         matrix_group.setStyleSheet(self._group_style())
         matrix_group.setStyleSheet(self._group_style())
         matrix_lay = QVBoxLayout(matrix_group)
         matrix_lay = QVBoxLayout(matrix_group)
@@ -989,9 +987,9 @@ class ScanningTab(QWidget):
         )
         )
         lay.addWidget(self._log_view, stretch=1)
         lay.addWidget(self._log_view, stretch=1)
 
 
-        btn_clear = QPushButton("Очистить")
-        btn_clear.setFixedWidth(90)
-        btn_clear.setStyleSheet(
+        self._btn_clear_log = TrPushButton("btn_clear")
+        self._btn_clear_log.setMinimumWidth(80)
+        self._btn_clear_log.setStyleSheet(
             "QPushButton {"
             "QPushButton {"
             f"  background: {_BTN_BG}; color: #777799;"
             f"  background: {_BTN_BG}; color: #777799;"
             "  border: 1px solid #333355; border-radius: 3px;"
             "  border: 1px solid #333355; border-radius: 3px;"
@@ -999,8 +997,8 @@ class ScanningTab(QWidget):
             "}"
             "}"
             "QPushButton:hover { color: #aaaacc; }"
             "QPushButton:hover { color: #aaaacc; }"
         )
         )
-        btn_clear.clicked.connect(self._log_view.clear)
-        lay.addWidget(btn_clear, alignment=Qt.AlignRight)
+        self._btn_clear_log.clicked.connect(self._log_view.clear)
+        lay.addWidget(self._btn_clear_log, alignment=Qt.AlignRight)
         return w
         return w
 
 
     _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
     _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
@@ -1217,7 +1215,7 @@ class ScanningTab(QWidget):
 
 
     def _on_scan_done(self, msg: str) -> None:
     def _on_scan_done(self, msg: str) -> None:
         self._scan_timer.stop()
         self._scan_timer.stop()
-        self._status_label.setText("Готово")
+        self._status_label.setText(i18n.tr("done_status"))
         self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
         self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
         self._log(msg, "INFO")
         self._log(msg, "INFO")
         for v in self._viewers:
         for v in self._viewers:

+ 20 - 22
apps/gui/src/tabs/seq_interp_tab.py

@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
     QMessageBox, QScrollArea, QSizePolicy, QFileDialog,
     QMessageBox, QScrollArea, QSizePolicy, QFileDialog,
 )
 )
 
 
+from src.gui.tr_widgets import TrGroupBox, TrPushButton
 from src.gui.adapters import (
 from src.gui.adapters import (
     build_block_rows, seq_metadata, validate_timing, find_block_at_time,
     build_block_rows, seq_metadata, validate_timing, find_block_at_time,
 )
 )
@@ -129,18 +130,10 @@ class SeqInterpTab(QWidget):
             self._plots.apply_theme()
             self._plots.apply_theme()
 
 
     def retranslate_ui(self) -> None:
     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"))
+        # All static buttons/groups auto-translate via TrPushButton/TrGroupBox.
+        # Only _btn_blocks toggles between two keys based on state.
         blocks_visible = self._table_container.isVisible()
         blocks_visible = self._table_container.isVisible()
         self._btn_blocks.setText(i18n.tr("blocks_open") if blocks_visible else i18n.tr("blocks_closed"))
         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                                                          #
     #  Button bar                                                          #
@@ -164,8 +157,8 @@ class SeqInterpTab(QWidget):
             f.setFixedWidth(2)
             f.setFixedWidth(2)
             return f
             return f
 
 
-        def btn(label: str, tip: str, slot=None, enabled: bool = True) -> QPushButton:
-            b = QPushButton(label)
+        def btn(key: str, tip: str, slot=None, enabled: bool = True) -> TrPushButton:
+            b = TrPushButton(key)
             b.setToolTip(tip)
             b.setToolTip(tip)
             b.setEnabled(enabled)
             b.setEnabled(enabled)
             b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
             b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
@@ -174,18 +167,23 @@ class SeqInterpTab(QWidget):
             lay.addWidget(b)
             lay.addWidget(b)
             return b
             return b
 
 
-        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)
+        self._btn_load_seq  = btn("btn_load_seq",    "Open Pulseq .seq file",             self._load_seq)
+        self._btn_load_hw   = btn("btn_hw_config",   "Load hardware constraints JSON",     self._load_hw_config)
+        self._btn_out_dir   = btn("btn_out_dir",     "Choose output directory",            self._choose_output_dir)
         lay.addWidget(sep())
         lay.addWidget(sep())
-        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)
+        self._btn_run       = btn("btn_run",         "Run interpretation pipeline",        self._run,    enabled=False)
+        self._btn_export    = btn("btn_export",      "Export all artifacts to output dir", self._export, enabled=False)
         lay.addWidget(sep())
         lay.addWidget(sep())
-        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)
+        self._btn_fit       = btn("btn_fit_all",     "Fit all plots to data",              lambda: self._plots.fit_all())
+        # _btn_blocks toggles text — keep as plain QPushButton, retranslate_ui handles it
+        self._btn_blocks    = QPushButton(i18n.tr("blocks_closed"))
+        self._btn_blocks.setToolTip("Toggle block table open/closed")
+        self._btn_blocks.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+        self._btn_blocks.clicked.connect(self._toggle_table)
+        lay.addWidget(self._btn_blocks)
         lay.addWidget(sep())
         lay.addWidget(sep())
         self._btn_send_scan = btn(
         self._btn_send_scan = btn(
-            i18n.tr("btn_send_scanner"),
+            "btn_send_scanner",
             "Send exported sequence info to Scanner tab",
             "Send exported sequence info to Scanner tab",
             self._send_to_scanner,
             self._send_to_scanner,
             enabled=False,
             enabled=False,
@@ -254,7 +252,7 @@ class SeqInterpTab(QWidget):
         lay.setContentsMargins(4, 4, 4, 4)
         lay.setContentsMargins(4, 4, 4, 4)
         lay.setSpacing(6)
         lay.setSpacing(6)
 
 
-        self._meta_grp = QGroupBox(i18n.tr("grp_seq_metadata"))
+        self._meta_grp = TrGroupBox("grp_seq_metadata")
         meta_form = QFormLayout(self._meta_grp)
         meta_form = QFormLayout(self._meta_grp)
         meta_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         meta_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         self._meta_labels: dict[str, QLabel] = {}
         self._meta_labels: dict[str, QLabel] = {}
@@ -273,7 +271,7 @@ class SeqInterpTab(QWidget):
         self._controls = DelayControlsPanel()
         self._controls = DelayControlsPanel()
         lay.addWidget(self._controls)
         lay.addWidget(self._controls)
 
 
-        self._warn_grp = QGroupBox(i18n.tr("grp_warnings"))
+        self._warn_grp = TrGroupBox("grp_warnings")
         warn_lay = QVBoxLayout(self._warn_grp)
         warn_lay = QVBoxLayout(self._warn_grp)
         self._warn_list = QListWidget()
         self._warn_list = QListWidget()
         self._warn_list.setFont(QFont("Arial", 9))
         self._warn_list.setFont(QFont("Arial", 9))

+ 46 - 43
apps/gui/src/tabs/spectroscopy_tab.py

@@ -31,11 +31,12 @@ from PySide6.QtWidgets import (
     QHBoxLayout, QHeaderView, QLabel, QLineEdit,
     QHBoxLayout, QHeaderView, QLabel, QLineEdit,
     QMessageBox, QProgressBar, QPushButton,
     QMessageBox, QProgressBar, QPushButton,
     QScrollArea, QSizePolicy, QSpinBox,
     QScrollArea, QSizePolicy, QSpinBox,
-    QSplitter, QTableWidget, QTableWidgetItem,
+    QTableWidget, QTableWidgetItem,
     QVBoxLayout, QWidget,
     QVBoxLayout, QWidget,
 )
 )
 
 
 from src.clients.spectroscopy_client import SpectroscopyClient, SpectroscopyError
 from src.clients.spectroscopy_client import SpectroscopyClient, SpectroscopyError
+from src.gui.tr_widgets import TrGroupBox, TrPushButton
 from src.gui.workers import OrchestratorWorker
 from src.gui.workers import OrchestratorWorker
 from src.gui.scheme_panel import system_is_dark
 from src.gui.scheme_panel import system_is_dark
 from src import i18n, theme
 from src import i18n, theme
@@ -185,13 +186,13 @@ class SpectroscopyTab(QWidget):
         lay.setContentsMargins(6, 4, 6, 4)
         lay.setContentsMargins(6, 4, 6, 4)
         lay.setSpacing(6)
         lay.setSpacing(6)
 
 
-        self._btn_load = QPushButton(i18n.tr("btn_load_json"))
+        self._btn_load = TrPushButton("btn_load_json")
         self._btn_load.setToolTip("Load a hardware JSON file and run NMR analysis")
         self._btn_load.setToolTip("Load a hardware JSON file and run NMR analysis")
         self._btn_load.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_load.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_load.clicked.connect(self._load_json)
         self._btn_load.clicked.connect(self._load_json)
         lay.addWidget(self._btn_load)
         lay.addWidget(self._btn_load)
 
 
-        self._btn_analyze = QPushButton(i18n.tr("btn_analyze"))
+        self._btn_analyze = TrPushButton("btn_analyze")
         self._btn_analyze.setToolTip(
         self._btn_analyze.setToolTip(
             "Re-run analysis with the current parameters\n"
             "Re-run analysis with the current parameters\n"
             "(same file, updated settings)"
             "(same file, updated settings)"
@@ -203,7 +204,7 @@ class SpectroscopyTab(QWidget):
 
 
         lay.addWidget(_vsep())
         lay.addWidget(_vsep())
 
 
-        self._btn_batch = QPushButton(i18n.tr("btn_batch"))
+        self._btn_batch = TrPushButton("btn_batch")
         self._btn_batch.setToolTip("Process a whole folder of hardware JSON files")
         self._btn_batch.setToolTip("Process a whole folder of hardware JSON files")
         self._btn_batch.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_batch.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_batch.clicked.connect(self._open_batch_dialog)
         self._btn_batch.clicked.connect(self._open_batch_dialog)
@@ -211,14 +212,14 @@ class SpectroscopyTab(QWidget):
 
 
         lay.addWidget(_vsep())
         lay.addWidget(_vsep())
 
 
-        self._btn_export = QPushButton(i18n.tr("btn_export_sp"))
+        self._btn_export = TrPushButton("btn_export_sp")
         self._btn_export.setToolTip("Save analysis results (.mat / .csv / .npz)")
         self._btn_export.setToolTip("Save analysis results (.mat / .csv / .npz)")
         self._btn_export.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_export.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_export.setEnabled(False)
         self._btn_export.setEnabled(False)
         self._btn_export.clicked.connect(self._export_results)
         self._btn_export.clicked.connect(self._export_results)
         lay.addWidget(self._btn_export)
         lay.addWidget(self._btn_export)
 
 
-        self._btn_clear = QPushButton(i18n.tr("btn_clear"))
+        self._btn_clear = TrPushButton("btn_clear")
         self._btn_clear.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_clear.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_clear.clicked.connect(self._clear_plots)
         self._btn_clear.clicked.connect(self._clear_plots)
         lay.addWidget(self._btn_clear)
         lay.addWidget(self._btn_clear)
@@ -236,14 +237,14 @@ class SpectroscopyTab(QWidget):
 
 
     # -- Main splitter -----------------------------------------------------
     # -- Main splitter -----------------------------------------------------
 
 
-    def _build_main(self) -> QSplitter:
-        split = QSplitter(Qt.Horizontal)
-        split.addWidget(self._build_left_panel())
-        split.addWidget(self._build_plots_panel())
-        split.setChildrenCollapsible(False)
-        split.setHandleWidth(0)
-        split.setSizes([230, 900])
-        return split
+    def _build_main(self) -> QWidget:
+        container = QWidget()
+        lay = QHBoxLayout(container)
+        lay.setContentsMargins(0, 0, 0, 0)
+        lay.setSpacing(2)
+        lay.addWidget(self._build_left_panel())
+        lay.addWidget(self._build_plots_panel(), stretch=1)
+        return container
 
 
     # -- Left panel --------------------------------------------------------
     # -- Left panel --------------------------------------------------------
 
 
@@ -256,7 +257,7 @@ class SpectroscopyTab(QWidget):
         lay.setSpacing(8)
         lay.setSpacing(8)
 
 
         # -- NMR Parameters -----------------------------------------------
         # -- NMR Parameters -----------------------------------------------
-        self._nmr_grp = QGroupBox(i18n.tr("grp_nmr_params"))
+        self._nmr_grp = TrGroupBox("grp_nmr_params")
         nmr_frm = QFormLayout(self._nmr_grp)
         nmr_frm = QFormLayout(self._nmr_grp)
         nmr_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         nmr_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 
 
@@ -322,7 +323,7 @@ class SpectroscopyTab(QWidget):
         lay.addWidget(self._nmr_grp)
         lay.addWidget(self._nmr_grp)
 
 
         # -- Metadata ------------------------------------------------------
         # -- Metadata ------------------------------------------------------
-        self._meta_grp = QGroupBox(i18n.tr("grp_metadata"))
+        self._meta_grp = TrGroupBox("grp_metadata")
         meta_frm = QFormLayout(self._meta_grp)
         meta_frm = QFormLayout(self._meta_grp)
         meta_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         meta_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         self._lbl_n   = QLabel("-")
         self._lbl_n   = QLabel("-")
@@ -342,7 +343,7 @@ class SpectroscopyTab(QWidget):
         lay.addWidget(self._meta_grp)
         lay.addWidget(self._meta_grp)
 
 
         # -- Metrics -------------------------------------------------------
         # -- Metrics -------------------------------------------------------
-        self._met_grp = QGroupBox(i18n.tr("grp_metrics"))
+        self._met_grp = TrGroupBox("grp_metrics")
         met_frm = QFormLayout(self._met_grp)
         met_frm = QFormLayout(self._met_grp)
         met_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         met_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         mono9 = QFont("Courier New", 9)
         mono9 = QFont("Courier New", 9)
@@ -366,7 +367,7 @@ class SpectroscopyTab(QWidget):
 
 
     # -- Plots panel -------------------------------------------------------
     # -- Plots panel -------------------------------------------------------
 
 
-    def _build_plots_panel(self) -> QSplitter:
+    def _build_plots_panel(self) -> QWidget:
         p = theme.palette()
         p = theme.palette()
         pg.setConfigOptions(
         pg.setConfigOptions(
             antialias=True,
             antialias=True,
@@ -378,7 +379,10 @@ class SpectroscopyTab(QWidget):
             "#ffffff50" if _dark else "#00000050", width=1, style=Qt.DashLine
             "#ffffff50" if _dark else "#00000050", width=1, style=Qt.DashLine
         )
         )
 
 
-        vsplit = QSplitter(Qt.Vertical)
+        vsplit = QWidget()
+        vsplit_lay = QVBoxLayout(vsplit)
+        vsplit_lay.setContentsMargins(0, 0, 0, 0)
+        vsplit_lay.setSpacing(2)
 
 
         self._raw_widget = pg.PlotWidget(
         self._raw_widget = pg.PlotWidget(
             viewBox=InteractiveViewBox(x_only=False)
             viewBox=InteractiveViewBox(x_only=False)
@@ -496,15 +500,15 @@ class SpectroscopyTab(QWidget):
             "spectrum": self._sp_widget,
             "spectrum": self._sp_widget,
         }
         }
 
 
-        vsplit.addWidget(self._build_plot_slot(0, "demod"))
-        vsplit.addWidget(self._build_plot_slot(1, "spectrum"))
+        vsplit_lay.addWidget(self._build_plot_slot(0, "demod"),    stretch=1)
+        vsplit_lay.addWidget(self._build_plot_slot(1, "spectrum"), stretch=1)
         self._refresh_plot_slots()
         self._refresh_plot_slots()
-        vsplit.setSizes([400, 400])
         return vsplit
         return vsplit
 
 
     def _build_plot_slot(self, slot_idx: int, default_key: str) -> QWidget:
     def _build_plot_slot(self, slot_idx: int, default_key: str) -> QWidget:
         frame = QFrame()
         frame = QFrame()
         frame.setFrameShape(QFrame.StyledPanel)
         frame.setFrameShape(QFrame.StyledPanel)
+        frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored)
         lay = QVBoxLayout(frame)
         lay = QVBoxLayout(frame)
         lay.setContentsMargins(4, 4, 4, 4)
         lay.setContentsMargins(4, 4, 4, 4)
         lay.setSpacing(4)
         lay.setSpacing(4)
@@ -544,18 +548,25 @@ class SpectroscopyTab(QWidget):
         self._refresh_plot_slots()
         self._refresh_plot_slots()
 
 
     def _refresh_plot_slots(self) -> None:
     def _refresh_plot_slots(self) -> None:
+        # Step 1: detach and hide every plot widget from all slot hosts.
+        # QLayout.removeWidget() does NOT hide the widget, so we must call
+        # hide() explicitly — otherwise detached widgets stay visible and
+        # overlap the content of whichever slot is rendered on top of them.
+        # We also iterate over all hosts (not just the "current" one) because
+        # during a swap the widget may already have been moved to a different
+        # host by the previous loop iteration, making a single-host removeWidget
+        # a silent no-op.
+        for pw in self._plot_widgets.values():
+            for host in self._plot_hosts:
+                host.removeWidget(pw)
+            pw.hide()
+
+        # Step 2: place and show only the widgets that are actually selected.
         for slot_idx, key in enumerate(self._plot_selection):
         for slot_idx, key in enumerate(self._plot_selection):
-            desired = self._plot_widgets[key]
-            current = self._plot_slot_widgets[slot_idx]
-            if current is desired:
-                continue
-
-            if current is not None:
-                self._plot_hosts[slot_idx].removeWidget(current)
-
-            self._plot_hosts[slot_idx].addWidget(desired)
-            desired.show()
-            self._plot_slot_widgets[slot_idx] = desired
+            pw = self._plot_widgets[key]
+            self._plot_hosts[slot_idx].addWidget(pw)
+            pw.show()
+            self._plot_slot_widgets[slot_idx] = pw
 
 
     # -- Status bar --------------------------------------------------------
     # -- Status bar --------------------------------------------------------
 
 
@@ -571,15 +582,7 @@ class SpectroscopyTab(QWidget):
         lay.addStretch()
         lay.addStretch()
         return bar
         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"))
+    # retranslate_ui is not needed — all widgets use TrPushButton / TrGroupBox.
 
 
     # -- Actions -----------------------------------------------------------
     # -- Actions -----------------------------------------------------------
 
 
@@ -1019,7 +1022,7 @@ class BatchDialog(QDialog):
         # Run row
         # Run row
         run_row = QHBoxLayout()
         run_row = QHBoxLayout()
         self._btn_run = QPushButton(i18n.tr("btn_run_batch"))
         self._btn_run = QPushButton(i18n.tr("btn_run_batch"))
-        self._btn_run.setFixedWidth(100)
+        self._btn_run.setMinimumWidth(110)
         self._btn_run.clicked.connect(self._run_batch)
         self._btn_run.clicked.connect(self._run_batch)
         run_row.addWidget(self._btn_run)
         run_row.addWidget(self._btn_run)
         self._prog = QProgressBar()
         self._prog = QProgressBar()

+ 46 - 0
services/orchestrator/orchestrator/main.py

@@ -20,6 +20,15 @@ class LoadRequest(BaseModel):
 class ModeRequest(BaseModel):
 class ModeRequest(BaseModel):
     mode: str  # "real" or "plug"
     mode: str  # "real" or "plug"
 
 
+
+class StepSpec(BaseModel):
+    name: str
+    params: Optional[Dict[str, Any]] = None
+
+
+class CreateScenarioRequest(BaseModel):
+    steps: list[StepSpec]
+
 # Выбор задач: боевой/заглушки
 # Выбор задач: боевой/заглушки
 mode = os.getenv("MODE", "stub")
 mode = os.getenv("MODE", "stub")
 if mode == "real":
 if mode == "real":
@@ -41,6 +50,43 @@ def health():
     return {"status": "ok"}
     return {"status": "ok"}
 
 
 
 
+@app.get("/tasks")
+def list_tasks():
+    """Return names of all registered task functions."""
+    return {"tasks": list(TASK_REGISTRY.keys())}
+
+
+@app.post("/scenario/create")
+def create_scenario(body: CreateScenarioRequest):
+    """
+    Create a job from an inline step list — no YAML template required.
+
+    Steps are validated against the current TASK_REGISTRY.
+    Returns {"job_id": "..."} immediately, same as /scenario/load/{id}.
+    """
+    for spec in body.steps:
+        if spec.name not in TASK_REGISTRY:
+            raise HTTPException(
+                status_code=400,
+                detail=f"Unknown task: '{spec.name}'. "
+                       f"Available: {sorted(TASK_REGISTRY.keys())}",
+            )
+
+    job_id = str(uuid.uuid4())
+    tpl = {"id": "custom", "steps": [
+        {"name": s.name, "params": s.params or {}} for s in body.steps
+    ]}
+    scenario = build_scenario_from_template(tpl)
+    SCENARIOS[job_id] = scenario
+
+    doc = JobDoc(
+        id=job_id,
+        scenario={"steps": [step_to_dict(s) for s in scenario.steps]},
+    )
+    doc.save()
+    return {"job_id": job_id}
+
+
 @app.get("/mode")
 @app.get("/mode")
 def get_mode_endpoint():
 def get_mode_endpoint():
     """Return the currently active task mode ('real' or 'plug')."""
     """Return the currently active task mode ('real' or 'plug')."""