Skip to content
Draft
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
5 changes: 2 additions & 3 deletions docs/KEYBOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ Actions with no default key are not listed; every one of them can still be bound
|-----|--------|
| `Left` | Previous file |
| `Right` | Next file |
| `Ctrl + L` | Open the library folder |
| `Alt + Up` | Go up one library folder |
| `Ctrl + L` | Open the library |
| `Ctrl + F` | Focus the film strip search box |
| `Ctrl + Shift + F` | Search every library folder and load the matches |

Expand Down Expand Up @@ -148,7 +147,7 @@ Actions with no default key are not listed; every one of them can still be bound
## Tabs
| Key | Action |
|-----|--------|
| `Ctrl + 1` | Setup tab |
| `Ctrl + 1` | Roll tab |
| `Ctrl + 2` | Geometry tab |
| `Ctrl + 3` | Tone tab |
| `Ctrl + 4` | Lab & Toning tab |
Expand Down
540 changes: 287 additions & 253 deletions docs/USER_GUIDE.md

Large diffs are not rendered by default.

116 changes: 112 additions & 4 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
resolve_preset_export,
)
from negpy.services.assets.composites import forget_composite, restore_maps
from negpy.services.assets import rolls
from negpy.services.assets.half_frame import (
HalfGeometry,
base_hash,
Expand Down Expand Up @@ -941,8 +942,20 @@ def restore_session(self) -> None:
active = self.session.repo.get_global_setting("session_active_path")
self._pending_scanned_file = active if active in paths else paths[0]
triplets = self.session.repo.get_global_setting("session_triplets", {}) or {}
self.state.active_roll_id = self._roll_id_for_restored_paths(paths)
self.request_asset_discovery(paths, auto_open=True, restore_triplets=triplets)

def _roll_id_for_restored_paths(self, paths: List[str]) -> Optional[str]:
"""The one roll every restored path agrees on, or None -- the roll a fresh
process would otherwise forget it had open, the same "only when unambiguous"
rule open_library_folders applies when several folders are opened at once."""
candidates = set(rolls.rolls_containing_path(self.session.repo, paths[0]))
for path in paths[1:]:
candidates &= set(rolls.rolls_containing_path(self.session.repo, path))
if not candidates:
return None
return next(iter(candidates)) if len(candidates) == 1 else None

def request_asset_discovery(
self,
paths: List[str],
Expand Down Expand Up @@ -1032,29 +1045,104 @@ def _start_next_asset_discovery(self) -> None:
if self._pending_asset_discoveries and not self._discovery_running and self._active_batch is None:
self._start_asset_discovery(self._pending_asset_discoveries.pop(0))

# --- Library (folders on disk) --------------------------------------------
# --- Library (a library of Rolls) ------------------------------------------

def library_roots(self) -> List[str]:
"""Top-level directories a library search walks. Maintained automatically by
importing a roll (or a parent full of them) — not a user-visible list."""
saved = self.session.repo.get_global_setting("library_roots", []) or []
return [p for p in saved if isinstance(p, str)]
return [p for p in saved if isinstance(p, str)] if isinstance(saved, list) else []

def _register_library_roots(self, paths: List[str]) -> None:
roots = self.library_roots()
new = [p for p in paths if p not in roots]
if new:
self.session.repo.save_global_setting("library_roots", [*roots, *new])

def has_rolls(self) -> bool:
return bool(rolls.saved_rolls(self.session.repo))

def import_subfolders_as_rolls(self, parent_path: str) -> List[str]:
"""Recognize every immediate subfolder of *parent_path* as its own roll, and
register it as a search root -- nothing is opened or loaded."""
roll_ids = rolls.import_subfolders_as_rolls(self.session.repo, parent_path)
if roll_ids:
self._register_library_roots([parent_path])
return roll_ids

def open_library_folder(self, folder: str, add_to_session: bool = False) -> None:
self.open_library_folders([folder], add_to_session=add_to_session)

def open_library_folders(self, folders: List[str], add_to_session: bool = False) -> None:
"""Load one or several folders' frames. Replacing the session costs nothing —
every edit lives in the database under its own content hash, not in the file list."""
"""Recognize and load one or several folders as rolls. Replacing the session
costs nothing — every edit lives in the database under its own content hash,
not in the file list."""
present = [f for f in folders if os.path.isdir(f)]
if not present:
self.set_status("Folder is no longer on disk", 3000)
return
if not add_to_session:
# Recognizing every opened folder is independent of which one, if any,
# becomes the active roll -- that only makes sense for a single one.
recognized = [rolls.recognize_folder(self.session.repo, f) for f in present]
self.state.active_roll_id = recognized[0] if len(recognized) == 1 else None
self._register_library_roots(present)
self.request_asset_discovery(
present,
auto_open=True,
replace_existing=not add_to_session,
reselect_path=self.state.current_file_path if add_to_session else None,
)

def open_roll(self, roll_id: str) -> None:
"""Open a roll (folder or virtual) by id. A folder roll's own contents are
(re)walked as usual, the same as opening it from the tree; its extra_paths --
files added by hand that are not physically in the folder -- ride along in the
same discovery pass, since request_asset_discovery already accepts a mix of
folder and file paths."""
entry = rolls.roll_for_id(self.session.repo, roll_id)
if entry is None:
self.set_status("That roll no longer exists", 3000)
return
if entry["kind"] == "folder":
paths = [entry["folder_path"], *entry.get("extra_paths", [])]
else:
paths = list(entry.get("member_paths", []))
if not paths:
self.set_status("This roll has no frames", 3000)
return
self.state.active_roll_id = roll_id
self.request_asset_discovery(paths, auto_open=True, replace_existing=True)

def create_roll_from_session(self, name: str) -> Optional[str]:
"""Save the frames currently in the Film Strip as a new virtual roll: a roll
that is not a folder, e.g. a library search's results, kept and named."""
paths = [f["path"] for f in self.state.uploaded_files if f.get("path")]
if not paths:
self.set_status("Nothing loaded to save as a roll", 3000)
return None
roll_id = rolls.create_virtual_roll(self.session.repo, name, paths)
self.state.active_roll_id = roll_id
self.set_status(f'Saved as roll "{name}"', 3000)
return roll_id

def request_rename_roll(self, roll_id: str, new_name: str, rename_folder: bool) -> bool:
"""Rename a roll's display name, and -- only if asked -- its backing folder on
disk too. All-or-nothing: if the disk rename fails (missing folder, a sibling
already named that, no permission), the display name is left alone as well,
so the two names can never end up telling different stories.
"""
if rename_folder:
entry = rolls.roll_for_id(self.session.repo, roll_id)
old_path = entry.get("folder_path", "") if entry else ""
new_path = rolls.rename_folder_roll_disk(self.session.repo, roll_id, new_name)
if new_path is None:
return False
if old_path and roll_id == self.state.active_roll_id:
self.session.rehome_folder_paths(old_path, new_path)
rolls.rename_roll(self.session.repo, roll_id, new_name)
return True

def invalidate_library_walk(self) -> None:
"""Drop the cached traversal so the next search re-reads the folders."""
QMetaObject.invokeMethod(self.library_worker, "invalidate", Qt.ConnectionType.QueuedConnection)
Expand Down Expand Up @@ -1093,6 +1181,9 @@ def _on_library_search_finished(self, paths: List[str]) -> None:
self.set_status("No frames in the library match that search", 4000)
return
self.set_status(f"{len(paths)} frame{'s' if len(paths) != 1 else ''} found", 3000)
# An ad hoc result, not (yet) any roll -- Save as Roll in the Film Strip turns it
# into one.
self.state.active_roll_id = None
self.request_asset_discovery(paths, auto_open=True, replace_existing=True)

def set_rgb_scan_mode(self, enabled: bool) -> None:
Expand Down Expand Up @@ -1463,6 +1554,13 @@ def _on_discovery_finished(self, valid_assets: List[Dict]) -> None:
self._reselect_after_discovery = None
active_discovery_keys = self._active_discovery_keys
self._active_discovery_keys = frozenset()

# Files appended (not replaced) while a roll is active join its membership, so
# reopening that roll later still shows what was added by hand.
if not replace_existing and self.state.active_roll_id:
for asset in valid_assets:
if asset.get("path"):
rolls.add_extra_member(self.session.repo, self.state.active_roll_id, asset["path"])
pending_scan = getattr(self, "_pending_scanned_file", None)

if replace_existing and valid_assets:
Expand Down Expand Up @@ -3296,6 +3394,16 @@ def _on_normalization_finished(self, locked_floors: tuple, locked_ceils: tuple)
self.status_progress_requested.emit(0, 0)
self.request_render()

def request_reset_roll(self) -> None:
"""Reset every visible frame to its own bare defaults -- Reset Settings, applied
to the whole roll at once."""
visible = [self.state.uploaded_files[i] for i in self.session.asset_model.visible_actual_indices_ordered()]
if not visible:
return
self.session.reset_roll(visible)
self.set_status(f"Reset {count_of(len(visible), 'frame')} to defaults", timeout=3000)
self.request_render()

def save_current_normalization_as_roll(self, name: str) -> None:
"""
Persists current batch normalization values as a named roll.
Expand Down
58 changes: 58 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ class AppState:
source_exif: Dict[str, Any] = field(default_factory=dict) # file_hash -> piexif dict
selected_file_idx: int = -1
selected_indices: List[int] = field(default_factory=list)
# The roll (negpy.services.assets.rolls) the loaded frames came from, if any -- a
# plain Add Files pick or a clear leaves this None. Files appended while it is set
# join that roll's membership so reopening it later still shows them.
active_roll_id: Optional[str] = None
active_adjustment_idx: int = 0
last_metrics: Dict[str, Any] = field(default_factory=dict)
metrics_lock: threading.Lock = field(default_factory=threading.Lock, init=False, compare=False, repr=False)
Expand Down Expand Up @@ -1364,6 +1368,21 @@ def reset_settings(self) -> None:
asset = self.state.uploaded_files[idx] if 0 <= idx < len(self.state.uploaded_files) else {}
self.update_config(self._asset_defaults(WorkspaceConfig(), asset), persist=True)

def reset_roll(self, assets: List[Dict]) -> None:
"""`reset_settings`, applied to every one of *assets* at once. Each frame's reset
is still an ordinary undo step; the active frame (if among them) re-renders via
`update_config`, the rest are written straight to the DB with an external history
step, the same split `_on_normalization_finished` uses for a roll-wide write.
"""
for f_info in assets:
new_p = self._asset_defaults(WorkspaceConfig(), f_info)
if f_info["hash"] == self.state.current_file_hash:
self.update_config(new_p, persist=True)
continue
old_p = self.repo.load_file_settings(f_info["hash"]) or self.config_for_asset(f_info)
self.push_external_history(f_info["hash"], old_p, new_p)
self.repo.save_file_settings(f_info["hash"], new_p, file_path=f_info["path"])

def reset_section(self, section: str) -> None:
"""Reset a single feature section to its default config."""
from negpy.features.exposure.models import ExposureConfig
Expand Down Expand Up @@ -1612,12 +1631,51 @@ def clear_files(self) -> None:
self.state.uploaded_files.clear()
self.state.thumbnails.clear()
self.state.rendered_thumbnails.clear()
self.state.active_roll_id = None
self._reset_active_image_state()

self.asset_model.refresh()
self.state_changed.emit()
self._persist_session()

def rehome_folder_paths(self, old_prefix: str, new_prefix: str) -> None:
"""After a folder roll's own folder is renamed on disk, repoint every loaded
asset (and the active file) that lived under *old_prefix* to *new_prefix* --
content hashes are unchanged, so edits and history still find their frame by
hash alone; only the session's own path bookkeeping needs to catch up.
"""
old_prefix = old_prefix.rstrip("/\\")

def rehome(path: str) -> str:
if path and (path == old_prefix or path.startswith(old_prefix + os.sep)):
return new_prefix + path[len(old_prefix) :]
return path

changed = False
for f in self.state.uploaded_files:
for key in ("path", "green_path", "blue_path"):
if f.get(key):
new_val = rehome(f[key])
if new_val != f[key]:
f[key] = new_val
changed = True
for key in ("stitch_paths", "hdr_paths"):
if f.get(key):
new_list = [rehome(p) for p in f[key]]
if new_list != f[key]:
f[key] = new_list
changed = True

if self.state.current_file_path:
new_current = rehome(self.state.current_file_path)
if new_current != self.state.current_file_path:
self.state.current_file_path = new_current
changed = True

if changed:
self.asset_model.refresh()
self._persist_session()

def remove_current_file(self) -> None:
"""
Removes the currently selected file from the session.
Expand Down
57 changes: 56 additions & 1 deletion negpy/desktop/view/confirm.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,31 @@
from PyQt6.QtWidgets import QMessageBox
from PyQt6.QtWidgets import QCheckBox, QMessageBox


def confirm_load_roll(parent, repo, image_count: int, label: str) -> bool:
"""Ask before hashing and thumbnailing a folder's images into the session.

Skippable via "Always load without asking", persisted so importing a library
full of rolls one at a time does not re-prompt for each.
"""
if repo.get_global_setting("library_autoload_folders", False):
return True

n = image_count
box = QMessageBox(parent)
box.setIcon(QMessageBox.Icon.Question)
box.setWindowTitle("Load Roll")
box.setText(f"Load {n} image{'s' if n != 1 else ''} from “{label}”?")
box.setInformativeText("They are hashed and thumbnailed on load, which takes a moment on a large roll.")
remember = QCheckBox("Always load without asking")
box.setCheckBox(remember)
load = box.addButton("Load", QMessageBox.ButtonRole.AcceptRole)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
box.exec()
if box.clickedButton() is not load:
return False
if remember.isChecked():
repo.save_global_setting("library_autoload_folders", True)
return True


def confirm_unload(parent, *, clear_all: bool = False, count: int = 1) -> bool:
Expand Down Expand Up @@ -29,6 +56,21 @@ def confirm_unload(parent, *, clear_all: bool = False, count: int = 1) -> bool:
return box.exec() == QMessageBox.StandardButton.Yes


def confirm_reset_roll(parent, count: int) -> bool:
"""Ask before resetting every visible frame to its own defaults. Each frame's
reset is still an ordinary undo step, but doing it to a whole roll at once is
easy to fire by accident. Enter confirms (default button); Esc cancels.
"""
box = QMessageBox(parent)
box.setIcon(QMessageBox.Icon.Question)
box.setWindowTitle("Reset Roll to Defaults")
box.setText(f"Reset all {count} loaded frame{'s' if count != 1 else ''} to their own defaults?")
box.setInformativeText("Every frame's edit is undone at once. Each one is still a normal undo step, frame by frame.")
box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
box.setDefaultButton(QMessageBox.StandardButton.Yes)
return box.exec() == QMessageBox.StandardButton.Yes


def confirm_delete_named(parent, kind: str, name: str, *, informative: str = "") -> bool:
"""Ask before deleting a named, user-created item — a work print, a roll, a
flat-field profile. None of them are undoable and none can be re-derived from the
Expand All @@ -45,6 +87,19 @@ def confirm_delete_named(parent, kind: str, name: str, *, informative: str = "")
return box.exec() == QMessageBox.StandardButton.Yes


def confirm_delete_several(parent, kind: str, names: list, *, informative: str = "") -> bool:
"""Ask before deleting several named items at once, selected together. Enter
confirms; Esc cancels."""
box = QMessageBox(parent)
box.setIcon(QMessageBox.Icon.Question)
box.setWindowTitle(f"Delete {len(names)} {kind}s")
box.setText(f"Delete these {len(names)} {kind.lower()}s?")
box.setInformativeText(informative or "\n".join(names))
box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
box.setDefaultButton(QMessageBox.StandardButton.Yes)
return box.exec() == QMessageBox.StandardButton.Yes


def confirm_delete_mask(parent) -> bool:
"""Ask before deleting a single dodge/burn mask. Enter confirms; Esc cancels."""
box = QMessageBox(parent)
Expand Down
Loading
Loading