Browse Source

fix button typos and add RU/EN i18n foundation

- Fix duplicate-word typos: "Refresh Refresh", "Run Run All",
  "Next Next Step", "Stop Abort" (scanner_tab), "Run Run"
  (seq_interp_tab), "Run Generate" (fid_tab)
- Add src/i18n.py: two-language translation dict (EN/RU) with
  tr(), set_language(), current_language() helpers
- Wire i18n into controls_panel, scanner_tab, seq_interp_tab,
  fid_tab: all visible button/label/groupbox text now goes through
  tr(); each widget exposes retranslate_ui() for live switching
- Pulse sequence names (FID, SE, TSE), log messages, and file
  extensions are intentionally kept in English at all times

Remaining: scanning_tab (hardcoded Russian), spectroscopy_tab,
app_window language-toggle button – to be done in a follow-up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
spacexerq 1 day ago
parent
commit
d851c69eb9

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

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

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

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

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

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

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

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

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

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