Преглед изворни кода

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_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:
@@ -185,16 +185,19 @@ class LFMRIWindow(QMainWindow):
         hw_config_path: str | None = None,
         output_dir: str | None = None,
         seq_file: str | None = None,
-        orchestrator_url: str = "http://localhost:1717",
-        seq_interp_url: str = "http://localhost:7475",
-        spectroscopy_url: str = "http://localhost:8002",
+        orchestrator_url:  str = "http://localhost:1717",
+        seq_interp_url:    str = "http://localhost:7475",
+        spectroscopy_url:  str = "http://localhost:8002",
+        reconstructor_url: str = "http://localhost:8081",
+        spectrometer_url:  str = "http://localhost:8000",
     ) -> None:
         super().__init__()
         self.setWindowTitle("LF-MRI System")
         self.setMinimumSize(960, 640)
 
-        self._hw_config_path = hw_config_path
-        self._output_dir = output_dir
+        self._hw_config_path  = hw_config_path
+        self._output_dir      = output_dir
+        self._orchestrator_url = orchestrator_url.rstrip("/")
 
         self._seq_tab = SeqInterpTab(
             hw_config_path=hw_config_path,
@@ -206,6 +209,8 @@ class LFMRIWindow(QMainWindow):
             orchestrator_url=orchestrator_url,
             seq_interp_url=seq_interp_url,
             spectroscopy_url=spectroscopy_url,
+            reconstructor_url=reconstructor_url,
+            spectrometer_url=spectrometer_url,
         )
         self._fid_tab = FidTab(
             hw_config_path=hw_config_path,
@@ -238,7 +243,6 @@ class LFMRIWindow(QMainWindow):
         self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
 
         # Mode selector state
-        self._orchestrator_url = orchestrator_url
         self._current_mode: str = "plug"   # updated by background fetch after startup
         self._mode_bridge = _ModeSignalBridge(self)
 
@@ -329,14 +333,14 @@ class LFMRIWindow(QMainWindow):
         tb.addWidget(self._btn_lang_en)
         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)
         tb.addWidget(self._nav_sep2)
 
         self._theme_btn_group = QButtonGroup(self)
         self._theme_btn_group.setExclusive(True)
-        self._btn_theme_dark  = QPushButton("")
-        self._btn_theme_light = QPushButton("")
+        self._btn_theme_dark  = QPushButton("")
+        self._btn_theme_light = QPushButton("")
         self._btn_theme_dark.setCheckable(True)
         self._btn_theme_light.setCheckable(True)
         self._btn_theme_dark.setChecked(True)  # default dark
@@ -534,16 +538,21 @@ class LFMRIWindow(QMainWindow):
     def _on_mode_fetched(self, mode: str) -> None:
         self._current_mode = mode
         self._update_mode_btn()
+        self._scanner_tab.refresh_scenarios()
 
     def _on_mode_set(self, mode: str) -> None:
         self._current_mode = mode
         self._mode_btn.setEnabled(True)
         self._update_mode_btn()
+        self._scanner_tab.refresh_scenarios()
 
     def _on_mode_error(self, msg: str) -> None:
         self._mode_btn.setEnabled(True)
-        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) ---------------------------------------
 
@@ -573,7 +582,10 @@ class LFMRIWindow(QMainWindow):
             result = client.set_mode(mode)
             self._mode_bridge.mode_set.emit(result)
         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):

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

