main_window.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. """
  2. Main application window for the MRI sequence interpreter GUI.
  3. Layout
  4. ──────
  5. [Button bar] ← QPushButton row, never clips text
  6. ┌─────────────┬────────────────────────────┬──────────────────────┐
  7. │ Left │ Centre │ Right │
  8. │ ─────────── │ [Sequence scheme] │ Block Details │
  9. │ Metadata │ ───────────────────────── │ Warnings │
  10. │ HW delays │ Waveform plots │ Sync XML │
  11. │ Warnings │ │ POST JSON │
  12. │ │ [Block table — hidden] │ Log │
  13. └─────────────┴────────────────────────────┴──────────────────────┘
  14. Design rules
  15. ────────────
  16. • All heavy work runs in QThread workers — UI thread stays responsive.
  17. • Block table is hidden by default; toggled with the "Blocks" button.
  18. • Log output lives in the right panel (PreviewPanel Log tab).
  19. • Sequence load state is shown as a coloured status indicator in the button bar.
  20. • hw_config.json is never silently overwritten.
  21. """
  22. from __future__ import annotations
  23. import logging
  24. import os
  25. from datetime import datetime
  26. from PySide6.QtCore import Qt, QSize
  27. from PySide6.QtGui import QFont, QColor
  28. from PySide6.QtWidgets import (
  29. QApplication, QMainWindow, QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
  30. QGroupBox, QFormLayout, QLabel, QListWidget, QListWidgetItem,
  31. QFrame, QPushButton, QProgressBar, QStatusBar,
  32. QMessageBox, QScrollArea, QSizePolicy, QFileDialog,
  33. )
  34. from seq_interp.src.gui.adapters import (
  35. build_block_rows, seq_metadata, validate_timing, find_block_at_time,
  36. )
  37. from seq_interp.src.gui.block_table import BlockTable
  38. from seq_interp.src.gui.controls_panel import DelayControlsPanel
  39. from seq_interp.src.gui.plot_panel import PlotPanel
  40. from seq_interp.src.gui.preview_panel import PreviewPanel
  41. from seq_interp.src.gui.scheme_panel import SchemePanel, system_is_dark
  42. from seq_interp.src.gui.workers import (
  43. LoadInterpWorker, SyncOnlyWorker, ExportWorker, XmlPreviewWorker,
  44. )
  45. # ── loading-state style maps (light / dark) ───────────────────────────────────
  46. _STATE_LIGHT: dict[str, tuple[str, str]] = {
  47. "idle": ("#757575", "●"),
  48. "selected": ("#1565c0", "●"),
  49. "loading": ("#e65100", "⟳"),
  50. "loaded": ("#2e7d32", "✓"),
  51. "failed": ("#c62828", "✗"),
  52. }
  53. _STATE_DARK: dict[str, tuple[str, str]] = {
  54. "idle": ("#9e9e9e", "●"),
  55. "selected": ("#64b5f6", "●"),
  56. "loading": ("#ff9800", "⟳"),
  57. "loaded": ("#81c784", "✓"),
  58. "failed": ("#ef9a9a", "✗"),
  59. }
  60. class MainWindow(QMainWindow):
  61. def __init__(self):
  62. super().__init__()
  63. self.setWindowTitle("MRI Sequence Interpreter")
  64. self.setMinimumSize(900, 600)
  65. # Size relative to available screen so we never start off-screen on
  66. # small monitors (e.g. 1366×768 laptops). Cap at 1600×940.
  67. screen = QApplication.primaryScreen()
  68. if screen is not None:
  69. ag = screen.availableGeometry()
  70. w = min(1600, max(900, int(ag.width() * 0.92)))
  71. h = min(940, max(600, int(ag.height() * 0.90)))
  72. self.resize(w, h)
  73. else:
  74. self.resize(1600, 940)
  75. # ── application state ──────────────────────────────────────────
  76. self._seq_path: str | None = None
  77. self._hw_config_path: str | None = None
  78. self._output_dir: str | None = None
  79. self._seq_data: dict | None = None
  80. self._sync_data: dict | None = None
  81. self._hw = None
  82. self._block_rows: list = []
  83. self._worker = None
  84. self._xml_preview_worker = None
  85. self._pending_table_select: int | None = None # remembered when table hidden
  86. # ── build UI ───────────────────────────────────────────────────
  87. central = QWidget()
  88. self.setCentralWidget(central)
  89. root_layout = QVBoxLayout(central)
  90. root_layout.setContentsMargins(0, 0, 0, 0)
  91. root_layout.setSpacing(0)
  92. root_layout.addWidget(self._build_button_bar())
  93. root_layout.addWidget(self._build_seq_status_bar())
  94. root_layout.addWidget(self._build_main_splitter(), stretch=1)
  95. self._build_statusbar()
  96. self._setup_logging()
  97. # ================================================================== #
  98. # Button bar (replaces QToolBar — never clips text) #
  99. # ================================================================== #
  100. def _build_button_bar(self) -> QWidget:
  101. bar = QWidget()
  102. bar.setObjectName("ButtonBar")
  103. bar.setStyleSheet(
  104. "#ButtonBar { background: palette(window); border-bottom: 1px solid palette(mid); }"
  105. )
  106. lay = QHBoxLayout(bar)
  107. lay.setContentsMargins(6, 4, 6, 4)
  108. lay.setSpacing(4)
  109. def sep() -> QFrame:
  110. f = QFrame()
  111. f.setFrameShape(QFrame.VLine)
  112. f.setFrameShadow(QFrame.Sunken)
  113. f.setFixedWidth(2)
  114. return f
  115. def btn(label: str, tip: str, slot=None, enabled: bool = True) -> QPushButton:
  116. b = QPushButton(label)
  117. b.setToolTip(tip)
  118. b.setEnabled(enabled)
  119. b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
  120. if slot:
  121. b.clicked.connect(slot)
  122. lay.addWidget(b)
  123. return b
  124. self._btn_load_seq = btn("📂 Load .seq", "Open Pulseq .seq file", self._load_seq)
  125. self._btn_load_hw = btn("⚙ HW Config", "Load hardware constraints JSON", self._load_hw_config)
  126. self._btn_out_dir = btn("📁 Output Dir", "Choose output directory", self._choose_output_dir)
  127. lay.addWidget(sep())
  128. self._btn_run = btn("▶ Run", "Run interpretation pipeline", self._run, enabled=False)
  129. self._btn_export = btn("💾 Export", "Export all artifacts to output dir", self._export, enabled=False)
  130. lay.addWidget(sep())
  131. self._btn_fit = btn("🔍 Fit All", "Fit all plots to data", lambda: self._plots.fit_all())
  132. self._btn_blocks = btn("📋 Blocks ▼", "Toggle block table open/closed", self._toggle_table)
  133. lay.addStretch()
  134. self._progress = QProgressBar()
  135. self._progress.setRange(0, 0)
  136. self._progress.setFixedWidth(120)
  137. self._progress.setVisible(False)
  138. lay.addWidget(self._progress)
  139. return bar
  140. def _build_seq_status_bar(self) -> QWidget:
  141. bar = QWidget()
  142. bar.setObjectName("SeqStatus")
  143. bar.setStyleSheet(
  144. "#SeqStatus { background: palette(window); border-bottom: 1px solid palette(mid); }"
  145. )
  146. lay = QHBoxLayout(bar)
  147. lay.setContentsMargins(8, 2, 8, 2)
  148. self._seq_status = QLabel(" ● No file selected")
  149. self._seq_status.setFont(QFont("Arial", 9))
  150. self._seq_status.setStyleSheet("color: #9e9e9e;")
  151. lay.addWidget(self._seq_status)
  152. lay.addStretch()
  153. return bar
  154. # ================================================================== #
  155. # Main three-panel splitter #
  156. # ================================================================== #
  157. def _build_main_splitter(self) -> QSplitter:
  158. root = QSplitter(Qt.Horizontal)
  159. root.addWidget(self._build_left_panel())
  160. root.addWidget(self._build_centre_panel())
  161. self._preview = PreviewPanel()
  162. self._preview.setMinimumWidth(260)
  163. self._preview.setMaximumWidth(480)
  164. root.addWidget(self._preview)
  165. root.setSizes([280, 1040, 280])
  166. # Wire signals
  167. self._plots.blockClicked.connect(self._on_block_from_plot)
  168. self._plots.timeHovered.connect(self._on_hover)
  169. self._table.blockSelected.connect(self._on_block_from_table)
  170. self._scheme.blockClicked.connect(self._on_block_from_scheme)
  171. self._controls.rerun.connect(self._rerun)
  172. self._controls.reloadConfig.connect(self._reload_hw_config)
  173. return root
  174. # ── left panel ────────────────────────────────────────────────────────────
  175. def _build_left_panel(self) -> QScrollArea:
  176. left = QWidget()
  177. left.setMinimumWidth(220)
  178. left.setMaximumWidth(360)
  179. lay = QVBoxLayout(left)
  180. lay.setContentsMargins(4, 4, 4, 4)
  181. lay.setSpacing(6)
  182. # Metadata
  183. meta_grp = QGroupBox("Sequence Metadata")
  184. meta_form = QFormLayout(meta_grp)
  185. meta_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
  186. self._meta_labels: dict[str, QLabel] = {}
  187. for key in [
  188. "Total blocks (orig)", "RF blocks", "ADC blocks", "Grad blocks",
  189. "RF raster (µs)", "Grad raster (µs)", "ADC raster (ns)",
  190. "Block raster (µs)", "RF delay (ns)", "TR delay (ns)",
  191. "Start delay (µs)", "Min block dur (ns)", "Gamma (MHz/T)", "RF scale",
  192. ]:
  193. lbl = QLabel("—")
  194. lbl.setFont(QFont("Courier New", 9))
  195. meta_form.addRow(QLabel(f"{key}:"), lbl)
  196. self._meta_labels[key] = lbl
  197. lay.addWidget(meta_grp)
  198. # Delay controls
  199. self._controls = DelayControlsPanel()
  200. lay.addWidget(self._controls)
  201. # Warnings (compact, left-panel copy)
  202. warn_grp = QGroupBox("Warnings")
  203. warn_lay = QVBoxLayout(warn_grp)
  204. self._warn_list = QListWidget()
  205. self._warn_list.setFont(QFont("Arial", 9))
  206. self._warn_list.setMaximumHeight(110)
  207. self._warn_list.setStyleSheet(
  208. "QListWidget { color: palette(text); background: palette(alternateBase); } "
  209. "QListWidget::item { padding: 2px; }"
  210. )
  211. warn_lay.addWidget(self._warn_list)
  212. lay.addWidget(warn_grp)
  213. lay.addStretch()
  214. scroll = QScrollArea()
  215. scroll.setWidget(left)
  216. scroll.setWidgetResizable(True)
  217. scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
  218. return scroll
  219. # ── centre panel ──────────────────────────────────────────────────────────
  220. def _build_centre_panel(self) -> QSplitter:
  221. vsplit = QSplitter(Qt.Vertical)
  222. self._centre_vsplit = vsplit # kept for table-toggle height adjustment
  223. # Scheme (always visible, compact)
  224. self._scheme = SchemePanel()
  225. vsplit.addWidget(self._scheme)
  226. # Plots
  227. self._plots = PlotPanel()
  228. vsplit.addWidget(self._plots)
  229. # Block table (hidden by default)
  230. self._table_container = QWidget()
  231. tc_lay = QVBoxLayout(self._table_container)
  232. tc_lay.setContentsMargins(0, 0, 0, 0)
  233. self._table = BlockTable()
  234. tc_lay.addWidget(self._table)
  235. self._table_container.setVisible(False)
  236. vsplit.addWidget(self._table_container)
  237. vsplit.setSizes([64, 700, 0])
  238. vsplit.setCollapsible(2, True)
  239. return vsplit
  240. # ================================================================== #
  241. # Status bar #
  242. # ================================================================== #
  243. def _build_statusbar(self) -> None:
  244. sb = QStatusBar()
  245. self.setStatusBar(sb)
  246. self._status_lbl = QLabel("Ready")
  247. sb.addWidget(self._status_lbl)
  248. def _setup_logging(self) -> None:
  249. log_dir = os.path.join(
  250. os.path.dirname(os.path.dirname(
  251. os.path.dirname(os.path.abspath(__file__))
  252. )),
  253. "log",
  254. )
  255. os.makedirs(log_dir, exist_ok=True)
  256. ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
  257. handler = logging.FileHandler(
  258. os.path.join(log_dir, f"gui_{ts}.log"), encoding="utf-8"
  259. )
  260. handler.setFormatter(
  261. logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
  262. )
  263. logging.getLogger().addHandler(handler)
  264. logging.getLogger().setLevel(logging.INFO)
  265. # ================================================================== #
  266. # File / directory actions #
  267. # ================================================================== #
  268. def _load_seq(self) -> None:
  269. path, _ = QFileDialog.getOpenFileName(
  270. self, "Open Pulseq sequence", "",
  271. "Pulseq files (*.seq);;All files (*)"
  272. )
  273. if not path:
  274. return
  275. self._seq_path = path
  276. name = os.path.basename(path)
  277. self._set_seq_state("selected", name)
  278. self._btn_run.setEnabled(True)
  279. self._log(f"Sequence selected: {path}")
  280. def _load_hw_config(self) -> None:
  281. path, _ = QFileDialog.getOpenFileName(
  282. self, "Open HW config", "",
  283. "JSON files (*.json);;All files (*)"
  284. )
  285. if not path:
  286. return
  287. self._hw_config_path = path
  288. self._log(f"HW config: {path}")
  289. def _choose_output_dir(self) -> None:
  290. path = QFileDialog.getExistingDirectory(self, "Choose output directory")
  291. if path:
  292. self._output_dir = path
  293. self._log(f"Output dir: {path}")
  294. def _reload_hw_config(self) -> None:
  295. if self._hw and self._hw_config_path:
  296. self._hw.load_from_json(self._hw_config_path)
  297. self._controls.load_from_hw(self._hw)
  298. self._log("HW config reloaded from file")
  299. elif self._hw:
  300. from seq_interp.src.hardware.constraints import HardwareConstraints
  301. self._hw = HardwareConstraints()
  302. self._controls.load_from_hw(self._hw)
  303. self._log("HW config reset to class defaults")
  304. # ================================================================== #
  305. # Table toggle #
  306. # ================================================================== #
  307. def _toggle_table(self) -> None:
  308. visible = not self._table_container.isVisible()
  309. self._table_container.setVisible(visible)
  310. self._btn_blocks.setText("📋 Blocks ▲" if visible else "📋 Blocks ▼")
  311. if visible:
  312. # Splitter stores size 0 for the hidden widget; explicitly give it
  313. # 200 px when revealed by taking from the plots section.
  314. sizes = self._centre_vsplit.sizes()
  315. if sizes[2] < 100:
  316. target = 200
  317. plots_h = max(100, sizes[1] - target)
  318. self._centre_vsplit.setSizes([sizes[0], plots_h, target])
  319. if self._pending_table_select is not None:
  320. self._table.select_by_sync_index(self._pending_table_select)
  321. self._pending_table_select = None
  322. # ================================================================== #
  323. # Run / rerun / export #
  324. # ================================================================== #
  325. def _run(self) -> None:
  326. if not self._seq_path:
  327. return
  328. name = os.path.basename(self._seq_path)
  329. self._set_seq_state("loading", name)
  330. self._start_busy("Loading and interpreting…")
  331. overrides = self._controls.get_overrides() if self._hw else {}
  332. self._worker = LoadInterpWorker(
  333. self._seq_path,
  334. hw_config_path=self._hw_config_path,
  335. hw_overrides=overrides,
  336. )
  337. self._worker.log_msg.connect(self._log)
  338. self._worker.finished.connect(self._on_interp_finished)
  339. self._worker.error.connect(self._on_worker_error)
  340. self._worker.start()
  341. def _rerun(self) -> None:
  342. if self._seq_data is None:
  343. self._run()
  344. return
  345. self._start_busy("Re-synchronizing…")
  346. self._worker = SyncOnlyWorker(
  347. self._seq_data,
  348. hw_config_path=self._hw_config_path,
  349. hw_overrides=self._controls.get_overrides(),
  350. )
  351. self._worker.log_msg.connect(self._log)
  352. self._worker.finished.connect(self._on_sync_finished)
  353. self._worker.error.connect(self._on_worker_error)
  354. self._worker.start()
  355. def _export(self) -> None:
  356. if self._seq_data is None or self._sync_data is None:
  357. return
  358. out = self._output_dir
  359. if not out:
  360. out = QFileDialog.getExistingDirectory(self, "Choose output directory")
  361. if not out:
  362. return
  363. self._output_dir = out
  364. self._start_busy("Exporting artifacts…")
  365. self._worker = ExportWorker(
  366. self._seq_data, self._sync_data, self._hw, out
  367. )
  368. self._worker.log_msg.connect(self._log)
  369. self._worker.finished.connect(self._on_export_finished)
  370. self._worker.error.connect(self._on_worker_error)
  371. self._worker.start()
  372. # ================================================================== #
  373. # Worker callbacks #
  374. # ================================================================== #
  375. def _on_interp_finished(self, seq_data: dict, sync_data: dict, hw) -> None:
  376. self._seq_data = seq_data
  377. self._sync_data = sync_data
  378. self._hw = hw
  379. self._stop_busy()
  380. self._apply_results(seq_data, sync_data, hw)
  381. def _on_sync_finished(self, sync_data: dict, hw) -> None:
  382. self._sync_data = sync_data
  383. self._hw = hw
  384. self._stop_busy()
  385. self._apply_results(self._seq_data, sync_data, hw)
  386. def _on_export_finished(self, output_dir: str, xml_text: str,
  387. post_text: str) -> None:
  388. self._stop_busy()
  389. self._preview.set_xml_text(xml_text)
  390. self._preview.set_post_json_text(post_text)
  391. self._log(f"Export complete → {output_dir}")
  392. QMessageBox.information(
  393. self, "Export complete", f"Artifacts written to:\n{output_dir}"
  394. )
  395. def _on_worker_error(self, msg: str) -> None:
  396. name = os.path.basename(self._seq_path or "")
  397. self._set_seq_state("failed", name, msg[:80])
  398. self._stop_busy()
  399. self._log(f"ERROR: {msg}", error=True)
  400. self._preview.add_error(msg)
  401. QMessageBox.critical(self, "Error", msg)
  402. # ================================================================== #
  403. # Results display #
  404. # ================================================================== #
  405. def _apply_results(self, seq_data: dict, sync_data: dict, hw) -> None:
  406. # Metadata labels
  407. meta = seq_metadata(seq_data, hw)
  408. for key, lbl in self._meta_labels.items():
  409. lbl.setText(str(meta.get(key, "—")))
  410. # Controls — populate once on first load, don't overwrite user edits
  411. if not self._controls._defaults:
  412. self._controls.load_from_hw(hw)
  413. # Warnings
  414. warnings = validate_timing(hw, seq_data, sync_data)
  415. self._refresh_warnings(warnings)
  416. self._preview.set_warnings(warnings)
  417. # Block table and scheme
  418. self._block_rows = build_block_rows(seq_data, sync_data)
  419. self._table.load_rows(self._block_rows)
  420. self._scheme.load_rows(self._block_rows)
  421. # Plots
  422. self._plots.plot_all(seq_data, sync_data)
  423. # XML / POST preview (async, non-blocking)
  424. pw = XmlPreviewWorker(sync_data, hw)
  425. pw.finished.connect(
  426. lambda xml, post: (
  427. self._preview.set_xml_text(xml),
  428. self._preview.set_post_json_text(post),
  429. )
  430. )
  431. pw.error.connect(lambda e: self._log(f"XML preview: {e}", error=True))
  432. pw.start()
  433. self._xml_preview_worker = pw # keep reference alive
  434. # Seq status
  435. blocks = seq_data.get("blocks", [])
  436. total_s = sum(sync_data.get("blocks_duration", []))
  437. parts = [f"{len(blocks)} blocks"]
  438. if any("RF" in b.get("type", []) for b in blocks):
  439. parts.append("RF")
  440. if any(b.get("has_adc") for b in blocks):
  441. parts.append("ADC")
  442. if any("GRAD" in b.get("type", []) for b in blocks):
  443. parts.append("Grad")
  444. if total_s >= 1e-3:
  445. parts.append(f"{total_s * 1e3:.2f} ms")
  446. else:
  447. parts.append(f"{total_s * 1e6:.1f} µs")
  448. name = os.path.basename(self._seq_path or "")
  449. self._set_seq_state("loaded", name, " · ".join(parts))
  450. self._act_enabled(run=True, export=True)
  451. self._status_lbl.setText(
  452. f"{len(blocks)} input blocks → {sync_data['number_of_blocks']} sync blocks"
  453. )
  454. def _refresh_warnings(self, warnings: list[str]) -> None:
  455. self._warn_list.clear()
  456. warn_color = QColor("#ff9800" if system_is_dark() else "#e65100")
  457. for w in warnings:
  458. item = QListWidgetItem(f"⚠ {w}")
  459. item.setForeground(warn_color)
  460. self._warn_list.addItem(item)
  461. # ================================================================== #
  462. # Block selection sync #
  463. # ================================================================== #
  464. def _on_block_from_plot(self, sync_index: int) -> None:
  465. self._select_block(sync_index, source="plot")
  466. def _on_block_from_table(self, sync_index: int) -> None:
  467. self._select_block(sync_index, source="table")
  468. def _on_block_from_scheme(self, sync_index: int) -> None:
  469. self._select_block(sync_index, source="scheme")
  470. def _select_block(self, sync_index: int, source: str) -> None:
  471. row = self._table.row_for_sync_index(sync_index)
  472. self._preview.show_block_details(row)
  473. self._scheme.select_block(sync_index)
  474. if source != "plot":
  475. self._plots.highlight_block(sync_index)
  476. if self._table_container.isVisible():
  477. if source != "table":
  478. self._table.select_by_sync_index(sync_index)
  479. else:
  480. # Remember for when the table is opened
  481. self._pending_table_select = sync_index
  482. # ================================================================== #
  483. # Status / log helpers #
  484. # ================================================================== #
  485. def _set_seq_state(self, state: str, name: str = "",
  486. detail: str = "") -> None:
  487. _state_map = _STATE_DARK if system_is_dark() else _STATE_LIGHT
  488. color, icon = _state_map.get(state, ("#9e9e9e", "●"))
  489. text = f" {icon} {name}" if name else f" {icon} No file selected"
  490. if detail:
  491. text += f" — {detail}"
  492. self._seq_status.setStyleSheet(f"color: {color}; font-weight: bold;")
  493. self._seq_status.setText(text)
  494. def _on_hover(self, t_s: float, channel: str, value: float) -> None:
  495. block = find_block_at_time(t_s, self._block_rows) if self._block_rows else None
  496. blk = f" block #{block.sync_index} [{block.block_type}]" if block else ""
  497. self._status_lbl.setText(
  498. f"t = {t_s * 1e6:.4f} µs {channel} = {value:.4g}{blk}"
  499. )
  500. def _log(self, msg: str, error: bool = False) -> None:
  501. self._preview.append_log(msg, error=error)
  502. if error:
  503. logging.error(msg)
  504. else:
  505. logging.info(msg)
  506. def _start_busy(self, tip: str) -> None:
  507. self._progress.setVisible(True)
  508. self._status_lbl.setText(tip)
  509. self._act_enabled(run=False, export=False)
  510. def _stop_busy(self) -> None:
  511. self._progress.setVisible(False)
  512. def _act_enabled(self, run: bool, export: bool) -> None:
  513. self._btn_run.setEnabled(run and bool(self._seq_path))
  514. self._btn_export.setEnabled(export and self._seq_data is not None)