diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4e8d9a66..2408cbfb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.41.0 +- Change: **Copy/paste and Apply-to-roll are now per-setting** — instead of a handful of broad section checkboxes, pasting settings and **Apply settings…** open a picker that lists exactly the settings you changed on the source frame, grouped in collapsible sections (Tone, Colour, Lab, Toning, Finish, Crop, Process, Retouch, Metadata, Export) with each value shown. Tick only what you want, hit Apply. Paste now pops the same picker (Ctrl+V) rather than replacing everything at once; per-frame things like dust spots, heal strokes and crop bounds are never overwritten. - 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. ## 0.40.0 diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 1bf6f2e4..36852822 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -7,6 +7,7 @@ from PyQt6.QtCore import QAbstractListModel, QModelIndex, QObject, Qt, pyqtSignal +from negpy.desktop.settings_catalog import apply_selected_fields from negpy.desktop.view.canvas.crop_guides import CropGuide from negpy.domain.models import ExportPreset, WorkspaceConfig from negpy.features.exposure.models import apply_targets @@ -295,20 +296,6 @@ def refresh(self) -> None: self.layoutChanged.emit() -_ASPECT_LABELS = { - "process": "Process", - "crop": "Crop", - "rotation": "Rotation", - "exposure": "Exposure", - "color": "Lab & Toning", - "finish": "Finish", - "bounds_luma": "Tonal span", - "bounds_colour": "Colour balance", -} - -_VALID_ASPECTS = frozenset(_ASPECT_LABELS) - - def _source_effective_bounds(process) -> Optional[tuple]: """The floors/ceils a source frame is currently rendering with. @@ -322,95 +309,6 @@ def _source_effective_bounds(process) -> Optional[tuple]: return None -def build_synced_config( - source: WorkspaceConfig, - target: WorkspaceConfig, - aspects: frozenset, - src_bounds: Optional[tuple], -) -> WorkspaceConfig: - """Pure per-target merge for a bulk "Apply to selected" action. - - `aspects` is a subset of _VALID_ASPECTS, checked independently in the Sync - Settings dialog. `src_bounds` is (floors, ceils) from _source_effective_bounds, - needed when bounds_luma/bounds_colour is checked. Builds the result by starting - from `target` and overlaying only the checked aspects, so anything not covered - by an aspect (flatfield, rgbscan, metadata, export, dust spots, per-frame local - bounds) always stays the target's own. - """ - out = target - - if "process" in aspects: - out = replace( - out, - process=replace( - source.process, - local_floors=out.process.local_floors, - local_ceils=out.process.local_ceils, - locked_floors=out.process.locked_floors, - locked_ceils=out.process.locked_ceils, - use_luma_average=out.process.use_luma_average, - use_colour_average=out.process.use_colour_average, - ), - ) - - if "crop" in aspects: - sg = source.geometry - out = replace( - out, - geometry=replace( - out.geometry, - auto_crop_enabled=sg.auto_crop_enabled, - autocrop_offset=sg.autocrop_offset, - autocrop_ratio=sg.autocrop_ratio, - autocrop_mode=sg.autocrop_mode, - manual_crop_rect=sg.manual_crop_rect, - ), - ) - - if "rotation" in aspects: - sg = source.geometry - out = replace( - out, - geometry=replace( - out.geometry, - rotation=sg.rotation, - fine_rotation=sg.fine_rotation, - flip_horizontal=sg.flip_horizontal, - flip_vertical=sg.flip_vertical, - ), - ) - - if "exposure" in aspects: - out = replace(out, exposure=source.exposure) - - if "color" in aspects: - out = replace(out, lab=source.lab, toning=source.toning) - - if "finish" in aspects: - # Heals are frame-specific: keep the target's spots AND strokes. - out = replace( - out, - retouch=replace( - source.retouch, - manual_dust_spots=out.retouch.manual_dust_spots, - manual_heal_strokes=out.retouch.manual_heal_strokes, - ), - finish=source.finish, - ) - - if aspects & {"bounds_luma", "bounds_colour"}: - floors, ceils = src_bounds - # locked_* is a shared pair, so always write it; toggle only the selected axis. - changes: dict = {"locked_floors": floors, "locked_ceils": ceils} - if "bounds_luma" in aspects: - changes["use_luma_average"] = True - if "bounds_colour" in aspects: - changes["use_colour_average"] = True - out = replace(out, process=replace(out.process, **changes)) - - return out - - def resolve_asset_rgbscan(params: WorkspaceConfig, asset: dict) -> WorkspaceConfig: """Overlay a frame's own RGB-scan triplet paths (from the asset dict) onto its export params — the authoritative source select_file uses. A non-triplet frame gets rgbscan @@ -874,23 +772,24 @@ def toggle_mark(self, mark: str) -> None: self.asset_model.refresh() self.files_changed.emit() - def sync_selected_settings(self, aspects: frozenset, scope: str = "selection") -> int: + def sync_selected_settings(self, rows, bounds_flags: tuple[bool, bool] = (False, False), scope: str = "selection") -> int: """ - Apply the active frame's settings to other frames. Returns the count changed. + Apply the active frame's chosen settings to other frames. Returns the count changed. - aspects: subset of _VALID_ASPECTS (process/crop/rotation/exposure/color/ - finish/bounds_luma/bounds_colour), checked independently. - scope: "selection" (the multi-selected frames) or "roll" (all loaded frames). + rows: SettingRows (from the granular picker) to copy from the source. + bounds_flags: (luma, colour) roll-baseline axes to broadcast; these need the + source's rendered bounds, not a config field. + scope: "selection" (the multi-selected frames) or "roll" (all loaded frames). """ - aspects = frozenset(aspects) & _VALID_ASPECTS - if self.state.selected_file_idx == -1 or not aspects: + rows = list(rows) + luma, colour = bounds_flags + if self.state.selected_file_idx == -1 or not (rows or luma or colour): return 0 source_config = self.state.config src_bounds = None - needs_bounds = bool(aspects & {"bounds_luma", "bounds_colour"}) - if needs_bounds: + if luma or colour: src_bounds = _source_effective_bounds(source_config.process) if src_bounds is None: self.settings_synced.emit("Render the source frame before syncing bounds") @@ -905,17 +804,26 @@ def sync_selected_settings(self, aspects: frozenset, scope: str = "selection") - target_hash = self.state.uploaded_files[idx]["hash"] target_config = self.repo.load_file_settings(target_hash) or WorkspaceConfig() target_path = self.state.uploaded_files[idx]["path"] - synced = build_synced_config(source_config, target_config, aspects, src_bounds) + synced = apply_selected_fields(source_config, target_config, rows) + if src_bounds is not None: + floors, ceils = src_bounds + changes: dict = {"locked_floors": floors, "locked_ceils": ceils} + if luma: + changes["use_luma_average"] = True + if colour: + changes["use_colour_average"] = True + synced = replace(synced, process=replace(synced.process, **changes)) self.push_external_history(target_hash, target_config, synced) self.repo.save_file_settings(target_hash, synced, file_path=target_path) count += 1 if count: - label = ", ".join(_ASPECT_LABELS[a] for a in _ASPECT_LABELS if a in aspects) + n = len(rows) + int(luma) + int(colour) + noun = "setting" if n == 1 else "settings" if scope == "roll": - msg = f"{label} synced to whole roll ({count} frames)" + msg = f"{n} {noun} synced to whole roll ({count} frames)" else: - msg = f"{label} synced to {count} frame{'s' if count != 1 else ''}" + msg = f"{n} {noun} synced to {count} frame{'s' if count != 1 else ''}" self.settings_synced.emit(msg) self.settings_saved.emit() return count @@ -1094,12 +1002,14 @@ def copy_settings(self, include_bounds: bool = False) -> None: def copy_settings_with_bounds(self) -> None: self.copy_settings(include_bounds=True) - def paste_settings(self) -> None: - if self.state.clipboard and self.state.current_file_hash: - import copy - - self.update_config(copy.deepcopy(self.state.clipboard), persist=True) - self.settings_pasted.emit() + def apply_pasted_fields(self, rows) -> None: + """Overlay the picked clipboard settings onto the active frame.""" + rows = list(rows) + if not rows or self.state.clipboard is None or not self.state.current_file_hash: + return + merged = apply_selected_fields(self.state.clipboard, self.state.config, rows) + self.update_config(merged, persist=True) + self.settings_pasted.emit() def persist_hidden_masks(self) -> None: """Writes the per-file mask hide-state through to settings so it survives restarts. diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py new file mode 100644 index 00000000..3f9290dc --- /dev/null +++ b/negpy/desktop/settings_catalog.py @@ -0,0 +1,252 @@ +"""Declarative catalog of copyable settings for the granular copy/paste/apply UI. + +Each SettingRow maps a human label to one or more fields of a WorkspaceConfig +sub-config. A row is "edited" when any of its fields differs from the default +config. Grouped rows (per-channel trims, linked metadata) copy their fields as a +unit so linked values can't drift apart. Excluded fields (per-frame bounds/dust/ +heal/masks, machine paths, derived caches) are simply not listed here. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Callable, Iterable, Optional + +from negpy.domain.models import WorkspaceConfig +from negpy.features.metadata.models import PUSH_PULL_LABELS + + +class SettingRow: + """One copyable setting. `fields` are config-field names on `section`; + `channels` gives per-channel letters for grouped numeric trims (e.g. "RGB").""" + + __slots__ = ("label", "section", "fields", "channels", "fmt") + + def __init__( + self, + label: str, + section: str, + fields: tuple[str, ...], + channels: str = "", + fmt: Optional[Callable[[tuple], str]] = None, + ): + self.label = label + self.section = section + self.fields = fields + self.channels = channels + self.fmt = fmt + + +def _fmt_scalar(v) -> str: + if isinstance(v, bool): + return "on" if v else "off" + if v is None: + return "—" + if isinstance(v, float): + return f"{v:g}" + if isinstance(v, (tuple, list)): + return "set" + return str(v) + + +def _format(row: SettingRow, values: tuple) -> str: + if row.fmt is not None: + return row.fmt(values) + if len(values) == 1: + return _fmt_scalar(values[0]) + if row.channels and len(row.channels) == len(values): + return " ".join(f"{c}{_fmt_scalar(v)}" for c, v in zip(row.channels, values)) + return " / ".join(_fmt_scalar(v) for v in values) + + +def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: + return SettingRow(label, section, tuple(fields), channels, fmt) + + +# fmt: off +CATALOG: list[tuple[str, tuple[SettingRow, ...]]] = [ + ("Process", ( + _row("Mode", "process", "process_mode"), + _row("Analysis Buffer", "process", "analysis_buffer"), + _row("Range", "process", "luma_range_clip"), + _row("Colour", "process", "color_range_clip"), + _row("White Point", "process", "white_point_offset"), + _row("White Trim", "process", "white_point_trim_red", "white_point_trim_green", "white_point_trim_blue", channels="RGB"), + _row("Black Point", "process", "black_point_offset"), + _row("Black Trim", "process", "black_point_trim_red", "black_point_trim_green", "black_point_trim_blue", channels="RGB"), + # Strength + profile + baked matrix copy atomically: strength alone would + # leave the target on a stale/None matrix. + _row("Crosstalk", "process", "crosstalk_strength", "crosstalk_profile", "crosstalk_matrix", fmt=lambda v: _fmt_scalar(v[0])), + )), + ("Crop", ( + _row("Auto Crop", "geometry", "auto_crop_enabled"), + _row("Crop Offset", "geometry", "autocrop_offset"), + _row("Crop Ratio", "geometry", "autocrop_ratio"), + _row("Crop Mode", "geometry", "autocrop_mode"), + _row("Manual Crop", "geometry", "manual_crop_rect"), + )), + ("Rotation", ( + _row("Rotation", "geometry", "rotation"), + _row("Fine Rotation", "geometry", "fine_rotation"), + _row("Flip Horizontal", "geometry", "flip_horizontal"), + _row("Flip Vertical", "geometry", "flip_vertical"), + )), + ("Tone", ( + _row("Print Density", "exposure", "density"), + _row("ISO-R Grade", "exposure", "grade"), + _row("Grade Trim", "exposure", "grade_trim_red", "grade_trim_green", "grade_trim_blue", channels="RGB"), + _row("Paper Black", "exposure", "paper_black"), + _row("Paper Dmin", "exposure", "paper_dmin"), + _row("Shadows Density", "exposure", "shadow_density"), + _row("Highlights Density", "exposure", "highlight_density"), + _row("Shadows Grade", "exposure", "shadow_grade"), + _row("Highlights Grade", "exposure", "highlight_grade"), + _row("Shadows Grade Trim", "exposure", "shadow_grade_trim_red", "shadow_grade_trim_green", "shadow_grade_trim_blue", channels="RGB"), + _row("Highlights Grade Trim", "exposure", "highlight_grade_trim_red", "highlight_grade_trim_green", "highlight_grade_trim_blue", channels="RGB"), + _row("Snap", "exposure", "midtone_gamma"), + _row("Snap Trim", "exposure", "midtone_gamma_trim_red", "midtone_gamma_trim_green", "midtone_gamma_trim_blue", channels="RGB"), + _row("Toe", "exposure", "toe"), + _row("Toe Width", "exposure", "toe_width"), + _row("Toe Trim", "exposure", "toe_trim_red", "toe_trim_green", "toe_trim_blue", channels="RGB"), + _row("Toe Width Trim", "exposure", "toe_width_trim_red", "toe_width_trim_green", "toe_width_trim_blue", channels="RGB"), + _row("Shoulder", "exposure", "shoulder"), + _row("Shoulder Width", "exposure", "shoulder_width"), + _row("Shoulder Trim", "exposure", "shoulder_trim_red", "shoulder_trim_green", "shoulder_trim_blue", channels="RGB"), + _row("Shoulder Width Trim", "exposure", "shoulder_width_trim_red", "shoulder_width_trim_green", "shoulder_width_trim_blue", channels="RGB"), + _row("Auto Exposure", "exposure", "auto_exposure"), + _row("Auto Contrast", "exposure", "auto_normalize_contrast"), + _row("Paper Profile", "exposure", "paper_profile"), + )), + ("Colour", ( + _row("Cyan", "exposure", "wb_cyan"), + _row("Magenta", "exposure", "wb_magenta"), + _row("Yellow", "exposure", "wb_yellow"), + _row("Shadow CMY", "exposure", "shadow_cyan", "shadow_magenta", "shadow_yellow", channels="CMY"), + _row("Highlight CMY", "exposure", "highlight_cyan", "highlight_magenta", "highlight_yellow", channels="CMY"), + _row("Cast Removal", "exposure", "cast_removal_strength"), + )), + ("Lab", ( + _row("Saturation", "lab", "saturation"), + _row("Vibrance", "lab", "vibrance"), + _row("Dye Mute", "lab", "chroma_damping"), + _row("CLAHE", "lab", "clahe_strength"), + _row("Sharpening", "lab", "sharpen"), + _row("Sharpen Method", "lab", "sharpen_method"), + _row("Radius", "lab", "sharpen_radius"), + _row("Masking", "lab", "sharpen_masking"), + _row("Denoise", "lab", "chroma_denoise"), + _row("Glow", "lab", "glow_amount"), + _row("Halation", "lab", "halation_strength"), + )), + ("Toning", ( + _row("Selenium", "toning", "selenium_strength"), + _row("Sepia", "toning", "sepia_strength"), + _row("Gold", "toning", "gold_strength"), + _row("Iron Blue", "toning", "blue_strength"), + _row("Copper", "toning", "copper_strength"), + _row("Vanadium", "toning", "vanadium_strength"), + _row("Shadow Hue", "toning", "shadow_tint_hue"), + _row("Shadow Strength", "toning", "shadow_tint_strength"), + _row("Highlight Hue", "toning", "highlight_tint_hue"), + _row("Highlight Strength", "toning", "highlight_tint_strength"), + )), + ("Finish", ( + _row("Vignette Burn", "finish", "vignette_stops"), + _row("Vignette Size", "finish", "vignette_size"), + _row("Vignette Roundness", "finish", "vignette_roundness"), + _row("Carrier Width", "finish", "carrier_width"), + _row("Carrier Roughness", "finish", "carrier_rough"), + _row("Border Width", "finish", "border_size"), + _row("Border Colour", "finish", "border_color"), + _row("Border Bottom Weight", "finish", "border_bottom_weight"), + _row("Border Match Paper", "finish", "border_match_paper"), + )), + ("Retouch", ( + _row("Dust Removal", "retouch", "dust_remove"), + _row("Dust Threshold", "retouch", "dust_threshold"), + _row("Dust Size", "retouch", "dust_size"), + _row("IR Removal", "retouch", "ir_dust_remove"), + _row("IR Threshold", "retouch", "ir_threshold"), + _row("IR Attenuation", "retouch", "ir_attenuation"), + )), + ("Metadata", ( + _row("Camera", "metadata", "camera_make", "camera_model", fmt=lambda v: " ".join(str(x) for x in v if x) or "—"), + _row("Lens", "metadata", "lens_make", "lens_model", fmt=lambda v: " ".join(str(x) for x in v if x) or "—"), + _row("Focal Length", "metadata", "focal_length_mm"), + _row("Max Aperture", "metadata", "max_aperture"), + _row("Film", "metadata", "film"), + _row("Film ISO", "metadata", "film_iso"), + _row("Film Manufacturer", "metadata", "film_manufacturer"), + _row("Film Colour Type", "metadata", "film_color_type"), + _row("Format", "metadata", "format", "format_other", fmt=lambda v: (v[1] if v[0] == "Other" and v[1] else v[0]) or "—"), + _row("Developer", "metadata", "developer"), + _row("Push/Pull", "metadata", "push_pull", fmt=lambda v: PUSH_PULL_LABELS.get(v[0], str(v[0]))), + _row("Scanning", "metadata", "scanning"), + _row("Exposure Override", "metadata", "exposure_override"), + _row("Protect Original Metadata", "metadata", "protect_original_metadata"), + )), + ("Export", ( + _row("Format", "export", "export_fmt"), + _row("JPEG Quality", "export", "jpeg_quality"), + _row("JXL Lossless", "export", "jxl_lossless"), + _row("JXL Distance", "export", "jxl_distance"), + _row("JXL Effort", "export", "jxl_effort"), + _row("WebP Quality", "export", "webp_quality"), + _row("WebP Lossless", "export", "webp_lossless"), + _row("WebP Method", "export", "webp_method"), + _row("Resolution Mode", "export", "export_resolution_mode"), + _row("Aspect Ratio", "export", "paper_aspect_ratio"), + _row("Print Size", "export", "export_print_size"), + _row("DPI", "export", "export_dpi"), + _row("Target Long Edge", "export", "export_target_long_edge_px"), + _row("Colour Space", "export", "export_color_space"), + _row("Filename Pattern", "export", "filename_pattern"), + _row("Overwrite", "export", "overwrite"), + _row("Output Mode", "export", "output_mode"), + _row("Sidecars", "export", "export_sidecars_enabled"), + )), +] +# fmt: on + +_DEFAULT = WorkspaceConfig() + + +def all_rows() -> list[SettingRow]: + return [r for _title, rows in CATALOG for r in rows] + + +def _row_edited(row: SettingRow, cfg: WorkspaceConfig) -> bool: + src = getattr(cfg, row.section) + dfl = getattr(_DEFAULT, row.section) + return any(getattr(src, f) != getattr(dfl, f) for f in row.fields) + + +def edited_sections(cfg: WorkspaceConfig) -> list[tuple[str, list[tuple[SettingRow, str]]]]: + """Per display section, the edited rows (differ from default) paired with a + formatted value string. Sections with no edited rows are dropped.""" + out: list[tuple[str, list[tuple[SettingRow, str]]]] = [] + for title, rows in CATALOG: + edited = [] + for r in rows: + if _row_edited(r, cfg): + values = tuple(getattr(getattr(cfg, r.section), f) for f in r.fields) + edited.append((r, _format(r, values))) + if edited: + out.append((title, edited)) + return out + + +def apply_selected_fields(source: WorkspaceConfig, target: WorkspaceConfig, rows: Iterable[SettingRow]) -> WorkspaceConfig: + """Overlay only the chosen rows' fields from source onto target (one replace + per section). Fields not listed — per-frame bounds, dust spots, heal strokes, + masks — stay the target's own.""" + by_section: dict[str, dict] = {} + for row in rows: + src_section = getattr(source, row.section) + changes = by_section.setdefault(row.section, {}) + for f in row.fields: + changes[f] = getattr(src_section, f) + out = target + for section, changes in by_section.items(): + out = replace(out, **{section: replace(getattr(out, section), **changes)}) + return out diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py index 7eba64c0..6273f248 100644 --- a/negpy/desktop/view/canvas/toolbar.py +++ b/negpy/desktop/view/canvas/toolbar.py @@ -15,6 +15,7 @@ from negpy.desktop.controller import AppController from negpy.desktop.view.keyboard_shortcuts import _context_undo +from negpy.desktop.view.widgets.granular_settings_dialog import open_paste_dialog from negpy.desktop.view.shortcut_registry import key_for, tooltip_with_shortcut from negpy.desktop.view.styles.theme import THEME from negpy.infrastructure.gpu.device import GPUDevice @@ -252,7 +253,7 @@ def _init_ui(self) -> None: ) self._action_copy_bounds.setToolTip("Copy settings plus the metering/normalization bounds") self._action_paste = overflow_menu.addAction( - qta.icon("fa5s.paste", color=icon_color), "Paste Settings Ctrl+V", self.session.paste_settings + qta.icon("fa5s.paste", color=icon_color), "Paste Settings Ctrl+V", lambda: open_paste_dialog(self, self.controller) ) self._action_paste.setToolTip("Paste the copied settings onto this image") overflow_menu.addSeparator() diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index f852103b..3d6a6d1d 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -8,6 +8,7 @@ from negpy.desktop.view.canvas.gpu_widget import GPUCanvasWidget from negpy.desktop.view.canvas.hud import CanvasHud from negpy.desktop.view.canvas.overlay import CanvasOverlay +from negpy.desktop.view.widgets.granular_settings_dialog import open_paste_dialog from negpy.infrastructure.gpu.device import GPUDevice from negpy.infrastructure.gpu.resources import GPUTexture from negpy.desktop.view.styles.theme import THEME @@ -575,7 +576,7 @@ def contextMenuEvent(self, event) -> None: act_copy_bounds = menu.addAction("Copy Settings + Bounds Ctrl+Shift+C") act_copy_bounds.triggered.connect(self._controller.session.copy_settings_with_bounds) # type: ignore[union-attr] act_paste = menu.addAction("Paste Settings Ctrl+V") - act_paste.triggered.connect(self._controller.session.paste_settings) # type: ignore[union-attr] + act_paste.triggered.connect(lambda: open_paste_dialog(self, self._controller)) # type: ignore[arg-type] act_paste.setEnabled(self.state.clipboard is not None) menu.addSeparator() act_reset = menu.addAction("Reset View") diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 37f36494..b04964c2 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -3,6 +3,7 @@ from PyQt6.QtGui import QKeySequence, QShortcut from negpy.desktop.session import ToolMode +from negpy.desktop.view.widgets.granular_settings_dialog import open_paste_dialog from negpy.desktop.view.shortcut_registry import ( REGISTRY, load_bindings, @@ -111,7 +112,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "export": controller.request_export, "copy": controller.session.copy_settings, "copy_with_bounds": controller.session.copy_settings_with_bounds, - "paste": controller.session.paste_settings, + "paste": lambda: open_paste_dialog(self.window, controller), "undo": lambda: _context_undo(controller), "redo": controller.session.redo, "show_shortcuts": lambda: _show_shortcuts(self.window), diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py index 74f952a6..3bf490f1 100644 --- a/negpy/desktop/view/sidebar/files.py +++ b/negpy/desktop/view/sidebar/files.py @@ -26,9 +26,10 @@ ) from negpy.desktop.controller import AppController +from negpy.desktop.session import _source_effective_bounds from negpy.desktop.view.confirm import confirm_unload from negpy.desktop.view.styles.theme import THEME -from negpy.desktop.view.widgets.sync_settings_dialog import SyncSettingsDialog +from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog, open_paste_dialog from negpy.infrastructure.filesystem.watcher import FolderWatchService from negpy.infrastructure.loaders.helpers import get_supported_raw_wildcards @@ -731,9 +732,19 @@ def _open_apply_dialog(self) -> None: sel_targets = len([i for i in set(state.selected_indices) if i != src and i in visible]) roll_targets = len([i for i in visible if i != src]) - dlg = SyncSettingsDialog(self, self._source_name(), sel_targets, roll_targets) + source_cfg = self.session.state.config + show_bounds = _source_effective_bounds(source_cfg.process) is not None + dlg = GranularSettingsDialog( + self, + source_cfg, + self._source_name(), + show_scope=True, + show_bounds=show_bounds, + sel_count=sel_targets, + roll_count=roll_targets, + ) if dlg.exec() == QDialog.DialogCode.Accepted: - self.session.sync_selected_settings(dlg.aspects(), dlg.scope()) + self.session.sync_selected_settings(dlg.selected(), dlg.bounds_flags(), dlg.scope()) def _build_context_menu(self) -> QMenu: state = self.session.state @@ -748,7 +759,7 @@ def _build_context_menu(self) -> QMenu: menu.addAction("Copy Settings Ctrl+C").triggered.connect(self.session.copy_settings) menu.addAction("Copy Settings + Bounds Ctrl+Shift+C").triggered.connect(self.session.copy_settings_with_bounds) act_paste = menu.addAction("Paste Settings Ctrl+V") - act_paste.triggered.connect(self.session.paste_settings) + act_paste.triggered.connect(lambda: open_paste_dialog(self, self.controller)) act_paste.setEnabled(state.clipboard is not None) menu.addAction("Reset Settings").triggered.connect(self.session.reset_settings) menu.addSeparator() diff --git a/negpy/desktop/view/widgets/granular_settings_dialog.py b/negpy/desktop/view/widgets/granular_settings_dialog.py new file mode 100644 index 00000000..bae60d96 --- /dev/null +++ b/negpy/desktop/view/widgets/granular_settings_dialog.py @@ -0,0 +1,190 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QRadioButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from negpy.desktop.settings_catalog import SettingRow, edited_sections +from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.collapsible import CollapsibleSection + + +class GranularSettingsDialog(QDialog): + """Per-setting picker for paste / apply-to-many. Lists one collapsible section + per edit area, showing only the settings that differ from default, each with a + checkbox and its value. Reuses the shortcut-editor's CollapsibleSection look.""" + + def __init__( + self, + parent, + source_cfg, + source_name: str, + *, + show_scope: bool = False, + show_bounds: bool = False, + sel_count: int = 0, + roll_count: int = 0, + ): + 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.setStyleSheet(f"QDialog {{ background: {THEME.bg_dark}; }}") + self.resize(420, 620) + + root = QVBoxLayout(self) + root.setContentsMargins(THEME.space_2xl, THEME.space_2xl, THEME.space_2xl, THEME.space_2xl) + root.setSpacing(THEME.space_xl) + + header = QLabel(f'From "{source_name}"' if source_name else "Nothing to apply") + header.setStyleSheet(f"color: {THEME.text_primary}; font-weight: bold;") + root.addWidget(header) + + 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()) + + self._update_apply_enabled() + + def _build_scope_row(self, sel_count: int, roll_count: int) -> QHBoxLayout: + row = QHBoxLayout() + self.scope_group = QButtonGroup(self) + 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) + row.addWidget(self.sel_radio) + row.addWidget(self.roll_radio) + row.addStretch() + return row + + def _build_checks_row(self) -> QHBoxLayout: + row = QHBoxLayout() + check_all = QPushButton("Check All") + check_all.clicked.connect(lambda: self._set_all_checked(True)) + check_none = QPushButton("Check None") + check_none.clicked.connect(lambda: self._set_all_checked(False)) + row.addWidget(check_all) + row.addWidget(check_none) + row.addStretch() + return row + + def _build_sections(self, source_cfg, show_bounds: bool) -> QScrollArea: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + container = QWidget() + col = QVBoxLayout(container) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(THEME.space_sm) + + for title, rows in edited_sections(source_cfg): + section = CollapsibleSection(title, expanded=True) + section.set_modified(len(rows)) + section.set_content(self._build_rows(rows)) + col.addWidget(section) + + if show_bounds: + section = CollapsibleSection("Roll baseline", expanded=True) + section.set_content(self._build_bounds_rows()) + col.addWidget(section) + + col.addStretch() + scroll.setWidget(container) + return scroll + + def _build_rows(self, rows: list[tuple[SettingRow, str]]) -> QWidget: + body = QWidget() + grid = QGridLayout(body) + grid.setContentsMargins(0, 0, 0, 0) + grid.setColumnStretch(0, 1) + for r, (row, value) in enumerate(rows): + box = QCheckBox(row.label) + box.setChecked(True) + box.stateChanged.connect(self._update_apply_enabled) + self._checks.append((box, row)) + val = QLabel(value) + val.setStyleSheet(f"color: {THEME.text_muted};") + val.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + grid.addWidget(box, r, 0) + grid.addWidget(val, r, 1) + return body + + def _build_bounds_rows(self) -> QWidget: + body = QWidget() + col = QVBoxLayout(body) + col.setContentsMargins(0, 0, 0, 0) + self._bounds_luma = QCheckBox("Tonal span") + self._bounds_colour = QCheckBox("Colour balance") + for box in (self._bounds_luma, self._bounds_colour): + box.stateChanged.connect(self._update_apply_enabled) + col.addWidget(box) + return body + + def _build_footer(self) -> QHBoxLayout: + row = QHBoxLayout() + row.addStretch() + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + self.apply_btn = QPushButton("Apply") + self.apply_btn.clicked.connect(self._on_apply) + row.addWidget(cancel_btn) + row.addWidget(self.apply_btn) + return row + + def _all_boxes(self) -> list[QCheckBox]: + boxes = [box for box, _ in self._checks] + boxes += [b for b in (self._bounds_luma, self._bounds_colour) if b is not None] + return boxes + + def _set_all_checked(self, checked: bool) -> None: + for box in self._all_boxes(): + box.setChecked(checked) + + def _update_apply_enabled(self) -> None: + self.apply_btn.setEnabled(any(box.isChecked() for box in self._all_boxes())) + + def _on_apply(self) -> None: + if hasattr(self, "sel_radio"): + 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 bounds_flags(self) -> tuple[bool, bool]: + return ( + self._bounds_luma is not None and self._bounds_luma.isChecked(), + self._bounds_colour is not None and self._bounds_colour.isChecked(), + ) + + def scope(self) -> str: + return self._scope + + +def open_paste_dialog(parent, controller) -> None: + """Open the granular picker on the clipboard config and apply the chosen + settings to the active frame. No-op when the clipboard is empty.""" + state = controller.session.state + if state.clipboard is None or not state.current_file_hash: + return + dlg = GranularSettingsDialog(parent, state.clipboard, "clipboard", show_scope=False) + if dlg.exec() == QDialog.DialogCode.Accepted: + controller.session.apply_pasted_fields(dlg.selected()) diff --git a/negpy/desktop/view/widgets/sync_settings_dialog.py b/negpy/desktop/view/widgets/sync_settings_dialog.py deleted file mode 100644 index 3dbb5e08..00000000 --- a/negpy/desktop/view/widgets/sync_settings_dialog.py +++ /dev/null @@ -1,122 +0,0 @@ -from PyQt6.QtWidgets import ( - QButtonGroup, - QCheckBox, - QDialog, - QHBoxLayout, - QLabel, - QPushButton, - QRadioButton, - QVBoxLayout, -) - -from negpy.desktop.view.styles.theme import THEME - -_GROUPS = ( - ("Setup", (("process", "Process"), ("crop", "Crop"), ("rotation", "Rotation"))), - ("Exposure", (("exposure", "Exposure"),)), - ("Lab & Toning", (("color", "Lab & Toning"),)), - ("Finish", (("finish", "Finish"),)), - ("Bounds", (("bounds_luma", "Tonal span"), ("bounds_colour", "Colour balance"))), -) - - -class SyncSettingsDialog(QDialog): - """Lightroom-style "Apply Settings" dialog: independent checkboxes + Apply.""" - - def __init__(self, parent, source_name: str, sel_count: int, roll_count: int): - super().__init__(parent) - self._aspects: frozenset = frozenset() - self._scope = "selection" if sel_count > 0 else "roll" - - self.setWindowTitle("Apply Settings") - self.setStyleSheet(f"QDialog {{ background: {THEME.bg_dark}; }}") - - root = QVBoxLayout(self) - root.setContentsMargins(THEME.space_2xl, THEME.space_2xl, THEME.space_2xl, THEME.space_2xl) - root.setSpacing(THEME.space_xl) - - header = QLabel(f'From "{source_name}"' if source_name else "No frame loaded") - header.setStyleSheet(f"color: {THEME.text_primary}; font-weight: bold;") - root.addWidget(header) - - root.addLayout(self._build_scope_row(sel_count, roll_count)) - root.addLayout(self._build_checks_row()) - root.addLayout(self._build_groups(), 1) - root.addLayout(self._build_footer()) - - self._update_apply_enabled() - - def _build_scope_row(self, sel_count: int, roll_count: int) -> QHBoxLayout: - row = QHBoxLayout() - self.scope_group = QButtonGroup(self) - - 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) - if sel_count > 0: - self.sel_radio.setChecked(True) - else: - self.roll_radio.setChecked(True) - - row.addWidget(self.sel_radio) - row.addWidget(self.roll_radio) - row.addStretch() - return row - - def _build_checks_row(self) -> QHBoxLayout: - row = QHBoxLayout() - check_all = QPushButton("Check All") - check_all.clicked.connect(lambda: self._set_all_checked(True)) - check_none = QPushButton("Check None") - check_none.clicked.connect(lambda: self._set_all_checked(False)) - row.addWidget(check_all) - row.addWidget(check_none) - row.addStretch() - return row - - def _build_groups(self) -> QVBoxLayout: - col = QVBoxLayout() - self._checkboxes: dict[str, QCheckBox] = {} - for group_label, items in _GROUPS: - label = QLabel(group_label.upper()) - label.setStyleSheet(f"color: {THEME.text_muted}; font-size: 10px; font-weight: bold; letter-spacing: 1px;") - col.addWidget(label) - for key, text in items: - box = QCheckBox(text) - box.stateChanged.connect(self._update_apply_enabled) - self._checkboxes[key] = box - col.addWidget(box) - return col - - def _build_footer(self) -> QHBoxLayout: - row = QHBoxLayout() - row.addStretch() - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.reject) - self.apply_btn = QPushButton("Apply") - self.apply_btn.clicked.connect(self._on_apply) - row.addWidget(cancel_btn) - row.addWidget(self.apply_btn) - return row - - def _set_all_checked(self, checked: bool) -> None: - for box in self._checkboxes.values(): - box.setChecked(checked) - - def _update_apply_enabled(self) -> None: - self.apply_btn.setEnabled(any(box.isChecked() for box in self._checkboxes.values())) - - def _on_apply(self) -> None: - self._aspects = frozenset(key for key, box in self._checkboxes.items() if box.isChecked()) - self._scope = "selection" if self.sel_radio.isChecked() else "roll" - self.accept() - - def aspects(self) -> frozenset: - return self._aspects - - def scope(self) -> str: - return self._scope diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 0823e2b7..e3aef26c 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -3,11 +3,18 @@ from dataclasses import replace from negpy.desktop.session import AppState, AssetListModel, DesktopSessionManager +from negpy.desktop.settings_catalog import all_rows from negpy.domain.models import WorkspaceConfig, GeometryConfig, RetouchConfig, ProcessConfig from negpy.features.rgbscan.models import RgbScanConfig from negpy.infrastructure.storage.repository import StorageRepository from negpy.kernel.system.config import APP_CONFIG +_ROWS = {r.label: r for r in all_rows()} + + +def _row(label: str): + return _ROWS[label] + class TestDesktopSessionSync(unittest.TestCase): def setUp(self): @@ -385,7 +392,7 @@ def test_sync_selected_settings_exclusions(self): self.mock_repo.load_file_settings.return_value = target_config self.session.update_selection([0, 1]) - self.session.sync_selected_settings(frozenset({"process", "exposure", "color", "finish"})) + self.session.sync_selected_settings([_row("Print Density"), _row("Mode"), _row("Dust Removal")]) args, _ = self.mock_repo.save_file_settings.call_args self.assertEqual(args[0], "hash2") @@ -393,13 +400,12 @@ def test_sync_selected_settings_exclusions(self): self.assertEqual(saved_config.exposure.density, 1.5) self.assertEqual(saved_config.process.process_mode, "E-6") - self.assertTrue(saved_config.process.e6_normalize) - # Geometry entirely preserved from target + # Geometry not selected → entirely preserved from target self.assertEqual(saved_config.geometry.rotation, 0) self.assertEqual(saved_config.geometry.fine_rotation, 0.0) self.assertIsNone(saved_config.geometry.manual_crop_rect) - # Per-file retouch fields preserved from target + # Per-file retouch fields preserved from target even though Dust Removal was synced self.assertEqual(saved_config.retouch.manual_dust_spots, []) self.assertTrue(saved_config.retouch.dust_remove) @@ -423,7 +429,7 @@ def test_sync_selected_settings_edits_with_geometry(self): self.mock_repo.load_file_settings.return_value = target_config self.session.update_selection([0, 1]) - self.session.sync_selected_settings(frozenset({"process", "exposure", "color", "finish", "crop", "rotation"})) + self.session.sync_selected_settings([_row("Print Density"), _row("Fine Rotation"), _row("Rotation"), _row("Manual Crop")]) args, _ = self.mock_repo.save_file_settings.call_args saved_config = args[1] @@ -453,7 +459,7 @@ def test_sync_selected_settings_geometry_only(self): self.mock_repo.load_file_settings.return_value = target_config self.session.update_selection([0, 1]) - self.session.sync_selected_settings(frozenset({"crop", "rotation"})) + self.session.sync_selected_settings([_row("Rotation"), _row("Fine Rotation"), _row("Manual Crop")]) args, _ = self.mock_repo.save_file_settings.call_args saved_config = args[1] @@ -465,13 +471,37 @@ def test_sync_selected_settings_geometry_only(self): # Other config preserved from target self.assertEqual(saved_config.exposure.density, 0.7) - def test_sync_selected_settings_invalid_aspect_is_noop(self): + def test_sync_selected_settings_empty_is_noop(self): self.session.state.selected_file_idx = 0 self.session.state.current_file_hash = "hash1" self.session.update_selection([0, 1]) - self.session.sync_selected_settings(frozenset({"bogus"})) + self.session.sync_selected_settings([]) self.mock_repo.save_file_settings.assert_not_called() + def test_apply_pasted_fields_applies_subset_and_renders(self): + self.session.state.current_file_hash = "hash1" + self.session.state.config = replace(WorkspaceConfig(), lab=replace(WorkspaceConfig().lab, saturation=1.9)) + self.session.state.clipboard = replace( + WorkspaceConfig(), + exposure=replace(WorkspaceConfig().exposure, density=2.2), + lab=replace(WorkspaceConfig().lab, saturation=0.3), + ) + rendered = [] + self.session.state_changed.connect(lambda: rendered.append(True)) + + self.session.apply_pasted_fields([_row("Print Density")]) + + self.assertEqual(self.session.state.config.exposure.density, 2.2) # pasted + self.assertEqual(self.session.state.config.lab.saturation, 1.9) # not selected → kept + self.assertTrue(rendered) + + def test_apply_pasted_fields_noop_when_clipboard_empty(self): + self.session.state.current_file_hash = "hash1" + self.session.state.clipboard = None + before = self.session.state.config + self.session.apply_pasted_fields([_row("Print Density")]) + self.assertIs(self.session.state.config, before) + def _seed_roll(self): self.session.state.uploaded_files = [ {"name": "a.arw", "path": "pa", "hash": "hash1"}, @@ -488,7 +518,7 @@ def test_sync_roll_scope_respects_active_filter(self): self._seed_roll() self.session.asset_model.set_filter(".arw", regex=False) # hides c.jpg - count = self.session.sync_selected_settings(frozenset({"exposure"}), scope="roll") + count = self.session.sync_selected_settings([_row("Print Density")], scope="roll") saved = {c.args[0] for c in self.mock_repo.save_file_settings.call_args_list} self.assertEqual(count, 1) @@ -498,7 +528,7 @@ def test_sync_roll_scope_no_filter_covers_all(self): self._seed_roll() self.session.asset_model.refresh() # no filter → every frame visible - count = self.session.sync_selected_settings(frozenset({"exposure"}), scope="roll") + count = self.session.sync_selected_settings([_row("Print Density")], scope="roll") saved = {c.args[0] for c in self.mock_repo.save_file_settings.call_args_list} self.assertEqual(count, 2) @@ -595,7 +625,7 @@ def test_sync_to_roll_records_target_history(self): self.session.asset_model.refresh() self.mock_repo.save_history_step.reset_mock() - count = self.session.sync_selected_settings(frozenset({"exposure"}), scope="roll") + count = self.session.sync_selected_settings([_row("Print Density")], scope="roll") self.assertEqual(count, 2) # Each target got a two-step write: pre-apply at 0, post-apply at 1. @@ -943,7 +973,7 @@ def test_sync_then_undo_restores_target(self): source = replace(self.session.state.config, exposure=replace(self.session.state.config.exposure, density=1.5)) self.session.update_config(source, persist=True) - count = self.session.sync_selected_settings(frozenset({"exposure"}), scope="roll") + count = self.session.sync_selected_settings([_row("Print Density")], scope="roll") self.assertEqual(count, 1) self.assertEqual(self.repo.load_file_settings("hash2").exposure.density, 1.5) diff --git a/tests/test_file_browser_widget.py b/tests/test_file_browser_widget.py index c7ab6b39..e698b8c2 100644 --- a/tests/test_file_browser_widget.py +++ b/tests/test_file_browser_widget.py @@ -1,3 +1,4 @@ +from dataclasses import replace from unittest.mock import MagicMock, patch import pytest @@ -6,10 +7,21 @@ from negpy.desktop.session import DesktopSessionManager from negpy.desktop.view.sidebar.files import THUMB_CELL_MAX, THUMB_CELL_MIN, FileBrowser from negpy.desktop.view.styles.theme import THEME -from negpy.desktop.view.widgets.sync_settings_dialog import SyncSettingsDialog +from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog +from negpy.domain.models import WorkspaceConfig from negpy.infrastructure.storage.repository import StorageRepository +def _edited_cfg() -> WorkspaceConfig: + """A config with a couple of non-default settings so the picker renders rows.""" + 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)), + ) + + @pytest.fixture def session(qapp): repo = MagicMock(spec=StorageRepository) @@ -134,8 +146,8 @@ def test_context_menu_multi_selection_adds_apply_and_remove_selected(browser, se assert "Unload" not in labels -def test_apply_dialog_shows_header_scope_and_counts(): - dlg = SyncSettingsDialog(None, "IMG_0001.cr2", sel_count=2, roll_count=3) +def test_apply_dialog_shows_header_scope_and_counts(qapp): + dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=2, roll_count=3) assert dlg.sel_radio.text() == "Selected frames (2)" assert dlg.sel_radio.isEnabled() assert dlg.sel_radio.isChecked() # selection preferred when it has targets @@ -143,53 +155,63 @@ def test_apply_dialog_shows_header_scope_and_counts(): assert dlg.roll_radio.isEnabled() -def test_apply_dialog_defaults_to_roll_when_selection_empty(): - dlg = SyncSettingsDialog(None, "IMG_0001.cr2", sel_count=0, roll_count=3) +def test_apply_dialog_defaults_to_roll_when_selection_empty(qapp): + dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=0, roll_count=3) assert not dlg.sel_radio.isEnabled() assert dlg.roll_radio.isChecked() -def test_apply_dialog_check_all_and_none(): - dlg = SyncSettingsDialog(None, "IMG_0001.cr2", sel_count=1, roll_count=3) +def test_apply_dialog_check_all_and_none(qapp): + dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=1, roll_count=3) + assert dlg.apply_btn.isEnabled() # rows checked by default + dlg._set_all_checked(False) + assert not any(box.isChecked() for box in dlg._all_boxes()) assert not dlg.apply_btn.isEnabled() dlg._set_all_checked(True) - assert all(box.isChecked() for box in dlg._checkboxes.values()) + assert all(box.isChecked() for box in dlg._all_boxes()) assert dlg.apply_btn.isEnabled() - dlg._set_all_checked(False) - assert not any(box.isChecked() for box in dlg._checkboxes.values()) - assert not dlg.apply_btn.isEnabled() -def test_apply_dialog_apply_collects_checked_aspects_and_scope(): - dlg = SyncSettingsDialog(None, "IMG_0001.cr2", sel_count=1, roll_count=3) - dlg._checkboxes["crop"].setChecked(True) - dlg._checkboxes["exposure"].setChecked(True) +def test_apply_dialog_apply_collects_checked_rows_and_scope(qapp): + dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=1, roll_count=3) dlg.roll_radio.setChecked(True) dlg._on_apply() - assert dlg.aspects() == frozenset({"crop", "exposure"}) + labels = {r.label for r in dlg.selected()} + assert "Print Density" in labels # the edited exposure setting + assert "Manual Crop" in labels # the edited geometry setting assert dlg.scope() == "roll" -def test_open_apply_dialog_routes_aspects_and_scope_to_session(browser, session): +def test_apply_dialog_only_lists_edited_settings(qapp): + dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=1, roll_count=3) + labels = {r.label for _box, r in dlg._checks} + assert labels == {"Print Density", "Manual Crop"} # nothing else was non-default + + +def test_open_apply_dialog_routes_rows_bounds_scope_to_session(browser, session): session.state.selected_indices = [0, 1] session.state.selected_file_idx = 0 session.sync_selected_settings = MagicMock() + rows = [object()] mock_dlg = MagicMock() mock_dlg.exec.return_value = QDialog.DialogCode.Accepted - mock_dlg.aspects.return_value = frozenset({"exposure"}) + mock_dlg.selected.return_value = rows + mock_dlg.bounds_flags.return_value = (False, False) mock_dlg.scope.return_value = "selection" - with patch("negpy.desktop.view.sidebar.files.SyncSettingsDialog", return_value=mock_dlg) as ctor: + with patch("negpy.desktop.view.sidebar.files.GranularSettingsDialog", return_value=mock_dlg) as ctor: browser._open_apply_dialog() - assert ctor.call_args.args[1:] == ("IMG_0001.cr2", 1, 3) # 1 other selected, 3 other on roll - session.sync_selected_settings.assert_called_once_with(frozenset({"exposure"}), "selection") + assert ctor.call_args.args[2] == "IMG_0001.cr2" + assert ctor.call_args.kwargs["sel_count"] == 1 # 1 other selected + assert ctor.call_args.kwargs["roll_count"] == 3 # 3 other on roll + session.sync_selected_settings.assert_called_once_with(rows, (False, False), "selection") def test_open_apply_dialog_noop_without_active_file(browser, session): session.state.selected_file_idx = -1 session.sync_selected_settings = MagicMock() - with patch("negpy.desktop.view.sidebar.files.SyncSettingsDialog") as ctor: + with patch("negpy.desktop.view.sidebar.files.GranularSettingsDialog") as ctor: browser._open_apply_dialog() ctor.assert_not_called() session.sync_selected_settings.assert_not_called() diff --git a/tests/test_sync_settings.py b/tests/test_sync_settings.py index 41ff084e..0c37e491 100644 --- a/tests/test_sync_settings.py +++ b/tests/test_sync_settings.py @@ -1,162 +1,107 @@ -"""Pure-merge logic for the Files sidebar "Apply to selected" bulk action.""" +"""Granular settings catalog + the per-field merge behind copy/paste and apply-to-many.""" from dataclasses import replace -from negpy.desktop.session import _source_effective_bounds, build_synced_config +from negpy.desktop.session import _source_effective_bounds +from negpy.desktop.settings_catalog import ( + CATALOG, + all_rows, + apply_selected_fields, + edited_sections, +) from negpy.domain.models import WorkspaceConfig -_BOUNDS = ((0.11, 0.22, 0.33), (0.88, 0.77, 0.66)) +_ROWS = {r.label: r for r in all_rows()} -def _source() -> WorkspaceConfig: +def _row(label: str): + return _ROWS[label] + + +# ── Catalog integrity ──────────────────────────────────────────────────────── + + +def test_every_catalog_field_exists_on_its_section(): + default = WorkspaceConfig() + for _title, rows in CATALOG: + for row in rows: + fields = getattr(default, row.section).__dataclass_fields__ + for f in row.fields: + assert f in fields, f"{row.label}: {row.section}.{f} is not a real field" + + +# ── edited_sections ────────────────────────────────────────────────────────── + + +def test_edited_sections_empty_for_default_config(): + assert edited_sections(WorkspaceConfig()) == [] + + +def test_edited_sections_lists_only_changed_rows_and_drops_empty_sections(): c = WorkspaceConfig() - return replace( - c, - exposure=replace(c.exposure, density=2.0, grade=130.0), - lab=replace(c.lab, saturation=1.5), - toning=replace(c.toning, sepia_strength=0.4), - finish=replace(c.finish, vignette_stops=0.3), - process=replace(c.process, process_mode="E-6", analysis_buffer=0.2), - geometry=replace(c.geometry, rotation=90, flip_horizontal=True, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), - retouch=replace( - c.retouch, - dust_threshold=0.5, - manual_dust_spots=[(0.5, 0.5, 0.01)], - manual_heal_strokes=[([[0.5, 0.5]], 8.0, 0.02, 0.0)], - ), - ) + cfg = replace(c, exposure=replace(c.exposure, density=1.4)) + sections = edited_sections(cfg) + assert len(sections) == 1 + title, rows = sections[0] + assert title == "Tone" + assert [r.label for r, _val in rows] == ["Print Density"] + assert rows[0][1] == "1.4" # formatted value -def _target() -> WorkspaceConfig: +def test_edited_sections_groups_trim_channels_into_one_row(): c = WorkspaceConfig() - return replace( + exp = replace(c.exposure, grade_trim_red=1.0, grade_trim_blue=-2.0) + _title, rows = edited_sections(replace(c, exposure=exp))[0] + by_label = {r.label: v for r, v in rows} + # one grouped "Grade Trim" row, value shows all three channels + assert by_label["Grade Trim"] == "R1 G0 B-2" + + +# ── apply_selected_fields ──────────────────────────────────────────────────── + + +def test_apply_copies_only_selected_rows(): + c = WorkspaceConfig() + src = replace(c, exposure=replace(c.exposure, density=2.0), lab=replace(c.lab, saturation=1.7)) + out = apply_selected_fields(src, c, [_row("Print Density")]) + assert out.exposure.density == 2.0 + assert out.lab.saturation == c.lab.saturation # not selected → untouched + + +def test_apply_grouped_trim_copies_all_channels(): + c = WorkspaceConfig() + src = replace(c, exposure=replace(c.exposure, grade_trim_red=1.0, grade_trim_green=2.0, grade_trim_blue=3.0)) + out = apply_selected_fields(src, c, [_row("Grade Trim")]) + assert (out.exposure.grade_trim_red, out.exposure.grade_trim_green, out.exposure.grade_trim_blue) == (1.0, 2.0, 3.0) + + +def test_apply_preserves_target_only_fields(): + c = WorkspaceConfig() + src = replace(c, exposure=replace(c.exposure, density=2.0)) + tgt = replace( c, - geometry=replace(c.geometry, rotation=270, manual_crop_rect=(0.2, 0.2, 0.8, 0.8)), - retouch=replace( - c.retouch, - manual_dust_spots=[(0.1, 0.1, 0.02)], - manual_heal_strokes=[([[0.2, 0.3], [0.4, 0.3]], 6.0, 0.0, 0.03)], - ), + retouch=replace(c.retouch, manual_dust_spots=[(0.5, 0.5, 0.01)]), process=replace(c.process, local_floors=(0.05, 0.05, 0.05), local_ceils=(0.95, 0.95, 0.95)), ) + out = apply_selected_fields(src, tgt, [_row("Print Density")]) + assert out.exposure.density == 2.0 + # per-frame fields never listed in the catalog → stay the target's own + assert out.retouch.manual_dust_spots == [(0.5, 0.5, 0.01)] + assert out.process.local_floors == (0.05, 0.05, 0.05) + + +def test_apply_crosstalk_copies_strength_profile_and_matrix_together(): + c = WorkspaceConfig() + src = replace( + c, process=replace(c.process, crosstalk_strength=0.4, crosstalk_profile="Portra", crosstalk_matrix=(1, 0, 0, 0, 1, 0, 0, 0, 1)) + ) + out = apply_selected_fields(src, c, [_row("Crosstalk")]) + assert out.process.crosstalk_strength == 0.4 + assert out.process.crosstalk_profile == "Portra" + assert out.process.crosstalk_matrix == (1, 0, 0, 0, 1, 0, 0, 0, 1) -def test_crop_only_copies_crop_keeps_rotation_and_flips(): - src, tgt = _source(), _target() - out = build_synced_config(src, tgt, frozenset({"crop"}), None) - assert out.geometry.manual_crop_rect == src.geometry.manual_crop_rect - assert out.geometry.rotation == tgt.geometry.rotation # rotation preserved - assert out.geometry.flip_horizontal == tgt.geometry.flip_horizontal # flip preserved - assert out.exposure == tgt.exposure - - -def test_rotation_only_copies_rotation_and_flips_keeps_crop(): - src, tgt = _source(), _target() - out = build_synced_config(src, tgt, frozenset({"rotation"}), None) - assert out.geometry.rotation == src.geometry.rotation - assert out.geometry.flip_horizontal == src.geometry.flip_horizontal # flips ride with rotation - assert out.geometry.manual_crop_rect == tgt.geometry.manual_crop_rect # crop preserved - assert out.exposure == tgt.exposure - - -def test_crop_and_rotation_together_cover_full_geometry(): - src, tgt = _source(), _target() - out = build_synced_config(src, tgt, frozenset({"crop", "rotation"}), None) - assert out.geometry == src.geometry - - -def test_process_aspect_copies_setup_keeps_bounds(): - src, tgt = _source(), _target() - out = build_synced_config(src, tgt, frozenset({"process"}), None) - assert out.process.process_mode == src.process.process_mode - assert out.process.analysis_buffer == src.process.analysis_buffer - # bounds-related fields stay the target's own - assert out.process.local_floors == tgt.process.local_floors - assert out.process.local_ceils == tgt.process.local_ceils - assert out.process.locked_floors == tgt.process.locked_floors - assert out.process.locked_ceils == tgt.process.locked_ceils - assert out.process.use_luma_average == tgt.process.use_luma_average - assert out.process.use_colour_average == tgt.process.use_colour_average - assert out.geometry == tgt.geometry - - -def test_exposure_aspect_copies_exposure_only(): - src, tgt = _source(), _target() - out = build_synced_config(src, tgt, frozenset({"exposure"}), None) - assert out.exposure == src.exposure - assert out.lab == tgt.lab - assert out.geometry == tgt.geometry - - -def test_color_aspect_copies_lab_and_toning(): - src, tgt = _source(), _target() - out = build_synced_config(src, tgt, frozenset({"color"}), None) - assert out.lab == src.lab - assert out.toning == src.toning - assert out.exposure == tgt.exposure - - -def test_finish_aspect_copies_retouch_and_finish_keeps_heals(): - src, tgt = _source(), _target() - out = build_synced_config(src, tgt, frozenset({"finish"}), None) - assert out.finish == src.finish - assert out.retouch.dust_threshold == src.retouch.dust_threshold - assert out.retouch.manual_dust_spots == tgt.retouch.manual_dust_spots # frame-specific, preserved - assert out.retouch.manual_heal_strokes == tgt.retouch.manual_heal_strokes # frame-specific, preserved - assert out.geometry == tgt.geometry - - -def test_all_aspects_checked_keeps_frame_specifics_and_untouched_fields(): - src, tgt = _source(), _target() - aspects = frozenset({"process", "crop", "rotation", "exposure", "color", "finish", "bounds_luma", "bounds_colour"}) - out = build_synced_config(src, tgt, aspects, _BOUNDS) - assert out.geometry == src.geometry - assert out.exposure == src.exposure - assert out.lab == src.lab - assert out.toning == src.toning - assert out.finish == src.finish - assert out.process.process_mode == src.process.process_mode - assert out.retouch.manual_dust_spots == tgt.retouch.manual_dust_spots - assert out.retouch.manual_heal_strokes == tgt.retouch.manual_heal_strokes - assert out.process.local_floors == tgt.process.local_floors # per-frame meter preserved - assert out.process.locked_floors == _BOUNDS[0] - assert out.process.locked_ceils == _BOUNDS[1] - assert out.process.use_luma_average and out.process.use_colour_average - # not a sync category: stays the target's own - assert out.flatfield == tgt.flatfield - assert out.rgbscan == tgt.rgbscan - assert out.metadata == tgt.metadata - - -def test_bounds_both_only_changes_baseline(): - src, tgt = _source(), _target() - out = build_synced_config(src, tgt, frozenset({"bounds_luma", "bounds_colour"}), _BOUNDS) - assert out.exposure == tgt.exposure - assert out.lab == tgt.lab - assert out.geometry == tgt.geometry - assert out.process.local_floors == tgt.process.local_floors # per-frame meter untouched - assert out.process.locked_floors == _BOUNDS[0] - assert out.process.locked_ceils == _BOUNDS[1] - assert out.process.use_luma_average is True - assert out.process.use_colour_average is True - - -def test_bounds_luma_preserves_other_axis(): - src, tgt = _source(), _target() - tgt = replace(tgt, process=replace(tgt.process, use_colour_average=True)) - out = build_synced_config(src, tgt, frozenset({"bounds_luma"}), _BOUNDS) - assert out.process.locked_floors == _BOUNDS[0] - assert out.process.use_luma_average is True - assert out.process.use_colour_average is True - - -def test_bounds_colour_preserves_other_axis(): - src, tgt = _source(), _target() - tgt = replace(tgt, process=replace(tgt.process, use_luma_average=True)) - out = build_synced_config(src, tgt, frozenset({"bounds_colour"}), _BOUNDS) - assert out.process.locked_ceils == _BOUNDS[1] - assert out.process.use_colour_average is True - assert out.process.use_luma_average is True +# ── _source_effective_bounds (roll-baseline broadcast) ─────────────────────── def test_source_effective_bounds_prefers_per_frame_meter():