@@ -7,10 +7,10 @@ import json
 
 from PySide6.QtCore import Signal
 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 (
-    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.setContentsMargins(4, 4, 4, 4)
 
-        self._grp = QGroupBox(i18n.tr("grp_hw_delays"))
+        self._grp = TrGroupBox("grp_hw_delays")
         form = QFormLayout(self._grp)
         form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 
@@ -58,10 +58,10 @@ class DelayControlsPanel(QWidget):
         outer.addWidget(self._grp)
 
         # 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.setSpacing(4)
         grid.addWidget(self.btn_apply,  0, 0)
@@ -80,13 +80,6 @@ class DelayControlsPanel(QWidget):
     # Public API
     # ------------------------------------------------------------------
 
-    def retranslate_ui(self) -> None:
-        self._grp.setTitle(i18n.tr("grp_hw_delays"))
-        self.btn_apply .setText(i18n.tr("btn_apply_rerun"))
-        self.btn_reset .setText(i18n.tr("btn_reset"))
-        self.btn_reload.setText(i18n.tr("btn_reload_config"))
-        self.btn_save  .setText(i18n.tr("btn_save_hw"))
-
     def load_from_hw(self, hw) -> None:
         for attr, (sb, scale) in self._spinboxes.items():
             val = getattr(hw, attr, 0.0)

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

@@ -2,6 +2,21 @@
 from __future__ import annotations
 
 _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]] = {
     # --- nav bar ---
@@ -25,6 +40,7 @@ _T: dict[str, dict[str, str]] = {
     "grp_job":            {"en": "Job",                "ru": "Задание"},
     "no_job":             {"en": "- no job -",         "ru": "- нет задания -"},
     "btn_load_scenario":  {"en": "Load Scenario",      "ru": "Загрузить сценарий"},
+    "btn_build_scenario": {"en": "Build custom…",      "ru": "Собрать вручную…"},
     "btn_run_all":        {"en": "Run All",            "ru": "Запустить всё"},
     "btn_next_step":      {"en": "Next Step",          "ru": "Следующий шаг"},
     "btn_abort":          {"en": "Abort",              "ru": "Прервать"},
@@ -32,19 +48,20 @@ _T: dict[str, dict[str, str]] = {
     "col_status":         {"en": "Status",             "ru": "Статус"},
     "col_result":         {"en": "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_log":            {"en": "Log",                "ru": "Журнал"},
 
     # --- seq_interp_tab ---
-    "btn_load_seq":       {"en": " Load .seq",         "ru": " Загрузить .seq"},
-    "btn_hw_config":      {"en": " HW Config",         "ru": " Конфиг ЖО"},
-    "btn_out_dir":        {"en": " Output Dir",        "ru": " Папка вывода"},
+    "btn_load_seq":       {"en": "Load .seq",          "ru": "Загрузить .seq"},
+    "btn_hw_config":      {"en": "HW Config",          "ru": "Конфиг ЖО"},
+    "btn_out_dir":        {"en": "Output Dir",         "ru": "Папка вывода"},
     "btn_run":            {"en": "Run",                "ru": "Запустить"},
-    "btn_export":         {"en": " Export",            "ru": " Экспорт"},
-    "btn_fit_all":        {"en": " Fit All",           "ru": " Вписать всё"},
-    "blocks_closed":      {"en": " Blocks ▾",          "ru": " Блоки ▾"},
-    "blocks_open":        {"en": " Blocks ▴",          "ru": " Блоки ▴"},
-    "btn_send_scanner":   {"en": " Send to Scanner",   "ru": " На сканнер"},
+    "btn_export":         {"en": "Export",             "ru": "Экспорт"},
+    "btn_fit_all":        {"en": "Fit All",            "ru": "Вписать всё"},
+    "blocks_closed":      {"en": "Blocks ▾",           "ru": "Блоки ▾"},
+    "blocks_open":        {"en": "Blocks ▴",           "ru": "Блоки ▴"},
+    "btn_send_scanner":   {"en": "Send to Scanner",    "ru": "На сканнер"},
     "grp_seq_metadata":   {"en": "Sequence Metadata",  "ru": "Метаданные посл-ти"},
     "grp_warnings":       {"en": "Warnings",           "ru": "Предупреждения"},
     "status_ready":       {"en": "Ready",              "ru": "Готово"},
@@ -52,7 +69,7 @@ _T: dict[str, dict[str, str]] = {
 
     # --- fid_tab ---
     "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"},
     "grp_fid_params":     {"en": "FID Parameters",                 "ru": "Параметры FID"},
     "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",
         "ru": "Нажмите 'Загрузить JSON...' для открытия JSON-файла и запуска анализа ЯМР",
     },
-    "batch_title":        {"en": "Batch NMR Analysis", "ru": "Пакетный анализ ЯМР"},
+    "batch_title":        {"en": "Batch NMR Analysis", "ru": "ЯМР анализ набора данных"},
     "folder_label":       {"en": "Folder:",             "ru": "Папка:"},
     "btn_browse":         {"en": "Browse...",           "ru": "Обзор..."},
     "cb_use_current":     {
         "en": "Use current parameters from the Spectroscopy tab",
         "ru": "Использовать текущие параметры вкладки Spectroscopy",
     },
-    "btn_run_batch":      {"en": "Run Batch",           "ru": "Запустить пакет"},
+    "btn_run_batch":      {"en": "Run Batch",           "ru": "Исследовать набор"},
     "btn_export_csv":     {"en": "Export CSV...",       "ru": "Экспорт CSV..."},
     "btn_close":          {"en": "Close",              "ru": "Закрыть"},
     "batch_status_init":  {
         "en": "Select a folder and click Run Batch.",
-        "ru": "Выберите папку и нажмите Запустить пакет.",
+        "ru": "Выберите папку и нажмите Исследовать набор.",
     },
 
     # --- 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_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": {
         "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": "Ошибка смены режима"},
 
@@ -168,3 +185,8 @@ def set_language(lang: str) -> None:
     global _lang
     if lang in ("en", "ru"):
         _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.tr_widgets import TrGroupBox, TrPushButton
 from src.gui.workers import LoadInterpWorker
 from src import i18n, theme
 
@@ -119,6 +120,12 @@ class FidTab(QWidget):
         if hasattr(self, "_plots"):
             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:
         bar = QWidget()
         bar.setObjectName("FidToolBar")
@@ -130,8 +137,8 @@ class FidTab(QWidget):
         lay.setContentsMargins(6, 4, 6, 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.setEnabled(enabled)
             b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
@@ -147,11 +154,11 @@ class FidTab(QWidget):
             f.setFixedWidth(2)
             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())
-        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._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",
                                      self._load_in_seq_tab, enabled=False)
         lay.addStretch()
@@ -187,7 +194,8 @@ class FidTab(QWidget):
         lay.setSpacing(8)
 
         # -- parameter form --------------------------------------------
-        param_grp = QGroupBox("FID Parameters")
+        self._param_grp = TrGroupBox("grp_fid_params")
+        param_grp = self._param_grp
         form = QFormLayout(param_grp)
         form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 
@@ -234,7 +242,8 @@ class FidTab(QWidget):
         self._update_bwfull()
 
         # -- pulse type ------------------------------------------------
-        pulse_grp = QGroupBox("Pulse type")
+        self._pulse_grp = TrGroupBox("grp_pulse_type")
+        pulse_grp = self._pulse_grp
         pulse_lay = QHBoxLayout(pulse_grp)
         self._rb_block = QRadioButton("block")
         self._rb_sinc  = QRadioButton("sinc")
@@ -248,7 +257,8 @@ class FidTab(QWidget):
 
         # -- output filename -------------------------------------------
         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)
         self._le_filename = QLineEdit("FID_" + datetime.now().strftime("%d%m%y_%H%M"))
         file_lay.addWidget(self._le_filename)
