From e6f0648cbc9b15a1d2ac3e221acc1f3a128d2bf5 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 23 Jul 2026 15:30:57 +0200 Subject: [PATCH 1/4] feat: redesign presets UX with granular save picker Presets are now overlays: a preset stores only the settings picked in the save dialog, applying sets those and leaves other edits alone. Legacy full-snapshot preset files still apply unchanged through the same flat-dict merge. - sidebar combo replaced with a visible preset list (double-click applies, tooltip summarizes contents) + Apply/Save/Edit/Delete row - GranularSettingsDialog gains ask_name + exclude_sections modes, reused for preset save and edit (rename + re-pick contents) - service exclude_keys deleted; policy now lives in the settings catalog + a Crop/Rotation dialog filter --- VERSION | 2 +- docs/CHANGELOG.md | 5 + negpy/desktop/settings_catalog.py | 28 ++- negpy/desktop/view/sidebar/presets.py | 160 +++++++++++------- negpy/desktop/view/styles/modern_dark.qss | 2 +- .../view/widgets/granular_settings_dialog.py | 38 ++++- negpy/services/assets/presets.py | 43 +---- tests/test_granular_save_mode.py | 55 ++++++ tests/test_presets_service.py | 6 +- tests/test_presets_sidebar.py | 50 ++++++ tests/test_retouch_logic.py | 13 +- tests/test_settings_catalog_presets.py | 61 +++++++ 12 files changed, 343 insertions(+), 120 deletions(-) create mode 100644 tests/test_granular_save_mode.py create mode 100644 tests/test_presets_sidebar.py create mode 100644 tests/test_settings_catalog_presets.py 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..a67917b0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## 0.42.0 + +- Change: **Presets redesigned** — the Presets section shows saved presets in a visible list (double-click applies, tooltips show what each preset stores), **Save…** opens the same per-setting picker as copy/paste with a name field, so you choose exactly which settings a preset contains, and an edit button reopens the picker to rename a preset or change what it stores. +- Change: **Presets overlay instead of replace** — applying a preset sets only the settings it stores and leaves the rest of your edit alone; per-frame crop and rotation are never carried. Existing preset files keep working and still replace the full look. + ## 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/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..f4c501bc 100644 --- a/negpy/desktop/view/sidebar/presets.py +++ b/negpy/desktop/view/sidebar/presets.py @@ -1,80 +1,74 @@ from PyQt6.QtWidgets import ( - QComboBox, - QPushButton, + QDialog, QHBoxLayout, - QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, ) import qtawesome as qta +from negpy.desktop.settings_catalog import 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"}) 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) + + 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.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") + 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 _on_delete_clicked(self) -> None: - from PyQt6.QtWidgets import QMessageBox + def _current_name(self) -> str: + item = self.preset_list.currentItem() + return item.text() if item is not None else "" - name = self.preset_combo.currentText() - if not name: - return - reply = QMessageBox.question( - self, - "Delete Preset", - f"Delete preset '{name}'?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Cancel, - ) - if reply == QMessageBox.StandardButton.Yes: - Presets.delete_preset(name) - self._refresh_presets() - - def _on_load_clicked(self) -> None: - name = self.preset_combo.currentText() + def _apply_preset(self) -> None: + name = self._current_name() if not name or not self.state.current_file_hash: return @@ -87,19 +81,69 @@ def _on_load_clicked(self) -> None: 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: + 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() + data = Presets.load_preset(name) if name else None + if not data: return + # Rebuild a config from the stored fields so the picker shows the + # preset's own values, not the current image's. + base = WorkspaceConfig().to_dict() + base.update(data) + cfg = WorkspaceConfig.from_flat_dict(base) + 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) - Presets.save_preset(name, self.state.config) - self._refresh_presets() - self.name_input.clear() + def _on_delete_clicked(self) -> None: + from PyQt6.QtWidgets import QMessageBox - 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) + name = self._current_name() + if not name: + return + reply = QMessageBox.question( + self, + "Delete Preset", + f"Delete preset '{name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + if reply == QMessageBox.StandardButton.Yes: + Presets.delete_preset(name) + self._refresh_presets(force=True) + + 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 + 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..1f8c76d7 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,20 @@ def __init__( show_bounds: bool = False, sel_count: int = 0, roll_count: int = 0, + ask_name: bool = False, + exclude_sections: frozenset[str] = frozenset(), ): super().__init__(parent) self._checks: list[tuple[QCheckBox, SettingRow]] = [] self._bounds_luma: QCheckBox | None = None self._bounds_colour: QCheckBox | None = None + self._name_edit: QLineEdit | None = None self._scope = "selection" if sel_count > 0 else "roll" - self.setWindowTitle("Paste Settings" if not show_scope else "Apply Settings") + 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,11 +59,16 @@ 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_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() @@ -86,7 +98,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 +108,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 +152,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,7 +173,10 @@ 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"): @@ -169,6 +186,13 @@ def _on_apply(self) -> None: 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(), 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..1c072a67 --- /dev/null +++ b/tests/test_granular_save_mode.py @@ -0,0 +1,55 @@ +"""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_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..2cc808c9 --- /dev/null +++ b/tests/test_presets_sidebar.py @@ -0,0 +1,50 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QDialog + +from negpy.desktop.settings_catalog import all_rows +from negpy.desktop.view.sidebar.presets import PresetsSidebar +from negpy.domain.models import WorkspaceConfig +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 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({}) == "" From b0784b783cfc852ab0d6bfc46cae23be44ef3bbd Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 23 Jul 2026 15:38:25 +0200 Subject: [PATCH 2/4] feat: ask apply mode when applying a preset Apply on top overlays only the preset's non-default fields (legacy snapshots stop clobbering unrelated edits); Replace edits resets the look sections to defaults first, keeping per-frame crop/rotation, metadata, export prefs, dust spots and masks. --- docs/CHANGELOG.md | 2 +- negpy/desktop/settings_catalog.py | 7 ++++ negpy/desktop/view/sidebar/presets.py | 53 ++++++++++++++++++++++---- tests/test_presets_sidebar.py | 41 ++++++++++++++++++++ tests/test_settings_catalog_presets.py | 8 +++- 5 files changed, 102 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a67917b0..56dc016c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.42.0 - Change: **Presets redesigned** — the Presets section shows saved presets in a visible list (double-click applies, tooltips show what each preset stores), **Save…** opens the same per-setting picker as copy/paste with a name field, so you choose exactly which settings a preset contains, and an edit button reopens the picker to rename a preset or change what it stores. -- Change: **Presets overlay instead of replace** — applying a preset sets only the settings it stores and leaves the rest of your edit alone; per-frame crop and rotation are never carried. Existing preset files keep working and still replace the full look. +- Change: **Applying a preset asks how** — **Apply on top** lays the preset's non-default settings over your current edits, **Replace edits** resets the look to the preset; per-frame crop, rotation, metadata, dust spots and masks are never touched. Existing preset files keep working. ## 0.41.0 diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index c19319ba..baaf77af 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -266,6 +266,13 @@ def _default_flat_json() -> dict[str, Any]: return json.loads(json.dumps(WorkspaceConfig().to_dict())) +def non_default_fields(data: Mapping[str, Any]) -> dict[str, Any]: + """Preset fields whose values differ from the defaults (json-normalized). + Unknown/legacy keys are kept — from_flat_dict migrations handle them.""" + dfl = _default_flat_json() + return {k: v for k, v in data.items() if k not in dfl or v != dfl[k]} + + 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.""" diff --git a/negpy/desktop/view/sidebar/presets.py b/negpy/desktop/view/sidebar/presets.py index f4c501bc..a2e79c5e 100644 --- a/negpy/desktop/view/sidebar/presets.py +++ b/negpy/desktop/view/sidebar/presets.py @@ -6,7 +6,13 @@ QPushButton, ) import qtawesome as qta -from negpy.desktop.settings_catalog import preset_summary, selected_flat_dict +from negpy.desktop.settings_catalog import ( + CATALOG, + apply_selected_fields, + non_default_fields, + preset_summary, + selected_flat_dict, +) from negpy.desktop.view.sidebar.base import BaseSidebar from negpy.desktop.view.styles.templates import wrap_tooltip from negpy.desktop.view.styles.theme import THEME @@ -16,6 +22,11 @@ _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): """ @@ -71,14 +82,42 @@ def _apply_preset(self) -> None: name = self._current_name() if not name or not self.state.current_file_hash: return + mode = self._ask_apply_mode(name) + if mode: + self._do_apply(name, mode) + def _ask_apply_mode(self, name: str) -> str: + from PyQt6.QtWidgets import QMessageBox + + box = QMessageBox(self) + box.setWindowTitle("Apply Preset") + box.setText(f'Apply "{name}" on top of your current edits, or replace them with the preset?') + overlay_btn = box.addButton("Apply on top", QMessageBox.ButtonRole.AcceptRole) + replace_btn = box.addButton("Replace edits", QMessageBox.ButtonRole.DestructiveRole) + box.addButton(QMessageBox.StandardButton.Cancel) + box.setDefaultButton(overlay_btn) + box.exec() + clicked = box.clickedButton() + if clicked is overlay_btn: + return "overlay" + if clicked is replace_btn: + return "replace" + return "" + + def _do_apply(self, name: str, mode: str) -> None: 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() + if not p_settings: + return + if mode == "replace": + base_cfg = apply_selected_fields(WorkspaceConfig(), self.state.config, _LOOK_ROWS) + else: + base_cfg = self.state.config + p_settings = non_default_fields(p_settings) + current_dict = base_cfg.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: if not self.state.current_file_hash: diff --git a/tests/test_presets_sidebar.py b/tests/test_presets_sidebar.py index 2cc808c9..42c008d1 100644 --- a/tests/test_presets_sidebar.py +++ b/tests/test_presets_sidebar.py @@ -1,3 +1,4 @@ +from dataclasses import replace from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -16,6 +17,15 @@ def _sidebar() -> PresetsSidebar: return PresetsSidebar(controller) +def _sidebar_with_file(config: WorkspaceConfig) -> PresetsSidebar: + controller = SimpleNamespace( + state=SimpleNamespace(config=config, current_file_hash="h1"), + session=MagicMock(), + request_render=MagicMock(), + ) + 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}) @@ -41,6 +51,37 @@ def test_edit_preset_renames_and_keeps_values(qapp, tmp_path, monkeypatch): assert Presets.load_preset("New") == {"density": 1.5} +def test_apply_overlay_merges_non_defaults_only(qapp, tmp_path, monkeypatch): + monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) + Presets.save_preset("P", {"density": 1.5, "saturation": 1.0}) + base = WorkspaceConfig() + cfg = replace(base, lab=replace(base.lab, saturation=1.4, vibrance=1.2)) + sb = _sidebar_with_file(cfg) + sb._do_apply("P", "overlay") + new_cfg = sb.controller.session.update_config.call_args[0][0] + assert new_cfg.exposure.density == 1.5 + assert new_cfg.lab.saturation == 1.4 + assert new_cfg.lab.vibrance == 1.2 + sb.controller.request_render.assert_called_once() + + +def test_apply_replace_resets_look_keeps_frame_state(qapp, tmp_path, monkeypatch): + monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) + Presets.save_preset("P", {"density": 1.5}) + base = WorkspaceConfig() + cfg = replace( + base, + lab=replace(base.lab, vibrance=1.2), + geometry=replace(base.geometry, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), + ) + sb = _sidebar_with_file(cfg) + sb._do_apply("P", "replace") + new_cfg = sb.controller.session.update_config.call_args[0][0] + assert new_cfg.exposure.density == 1.5 + assert new_cfg.lab.vibrance == base.lab.vibrance + assert new_cfg.geometry.manual_crop_rect == (0.1, 0.1, 0.9, 0.9) + + 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}) diff --git a/tests/test_settings_catalog_presets.py b/tests/test_settings_catalog_presets.py index 60de3c62..d931357d 100644 --- a/tests/test_settings_catalog_presets.py +++ b/tests/test_settings_catalog_presets.py @@ -3,7 +3,7 @@ from dataclasses import replace -from negpy.desktop.settings_catalog import all_rows, preset_summary, selected_flat_dict +from negpy.desktop.settings_catalog import all_rows, non_default_fields, preset_summary, selected_flat_dict from negpy.domain.models import WorkspaceConfig @@ -51,6 +51,12 @@ def test_full_snapshot_preset_applies(): assert merged == WorkspaceConfig() +def test_non_default_fields_filters_defaults_keeps_legacy(): + base = WorkspaceConfig() + data = {"density": base.exposure.density, "saturation": 1.4, "true_black": False} + assert non_default_fields(data) == {"saturation": 1.4, "true_black": False} + + 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" From 459fa04f8ad66d1791537f20e703f1ba1c4ee273 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 23 Jul 2026 16:07:55 +0200 Subject: [PATCH 3/4] feat: preset apply picker with scope and mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applying a preset opens the granular picker on the preset's own values (untick to skip), targeting current frame, selection or whole visible roll via session.apply_preset_fields — sync_selected_settings sibling that sources from the preset and includes the active frame. Overlay/ replace-look radios subsume the previous QMessageBox; replace prepends look-section reset rows sourced from the preset config's defaults. --- docs/CHANGELOG.md | 2 +- negpy/desktop/session.py | 37 ++++++ negpy/desktop/settings_catalog.py | 7 -- negpy/desktop/view/sidebar/presets.py | 78 ++++++------- .../view/widgets/granular_settings_dialog.py | 47 +++++++- tests/test_granular_save_mode.py | 16 +++ tests/test_presets_sidebar.py | 108 ++++++++++++------ tests/test_settings_catalog_presets.py | 8 +- 8 files changed, 208 insertions(+), 95 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 56dc016c..8ebf7a05 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.42.0 - Change: **Presets redesigned** — the Presets section shows saved presets in a visible list (double-click applies, tooltips show what each preset stores), **Save…** opens the same per-setting picker as copy/paste with a name field, so you choose exactly which settings a preset contains, and an edit button reopens the picker to rename a preset or change what it stores. -- Change: **Applying a preset asks how** — **Apply on top** lays the preset's non-default settings over your current edits, **Replace edits** resets the look to the preset; per-frame crop, rotation, metadata, dust spots and masks are never touched. Existing preset files keep working. +- Change: **Applying a preset shows what it applies** — a picker lists the preset's settings with their values (untick what you don't want), targets the current frame, the selected frames or the whole roll, and applies either on top of existing edits or as a look replacement; per-frame crop, rotation, metadata, dust spots and masks are never touched. Existing preset files keep working. ## 0.41.0 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 baaf77af..c19319ba 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -266,13 +266,6 @@ def _default_flat_json() -> dict[str, Any]: return json.loads(json.dumps(WorkspaceConfig().to_dict())) -def non_default_fields(data: Mapping[str, Any]) -> dict[str, Any]: - """Preset fields whose values differ from the defaults (json-normalized). - Unknown/legacy keys are kept — from_flat_dict migrations handle them.""" - dfl = _default_flat_json() - return {k: v for k, v in data.items() if k not in dfl or v != dfl[k]} - - 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.""" diff --git a/negpy/desktop/view/sidebar/presets.py b/negpy/desktop/view/sidebar/presets.py index a2e79c5e..615e500b 100644 --- a/negpy/desktop/view/sidebar/presets.py +++ b/negpy/desktop/view/sidebar/presets.py @@ -8,8 +8,6 @@ import qtawesome as qta from negpy.desktop.settings_catalog import ( CATALOG, - apply_selected_fields, - non_default_fields, preset_summary, selected_flat_dict, ) @@ -82,42 +80,41 @@ def _apply_preset(self) -> None: name = self._current_name() if not name or not self.state.current_file_hash: return - mode = self._ask_apply_mode(name) - if mode: - self._do_apply(name, mode) - - def _ask_apply_mode(self, name: str) -> str: - from PyQt6.QtWidgets import QMessageBox + preset_cfg = self._preset_config(name) + if preset_cfg is None: + return - box = QMessageBox(self) - box.setWindowTitle("Apply Preset") - box.setText(f'Apply "{name}" on top of your current edits, or replace them with the preset?') - overlay_btn = box.addButton("Apply on top", QMessageBox.ButtonRole.AcceptRole) - replace_btn = box.addButton("Replace edits", QMessageBox.ButtonRole.DestructiveRole) - box.addButton(QMessageBox.StandardButton.Cancel) - box.setDefaultButton(overlay_btn) - box.exec() - clicked = box.clickedButton() - if clicked is overlay_btn: - return "overlay" - if clicked is replace_btn: - return "replace" - return "" - - def _do_apply(self, name: str, mode: str) -> None: - p_settings = Presets.load_preset(name) - if not p_settings: + 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 - if mode == "replace": - base_cfg = apply_selected_fields(WorkspaceConfig(), self.state.config, _LOOK_ROWS) - else: - base_cfg = self.state.config - p_settings = non_default_fields(p_settings) - current_dict = base_cfg.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() + 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: @@ -135,14 +132,9 @@ def _on_save_clicked(self) -> None: def _on_edit_clicked(self) -> None: name = self._current_name() - data = Presets.load_preset(name) if name else None - if not data: + cfg = self._preset_config(name) if name else None + if cfg is None: return - # Rebuild a config from the stored fields so the picker shows the - # preset's own values, not the current image's. - base = WorkspaceConfig().to_dict() - base.update(data) - cfg = WorkspaceConfig.from_flat_dict(base) dlg = GranularSettingsDialog(self, cfg, name, ask_name=True, exclude_sections=_PRESET_EXCLUDED_SECTIONS) dlg.setWindowTitle("Edit Preset") dlg.set_name(name) diff --git a/negpy/desktop/view/widgets/granular_settings_dialog.py b/negpy/desktop/view/widgets/granular_settings_dialog.py index 1f8c76d7..a1b61102 100644 --- a/negpy/desktop/view/widgets/granular_settings_dialog.py +++ b/negpy/desktop/view/widgets/granular_settings_dialog.py @@ -36,13 +36,18 @@ def __init__( 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._name_edit: QLineEdit | None = None - self._scope = "selection" if sel_count > 0 else "roll" + if show_current: + self._scope = "current" + else: + self._scope = "selection" if sel_count > 0 else "roll" if ask_name: self.setWindowTitle("Save Preset") @@ -65,28 +70,52 @@ def __init__( 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, 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") @@ -180,7 +209,10 @@ def _update_apply_enabled(self) -> None: 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]: @@ -202,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/tests/test_granular_save_mode.py b/tests/test_granular_save_mode.py index 1c072a67..b2a24cb5 100644 --- a/tests/test_granular_save_mode.py +++ b/tests/test_granular_save_mode.py @@ -47,6 +47,22 @@ def test_set_name_prefills_and_enables(qapp): 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" diff --git a/tests/test_presets_sidebar.py b/tests/test_presets_sidebar.py index 42c008d1..68128942 100644 --- a/tests/test_presets_sidebar.py +++ b/tests/test_presets_sidebar.py @@ -5,9 +5,11 @@ 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 @@ -17,15 +19,6 @@ def _sidebar() -> PresetsSidebar: return PresetsSidebar(controller) -def _sidebar_with_file(config: WorkspaceConfig) -> PresetsSidebar: - controller = SimpleNamespace( - state=SimpleNamespace(config=config, current_file_hash="h1"), - session=MagicMock(), - request_render=MagicMock(), - ) - 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}) @@ -51,35 +44,86 @@ def test_edit_preset_renames_and_keeps_values(qapp, tmp_path, monkeypatch): assert Presets.load_preset("New") == {"density": 1.5} -def test_apply_overlay_merges_non_defaults_only(qapp, tmp_path, monkeypatch): - monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) - Presets.save_preset("P", {"density": 1.5, "saturation": 1.0}) +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() - cfg = replace(base, lab=replace(base.lab, saturation=1.4, vibrance=1.2)) - sb = _sidebar_with_file(cfg) - sb._do_apply("P", "overlay") - new_cfg = sb.controller.session.update_config.call_args[0][0] - assert new_cfg.exposure.density == 1.5 - assert new_cfg.lab.saturation == 1.4 - assert new_cfg.lab.vibrance == 1.2 - sb.controller.request_render.assert_called_once() + 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_replace_resets_look_keeps_frame_state(qapp, tmp_path, monkeypatch): +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}) - base = WorkspaceConfig() - cfg = replace( - base, - lab=replace(base.lab, vibrance=1.2), - geometry=replace(base.geometry, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), + controller = SimpleNamespace( + state=SimpleNamespace(config=WorkspaceConfig(), current_file_hash="h0", selected_indices=[0]), + session=MagicMock(), + request_render=MagicMock(), ) - sb = _sidebar_with_file(cfg) - sb._do_apply("P", "replace") - new_cfg = sb.controller.session.update_config.call_args[0][0] - assert new_cfg.exposure.density == 1.5 - assert new_cfg.lab.vibrance == base.lab.vibrance - assert new_cfg.geometry.manual_crop_rect == (0.1, 0.1, 0.9, 0.9) + 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): diff --git a/tests/test_settings_catalog_presets.py b/tests/test_settings_catalog_presets.py index d931357d..60de3c62 100644 --- a/tests/test_settings_catalog_presets.py +++ b/tests/test_settings_catalog_presets.py @@ -3,7 +3,7 @@ from dataclasses import replace -from negpy.desktop.settings_catalog import all_rows, non_default_fields, preset_summary, selected_flat_dict +from negpy.desktop.settings_catalog import all_rows, preset_summary, selected_flat_dict from negpy.domain.models import WorkspaceConfig @@ -51,12 +51,6 @@ def test_full_snapshot_preset_applies(): assert merged == WorkspaceConfig() -def test_non_default_fields_filters_defaults_keeps_legacy(): - base = WorkspaceConfig() - data = {"density": base.exposure.density, "saturation": 1.4, "true_black": False} - assert non_default_fields(data) == {"saturation": 1.4, "true_black": False} - - 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" From c6b4aefd596420a51d20562a48b5a0c37df32e0a Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 23 Jul 2026 16:10:52 +0200 Subject: [PATCH 4/4] docs: consolidate 0.42.0 presets changelog bullet --- docs/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8ebf7a05..53f52b5c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,8 +2,7 @@ ## 0.42.0 -- Change: **Presets redesigned** — the Presets section shows saved presets in a visible list (double-click applies, tooltips show what each preset stores), **Save…** opens the same per-setting picker as copy/paste with a name field, so you choose exactly which settings a preset contains, and an edit button reopens the picker to rename a preset or change what it stores. -- Change: **Applying a preset shows what it applies** — a picker lists the preset's settings with their values (untick what you don't want), targets the current frame, the selected frames or the whole roll, and applies either on top of existing edits or as a look replacement; per-frame crop, rotation, metadata, dust spots and masks are never touched. Existing preset files keep working. +- 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