From 3b9075d0b0d62b479b39bfbbd0635372dc3e0ea8 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Wed, 19 Aug 2026 22:45:53 +0200 Subject: [PATCH] feat(settings): let the user choose which settings persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which settings carried onto a freshly-opened file was hard-coded across ~25 inline blocks reading 38 `last_*` global keys. Eleven of those keys had no reader at all, six were written from five other places, and nothing enumerated them. Rewrite the mechanism onto the existing settings catalog: the sticky store is now one snapshot of the last persisted edit plus a list of chosen SettingRow ids, applied with apply_selected_fields. Every catalog row becomes stickyable, so a look setting can be opted in and a default one opted out. Persistent Settings… in the canvas overflow menu opens the picker, reusing GranularSettingsDialog in a new pick mode with tri-state group checkboxes on CollapsibleSection. Four carries stay hard-coded because they are not plain config-value copies: the rig-global flat-field profile, the Kelvin roll-locks, the 21 ExportConfig fields with no catalog row (output folder, ICC paths, contact-sheet layout), and the scan-setup prefs, which are kept out of the catalog so preset "Replace look" cannot reset the decode mode. Description fields keep their own key so the last confirm wins for the roll. Legacy keys migrate once on startup. --- docs/USER_GUIDE.md | 12 +- negpy/desktop/session.py | 236 +++++------------- negpy/desktop/settings_catalog.py | 127 ++++++---- negpy/desktop/sticky.py | 128 ++++++++++ negpy/desktop/view/canvas/toolbar.py | 11 + negpy/desktop/view/keyboard_shortcuts.py | 3 +- negpy/desktop/view/shortcut_registry.py | 1 + negpy/desktop/view/sidebar/sensor.py | 2 - negpy/desktop/view/widgets/collapsible.py | 35 +++ .../view/widgets/granular_settings_dialog.py | 74 +++++- tests/test_desktop_session.py | 129 +++++++--- tests/test_sticky_dialog.py | 61 +++++ tests/test_sticky_settings.py | 138 ++++++++++ 13 files changed, 678 insertions(+), 279 deletions(-) create mode 100644 negpy/desktop/sticky.py create mode 100644 tests/test_sticky_dialog.py create mode 100644 tests/test_sticky_settings.py diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 849c645c..b6775a39 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -11,9 +11,9 @@ This guide is for new users. It explains what each control does and when to reac ### Screen layout * **Left, the film strip**: your loaded frames as a contact sheet, plus import, sorting and triage tools. -* **Centre, the canvas**: the live preview. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. Scroll or pinch to zoom, drag to pan. A floating toolbar along the bottom holds Fit/1:1 zoom (**1:1** is one scan pixel per screen pixel, and lights up while you are at it; below **HQ** the preview is scaled up to reach it, which a **preview res · HQ off** pill on the canvas says), undo/redo, rotate/flip and more, moving overflow items into an **⋯** menu as the window narrows. **Edit Toolbar…** in that menu chooses which controls sit on the row and in what order (drag them into place); what does not fit collapses from the right, and the **⋯** menu keeps every action whatever the row shows. That menu also holds **Immersive Canvas** (the image fills the canvas and the toolbar overlaps it; turn it off to reserve space) **Show Slider Values** (every slider's value box stays open instead of appearing under the pointer) and **Reverse Scroll Zoom** (scroll up zooms out instead of in). Right-click the image for **Reset View** and **Sticky Zoom** (keeps the current zoom when you switch frames), plus the picker tools and copy/paste settings. With nothing loaded the canvas shows **Load some scans to get started**; click it for **Add files** or **Add folder**. +* **Centre, the canvas**: the live preview. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. Scroll or pinch to zoom, drag to pan. A floating toolbar along the bottom holds Fit/1:1 zoom (**1:1** is one scan pixel per screen pixel, and lights up while you are at it; below **HQ** the preview is scaled up to reach it, which a **preview res · HQ off** pill on the canvas says), undo/redo, rotate/flip and more, moving overflow items into an **⋯** menu as the window narrows. **Edit Toolbar…** in that menu chooses which controls sit on the row and in what order (drag them into place); what does not fit collapses from the right, and the **⋯** menu keeps every action whatever the row shows. That menu also holds **Immersive Canvas** (the image fills the canvas and the toolbar overlaps it; turn it off to reserve space) **Show Slider Values** (every slider's value box stays open instead of appearing under the pointer) and **Reverse Scroll Zoom** (scroll up zooms out instead of in). **Persistent Settings…** chooses which settings carry onto the next file you open. Right-click the image for **Reset View** and **Sticky Zoom** (keeps the current zoom when you switch frames), plus the picker tools and copy/paste settings. With nothing loaded the canvas shows **Load some scans to get started**; click it for **Add files** or **Add folder**. * **Left, the film strip**: your loaded frames as a contact sheet, plus import, sorting, and triage tools. -* **Centre, the canvas**: the live preview of the current frame. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. Scroll/pinch to zoom and drag to pan; a floating toolbar along the bottom holds Fit/1:1 zoom (**1:1** is one scan pixel per screen pixel, and lights up while you are at it; below **HQ** the preview is scaled up to reach it, which a **preview res · HQ off** pill on the canvas says) plus undo/redo, rotate/flip and more, moving overflow items into an **⋯** menu when the window narrows. **Edit Toolbar…** in that menu chooses which controls sit on the row and in what order (drag them into place); what does not fit collapses from the right, and the **⋯** menu keeps every action whatever the row shows. That menu also has **Immersive Canvas** (image fills the canvas and the toolbar overlaps it; turn off to reserve space so it never occludes the image) **Show Slider Values** (keeps every slider's value box open instead of revealing it under the pointer; turn it on if you work by the numbers) and **Reverse Scroll Zoom** (scroll up zooms out instead of in). Right-click the image for **Reset View** and **Sticky Zoom** (keeps the current zoom level when you switch to another frame, instead of resetting to fit), alongside the picker tools, copy/paste settings, and **Unload** (removes the frame from the session; its saved edit is kept). With nothing loaded it shows **Load some scans to get started**; click it for **Add files** / **Add folder**. +* **Centre, the canvas**: the live preview of the current frame. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. Scroll/pinch to zoom and drag to pan; a floating toolbar along the bottom holds Fit/1:1 zoom (**1:1** is one scan pixel per screen pixel, and lights up while you are at it; below **HQ** the preview is scaled up to reach it, which a **preview res · HQ off** pill on the canvas says) plus undo/redo, rotate/flip and more, moving overflow items into an **⋯** menu when the window narrows. **Edit Toolbar…** in that menu chooses which controls sit on the row and in what order (drag them into place); what does not fit collapses from the right, and the **⋯** menu keeps every action whatever the row shows. That menu also has **Immersive Canvas** (image fills the canvas and the toolbar overlaps it; turn off to reserve space so it never occludes the image) **Show Slider Values** (keeps every slider's value box open instead of revealing it under the pointer; turn it on if you work by the numbers) and **Reverse Scroll Zoom** (scroll up zooms out instead of in). **Persistent Settings…** chooses which settings carry onto the next file you open. Right-click the image for **Reset View** and **Sticky Zoom** (keeps the current zoom level when you switch to another frame, instead of resetting to fit), alongside the picker tools, copy/paste settings, and **Unload** (removes the frame from the session; its saved edit is kept). With nothing loaded it shows **Load some scans to get started**; click it for **Add files** / **Add folder**. * **Right, the controls**: a pinned **Analysis** readout at the top, and below it an icon tab bar. Each icon opens a *workflow page* holding one or more collapsible panels. ### Before / After @@ -49,6 +49,14 @@ A small **dot** on a panel header, and on a tab icon, means you changed somethin Both side panels can be narrowed to give the canvas more room. As the controls panel shrinks, tab icons that no longer fit move into a **»** menu at the right of the tab bar. The tab you are on always stays visible. +### What carries to the next frame + +Open a frame you have not edited and it does not start from bare defaults: the settings that belong to the *rig and the roll* rather than the picture come with it — film process, crop ratio, flips, calibration, paper stock, the Lab polish and your export preferences. The look itself (density, filtration, tone curve, toning, dodge and burn) starts clean on every frame. + +**Persistent Settings…** in the canvas **⋯** menu changes that list. Every setting the copy/paste picker knows is there, grouped by panel; tick one to make it carry, untick one to stop it. Tick the whole group from its header checkbox. Values shown are the ones from your last saved edit, so the list reads as what would actually carry. + +A frame you have already edited keeps its own look whatever you tick — only export and metadata settings reach it. **Reset Settings** on a frame ignores this list and returns it to bare defaults. + ### Menu bar (macOS) On macOS NegPy has a menu bar. Almost nothing in it is new: apart from Report an Issue, every item runs something a button, a window control or a keyboard shortcut already runs. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 7f7f22b0..a34fe7e8 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -7,7 +7,17 @@ from PyQt6.QtCore import QAbstractListModel, QModelIndex, QObject, Qt, pyqtSignal -from negpy.desktop.settings_catalog import apply_selected_fields +from negpy.desktop.settings_catalog import GLOBAL_TIER_SECTIONS, apply_selected_fields +from negpy.desktop.sticky import ( + ALWAYS_STICKY_PROCESS, + DESCRIPTION_FIELDS_KEY, + EXPORT_REMAINDER, + STICKY_CONFIG_KEY, + load_sticky_config, + load_sticky_rows, + migrate_legacy, + sticky_snapshot, +) from negpy.desktop.view.canvas.crop_guides import CropGuide from negpy.domain.models import ExportPreset, WorkspaceConfig from negpy.features.exposure.models import apply_targets @@ -593,6 +603,8 @@ def __init__(self, repo: StorageRepository): self.settings_saved.connect(self._invalidate_search_facts) # is_dirty initialised to False via AppState default + migrate_legacy(self.repo) + # Load global hardware settings saved_gpu = self.repo.get_global_setting("gpu_enabled") if saved_gpu is not None: @@ -789,34 +801,49 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa """ Overlays globally persisted settings onto the config. - Two tiers: - - only_global=True (file has a sidecar): only export preferences are overlaid. - - only_global=False (new file, no sidecar): workflow settings are overlaid - (process mode, roll name, analysis buffer, geometry defaults, export). - Per-image look settings (exposure, lab, toning, retouch) are intentionally - NOT carried over so that fresh files always start from clean defaults. + Which settings carry is the user's choice, held as catalog row ids and edited in + the Persistent Settings dialog. Two tiers: + - only_global=True (file has a sidecar): only GLOBAL_TIER_SECTIONS rows carry, so + the saved edit keeps its own look. + - only_global=False (new file, no sidecar): every chosen row carries. + + The carries below are hard-coded because they are not plain config-value copies: + the rig-global flat-field profile, the Kelvin roll-locks, the export fields with no + catalog row, and the scan-setup preferences. """ - from negpy.domain.models import ExportConfig + from negpy.features.metadata.models import resolve_description_fields sticky_export = self.repo.get_global_setting("last_export_config") if sticky_export: - valid_keys = ExportConfig.__dataclass_fields__.keys() - filtered = {k: v for k, v in sticky_export.items() if k in valid_keys} - config = replace(config, export=ExportConfig(**filtered)) + remainder = {k: v for k, v in sticky_export.items() if k in EXPORT_REMAINDER} + if remainder: + config = replace(config, export=replace(config.export, **remainder)) - sticky_protect = self.repo.get_global_setting("last_protect_original_metadata") - if sticky_protect is not None: - config = replace( - config, - metadata=replace(config.metadata, protect_original_metadata=bool(sticky_protect)), - ) + # Flat-field profile and distortion k1 are rig-global, so the active profile's values + # always override the per-file ones. New files default to enabled when a profile is + # active, and saved files keep their toggle. + active_ff = self.repo.get_global_setting("flatfield_active_profile") + ff_prof = FlatFieldProfiles.get(active_ff) if active_ff else None + ff_id = ff_prof.id if ff_prof else "" + ff_k1 = ff_prof.k1 if ff_prof else 0.0 + config = replace(config, flatfield=replace(config.flatfield, profile_id=ff_id, k1=ff_k1)) - # Description fields: unset (None) inherits the sticky roll choice, and an explicit - # per-frame tuple is left alone. + rows = load_sticky_rows(self.repo) + if only_global: + rows = [r for r in rows if r.section in GLOBAL_TIER_SECTIONS] + # Description fields carry on their own key, so the last Description… confirm wins + # for the roll rather than whichever frame was saved last. + wants_desc = any("description_fields" in r.fields for r in rows) + rows = [r for r in rows if "description_fields" not in r.fields] + + sticky_cfg = load_sticky_config(self.repo) + if sticky_cfg is not None and rows: + config = apply_selected_fields(sticky_cfg, config, rows) + + # Unset (None) inherits the sticky roll choice, then the gear-only defaults; an + # explicit per-frame tuple is left alone. if config.metadata.description_fields is None: - from negpy.features.metadata.models import resolve_description_fields - - sticky_desc = self.repo.get_global_setting("last_description_fields") + sticky_desc = self.repo.get_global_setting(DESCRIPTION_FIELDS_KEY) if wants_desc else None config = replace( config, metadata=replace( @@ -825,15 +852,6 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa ), ) - # Flat-field profile and distortion k1 are rig-global, so the active profile's values - # always override the per-file ones. New files default to enabled when a profile is - # active, and saved files keep their toggle. - active_ff = self.repo.get_global_setting("flatfield_active_profile") - ff_prof = FlatFieldProfiles.get(active_ff) if active_ff else None - ff_id = ff_prof.id if ff_prof else "" - ff_k1 = ff_prof.k1 if ff_prof else 0.0 - config = replace(config, flatfield=replace(config.flatfield, profile_id=ff_id, k1=ff_k1)) - # Temperature roll-locks (per region): re-aim each locked region's M/Y # pair at its Kelvin target, keeping the frame's own off-locus tint. for lock_key, m_field, y_field in ( @@ -853,163 +871,27 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa config = replace(config, flatfield=replace(config.flatfield, apply=bool(ff_id))) - # Workflow settings, safe to carry across all files on a roll - sticky_mode = self.repo.get_global_setting("last_process_mode") - sticky_buffer = self.repo.get_global_setting("last_analysis_buffer") - sticky_luma_range_clip = self.repo.get_global_setting("last_luma_range_clip") - sticky_color_range_clip = self.repo.get_global_setting("last_color_range_clip") - # The roll-average baseline is roll-scoped, written per-file by Batch Analysis or a - # saved roll, and never seeded onto fresh files. - sticky_crosstalk_strength = self.repo.get_global_setting("last_crosstalk_strength") - sticky_crosstalk_matrix = self.repo.get_global_setting("last_crosstalk_matrix") - sticky_crosstalk_profile = self.repo.get_global_setting("last_crosstalk_profile") - sticky_sensor_matrix = self.repo.get_global_setting("last_sensor_matrix") - sticky_sensor_profile = self.repo.get_global_setting("last_sensor_profile") - new_process = config.process - if sticky_mode: - new_process = replace(new_process, process_mode=sticky_mode) - if sticky_buffer is not None: - new_process = replace(new_process, analysis_buffer=float(sticky_buffer)) - if sticky_luma_range_clip is not None: - new_process = replace(new_process, luma_range_clip=float(sticky_luma_range_clip)) - if sticky_color_range_clip is not None: - new_process = replace(new_process, color_range_clip=float(sticky_color_range_clip)) - if sticky_crosstalk_strength is not None: - new_process = replace(new_process, crosstalk_strength=float(sticky_crosstalk_strength)) - if sticky_crosstalk_matrix: - new_process = replace(new_process, crosstalk_matrix=tuple(sticky_crosstalk_matrix)) - if sticky_crosstalk_profile: - new_process = replace(new_process, crosstalk_profile=str(sticky_crosstalk_profile)) - if sticky_sensor_matrix: - new_process = replace(new_process, sensor_matrix=tuple(sticky_sensor_matrix)) - if sticky_sensor_profile: - new_process = replace(new_process, sensor_profile=str(sticky_sensor_profile)) - config = replace(config, process=new_process) - - sticky_ratio = self.repo.get_global_setting("last_aspect_ratio") - sticky_autocrop_mode = self.repo.get_global_setting("last_autocrop_mode") - sticky_offset = self.repo.get_global_setting("last_autocrop_offset") - sticky_rebate_trim = self.repo.get_global_setting("last_autocrop_rebate_trim") - sticky_flip_h = self.repo.get_global_setting("last_flip_horizontal") - sticky_flip_v = self.repo.get_global_setting("last_flip_vertical") - new_geo = config.geometry - if sticky_ratio: - new_geo = replace(new_geo, autocrop_ratio=sticky_ratio) - if sticky_autocrop_mode: - new_geo = replace(new_geo, autocrop_mode=str(sticky_autocrop_mode)) - if sticky_offset is not None: - new_geo = replace(new_geo, autocrop_offset=int(sticky_offset)) - if sticky_rebate_trim is not None: - new_geo = replace(new_geo, autocrop_rebate_trim=float(sticky_rebate_trim)) - if sticky_flip_h is not None: - new_geo = replace(new_geo, flip_horizontal=bool(sticky_flip_h)) - if sticky_flip_v is not None: - new_geo = replace(new_geo, flip_vertical=bool(sticky_flip_v)) - config = replace(config, geometry=new_geo) - - sticky_lab = self.repo.get_global_setting("last_lab_config") - if sticky_lab: - from negpy.features.lab.models import LabConfig - - valid_keys = LabConfig.__dataclass_fields__.keys() - config = replace(config, lab=LabConfig(**{k: v for k, v in sticky_lab.items() if k in valid_keys})) - - # Exposure, toning and retouch are per-image look decisions and stay out of here: - # fresh files start from WorkspaceConfig defaults. linear_raw and dust_remove are the - # exception, since they are workflow preferences. - sticky_linear_raw = self.repo.get_global_setting("last_linear_raw") - if sticky_linear_raw is not None: - config = replace(config, process=replace(config.process, linear_raw=bool(sticky_linear_raw))) - sticky_narrowband = self.repo.get_global_setting("last_narrowband_scan") - if sticky_narrowband is not None: - config = replace(config, process=replace(config.process, narrowband_scan=bool(sticky_narrowband))) - # Hue Trim is a property of the light source, not the frame, so it carries across files. - sticky_hue_trim = self.repo.get_global_setting("last_hue_trim") - if sticky_hue_trim is not None: - config = replace(config, process=replace(config.process, hue_trim=float(sticky_hue_trim))) - - # The processing toggles (Auto Density, Auto Grade, Shadow Neutral, Paper White, - # Paper Black, Cast Removal) are workflow preferences, not per-image looks, so carry - # them to fresh files unless a file changes them. - new_exp = config.exposure - for key, attr in ( - ("last_auto_exposure", "auto_exposure"), - ("last_auto_normalize_contrast", "auto_normalize_contrast"), - ("last_paper_dmin", "paper_dmin"), - ("last_paper_black", "paper_black"), - ): - val = self.repo.get_global_setting(key) + for legacy_key, attr in ALWAYS_STICKY_PROCESS: + val = self.repo.get_global_setting(legacy_key) if val is not None: - new_exp = replace(new_exp, **{attr: bool(val)}) - # True Black renamed to Paper Black (inverted); honour a legacy sticky pref. - if self.repo.get_global_setting("last_paper_black") is None: - legacy_bpc = self.repo.get_global_setting("last_true_black") - if legacy_bpc is not None: - new_exp = replace(new_exp, paper_black=not bool(legacy_bpc)) - sticky_cast_removal = self.repo.get_global_setting("last_cast_removal_strength") - if sticky_cast_removal is not None: - new_exp = replace(new_exp, cast_removal_strength=float(sticky_cast_removal)) - config = replace(config, exposure=new_exp) - - # Paper stock is roll-wide, and the render guards against a cross-mode leak. - sticky_paper = self.repo.get_global_setting("last_paper_profile") - if sticky_paper: - config = replace(config, exposure=replace(config.exposure, paper_profile=str(sticky_paper))) - - # Exception: dust_remove is a workflow preference, not an image-specific look. - sticky_dust = self.repo.get_global_setting("last_dust_remove") - if sticky_dust is not None: - config = replace(config, retouch=replace(config.retouch, dust_remove=bool(sticky_dust))) - - return config + new_process = replace(new_process, **{attr: bool(val)}) + return replace(config, process=new_process) def _persist_sticky_settings(self, config: WorkspaceConfig) -> None: - """ - Saves current settings to global storage in a single transaction. + """Snapshot the settings a fresh file can inherit, in a single transaction. + + `last_export_config` is separate from the snapshot because EXPORT_REMAINDER — the + output folder, ICC paths, contact-sheet layout — has no catalog row to travel on. """ from dataclasses import asdict self.repo.save_global_settings( { - "last_process_mode": config.process.process_mode, - "last_analysis_buffer": config.process.analysis_buffer, - "last_luma_range_clip": config.process.luma_range_clip, - "last_color_range_clip": config.process.color_range_clip, - "last_crosstalk_strength": config.process.crosstalk_strength, - "last_crosstalk_matrix": config.process.crosstalk_matrix, - "last_crosstalk_profile": config.process.crosstalk_profile, - "last_sensor_matrix": config.process.sensor_matrix, - "last_sensor_profile": config.process.sensor_profile, - "last_density": config.exposure.density, - "last_grade": config.exposure.grade, - "last_wb_cyan": config.exposure.wb_cyan, - "last_wb_magenta": config.exposure.wb_magenta, - "last_wb_yellow": config.exposure.wb_yellow, + STICKY_CONFIG_KEY: sticky_snapshot(config), + "last_export_config": asdict(config.export), "last_linear_raw": config.process.linear_raw, "last_narrowband_scan": config.process.narrowband_scan, - "last_auto_exposure": config.exposure.auto_exposure, - "last_auto_normalize_contrast": config.exposure.auto_normalize_contrast, - "last_paper_dmin": config.exposure.paper_dmin, - "last_paper_black": config.exposure.paper_black, - "last_cast_removal_strength": config.exposure.cast_removal_strength, - "last_paper_profile": config.exposure.paper_profile, - "last_toe": config.exposure.toe, - "last_toe_width": config.exposure.toe_width, - "last_shoulder": config.exposure.shoulder, - "last_shoulder_width": config.exposure.shoulder_width, - "last_aspect_ratio": config.geometry.autocrop_ratio, - "last_autocrop_mode": config.geometry.autocrop_mode, - "last_autocrop_offset": config.geometry.autocrop_offset, - "last_autocrop_rebate_trim": config.geometry.autocrop_rebate_trim, - "last_flip_horizontal": config.geometry.flip_horizontal, - "last_flip_vertical": config.geometry.flip_vertical, - "last_export_config": asdict(config.export), - "last_lab_config": asdict(config.lab), - "last_toning_config": asdict(config.toning), - "last_retouch_config": asdict(config.retouch), - "last_dust_remove": config.retouch.dust_remove, - "last_protect_original_metadata": config.metadata.protect_original_metadata, } ) diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index be0966f7..06a08e33 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -21,9 +21,10 @@ class SettingRow: """One copyable setting. `fields` are config-field names on `section`; - `channels` gives per-channel letters for grouped numeric trims (e.g. "RGB").""" + `channels` gives per-channel letters for grouped numeric trims (e.g. "RGB"). + `sticky` marks the rows that carry onto a freshly-opened file by default.""" - __slots__ = ("label", "section", "fields", "channels", "fmt") + __slots__ = ("label", "section", "fields", "channels", "fmt", "sticky") def __init__( self, @@ -32,12 +33,19 @@ def __init__( fields: tuple[str, ...], channels: str = "", fmt: Optional[Callable[[tuple], str]] = None, + sticky: bool = False, ): self.label = label self.section = section self.fields = fields self.channels = channels self.fmt = fmt + self.sticky = sticky + + @property + def id(self) -> str: + """Stable identity for persistence. Unique because no field is listed twice.""" + return f"{self.section}.{self.fields[0]}" def _fmt_scalar(v) -> str: @@ -62,34 +70,34 @@ def _format(row: SettingRow, values: tuple) -> str: 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) +def _row(label, section, *fields, channels="", fmt=None, sticky=False) -> SettingRow: + return SettingRow(label, section, tuple(fields), channels, fmt, sticky) # 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("Color", "process", "color_range_clip"), + _row("Mode", "process", "process_mode", sticky=True), + _row("Analysis Buffer", "process", "analysis_buffer", sticky=True), + _row("Range", "process", "luma_range_clip", sticky=True), + _row("Color", "process", "color_range_clip", sticky=True), _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 and the baked matrix copy atomically: strength alone would leave the # target on a stale or None matrix. - _row("Crosstalk", "process", "crosstalk_strength", "crosstalk_profile", "crosstalk_matrix", fmt=lambda v: _fmt_scalar(v[0])), - _row("Trichrome Calibration", "process", "sensor_profile", "sensor_matrix", fmt=lambda v: _fmt_scalar(v[0])), + _row("Crosstalk", "process", "crosstalk_strength", "crosstalk_profile", "crosstalk_matrix", fmt=lambda v: _fmt_scalar(v[0]), sticky=True), + _row("Trichrome Calibration", "process", "sensor_profile", "sensor_matrix", fmt=lambda v: _fmt_scalar(v[0]), sticky=True), # Absent from _BOUNDS_INPUT_FIELDS: it acts after inversion, so it never feeds the meters. - _row("Hue Trim", "process", "hue_trim"), + _row("Hue Trim", "process", "hue_trim", sticky=True), )), ("Crop", ( _row("Auto Crop", "geometry", "crop_from_auto"), - _row("Crop Offset", "geometry", "autocrop_offset"), - _row("Rebate Trim", "geometry", "autocrop_rebate_trim"), - _row("Crop Ratio", "geometry", "autocrop_ratio"), - _row("Crop Mode", "geometry", "autocrop_mode"), + _row("Crop Offset", "geometry", "autocrop_offset", sticky=True), + _row("Rebate Trim", "geometry", "autocrop_rebate_trim", sticky=True), + _row("Crop Ratio", "geometry", "autocrop_ratio", sticky=True), + _row("Crop Mode", "geometry", "autocrop_mode", sticky=True), # Rect and key copy together: without the key a copied auto rect looks freshly # detected on the target. _row("Crop", "geometry", "crop_rect", "crop_detect_key", fmt=lambda v: _fmt_scalar(v[0])), @@ -99,15 +107,15 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: _row("Fine Rotation", "geometry", "fine_rotation"), _row("Easel Tilt", "geometry", "converge_v"), _row("Easel Swing", "geometry", "converge_h"), - _row("Flip Horizontal", "geometry", "flip_horizontal"), - _row("Flip Vertical", "geometry", "flip_vertical"), + _row("Flip Horizontal", "geometry", "flip_horizontal", sticky=True), + _row("Flip Vertical", "geometry", "flip_vertical", sticky=True), )), ("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("Paper Black", "exposure", "paper_black", sticky=True), + _row("Paper Dmin", "exposure", "paper_dmin", sticky=True), _row("Shadows Density", "exposure", "shadow_density"), _row("Highlights Density", "exposure", "highlight_density"), _row("Shadows Grade", "exposure", "shadow_grade"), @@ -129,9 +137,9 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: _row("Separation Damping", "exposure", "separation_damping"), _row("Contrast Mask", "exposure", "contrast_mask"), _row("Mask Spacer", "exposure", "mask_spacer"), - _row("Auto Exposure", "exposure", "auto_exposure"), - _row("Auto Contrast", "exposure", "auto_normalize_contrast"), - _row("Paper Profile", "exposure", "paper_profile"), + _row("Auto Exposure", "exposure", "auto_exposure", sticky=True), + _row("Auto Contrast", "exposure", "auto_normalize_contrast", sticky=True), + _row("Paper Profile", "exposure", "paper_profile", sticky=True), )), ("Color", ( _row("Cyan", "exposure", "wb_cyan"), @@ -139,19 +147,19 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: _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"), + _row("Cast Removal", "exposure", "cast_removal_strength", sticky=True), )), ("Lab", ( - _row("Chroma", "lab", "saturation"), - _row("Skin Protection", "lab", "skin_protection"), - _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("Chroma Denoise", "lab", "chroma_denoise"), - _row("Glow", "lab", "glow_amount"), - _row("Halation", "lab", "halation_strength"), + _row("Chroma", "lab", "saturation", sticky=True), + _row("Skin Protection", "lab", "skin_protection", sticky=True), + _row("CLAHE", "lab", "clahe_strength", sticky=True), + _row("Sharpening", "lab", "sharpen", sticky=True), + _row("Sharpen Method", "lab", "sharpen_method", sticky=True), + _row("Radius", "lab", "sharpen_radius", sticky=True), + _row("Masking", "lab", "sharpen_masking", sticky=True), + _row("Chroma Denoise", "lab", "chroma_denoise", sticky=True), + _row("Glow", "lab", "glow_amount", sticky=True), + _row("Halation", "lab", "halation_strength", sticky=True), )), ("Alternative Processes", ( _row("Process", "altproc", "alt_process"), @@ -190,7 +198,7 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: _row("Border Match Paper", "finish", "border_match_paper"), )), ("Retouch", ( - _row("Dust Removal", "retouch", "dust_remove"), + _row("Dust Removal", "retouch", "dust_remove", sticky=True), _row("Dust Threshold", "retouch", "dust_threshold"), _row("Dust Size", "retouch", "dust_size"), _row("IR Removal", "retouch", "ir_dust_remove"), @@ -223,33 +231,34 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: _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"), + _row("Protect Original Metadata", "metadata", "protect_original_metadata", sticky=True), _row( "Description Fields", "metadata", "description_fields", fmt=lambda v: (", ".join(str(x) for x in v[0]) if v[0] else "—"), + sticky=True, ), )), ("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("Color 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"), + _row("Format", "export", "export_fmt", sticky=True), + _row("JPEG Quality", "export", "jpeg_quality", sticky=True), + _row("JXL Lossless", "export", "jxl_lossless", sticky=True), + _row("JXL Distance", "export", "jxl_distance", sticky=True), + _row("JXL Effort", "export", "jxl_effort", sticky=True), + _row("WebP Quality", "export", "webp_quality", sticky=True), + _row("WebP Lossless", "export", "webp_lossless", sticky=True), + _row("WebP Method", "export", "webp_method", sticky=True), + _row("Resolution Mode", "export", "export_resolution_mode", sticky=True), + _row("Aspect Ratio", "export", "paper_aspect_ratio", sticky=True), + _row("Print Size", "export", "export_print_size", sticky=True), + _row("DPI", "export", "export_dpi", sticky=True), + _row("Target Long Edge", "export", "export_target_long_edge_px", sticky=True), + _row("Color Space", "export", "export_color_space", sticky=True), + _row("Filename Pattern", "export", "filename_pattern", sticky=True), + _row("Overwrite", "export", "overwrite", sticky=True), + _row("Output Mode", "export", "output_mode", sticky=True), + _row("Sidecars", "export", "export_sidecars_enabled", sticky=True), )), ] # fmt: on @@ -286,6 +295,18 @@ def all_rows() -> list[SettingRow]: return [r for _title, rows in CATALOG for r in rows] +def rows_by_id() -> dict[str, SettingRow]: + return {r.id: r for r in all_rows()} + + +# What carries onto a fresh file out of the box. The user's own choice overrides it. +DEFAULT_STICKY_IDS: frozenset[str] = frozenset(r.id for r in all_rows() if r.sticky) + +# Config attrs whose rows also carry onto a file that already has a saved edit. Everything +# else is a per-frame decision and the saved edit wins. +GLOBAL_TIER_SECTIONS: frozenset[str] = frozenset({"export", "metadata"}) + + def _row_edited(row: SettingRow, cfg: WorkspaceConfig) -> bool: src = getattr(cfg, row.section) dfl = getattr(_DEFAULT, row.section) diff --git a/negpy/desktop/sticky.py b/negpy/desktop/sticky.py new file mode 100644 index 00000000..9384d087 --- /dev/null +++ b/negpy/desktop/sticky.py @@ -0,0 +1,128 @@ +"""Storage for the settings that carry onto a freshly-opened file. + +The set is a user choice, held as a list of `SettingRow` ids, and the values come from a +flat snapshot of the last persisted edit. Everything the picker can offer is a catalog +row; the few carries that are not plain config values — the rig-global flat-field +profile, the Kelvin roll-locks — stay in `AppState._apply_sticky_settings`. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from negpy.domain.interfaces import IRepository +from negpy.domain.models import ExportConfig, WorkspaceConfig +from negpy.desktop.settings_catalog import ( + CATALOG, + DEFAULT_STICKY_IDS, + SettingRow, + all_rows, + rows_by_id, + selected_flat_dict, +) + +STICKY_CONFIG_KEY = "sticky_config" +STICKY_ROWS_KEY = "sticky_rows" + +_CATALOG_EXPORT_FIELDS = frozenset(f for title, rows in CATALOG if title == "Export" for r in rows for f in r.fields) + +# Export fields the catalog deliberately does not list: the output folder, ICC paths and +# the contact-sheet layout. They are workspace state rather than a look, so they carry +# unconditionally and never appear in the picker. Derived, so a new Export row cannot +# leave one silently uncarried. +EXPORT_REMAINDER: frozenset[str] = frozenset(ExportConfig.__dataclass_fields__) - _CATALOG_EXPORT_FIELDS + +# Scan-setup preferences, carried unconditionally for the same reason. Kept out of the +# catalog because that would put them in the preset "Replace look" set, where resetting +# the decode mode would force a re-decode. +ALWAYS_STICKY_PROCESS: tuple[tuple[str, str], ...] = ( + ("last_linear_raw", "linear_raw"), + ("last_narrowband_scan", "narrowband_scan"), +) + + +def load_sticky_rows(repo: IRepository) -> list[SettingRow]: + """The rows the user has chosen to carry, defaults when they never chose.""" + stored = repo.get_global_setting(STICKY_ROWS_KEY) + ids = set(stored) if isinstance(stored, list) else set(DEFAULT_STICKY_IDS) + by_id = rows_by_id() + return [row for row_id, row in by_id.items() if row_id in ids] + + +def save_sticky_rows(repo: IRepository, ids: list[str]) -> None: + repo.save_global_setting(STICKY_ROWS_KEY, sorted(ids)) + + +# Kept out of the snapshot and written only by the Description… dialog, so the last +# confirm wins for the roll instead of whichever frame was saved last. +DESCRIPTION_FIELDS_KEY = "last_description_fields" + + +def sticky_snapshot(config: WorkspaceConfig) -> dict[str, Any]: + """Every catalog-reachable field, so any row can be made sticky later.""" + flat = selected_flat_dict(config, all_rows()) + flat.pop("description_fields", None) + return flat + + +def load_sticky_config(repo: IRepository) -> Optional[WorkspaceConfig]: + stored = repo.get_global_setting(STICKY_CONFIG_KEY) + if not isinstance(stored, dict) or not stored: + return None + return WorkspaceConfig.from_flat_dict(stored) + + +# Legacy per-key sticky store, superseded by STICKY_CONFIG_KEY. Only the keys the old +# _apply_sticky_settings actually read are worth carrying forward. +_LEGACY_KEYS: dict[str, str] = { + "last_process_mode": "process_mode", + "last_analysis_buffer": "analysis_buffer", + "last_luma_range_clip": "luma_range_clip", + "last_color_range_clip": "color_range_clip", + "last_crosstalk_strength": "crosstalk_strength", + "last_crosstalk_matrix": "crosstalk_matrix", + "last_crosstalk_profile": "crosstalk_profile", + "last_sensor_matrix": "sensor_matrix", + "last_sensor_profile": "sensor_profile", + "last_hue_trim": "hue_trim", + "last_aspect_ratio": "autocrop_ratio", + "last_autocrop_mode": "autocrop_mode", + "last_autocrop_offset": "autocrop_offset", + "last_autocrop_rebate_trim": "autocrop_rebate_trim", + "last_flip_horizontal": "flip_horizontal", + "last_flip_vertical": "flip_vertical", + "last_auto_exposure": "auto_exposure", + "last_auto_normalize_contrast": "auto_normalize_contrast", + "last_paper_dmin": "paper_dmin", + "last_paper_black": "paper_black", + "last_cast_removal_strength": "cast_removal_strength", + "last_paper_profile": "paper_profile", + "last_dust_remove": "dust_remove", + "last_protect_original_metadata": "protect_original_metadata", +} + +_LEGACY_DICT_KEYS = ("last_lab_config", "last_export_config") + + +def migrate_legacy(repo: IRepository) -> None: + """Seed the snapshot from the superseded per-key store, once.""" + if repo.get_global_setting(STICKY_CONFIG_KEY) is not None: + return + flat: dict[str, Any] = {} + for legacy_key, field in _LEGACY_KEYS.items(): + value = repo.get_global_setting(legacy_key) + if value is not None: + flat[field] = value + for legacy_key in _LEGACY_DICT_KEYS: + stored = repo.get_global_setting(legacy_key) + if isinstance(stored, dict): + flat.update(stored) + # True Black was renamed to Paper Black, inverted. + if "paper_black" not in flat: + legacy_bpc = repo.get_global_setting("last_true_black") + if legacy_bpc is not None: + flat["paper_black"] = not bool(legacy_bpc) + if not flat: + return + known = {f for r in all_rows() for f in r.fields} + repo.save_global_setting(STICKY_CONFIG_KEY, {k: v for k, v in flat.items() if k in known}) diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py index 45003107..ca7b4cc8 100644 --- a/negpy/desktop/view/canvas/toolbar.py +++ b/negpy/desktop/view/canvas/toolbar.py @@ -394,6 +394,12 @@ def _init_ui(self) -> None: qta.icon("fa5s.history", color=icon_color), "Reset Settings", self.session.reset_settings ) reset_settings_action.setToolTip("Discard all edits and return this image to its default look") + persistent_action = overflow_menu.addAction( + qta.icon("fa5s.thumbtack", color=icon_color), + tooltip_with_shortcut("Persistent Settings…", "persistent_settings"), + self._show_sticky_dialog, + ) + persistent_action.setToolTip("Choose which settings carry onto the next file you open") overflow_menu.addSeparator() unload_action = overflow_menu.addAction(qta.icon("fa5s.times-circle", color=icon_color), "Unload", self._on_overflow_unload) unload_action.setToolTip("Remove this image from the session (its saved edit is kept)") @@ -808,6 +814,11 @@ def _show_shortcuts(self) -> None: dlg = ShortcutsOverlay(self.window().shortcut_manager, self.window()) dlg.exec() + def _show_sticky_dialog(self) -> None: + from negpy.desktop.view.widgets.granular_settings_dialog import open_sticky_dialog + + open_sticky_dialog(self.window(), self.controller) + def _show_database_dialog(self) -> None: from negpy.desktop.view.widgets.database_dialog import DatabaseDialog diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 8a31f1d2..8ff4b7ae 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -4,7 +4,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.widgets.granular_settings_dialog import open_paste_dialog, open_sticky_dialog from negpy.desktop.view.shortcut_registry import ( REGISTRY, load_bindings, @@ -182,6 +182,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "copy": controller.session.copy_settings, "copy_with_bounds": controller.session.copy_settings_with_bounds, "paste": lambda: open_paste_dialog(self.window, controller), + "persistent_settings": lambda: open_sticky_dialog(self.window, controller), "save_work_print": self.window.right_panel.history_panel.save_work_print, "undo": lambda: _context_undo(controller), "redo": controller.session.redo, diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index e14ef949..3fe4fabc 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -201,6 +201,7 @@ class ShortcutEntry: "copy": ShortcutEntry("Ctrl+C", "Copy settings", "Actions"), "copy_with_bounds": ShortcutEntry("Ctrl+Shift+C", "Copy settings (with bounds)", "Actions"), "paste": ShortcutEntry("Ctrl+V", "Paste settings", "Actions"), + "persistent_settings": ShortcutEntry("", "Choose which settings carry to the next file", "Actions"), "save_work_print": ShortcutEntry("Ctrl+Shift+S", "Save the current edit as a named work print", "Actions"), "undo": ShortcutEntry("Ctrl+Z", "Undo", "Actions"), "redo": ShortcutEntry("Ctrl+Y", "Redo", "Actions"), diff --git a/negpy/desktop/view/sidebar/sensor.py b/negpy/desktop/view/sidebar/sensor.py index 37b3765d..e1b2eaa4 100644 --- a/negpy/desktop/view/sidebar/sensor.py +++ b/negpy/desktop/view/sidebar/sensor.py @@ -375,8 +375,6 @@ def _on_crosstalk_editor_finished(self, dlg, result: int) -> None: def _on_hue_trim_changed(self, val: float, persist: bool = True) -> None: # Sticky on commit only, so a drag doesn't write every intermediate value. self.update_config_section("process", hue_trim=val, persist=persist) - if persist: - self.controller.session.repo.save_global_setting("last_hue_trim", float(val)) def sync_ui(self) -> None: conf = self.state.config.process diff --git a/negpy/desktop/view/widgets/collapsible.py b/negpy/desktop/view/widgets/collapsible.py index e84523c1..02628450 100644 --- a/negpy/desktop/view/widgets/collapsible.py +++ b/negpy/desktop/view/widgets/collapsible.py @@ -2,6 +2,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, + QCheckBox, QPushButton, QFrame, QHBoxLayout, @@ -22,6 +23,7 @@ class CollapsibleSection(QWidget): reset_requested = pyqtSignal() expanded_changed = pyqtSignal(bool) info_requested = pyqtSignal() + selection_toggled = pyqtSignal(bool) def __init__( self, @@ -30,6 +32,7 @@ def __init__( icon: Optional[QIcon] = None, background_widget: Optional[QWidget] = None, info: bool = False, + select: bool = False, parent=None, ): super().__init__(parent) @@ -54,6 +57,17 @@ def __init__( btn_layout.setContentsMargins(THEME.space_xl, 8, THEME.space_xl, 8) btn_layout.setSpacing(10) + # Nested in the header button like reset_btn, so ticking the section does not also + # collapse it. Tristate is for display only: a click always resolves to all or none. + self.select_box: Optional[QCheckBox] = None + if select: + self.select_box = QCheckBox() + self.select_box.setTristate(True) + self.select_box.setCursor(Qt.CursorShape.PointingHandCursor) + self.select_box.setToolTip(f"Select every {title} setting") + self.select_box.clicked.connect(self._on_select_clicked) + btn_layout.addWidget(self.select_box) + if icon: icon_label = QLabel() icon_label.setPixmap(icon.pixmap(14, 14)) @@ -144,6 +158,27 @@ def set_modified(self, count: int) -> None: else: self.title_label.setText(self._title_text) + def set_selection_state(self, checked: int, total: int) -> None: + """Reflect how many of the section's rows are ticked. Emits nothing.""" + if self.select_box is None: + return + if checked == 0: + state = Qt.CheckState.Unchecked + elif checked == total: + state = Qt.CheckState.Checked + else: + state = Qt.CheckState.PartiallyChecked + self.select_box.blockSignals(True) + self.select_box.setCheckState(state) + self.select_box.blockSignals(False) + + def _on_select_clicked(self) -> None: + if self.select_box is None: + return + want = self.select_box.checkState() != Qt.CheckState.Checked + self.select_box.setCheckState(Qt.CheckState.Checked if want else Qt.CheckState.Unchecked) + self.selection_toggled.emit(want) + def _on_reset_clicked(self) -> None: self.reset_requested.emit() diff --git a/negpy/desktop/view/widgets/granular_settings_dialog.py b/negpy/desktop/view/widgets/granular_settings_dialog.py index e184ff9c..5315a238 100644 --- a/negpy/desktop/view/widgets/granular_settings_dialog.py +++ b/negpy/desktop/view/widgets/granular_settings_dialog.py @@ -1,3 +1,5 @@ +from functools import partial + from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QButtonGroup, @@ -39,10 +41,15 @@ def __init__( exclude_sections: frozenset[str] = frozenset(), show_current: bool = False, show_apply_mode: bool = False, + preselect_ids: frozenset[str] | None = None, ): + # ponytail: at nine flags this class is at its ceiling. A tenth means splitting + # pick mode into its own dialog. super().__init__(parent) self._checks: list[tuple[QCheckBox, SettingRow, bool, QWidget]] = [] self._sections: list[tuple[QWidget, int]] = [] + self._section_rows: list[tuple[CollapsibleSection, tuple[str, ...]]] = [] + self._preselect_ids = preselect_ids self._bounds_luma: QCheckBox | None = None self._bounds_color: QCheckBox | None = None self._name_edit: QLineEdit | None = None @@ -51,7 +58,9 @@ def __init__( else: self._scope = "selection" if sel_count > 0 else "roll" - if ask_name: + if preselect_ids is not None: + self.setWindowTitle("Persistent Settings") + elif ask_name: self.setWindowTitle("Save Preset") else: self.setWindowTitle("Paste Settings" if not show_scope else "Apply Settings") @@ -62,7 +71,11 @@ def __init__( 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") + if preselect_ids is not None: + header = QLabel("Settings that carry onto the next file you open") + header.setToolTip("A file you have already edited keeps its own look; only export and metadata settings reach it.") + else: + 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) @@ -80,6 +93,7 @@ def __init__( root.addLayout(self._build_footer(ask_name)) self._apply_visibility() + self._refresh_section_states() self._update_apply_enabled() def _build_scope_row(self, sel_count: int, roll_count: int, show_current: bool = False) -> QHBoxLayout: @@ -128,6 +142,7 @@ def _build_checks_row(self) -> QHBoxLayout: self._show_unchanged = QCheckBox("Show unchanged settings") self._show_unchanged.setToolTip("List settings still at their default, so they can be applied too") self._show_unchanged.toggled.connect(self._apply_visibility) + self._show_unchanged.setVisible(self._preselect_ids is None) row.addWidget(check_all) row.addWidget(check_none) row.addStretch() @@ -143,13 +158,20 @@ def _build_sections(self, source_cfg, show_bounds: bool, exclude_sections: froze col.setContentsMargins(0, 0, 0, 0) col.setSpacing(THEME.space_sm) + pick_mode = self._preselect_ids is not None for title, rows in catalog_sections(source_cfg): if title in exclude_sections: continue edited_count = sum(1 for _r, _v, edited in rows if edited) - section = CollapsibleSection(title, expanded=True) - section.set_modified(edited_count) - section.set_content(self._build_rows(rows)) + section = CollapsibleSection(title, expanded=not pick_mode, select=pick_mode) + if pick_mode: + boxes = self._build_rows(rows) + section.set_content(boxes) + section.selection_toggled.connect(partial(self._on_section_toggled, section, tuple(r for r, _v, _e in rows))) + self._section_rows.append((section, tuple(r.id for r, _v, _e in rows))) + else: + section.set_modified(edited_count) + section.set_content(self._build_rows(rows)) col.addWidget(section) self._sections.append((section, edited_count)) @@ -173,8 +195,9 @@ def _build_rows(self, rows: list[tuple[SettingRow, str, bool]]) -> QWidget: line_layout = QHBoxLayout(line) line_layout.setContentsMargins(0, 0, 0, 0) box = QCheckBox(row.label) - box.setChecked(edited) + box.setChecked(row.id in self._preselect_ids if self._preselect_ids is not None else edited) box.stateChanged.connect(self._update_apply_enabled) + box.stateChanged.connect(self._refresh_section_states) self._checks.append((box, row, edited, line)) val = QLabel(value) val.setStyleSheet(f"color: {THEME.text_muted};") @@ -212,7 +235,23 @@ def _all_boxes(self) -> list[QCheckBox]: boxes += [b for b in (self._bounds_luma, self._bounds_color) if b is not None] return boxes + def _on_section_toggled(self, _section, rows: tuple[SettingRow, ...], checked: bool) -> None: + wanted = {r.id for r in rows} + for box, row, _edited, _line in self._checks: + if row.id in wanted: + box.setChecked(checked) + + def _refresh_section_states(self) -> None: + if self._preselect_ids is None: + return + state = {row.id: box.isChecked() for box, row, _edited, _line in self._checks} + for section, row_ids in self._section_rows: + section.set_selection_state(sum(1 for i in row_ids if state.get(i)), len(row_ids)) + def _apply_visibility(self) -> None: + # Pick mode lists everything: a setting still at its default must stay selectable. + if self._preselect_ids is not None: + return show_all = self._show_unchanged.isChecked() for box, _row, edited, line in self._checks: if edited: @@ -225,7 +264,7 @@ def _apply_visibility(self) -> None: section.setVisible(show_all or edited_count > 0) def _set_all_checked(self, checked: bool) -> None: - show_all = self._show_unchanged.isChecked() + show_all = self._show_unchanged.isChecked() or self._preselect_ids is not None for box, _row, edited, _line in self._checks: if edited or show_all: box.setChecked(checked) @@ -234,6 +273,10 @@ def _set_all_checked(self, checked: bool) -> None: box.setChecked(checked) def _update_apply_enabled(self) -> None: + # Ticking nothing is a valid choice in pick mode: nothing carries over. + if self._preselect_ids is not None: + self.apply_btn.setEnabled(True) + return 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()) @@ -250,6 +293,9 @@ def _on_apply(self) -> None: def selected(self) -> list[SettingRow]: return [row for box, row, _edited, _line in self._checks if box.isChecked()] + def selected_ids(self) -> list[str]: + return [row.id for row in self.selected()] + def name(self) -> str: return self._name_edit.text().strip() if self._name_edit is not None else "" @@ -281,3 +327,17 @@ def open_paste_dialog(parent, controller) -> None: dlg = GranularSettingsDialog(parent, state.clipboard, "clipboard", show_scope=False) if dlg.exec() == QDialog.DialogCode.Accepted: controller.session.apply_pasted_fields(dlg.selected()) + + +def open_sticky_dialog(parent, controller) -> None: + """Pick which settings carry onto a freshly-opened file. Values shown are the last + saved edit's, so the list reads as what would actually carry.""" + from negpy.domain.models import WorkspaceConfig + from negpy.desktop.sticky import load_sticky_config, load_sticky_rows, save_sticky_rows + + repo = controller.session.repo + source = load_sticky_config(repo) or WorkspaceConfig() + chosen = frozenset(r.id for r in load_sticky_rows(repo)) + dlg = GranularSettingsDialog(parent, source, "", preselect_ids=chosen) + if dlg.exec() == QDialog.DialogCode.Accepted: + save_sticky_rows(repo, dlg.selected_ids()) diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index d7d92525..80ebb55e 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -73,11 +73,13 @@ def test_config_for_asset_saved_uses_saved_edits_and_global_overlays_only(self): geometry=replace(defaults.geometry, autocrop_ratio="4:3"), ) sticky = { - "last_export_config": {"jpeg_quality": 73}, - "last_protect_original_metadata": True, - # Workflow defaults must not overwrite an edited/saved asset. - "last_process_mode": ProcessMode.C41, - "last_aspect_ratio": "1:1", + "sticky_config": { + "jpeg_quality": 73, + "protect_original_metadata": True, + # Workflow defaults must not overwrite an edited/saved asset. + "process_mode": ProcessMode.C41, + "autocrop_ratio": "1:1", + }, } self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) asset = {"name": "saved.dng", "path": "/roll/saved.dng", "hash": "saved-hash"} @@ -103,11 +105,12 @@ def test_config_for_asset_fresh_starts_clean_not_from_active_creative_edits(self ) self.session.state.config = active sticky = { - "last_export_config": {}, - "last_process_mode": ProcessMode.E6, - "last_aspect_ratio": "1:1", - "last_autocrop_offset": 7, - "last_auto_exposure": True, + "sticky_config": { + "process_mode": ProcessMode.E6, + "autocrop_ratio": "1:1", + "autocrop_offset": 7, + "auto_exposure": True, + }, "last_narrowband_scan": True, } self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) @@ -191,14 +194,13 @@ def test_persist_writes_sticky_settings_in_one_batch(self): self.mock_repo.save_global_settings.assert_called_once() saved = self.mock_repo.save_global_settings.call_args.args[0] - self.assertEqual(saved["last_density"], 1.5) - self.assertIn("last_process_mode", saved) + snapshot = saved["sticky_config"] + self.assertEqual(snapshot["density"], 1.5) + for field in ("process_mode", "jpeg_quality", "dust_remove", "paper_black", "protect_original_metadata", "cast_removal_strength"): + self.assertIn(field, snapshot) + # Carried on their own keys: no catalog row reaches them. self.assertIn("last_export_config", saved) - self.assertIn("last_dust_remove", saved) - self.assertIn("last_paper_black", saved) self.assertIn("last_narrowband_scan", saved) - self.assertIn("last_protect_original_metadata", saved) - self.assertIn("last_cast_removal_strength", saved) def test_persist_active_batch_config_saves_before_exposing_state(self): original = self.session.state.config @@ -229,19 +231,13 @@ def test_persist_active_batch_config_keeps_state_unchanged_on_failure(self): self.assertFalse(self.session.state.is_dirty) def test_protect_original_metadata_carries_globally(self): - sticky = { - "last_export_config": {}, - "last_protect_original_metadata": True, - } + sticky = {"sticky_config": {"protect_original_metadata": True}} self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) config = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False) self.assertTrue(config.metadata.protect_original_metadata) def test_protect_original_metadata_applied_to_saved_files(self): - sticky = { - "last_export_config": {}, - "last_protect_original_metadata": True, - } + sticky = {"sticky_config": {"protect_original_metadata": True}} self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) base = WorkspaceConfig(metadata=replace(WorkspaceConfig().metadata, protect_original_metadata=False)) config = self.session._apply_sticky_settings(base, only_global=True) @@ -281,13 +277,14 @@ def test_persist_sticky_settings_does_not_write_description_fields(self): def test_processing_toggles_carry_to_new_files(self): # Globally remembered toggles must be applied to a fresh (sidecar-less) file. sticky = { - "last_export_config": {}, - "last_auto_exposure": True, - "last_auto_normalize_contrast": True, - "last_paper_dmin": True, - "last_paper_black": True, - "last_paper_profile": "ilford_mg_rc", - "last_cast_removal_strength": 0.8, + "sticky_config": { + "auto_exposure": True, + "auto_normalize_contrast": True, + "paper_dmin": True, + "paper_black": True, + "paper_profile": "ilford_mg_rc", + "cast_removal_strength": 0.8, + }, } self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) config = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False) @@ -300,14 +297,14 @@ def test_processing_toggles_carry_to_new_files(self): def test_cast_removal_zero_carries_to_new_files(self): """Sticky must carry an explicit zero, not just non-zero — default is 0.5.""" - sticky = {"last_export_config": {}, "last_cast_removal_strength": 0.0} + sticky = {"sticky_config": {"cast_removal_strength": 0.0}} self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) config = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False) self.assertEqual(config.exposure.cast_removal_strength, 0.0) def test_paper_black_carries_to_new_files(self): """Sticky must carry an explicit value over the file's base.""" - sticky = {"last_export_config": {}, "last_paper_black": False} + sticky = {"sticky_config": {"paper_black": False}} self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) base = WorkspaceConfig(exposure=replace(WorkspaceConfig().exposure, paper_black=True)) config = self.session._apply_sticky_settings(base, only_global=False) @@ -315,10 +312,69 @@ def test_paper_black_carries_to_new_files(self): def test_legacy_true_black_sticky_migrates_inverted(self): """A pre-rename sticky (last_true_black) maps to paper_black inverted.""" - sticky = {"last_export_config": {}, "last_true_black": False} + from negpy.desktop.sticky import migrate_legacy + + legacy = {"last_true_black": False} + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: legacy.get(key, default) + migrate_legacy(self.mock_repo) + key, snapshot = self.mock_repo.save_global_setting.call_args.args + self.assertEqual(key, "sticky_config") + self.assertTrue(snapshot["paper_black"]) + + def test_opt_in_row_carries_once_chosen(self): + """A look setting is not sticky by default, but becomes so when ticked.""" + sticky = {"sticky_config": {"density": 2.2}} self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) config = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False) - self.assertTrue(config.exposure.paper_black) + self.assertEqual(config.exposure.density, WorkspaceConfig().exposure.density) + + sticky["sticky_rows"] = ["exposure.density"] + config = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False) + self.assertEqual(config.exposure.density, 2.2) + + def test_unticked_default_row_stops_carrying(self): + sticky = {"sticky_config": {"process_mode": ProcessMode.E6}, "sticky_rows": []} + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) + config = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False) + self.assertEqual(config.process.process_mode, WorkspaceConfig().process.process_mode) + + def test_only_global_tier_ignores_look_rows(self): + """A saved edit keeps its own look even when the user made a look row sticky.""" + sticky = { + "sticky_config": {"density": 2.2, "jpeg_quality": 73}, + "sticky_rows": ["exposure.density", "export.jpeg_quality"], + } + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) + config = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=True) + self.assertEqual(config.exposure.density, WorkspaceConfig().exposure.density) + self.assertEqual(config.export.jpeg_quality, 73) + + def test_saved_file_keeps_cached_bounds(self): + """The global tier must not touch a bounds-input field, or resolve_bounds re-runs.""" + sticky = { + "sticky_config": {"process_mode": ProcessMode.E6, "jpeg_quality": 73}, + "sticky_rows": ["process.process_mode", "export.jpeg_quality"], + } + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) + base = WorkspaceConfig( + process=replace( + WorkspaceConfig().process, + locked_floors=(0.1, 0.2, 0.3), + locked_ceils=(1.1, 1.2, 1.3), + ) + ) + config = self.session._apply_sticky_settings(base, only_global=True) + self.assertTrue(config.process.is_locked_initialized) + self.assertEqual(config.process.locked_floors, (0.1, 0.2, 0.3)) + + def test_export_remainder_carries_without_a_catalog_row(self): + """The output folder has no row, so it rides last_export_config unconditionally.""" + sticky = {"last_export_config": {"export_path": "/out", "jpeg_quality": 99}, "sticky_rows": []} + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) + config = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False) + self.assertEqual(config.export.export_path, "/out") + # jpeg_quality has a row, and no row was chosen, so it must not ride along. + self.assertEqual(config.export.jpeg_quality, WorkspaceConfig().export.jpeg_quality) def test_roll_average_not_seeded_onto_fresh_files(self): # A roll baseline must not leak onto a fresh (sidecar-less) file. @@ -542,8 +598,7 @@ def test_sync_fresh_target_keeps_sticky_workflow_not_bare_defaults(self): sticky-aware hydrated config, not bare WorkspaceConfig(), so syncing one field doesn't silently reset its scan/process-mode to dataclass defaults.""" sticky = { - "last_export_config": {}, - "last_process_mode": ProcessMode.E6, + "sticky_config": {"process_mode": ProcessMode.E6}, "last_narrowband_scan": True, } self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) diff --git a/tests/test_sticky_dialog.py b/tests/test_sticky_dialog.py new file mode 100644 index 00000000..c6395016 --- /dev/null +++ b/tests/test_sticky_dialog.py @@ -0,0 +1,61 @@ +"""GranularSettingsDialog pick mode (the Persistent Settings picker).""" + +from PyQt6.QtCore import Qt + +from negpy.desktop.settings_catalog import all_rows +from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog +from negpy.domain.models import WorkspaceConfig + + +def _dlg(preselect): + return GranularSettingsDialog(None, WorkspaceConfig(), "", preselect_ids=frozenset(preselect)) + + +def test_pick_mode_titles_and_preselection(qapp): + dlg = _dlg({"exposure.density", "lab.saturation"}) + assert dlg.windowTitle() == "Persistent Settings" + assert set(dlg.selected_ids()) == {"exposure.density", "lab.saturation"} + + +def test_pick_mode_lists_every_row_even_at_default(qapp): + """A setting still at its default must stay tickable, or it could never be made sticky.""" + dlg = _dlg(set()) + assert len(dlg._checks) == len(all_rows()) + assert not any(line.isHidden() for _b, _r, _e, line in dlg._checks) + assert dlg._show_unchanged.isHidden() + + +def test_pick_mode_allows_selecting_nothing(qapp): + dlg = _dlg({"exposure.density"}) + dlg._set_all_checked(False) + assert dlg.selected_ids() == [] + # Ticking nothing is a valid choice: nothing carries over. + assert dlg.apply_btn.isEnabled() + + +def test_check_all_reaches_every_row(qapp): + dlg = _dlg(set()) + dlg._set_all_checked(True) + assert len(dlg.selected_ids()) == len(all_rows()) + + +def test_section_checkbox_is_tristate_and_toggles_its_rows(qapp): + dlg = _dlg({"lab.saturation"}) + section, row_ids = next((s, ids) for s, ids in dlg._section_rows if "lab.saturation" in ids) + assert section.select_box.checkState() == Qt.CheckState.PartiallyChecked + + section._on_select_clicked() + assert set(row_ids) <= set(dlg.selected_ids()) + assert section.select_box.checkState() == Qt.CheckState.Checked + + section._on_select_clicked() + assert not (set(row_ids) & set(dlg.selected_ids())) + assert section.select_box.checkState() == Qt.CheckState.Unchecked + + +def test_paste_mode_keeps_no_section_checkboxes(qapp): + """The existing callers must be untouched by pick mode.""" + dlg = GranularSettingsDialog(None, WorkspaceConfig(), "clipboard") + assert dlg._section_rows == [] + assert all(s.select_box is None for s, _n in dlg._sections) + assert not dlg._show_unchanged.isHidden() diff --git a/tests/test_sticky_settings.py b/tests/test_sticky_settings.py new file mode 100644 index 00000000..e4362a68 --- /dev/null +++ b/tests/test_sticky_settings.py @@ -0,0 +1,138 @@ +import unittest +from dataclasses import replace +from unittest.mock import MagicMock + +from negpy.desktop.settings_catalog import ( + DEFAULT_STICKY_IDS, + GLOBAL_TIER_SECTIONS, + all_rows, + rows_by_id, +) +from negpy.desktop.sticky import ( + ALWAYS_STICKY_PROCESS, + EXPORT_REMAINDER, + STICKY_CONFIG_KEY, + STICKY_ROWS_KEY, + load_sticky_rows, + migrate_legacy, + save_sticky_rows, + sticky_snapshot, +) +from negpy.domain.models import ExportConfig, WorkspaceConfig + + +def _repo(settings=None): + store = dict(settings or {}) + repo = MagicMock() + repo.get_global_setting.side_effect = lambda key, default=None: store.get(key, default) + repo.save_global_setting.side_effect = lambda key, value: store.__setitem__(key, value) + repo.store = store + return repo + + +class TestCatalogStickyIds(unittest.TestCase): + def test_row_ids_are_unique(self): + self.assertEqual(len(rows_by_id()), len(all_rows())) + + def test_default_sticky_ids_all_resolve(self): + by_id = rows_by_id() + for row_id in DEFAULT_STICKY_IDS: + self.assertIn(row_id, by_id) + + def test_export_remainder_completes_the_catalog(self): + """Every ExportConfig field either has a row or rides the remainder. Without this + a new export field would silently stop carrying over.""" + catalog_export = {f for r in all_rows() if r.section == "export" for f in r.fields} + self.assertEqual(catalog_export | EXPORT_REMAINDER, set(ExportConfig.__dataclass_fields__)) + self.assertFalse(catalog_export & EXPORT_REMAINDER) + + def test_global_tier_sections_are_real_config_attrs(self): + sections = {r.section for r in all_rows()} + self.assertTrue(GLOBAL_TIER_SECTIONS <= sections) + + +class TestStickyStore(unittest.TestCase): + def test_defaults_when_user_never_chose(self): + rows = load_sticky_rows(_repo()) + self.assertEqual({r.id for r in rows}, set(DEFAULT_STICKY_IDS)) + + def test_stored_choice_wins(self): + repo = _repo({STICKY_ROWS_KEY: ["exposure.density"]}) + self.assertEqual([r.id for r in load_sticky_rows(repo)], ["exposure.density"]) + + def test_empty_choice_means_nothing_carries(self): + repo = _repo({STICKY_ROWS_KEY: []}) + self.assertEqual(load_sticky_rows(repo), []) + + def test_unknown_ids_are_dropped(self): + repo = _repo({STICKY_ROWS_KEY: ["exposure.density", "gone.retired_field"]}) + self.assertEqual([r.id for r in load_sticky_rows(repo)], ["exposure.density"]) + + def test_save_round_trips(self): + repo = _repo() + save_sticky_rows(repo, ["lab.saturation", "exposure.density"]) + self.assertEqual({r.id for r in load_sticky_rows(repo)}, {"lab.saturation", "exposure.density"}) + + def test_snapshot_excludes_description_fields(self): + """It carries on its own key so the last Description… confirm wins for the roll.""" + cfg = WorkspaceConfig() + cfg = replace(cfg, metadata=replace(cfg.metadata, description_fields=("camera", "iso"))) + self.assertNotIn("description_fields", sticky_snapshot(cfg)) + + def test_snapshot_covers_every_other_catalog_field(self): + snapshot = sticky_snapshot(WorkspaceConfig()) + expected = {f for r in all_rows() for f in r.fields} - {"description_fields"} + self.assertEqual(set(snapshot), expected) + + +class TestLegacyMigration(unittest.TestCase): + def test_seeds_from_legacy_keys(self): + repo = _repo( + { + "last_process_mode": "B&W Negative", + "last_aspect_ratio": "1:1", + "last_lab_config": {"saturation": 1.4}, + } + ) + migrate_legacy(repo) + snapshot = repo.store[STICKY_CONFIG_KEY] + self.assertEqual(snapshot["process_mode"], "B&W Negative") + self.assertEqual(snapshot["autocrop_ratio"], "1:1") + self.assertEqual(snapshot["saturation"], 1.4) + + def test_true_black_maps_inverted(self): + repo = _repo({"last_true_black": False}) + migrate_legacy(repo) + self.assertTrue(repo.store[STICKY_CONFIG_KEY]["paper_black"]) + + def test_explicit_paper_black_beats_legacy(self): + repo = _repo({"last_true_black": False, "last_paper_black": False}) + migrate_legacy(repo) + self.assertFalse(repo.store[STICKY_CONFIG_KEY]["paper_black"]) + + def test_no_op_when_snapshot_exists(self): + repo = _repo({STICKY_CONFIG_KEY: {"density": 2.0}, "last_process_mode": "B&W Negative"}) + migrate_legacy(repo) + self.assertEqual(repo.store[STICKY_CONFIG_KEY], {"density": 2.0}) + + def test_no_op_on_a_fresh_install(self): + repo = _repo() + migrate_legacy(repo) + self.assertNotIn(STICKY_CONFIG_KEY, repo.store) + + def test_drops_keys_with_no_catalog_row(self): + repo = _repo({"last_process_mode": "B&W Negative", "last_export_config": {"export_path": "/out"}}) + migrate_legacy(repo) + self.assertNotIn("export_path", repo.store[STICKY_CONFIG_KEY]) + + +class TestAlwaysSticky(unittest.TestCase): + def test_scan_prefs_are_not_catalog_rows(self): + """They must stay out, or preset "Replace look" would reset the decode mode.""" + catalog_fields = {f for r in all_rows() for f in r.fields} + for _key, field in ALWAYS_STICKY_PROCESS: + self.assertNotIn(field, catalog_fields) + + +if __name__ == "__main__": + unittest.main()