@@ -268,7 +278,7 @@ class FidTab(QWidget):
         bar.setFixedHeight(22)
         lay = QHBoxLayout(bar)
         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))
         lay.addWidget(self._status_lbl)
         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
 
+import copy
 import json
 
 from PySide6.QtCore import Qt, QTimer, Signal
 from PySide6.QtGui import QFont, QColor
 from PySide6.QtWidgets import (
     QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
-    QGroupBox, QLabel, QPushButton, QProgressBar,
+    QLabel, QPushButton, QProgressBar,
     QTextEdit, QScrollArea, QSizePolicy, QTabWidget,
     QComboBox, QLineEdit, QTableWidget, QTableWidgetItem,
     QHeaderView, QAbstractItemView,
+    QDialog, QDialogButtonBox, QListWidget, QListWidgetItem,
+    QFrame, QToolButton, QMessageBox, QSplitter as _QSplitter,
 )
 
 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 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):
     """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__(
         self,
@@ -63,36 +335,40 @@ class ScannerTab(QWidget):
     ) -> None:
         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._spectroscopy_url  = spectroscopy_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._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.setInterval(_POLL_INTERVAL_MS)
         self._poll_timer.timeout.connect(self._poll_status)
 
-        # Service status polling
+        # Service health polling
         self._svc_workers: dict[str, OrchestratorWorker] = {}
         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._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()
 
     # ================================================================== #
@@ -103,147 +379,91 @@ class ScannerTab(QWidget):
         self._hw_config_path = 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:
         """
