From 413c809a3a6f825663804a5b0fc70dcae8e597c2 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 12 Oct 2026 22:19:30 +0200 Subject: [PATCH 1/2] feat(analysis): remember Analysis collapse per Roll and Frame tab Analysis is pinned above both Roll and Frame, but shared one expanded state, so collapsing it on Roll also collapsed it on Frame. Each tab now keeps its own: Frame stays on section_expanded_analysis, Roll uses section_expanded_analysis_roll, and switching tabs restores it. make_section gains persist=False so a caller can own the expanded state without losing the help dialog's saved geometry. --- docs/USER_GUIDE.md | 2 +- negpy/desktop/view/sidebar/right_panel.py | 15 +++++++++++---- negpy/desktop/view/widgets/collapsible.py | 6 ++++-- tests/test_right_panel_wiring.py | 16 ++++++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2f7bab367..b9866942d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 Roll and Frame 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. Each tab remembers whether the readout is collapsed. Top to bottom: #### Photometric curve diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index ce1bb4f16..9aed82553 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -267,9 +267,9 @@ def _build_frame_page(self, wrap_scroll) -> QWidget: analysis_layout.addWidget(self.stats_widget, 0) repo = self.controller.session.repo - self.analysis_section = make_section( - repo, "Analysis", "analysis", analysis_content, "fa5s.chart-bar", THEME.sidebar_expanded_defaults["analysis"] - ) + # Roll and Frame each keep their own expanded state; _switch_group restores it. + self.analysis_section = make_section(repo, "Analysis", "analysis", analysis_content, "fa5s.chart-bar", persist=False) + self.analysis_section.expanded_changed.connect(lambda checked: repo.save_global_setting(self._analysis_expanded_setting(), checked)) self.clipping_btn = self.analysis_section.add_header_toggle("fa5s.adjust", "Clipping Overlay") self.clipping_btn.setChecked(self.controller.state.clipping_overlay) self.clipping_btn.toggled.connect(self.controller.toggle_clipping_overlay) @@ -504,6 +504,9 @@ def show_analysis_help(self) -> None: SectionHelpDialog("analysis", "Analysis", self, repo=self.controller.session.repo).exec() + def _analysis_expanded_setting(self) -> str: + return "section_expanded_analysis_roll" if self._group_keys[self._active_group] == "roll" else "section_expanded_analysis" + def _resize_splitter_for_analysis(self, expanded: bool) -> None: """Pin the collapsed Analysis header at the top: shrink pane 0 to the header and give the rest to the tabs; restore the prior size when re-expanded.""" @@ -677,7 +680,11 @@ 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")) + analysis_shown = self._group_keys[index] in ("roll", "frame") + self.analysis_section.setVisible(analysis_shown) + if analysis_shown: + saved = self.controller.session.repo.get_global_setting(self._analysis_expanded_setting()) + self.analysis_section.set_expanded(THEME.sidebar_expanded_defaults["analysis"] if saved is None else bool(saved)) for i, btn in enumerate(self._group_buttons): btn.setChecked(i == index) self.group_switcher.set_pinned(index) diff --git a/negpy/desktop/view/widgets/collapsible.py b/negpy/desktop/view/widgets/collapsible.py index 7d91bbf90..0a776ce83 100644 --- a/negpy/desktop/view/widgets/collapsible.py +++ b/negpy/desktop/view/widgets/collapsible.py @@ -336,15 +336,17 @@ def make_section( default_expanded: bool = False, background_widget: Optional[QWidget] = None, collapsible: bool = True, + *, + persist: bool = True, ) -> CollapsibleSection: """The one way a sidebar builds a section: persisted under section_expanded_{key}, and the ⓘ guide present exactly when docs/USER_GUIDE.md carries a `panel:{key}` marker. The help dialog is parented to the section, so it centres on the window the section is in. collapsible=False always expands and never reads or writes the persisted setting; repo=None - keeps the section but not its expanded state.""" + or persist=False keeps the section but not its expanded state.""" from negpy.desktop.view.widgets.section_help_dialog import SectionHelpDialog, has_guide - persist = collapsible and repo is not None + persist = persist and collapsible and repo is not None if collapsible: setting = f"section_expanded_{key}" persisted = repo.get_global_setting(setting) if persist else None diff --git a/tests/test_right_panel_wiring.py b/tests/test_right_panel_wiring.py index 2b96a531c..f90ab88af 100644 --- a/tests/test_right_panel_wiring.py +++ b/tests/test_right_panel_wiring.py @@ -86,6 +86,22 @@ def test_analysis_shows_on_the_roll_and_frame_tabs_only(): panel.analysis_section.setVisible.assert_called_once_with(key in ("roll", "frame")) +def test_roll_and_frame_each_restore_their_own_analysis_expanded_state(): + panel = _group_panel_stub() + panel._analysis_expanded_setting = lambda: RightPanel._analysis_expanded_setting(panel) + saved = {"section_expanded_analysis_roll": False, "section_expanded_analysis": True} + panel.controller.session.repo.get_global_setting.side_effect = saved.get + + RightPanel._switch_group(panel, 0) + panel.analysis_section.set_expanded.assert_called_with(False) + RightPanel._switch_group(panel, 1) + panel.analysis_section.set_expanded.assert_called_with(True) + + panel.analysis_section.reset_mock() + RightPanel._switch_group(panel, 2) + panel.analysis_section.set_expanded.assert_not_called() + + def test_switch_group_activates_the_chosen_scanner_only_on_the_scan_tab(): panel = _group_panel_stub(scan_index=5) From b6d39a28ee520a3e8de8b448ba59d961fae30e6c Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 12 Oct 2026 22:26:22 +0200 Subject: [PATCH 2/2] fix(analysis): keep the Analysis height across restarts The splitter split was set before the window was shown, when the splitter has no real height, so Qt rescaled it: a saved [500, 409] came back as [275, 634]. Starting collapsed and then expanding at startup locked in the rescaled height. The split is now applied once, after the first show, and before that no resize happens. The expanded height comes from the saved setting and from drags while Analysis is expanded. A drag of the collapsed header no longer overwrites it. --- negpy/desktop/view/sidebar/right_panel.py | 34 +++++++++++++++-------- tests/test_right_panel_wiring.py | 27 ++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 9aed82553..5d8b53c86 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -176,17 +176,17 @@ def wrap_scroll(widget: QWidget) -> QScrollArea: 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]) + sizes = [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())) + sizes = default_analysis_split(QApplication.primaryScreen()) + self.splitter.setSizes(sizes) + self.splitter.splitterMoved.connect(self._on_analysis_splitter_moved) # 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_expanded_size = sizes[0] + self._analysis_split_shown = False 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.splitter, 1) @@ -507,17 +507,29 @@ def show_analysis_help(self) -> None: def _analysis_expanded_setting(self) -> str: return "section_expanded_analysis_roll" if self._group_keys[self._active_group] == "roll" else "section_expanded_analysis" + def showEvent(self, event) -> None: + super().showEvent(event) + # Before the first show the splitter has no real height, so a split set then is rescaled. + if not self._analysis_split_shown: + self._analysis_split_shown = True + QTimer.singleShot(0, lambda: self._resize_splitter_for_analysis(self.analysis_section.toggle_button.isChecked())) + + def _on_analysis_splitter_moved(self, *_) -> None: + if not self.analysis_section.toggle_button.isChecked(): + return + sizes = self.splitter.sizes() + self._analysis_expanded_size = sizes[0] + self.controller.session.repo.save_global_setting("analysis_splitter_sizes", sizes) + def _resize_splitter_for_analysis(self, expanded: bool) -> None: """Pin the collapsed Analysis header at the top: shrink pane 0 to the header and - give the rest to the tabs; restore the prior size when re-expanded.""" - sizes = self.splitter.sizes() - total = sum(sizes) - if total <= 0: + give the rest to the tabs; restore the dragged size when re-expanded.""" + total = sum(self.splitter.sizes()) + if total <= 0 or not self._analysis_split_shown: return if expanded: top = min(max(self._analysis_expanded_size, 120), max(120, total - 120)) else: - self._analysis_expanded_size = sizes[0] top = max(1, self.analysis_section.sizeHint().height()) self.splitter.setSizes([top, max(0, total - top)]) diff --git a/tests/test_right_panel_wiring.py b/tests/test_right_panel_wiring.py index f90ab88af..85a6a2dd8 100644 --- a/tests/test_right_panel_wiring.py +++ b/tests/test_right_panel_wiring.py @@ -102,6 +102,33 @@ def test_roll_and_frame_each_restore_their_own_analysis_expanded_state(): panel.analysis_section.set_expanded.assert_not_called() +def test_only_an_expanded_analysis_drag_sets_the_saved_height(): + panel = MagicMock() + panel._analysis_expanded_size = 300 + panel.splitter.sizes.return_value = [40, 860] + panel.analysis_section.toggle_button.isChecked.return_value = False + + RightPanel._on_analysis_splitter_moved(panel) + assert panel._analysis_expanded_size == 300 + panel.controller.session.repo.save_global_setting.assert_not_called() + + panel.splitter.sizes.return_value = [500, 400] + panel.analysis_section.toggle_button.isChecked.return_value = True + RightPanel._on_analysis_splitter_moved(panel) + assert panel._analysis_expanded_size == 500 + panel.controller.session.repo.save_global_setting.assert_called_once_with("analysis_splitter_sizes", [500, 400]) + + +def test_analysis_split_waits_for_the_first_show(): + panel = MagicMock() + panel.splitter.sizes.return_value = [189, 287] + panel._analysis_split_shown = False + + RightPanel._resize_splitter_for_analysis(panel, True) + + panel.splitter.setSizes.assert_not_called() + + def test_switch_group_activates_the_chosen_scanner_only_on_the_scan_tab(): panel = _group_panel_stub(scan_index=5)