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 docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This guide is for new users. It explains what each control does, when you'd 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 of the current frame. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. A floating toolbar along the bottom holds Fit/1:1 zoom, undo/redo, rotate/flip and more, moving overflow items into an **⋯** menu when the window narrows — that menu also has **Immersive Canvas** (image fills the canvas and the toolbar overlaps it; turn off to reserve space above the toolbar so it never occludes the image). 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 plus undo/redo, rotate/flip and more, moving overflow items into an **⋯** menu when the window narrows — 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). 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 and copy/paste settings. 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.

### The workflow (and the order things happen)
Expand Down
6 changes: 5 additions & 1 deletion negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ def _connect_signals(self) -> None:
self.session.active_file_changing.connect(lambda: self._update_thumbnail_from_state(force_readback=True))
self.session.session_emptied.connect(self._render_memo.clear)
self.session.session_emptied.connect(self._strip_memo.clear)
self.session.file_selected.connect(self.load_file)
self.session.file_selected.connect(self._on_file_selected_load)
self.session.state_changed.connect(self.config_updated.emit)
self.session.state_changed.connect(self._render_debounce.start)
self.session.files_changed.connect(self._render_debounce.start)
Expand Down Expand Up @@ -1220,6 +1220,10 @@ def _strip_memo_key(self, kind: str = "tone") -> str:
exposure = replace(exposure, density=1.0, grade=115.0)
return f"{kind}:{self._render_memo_key(replace(self.state.config, exposure=exposure))}"

def _on_file_selected_load(self, file_path: str) -> None:
"""``session.file_selected`` handler: navigation honors the sticky-zoom preference."""
self.load_file(file_path, preserve_zoom=self.state.sticky_zoom)

