Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.41.0
0.42.0
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Change Log

## 0.42.0

- Change: **Presets UX pass** — presets now live in a visible list (tooltips show contents), and Save, Edit and Apply all use the per-setting picker from copy/paste: a preset stores exactly the settings you tick, and applying shows them again with a choice of current frame, selection or whole roll, laid over existing edits or replacing the look. Per-frame crop, rotation, metadata, dust and masks are never touched; existing preset files keep working.

## 0.41.0

- New: **Stitch multi-shot scans** — select overlapping shots of one frame (e.g. a 6×6 scanned in two halves) on the contact sheet and pick **Stitch selected frames**. Alignment, exposure matching and blending happen on the linear scan data before conversion, so the result develops like a single raw. No new file is written: the composite edits and exports like any frame, and **Unstitch** restores the parts. IR dust data is kept when all parts have it.
Expand Down
37 changes: 37 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,43 @@ def sync_selected_settings(self, rows, bounds_flags: tuple[bool, bool] = (False,
self.settings_saved.emit()
return count

def apply_preset_fields(self, source: WorkspaceConfig, rows, scope: str = "current") -> int:
"""Overlay a preset's chosen rows onto the current frame, the selection, or
the whole (visible) roll. Unlike sync_selected_settings the source is the
preset itself, so the active frame is a target too. Returns frames changed."""
rows = list(rows)
if not rows or self.state.selected_file_idx == -1:
return 0

if scope == "roll":
target_indices = self.asset_model.visible_actual_indices_ordered()
elif scope == "selection":
target_indices = self.state.selected_indices
else:
target_indices = [self.state.selected_file_idx]

count = 0
for idx in target_indices:
if not (0 <= idx < len(self.state.uploaded_files)):
continue
if idx == self.state.selected_file_idx:
self.update_config(apply_selected_fields(source, self.state.config, rows), persist=True, render=False)
count += 1
continue
target_hash = self.state.uploaded_files[idx]["hash"]
target_config = self.repo.load_file_settings(target_hash) or WorkspaceConfig()
synced = apply_selected_fields(source, target_config, rows)
self.push_external_history(target_hash, target_config, synced)
self.repo.save_file_settings(target_hash, synced, file_path=self.state.uploaded_files[idx]["path"])
count += 1

if count:
n = len(rows)
noun = "setting" if n == 1 else "settings"
self.settings_synced.emit(f"Preset applied: {n} {noun} to {count} frame{'s' if count != 1 else ''}")
self.settings_saved.emit()
return count

def next_file(self) -> None:
display_idx = self.asset_model.actual_to_display(self.state.selected_file_idx)
if display_idx == -1:
Expand Down
28 changes: 27 additions & 1 deletion negpy/desktop/settings_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@

from __future__ import annotations

import json
from dataclasses import replace
from typing import Callable, Iterable, Optional
from functools import lru_cache
from typing import Any, Callable, Iterable, Mapping, Optional

from negpy.domain.models import WorkspaceConfig
from negpy.features.metadata.models import PUSH_PULL_LABELS
Expand Down Expand Up @@ -250,3 +252,27 @@ def apply_selected_fields(source: WorkspaceConfig, target: WorkspaceConfig, rows
for section, changes in by_section.items():
out = replace(out, **{section: replace(getattr(out, section), **changes)})
return out


def selected_flat_dict(cfg: WorkspaceConfig, rows: Iterable[SettingRow]) -> dict[str, Any]:
"""Flat dict of the chosen rows' fields (flat keys are field names). A row's
fields travel as a unit, default-valued ones included."""
return {f: getattr(getattr(cfg, r.section), f) for r in rows for f in r.fields}


# json round-trip so tuple-typed defaults compare equal to json-loaded preset values.
@lru_cache(maxsize=1)
def _default_flat_json() -> dict[str, Any]:
return json.loads(json.dumps(WorkspaceConfig().to_dict()))


def preset_summary(data: Mapping[str, Any]) -> str:
"""One line per display section listing the non-default settings a preset
stores, e.g. "Tone: Print Density, Snap". Unknown keys are skipped."""
dfl = _default_flat_json()
lines = []
for title, rows in CATALOG:
labels = [r.label for r in rows if any(f in data and data[f] != dfl.get(f) for f in r.fields)]
if labels:
lines.append(f"{title}: {', '.join(labels)}")
return "\n".join(lines)
191 changes: 133 additions & 58 deletions negpy/desktop/view/sidebar/presets.py
Original file line number Diff line number Diff line change
@@ -1,65 +1,154 @@
from PyQt6.QtWidgets import (
QComboBox,
QPushButton,
QDialog,
QHBoxLayout,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
)
import qtawesome as qta
from negpy.desktop.settings_catalog import (
CATALOG,
preset_summary,
selected_flat_dict,
)
from negpy.desktop.view.sidebar.base import BaseSidebar
from negpy.services.assets.presets import Presets
from negpy.domain.models import WorkspaceConfig
from negpy.desktop.view.styles.templates import wrap_tooltip
from negpy.desktop.view.styles.theme import THEME
from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog
from negpy.domain.models import WorkspaceConfig
from negpy.services.assets.presets import Presets

_PRESET_EXCLUDED_SECTIONS = frozenset({"Crop", "Rotation"})

# "Replace edits" resets only the look sections; per-frame geometry, frame
# metadata and export prefs stay (dust/heal/masks aren't catalog rows at all).
_REPLACE_KEPT_SECTIONS = frozenset({"Crop", "Rotation", "Metadata", "Export"})
_LOOK_ROWS = tuple(r for title, rows in CATALOG if title not in _REPLACE_KEPT_SECTIONS for r in rows)


class PresetsSidebar(BaseSidebar):
"""
Panel for saving and loading editing presets.
Panel for saving and applying editing presets.
"""

def _init_ui(self) -> None:
# Load Row
row_load = QHBoxLayout()
self.preset_combo = QComboBox()
self.preset_combo.setToolTip("Saved presets — full WorkspaceConfig snapshots")
self.preset_list = QListWidget()
self.preset_list.setObjectName("preset_list")
self.preset_list.setMaximumHeight(180)
self._refresh_presets()
self.layout.addWidget(self.preset_list)

self.load_btn = QPushButton(" Load")
self.load_btn.setIcon(qta.icon("fa5s.upload", color=THEME.text_primary))
self.load_btn.setToolTip("Apply the selected preset to the current image")
row = QHBoxLayout()
self.apply_btn = QPushButton(" Apply")
self.apply_btn.setIcon(qta.icon("fa5s.check", color=THEME.text_primary))
self.apply_btn.setToolTip("Apply the selected preset to the current image (or double-click a preset)")

self.save_btn = QPushButton(" Save…")
self.save_btn.setIcon(qta.icon("fa5s.save", color=THEME.text_primary))
self.save_btn.setToolTip("Pick which of the current settings to store as a new preset")

self.edit_btn = QPushButton()
self.edit_btn.setIcon(qta.icon("fa5s.pen", color=THEME.text_primary))
self.edit_btn.setToolTip("Edit the selected preset — rename it or change which settings it stores")
self.edit_btn.setFixedWidth(32)

self.delete_btn = QPushButton()
self.delete_btn.setIcon(qta.icon("fa5s.trash", color=THEME.text_primary))
self.delete_btn.setToolTip("Delete the selected preset")
self.delete_btn.setFixedWidth(32)

row_load.addWidget(self.preset_combo, stretch=1)
row_load.addWidget(self.load_btn)
row_load.addWidget(self.delete_btn)
self.layout.addLayout(row_load)

# Save Row
row_save = QHBoxLayout()
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("New Preset Name")
self.save_btn = QPushButton(" Save")
self.save_btn.setIcon(qta.icon("fa5s.save", color=THEME.text_primary))
self.save_btn.setToolTip("Save the current settings as a new preset under the typed name")

row_save.addWidget(self.name_input, stretch=1)
row_save.addWidget(self.save_btn)
self.layout.addLayout(row_save)
row.addWidget(self.apply_btn, stretch=1)
row.addWidget(self.save_btn, stretch=1)
row.addWidget(self.edit_btn)
row.addWidget(self.delete_btn)
self.layout.addLayout(row)

self.layout.addStretch()

def _connect_signals(self) -> None:
self.load_btn.clicked.connect(self._on_load_clicked)
self.preset_list.itemDoubleClicked.connect(self._apply_preset)
self.apply_btn.clicked.connect(self._apply_preset)
self.save_btn.clicked.connect(self._on_save_clicked)
self.edit_btn.clicked.connect(self._on_edit_clicked)
self.delete_btn.clicked.connect(self._on_delete_clicked)

def _current_name(self) -> str:
item = self.preset_list.currentItem()
return item.text() if item is not None else ""

def _apply_preset(self) -> None:
name = self._current_name()
if not name or not self.state.current_file_hash:
return
preset_cfg = self._preset_config(name)
if preset_cfg is None:
return

visible = self.controller.session.asset_model.visible_actual_indices()
sel_count = len([i for i in set(self.state.selected_indices) if i in visible])
dlg = GranularSettingsDialog(
self,
preset_cfg,
name,
show_scope=True,
show_current=True,
show_apply_mode=True,
sel_count=sel_count,
roll_count=len(visible),
exclude_sections=_PRESET_EXCLUDED_SECTIONS,
)
dlg.setWindowTitle("Apply Preset")
if dlg.exec() != QDialog.DialogCode.Accepted:
return
rows = dlg.selected()
if dlg.apply_mode() == "replace":
rows = list(_LOOK_ROWS) + rows
if self.controller.session.apply_preset_fields(preset_cfg, rows, dlg.scope()):
self.controller.request_render()

def _preset_config(self, name: str) -> WorkspaceConfig | None:
"""The preset's stored fields over defaults, so pickers show the preset's
own values, not the current image's."""
data = Presets.load_preset(name)
if not data:
return None
base = WorkspaceConfig().to_dict()
base.update(data)
return WorkspaceConfig.from_flat_dict(base)

def _on_save_clicked(self) -> None:
if not self.state.current_file_hash:
return
dlg = GranularSettingsDialog(
self,
self.state.config,
"current settings",
ask_name=True,
exclude_sections=_PRESET_EXCLUDED_SECTIONS,
)
if dlg.exec() == QDialog.DialogCode.Accepted:
Presets.save_preset(dlg.name(), selected_flat_dict(self.state.config, dlg.selected()))
self._refresh_presets(force=True)

def _on_edit_clicked(self) -> None:
name = self._current_name()
cfg = self._preset_config(name) if name else None
if cfg is None:
return
dlg = GranularSettingsDialog(self, cfg, name, ask_name=True, exclude_sections=_PRESET_EXCLUDED_SECTIONS)
dlg.setWindowTitle("Edit Preset")
dlg.set_name(name)
if dlg.exec() == QDialog.DialogCode.Accepted:
new_name = dlg.name()
Presets.save_preset(new_name, selected_flat_dict(cfg, dlg.selected()))
if new_name != name:
Presets.delete_preset(name)
self._refresh_presets(force=True)

def _on_delete_clicked(self) -> None:
from PyQt6.QtWidgets import QMessageBox

name = self.preset_combo.currentText()
name = self._current_name()
if not name:
return
reply = QMessageBox.question(
Expand All @@ -71,35 +160,21 @@ def _on_delete_clicked(self) -> None:
)
if reply == QMessageBox.StandardButton.Yes:
Presets.delete_preset(name)
self._refresh_presets()
self._refresh_presets(force=True)

def _on_load_clicked(self) -> None:
name = self.preset_combo.currentText()
if not name or not self.state.current_file_hash:
return

p_settings = Presets.load_preset(name)
if p_settings:
current_dict = self.state.config.to_dict()
current_dict.update(p_settings)
new_config = WorkspaceConfig.from_flat_dict(current_dict)
self.controller.session.update_config(new_config, persist=True)
self.controller.request_render()

def _on_save_clicked(self) -> None:
name = self.name_input.text()
if not name or not self.state.current_file_hash:
def _refresh_presets(self, force: bool = False) -> None:
# sync_ui fires on every state sync; skip the rebuild (N file reads)
# unless the name set actually changed. force covers same-name overwrites.
names = sorted(Presets.list_presets())
if not force and names == [self.preset_list.item(i).text() for i in range(self.preset_list.count())]:
return

Presets.save_preset(name, self.state.config)
self._refresh_presets()
self.name_input.clear()

def _refresh_presets(self) -> None:
self.preset_combo.blockSignals(True)
self.preset_combo.clear()
self.preset_combo.addItems(Presets.list_presets())
self.preset_combo.blockSignals(False)
self.preset_list.clear()
for name in names:
item = QListWidgetItem(name)
data = Presets.load_preset(name)
if data:
item.setToolTip(wrap_tooltip(preset_summary(data)))
self.preset_list.addItem(item)

def sync_ui(self) -> None:
self._refresh_presets()
2 changes: 1 addition & 1 deletion negpy/desktop/view/styles/modern_dark.qss
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ QPushButton#collapsible_header[overlay="true"]:checked {
background-color: rgba(26, 26, 26, 0.82);
}

/* Preset list in the Export Presets dialog. */
/* Preset lists: Export Presets dialog and the Presets sidebar. */
QListWidget#preset_list {
background: #0D0D0D;
border: 1px solid #262626;
Expand Down
Loading
Loading