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/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 cd84441f1..ce1bb4f16 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) @@ -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() 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 44609a1d0..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: @@ -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)