From 5f4c4d15bcd3dff7baf33a2bac2fe838a60313fa Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:47:34 +0200 Subject: [PATCH] Add Sticky Zoom to keep zoom level across image switches Navigating to another frame always reset the canvas to fit-to-window. Sticky Zoom (toolbar overflow menu, canvas right-click menu, and a bindable shortcut) keeps the current zoom level instead, and persists across restarts like Immersive Canvas. --- docs/USER_GUIDE.md | 2 +- negpy/desktop/controller.py | 6 +++++- negpy/desktop/session.py | 15 +++++++++++++++ negpy/desktop/view/canvas/toolbar.py | 11 +++++++++++ negpy/desktop/view/canvas/widget.py | 4 ++++ negpy/desktop/view/keyboard_shortcuts.py | 1 + negpy/desktop/view/shortcut_registry.py | 1 + tests/test_controller.py | 20 ++++++++++++++++++++ tests/test_desktop_session.py | 10 ++++++++++ 9 files changed, 68 insertions(+), 2 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index bf45dd2e..6f61241b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index f4298722..4916bf0c 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -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) @@ -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. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index a7d642f9..875f1953 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -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 @@ -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) @@ -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: diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py index 80544e14..13515491 100644 --- a/negpy/desktop/view/canvas/toolbar.py +++ b/negpy/desktop/view/canvas/toolbar.py @@ -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) @@ -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 @@ -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) @@ -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) diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index 510c4fcc..ce20d77f 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -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: diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index bfda59ff..ef08b82b 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -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, diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 75779527..c61eef03 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -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"), diff --git a/tests/test_controller.py b/tests/test_controller.py index 95344c58..ead0aecc 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -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" diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 09002346..e0277e6c 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -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()