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. 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)
Expand Down
16 changes: 16 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions negpy/desktop/view/canvas/gpu_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions negpy/desktop/view/canvas/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
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 @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions negpy/desktop/view/canvas/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()."""
Expand All @@ -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:
Expand Down Expand Up @@ -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()
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 @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions negpy/desktop/view/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
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 @@ -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"),
Expand Down
Loading