diff --git a/VERSION b/VERSION index 72a8a631..787ffc30 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.0 +0.42.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ce09ad3e..53f52b5c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## 0.42.0 + +- Change: **Presets UX pass** — presets now live in a visible list (tooltips show contents), and Save, Edit and Apply all use the per-setting picker from copy/paste: a preset stores exactly the settings you tick, and applying shows them again with a choice of current frame, selection or whole roll, laid over existing edits or replacing the look. Per-frame crop, rotation, metadata, dust and masks are never touched; existing preset files keep working. + ## 0.41.0 - New: **Stitch multi-shot scans** — select overlapping shots of one frame (e.g. a 6×6 scanned in two halves) on the contact sheet and pick **Stitch selected frames**. Alignment, exposure matching and blending happen on the linear scan data before conversion, so the result develops like a single raw. No new file is written: the composite edits and exports like any frame, and **Unstitch** restores the parts. IR dust data is kept when all parts have it. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 36852822..3bcf5c1a 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -828,6 +828,43 @@ def sync_selected_settings(self, rows, bounds_flags: tuple[bool, bool] = (False, self.settings_saved.emit() return count + def apply_preset_fields(self, source: WorkspaceConfig, rows, scope: str = "current") -> int: + """Overlay a preset's chosen rows onto the current frame, the selection, or + the whole (visible) roll. Unlike sync_selected_settings the source is the + preset itself, so the active frame is a target too. Returns frames changed.""" + rows = list(rows) + if not rows or self.state.selected_file_idx == -1: + return 0 + + if scope == "roll": + target_indices = self.asset_model.visible_actual_indices_ordered() + elif scope == "selection": + target_indices = self.state.selected_indices + else: + target_indices = [self.state.selected_file_idx] + + count = 0 + for idx in target_indices: + if not (0 <= idx < len(self.state.uploaded_files)): + continue + if idx == self.state.selected_file_idx: + self.update_config(apply_selected_fields(source, self.state.config, rows), persist=True, render=False) + count += 1 + continue + target_hash = self.state.uploaded_files[idx]["hash"] + target_config = self.repo.load_file_settings(target_hash) or WorkspaceConfig() + synced = apply_selected_fields(source, target_config, rows) + self.push_external_history(target_hash, target_config, synced) + self.repo.save_file_settings(target_hash, synced, file_path=self.state.uploaded_files[idx]["path"]) + count += 1 + + if count: + n = len(rows) + noun = "setting" if n == 1 else "settings" + self.settings_synced.emit(f"Preset applied: {n} {noun} to {count} frame{'s' if count != 1 else ''}") + self.settings_saved.emit() + return count + def next_file(self) -> None: display_idx = self.asset_model.actual_to_display(self.state.selected_file_idx) if display_idx == -1: diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index 3f9290dc..c19319ba 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -9,8 +9,10 @@ from __future__ import annotations +import json from dataclasses import replace -from typing import Callable, Iterable, Optional +from functools import lru_cache +from typing import Any, Callable, Iterable, Mapping, Optional from negpy.domain.models import WorkspaceConfig from negpy.features.metadata.models import PUSH_PULL_LABELS @@ -250,3 +252,27 @@ def apply_selected_fields(source: WorkspaceConfig, target: WorkspaceConfig, rows for section, changes in by_section.items(): out = replace(out, **{section: replace(getattr(out, section), **changes)}) return out + + +def selected_flat_dict(cfg: WorkspaceConfig, rows: Iterable[SettingRow]) -> dict[str, Any]: + """Flat dict of the chosen rows' fields (flat keys are field names). A row's + fields travel as a unit, default-valued ones included.""" + return {f: getattr(getattr(cfg, r.section), f) for r in rows for f in r.fields} + + +# json round-trip so tuple-typed defaults compare equal to json-loaded preset values. +@lru_cache(maxsize=1) +def _default_flat_json() -> dict[str, Any]: + return json.loads(json.dumps(WorkspaceConfig().to_dict())) + + +def preset_summary(data: Mapping[str, Any]) -> str: + """One line per display section listing the non-default settings a preset + stores, e.g. "Tone: Print Density, Snap". Unknown keys are skipped.""" + dfl = _default_flat_json() + lines = [] + for title, rows in CATALOG: + labels = [r.label for r in rows if any(f in data and data[f] != dfl.get(f) for f in r.fields)] + if labels: + lines.append(f"{title}: {', '.join(labels)}") + return "\n".join(lines) diff --git a/negpy/desktop/view/sidebar/presets.py b/negpy/desktop/view/sidebar/presets.py index acfbd942..615e500b 100644 --- a/negpy/desktop/view/sidebar/presets.py +++ b/negpy/desktop/view/sidebar/presets.py @@ -1,65 +1,154 @@ from PyQt6.QtWidgets import ( - QComboBox, - QPushButton, + QDialog, QHBoxLayout, - QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, ) import qtawesome as qta +from negpy.desktop.settings_catalog import ( + CATALOG, + preset_summary, + selected_flat_dict, +) from negpy.desktop.view.sidebar.base import BaseSidebar -from negpy.services.assets.presets import Presets -from negpy.domain.models import WorkspaceConfig +from negpy.desktop.view.styles.templates import wrap_tooltip from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog +from negpy.domain.models import WorkspaceConfig +from negpy.services.assets.presets import Presets + +_PRESET_EXCLUDED_SECTIONS = frozenset({"Crop", "Rotation"}) + +# "Replace edits" resets only the look sections; per-frame geometry, frame +# metadata and export prefs stay (dust/heal/masks aren't catalog rows at all). +_REPLACE_KEPT_SECTIONS = frozenset({"Crop", "Rotation", "Metadata", "Export"}) +_LOOK_ROWS = tuple(r for title, rows in CATALOG if title not in _REPLACE_KEPT_SECTIONS for r in rows) class PresetsSidebar(BaseSidebar): """ - Panel for saving and loading editing presets. + Panel for saving and applying editing presets. """ def _init_ui(self) -> None: - # Load Row - row_load = QHBoxLayout() - self.preset_combo = QComboBox() - self.preset_combo.setToolTip("Saved presets — full WorkspaceConfig snapshots") + self.preset_list = QListWidget() + self.preset_list.setObjectName("preset_list") + self.preset_list.setMaximumHeight(180) self._refresh_presets() + self.layout.addWidget(self.preset_list) - self.load_btn = QPushButton(" Load") - self.load_btn.setIcon(qta.icon("fa5s.upload", color=THEME.text_primary)) - self.load_btn.setToolTip("Apply the selected preset to the current image") + row = QHBoxLayout() + self.apply_btn = QPushButton(" Apply") + self.apply_btn.setIcon(qta.icon("fa5s.check", color=THEME.text_primary)) + self.apply_btn.setToolTip("Apply the selected preset to the current image (or double-click a preset)") + + self.save_btn = QPushButton(" Save…") + self.save_btn.setIcon(qta.icon("fa5s.save", color=THEME.text_primary)) + self.save_btn.setToolTip("Pick which of the current settings to store as a new preset") + + self.edit_btn = QPushButton() + self.edit_btn.setIcon(qta.icon("fa5s.pen", color=THEME.text_primary)) + self.edit_btn.setToolTip("Edit the selected preset — rename it or change which settings it stores") + self.edit_btn.setFixedWidth(32) self.delete_btn = QPushButton() self.delete_btn.setIcon(qta.icon("fa5s.trash", color=THEME.text_primary)) self.delete_btn.setToolTip("Delete the selected preset") self.delete_btn.setFixedWidth(32) - row_load.addWidget(self.preset_combo, stretch=1) - row_load.addWidget(self.load_btn) - row_load.addWidget(self.delete_btn) - self.layout.addLayout(row_load) - - # Save Row - row_save = QHBoxLayout() - self.name_input = QLineEdit() - self.name_input.setPlaceholderText("New Preset Name") - self.save_btn = QPushButton(" Save") - self.save_btn.setIcon(qta.icon("fa5s.save", color=THEME.text_primary)) - self.save_btn.setToolTip("Save the current settings as a new preset under the typed name") - - row_save.addWidget(self.name_input, stretch=1) - row_save.addWidget(self.save_btn) - self.layout.addLayout(row_save) + row.addWidget(self.apply_btn, stretch=1) + row.addWidget(self.save_btn, stretch=1) + row.addWidget(self.edit_btn) + row.addWidget(self.delete_btn) + self.layout.addLayout(row) self.layout.addStretch() def _connect_signals(self) -> None: - self.load_btn.clicked.connect(self._on_load_clicked) + self.preset_list.itemDoubleClicked.connect(self._apply_preset) + self.apply_btn.clicked.connect(self._apply_preset) self.save_btn.clicked.connect(self._on_save_clicked) + self.edit_btn.clicked.connect(self._on_edit_clicked) self.delete_btn.clicked.connect(self._on_delete_clicked) + def _current_name(self) -> str: + item = self.preset_list.currentItem() + return item.text() if item is not None else "" + + def _apply_preset(self) -> None: + name = self._current_name() + if not name or not self.state.current_file_hash: + return + preset_cfg = self._preset_config(name) + if preset_cfg is None: + return + + visible = self.controller.session.asset_model.visible_actual_indices() + sel_count = len([i for i in set(self.state.selected_indices) if i in visible]) + dlg = GranularSettingsDialog( + self, + preset_cfg, + name, + show_scope=True, + show_current=True, + show_apply_mode=True, + sel_count=sel_count, + roll_count=len(visible), + exclude_sections=_PRESET_EXCLUDED_SECTIONS, + ) + dlg.setWindowTitle("Apply Preset") + if dlg.exec() != QDialog.DialogCode.Accepted: + return + rows = dlg.selected() + if dlg.apply_mode() == "replace": + rows = list(_LOOK_ROWS) + rows + if self.controller.session.apply_preset_fields(preset_cfg, rows, dlg.scope()): + self.controller.request_render() + + def _preset_config(self, name: str) -> WorkspaceConfig | None: + """The preset's stored fields over defaults, so pickers show the preset's + own values, not the current image's.""" + data = Presets.load_preset(name) + if not data: + return None + base = WorkspaceConfig().to_dict() + base.update(data) + return WorkspaceConfig.from_flat_dict(base) + + def _on_save_clicked(self) -> None: + if not self.state.current_file_hash: + return + dlg = GranularSettingsDialog( + self, + self.state.config, + "current settings", + ask_name=True, + exclude_sections=_PRESET_EXCLUDED_SECTIONS, + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + Presets.save_preset(dlg.name(), selected_flat_dict(self.state.config, dlg.selected())) + self._refresh_presets(force=True) + + def _on_edit_clicked(self) -> None: + name = self._current_name() + cfg = self._preset_config(name) if name else None + if cfg is None: + return + dlg = GranularSettingsDialog(self, cfg, name, ask_name=True, exclude_sections=_PRESET_EXCLUDED_SECTIONS) + dlg.setWindowTitle("Edit Preset") + dlg.set_name(name) + if dlg.exec() == QDialog.DialogCode.Accepted: + new_name = dlg.name() + Presets.save_preset(new_name, selected_flat_dict(cfg, dlg.selected())) + if new_name != name: + Presets.delete_preset(name) + self._refresh_presets(force=True) + def _on_delete_clicked(self) -> None: from PyQt6.QtWidgets import QMessageBox - name = self.preset_combo.currentText() + name = self._current_name() if not name: return reply = QMessageBox.question( @@ -71,35 +160,21 @@ def _on_delete_clicked(self) -> None: ) if reply == QMessageBox.StandardButton.Yes: Presets.delete_preset(name) - self._refresh_presets() + self._refresh_presets(force=True) - def _on_load_clicked(self) -> None: - name = self.preset_combo.currentText() - if not name or not self.state.current_file_hash: - return - - p_settings = Presets.load_preset(name) - if p_settings: - current_dict = self.state.config.to_dict() - current_dict.update(p_settings) - new_config = WorkspaceConfig.from_flat_dict(current_dict) - self.controller.session.update_config(new_config, persist=True) - self.controller.request_render() - - def _on_save_clicked(self) -> None: - name = self.name_input.text() - if not name or not self.state.current_file_hash: + def _refresh_presets(self, force: bool = False) -> None: + # sync_ui fires on every state sync; skip the rebuild (N file reads) + # unless the name set actually changed. force covers same-name overwrites. + names = sorted(Presets.list_presets()) + if not force and names == [self.preset_list.item(i).text() for i in range(self.preset_list.count())]: return - - Presets.save_preset(name, self.state.config) - self._refresh_presets() - self.name_input.clear() - - def _refresh_presets(self) -> None: - self.preset_combo.blockSignals(True) - self.preset_combo.clear() - self.preset_combo.addItems(Presets.list_presets()) - self.preset_combo.blockSignals(False) + self.preset_list.clear() + for name in names: + item = QListWidgetItem(name) + data = Presets.load_preset(name) + if data: + item.setToolTip(wrap_tooltip(preset_summary(data))) + self.preset_list.addItem(item) def sync_ui(self) -> None: self._refresh_presets() diff --git a/negpy/desktop/view/styles/modern_dark.qss b/negpy/desktop/view/styles/modern_dark.qss index fe289d6c..e51edf36 100644 --- a/negpy/desktop/view/styles/modern_dark.qss +++ b/negpy/desktop/view/styles/modern_dark.qss @@ -498,7 +498,7 @@ QPushButton#collapsible_header[overlay="true"]:checked { background-color: rgba(26, 26, 26, 0.82); } -/* Preset list in the Export Presets dialog. */ +/* Preset lists: Export Presets dialog and the Presets sidebar. */ QListWidget#preset_list { background: #0D0D0D; border: 1px solid #262626; diff --git a/negpy/desktop/view/widgets/granular_settings_dialog.py b/negpy/desktop/view/widgets/granular_settings_dialog.py index bae60d96..a1b61102 100644 --- a/negpy/desktop/view/widgets/granular_settings_dialog.py +++ b/negpy/desktop/view/widgets/granular_settings_dialog.py @@ -6,6 +6,7 @@ QGridLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QRadioButton, QScrollArea, @@ -33,14 +34,25 @@ def __init__( show_bounds: bool = False, sel_count: int = 0, roll_count: int = 0, + ask_name: bool = False, + exclude_sections: frozenset[str] = frozenset(), + show_current: bool = False, + show_apply_mode: bool = False, ): super().__init__(parent) self._checks: list[tuple[QCheckBox, SettingRow]] = [] self._bounds_luma: QCheckBox | None = None self._bounds_colour: QCheckBox | None = None - self._scope = "selection" if sel_count > 0 else "roll" - - self.setWindowTitle("Paste Settings" if not show_scope else "Apply Settings") + self._name_edit: QLineEdit | None = None + if show_current: + self._scope = "current" + else: + self._scope = "selection" if sel_count > 0 else "roll" + + if ask_name: + self.setWindowTitle("Save Preset") + else: + self.setWindowTitle("Paste Settings" if not show_scope else "Apply Settings") self.setStyleSheet(f"QDialog {{ background: {THEME.bg_dark}; }}") self.resize(420, 620) @@ -52,29 +64,58 @@ def __init__( header.setStyleSheet(f"color: {THEME.text_primary}; font-weight: bold;") root.addWidget(header) + if ask_name: + self._name_edit = QLineEdit() + self._name_edit.setPlaceholderText("Preset name") + self._name_edit.textChanged.connect(self._update_apply_enabled) + root.addWidget(self._name_edit) if show_scope: - root.addLayout(self._build_scope_row(sel_count, roll_count)) + root.addLayout(self._build_scope_row(sel_count, roll_count, show_current)) + if show_apply_mode: + root.addLayout(self._build_mode_row()) root.addLayout(self._build_checks_row()) - root.addWidget(self._build_sections(source_cfg, show_bounds), 1) - root.addLayout(self._build_footer()) + root.addWidget(self._build_sections(source_cfg, show_bounds, exclude_sections), 1) + root.addLayout(self._build_footer(ask_name)) self._update_apply_enabled() - def _build_scope_row(self, sel_count: int, roll_count: int) -> QHBoxLayout: + def _build_scope_row(self, sel_count: int, roll_count: int, show_current: bool = False) -> QHBoxLayout: row = QHBoxLayout() self.scope_group = QButtonGroup(self) + if show_current: + self.current_radio = QRadioButton("Current frame") + self.scope_group.addButton(self.current_radio) + row.addWidget(self.current_radio) self.sel_radio = QRadioButton(f"Selected frames ({sel_count})") self.sel_radio.setEnabled(sel_count > 0) self.roll_radio = QRadioButton(f"Whole roll ({roll_count})") self.roll_radio.setEnabled(roll_count > 0) self.scope_group.addButton(self.sel_radio) self.scope_group.addButton(self.roll_radio) - (self.sel_radio if sel_count > 0 else self.roll_radio).setChecked(True) + if show_current: + self.current_radio.setChecked(True) + else: + (self.sel_radio if sel_count > 0 else self.roll_radio).setChecked(True) row.addWidget(self.sel_radio) row.addWidget(self.roll_radio) row.addStretch() return row + def _build_mode_row(self) -> QHBoxLayout: + row = QHBoxLayout() + self.mode_group = QButtonGroup(self) + self.overlay_radio = QRadioButton("Apply on top") + self.overlay_radio.setToolTip("Only the ticked settings change; the rest of each frame's edit stays") + self.replace_radio = QRadioButton("Replace look") + self.replace_radio.setToolTip("Reset look settings to defaults first — crop, rotation, metadata, export and retouch marks stay") + self.mode_group.addButton(self.overlay_radio) + self.mode_group.addButton(self.replace_radio) + self.overlay_radio.setChecked(True) + row.addWidget(self.overlay_radio) + row.addWidget(self.replace_radio) + row.addStretch() + return row + def _build_checks_row(self) -> QHBoxLayout: row = QHBoxLayout() check_all = QPushButton("Check All") @@ -86,7 +127,7 @@ def _build_checks_row(self) -> QHBoxLayout: row.addStretch() return row - def _build_sections(self, source_cfg, show_bounds: bool) -> QScrollArea: + def _build_sections(self, source_cfg, show_bounds: bool, exclude_sections: frozenset[str] = frozenset()) -> QScrollArea: scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QScrollArea.Shape.NoFrame) @@ -96,6 +137,8 @@ def _build_sections(self, source_cfg, show_bounds: bool) -> QScrollArea: col.setSpacing(THEME.space_sm) for title, rows in edited_sections(source_cfg): + if title in exclude_sections: + continue section = CollapsibleSection(title, expanded=True) section.set_modified(len(rows)) section.set_content(self._build_rows(rows)) @@ -138,12 +181,12 @@ def _build_bounds_rows(self) -> QWidget: col.addWidget(box) return body - def _build_footer(self) -> QHBoxLayout: + def _build_footer(self, ask_name: bool = False) -> QHBoxLayout: row = QHBoxLayout() row.addStretch() cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(self.reject) - self.apply_btn = QPushButton("Apply") + self.apply_btn = QPushButton("Save" if ask_name else "Apply") self.apply_btn.clicked.connect(self._on_apply) row.addWidget(cancel_btn) row.addWidget(self.apply_btn) @@ -159,16 +202,29 @@ def _set_all_checked(self, checked: bool) -> None: box.setChecked(checked) def _update_apply_enabled(self) -> None: - self.apply_btn.setEnabled(any(box.isChecked() for box in self._all_boxes())) + enabled = any(box.isChecked() for box in self._all_boxes()) + if self._name_edit is not None: + enabled = enabled and bool(self._name_edit.text().strip()) + self.apply_btn.setEnabled(enabled) def _on_apply(self) -> None: if hasattr(self, "sel_radio"): - self._scope = "selection" if self.sel_radio.isChecked() else "roll" + if getattr(self, "current_radio", None) is not None and self.current_radio.isChecked(): + self._scope = "current" + else: + self._scope = "selection" if self.sel_radio.isChecked() else "roll" self.accept() def selected(self) -> list[SettingRow]: return [row for box, row in self._checks if box.isChecked()] + def name(self) -> str: + return self._name_edit.text().strip() if self._name_edit is not None else "" + + def set_name(self, value: str) -> None: + if self._name_edit is not None: + self._name_edit.setText(value) + def bounds_flags(self) -> tuple[bool, bool]: return ( self._bounds_luma is not None and self._bounds_luma.isChecked(), @@ -178,6 +234,11 @@ def bounds_flags(self) -> tuple[bool, bool]: def scope(self) -> str: return self._scope + def apply_mode(self) -> str: + if getattr(self, "replace_radio", None) is not None and self.replace_radio.isChecked(): + return "replace" + return "overlay" + def open_paste_dialog(parent, controller) -> None: """Open the granular picker on the clipboard config and apply the chosen diff --git a/negpy/services/assets/presets.py b/negpy/services/assets/presets.py index 8cef194a..321c3e13 100644 --- a/negpy/services/assets/presets.py +++ b/negpy/services/assets/presets.py @@ -2,7 +2,6 @@ import os from typing import List, Dict, Any, Optional from negpy.kernel.system.config import APP_CONFIG -from negpy.domain.models import WorkspaceConfig class Presets: @@ -11,49 +10,11 @@ class Presets: """ @staticmethod - def save_preset(name: str, settings: WorkspaceConfig) -> None: - """ - Saves partial WorkspaceConfig to JSON. - """ + def save_preset(name: str, settings: Dict[str, Any]) -> None: os.makedirs(APP_CONFIG.presets_dir, exist_ok=True) - - # Exclude keys that are not relevant for presets - exclude_keys = { - "rotation", - "fine_rotation", - "autocrop", - "autocrop_mode", - "autocrop_offset", - "manual_dust_spots", - "manual_heal_strokes", - "local_adjustments", - "active_adjustment_idx", - "export_path", - "icc_input_path", - "icc_output_path", - "autocrop_assist_point", - "autocrop_assist_luma", - "manual_dust_size", - "locked_floors", - "locked_ceils", - "use_luma_average", - "use_colour_average", - "roll_name", - "analysis_buffer", - "luma_range_clip", - "color_range_clip", - "flip_horizontal", - "flip_vertical", - } - - settings_dict = settings.to_dict() - default_dict = WorkspaceConfig().to_dict() - - filtered = {k: v for k, v in settings_dict.items() if k in default_dict and k not in exclude_keys} - filepath = os.path.join(APP_CONFIG.presets_dir, f"{name}.json") with open(filepath, "w") as f_out: - json.dump(filtered, f_out, indent=4) + json.dump(settings, f_out, indent=4) @staticmethod def load_preset(name: str) -> Optional[Dict[str, Any]]: diff --git a/tests/test_granular_save_mode.py b/tests/test_granular_save_mode.py new file mode 100644 index 00000000..b2a24cb5 --- /dev/null +++ b/tests/test_granular_save_mode.py @@ -0,0 +1,71 @@ +"""GranularSettingsDialog ask_name / exclude_sections modes (preset save flow).""" + +from dataclasses import replace + +from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog +from negpy.domain.models import WorkspaceConfig + + +def _edited_cfg() -> WorkspaceConfig: + c = WorkspaceConfig() + return replace( + c, + exposure=replace(c.exposure, density=1.5), + geometry=replace(c.geometry, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), + ) + + +def test_save_mode_button_title_and_gating(qapp): + dlg = GranularSettingsDialog(None, _edited_cfg(), "current settings", ask_name=True) + assert dlg.windowTitle() == "Save Preset" + assert dlg.apply_btn.text() == "Save" + assert not dlg.apply_btn.isEnabled() + + dlg._name_edit.setText(" Portra ") + assert dlg.apply_btn.isEnabled() + assert dlg.name() == "Portra" + + dlg._set_all_checked(False) + assert not dlg.apply_btn.isEnabled() + + +def test_exclude_sections_hides_geometry_rows(qapp): + dlg = GranularSettingsDialog( + None, + _edited_cfg(), + "current settings", + ask_name=True, + exclude_sections=frozenset({"Crop", "Rotation"}), + ) + assert {row.label for _box, row in dlg._checks} == {"Print Density"} + + +def test_set_name_prefills_and_enables(qapp): + dlg = GranularSettingsDialog(None, _edited_cfg(), "Portra", ask_name=True) + dlg.set_name("Portra") + assert dlg.name() == "Portra" + assert dlg.apply_btn.isEnabled() + + +def test_scope_current_and_apply_mode(qapp): + dlg = GranularSettingsDialog( + None, _edited_cfg(), "P", show_scope=True, show_current=True, show_apply_mode=True, sel_count=2, roll_count=3 + ) + assert dlg.current_radio.isChecked() + assert dlg.apply_mode() == "overlay" + dlg._on_apply() + assert dlg.scope() == "current" + + dlg.replace_radio.setChecked(True) + dlg.sel_radio.setChecked(True) + dlg._on_apply() + assert dlg.scope() == "selection" + assert dlg.apply_mode() == "replace" + + +def test_default_mode_unchanged(qapp): + dlg = GranularSettingsDialog(None, _edited_cfg(), "clipboard") + assert dlg.windowTitle() == "Paste Settings" + assert dlg.apply_btn.text() == "Apply" + assert dlg.name() == "" + assert dlg.apply_btn.isEnabled() diff --git a/tests/test_presets_service.py b/tests/test_presets_service.py index c100e8fe..883daa5c 100644 --- a/tests/test_presets_service.py +++ b/tests/test_presets_service.py @@ -1,13 +1,13 @@ -from negpy.domain.models import WorkspaceConfig from negpy.kernel.system.config import APP_CONFIG from negpy.services.assets.presets import Presets -def test_save_delete_roundtrip(tmp_path, monkeypatch): +def test_save_load_delete_roundtrip(tmp_path, monkeypatch): monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) - Presets.save_preset("Portra Look", WorkspaceConfig()) + Presets.save_preset("Portra Look", {"density": 1.5}) assert "Portra Look" in Presets.list_presets() + assert Presets.load_preset("Portra Look") == {"density": 1.5} assert Presets.delete_preset("Portra Look") is True assert Presets.list_presets() == [] diff --git a/tests/test_presets_sidebar.py b/tests/test_presets_sidebar.py new file mode 100644 index 00000000..68128942 --- /dev/null +++ b/tests/test_presets_sidebar.py @@ -0,0 +1,135 @@ +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QDialog + +from negpy.desktop.session import DesktopSessionManager +from negpy.desktop.settings_catalog import all_rows +from negpy.desktop.view.sidebar.presets import PresetsSidebar +from negpy.domain.models import WorkspaceConfig +from negpy.infrastructure.storage.repository import StorageRepository +from negpy.kernel.system.config import APP_CONFIG +from negpy.services.assets.presets import Presets + + +def _sidebar() -> PresetsSidebar: + controller = SimpleNamespace(state=SimpleNamespace(config=WorkspaceConfig(), current_file_hash=None)) + return PresetsSidebar(controller) + + +def test_list_populates_with_summary_tooltip(qapp, tmp_path, monkeypatch): + monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) + Presets.save_preset("Portra", {"density": 1.5}) + sb = _sidebar() + assert [sb.preset_list.item(i).text() for i in range(sb.preset_list.count())] == ["Portra"] + assert "Print Density" in sb.preset_list.item(0).toolTip() + + +def test_edit_preset_renames_and_keeps_values(qapp, tmp_path, monkeypatch): + monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) + Presets.save_preset("Old", {"density": 1.5}) + sb = _sidebar() + sb.preset_list.setCurrentRow(0) + + mock_dlg = MagicMock() + mock_dlg.exec.return_value = QDialog.DialogCode.Accepted + mock_dlg.name.return_value = "New" + mock_dlg.selected.return_value = [next(r for r in all_rows() if r.label == "Print Density")] + with patch("negpy.desktop.view.sidebar.presets.GranularSettingsDialog", return_value=mock_dlg): + sb._on_edit_clicked() + + assert Presets.list_presets() == ["New"] + assert Presets.load_preset("New") == {"density": 1.5} + + +def _session() -> DesktopSessionManager: + repo = MagicMock(spec=StorageRepository) + repo.get_global_setting.return_value = None + repo.load_file_settings.return_value = None + repo.load_file_settings_by_path.return_value = None + repo.get_max_history_index.return_value = 0 + mgr = DesktopSessionManager(repo) + mgr.state.uploaded_files = [{"name": f"f{i}.tif", "path": f"/tmp/f{i}.tif", "hash": f"h{i}"} for i in range(3)] + mgr.asset_model.refresh() + mgr.state.selected_file_idx = 0 + mgr.state.selected_indices = [0, 1] + mgr.state.current_file_hash = "h0" + mgr.state.current_file_path = "/tmp/f0.tif" + return mgr + + +def _density_row(): + return next(r for r in all_rows() if r.label == "Print Density") + + +def _preset_cfg(density: float) -> WorkspaceConfig: + base = WorkspaceConfig() + return replace(base, exposure=replace(base.exposure, density=density)) + + +def test_apply_preset_fields_current_scope(qapp): + mgr = _session() + base = WorkspaceConfig() + mgr.state.config = replace(base, lab=replace(base.lab, vibrance=1.2)) + assert mgr.apply_preset_fields(_preset_cfg(1.5), [_density_row()], "current") == 1 + assert mgr.state.config.exposure.density == 1.5 + assert mgr.state.config.lab.vibrance == 1.2 + saved_hashes = [c.args[0] for c in mgr.repo.save_file_settings.call_args_list] + assert saved_hashes == ["h0"] + + +def test_apply_preset_fields_selection_scope(qapp): + mgr = _session() + assert mgr.apply_preset_fields(_preset_cfg(1.5), [_density_row()], "selection") == 2 + saved = {c.args[0]: c.args[1] for c in mgr.repo.save_file_settings.call_args_list} + assert saved["h1"].exposure.density == 1.5 + assert "h2" not in saved + assert mgr.state.config.exposure.density == 1.5 + + +def test_apply_preset_fields_roll_scope(qapp): + mgr = _session() + assert mgr.apply_preset_fields(_preset_cfg(1.5), [_density_row()], "roll") == 3 + saved = {c.args[0]: c.args[1] for c in mgr.repo.save_file_settings.call_args_list} + assert saved["h1"].exposure.density == 1.5 + assert saved["h2"].exposure.density == 1.5 + assert mgr.state.config.exposure.density == 1.5 + + +def test_apply_dialog_routes_scope_and_mode(qapp, tmp_path, monkeypatch): + monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) + Presets.save_preset("P", {"density": 1.5}) + controller = SimpleNamespace( + state=SimpleNamespace(config=WorkspaceConfig(), current_file_hash="h0", selected_indices=[0]), + session=MagicMock(), + request_render=MagicMock(), + ) + controller.session.asset_model.visible_actual_indices.return_value = [0, 1, 2] + controller.session.apply_preset_fields.return_value = 3 + sb = PresetsSidebar(controller) + sb.preset_list.setCurrentRow(0) + + mock_dlg = MagicMock() + mock_dlg.exec.return_value = QDialog.DialogCode.Accepted + mock_dlg.selected.return_value = [_density_row()] + mock_dlg.apply_mode.return_value = "replace" + mock_dlg.scope.return_value = "roll" + with patch("negpy.desktop.view.sidebar.presets.GranularSettingsDialog", return_value=mock_dlg): + sb._apply_preset() + + (src_cfg, rows, scope), _ = controller.session.apply_preset_fields.call_args + assert scope == "roll" + assert src_cfg.exposure.density == 1.5 + assert len(rows) > 1 + controller.request_render.assert_called_once() + + +def test_sync_ui_skips_rebuild_when_names_unchanged(qapp, tmp_path, monkeypatch): + monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) + Presets.save_preset("Portra", {"density": 1.5}) + sb = _sidebar() + sb.preset_list.item(0).setData(Qt.ItemDataRole.UserRole, "keep") + sb.sync_ui() + assert sb.preset_list.item(0).data(Qt.ItemDataRole.UserRole) == "keep" diff --git a/tests/test_retouch_logic.py b/tests/test_retouch_logic.py index e8755e67..18a015c4 100644 --- a/tests/test_retouch_logic.py +++ b/tests/test_retouch_logic.py @@ -347,13 +347,11 @@ def test_old_config_without_strokes_loads_default(): assert restored.retouch.manual_heal_strokes == [] -def test_preset_save_excludes_frame_specific_heals(tmp_path, monkeypatch): - from dataclasses import replace as dc_replace +def test_preset_save_excludes_frame_specific_heals(): + # Enforcement moved from the presets service to the catalog: dust spots and + # heal strokes have no SettingRow, so even saving every row can't leak them. + from negpy.desktop.settings_catalog import all_rows, selected_flat_dict - import negpy.services.assets.presets as presets_mod - from negpy.kernel.system.config import APP_CONFIG - - monkeypatch.setattr(presets_mod, "APP_CONFIG", dc_replace(APP_CONFIG, presets_dir=str(tmp_path))) cfg = WorkspaceConfig( retouch=RetouchConfig( dust_remove=True, @@ -361,8 +359,7 @@ def test_preset_save_excludes_frame_specific_heals(tmp_path, monkeypatch): manual_heal_strokes=[([[0.3, 0.4]], 5.0, 0.02, -0.01)], ) ) - presets_mod.Presets.save_preset("t", cfg) - data = json.loads((tmp_path / "t.json").read_text()) + data = selected_flat_dict(cfg, all_rows()) assert "manual_heal_strokes" not in data assert "manual_dust_spots" not in data assert data["dust_remove"] is True diff --git a/tests/test_settings_catalog_presets.py b/tests/test_settings_catalog_presets.py new file mode 100644 index 00000000..60de3c62 --- /dev/null +++ b/tests/test_settings_catalog_presets.py @@ -0,0 +1,61 @@ +"""Preset save/apply building blocks: selected_flat_dict row atomicity, +the flat-dict merge apply path, and preset_summary tooltips.""" + +from dataclasses import replace + +from negpy.desktop.settings_catalog import all_rows, preset_summary, selected_flat_dict +from negpy.domain.models import WorkspaceConfig + + +def _row(label: str): + return next(r for r in all_rows() if r.label == label) + + +def _merge(cfg: WorkspaceConfig, preset: dict) -> WorkspaceConfig: + d = cfg.to_dict() + d.update(preset) + return WorkspaceConfig.from_flat_dict(d) + + +def test_channel_grouped_row_stores_all_channels(): + base = WorkspaceConfig() + cfg = replace(base, process=replace(base.process, white_point_trim_red=0.1)) + data = selected_flat_dict(cfg, [_row("White Trim")]) + assert set(data) == {"white_point_trim_red", "white_point_trim_green", "white_point_trim_blue"} + assert data["white_point_trim_red"] == 0.1 + assert data["white_point_trim_green"] == base.process.white_point_trim_green + + +def test_crosstalk_row_bundles_fields(): + data = selected_flat_dict(WorkspaceConfig(), [_row("Crosstalk")]) + assert set(data) == {"crosstalk_strength", "crosstalk_profile", "crosstalk_matrix"} + + +def test_overlay_apply_preserves_unrelated_edits(): + base = WorkspaceConfig() + cfg = replace(base, lab=replace(base.lab, saturation=1.4)) + merged = _merge(cfg, {"density": 2.2}) + assert merged.lab.saturation == 1.4 + assert merged.exposure.density == 2.2 + + +def test_legacy_preset_key_migrates(): + merged = _merge(WorkspaceConfig(), {"true_black": False}) + assert merged.exposure.paper_black is True + + +def test_full_snapshot_preset_applies(): + base = WorkspaceConfig() + cfg = replace(base, lab=replace(base.lab, saturation=1.4)) + merged = _merge(cfg, WorkspaceConfig().to_dict()) + assert merged == WorkspaceConfig() + + +def test_preset_summary_lists_non_default_settings(): + s = preset_summary({"density": 1.5, "wb_cyan": 0.2, "bogus": 1}) + assert s == "Tone: Print Density\nColour: Cyan" + + +def test_preset_summary_skips_defaults_and_empty(): + assert preset_summary({"density": WorkspaceConfig().exposure.density}) == "" + assert preset_summary({}) == ""