-        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
-        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._steps_table.setRowCount(0)
         self._btn_run_all.setEnabled(False)
         self._btn_next.setEnabled(False)
         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._poll_timer.start()
 
     def apply_seq_info(self, info_dict: dict) -> None:
         """Receive sequence info from SeqInterpTab after export."""
         self._seq_info = info_dict
-        summary_lines = []
+        lines = []
         if "infostr" in info_dict:
-            summary_lines.append(info_dict["infostr"])
+            lines.append(info_dict["infostr"])
         if "time" in info_dict:
-            summary_lines.append(info_dict["time"])
+            lines.append(info_dict["time"])
         adc = info_dict.get("iadc", {})
         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.")
 
-    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)
 
-        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] = {}
-        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)
             self._svc_labels[key] = lbl
             lay.addWidget(lbl)
-            if i < len(self._svc_defs) - 1:
+            if i < len(svc_defs) - 1:
                 sep = QLabel("  |  ")
-                sep.setStyleSheet("color: #333355; background: transparent;")
+                sep.setStyleSheet("color: #2a2a44; font-size: 10px;")
                 lay.addWidget(sep)
 
         lay.addStretch()
 
         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(
-            "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)
         lay.addWidget(btn_refresh)
 
-        return bar
+        self._svc_strip = strip
+        return strip
+
+    # -- Service health polling ------------------------------------------------
 
     def _poll_all_services(self) -> None:
-        """Kick off background health checks for every service."""
         import httpx
 
         def _check(url: str) -> bool:
@@ -256,19 +476,15 @@ class ScannerTab(QWidget):
         checks: dict[str, str] = {
             "orchestrator":  f"{self._orchestrator_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",
             "reconstructor": f"{self._reconstructor_url}/health",
             "spectroscopy":  f"{self._spectroscopy_url}/health",
         }
 
         for key, url in checks.items():
-            # Skip if a previous check for this service is still running
             existing = self._svc_workers.get(key)
             if existing and existing.isRunning():
                 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.finished.connect(
                 lambda ok, k=key: self._on_svc_checked(k, bool(ok))
@@ -280,118 +496,196 @@ class ScannerTab(QWidget):
             self._svc_workers[key] = worker
 
     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)
         if lbl is None:
             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:
         bar = QWidget()
         lay = QHBoxLayout(bar)
         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.setMaximumWidth(260)
         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)
         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.setRange(0, 0)
         self._conn_progress.setFixedWidth(80)
         self._conn_progress.setVisible(False)
         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()
         return bar
 
+    # -- Left panel --------------------------------------------------------
+
     def _build_left_panel(self) -> QWidget:
         container = QWidget()
         container.setMinimumWidth(200)
-        container.setMaximumWidth(320)
+        container.setMaximumWidth(300)
         lay = QVBoxLayout(container)
         lay.setContentsMargins(4, 4, 4, 4)
         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.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)
