Skip to content

Commit eb255db

Browse files
committed
Refactor camera scan state and loaders
Introduce explicit scan state and tidy worker APIs for camera discovery. - Add CameraScanState enum and use it as single source of truth in CameraConfigDialog (_scan_state). - Replace ad-hoc worker checks with _set_scan_state, _finish_scan and _cleanup_scan_worker to manage UI overlays, progress/cancel controls and stability guarantees. - Add request_scan_cancel() to request interruption and handle canceled/result flows; DetectCamerasWorker now emits result (even empty) and canceled when interrupted. - Simplify QThread usage: remove custom finished signals, add typing annotations and clearer run() signatures in loaders; adjust worker signals (canceled) and small API refinements (request_cancel return types). - Update UI hookup (ui_blocks) to call request_scan_cancel instead of private handler. - Update unit and e2e tests to follow new scan lifecycle (wait for scan_started/scan_finished, helper _run_scan_and_wait, and scan state assertions). Files changed: camera_config_dialog.py, loaders.py, ui_blocks.py and related tests to match the new scan lifecycle and worker behaviour.
1 parent 94a4d04 commit eb255db

File tree

5 files changed

+220
-143
lines changed

5 files changed

+220
-143
lines changed

dlclivegui/gui/camera_config/camera_config_dialog.py

Lines changed: 123 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key
2121
from ...config import CameraSettings, MultiCameraSettings
22-
from .loaders import CameraLoadWorker, CameraProbeWorker, DetectCamerasWorker
22+
from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker
2323
from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap
2424
from .ui_blocks import setup_camera_config_dialog_ui
2525

@@ -64,6 +64,7 @@ def __init__(
6464

6565
# Camera detection worker
6666
self._scan_worker: DetectCamerasWorker | None = None
67+
self._scan_state: CameraScanState = CameraScanState.IDLE
6768

6869
# UI elements for eventFilter (assigned in _setup_ui)
6970
self._settings_scroll: QScrollArea | None = None
@@ -171,7 +172,8 @@ def _on_close_cleanup(self) -> None:
171172
pass
172173
# Keep this short to reduce UI freeze
173174
sw.wait(300)
174-
self._scan_worker = None
175+
self._set_scan_state(CameraScanState.IDLE)
176+
self._cleanup_scan_worker()
175177

176178
# Cancel probe worker
177179
pw = getattr(self, "_probe_worker", None)
@@ -260,7 +262,7 @@ def _connect_signals(self) -> None:
260262
self.cancel_btn.clicked.connect(self.reject)
261263
self.scan_started.connect(lambda _: setattr(self, "_dialog_active", True))
262264
self.scan_finished.connect(lambda: setattr(self, "_dialog_active", False))
263-
self.scan_cancel_btn.clicked.connect(self._on_scan_cancel)
265+
self.scan_cancel_btn.clicked.connect(self.request_scan_cancel)
264266

265267
def _mark_dirty(*_args):
266268
self.apply_settings_btn.setEnabled(True)
@@ -312,29 +314,6 @@ def _update_button_states(self) -> None:
312314
available_row = self.available_cameras_list.currentRow()
313315
self.add_camera_btn.setEnabled(available_row >= 0 and not scan_running)
314316

315-
def _sync_scan_ui(self) -> None:
316-
"""
317-
Sync *scan-related* UI controls based on scan state.
318-
319-
Conservative policy during scan:
320-
- Allow editing/previewing already configured cameras (Active list)
321-
- Disallow structural changes (add/remove/reorder) and available-list actions
322-
"""
323-
scanning = self._is_scan_running()
324-
325-
# Discovery controls
326-
self.backend_combo.setEnabled(not scanning)
327-
self.refresh_btn.setEnabled(not scanning)
328-
329-
# Available camera list + add flow is blocked during scan
330-
self.available_cameras_list.setEnabled(not scanning)
331-
self.add_camera_btn.setEnabled(False if scanning else (self.available_cameras_list.currentRow() >= 0))
332-
333-
# Scan cancel button visibility is already managed in your scan start/finish,
334-
# but keeping enabled state here makes it robust.
335-
if hasattr(self, "scan_cancel_btn"):
336-
self.scan_cancel_btn.setEnabled(scanning)
337-
338317
def _sync_preview_ui(self) -> None:
339318
"""Update buttons/overlays based on preview state only."""
340319
st = self._preview.state
@@ -478,93 +457,158 @@ def _on_backend_changed(self, _index: int) -> None:
478457
self._refresh_available_cameras()
479458

480459
def _is_scan_running(self) -> bool:
481-
return bool(self._scan_worker and self._scan_worker.isRunning())
460+
return self._scan_state in (CameraScanState.RUNNING, CameraScanState.CANCELING)
461+
462+
def _set_scan_state(self, state: CameraScanState, message: str | None = None) -> None:
463+
"""Single source of truth for scan-related UI controls."""
464+
self._scan_state = state
465+
466+
scanning = state in (CameraScanState.RUNNING, CameraScanState.CANCELING)
467+
468+
# Overlay message
469+
if scanning:
470+
self._show_scan_overlay(
471+
message or ("Canceling discovery…" if state == CameraScanState.CANCELING else "Discovering cameras…")
472+
)
473+
else:
474+
self._hide_scan_overlay()
475+
476+
# Progress + cancel controls
477+
self.scan_progress.setVisible(scanning)
478+
if scanning:
479+
self.scan_progress.setRange(0, 0) # indeterminate
480+
self.scan_cancel_btn.setVisible(scanning)
481+
self.scan_cancel_btn.setEnabled(state == CameraScanState.RUNNING) # disabled while canceling
482+
483+
# Disable discovery inputs while scanning
484+
self.backend_combo.setEnabled(not scanning)
485+
self.refresh_btn.setEnabled(not scanning)
486+
487+
# Available list + add flow blocked while scanning (structure edits disallowed)
488+
self.available_cameras_list.setEnabled(not scanning)
489+
self.add_camera_btn.setEnabled(False if scanning else (self.available_cameras_list.currentRow() >= 0))
490+
491+
self._update_button_states()
492+
493+
def _cleanup_scan_worker(self) -> None:
494+
# worker is truly finished now
495+
w = self._scan_worker
496+
self._scan_worker = None
497+
if w is not None:
498+
w.deleteLater()
499+
500+
def _finish_scan(self, reason: str) -> None:
501+
"""Mark scan UX complete (idempotent) and emit scan_finished queued."""
502+
if self._scan_state in (CameraScanState.DONE, CameraScanState.IDLE):
503+
return
504+
505+
# Transition scan UX to DONE (UI controls restored)
506+
self._set_scan_state(CameraScanState.DONE)
507+
508+
QTimer.singleShot(0, self.scan_finished.emit)
509+
510+
LOGGER.debug("[Scan] finished reason=%s", reason)
482511

483512
def _refresh_available_cameras(self) -> None:
484513
"""Refresh the list of available cameras asynchronously."""
485-
backend = self.backend_combo.currentData()
486-
if not backend:
487-
backend = self.backend_combo.currentText().split()[0]
514+
backend = self.backend_combo.currentData() or self.backend_combo.currentText().split()[0]
488515

489-
# If already scanning, ignore new requests to avoid races
490-
if getattr(self, "_scan_worker", None) and self._scan_worker.isRunning():
516+
if self._is_scan_running():
491517
self._show_scan_overlay("Already discovering cameras…")
492518
return
493519

494-
# Reset list UI and show progress
520+
# Reset UI/list
495521
self.available_cameras_list.clear()
496522
self._detected_cameras = []
497-
msg = f"Discovering {backend} cameras…"
498-
self._show_scan_overlay(msg)
499-
self.scan_progress.setRange(0, 0)
500-
self.scan_progress.setVisible(True)
501-
self.scan_cancel_btn.setVisible(True)
502-
self.available_cameras_list.setEnabled(False)
503-
self.add_camera_btn.setEnabled(False)
504-
self.refresh_btn.setEnabled(False)
505-
self.backend_combo.setEnabled(False)
506-
507-
self._sync_scan_ui()
508-
self._update_button_states()
523+
524+
self._set_scan_state(CameraScanState.RUNNING, message=f"Discovering {backend} cameras…")
509525

510526
# Start worker
511-
self._scan_worker = DetectCamerasWorker(backend, max_devices=10, parent=self)
512-
self._scan_worker.progress.connect(self._on_scan_progress)
513-
self._scan_worker.result.connect(self._on_scan_result)
514-
self._scan_worker.error.connect(self._on_scan_error)
515-
self._scan_worker.finished.connect(self._on_scan_finished)
527+
w = DetectCamerasWorker(backend, max_devices=10, parent=self)
528+
self._scan_worker = w
529+
530+
w.progress.connect(self._on_scan_progress)
531+
w.result.connect(self._on_scan_result)
532+
w.error.connect(self._on_scan_error)
533+
w.canceled.connect(self._on_scan_canceled)
534+
535+
# Cleanup only
536+
w.finished.connect(self._cleanup_scan_worker)
537+
516538
self.scan_started.emit(f"Scanning {backend} cameras…")
517-
self._scan_worker.start()
539+
w.start()
518540

519541
def _on_scan_progress(self, msg: str) -> None:
542+
if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING):
543+
return
520544
self._show_scan_overlay(msg or "Discovering cameras…")
521545

