diff --git a/dlclivegui/cameras/backends/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py index 1201749b..38613fb2 100644 --- a/dlclivegui/cameras/backends/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -245,15 +245,26 @@ def stop(self) -> None: self._release_capture() def device_name(self) -> str: - base_name = "OpenCV" + ns = self.parse_options(self.settings) + + if ns.device_name: + return ns.device_name + + name = str(getattr(self.settings, "name", "") or "").strip() + if name: + return name + + api_name = "" if self._capture and hasattr(self._capture, "getBackendName"): try: - backend_name = self._capture.getBackendName() + api_name = self._capture.getBackendName() except Exception: - backend_name = "" - if backend_name: - base_name = backend_name - return f"{base_name} camera #{self.settings.index}" + api_name = "" + + if api_name: + return f"OpenCV {api_name} camera #{self.settings.index}" + + return f"OpenCV camera #{self.settings.index}" @property def actual_fps(self) -> float | None: diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 17702409..adf825c4 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -98,13 +98,19 @@ def __init__(self, config: ApplicationSettings | None = None): self._model_path_store = ModelPathStore(self.settings) self._settings_store = DLCLiveGUISettingsStore(self.settings) + last_cfg_path = self._settings_store.get_last_config_path() + last_cfg_file = self._valid_config_file_path(last_cfg_path) if config is None: # 1) snapshot cfg = self._settings_store.load_full_config_snapshot() if cfg is not None: config = cfg - self._config_path = None - logger.info("Loaded configuration from QSettings snapshot.") + self._config_path = last_cfg_file + if self._config_path is not None: + logger.info(f"Loaded configuration from QSettings snapshot; associated file: {self._config_path}") + else: + logger.info("Loaded configuration from QSettings snapshot without associated config file.") + else: # 2) last config file path last_cfg_path = self._settings_store.get_last_config_path() @@ -141,7 +147,8 @@ def __init__(self, config: ApplicationSettings | None = None): ) self._config = config - self._inference_camera_id: str | None = None # Camera ID used for inference + self._inference_camera_id: str | None = self._settings_store.get_inference_camera_id() + self._active_inference_camera_id: str | None = None self._running_cams_ids: set[str] = set() self._current_frame: np.ndarray | None = None self._raw_frame: np.ndarray | None = None @@ -224,6 +231,19 @@ def resizeEvent(self, event): if not self.multi_camera_controller.is_running(): self._show_logo_and_text() + def _valid_config_file_path(self, path: str | None) -> Path | None: + if not path: + return None + + try: + p = Path(path).expanduser() + if p.exists() and p.is_file(): + return p.resolve() + except Exception: + logger.debug("Invalid config file path: %s", path, exc_info=True) + + return None + # ------------------------------------------------------------------ UI def _init_theme_actions(self) -> None: """Set initial checked state for theme actions based on current app stylesheet.""" @@ -270,32 +290,46 @@ def _setup_ui(self) -> None: for lbl in (self.camera_stats_label, self.dlc_stats_label, self.recording_stats_label): lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) - # Controls panel with fixed width to prevent shifting - controls_widget = QWidget() - # controls_widget.setMaximumWidth(500) - controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - controls_layout = QVBoxLayout(controls_widget) - controls_layout.setContentsMargins(5, 5, 5, 5) + # Controls panel content + controls_content_widget = QWidget() + controls_content_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + controls_layout = QVBoxLayout(controls_content_widget) + controls_layout.setContentsMargins(5, 5, 5, 0) controls_layout.addWidget(self._build_camera_group()) controls_layout.addWidget(self._build_dlc_group()) controls_layout.addWidget(self._build_recording_group()) controls_layout.addWidget(self._build_viz_group()) - # Preview/Stop buttons at bottom of controls - wrap in widget + # Preview/Stop buttons stay outside the scroll area as a fixed footer button_bar_widget = QWidget() + button_bar_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button_bar = QHBoxLayout(button_bar_widget) button_bar.setContentsMargins(0, 5, 0, 5) + self.preview_button = QPushButton("Start Preview") self.preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.preview_button.setMinimumWidth(150) + self.stop_preview_button = QPushButton("Stop Preview") self.stop_preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) self.stop_preview_button.setEnabled(False) self.stop_preview_button.setMinimumWidth(150) + button_bar.addWidget(self.preview_button) button_bar.addWidget(self.stop_preview_button) - controls_layout.addWidget(button_bar_widget) - controls_layout.addStretch(1) + + controls_widget = lyts.make_scrollable_with_fixed_footer( + controls_content_widget, + button_bar_widget, + object_name="ControlsPanel", + scroll_object_name="ControlsScrollArea", + footer_object_name="ControlsFooter", + margins=(0, 0, 0, 0), + spacing=0, + footer_margins=(5, 0, 5, 0), + ) # Add controls and video panel to main layout ## Dock widget for controls @@ -813,8 +847,8 @@ def _connect_signals(self) -> None: self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) - self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_dlc_controls_enabled()) - self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) + self.processor_combo.currentIndexChanged.connect(self._on_processor_changed) + self.allow_processor_ctrl_checkbox.stateChanged.connect(self._on_processor_control_changed) # Recording settings ## Session name persistence + preview updates @@ -883,6 +917,13 @@ def _apply_config(self, config: ApplicationSettings) -> None: if hasattr(self, "bbox_color_combo"): color_ui.set_bbox_combo_from_bgr(self.bbox_color_combo, self._bbox_color) + # Processor + ## Allow processor control checkbox state + if hasattr(self, "allow_processor_ctrl_checkbox"): + self.allow_processor_ctrl_checkbox.setChecked( + self._settings_store.get_processor_control_enabled(default=False) + ) + # Update DLC camera list self._refresh_dlc_camera_list() @@ -988,10 +1029,36 @@ def _visualization_settings_from_ui(self) -> VisualizationSettings: bbox_color=self._bbox_color, ) + def _suggest_config_dialog_path(self) -> str: + """Return best initial path for load/save config dialogs.""" + if getattr(self, "_config_path", None) is not None: + try: + return str(self._config_path) + except Exception: + pass + + last_cfg = self._settings_store.get_last_config_path() + valid_last = self._valid_config_file_path(last_cfg) + if valid_last is not None: + return str(valid_last) + + if last_cfg: + try: + p = Path(last_cfg).expanduser() + parent = p.parent + if parent.exists() and parent.is_dir(): + return str(parent / (p.name or "config.json")) + except Exception: + logger.debug("Failed to derive config dialog path from %s", last_cfg, exc_info=True) + + return str(Path.home() / "config.json") + # ------------------------------------------------------------------ # Actions def _action_load_config(self) -> None: - file_name, _ = QFileDialog.getOpenFileName(self, "Load configuration", str(Path.home()), "JSON files (*.json)") + file_name, _ = QFileDialog.getOpenFileName( + self, "Load configuration", self._suggest_config_dialog_path(), "JSON files (*.json)" + ) if not file_name: return try: @@ -1001,6 +1068,12 @@ def _action_load_config(self) -> None: return self._settings_store.set_last_config_path(file_name) self._settings_store.save_full_config_snapshot(config) + + try: + self.settings.sync() + except Exception: + logger.debug("Failed to sync settings after loading config", exc_info=True) + self._config = config self._config_path = Path(file_name) self._apply_config(config) @@ -1012,19 +1085,22 @@ def _action_save_config(self) -> None: if self._config_path is None: self._action_save_config_as() return - self._save_config_to_path(self._config_path) + if self._save_config_to_path(self._config_path): + self._config_path = self._config_path.expanduser() def _action_save_config_as(self) -> None: - file_name, _ = QFileDialog.getSaveFileName(self, "Save configuration", str(Path.home()), "JSON files (*.json)") + file_name, _ = QFileDialog.getSaveFileName( + self, "Save configuration", self._suggest_config_dialog_path(), "JSON files (*.json)" + ) if not file_name: return - path = Path(file_name) + path = Path(file_name).expanduser() if path.suffix.lower() != ".json": path = path.with_suffix(".json") - self._config_path = path - self._save_config_to_path(path) + if self._save_config_to_path(path): + self._config_path = path - def _save_config_to_path(self, path: Path) -> None: + def _save_config_to_path(self, path: Path) -> bool: try: config = self._current_config(allow_empty_model_path=True) config.save(path) @@ -1032,8 +1108,9 @@ def _save_config_to_path(self, path: Path) -> None: self._settings_store.save_full_config_snapshot(config) except Exception as exc: # pragma: no cover - GUI interaction self._show_error(str(exc)) - return + return False self.statusBar().showMessage(f"Saved configuration to {path}", 5000) + return True def _action_browse_model(self) -> None: # Prefer persisted last-used directory, then config.dlc.model_directory, then home @@ -1149,7 +1226,31 @@ def _processor_control_enabled(self) -> bool: getattr(self, "allow_processor_ctrl_checkbox", None) and self.allow_processor_ctrl_checkbox.isChecked() ) + def _on_processor_changed(self, _index: int) -> None: + """Persist selected processor key when the user changes the processor combo.""" + processor_key = self.processor_combo.currentData() + self._settings_store.set_processor_key(processor_key) + + if hasattr(self.processor_combo, "update_shrink_width"): + self.processor_combo.update_shrink_width() + + def _on_processor_control_changed(self, _state: int) -> None: + """Persist processor-control checkbox state and refresh dependent UI.""" + enabled = self.allow_processor_ctrl_checkbox.isChecked() + self._settings_store.set_processor_control_enabled(enabled) + + self._update_dlc_controls_enabled() + self._update_processor_status() + def _refresh_processors(self) -> None: + """Scan processors and restore the last selected processor best-effort.""" + previous_key = None + if hasattr(self, "processor_combo"): + previous_key = self.processor_combo.currentData() + + preferred_key = previous_key or self._settings_store.get_processor_key() + + self.processor_combo.blockSignals(True) self.processor_combo.clear() self.processor_combo.addItem("No Processor", None) @@ -1172,8 +1273,23 @@ def _refresh_processors(self) -> None: display_name = f"{info['name']} ({info['file']})" self.processor_combo.addItem(display_name, key) + # Restore selected processor best-effort. + if preferred_key is not None: + idx = self.processor_combo.findData(preferred_key) + if idx >= 0: + self.processor_combo.setCurrentIndex(idx) + else: + self.processor_combo.setCurrentIndex(0) + else: + self.processor_combo.setCurrentIndex(0) + + self.processor_combo.blockSignals(False) self.processor_combo.update_shrink_width() - self.statusBar().showMessage(f"Found {len(self._processor_keys)} processor(s) in {source_text}", 3000) + + self.statusBar().showMessage( + f"Found {len(self._processor_keys)} processor(s) in {source_text}", + 3000, + ) # ------------------------------------------------------------------ # Recording path preview and session name persistence @@ -1302,16 +1418,20 @@ def _on_multi_camera_settings_changed(self, settings: MultiCameraSettings) -> No self.statusBar().showMessage(f"Camera configuration updated: {active_count} active camera(s)", 3000) def _update_active_cameras_label(self) -> None: - """Update the label showing active cameras.""" active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: self.active_cameras_label.setText("No cameras configured") - elif len(active_cams) == 1: + return + + if len(active_cams) == 1: cam = active_cams[0] - self.active_cameras_label.setText(f"{cam.name} [{cam.backend}:{cam.index}] @ {cam.fps:.1f} fps") - else: - cam_names = [f"{c.name}" for c in active_cams] - self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") + display_id = get_display_id(cam) + self.active_cameras_label.setText(f"{display_id} [{cam.backend}:{cam.index}]") + return + + cam_names = [get_display_id(c) for c in active_cams] + self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") def _validate_configured_cameras(self) -> None: """Validate that configured cameras are available. @@ -1353,68 +1473,111 @@ def _validate_configured_cameras(self) -> None: def _label_for_cam_id(self, cam_id: str) -> str: for cam in self._config.multi_camera.get_active_cameras(): if get_camera_id(cam) == cam_id: - return f"{cam.name} [{cam.backend}:{cam.index}]" - return cam_id + display_id = get_display_id(cam) + return f"{display_id} [{cam.backend}:{cam.index}]" + + display_id = self._multi_camera_display_ids.get(cam_id) + if display_id: + return display_id + + return "Unknown camera" def _refresh_dlc_camera_list_running(self) -> None: - """Populate the inference camera dropdown from currently running cameras.""" + """Populate inference camera dropdown from currently running cameras. + + - Keep the user's preferred camera if it is running + - Otherwise use a temporary runtime fallback + - Never persist fallback choices caused by preview/update events + """ + preferred_id = self._inference_camera_id or self._settings_store.get_inference_camera_id() + self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() + for cam in self._config.multi_camera.get_active_cameras(): cam_id = get_camera_id(cam) if cam_id in self._running_cams_ids: self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) - # Keep current selection if still present, else select first running - if self._inference_camera_id in self._running_cams_ids: - idx = self.dlc_camera_combo.findData(self._inference_camera_id) + selected_id = None + + if preferred_id in self._running_cams_ids: + idx = self.dlc_camera_combo.findData(preferred_id) if idx >= 0: self.dlc_camera_combo.setCurrentIndex(idx) - elif self.dlc_camera_combo.count() > 0: + selected_id = preferred_id + + if selected_id is None and self.dlc_camera_combo.count() > 0: self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() + selected_id = self.dlc_camera_combo.currentData() + + self._active_inference_camera_id = selected_id + self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() - def _set_dlc_combo_to_id(self, cam_id: str) -> None: - """Update combo selection to a given ID without firing signals.""" + def _set_dlc_combo_to_id(self, cam_id: str) -> bool: + """Update combo selection to a given camera ID without firing signals.""" self.dlc_camera_combo.blockSignals(True) - idx = self.dlc_camera_combo.findData(cam_id) - if idx >= 0: - self.dlc_camera_combo.setCurrentIndex(idx) - self.dlc_camera_combo.blockSignals(False) + try: + idx = self.dlc_camera_combo.findData(cam_id) + if idx >= 0: + self.dlc_camera_combo.setCurrentIndex(idx) + return True + return False + finally: + self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() def _refresh_dlc_camera_list(self) -> None: - """Populate the inference camera dropdown from active cameras.""" + """Populate inference camera dropdown from configured active cameras.""" + preferred_id = self._inference_camera_id or self._settings_store.get_inference_camera_id() + self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() active_cams = self._config.multi_camera.get_active_cameras() for cam in active_cams: - cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" - label = f"{cam.name} [{cam.backend}:{cam.index}]" + cam_id = get_camera_id(cam) + display_id = get_display_id(cam) + label = f"{display_id} [{cam.backend}:{cam.index}]" self.dlc_camera_combo.addItem(label, cam_id) - # Keep previous selection if still present, else default to first - if self._inference_camera_id is not None: - idx = self.dlc_camera_combo.findData(self._inference_camera_id) + selected_id = None + + if preferred_id is not None: + idx = self.dlc_camera_combo.findData(preferred_id) if idx >= 0: self.dlc_camera_combo.setCurrentIndex(idx) - elif self.dlc_camera_combo.count() > 0: - self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() - else: - if self.dlc_camera_combo.count() > 0: - self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() + selected_id = preferred_id + + if selected_id is None and self.dlc_camera_combo.count() > 0: + self.dlc_camera_combo.setCurrentIndex(0) + selected_id = self.dlc_camera_combo.currentData() + + # First-run convenience only. + # If there is no previous preference, initialize one. + if self._inference_camera_id is None and preferred_id is None: + self._inference_camera_id = selected_id + self._settings_store.set_inference_camera_id(selected_id) + + self._active_inference_camera_id = selected_id self.dlc_camera_combo.blockSignals(False) self.dlc_camera_combo.update_shrink_width() def _on_dlc_camera_changed(self, _index: int) -> None: - """Track user selection of the inference camera.""" - self._inference_camera_id = self.dlc_camera_combo.currentData() + """Track explicit user selection of the inference camera.""" + cam_id = self.dlc_camera_combo.currentData() + + self._inference_camera_id = cam_id + self._active_inference_camera_id = cam_id + + self._settings_store.set_inference_camera_id(cam_id) + self.dlc_camera_combo.update_shrink_width() - # Force redraw so bbox/pose overlays switch to the new tile immediately + + # Force redraw so bbox/pose overlays switch to the new tile immediately. if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1426,7 +1589,7 @@ def _render_overlays_for_recording(self, cam_id, frame): offset, scale = (0, 0), (1.0, 1.0) # If this is the inference camera, apply pose overlays - if cam_id == self._inference_camera_id and self._last_pose and self._last_pose.pose is not None: + if cam_id == self._active_inference_camera_id and self._last_pose and self._last_pose.pose is not None: output = draw_pose( output, self._last_pose.pose, @@ -1483,25 +1646,30 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._running_cams_ids = new_running self._refresh_dlc_camera_list_running() - # Determine DLC camera (first active camera) - selected_id = self._inference_camera_id + preferred_id = self._inference_camera_id available_ids = list(frame_data.frames.keys()) - if selected_id in frame_data.frames: - dlc_cam_id = selected_id + + if preferred_id in frame_data.frames: + dlc_cam_id = preferred_id else: dlc_cam_id = available_ids[0] if available_ids else "" + if dlc_cam_id: - self._inference_camera_id = dlc_cam_id - self._set_dlc_combo_to_id(dlc_cam_id) - self.statusBar().showMessage( - f"DLC inference camera changed to {self._label_for_cam_id(dlc_cam_id)}", 3000 - ) - else: # No more cameras available + if self._active_inference_camera_id != dlc_cam_id: + self._active_inference_camera_id = dlc_cam_id + self._set_dlc_combo_to_id(dlc_cam_id) + self.statusBar().showMessage( + f"Using temporary DLC inference camera: {self._label_for_cam_id(dlc_cam_id)}", + 3000, + ) + else: if self._dlc_active: self._stop_inference(show_message=True) self._display_dirty = True return + self._active_inference_camera_id = dlc_cam_id + # Check if this frame is from the DLC camera is_dlc_camera_frame = frame_data.source_camera_id == dlc_cam_id @@ -1548,12 +1716,19 @@ def _on_multi_camera_stopped(self) -> None: self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) + self._current_frame = None self._multi_camera_frames.clear() self._multi_camera_display_ids.clear() + self._running_cams_ids.clear() + self._display_dirty = False + self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Multi-camera preview stopped", 3000) + + self._update_active_cameras_label() + self._refresh_dlc_camera_list() self._update_inference_buttons() self._update_camera_controls_enabled() self._update_dlc_controls_enabled() @@ -1780,6 +1955,8 @@ def _configure_dlc(self) -> bool: processor = None if self._processor_control_enabled(): selected_key = self.processor_combo.currentData() + self._settings_store.set_processor_key(selected_key) + if selected_key is not None and self._scanned_processors: try: # For now, instantiate with no parameters @@ -2248,9 +2425,28 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha # Remember model path on exit self._model_path_store.save_if_valid(self.model_path_edit.text().strip()) + # Remember processor folder on exit if hasattr(self, "processor_folder_edit"): self._settings_store.set_processor_folder(self.processor_folder_edit.text().strip()) + # Remember user-preferred inference camera on exit. + if hasattr(self, "_inference_camera_id"): + self._settings_store.set_inference_camera_id(self._inference_camera_id) + + # Remember selected processor on exit + if hasattr(self, "processor_combo"): + self._settings_store.set_processor_key(self.processor_combo.currentData()) + + # Remember processor-control checkbox state on exit + if hasattr(self, "allow_processor_ctrl_checkbox"): + self._settings_store.set_processor_control_enabled(self.allow_processor_ctrl_checkbox.isChecked()) + + # Flush QSettings best-effort + try: + self.settings.sync() + except Exception: + logger.exception("Failed to sync QSettings on close", exc_info=True) + # Close the window super().closeEvent(event) diff --git a/dlclivegui/gui/misc/layouts.py b/dlclivegui/gui/misc/layouts.py index 2b09b47f..d80ba114 100644 --- a/dlclivegui/gui/misc/layouts.py +++ b/dlclivegui/gui/misc/layouts.py @@ -3,8 +3,19 @@ from collections.abc import Sequence -from PySide6.QtCore import QObject, Qt -from PySide6.QtWidgets import QComboBox, QGridLayout, QLabel, QSizePolicy, QStyle, QStyleOptionComboBox, QWidget +from PySide6.QtCore import QEvent, QObject, QSize, Qt, QTimer +from PySide6.QtWidgets import ( + QComboBox, + QFrame, + QGridLayout, + QLabel, + QScrollArea, + QSizePolicy, + QStyle, + QStyleOptionComboBox, + QVBoxLayout, + QWidget, +) def _combo_width_for_current_text(combo: QComboBox, extra_padding: int = 10) -> int: @@ -226,3 +237,186 @@ def _add_pair(label_text: str | None, widget: QWidget | None, stretch: int) -> N grid.setColumnStretch(c, s) return row + + +class _VerticalOnlyScrollArea(QScrollArea): + """ + Vertical-only scroll area. + + It does not introduce horizontal scrolling and does not manually force the + child width on resize. Instead, it makes the child keep at least its natural + layout width, and makes the scroll area advertise that width as its own + minimum width. + + This avoids horizontal clipping without creating a width feedback loop. + """ + + def __init__(self, content_widget: QWidget, parent: QWidget | None = None): + super().__init__(parent) + + self._content_widget = content_widget + + self.setFrameShape(QFrame.Shape.NoFrame) + self.setViewportMargins(0, 0, 0, 0) + self.setContentsMargins(0, 0, 0, 0) + + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + # Let the scroll area resize the content to the available viewport size, + # but never below the content's minimum width. + self.setWidgetResizable(True) + self.setWidget(content_widget) + + self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) + content_widget.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) + + self.update_width_constraints() + + def _scrollbar_extent(self) -> int: + return self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent, None, self) + + def _frame_width_total(self) -> int: + frame = self.frameWidth() + return frame * 2 + + def _natural_content_width(self) -> int: + """ + Return the content's natural layout width. + + Important: do not use content_widget.minimumWidth() here, because this + class sets that value. Using it would create a feedback loop where the + natural width grows after resizes. + """ + layout = self._content_widget.layout() + + candidates: list[int] = [ + self._content_widget.sizeHint().width(), + self._content_widget.minimumSizeHint().width(), + ] + + if layout is not None: + candidates.append(layout.sizeHint().width()) + candidates.append(layout.minimumSize().width()) + + return max(0, *candidates) + + def _natural_content_height(self) -> int: + layout = self._content_widget.layout() + + candidates: list[int] = [ + self._content_widget.sizeHint().height(), + self._content_widget.minimumSizeHint().height(), + ] + + if layout is not None: + candidates.append(layout.sizeHint().height()) + candidates.append(layout.minimumSize().height()) + + return max(0, *candidates) + + def update_width_constraints(self) -> None: + natural_width = self._natural_content_width() + + # Reserve vertical-scrollbar width so that when the scrollbar appears, + # the content still has enough viewport width and is not horizontally clipped. + min_scroll_width = natural_width + self._scrollbar_extent() + self._frame_width_total() + + if self._content_widget.minimumWidth() != natural_width: + self._content_widget.setMinimumWidth(natural_width) + + if self.minimumWidth() != min_scroll_width: + self.setMinimumWidth(min_scroll_width) + + self._content_widget.updateGeometry() + self.updateGeometry() + + def event(self, event) -> bool: + result = super().event(event) + + if event.type() in { + QEvent.Type.Show, + QEvent.Type.LayoutRequest, + QEvent.Type.FontChange, + QEvent.Type.StyleChange, + QEvent.Type.PaletteChange, + }: + self.update_width_constraints() + + return result + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self.update_width_constraints() + + def sizeHint(self) -> QSize: + return QSize( + self._natural_content_width() + self._scrollbar_extent() + self._frame_width_total(), + self._natural_content_height(), + ) + + def minimumSizeHint(self) -> QSize: + # Width is strict, height is deliberately small so vertical scrolling can happen. + return QSize( + self._natural_content_width() + self._scrollbar_extent() + self._frame_width_total(), + 120, + ) + + +def make_scrollable_with_fixed_footer( + content_widget: QWidget, + footer_widget: QWidget | None = None, + *, + object_name: str | None = None, + scroll_object_name: str | None = None, + footer_object_name: str | None = None, + margins: tuple[int, int, int, int] = (0, 0, 0, 0), + spacing: int = 0, + footer_margins: tuple[int, int, int, int] = (0, 0, 0, 0), +) -> QWidget: + """ + Wrap `content_widget` in a vertical-only scroll area and keep `footer_widget` + outside the scroll area. + + Guarantees: + - existing content layout is not restructured; + - vertical scrolling appears only when height is insufficient; + - horizontal scrolling is never introduced; + - content is not hidden horizontally: the wrapper advertises the natural + content width as its minimum width; + - footer controls remain visible. + """ + wrapper = QWidget() + if object_name: + wrapper.setObjectName(object_name) + + wrapper_layout = QVBoxLayout(wrapper) + wrapper_layout.setContentsMargins(*margins) + wrapper_layout.setSpacing(max(0, int(spacing))) + + scroll = _VerticalOnlyScrollArea(content_widget, wrapper) + if scroll_object_name: + scroll.setObjectName(scroll_object_name) + + wrapper_layout.addWidget(scroll, stretch=1) + + if footer_widget is not None: + footer_wrapper = QWidget(wrapper) + if footer_object_name: + footer_wrapper.setObjectName(footer_object_name) + + footer_layout = QVBoxLayout(footer_wrapper) + footer_layout.setContentsMargins(*footer_margins) + footer_layout.setSpacing(0) + footer_layout.addWidget(footer_widget) + + footer_wrapper.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) + wrapper_layout.addWidget(footer_wrapper, stretch=0) + + wrapper.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) + + # The wrapper must also resist horizontal shrinking, otherwise the dock can + # still become narrower than the scroll area's valid width. + QTimer.singleShot(0, lambda: wrapper.setMinimumWidth(scroll.minimumSizeHint().width())) + + return wrapper diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index 0107afb1..c6c0171e 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -13,56 +13,144 @@ class DLCLiveGUISettingsStore: + """Small QSettings-backed store for lightweight GUI preferences. + + Stores UI/session preferences that should survive + application restarts but do not necessarily belong in exported JSON configs. + + Full application configuration snapshots are also stored here separately as + JSON for convenient startup restore. + """ + + # --- app/config keys --- + KEY_LAST_CONFIG_PATH = "app/last_config_path" + KEY_CONFIG_JSON = "app/config_json" + + # --- dlc/model keys --- + KEY_LAST_MODEL_PATH = "dlc/last_model_path" + KEY_PROCESSOR_FOLDER = "dlc/processor_folder" + KEY_INFERENCE_CAMERA_ID = "dlc/inference_camera_id" + KEY_PROCESSOR_KEY = "dlc/processor_key" + KEY_PROCESSOR_CONTROL_ENABLED = "dlc/processor_control_enabled" + + # --- recording keys --- + KEY_SESSION_NAME = "recording/session_name" + KEY_USE_TIMESTAMP = "recording/use_timestamp" + KEY_FAST_ENCODING = "recording/fast_encoding" + def __init__(self, qsettings: QSettings | None = None): self._s = qsettings or QSettings("DeepLabCut", "DLCLiveGUI") - # --- lightweight prefs --- + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _get_bool(self, key: str, default: bool = False) -> bool: + """Read a bool from QSettings, handling Qt/string/int variants.""" + value = self._s.value(key, default) + + if isinstance(value, bool): + return value + + if isinstance(value, (int, float)): + return bool(value) + + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + + return bool(default) + + def _get_optional_str(self, key: str, default: str = "") -> str | None: + """Read an optional string from QSettings.""" + value = self._s.value(key, default) + value = str(value).strip() if value is not None else "" + return value or None + + def _set_optional_str(self, key: str, value: str | None) -> None: + """Persist optional string values as empty strings when unset.""" + self._s.setValue(key, str(value).strip() if value else "") + + # ------------------------------------------------------------------ + # App/model prefs + # ------------------------------------------------------------------ def get_last_model_path(self) -> str | None: - v = self._s.value("dlc/last_model_path", "") - return str(v) if v else None + return self._get_optional_str(self.KEY_LAST_MODEL_PATH) def set_last_model_path(self, path: str) -> None: - self._s.setValue("dlc/last_model_path", path or "") + self._set_optional_str(self.KEY_LAST_MODEL_PATH, path) def get_last_config_path(self) -> str | None: - v = self._s.value("app/last_config_path", "") - return str(v) if v else None + return self._get_optional_str(self.KEY_LAST_CONFIG_PATH) def set_last_config_path(self, path: str) -> None: - self._s.setValue("app/last_config_path", path or "") + self._set_optional_str(self.KEY_LAST_CONFIG_PATH, path) + # ------------------------------------------------------------------ + # Recording prefs + # ------------------------------------------------------------------ def get_session_name(self) -> str: - v = self._s.value("recording/session_name", "") - return str(v) if v else "" + return self._get_optional_str(self.KEY_SESSION_NAME) or "" def set_session_name(self, name: str) -> None: - self._s.setValue("recording/session_name", name or "") + self._set_optional_str(self.KEY_SESSION_NAME, name) def get_use_timestamp(self, default: bool = True) -> bool: - v = self._s.value("recording/use_timestamp", default) - if isinstance(v, bool): - return v - if isinstance(v, (int, float)): - return bool(v) - if isinstance(v, str): - return v.strip().lower() in ("1", "true", "yes", "on") - return bool(default) + return self._get_bool(self.KEY_USE_TIMESTAMP, default=default) def set_use_timestamp(self, value: bool) -> None: - self._s.setValue("recording/use_timestamp", bool(value)) + self._s.setValue(self.KEY_USE_TIMESTAMP, bool(value)) def get_fast_encoding(self, default: bool = False) -> bool: - value = self._s.value("recording/fast_encoding", default) - if isinstance(value, bool): - return value - return str(value).strip().lower() in {"1", "true", "yes", "on"} + return self._get_bool(self.KEY_FAST_ENCODING, default=default) - def get_processor_folder(self, default: str = "") -> str: + def set_fast_encoding(self, enabled: bool) -> None: + self._s.setValue(self.KEY_FAST_ENCODING, bool(enabled)) + + # ------------------------------------------------------------------ + # DLC camera / processor prefs + # ------------------------------------------------------------------ + def get_inference_camera_id(self, default: str | None = None) -> str | None: + """Return the last explicitly selected DLC inference camera ID. + + This is a user preference. Runtime fallbacks during preview should not + overwrite this value unless the user explicitly changes the combo. """ - Return the persisted processor folder if it still exists and is a directory. - Otherwise return default. + return self._get_optional_str(self.KEY_INFERENCE_CAMERA_ID, default or "") + + def set_inference_camera_id(self, camera_id: str | None) -> None: + """Persist the explicitly selected DLC inference camera ID.""" + self._set_optional_str(self.KEY_INFERENCE_CAMERA_ID, camera_id) + + def get_processor_key(self, default: str | None = None) -> str | None: + """Return the last selected processor key, if any.""" + return self._get_optional_str(self.KEY_PROCESSOR_KEY, default or "") + + def set_processor_key(self, processor_key: str | None) -> None: + """Persist the selected processor key. + + The key may become unavailable if the processor folder changes. In that + case the GUI should simply fall back to "No Processor" while keeping + refresh behavior graceful. """ - value = self._s.value("dlc/processor_folder", default) + self._set_optional_str(self.KEY_PROCESSOR_KEY, processor_key) + + def get_processor_control_enabled(self, default: bool = False) -> bool: + """Return whether processor-based control was enabled last time.""" + return self._get_bool(self.KEY_PROCESSOR_CONTROL_ENABLED, default=default) + + def set_processor_control_enabled(self, enabled: bool) -> None: + """Persist processor-based control checkbox state.""" + self._s.setValue(self.KEY_PROCESSOR_CONTROL_ENABLED, bool(enabled)) + + def get_processor_folder(self, default: str = "") -> str: + """Return the persisted processor folder if it still exists. + + If the stored folder is missing or invalid, return default. + """ + value = self._s.value(self.KEY_PROCESSOR_FOLDER, default) value = str(value).strip() if value is not None else "" if not value: @@ -78,9 +166,10 @@ def get_processor_folder(self, default: str = "") -> str: return default def set_processor_folder(self, folder: str) -> None: - """ - Persist processor folder only if it exists and is a directory. - Invalid folders are ignored. + """Persist processor folder only if it exists and is a directory. + + Invalid folders are ignored so we do not accidentally replace a valid + stored folder with an unusable value. """ folder = str(folder).strip() if folder is not None else "" if not folder: @@ -89,25 +178,27 @@ def set_processor_folder(self, folder: str) -> None: try: path = Path(folder).expanduser() if path.is_dir(): - self._s.setValue("dlc/processor_folder", str(path.resolve())) + self._s.setValue(self.KEY_PROCESSOR_FOLDER, str(path.resolve())) except Exception: logger.debug("Failed to persist processor folder: %s", folder, exc_info=True) - def set_fast_encoding(self, enabled: bool) -> None: - self._s.setValue("recording/fast_encoding", bool(enabled)) - - # --- optional: snapshot full config as JSON in QSettings --- + # ------------------------------------------------------------------ + # Full config snapshot + # ------------------------------------------------------------------ def save_full_config_snapshot(self, cfg: ApplicationSettings) -> None: - self._s.setValue("app/config_json", cfg.model_dump_json()) + """Persist the current full application config as JSON in QSettings.""" + self._s.setValue(self.KEY_CONFIG_JSON, cfg.model_dump_json()) def load_full_config_snapshot(self) -> ApplicationSettings | None: - raw = self._s.value("app/config_json", "") + """Load the previously persisted full application config snapshot.""" + raw = self._s.value(self.KEY_CONFIG_JSON, "") if not raw: return None + try: return ApplicationSettings.model_validate_json(str(raw)) except Exception: - logger.debug("Failed to load full config snapshot from QSettings") + logger.debug("Failed to load full config snapshot from QSettings", exc_info=True) return None @@ -121,19 +212,24 @@ def __init__(self, settings: QSettings | None = None): # Normalization helpers # ------------------------- def _as_path(self, p: str | None) -> Path | None: - """Best-effort conversion to Path (expand ~, interpret '.' as cwd).""" + """Best-effort conversion to Path. + + Expands '~' and interprets '.' as the current working directory. + """ if not p: return None + s = str(p).strip() if not s: return None + try: pp = Path(s).expanduser() if s in (".", "./"): pp = Path.cwd() return pp except Exception: - logger.debug("Failed to parse path: %s", p) + logger.debug("Failed to parse path: %s", p, exc_info=True) return None def _norm_existing_dir(self, p: str | None) -> str | None: @@ -141,27 +237,31 @@ def _norm_existing_dir(self, p: str | None) -> str | None: pp = self._as_path(p) if pp is None: return None + try: - # If a file was given, use its parent directory + # If a file was given, use its parent directory. if pp.exists() and pp.is_file(): pp = pp.parent if pp.exists() and pp.is_dir(): return str(pp.resolve()) except Exception: - logger.debug("Failed to normalize directory: %s", p) + logger.debug("Failed to normalize directory: %s", p, exc_info=True) + return None def _norm_existing_path(self, p: str | None) -> str | None: - """Return an absolute, resolved existing path (file or dir), else None.""" + """Return an absolute, resolved existing path, file or dir, else None.""" pp = self._as_path(p) if pp is None: return None + try: if pp.exists(): return str(pp.resolve()) except Exception: - logger.debug("Failed to normalize path: %s", p) + logger.debug("Failed to normalize path: %s", p, exc_info=True) + return None # ------------------------- @@ -176,28 +276,30 @@ def load_last(self) -> str | None: try: pp = Path(path) - # Accept a valid model *file* + + # Accept a valid model file. if pp.is_file() and (Engine.is_pytorch_model_path(pp) or Engine.is_tensorflow_model_dir_path(pp.parent)): return str(pp) except Exception: - logger.debug("Last model path not valid/usable: %s", path) + logger.debug("Last model path not valid/usable: %s", path, exc_info=True) return None def load_last_dir(self) -> str | None: """Return last directory if it still exists and is a directory.""" val = self._settings.value("dlc/last_model_dir") - d = self._norm_existing_dir(str(val)) if val else None - return d + return self._norm_existing_dir(str(val)) if val else None # ------------------------- # Save # ------------------------- def save_if_valid(self, path: str) -> None: - """ - Save last model path if it looks valid/usable, and always save its directory. - - For files: always save parent directory. - - For directories: save directory itself if it looks like a TF model dir. + """Save last model path if it looks valid/usable. + + Also saves a safe directory for QFileDialog.setDirectory(...). + + - For files: saves parent directory. + - For directories: saves the directory itself when appropriate. """ norm = self._norm_existing_path(path) if not norm: @@ -206,7 +308,7 @@ def save_if_valid(self, path: str) -> None: try: p = Path(norm) - # Always persist a *directory* that is safe for QFileDialog.setDirectory(...) + # Always persist a directory that is safe for QFileDialog.setDirectory(...). if p.is_dir(): model_dir = p else: @@ -216,13 +318,12 @@ def save_if_valid(self, path: str) -> None: if model_dir_norm: self._settings.setValue("dlc/last_model_dir", model_dir_norm) - # Persist model path if it is a valid model file, or a TF model directory + # Persist model path if it is a valid model file, or a TF model file + # whose parent is a TensorFlow model directory. if Engine.is_pytorch_model_path(p): self._settings.setValue("dlc/last_model_path", str(p)) elif p.parent.is_dir() and Engine.is_tensorflow_model_dir_path(p.parent): self._settings.setValue("dlc/last_model_path", str(p)) - # elif p.is_dir() and Engine.is_tensorflow_model_dir_path(p): - # self._settings.setValue("dlc/last_model_path", str(p)) except Exception: logger.debug("Failed to save model path: %s", path, exc_info=True) @@ -231,6 +332,7 @@ def save_last_dir(self, directory: str) -> None: d = self._norm_existing_dir(directory) if not d: return + try: self._settings.setValue("dlc/last_model_dir", d) except Exception: @@ -240,12 +342,12 @@ def save_last_dir(self, directory: str) -> None: # Resolve # ------------------------- def resolve(self, config_path: str | None) -> str: - """ - Resolve the best model path to display in the UI. + """Resolve the best model path to display in the UI. + Preference: - 1) config_path if valid/usable - 2) persisted last model path if valid/usable - 3) empty + 1. config_path if valid/usable + 2. persisted last model path if valid/usable + 3. empty string """ cfg = self._norm_existing_path(config_path) if cfg: @@ -256,7 +358,7 @@ def resolve(self, config_path: str | None) -> str: if p.is_dir() and Engine.is_tensorflow_model_dir_path(p): return cfg except Exception: - logger.debug("Config path not usable: %s", cfg) + logger.debug("Config path not usable: %s", cfg, exc_info=True) persisted = self.load_last() if persisted: @@ -265,16 +367,16 @@ def resolve(self, config_path: str | None) -> str: return "" def suggest_start_dir(self, fallback_dir: str | None = None) -> str: + """Pick the best directory to start file dialogs in. + + Guarantees: returns an existing absolute directory, never '.'. """ - Pick the best directory to start file dialogs in. - Guarantees: returns an existing absolute directory (never '.'). - """ - # 1) last dir + # 1. last dir last_dir = self.load_last_dir() if last_dir: return last_dir - # 2) directory of last valid model path + # 2. directory of last valid model path last = self.load_last() if last: try: @@ -288,25 +390,29 @@ def suggest_start_dir(self, fallback_dir: str | None = None) -> str: if d: return d except Exception: - logger.debug("Failed to derive start dir from last model: %s", last) + logger.debug("Failed to derive start dir from last model: %s", last, exc_info=True) - # 3) fallback dir (e.g. config.dlc.model_directory) + # 3. fallback dir, e.g. config.dlc.model_directory fb = self._norm_existing_dir(fallback_dir) if fb: return fb - # 4) last resort: cwd if exists else home + # 4. last resort: cwd if exists else home cwd = self._norm_existing_dir(str(Path.cwd())) return cwd or str(Path.home()) def suggest_selected_file(self) -> str | None: - """Return a file to preselect if it exists (only files, not directories).""" + """Return a file to preselect if it exists. + + Only files are returned, not directories. + """ last = self.load_last() if not last: return None + try: p = Path(last) return str(p) if p.exists() and p.is_file() else None except Exception: - logger.debug("Failed to check existence of last model: %s", last) + logger.debug("Failed to check existence of last model: %s", last, exc_info=True) return None diff --git a/tests/cameras/backends/test_opencv_backend.py b/tests/cameras/backends/test_opencv_backend.py index 5fff0991..e2468297 100644 --- a/tests/cameras/backends/test_opencv_backend.py +++ b/tests/cameras/backends/test_opencv_backend.py @@ -99,7 +99,7 @@ def fake_videocapture(index, flag): assert any(idx == 0 for idx, _ in calls) assert not any(idx == 1 for idx, _ in calls) # since alt index probe is commented out - assert "camera" in backend.device_name().lower() + assert "test" in backend.device_name().lower() def test_open_raises_when_unable_to_open(monkeypatch, fake_capture_factory): diff --git a/tests/utils/test_settings_store.py b/tests/utils/test_settings_store.py index 318dc49c..70238242 100644 --- a/tests/utils/test_settings_store.py +++ b/tests/utils/test_settings_store.py @@ -11,10 +11,11 @@ class InMemoryQSettings: - """Stand-in for QSettings""" + """Small stand-in for QSettings.""" def __init__(self): self._d = {} + self.synced = False def value(self, key: str, default=None): return self._d.get(key, default) @@ -22,6 +23,12 @@ def value(self, key: str, default=None): def setValue(self, key: str, value): self._d[key] = value + def remove(self, key: str): + self._d.pop(key, None) + + def sync(self): + self.synced = True + # ----------------------------- # QtSettingsStore @@ -348,3 +355,160 @@ def test_model_path_store_suggest_selected_file_returns_none_when_missing(tmp_pa settings.setValue("dlc/last_model_path", str(missing)) assert mps.suggest_selected_file() is None + + +# ----------------------------- +# Inference camera ID +# ----------------------------- +def test_settings_store_inference_camera_id_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_inference_camera_id() is None + + settstore.set_inference_camera_id("opencv:0") + assert settstore.get_inference_camera_id() == "opencv:0" + + settstore.set_inference_camera_id(None) + assert settstore.get_inference_camera_id() is None + + settstore.set_inference_camera_id("") + assert settstore.get_inference_camera_id() is None + + +# ----------------------------- +# Processor settings +# ----------------------------- +def test_settings_store_processor_folder_roundtrip_when_valid(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + folder = tmp_path / "processors" + folder.mkdir() + + settstore.set_processor_folder(str(folder)) + + assert settstore.get_processor_folder(default="fallback") == str(folder.resolve()) + + +def test_settings_store_processor_folder_ignores_invalid_value(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + missing = tmp_path / "missing" + + settstore.set_processor_folder(str(missing)) + + assert settstore.get_processor_folder(default="fallback") == "fallback" + + +def test_settings_store_get_processor_folder_returns_default_if_stored_missing(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + missing = tmp_path / "missing" + s.setValue("dlc/processor_folder", str(missing)) + + assert settstore.get_processor_folder(default="fallback") == "fallback" + + +def test_settings_store_processor_key_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_key() is None + + settstore.set_processor_key("my_processor") + assert settstore.get_processor_key() == "my_processor" + + settstore.set_processor_key(None) + assert settstore.get_processor_key() is None + + settstore.set_processor_key("") + assert settstore.get_processor_key() is None + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + (True, True), + (False, False), + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + (1, True), + (0, False), + ], +) +def test_settings_store_processor_control_bool_parsing(stored, expected): + s = InMemoryQSettings() + s.setValue("dlc/processor_control_enabled", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_control_enabled(default=not expected) is expected + + +def test_settings_store_processor_control_enabled_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_control_enabled(default=False) is False + + settstore.set_processor_control_enabled(True) + assert settstore.get_processor_control_enabled(default=False) is True + + settstore.set_processor_control_enabled(False) + assert settstore.get_processor_control_enabled(default=True) is False + + +# ----------------------------- +# Recording +# ----------------------------- +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_settings_store_use_timestamp_bool_variants(stored, expected): + s = InMemoryQSettings() + s.setValue("recording/use_timestamp", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_use_timestamp(default=not expected) is expected + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_settings_store_fast_encoding_bool_variants(stored, expected): + s = InMemoryQSettings() + s.setValue("recording/fast_encoding", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_fast_encoding(default=not expected) is expected