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
4 changes: 2 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ If Windows blocks the default data folder, NegPy suggests `%LOCALAPPDATA%\NegPy\

* **Left, the film strip**: your frames as a contact sheet, with import, sorting and triage tools.
* **Center, the canvas**: the live preview. Scroll or pinch to zoom, drag to pan. The bottom toolbar holds navigation, zoom (**Fit to Window**, 25–400%, **HQ Preview**), rotate/flip (on every selected frame), **Tools** (arms any canvas tool: crop, straighten, the pickers, masks, retouching), undo/redo, the **Print** menu (Before / After and the peeks below) and **Overlays** (Zone Overlay, Clipping Overlay, Printing Notes, Grain Focuser). An armed canvas tool shows its use, its slider and **Done** on the toolbar; its name opens the same menu to switch tools. The **⋯** menu holds every action, including **Preferences…** (§15). Right-click the toolbar for **Edit Toolbar…**. Right-click the image for **Reset View**, **Sticky Zoom**, the pickers, copy/paste settings, **Reset Settings**, **Reset to Roll Settings** and **Unload** (drop the frame from the session; its edit stays).
* **Right, the controls**: tabs **Roll** / **Frame** / **Metadata** / **Gear** / **Export** / **Scan**. **Frame** has a pinned **Analysis** readout and its own row of tabs. Only Roll and Frame change the render.
* **Right, the controls**: tabs **Roll** / **Frame** / **Metadata** / **Gear** / **Export** / **Scan**. **Roll** and **Frame** have a pinned **Analysis** readout; **Frame** has its own row of tabs. Only Roll and Frame change the render.

With nothing open, the canvas offers **Load Scans…** and **Take the Tour**.

Expand Down Expand Up @@ -291,7 +291,7 @@ A scene is a group of frames in one roll shot in the same light. It has its own
<!-- panel:analysis -->
## 3. Analysis readout (always visible)

The readout sits above the tabs and describes the current frame as you edit. Top to bottom:
The readout sits above the Roll and Frame tabs and describes the current frame as you edit. Top to bottom:

#### Photometric curve

Expand Down
8 changes: 5 additions & 3 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6688,8 +6688,8 @@ def request_render(
if preview_raw is None:
return

# A drag asks for no metrics, the release does. Interactive frames go through the
# proxy, so full resolution arrives only once the gesture settles. The baseline
# A drag passes readback_metrics=False, the release True. Interactive frames go
# through the proxy, so full resolution arrives only once the gesture settles. The baseline
# capture wants no metrics but full resolution: it is painted beside the edit, and a
# proxy would show softer pixels on one side of the divider.
interactive = not readback_metrics and not compare_capture
Expand Down Expand Up @@ -6723,7 +6723,9 @@ def request_render(
source_hash=self.state.current_file_hash or "preview",
preview_size=target_size,
gpu_enabled=self.state.gpu_enabled,
readback_metrics=readback_metrics,
# A drag of the edit itself reads metrics back from the proxy, so the Analysis
# panel tracks it. An override must not replace the edit's histograms.
readback_metrics=readback_metrics or (interactive and config_override is None and not ephemeral),
ir_buffer=ir_buffer,
detect_buffer=detect_buffer,
crop_preview_full=crop_preview_full,
Expand Down
78 changes: 34 additions & 44 deletions negpy/desktop/view/sidebar/right_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ def _tab_width(labels: list[str], pixel_size: int) -> int:

class RightPanel(QWidget):
"""Right sidebar panel: a flat tab switcher across Roll / Frame / Metadata / Gear /
Export / Scan, in the order an edit moves through them. Frame, Metadata and Gear pin
a section above their own scroll area (Analysis, Preview, the Items/Presets
switcher); Export and Scan are plain pages."""
Export / Scan, in the order an edit moves through them. Analysis is pinned above
Roll and Frame. Metadata and Gear pin a section above their own scroll area
(Preview, the Items/Presets switcher); Export and Scan are plain pages."""

def __init__(self, controller: AppController):
super().__init__()
Expand Down Expand Up @@ -164,19 +164,42 @@ def wrap_scroll(widget: QWidget) -> QScrollArea:
if key == "scan":
self._scan_group_index = i

# Vertical splitter lets the user resize Analysis vs. the tabs below
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.splitter.addWidget(self.analysis_section)
self.splitter.addWidget(self.group_stack)
self.splitter.setCollapsible(0, False)
self.splitter.setCollapsible(1, False)
self.splitter.setStretchFactor(0, 0)
self.splitter.setStretchFactor(1, 1)

repo = self.controller.session.repo
saved_sizes = repo.get_global_setting("analysis_splitter_sizes")
if isinstance(saved_sizes, list) and len(saved_sizes) == 2:
self.splitter.setSizes([int(s) for s in saved_sizes])
else:
self.splitter.setSizes(default_analysis_split(QApplication.primaryScreen()))
self.splitter.splitterMoved.connect(lambda *_: repo.save_global_setting("analysis_splitter_sizes", self.splitter.sizes()))

# Collapsing the Analysis section hands its splitter space back to the tabs below and pins
# the header at the top, instead of leaving a large empty pane.
self._analysis_expanded_size = self.splitter.sizes()[0]
self.analysis_section.expanded_changed.connect(self._resize_splitter_for_analysis)
if not self.analysis_section.toggle_button.isChecked():
self._resize_splitter_for_analysis(False)

layout.addWidget(self.group_switcher)
layout.addWidget(self.group_stack, 1)
layout.addWidget(self.splitter, 1)

self.apply_shortcut_tooltips()

repo = self.controller.session.repo
saved_group = repo.get_global_setting("right_panel_group", 0)
self._switch_group(saved_group if isinstance(saved_group, int) and 0 <= saved_group < len(self._group_buttons) else 0)

def _build_frame_page(self, wrap_scroll) -> QWidget:
"""Sticky Analysis section pinned above a second, inner tab switcher for the
per-image workflow control groups, Favorites and History -- everything that
changes what the canvas shows for the one loaded frame."""
"""The Analysis section, and a second, inner tab switcher for the per-image
workflow control groups, Favorites and History -- everything that changes what
the canvas shows for the one loaded frame."""
page = QWidget()
page_layout = QVBoxLayout(page)
page_layout.setContentsMargins(0, 0, 0, 0)
Expand Down Expand Up @@ -254,7 +277,6 @@ def _build_frame_page(self, wrap_scroll) -> QWidget:
self.zone_highlight_btn.setChecked(self.controller.state.zone_hover_highlight)
self.zone_highlight_btn.toggled.connect(self.controller.toggle_zone_hover_highlight)
self._refresh_zone_strip_hint()
analysis_expanded = self.analysis_section.toggle_button.isChecked()

# Tab content widgets
self.controls_panel = ControlsPanel(self.controller)
Expand Down Expand Up @@ -312,38 +334,8 @@ def _build_frame_page(self, wrap_scroll) -> QWidget:
for attr in section_attrs:
self._section_tab_index[attr] = i

# Tabs (switcher + stack) live in the bottom splitter pane
tabs_container = QWidget()
tabs_vbox = QVBoxLayout(tabs_container)
tabs_vbox.setContentsMargins(0, 0, 0, 0)
tabs_vbox.setSpacing(0)
tabs_vbox.addWidget(self.switcher)
tabs_vbox.addWidget(self.stack, 1)

# Vertical splitter lets the user resize Analysis vs. the tabs below
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.splitter.addWidget(self.analysis_section)
self.splitter.addWidget(tabs_container)
self.splitter.setCollapsible(0, False)
self.splitter.setCollapsible(1, False)
self.splitter.setStretchFactor(0, 0)
self.splitter.setStretchFactor(1, 1)

saved_sizes = repo.get_global_setting("analysis_splitter_sizes")
if isinstance(saved_sizes, list) and len(saved_sizes) == 2:
self.splitter.setSizes([int(s) for s in saved_sizes])
else:
self.splitter.setSizes(default_analysis_split(QApplication.primaryScreen()))
self.splitter.splitterMoved.connect(lambda *_: repo.save_global_setting("analysis_splitter_sizes", self.splitter.sizes()))

# Collapsing the Analysis section hands its splitter space back to the tabs below and pins
# the header at the top, instead of leaving a large empty pane.
self._analysis_expanded_size = self.splitter.sizes()[0]
self.analysis_section.expanded_changed.connect(self._resize_splitter_for_analysis)
if not analysis_expanded:
self._resize_splitter_for_analysis(False)

page_layout.addWidget(self.splitter, 1)
page_layout.addWidget(self.switcher)
page_layout.addWidget(self.stack, 1)

saved_tab = repo.get_global_setting("right_panel_tab", 0)
self._switch_tab(saved_tab if isinstance(saved_tab, int) and 0 <= saved_tab < len(self._tab_buttons) else 0)
Expand Down Expand Up @@ -685,6 +677,7 @@ def _switch_group(self, index: int) -> None:
self._active_group = index
self.controller.session.repo.save_global_setting("right_panel_group", index)
self.group_stack.setCurrentIndex(index)
self.analysis_section.setVisible(self._group_keys[index] in ("roll", "frame"))
for i, btn in enumerate(self._group_buttons):
btn.setChecked(i == index)
self.group_switcher.set_pinned(index)
Expand Down Expand Up @@ -833,9 +826,6 @@ def _refresh_zone_placement(self) -> None:
def _update_analysis(self) -> None:
metrics = self.controller.session.state.last_metrics
self.zone_strip.set_placement_enabled(self.controller.zone_placement_available())
# Mid-gesture frames carry no metrics; the settle frame refreshes all of this.
if self.controller.session.state.canvas_value("interactive"):
return
# The film mode picks the vectorscope's scale, and it can change with the frame.
self._sync_scale_btn()

Expand Down
18 changes: 18 additions & 0 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,24 @@ def test_render_does_not_wait_for_a_running_neighbor_prefetch(self):
self.assertTrue(self.controller._is_rendering)
self.assertIsNone(self.controller._pending_render_task)

def test_a_drag_reads_metrics_back_but_an_override_does_not(self):
import numpy as np

from negpy.domain.models import WorkspaceConfig

emitted = []
self.controller.render_requested.connect(emitted.append)
self.controller.state.preview_raw = np.zeros((4, 4, 3), dtype=np.float32)

self.controller.request_render(readback_metrics=False)
self.controller._is_rendering = False
self.controller.request_render(readback_metrics=False, config_override=WorkspaceConfig())

drag, override = emitted
self.assertTrue(drag.interactive)
self.assertTrue(drag.readback_metrics)
self.assertFalse(override.readback_metrics)

def test_stale_prefetch_finish_starts_the_next_queued_prefetch(self):
from negpy.desktop.workers.render import PreviewLoadTask

Expand Down
9 changes: 0 additions & 9 deletions tests/test_interactive_decoupling.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,6 @@ def test_thumbnail_is_not_refreshed_from_the_crop_tools_uncropped_preview(self):
AppController._on_render_finished(stub, None, {"crop_preview_full": False, "source_hash": "h1"})
stub._update_thumbnail_from_state.assert_called_once()

def test_analysis_panel_skips_interactive_frames(self):
from negpy.desktop.session import AppState
from negpy.desktop.view.sidebar.right_panel import RightPanel

panel = MagicMock()
panel.controller.session.state = AppState(last_metrics={"interactive": True})
RightPanel._update_analysis(panel)
panel._update_histograms.assert_not_called()

def test_bounds_are_not_persisted_mid_gesture(self):
from negpy.desktop.controller import AppController

Expand Down
16 changes: 13 additions & 3 deletions tests/test_right_panel_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ def test_update_analysis_refreshes_histograms() -> None:
panel._update_histograms.assert_called_once_with(metrics)


def test_update_analysis_skips_mid_gesture_frames() -> None:
panel = _panel_stub({"interactive": True})
def test_update_analysis_tracks_mid_gesture_frames() -> None:
metrics = {"interactive": True, "histogram_density": [1.0]}
panel = _panel_stub(metrics)

RightPanel._update_analysis(panel)

panel._update_histograms.assert_not_called()
panel._update_histograms.assert_called_once_with(metrics)


def test_a_peek_refreshes_over_a_stale_interactive_print() -> None:
Expand Down Expand Up @@ -76,6 +77,15 @@ def test_switch_group_persists_the_index_and_updates_the_stack():
panel._group_buttons[0].setChecked.assert_called_once_with(False)


def test_analysis_shows_on_the_roll_and_frame_tabs_only():
panel = _group_panel_stub()

for index, key in enumerate(panel._group_keys):
panel.analysis_section.reset_mock()
RightPanel._switch_group(panel, index)
panel.analysis_section.setVisible.assert_called_once_with(key in ("roll", "frame"))


def test_switch_group_activates_the_chosen_scanner_only_on_the_scan_tab():
panel = _group_panel_stub(scan_index=5)

Expand Down
Loading