From 41ac07f2711e645a21af6b30d8fcfc0b38553bc5 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 12 Oct 2026 15:26:27 +0200 Subject: [PATCH 1/2] feat(analysis): pin Analysis above the Roll tab too Analysis moves out of the Frame page into a splitter above the outer tab stack, shown on Roll and Frame and hidden elsewhere. Black/White Point and the other Roll cards can now be judged against the histogram without switching tabs. Refs #1370 --- docs/USER_GUIDE.md | 4 +- negpy/desktop/view/sidebar/right_panel.py | 75 ++++++++++------------- tests/test_right_panel_wiring.py | 9 +++ 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 54acca7d6..2f7bab367 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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**. @@ -291,7 +291,7 @@ A scene is a group of frames in one roll shot in the same light. It has its own ## 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 diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index cd84441f1..878979da0 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -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__() @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/tests/test_right_panel_wiring.py b/tests/test_right_panel_wiring.py index 44609a1d0..54b2a1ebd 100644 --- a/tests/test_right_panel_wiring.py +++ b/tests/test_right_panel_wiring.py @@ -76,6 +76,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) From 0e227ff3a3ba52d12d7723f82ccccd896a50a7a2 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 12 Oct 2026 15:27:09 +0200 Subject: [PATCH 2/2] feat(analysis): refresh Analysis live while a slider drags A drag render of the edit now reads metrics back from the interactive proxy, and the Analysis panel no longer skips interactive frames, so the histogram, waveform and stats track the slider instead of waiting for release. Config-override renders (flat peek, zone preview) and the splash still skip readback so they never replace the edit's histograms. Headless drag on DSC00448.ARW: 20/20 frames refresh the chart; worker frame time 1.7 -> 20 ms median (the metrics map waits on the GPU, about 11 ms of it), _update_analysis 2.3 ms per frame on the UI thread. Fixes #1370 --- negpy/desktop/controller.py | 8 +++++--- negpy/desktop/view/sidebar/right_panel.py | 3 --- tests/test_controller.py | 18 ++++++++++++++++++ tests/test_interactive_decoupling.py | 9 --------- tests/test_right_panel_wiring.py | 7 ++++--- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index b17a8e261..f48ba45ad 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -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 @@ -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, diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 878979da0..ce1bb4f16 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -826,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() diff --git a/tests/test_controller.py b/tests/test_controller.py index 96d689337..2cd564b21 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -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 diff --git a/tests/test_interactive_decoupling.py b/tests/test_interactive_decoupling.py index bdeb8e568..33a1a640f 100644 --- a/tests/test_interactive_decoupling.py +++ b/tests/test_interactive_decoupling.py @@ -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 diff --git a/tests/test_right_panel_wiring.py b/tests/test_right_panel_wiring.py index 54b2a1ebd..2b96a531c 100644 --- a/tests/test_right_panel_wiring.py +++ b/tests/test_right_panel_wiring.py @@ -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: