From b365d1a6008cb70e634170f8eaf2290a6fbd8374 Mon Sep 17 00:00:00 2001 From: Max Ferrin Date: Tue, 19 May 2026 18:26:23 -0700 Subject: [PATCH] add napari widget button for plate overview Closes #10. Adds a "Generate plate overview" button to PhenixDataLoaderWidget that prompts for an output directory, calls generate_plate_overview() with defaults, and opens the result in the OS file viewer. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pyphenix/_widget.py | 89 +++++++++++++++++++++++++++++++++++++++++ tests/test_widget.py | 78 ++++++++++++++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 4 deletions(-) diff --git a/src/pyphenix/_widget.py b/src/pyphenix/_widget.py index 3f37eee..a4ea660 100644 --- a/src/pyphenix/_widget.py +++ b/src/pyphenix/_widget.py @@ -1,3 +1,5 @@ +import subprocess +import sys import napari from napari.utils import notifications import numpy as np @@ -13,6 +15,7 @@ from ._reader import OperaPhenixReader from ._colormaps import channel_color +from ._overview import generate_plate_overview def _normalise_well_str(well: str) -> str: """ @@ -786,6 +789,28 @@ def _build_ui(self): save_group.setContentLayout(save_layout) layout.addWidget(save_group) + # ── Plate overview ──────────────────────────────────────────── + overview_group = CollapsibleSection("Plate Overview") + overview_layout = QVBoxLayout() + + self.plate_overview_btn = QPushButton("Generate plate overview") + self.plate_overview_btn.setToolTip( + "Render a grid of every well as PNGs (one per channel combo) " + "into a directory of your choice.\n" + "Uses plate-wide contrast limits so wells are visually " + "comparable." + ) + self.plate_overview_btn.clicked.connect(self._generate_plate_overview) + self.plate_overview_btn.setEnabled(False) + overview_layout.addWidget(self.plate_overview_btn) + + self.plate_overview_status_label = QLabel("") + self.plate_overview_status_label.setWordWrap(True) + overview_layout.addWidget(self.plate_overview_status_label) + + overview_group.setContentLayout(overview_layout) + layout.addWidget(overview_group) + # Add stretch to push everything to the top layout.addStretch() @@ -1074,6 +1099,7 @@ def _set_controls_enabled(self, enabled: bool): self.channel_clear_all_btn.setEnabled(enabled) self.z_select_all_btn.setEnabled(enabled) self.z_clear_all_btn.setEnabled(enabled) + self.plate_overview_btn.setEnabled(enabled) if enabled and self.reader and self.reader.ffc_profiles: self.ffc_checkbox.setEnabled(True) @@ -1305,6 +1331,69 @@ def _save_data(self): import traceback traceback.print_exc() + # ------------------------------------------------------------------ + # Plate overview + # ------------------------------------------------------------------ + + def _generate_plate_overview(self): + """Render plate-wide overview PNGs into a user-chosen directory.""" + if self.reader is None: + notifications.show_warning("Please load an experiment first") + return + + exp_path = self.path_input.text() + + output_dir = QFileDialog.getExistingDirectory( + self, + "Select output directory for plate overview", + exp_path, + ) + if not output_dir: + return + + self.plate_overview_status_label.setText( + "Generating plate overview… (see terminal for progress)" + ) + notifications.show_info( + "Generating plate overview — this may take a while." + ) + + try: + generate_plate_overview(exp_path, output_dir) + except Exception as e: + self.plate_overview_status_label.setText( + f"Error: {e}" + ) + notifications.show_error( + f"Error generating plate overview: {str(e)}" + ) + import traceback + traceback.print_exc() + return + + self.plate_overview_status_label.setText( + f"Done: wrote PNGs to {output_dir}" + ) + notifications.show_info(f"Plate overview written to {output_dir}") + + self._open_in_file_viewer(output_dir) + + def _open_in_file_viewer(self, path: str): + """Open ``path`` in the host OS's native file viewer.""" + if sys.platform == "darwin": + cmd = ["open", path] + elif sys.platform == "win32": + cmd = ["explorer", path] + else: + cmd = ["xdg-open", path] + + try: + subprocess.run(cmd, check=False) + except Exception as e: + notifications.show_warning( + f"Could not open {path} in file viewer: {e}" + ) + # ------------------------------------------------------------------ # Build a human-readable label for the current selection # ------------------------------------------------------------------ diff --git a/tests/test_widget.py b/tests/test_widget.py index e5f527b..230f9ff 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -319,19 +319,89 @@ def test_add_layers_creates_image_layers(widget_with_viewer): def test_set_controls_enabled(widget_with_viewer): """Test _set_controls_enabled method.""" widget, _ = widget_with_viewer - + # Enable all controls widget._set_controls_enabled(True) - + assert widget.well_combo.isEnabled() assert widget.field_combo.isEnabled() assert widget.time_list.isEnabled() assert widget.channel_list.isEnabled() - + assert widget.plate_overview_btn.isEnabled() + # Disable all controls widget._set_controls_enabled(False) - + assert not widget.well_combo.isEnabled() assert not widget.field_combo.isEnabled() assert not widget.time_list.isEnabled() assert not widget.channel_list.isEnabled() + assert not widget.plate_overview_btn.isEnabled() + + +def test_plate_overview_button_disabled_initially(widget_with_viewer): + """Plate overview button starts disabled until an experiment is loaded.""" + widget, _ = widget_with_viewer + assert not widget.plate_overview_btn.isEnabled() + + +def test_plate_overview_warns_without_experiment(widget_with_viewer): + """Clicking with no reader loaded shows a warning, not a crash.""" + widget, _ = widget_with_viewer + assert widget.reader is None + + with patch('pyphenix._widget.notifications.show_warning') as mock_warn: + widget._generate_plate_overview() + mock_warn.assert_called_once() + + +def test_plate_overview_button_wiring(widget_with_viewer, tmp_path, + monkeypatch): + """Mock QFileDialog and the OS-open call, verify wiring to + generate_plate_overview().""" + widget, _ = widget_with_viewer + + # Pretend an experiment is loaded. + widget.reader = Mock() + widget.path_input.setText("/some/experiment") + + chosen_dir = str(tmp_path) + dialog_mock = Mock(return_value=chosen_dir) + monkeypatch.setattr( + 'pyphenix._widget.QFileDialog.getExistingDirectory', dialog_mock + ) + + with patch('pyphenix._widget.generate_plate_overview') as mock_gen, \ + patch('pyphenix._widget.subprocess.run') as mock_run: + widget._generate_plate_overview() + + # Dialog was opened defaulting to the experiment directory. + args, _kwargs = dialog_mock.call_args + assert "/some/experiment" in args + + # generate_plate_overview called with (experiment_path, output_dir). + mock_gen.assert_called_once_with("/some/experiment", chosen_dir) + + # OS file viewer was invoked with the chosen directory. + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert chosen_dir in cmd + + +def test_plate_overview_cancelled_dialog_does_nothing(widget_with_viewer, + monkeypatch): + """If the user cancels the directory dialog, nothing is generated.""" + widget, _ = widget_with_viewer + widget.reader = Mock() + widget.path_input.setText("/some/experiment") + + monkeypatch.setattr( + 'pyphenix._widget.QFileDialog.getExistingDirectory', + lambda *a, **kw: "" # user cancelled + ) + + with patch('pyphenix._widget.generate_plate_overview') as mock_gen, \ + patch('pyphenix._widget.subprocess.run') as mock_run: + widget._generate_plate_overview() + mock_gen.assert_not_called() + mock_run.assert_not_called()