Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/pyphenix/_widget.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import subprocess
import sys
import napari
from napari.utils import notifications
import numpy as np
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"<b>Error:</b> {e}"
)
notifications.show_error(
f"Error generating plate overview: {str(e)}"
)
import traceback
traceback.print_exc()
return

self.plate_overview_status_label.setText(
f"<b>Done:</b> 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
# ------------------------------------------------------------------
Expand Down
78 changes: 74 additions & 4 deletions tests/test_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading