"""Qt bridge over the headless ScanEngine. Exposes exactly the signal surface the old in-GUI ScanWorker had, so window code connects to it unchanged, and adapts prompts/progress to Qt. The engine itself stays Qt-free and reusable by any other front end. """ from __future__ import annotations import threading import traceback from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot from core.scan_engine import ScanAborted, ScanCallbacks, ScanEngine class QtScanController(QObject): """Runs a ScanEngine on the caller's QThread and republishes its events. Move this to a QThread and connect `started` → `run`, exactly like the previous ScanWorker. """ started = pyqtSignal() completed = pyqtSignal() dc_bias_updated = pyqtSignal(int, object) # row index, per-frame DC means failed = pyqtSignal(str) row_started = pyqtSignal(int, int, int, int) # row, n_rows, angle_idx, n_angles row_done = pyqtSignal(int, int, int, int) status_msg = pyqtSignal(str) user_prompt = pyqtSignal(str, str) # title, message paused_changed = pyqtSignal(bool) # True while paused at a row boundary def __init__(self, stage, scope, rotator, plan, out_path, resume=None, on_scan_active=None, burst_mode=False, strict_rows=False): super().__init__() self._prompt_event = threading.Event() self._on_scan_active = on_scan_active callbacks = ScanCallbacks( on_status=self.status_msg.emit, on_started=self.started.emit, on_row_started=self.row_started.emit, on_row_done=self.row_done.emit, on_dc_bias=self.dc_bias_updated.emit, on_paused_changed=self.paused_changed.emit, prompt=self._blocking_prompt, ) self._engine = ScanEngine(stage, scope, rotator, plan, out_path, resume=resume, callbacks=callbacks, burst_mode=burst_mode, strict_rows=strict_rows) # ── Engine control (called from the GUI thread) ─────────────────────────── def abort(self): self._engine.abort() self._prompt_event.set() # release a scan parked on a prompt def pause(self): self._engine.pause() def resume(self): self._engine.resume() def acknowledge_prompt(self): """Called from the GUI thread when the operator dismisses a prompt.""" self._prompt_event.set() # ── Callback plumbing ───────────────────────────────────────────────────── def _blocking_prompt(self, title: str, message: str): """Ask the GUI thread, then block the scan thread until answered. Polls rather than waiting forever so an abort during a prompt takes effect immediately instead of deadlocking the scan thread. """ self._prompt_event.clear() self.user_prompt.emit(title, message) while not self._prompt_event.wait(0.2): if self._engine.aborted: return @pyqtSlot() def run(self): if self._on_scan_active is not None: self._on_scan_active(True) try: self._engine.run() self.completed.emit() except ScanAborted as exc: self.failed.emit(str(exc)) except Exception as exc: traceback.print_exc() self.failed.emit(str(exc)) finally: if self._on_scan_active is not None: self._on_scan_active(False)