def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: bool = False) -> None:
"""
Dispatches RAW decode to a background worker to keep the UI thread free.
Expand Down
15 changes: 15 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ class AppState:
# canvas and the toolbar overlaps.
immersive_canvas: bool = True

# When True, switching to a different image keeps the current zoom level
# instead of resetting to fit-to-window.
sticky_zoom: bool = False

# Crop tool composition guide (CropGuide value); display-only, so not in GeometryConfig
crop_guide: str = "thirds"
crop_guide_orientation: int = 0
Expand Down Expand Up @@ -472,6 +476,10 @@ def __init__(self, repo: StorageRepository):
if saved_immersive is not None:
self.state.immersive_canvas = bool(saved_immersive)

saved_sticky_zoom = self.repo.get_global_setting("sticky_zoom")
if saved_sticky_zoom is not None:
self.state.sticky_zoom = bool(saved_sticky_zoom)

saved_guide = self.repo.get_global_setting("crop_guide")
if saved_guide in set(CropGuide):
self.state.crop_guide = str(saved_guide)
Expand Down Expand Up @@ -574,6 +582,13 @@ def set_immersive_canvas(self, enabled: bool) -> None:
self.repo.save_global_setting("immersive_canvas", enabled)
self.state_changed.emit()

def set_sticky_zoom(self, enabled: bool) -> None:
"""Updates and persists whether zoom carries over between images."""
if self.state.sticky_zoom != enabled:
self.state.sticky_zoom = enabled
self.repo.save_global_setting("sticky_zoom", enabled)
self.state_changed.emit()

def set_canvas_bg(self, index: int) -> None:
"""Updates and persists the canvas background color index."""
if self.state.canvas_bg_index != index:
Expand Down
11 changes: 11 additions & 0 deletions negpy/desktop/view/canvas/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ def _init_ui(self) -> None:
self._ov_immersive_action.setToolTip(
tooltip_with_shortcut("Toolbar overlaps image — turn off to fit the image above the toolbar", "toggle_immersive_canvas")
)
self._ov_sticky_zoom_action = overflow_menu.addAction("Sticky Zoom")
self._ov_sticky_zoom_action.setCheckable(True)
self._ov_sticky_zoom_action.setChecked(self.session.state.sticky_zoom)
self._ov_sticky_zoom_action.setToolTip(
tooltip_with_shortcut("Keep the current zoom level when switching images, instead of resetting to fit", "toggle_sticky_zoom")
)
overflow_menu.addSeparator()

db_action = overflow_menu.addAction(qta.icon("fa5s.database", color=icon_color), "Manage Database…", self._show_database_dialog)
Expand Down Expand Up @@ -484,6 +490,7 @@ def _connect_signals(self) -> None:
self._ov_undo_action.triggered.connect(lambda: _context_undo(self.controller))
self._ov_redo_action.triggered.connect(self.session.redo)
self._ov_immersive_action.triggered.connect(self._on_immersive_toggled)
self._ov_sticky_zoom_action.triggered.connect(self._on_sticky_zoom_toggled)

def _on_overflow_unload(self) -> None:
from negpy.desktop.view.confirm import confirm_unload
Expand All @@ -496,6 +503,9 @@ def _on_overflow_unload(self) -> None:
def _on_immersive_toggled(self, checked: bool) -> None:
self.session.set_immersive_canvas(checked)

def _on_sticky_zoom_toggled(self, checked: bool) -> None:
self.session.set_sticky_zoom(checked)

def _on_gpu_toggled(self, checked: bool) -> None:
if checked != self.session.state.gpu_enabled:
self.session.set_gpu_enabled(checked)
Expand Down Expand Up @@ -655,6 +665,7 @@ def _update_ui_state(self) -> None:
self._ov_flip_h_action.setChecked(geo.flip_horizontal)
self._ov_flip_v_action.setChecked(geo.flip_vertical)
self._ov_immersive_action.setChecked(state.immersive_canvas)
self._ov_sticky_zoom_action.setChecked(state.sticky_zoom)

self.btn_undo.setEnabled(state.undo_index > 0)
self.btn_redo.setEnabled(state.undo_index < state.max_history_index)
Expand Down
4 changes: 4 additions & 0 deletions negpy/desktop/view/canvas/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,10 @@ def contextMenuEvent(self, event) -> None:
menu.addSeparator()
act_reset = menu.addAction("Reset View")
act_reset.triggered.connect(self.fit_to_window)
act_sticky_zoom = menu.addAction("Sticky Zoom")
act_sticky_zoom.setCheckable(True)
act_sticky_zoom.setChecked(self.state.sticky_zoom)
act_sticky_zoom.toggled.connect(self._controller.session.set_sticky_zoom) # type: ignore[union-attr]
menu.exec(event.globalPos())

def _exec_retouch_menu(self, event) -> None:
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/keyboard_shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]:
"search_library": self.window.session_panel.file_browser.search_library,
"toggle_library_tree": self.window.session_panel.toggle_library_tree,
"toggle_immersive_canvas": lambda: controller.session.set_immersive_canvas(not controller.session.state.immersive_canvas),
"toggle_sticky_zoom": lambda: controller.session.set_sticky_zoom(not controller.session.state.sticky_zoom),
"toggle_left_panel": self.window.toggle_session_dock,
"toggle_right_panel": self.window.toggle_controls_dock,
"reset_panel_layout": self.window.reset_panel_layout,
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/shortcut_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ class ShortcutEntry:
"search_library": ShortcutEntry("Ctrl+Shift+F", "Search every library folder and load the matches", "Navigation"),
"toggle_library_tree": ShortcutEntry("", "Show/hide the library folder tree", "View"),
"toggle_immersive_canvas": ShortcutEntry("", "Immersive canvas (toolbar overlaps image)", "View"),
"toggle_sticky_zoom": ShortcutEntry("", "Sticky zoom (keep zoom level when switching images)", "View"),
"toggle_left_panel": ShortcutEntry("Ctrl+[", "Toggle session panel (re-docks when floating)", "View"),
"toggle_right_panel": ShortcutEntry("Ctrl+]", "Toggle controls panel (re-docks when floating)", "View"),
"reset_panel_layout": ShortcutEntry("Ctrl+Shift+L", "Dock session and controls panels", "View"),
Expand Down
20 changes: 20 additions & 0 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,26 @@ def test_load_file_preserve_zoom(self):

mock_slot.assert_not_called()

def test_file_selected_navigation_resets_zoom_by_default(self):
"""Switching images via session.file_selected resets zoom unless sticky_zoom is on."""
self.controller.state.sticky_zoom = False
mock_slot = MagicMock()
self.controller.zoom_requested.connect(mock_slot)

self.controller._on_file_selected_load("dummy.dng")

mock_slot.assert_called_once_with(1.0)

def test_file_selected_navigation_preserves_zoom_when_sticky(self):
"""With sticky_zoom on, switching images must not reset the zoom level."""
self.controller.state.sticky_zoom = True
mock_slot = MagicMock()
self.controller.zoom_requested.connect(mock_slot)

self.controller._on_file_selected_load("dummy.dng")

mock_slot.assert_not_called()

def test_toggle_hq_preview_preserves_zoom(self):
"""Test that toggling HQ mode persists via session and preserves zoom."""
self.controller.state.current_file_path = "dummy.dng"
Expand Down
10 changes: 10 additions & 0 deletions tests/test_desktop_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ def test_set_autodetect_enabled_noop_when_unchanged(self):
self.session.set_autodetect_enabled(False)
self.mock_repo.save_global_setting.assert_not_called()

def test_set_sticky_zoom_persists(self):
self.assertFalse(self.session.state.sticky_zoom)
self.session.set_sticky_zoom(True)
self.assertTrue(self.session.state.sticky_zoom)
self.mock_repo.save_global_setting.assert_called_with("sticky_zoom", True)

def test_set_sticky_zoom_noop_when_unchanged(self):
self.session.set_sticky_zoom(False)
self.mock_repo.save_global_setting.assert_not_called()

def test_persist_writes_sticky_settings_in_one_batch(self):
self.session.select_file(0)
self.mock_repo.save_global_settings.reset_mock()
Expand Down
Loading