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()