From 3e90cac71ef642d9df944e9b78c99cb3f7c06969 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:19:49 +0200 Subject: [PATCH] Add Immersive Canvas toggle to fit image above the floating toolbar When disabled, fit-to-window reserves space for the toolbar pill so the image is never occluded. The setting persists across sessions and is accessible from the overflow menu and the shortcut editor. --- docs/USER_GUIDE.md | 2 +- negpy/desktop/session.py | 16 ++++++++++++++++ negpy/desktop/view/canvas/gpu_widget.py | 9 +++++++-- negpy/desktop/view/canvas/overlay.py | 7 ++++--- negpy/desktop/view/canvas/toolbar.py | 11 +++++++++++ negpy/desktop/view/canvas/widget.py | 14 ++++++++++++++ negpy/desktop/view/keyboard_shortcuts.py | 1 + negpy/desktop/view/main_window.py | 6 ++++++ negpy/desktop/view/shortcut_registry.py | 1 + 9 files changed, 61 insertions(+), 6 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d8a0c5e7..82374cc5 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. 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. 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**. * **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/session.py b/negpy/desktop/session.py index ea8e0a25..4a6bbabd 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -95,6 +95,11 @@ class AppState: # Canvas background color swatch index (0=Black, 1=Dark Grey, 2=Mid Grey) canvas_bg_index: int = 0 + # When False, fit-to-window reserves space for the floating toolbar so the + # image never sits behind it. When True (default), the image fills the full + # canvas and the toolbar overlaps. + immersive_canvas: bool = True + # Crop tool composition guide (CropGuide value); display-only, so not in GeometryConfig crop_guide: str = "thirds" crop_guide_orientation: int = 0 @@ -459,6 +464,10 @@ def __init__(self, repo: StorageRepository): if saved_bg is not None: self.state.canvas_bg_index = int(saved_bg) + saved_immersive = self.repo.get_global_setting("immersive_canvas") + if saved_immersive is not None: + self.state.immersive_canvas = bool(saved_immersive) + saved_guide = self.repo.get_global_setting("crop_guide") if saved_guide in set(CropGuide): self.state.crop_guide = str(saved_guide) @@ -554,6 +563,13 @@ def set_autodetect_enabled(self, enabled: bool) -> None: self.repo.save_global_setting("autodetect_enabled", enabled) self.state_changed.emit() + def set_immersive_canvas(self, enabled: bool) -> None: + """Updates and persists the immersive canvas preference.""" + if self.state.immersive_canvas != enabled: + self.state.immersive_canvas = enabled + self.repo.save_global_setting("immersive_canvas", 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/gpu_widget.py b/negpy/desktop/view/canvas/gpu_widget.py index a8808bdf..7846279d 100644 --- a/negpy/desktop/view/canvas/gpu_widget.py +++ b/negpy/desktop/view/canvas/gpu_widget.py @@ -50,6 +50,7 @@ def __init__(self, parent: Optional[QWidget] = None): self.pan_x: float = 0.0 self.pan_y: float = 0.0 self._bg: Tuple[float, float, float] = (0.02, 0.02, 0.02) + self.fit_height_reserve: float = 0.0 # Debounce resize to prevent context thrashing self.resize_timer = QTimer() @@ -369,10 +370,14 @@ def _draw_frame(self) -> None: ww, wh = float(current_tex.width), float(current_tex.height) iw, ih = float(self.image_size[0]), float(self.image_size[1]) - r = min(ww / iw, wh / ih) + dpr = self.devicePixelRatioF() + reserve_px = self.fit_height_reserve * dpr + fit_h = max(1.0, wh - reserve_px) + r = min(ww / iw, fit_h / ih) nw, nh = iw * r, ih * r - nx, ny = (ww - nw) / 2.0, (wh - nh) / 2.0 + nx = (ww - nw) / 2.0 + ny = (fit_h - nh) / 2.0 self.device.queue.write_buffer( self.uniform_buffer, diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 639b276e..7c63239a 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -248,6 +248,7 @@ def __init__(self, state: AppState, parent=None): self.zoom_level: float = 1.0 self.pan_x: float = 0.0 self.pan_y: float = 0.0 + self.fit_height_reserve: float = 0.0 self._view_rect: QRectF = QRectF() @@ -434,18 +435,18 @@ def _recalc_view_rect(self) -> None: self._view_rect = QRectF() return - # No margins - use full widget dimensions w, h = self.width(), self.height() img_w, img_h = size.width(), size.height() - scale_fit = min(w / img_w, h / img_h) + fit_h = max(1.0, h - self.fit_height_reserve) + scale_fit = min(w / img_w, fit_h / img_h) total_scale = scale_fit * self.zoom_level final_w = img_w * total_scale final_h = img_h * total_scale center_x = (w / 2) + (self.pan_x * w) - center_y = (h / 2) + (self.pan_y * h) + center_y = (fit_h / 2) + (self.pan_y * h) self._view_rect = QRectF(center_x - (final_w / 2), center_y - (final_h / 2), final_w, final_h) self._remap_inflight_points(old_rect) diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py index 0a7e3fe0..80544e14 100644 --- a/negpy/desktop/view/canvas/toolbar.py +++ b/negpy/desktop/view/canvas/toolbar.py @@ -321,6 +321,12 @@ def _init_ui(self) -> None: self._reset_panel_layout, ) reset_layout_action.setToolTip("Restore the default panel sizes and positions") + self._ov_immersive_action = overflow_menu.addAction("Immersive Canvas") + self._ov_immersive_action.setCheckable(True) + self._ov_immersive_action.setChecked(self.session.state.immersive_canvas) + self._ov_immersive_action.setToolTip( + tooltip_with_shortcut("Toolbar overlaps image — turn off to fit the image above the toolbar", "toggle_immersive_canvas") + ) overflow_menu.addSeparator() db_action = overflow_menu.addAction(qta.icon("fa5s.database", color=icon_color), "Manage Database…", self._show_database_dialog) @@ -477,6 +483,7 @@ def _connect_signals(self) -> None: self._ov_flat_peek_action.triggered.connect(lambda checked: self.controller.toggle_flat_peek(force=checked)) 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) def _on_overflow_unload(self) -> None: from negpy.desktop.view.confirm import confirm_unload @@ -486,6 +493,9 @@ def _on_overflow_unload(self) -> None: if confirm_unload(self): self.session.remove_current_file() + def _on_immersive_toggled(self, checked: bool) -> None: + self.session.set_immersive_canvas(checked) + def _on_gpu_toggled(self, checked: bool) -> None: if checked != self.session.state.gpu_enabled: self.session.set_gpu_enabled(checked) @@ -644,6 +654,7 @@ def _update_ui_state(self) -> None: self.btn_flip_v.setChecked(geo.flip_vertical) 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.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 7b803eee..510c4fcc 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -249,6 +249,15 @@ def _image_dims(self) -> Optional[Tuple[int, int]]: return int(buf.width), int(buf.height) return None + def _toolbar_reserved_height(self) -> int: + """Logical pixels reserved at the canvas bottom for the floating toolbar + when immersive mode is off.""" + tb = self._floating_toolbar + if tb is None: + return 0 + size = tb.pill_size_hint() if hasattr(tb, "pill_size_hint") else tb.sizeHint() + return size.height() + _TOOLBAR_INSET + def _fit_scale(self) -> Optional[float]: """Device-pixel scale the shader applies at zoom_level 1.0 — its fit ratio min(viewport / image). True pixel zoom = zoom_level * _fit_scale().""" @@ -259,6 +268,8 @@ def _fit_scale(self) -> Optional[float]: dpr = self.devicePixelRatioF() vw = max(1.0, self.width() * dpr) vh = max(1.0, self.height() * dpr) + if not self.state.immersive_canvas: + vh = max(1.0, vh - self._toolbar_reserved_height() * dpr) return min(vw / max(1, img_w), vh / max(1, img_h)) def current_zoom_percent(self) -> int: @@ -523,7 +534,10 @@ def mouseReleaseEvent(self, event: QMouseEvent) -> None: def _sync_transform(self) -> None: """Propagates zoom/pan to sub-widgets.""" + reserve = self._toolbar_reserved_height() if not self.state.immersive_canvas else 0 + self.gpu_widget.fit_height_reserve = reserve self.gpu_widget.set_transform(self.zoom_level, self.pan_offset.x(), self.pan_offset.y()) + self.overlay.fit_height_reserve = reserve self.overlay.set_transform(self.zoom_level, self.pan_offset.x(), self.pan_offset.y()) self.zoom_changed.emit(self.zoom_level) self.update() diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 547e8c83..a0e491c6 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -118,6 +118,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "focus_search": self.window.session_panel.file_browser.focus_search, "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_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/main_window.py b/negpy/desktop/view/main_window.py index 0d83baee..05e50ff8 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -333,6 +333,11 @@ def _update_title(self) -> None: else: self.setWindowTitle("NegPy") + def _on_immersive_changed(self) -> None: + if getattr(self, "_last_immersive", None) != self.controller.session.state.immersive_canvas: + self._last_immersive = self.controller.session.state.immersive_canvas + self.canvas.fit_to_window() + def show_tutorial(self) -> None: from negpy.desktop.view.widgets.tutorial_steps import build @@ -409,6 +414,7 @@ def reset_panel_layout(self) -> None: def _connect_signals(self) -> None: """Wire controller and view.""" self.controller.session.state_changed.connect(self._update_title) + self.controller.session.state_changed.connect(self._on_immersive_changed) # visibilityChanged only mirrors the button — it also fires on close/minimize, # so we persist in the toggle methods to avoid clobbering the saved state on exit. diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 0db7f789..bc2476ca 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -135,6 +135,7 @@ class ShortcutEntry: "focus_search": ShortcutEntry("Ctrl+F", "Focus the film strip search box", "Navigation"), "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_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"),