522546
def _on_scan_result(self, cams: list) -> None:
547+
if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING):
548+
return
549+
550+
# Apply results to UI first (stability guarantee)
523551
self._detected_cameras = cams or []
524-
self.available_cameras_list.clear() # replace list contents
552+
self.available_cameras_list.clear()
525553

526554
if not self._detected_cameras:
527555
placeholder = QListWidgetItem("No cameras detected.")
528556
placeholder.setFlags(Qt.ItemIsEnabled)
529557
self.available_cameras_list.addItem(placeholder)
530-
return
531-
532-
for cam in self._detected_cameras:
533-
item = QListWidgetItem(f"{cam.label} (index {cam.index})")
534-
item.setData(Qt.ItemDataRole.UserRole, cam)
535-
self.available_cameras_list.addItem(item)
558+
else:
559+
for cam in self._detected_cameras:
560+
item = QListWidgetItem(f"{cam.label} (index {cam.index})")
561+
item.setData(Qt.ItemDataRole.UserRole, cam)
562+
self.available_cameras_list.addItem(item)
563+
self.available_cameras_list.setCurrentRow(0)
536564

537-
self.available_cameras_list.setCurrentRow(0)
565+
# Now UI is stable: finish scan UX and emit scan_finished queued
566+
self._finish_scan("result")
538567

539568
def _on_scan_error(self, msg: str) -> None:
569+
if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING):
570+
return
571+
540572
QMessageBox.warning(self, "Camera Scan", f"Failed to detect cameras:\n{msg}")
541573

542-
def _on_scan_finished(self) -> None:
543-
self._hide_scan_overlay()
544-
self.scan_progress.setVisible(False)
545-
self._scan_worker = None
574+
# Ensure UI is stable (list is stable even if empty) before finishing
575+
if self.available_cameras_list.count() == 0:
576+
placeholder = QListWidgetItem("Scan failed.")
577+
placeholder.setFlags(Qt.ItemIsEnabled)
578+
self.available_cameras_list.addItem(placeholder)
546579

547-
self.scan_cancel_btn.setVisible(False)
548-
self.scan_cancel_btn.setEnabled(True)
549-
self.available_cameras_list.setEnabled(True)
550-
self.refresh_btn.setEnabled(True)
551-
self.backend_combo.setEnabled(True)
580+
self._finish_scan("error")
552581

553-
self._sync_scan_ui()
554-
self._update_button_states()
555-
self.scan_finished.emit()
582+
def request_scan_cancel(self) -> None:
583+
if not self._is_scan_running():
584+
return
556585

557-
def _on_scan_cancel(self) -> None:
558-
"""User requested to cancel discovery."""
559-
if self._scan_worker and self._scan_worker.isRunning():
586+
self._set_scan_state(CameraScanState.CANCELING, message="Canceling discovery…")
587+
588+
w = self._scan_worker
589+
if w is not None:
560590
try:
561-
self._scan_worker.requestInterruption()
591+
w.requestInterruption()
562592
except Exception:
563593
pass
564-
# Keep the busy bar, update texts
565-
self._show_scan_overlay("Canceling discovery…")
566-
self.scan_progress.setVisible(True) # stay visible as indeterminate
567-
self.scan_cancel_btn.setEnabled(False)
594+
595+
# Guarantee UI stability before scan_finished:
596+
if self.available_cameras_list.count() == 0:
597+
placeholder = QListWidgetItem("Scan canceled.")
598+
placeholder.setFlags(Qt.ItemIsEnabled)
599+
self.available_cameras_list.addItem(placeholder)
600+
601+
self._finish_scan("cancel")
602+
603+
def _on_scan_canceled(self) -> None:
604+
self._set_scan_state(CameraScanState.CANCELING, message="Finalizing cancellation…")
605+
# If cancel is requested without clicking cancel (e.g., dialog closing), ensure UI finishes
606+
if self._scan_state in (CameraScanState.RUNNING, CameraScanState.CANCELING):
607+
if self.available_cameras_list.count() == 0:
608+
placeholder = QListWidgetItem("Scan canceled.")
609+
placeholder.setFlags(Qt.ItemIsEnabled)
610+
self.available_cameras_list.addItem(placeholder)
611+
self._finish_scan("canceled")
568612

569613
def _on_available_camera_selected(self, row: int) -> None:
570614
if self._scan_worker and self._scan_worker.isRunning():

0 commit comments

Comments
 (0)