diff --git a/probeflow/gui/dialogs/image_viewer_build_mixin.py b/probeflow/gui/dialogs/image_viewer_build_mixin.py index 2252519..5d0d654 100644 --- a/probeflow/gui/dialogs/image_viewer_build_mixin.py +++ b/probeflow/gui/dialogs/image_viewer_build_mixin.py @@ -312,18 +312,20 @@ def _sidebar_tab(key: str, label: str, tip: str = "") -> tuple[QWidget, QVBoxLay "processing", "Process", "Line corrections, background subtraction, filters and FFT tools.", ) - _roi_tab, roi_lay = _sidebar_tab( - "roi", "ROI", - "Create, edit and combine regions of interest.", - ) - _masks_tab, masks_lay = _sidebar_tab( - "masks", "Masks", - "Active mask layer: edge-detection output, cleanup, and conversion to ROIs.", - ) _measurements_tab, measurements_lay = _sidebar_tab( "measurements", "Measure", "Distances, angles, ROI statistics, features and results.", ) + # ROI and Masks share one tab; each is a collapsible section (built when + # the panels are created, below). + _roimask_tab, roimask_lay = _sidebar_tab( + "roi", "ROI/Mask", + "Regions of interest and the active mask layer (edge-detection " + "output, cleanup, conversion to ROIs).", + ) + # Legacy alias so any "masks" navigation lands on the merged tab; no extra + # rail entry (the rail is driven by _sidebar_tab_meta only). + self._sidebar_tab_indices["masks"] = self._sidebar_tab_indices["roi"] _export_tab, export_lay = _sidebar_tab( "export", "Export", "Save images (PNG/PDF/SXM/GWY), provenance and hand-off to tools.", @@ -787,9 +789,8 @@ def _summary_row(row: int, name: str, attr: str, *, elide: bool = False) -> QLab roi_hint_lbl.setFont(ui_font(8)) roi_hint_lbl.setWordWrap(True) roi_hint_lbl.setStyleSheet("color: palette(mid);") - roi_lay.addWidget(roi_hint_lbl) - # The ROI manager panel itself is added to ``roi_lay`` after it is - # constructed below (it needs the ROI-set getter and callbacks). + # The hint and the ROI manager panel are added to the ROI collapsible + # section of the "ROI/Mask" tab after the panel is constructed below. # The Measure tab is driven entirely by the ImageMeasurementsPanel's own # tool menu (added to ``measurements_lay`` after it is constructed below); @@ -831,8 +832,8 @@ def _summary_row(row: int, name: str, attr: str, *, elide: bool = False) -> QLab rail_lay.addWidget(rail_sep) _rail_abbrev = { - "View": "View", "Process": "Proc", "ROI": "ROI", - "Measure": "Meas", "Export": "Exp", + "View": "View", "Process": "Proc", "Measure": "Meas", + "ROI/Mask": "R/M", "Export": "Exp", } for _key, _label, _tip in self._sidebar_tab_meta: rail_btn = QToolButton() @@ -956,18 +957,30 @@ def _summary_row(row: int, name: str, attr: str, *, elide: bool = False) -> QLab ) self._mask_panel.setObjectName("imageViewerMaskManagerPanel") + # ROI and Masks share the "ROI/Mask" tab as two collapsible sections + # (same pattern as the View tab's "Spectroscopy overlay"). ROI is open by + # default; Masks starts collapsed. + _roi_btn, _roi_body, roi_section_lay = _collapsible_section( + roimask_lay, "Regions of interest", expanded=True + ) + roi_section_lay.addWidget(roi_hint_lbl) + roi_section_lay.addWidget(self._roi_panel, 1) + + self._mask_section_btn, _mask_body, mask_section_lay = _collapsible_section( + roimask_lay, "Masks", expanded=False + ) mask_hint = QLabel( "Masks come from Advanced Edge Detection (Process tab). The active " "mask (●) restricts statistics and can become ROI(s)." ) mask_hint.setWordWrap(True) mask_hint.setFont(ui_font(8)) - masks_lay.addWidget(mask_hint) - masks_lay.addWidget(self._mask_panel, 1) + mask_section_lay.addWidget(mask_hint) + mask_section_lay.addWidget(self._mask_panel, 1) + roimask_lay.addStretch(1) - # ROI manager and measurements now live in their sidebar tabs (built - # above) rather than in separate floating docks. - roi_lay.addWidget(self._roi_panel, 1) + # Measurements now lives in its sidebar tab (built above) rather than in a + # separate floating dock. measurements_lay.addWidget(self._measurement_panel, 1) # Manager for floating, dismissible tool panels over the canvas. diff --git a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py index dc81c36..e0398d1 100644 --- a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py +++ b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py @@ -651,6 +651,12 @@ def _show_sidebar_tab(self, key: str) -> None: idx = self._sidebar_tab_indices.get(key) if idx is not None: self._sidebar_tabs.setCurrentIndex(idx) + # ROI and Masks share one tab; a request to show "masks" expands that + # collapsible section so the mask controls are revealed. + if key == "masks": + btn = getattr(self, "_mask_section_btn", None) + if btn is not None: + btn.setChecked(True) def _show_sidebar_tool(self, title: str, widget, on_close=None) -> None: """Host an interactive tool's controls in the sidebar column (page 1). diff --git a/probeflow/gui/viewer/shortcuts.py b/probeflow/gui/viewer/shortcuts.py index 6df56f3..c074a93 100644 --- a/probeflow/gui/viewer/shortcuts.py +++ b/probeflow/gui/viewer/shortcuts.py @@ -25,8 +25,8 @@ class ViewerCommand: ), ViewerCommand("panel.view", "Histogram / Contrast", "View", ("Ctrl+1",), "Show the View sidebar tab.", aliases=("display", "contrast", "histogram")), ViewerCommand("panel.process", "Processing panel", "View", ("Ctrl+2",), "Show the Process sidebar tab.", aliases=("process", "processing")), - ViewerCommand("panel.roi", "ROI panel", "View", ("Ctrl+3",), "Show the ROI sidebar tab.", aliases=("roi", "selection")), - ViewerCommand("panel.measure", "Measurements panel", "View", ("Ctrl+4",), "Show the Measure sidebar tab.", aliases=("measure", "measurement")), + ViewerCommand("panel.measure", "Measurements panel", "View", ("Ctrl+3",), "Show the Measure sidebar tab.", aliases=("measure", "measurement")), + ViewerCommand("panel.roi", "ROI / Mask panel", "View", ("Ctrl+4",), "Show the ROI/Mask sidebar tab.", aliases=("roi", "mask", "selection")), ViewerCommand("panel.export", "Export panel", "View", ("Ctrl+5",), "Show the Export sidebar tab.", aliases=("save", "write")), ViewerCommand("view.fit", "Fit image to window", "View", ("Ctrl+0",), "Fit the image to the visible canvas.", aliases=("zoom", "fit")), ViewerCommand("view.one_to_one", "View at 1:1", "View", ("Ctrl+Shift+0",), "View the image at native raster size.", aliases=("native", "actual size")), diff --git a/tests/test_gui_processing_panel.py b/tests/test_gui_processing_panel.py index 63f7f29..8f446ae 100644 --- a/tests/test_gui_processing_panel.py +++ b/tests/test_gui_processing_panel.py @@ -188,6 +188,31 @@ def test_viewer_full_panel_round_trips_standard_processing_state(qapp): ) +def test_sidebar_merges_roi_mask_and_orders_tabs(qapp, monkeypatch): + from probeflow.gui import ImageViewerDialog, SxmFile, THEMES + + monkeypatch.setattr(ImageViewerDialog, "_load_current", lambda self: None) + entry = SxmFile(path=Path("/tmp/example.sxm"), stem="example", Nx=8, Ny=8) + dlg = ImageViewerDialog(entry, [entry], "gray", THEMES["dark"]) + try: + tabs = dlg._sidebar_tabs + assert [tabs.tabText(i) for i in range(tabs.count())] == [ + "View", "Process", "Measure", "ROI/Mask", "Export", + ] + # The old "masks" key aliases onto the merged tab (no separate tab). + assert dlg._sidebar_tab_indices["masks"] == dlg._sidebar_tab_indices["roi"] + # ROI section open, Masks section collapsed by default. + assert dlg._mask_section_btn.isChecked() is False + # Navigating to "masks" selects the merged tab and reveals the section. + dlg._show_sidebar_tab("masks") + assert tabs.tabText(tabs.currentIndex()) == "ROI/Mask" + assert dlg._mask_section_btn.isChecked() is True + finally: + dlg.close() + dlg.deleteLater() + qapp.processEvents() + + def test_format_gaussian_readout_pure(): from probeflow.gui.processing import format_gaussian_readout