-        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.setFont(QFont("Courier New", 8))
         self._job_label.setWordWrap(True)
         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.setEnabled(False)
         self._btn_run_all.clicked.connect(self._on_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.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.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:
         panel = QWidget()
@@ -404,9 +698,11 @@ class ScannerTab(QWidget):
         self._steps_table.setHorizontalHeaderLabels([
             i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
         ])
-        self._steps_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
-        self._steps_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
-        self._steps_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
+        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.setEditTriggers(QAbstractItemView.NoEditTriggers)
         self._steps_table.setSelectionBehavior(QAbstractItemView.SelectRows)
@@ -414,26 +710,35 @@ class ScannerTab(QWidget):
         self._steps_table.currentCellChanged.connect(
             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()
+        self._bottom_tabs = bottom_tabs
 
         self._step_result_view = QTextEdit()
         self._step_result_view.setReadOnly(True)
         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"))
+        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.setReadOnly(True)
         self._seq_info_view.setFont(QFont("Courier New", 9))
         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.setReadOnly(True)
         self._log_view.setFont(QFont("Courier New", 9))
         bottom_tabs.addTab(self._log_view, i18n.tr("tab_log"))
+        bind_tab_text(bottom_tabs, 3, "tab_log")
 
         lay.addWidget(bottom_tabs, stretch=1)
         return panel
@@ -454,7 +759,7 @@ class ScannerTab(QWidget):
         worker.finished.connect(self._on_healthcheck_done)
         worker.error.connect(self._on_healthcheck_error)
         worker.start()
-        self._hc_worker = worker  # keep alive
+        self._hc_worker = worker
 
     def _on_healthcheck_done(self, ok: object) -> None:
         self._btn_connect.setEnabled(True)
@@ -463,7 +768,7 @@ class ScannerTab(QWidget):
             self._conn_state = "online"
             self._status_label.setText(i18n.tr("online"))
             self._status_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
-            self._append_log(f"Connected to orchestrator: {self._client.base_url}")
+            self._append_log(f"Connected: {self._client.base_url}")
             self._on_refresh_scenarios()
         else:
             self._conn_state = "offline"
@@ -491,15 +796,37 @@ class ScannerTab(QWidget):
         self._list_worker = worker
 
     def _on_scenarios_loaded(self, scenarios: object) -> None:
+        items = list(scenarios or [])
         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                                                         #
     # ================================================================== #
 
+    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:
         scenario_id = self._scenario_combo.currentText()
         if not scenario_id:
@@ -521,13 +848,13 @@ class ScannerTab(QWidget):
 
     def _on_scenario_loaded(self, job_id: object) -> None:
         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._btn_run_all.setEnabled(True)
         self._btn_next.setEnabled(True)
         self._btn_abort.setEnabled(False)
         self._steps_table.setRowCount(0)
-        # Fetch initial step list
+        self._show_job_progress(True)
         self._fetch_status_once()
 
     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.error.connect(self._on_worker_error)
         self._run_worker.start()
-
         self._poll_timer.start()
 
     def _on_run_all_done(self, result: object) -> None:
@@ -596,10 +922,10 @@ class ScannerTab(QWidget):
 
     def _poll_status(self) -> None:
         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.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()
 
     def _on_status_received(self, status: object) -> None:
@@ -607,8 +933,8 @@ class ScannerTab(QWidget):
             return
         steps = status.get("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", "")
         if job_status == "done" or job_status.startswith("failed"):
             self._poll_timer.stop()
@@ -618,7 +944,6 @@ class ScannerTab(QWidget):
                 self._emit_result_if_found(steps)
 
     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:
             result = step.get("result") or {}
             if not isinstance(result, dict):
@@ -637,12 +962,12 @@ class ScannerTab(QWidget):
         current_row = self._steps_table.currentRow()
         self._steps_table.setRowCount(len(steps))
         for row, step in enumerate(steps):
-            name = step.get("name", "")
+            name   = step.get("name", "")
             status = step.get("status", "pending").lower()
             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)
             result_item = QTableWidgetItem(result_str)
 
@@ -657,18 +982,44 @@ class ScannerTab(QWidget):
         if current_row >= 0:
             self._steps_table.setCurrentCell(current_row, 0)
 
-        # Store full step data for detail view
         self._steps_data = steps
 
     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
         step = self._steps_data[row]
+
         result = step.get("result", None)
         self._step_result_view.setPlainText(
             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                                                                 #
     # ================================================================== #
@@ -676,7 +1027,14 @@ class ScannerTab(QWidget):
     def _append_log(self, msg: str) -> None:
         self._log_view.append(msg)
         if self._seq_info is not None:
-            # Also refresh seq info view with latest raw dict
             self._seq_info_view.setPlainText(
                 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
 
 from src import i18n, theme
+from src.gui.tr_widgets import TrGroupBox, TrLabel, TrPushButton, bind_tab_text
 
 # -- colour palette -------------------------------------------------------------
 _BG_DARK      = "#1a1a2e"
@@ -326,7 +327,7 @@ class ProtocolListWidget(QWidget):
         lay.setContentsMargins(4, 8, 4, 4)
         lay.setSpacing(4)
 
-        self._header_lbl = QLabel(i18n.tr("protocols_header"))
+        self._header_lbl = TrLabel("protocols_header")
         self._header_lbl.setStyleSheet(
             "color: #aaaaaa; font-weight: bold; font-size: 11px; background: transparent;"
         )
@@ -359,8 +360,7 @@ class ProtocolListWidget(QWidget):
         self._list.currentTextChanged.connect(self.protocol_selected)
         lay.addWidget(self._list, stretch=1)
 
-    def retranslate_ui(self) -> None:
-        self._header_lbl.setText(i18n.tr("protocols_header"))
+    # retranslate_ui not needed — _header_lbl uses TrLabel.
 
 
 # ==============================================================================
@@ -592,15 +592,10 @@ class ScanningTab(QWidget):
         self._orchestrator_url = url
 
     def retranslate_ui(self) -> None:
-        self._protocol_list.retranslate_ui()
-        # param tabs: 0-3 = placeholders, 4 = geometry
-        _keys = ["tab_main", "tab_contrast", "tab_resolution", "tab_system", "tab_geometry"]
-        for idx, key in enumerate(_keys):
-            self._param_tabs.setTabText(idx, i18n.tr(key))
-        self._preset_group.setTitle(i18n.tr("grp_orientation"))
-        self._rot_group.setTitle(i18n.tr("grp_rotation"))
-        self._matrix_group.setTitle(i18n.tr("grp_rot_matrix"))
-        # scan button: keep text in sync with current scan state
+        # 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():
             self._btn_scan.setText(i18n.tr("btn_stop_scan"))
         else:
@@ -773,7 +768,7 @@ class ScanningTab(QWidget):
         btn_load.clicked.connect(self._on_load_seq_clicked)
         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.setStyleSheet(
             "color: #555577; font-size: 10px; background: transparent;"
@@ -827,17 +822,20 @@ class ScanningTab(QWidget):
         """)
 
         _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.setAlignment(Qt.AlignCenter)
             w.setStyleSheet("color: #555577; background: #16162a;")
             self._param_tabs.addTab(w, i18n.tr(key))
+            bind_tab_text(self._param_tabs, idx, key)
 
         geo_tab = self._build_geometry_tab()
         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()
-        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)
 
@@ -899,7 +897,7 @@ class ScanningTab(QWidget):
         lay.setSpacing(16)
 
         # -- orientation presets --------------------------------------------
-        self._preset_group = QGroupBox(i18n.tr("grp_orientation"))
+        self._preset_group = TrGroupBox("grp_orientation")
         preset_group = self._preset_group
         preset_group.setStyleSheet(self._group_style())
         preset_lay = QVBoxLayout(preset_group)
@@ -920,7 +918,7 @@ class ScanningTab(QWidget):
         lay.addWidget(preset_group)
 
         # -- rotation angles ------------------------------------------------
-        self._rot_group = QGroupBox(i18n.tr("grp_rotation"))
+        self._rot_group = TrGroupBox("grp_rotation")
         rot_group = self._rot_group
         rot_group.setStyleSheet(self._group_style())
         form = QFormLayout(rot_group)
@@ -955,7 +953,7 @@ class ScanningTab(QWidget):
         lay.addWidget(rot_group)
 
         # -- 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.setStyleSheet(self._group_style())
         matrix_lay = QVBoxLayout(matrix_group)
@@ -989,9 +987,9 @@ class ScanningTab(QWidget):
         )
         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 {"
             f"  background: {_BTN_BG}; color: #777799;"
             "  border: 1px solid #333355; border-radius: 3px;"
@@ -999,8 +997,8 @@ class ScanningTab(QWidget):
             "}"
             "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
 
     _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
@@ -1217,7 +1215,7 @@ class ScanningTab(QWidget):
 
     def _on_scan_done(self, msg: str) -> None:
         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._log(msg, "INFO")
         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,
 )
 
+from src.gui.tr_widgets import TrGroupBox, TrPushButton
 from src.gui.adapters import (
     build_block_rows, seq_metadata, validate_timing, find_block_at_time,
 )
@@ -129,18 +130,10 @@ class SeqInterpTab(QWidget):
             self._plots.apply_theme()
 
     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()
         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                                                          #
@@ -164,8 +157,8 @@ class SeqInterpTab(QWidget):
             f.setFixedWidth(2)
             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.setEnabled(enabled)
             b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
@@ -174,18 +167,23 @@ class SeqInterpTab(QWidget):
             lay.addWidget(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())
-        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())
-        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())
         self._btn_send_scan = btn(
-            i18n.tr("btn_send_scanner"),
+            "btn_send_scanner",
             "Send exported sequence info to Scanner tab",
             self._send_to_scanner,
             enabled=False,
@@ -254,7 +252,7 @@ class SeqInterpTab(QWidget):
         lay.setContentsMargins(4, 4, 4, 4)
         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.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         self._meta_labels: dict[str, QLabel] = {}
@@ -273,7 +271,7 @@ class SeqInterpTab(QWidget):
         self._controls = DelayControlsPanel()
         lay.addWidget(self._controls)
 
-        self._warn_grp = QGroupBox(i18n.tr("grp_warnings"))
+        self._warn_grp = TrGroupBox("grp_warnings")
         warn_lay = QVBoxLayout(self._warn_grp)
         self._warn_list = QListWidget()
         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,
     QMessageBox, QProgressBar, QPushButton,
     QScrollArea, QSizePolicy, QSpinBox,
-    QSplitter, QTableWidget, QTableWidgetItem,
+    QTableWidget, QTableWidgetItem,
     QVBoxLayout, QWidget,
 )
 
 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.scheme_panel import system_is_dark
 from src import i18n, theme
@@ -185,13 +186,13 @@ class SpectroscopyTab(QWidget):
         lay.setContentsMargins(6, 4, 6, 4)
         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.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_load.clicked.connect(self._load_json)
         lay.addWidget(self._btn_load)
 
-        self._btn_analyze = QPushButton(i18n.tr("btn_analyze"))
+        self._btn_analyze = TrPushButton("btn_analyze")
         self._btn_analyze.setToolTip(
             "Re-run analysis with the current parameters\n"
             "(same file, updated settings)"
@@ -203,7 +204,7 @@ class SpectroscopyTab(QWidget):
 
         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.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_batch.clicked.connect(self._open_batch_dialog)
@@ -211,14 +212,14 @@ class SpectroscopyTab(QWidget):
 
         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.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_export.setEnabled(False)
         self._btn_export.clicked.connect(self._export_results)
         lay.addWidget(self._btn_export)
 
-        self._btn_clear = QPushButton(i18n.tr("btn_clear"))
+        self._btn_clear = TrPushButton("btn_clear")
         self._btn_clear.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
         self._btn_clear.clicked.connect(self._clear_plots)
         lay.addWidget(self._btn_clear)
@@ -236,14 +237,14 @@ class SpectroscopyTab(QWidget):
 
     # -- 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 --------------------------------------------------------
 
@@ -256,7 +257,7 @@ class SpectroscopyTab(QWidget):
         lay.setSpacing(8)
 
         # -- 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.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 
@@ -322,7 +323,7 @@ class SpectroscopyTab(QWidget):
         lay.addWidget(self._nmr_grp)
 
         # -- Metadata ------------------------------------------------------
-        self._meta_grp = QGroupBox(i18n.tr("grp_metadata"))
+        self._meta_grp = TrGroupBox("grp_metadata")
         meta_frm = QFormLayout(self._meta_grp)
         meta_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         self._lbl_n   = QLabel("-")
@@ -342,7 +343,7 @@ class SpectroscopyTab(QWidget):
         lay.addWidget(self._meta_grp)
 
         # -- Metrics -------------------------------------------------------
-        self._met_grp = QGroupBox(i18n.tr("grp_metrics"))
+        self._met_grp = TrGroupBox("grp_metrics")
         met_frm = QFormLayout(self._met_grp)
         met_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
         mono9 = QFont("Courier New", 9)
@@ -366,7 +367,7 @@ class SpectroscopyTab(QWidget):
 
     # -- Plots panel -------------------------------------------------------
 
-    def _build_plots_panel(self) -> QSplitter:
+    def _build_plots_panel(self) -> QWidget:
         p = theme.palette()
         pg.setConfigOptions(
             antialias=True,
@@ -378,7 +379,10 @@ class SpectroscopyTab(QWidget):
             "#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(
             viewBox=InteractiveViewBox(x_only=False)
@@ -496,15 +500,15 @@ class SpectroscopyTab(QWidget):
             "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()
-        vsplit.setSizes([400, 400])
         return vsplit
 
     def _build_plot_slot(self, slot_idx: int, default_key: str) -> QWidget:
         frame = QFrame()
         frame.setFrameShape(QFrame.StyledPanel)
+        frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored)
         lay = QVBoxLayout(frame)
         lay.setContentsMargins(4, 4, 4, 4)
         lay.setSpacing(4)
@@ -544,18 +548,25 @@ class SpectroscopyTab(QWidget):
         self._refresh_plot_slots()
 
     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):
-            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 --------------------------------------------------------
 
@@ -571,15 +582,7 @@ class SpectroscopyTab(QWidget):
         lay.addStretch()
         return bar
 
-    def retranslate_ui(self) -> None:
-        self._btn_load   .setText(i18n.tr("btn_load_json"))
-        self._btn_analyze.setText(i18n.tr("btn_analyze"))
-        self._btn_batch  .setText(i18n.tr("btn_batch"))
-        self._btn_export .setText(i18n.tr("btn_export_sp"))
-        self._btn_clear  .setText(i18n.tr("btn_clear"))
-        self._nmr_grp .setTitle(i18n.tr("grp_nmr_params"))
-        self._meta_grp.setTitle(i18n.tr("grp_metadata"))
-        self._met_grp .setTitle(i18n.tr("grp_metrics"))
+    # retranslate_ui is not needed — all widgets use TrPushButton / TrGroupBox.
 
     # -- Actions -----------------------------------------------------------
 
@@ -1019,7 +1022,7 @@ class BatchDialog(QDialog):
         # Run row
         run_row = QHBoxLayout()
         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)
         run_row.addWidget(self._btn_run)
         self._prog = QProgressBar()

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

@@ -20,6 +20,15 @@ class LoadRequest(BaseModel):
 class ModeRequest(BaseModel):
     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")
 if mode == "real":
@@ -41,6 +50,43 @@ def health():
     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")
 def get_mode_endpoint():
     """Return the currently active task mode ('real' or 'plug')."""