From afc119330c382e09c37bdb3ed1a13ebcde4a9f85 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 29 Dec 2025 01:10:21 -0800 Subject: [PATCH 1/7] feat: Add Views tab to Configuration dialog for downsampled view settings Add new Views tab to PreferencesDialog with settings for: - Generate Downsampled Well Images - Display Plate View - Well Resolutions (comma-separated) - Plate Resolution - Z-Projection Mode (mip/middle) - Mosaic Target Pixel Size Settings are saved to config file and applied to _def module at runtime. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- software/control/widgets.py | 89 ++++++++++++++++++- .../tests/control/test_preferences_dialog.py | 71 +++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index d86a736da..ad5fcf7ce 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -313,6 +313,7 @@ def _init_ui(self): self._create_general_tab() self._create_acquisition_tab() self._create_camera_tab() + self._create_views_tab() self._create_advanced_tab() # Buttons @@ -648,6 +649,63 @@ def _create_advanced_tab(self): tab_layout.addWidget(scroll) self.tab_widget.addTab(tab, "Advanced") + def _create_views_tab(self): + tab = QWidget() + layout = QFormLayout(tab) + layout.setSpacing(10) + + # Description label + desc_label = QLabel("Settings for downsampled well/plate views during acquisition.") + desc_label.setStyleSheet("color: #666; font-style: italic;") + desc_label.setWordWrap(True) + layout.addRow(desc_label) + + # Generate Downsampled Well Images + self.generate_downsampled_checkbox = QCheckBox() + self.generate_downsampled_checkbox.setChecked( + self._get_config_bool("VIEWS", "generate_downsampled_well_images", False) + ) + layout.addRow("Generate Downsampled Well Images:", self.generate_downsampled_checkbox) + + # Display Plate View + self.display_plate_view_checkbox = QCheckBox() + self.display_plate_view_checkbox.setChecked(self._get_config_bool("VIEWS", "display_plate_view", False)) + layout.addRow("Display Plate View:", self.display_plate_view_checkbox) + + # Well Resolutions (comma-separated) + self.well_resolutions_edit = QLineEdit() + default_resolutions = self._get_config_value("VIEWS", "downsampled_well_resolutions_um", "5.0, 10.0, 20.0") + self.well_resolutions_edit.setText(default_resolutions) + self.well_resolutions_edit.setToolTip("Comma-separated list of resolution values in micrometers") + layout.addRow("Well Resolutions (μm):", self.well_resolutions_edit) + + # Plate Resolution + self.plate_resolution_spinbox = QDoubleSpinBox() + self.plate_resolution_spinbox.setRange(1.0, 100.0) + self.plate_resolution_spinbox.setSingleStep(1.0) + self.plate_resolution_spinbox.setValue(self._get_config_float("VIEWS", "downsampled_plate_resolution_um", 10.0)) + self.plate_resolution_spinbox.setSuffix(" μm") + layout.addRow("Plate Resolution:", self.plate_resolution_spinbox) + + # Z-Projection Mode + self.z_projection_combo = QComboBox() + self.z_projection_combo.addItems(["mip", "middle"]) + current_projection = self._get_config_value("VIEWS", "downsampled_z_projection", "mip") + self.z_projection_combo.setCurrentText(current_projection) + layout.addRow("Z-Projection Mode:", self.z_projection_combo) + + # Mosaic Target Pixel Size + self.mosaic_pixel_size_spinbox = QDoubleSpinBox() + self.mosaic_pixel_size_spinbox.setRange(0.5, 20.0) + self.mosaic_pixel_size_spinbox.setSingleStep(0.5) + self.mosaic_pixel_size_spinbox.setValue( + self._get_config_float("VIEWS", "mosaic_view_target_pixel_size_um", 2.0) + ) + self.mosaic_pixel_size_spinbox.setSuffix(" μm") + layout.addRow("Mosaic Target Pixel Size:", self.mosaic_pixel_size_spinbox) + + self.tab_widget.addTab(tab, "Views") + def _get_config_value(self, section, option, default=""): try: return self.config.get(section, option) @@ -692,7 +750,7 @@ def _ensure_section(self, section): def _apply_settings(self): # Ensure all required sections exist - for section in ["GENERAL", "CAMERA_CONFIG", "AF", "SOFTWARE_POS_LIMIT", "TRACKING"]: + for section in ["GENERAL", "CAMERA_CONFIG", "AF", "SOFTWARE_POS_LIMIT", "TRACKING", "VIEWS"]: self._ensure_section(section) # General settings @@ -757,6 +815,22 @@ def _apply_settings(self): self.config.set("TRACKING", "default_tracker", self.default_tracker_combo.currentText()) self.config.set("TRACKING", "search_area_ratio", str(self.search_area_ratio.value())) + # Views settings + self.config.set( + "VIEWS", + "generate_downsampled_well_images", + "true" if self.generate_downsampled_checkbox.isChecked() else "false", + ) + self.config.set( + "VIEWS", + "display_plate_view", + "true" if self.display_plate_view_checkbox.isChecked() else "false", + ) + self.config.set("VIEWS", "downsampled_well_resolutions_um", self.well_resolutions_edit.text()) + self.config.set("VIEWS", "downsampled_plate_resolution_um", str(self.plate_resolution_spinbox.value())) + self.config.set("VIEWS", "downsampled_z_projection", self.z_projection_combo.currentText()) + self.config.set("VIEWS", "mosaic_view_target_pixel_size_um", str(self.mosaic_pixel_size_spinbox.value())) + # Save to file try: with open(self.config_filepath, "w") as f: @@ -830,6 +904,19 @@ def _apply_live_settings(self): _def.Tracking.DEFAULT_TRACKER = self.default_tracker_combo.currentText() _def.Tracking.SEARCH_AREA_RATIO = self.search_area_ratio.value() + # Views settings + _def.GENERATE_DOWNSAMPLED_WELL_IMAGES = self.generate_downsampled_checkbox.isChecked() + _def.DISPLAY_PLATE_VIEW = self.display_plate_view_checkbox.isChecked() + # Parse comma-separated resolutions + resolutions_str = self.well_resolutions_edit.text() + try: + _def.DOWNSAMPLED_WELL_RESOLUTIONS_UM = [float(x.strip()) for x in resolutions_str.split(",") if x.strip()] + except ValueError: + self._log.warning(f"Invalid well resolutions format: {resolutions_str}") + _def.DOWNSAMPLED_PLATE_RESOLUTION_UM = self.plate_resolution_spinbox.value() + _def.DOWNSAMPLED_Z_PROJECTION = _def.ZProjectionMode.convert_to_enum(self.z_projection_combo.currentText()) + _def.MOSAIC_VIEW_TARGET_PIXEL_SIZE_UM = self.mosaic_pixel_size_spinbox.value() + def _get_changes(self): """Get list of settings that have changed from current config. Returns list of (name, old, new, requires_restart) tuples.""" diff --git a/software/tests/control/test_preferences_dialog.py b/software/tests/control/test_preferences_dialog.py index c3a8346a1..cbe0420cc 100644 --- a/software/tests/control/test_preferences_dialog.py +++ b/software/tests/control/test_preferences_dialog.py @@ -39,6 +39,14 @@ def sample_config(): config.set("TRACKING", "default_tracker", "csrt") config.set("TRACKING", "search_area_ratio", "10") + config.add_section("VIEWS") + config.set("VIEWS", "generate_downsampled_well_images", "false") + config.set("VIEWS", "display_plate_view", "true") + config.set("VIEWS", "downsampled_well_resolutions_um", "5.0, 10.0, 20.0") + config.set("VIEWS", "downsampled_plate_resolution_um", "10.0") + config.set("VIEWS", "downsampled_z_projection", "mip") + config.set("VIEWS", "mosaic_view_target_pixel_size_um", "2.0") + return config @@ -351,3 +359,66 @@ def test_max_velocity_initialized(self, preferences_dialog): def test_af_threshold_initialized(self, preferences_dialog): assert preferences_dialog.af_stop_threshold.value() == 0.85 + + +class TestViewsTab: + """Test Views tab functionality.""" + + def test_views_tab_exists(self, preferences_dialog): + tab_names = [preferences_dialog.tab_widget.tabText(i) for i in range(preferences_dialog.tab_widget.count())] + assert "Views" in tab_names + + def test_generate_downsampled_checkbox_initialized(self, preferences_dialog): + assert preferences_dialog.generate_downsampled_checkbox.isChecked() is False + + def test_display_plate_view_checkbox_initialized(self, preferences_dialog): + assert preferences_dialog.display_plate_view_checkbox.isChecked() is True + + def test_well_resolutions_initialized(self, preferences_dialog): + assert preferences_dialog.well_resolutions_edit.text() == "5.0, 10.0, 20.0" + + def test_plate_resolution_initialized(self, preferences_dialog): + assert preferences_dialog.plate_resolution_spinbox.value() == 10.0 + + def test_z_projection_initialized(self, preferences_dialog): + assert preferences_dialog.z_projection_combo.currentText() == "mip" + + def test_mosaic_pixel_size_initialized(self, preferences_dialog): + assert preferences_dialog.mosaic_pixel_size_spinbox.value() == 2.0 + + def test_views_settings_saved_to_file(self, preferences_dialog, temp_config_file): + preferences_dialog.generate_downsampled_checkbox.setChecked(True) + preferences_dialog.display_plate_view_checkbox.setChecked(False) + preferences_dialog.well_resolutions_edit.setText("2.5, 5.0") + preferences_dialog.plate_resolution_spinbox.setValue(15.0) + preferences_dialog.z_projection_combo.setCurrentText("middle") + preferences_dialog.mosaic_pixel_size_spinbox.setValue(3.0) + + preferences_dialog._apply_settings() + + from configparser import ConfigParser + + saved_config = ConfigParser() + saved_config.read(temp_config_file) + + assert saved_config.get("VIEWS", "generate_downsampled_well_images") == "true" + assert saved_config.get("VIEWS", "display_plate_view") == "false" + assert saved_config.get("VIEWS", "downsampled_well_resolutions_um") == "2.5, 5.0" + assert saved_config.get("VIEWS", "downsampled_plate_resolution_um") == "15.0" + assert saved_config.get("VIEWS", "downsampled_z_projection") == "middle" + assert saved_config.get("VIEWS", "mosaic_view_target_pixel_size_um") == "3.0" + + def test_views_settings_applied_to_def(self, preferences_dialog): + import control._def as _def + + preferences_dialog.generate_downsampled_checkbox.setChecked(True) + preferences_dialog.display_plate_view_checkbox.setChecked(True) + preferences_dialog.plate_resolution_spinbox.setValue(20.0) + preferences_dialog.mosaic_pixel_size_spinbox.setValue(4.0) + + preferences_dialog._apply_live_settings() + + assert _def.GENERATE_DOWNSAMPLED_WELL_IMAGES is True + assert _def.DISPLAY_PLATE_VIEW is True + assert _def.DOWNSAMPLED_PLATE_RESOLUTION_UM == 20.0 + assert _def.MOSAIC_VIEW_TARGET_PIXEL_SIZE_UM == 4.0 From 1c7f944a16dee765dccc8ba2f18e369163101581 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 5 Jan 2026 16:57:33 -0800 Subject: [PATCH 2/7] fix: Add input validation and change detection for Views settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add QRegularExpressionValidator to well_resolutions_edit field to validate comma-separated positive numbers - Add Views settings to _get_changes() method so changes are detected and properly saved/applied (was missing, causing settings to not take effect) - Add tests for validator and Views change detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- software/control/widgets.py | 41 ++++++++++- .../tests/control/test_preferences_dialog.py | 69 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index ad5fcf7ce..3cc006f11 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -676,7 +676,15 @@ def _create_views_tab(self): self.well_resolutions_edit = QLineEdit() default_resolutions = self._get_config_value("VIEWS", "downsampled_well_resolutions_um", "5.0, 10.0, 20.0") self.well_resolutions_edit.setText(default_resolutions) - self.well_resolutions_edit.setToolTip("Comma-separated list of resolution values in micrometers") + self.well_resolutions_edit.setToolTip( + "Comma-separated list of resolution values in micrometers (e.g., 5.0, 10.0, 20.0)" + ) + # Validator for comma-separated positive numbers + from qtpy.QtCore import QRegularExpression + from qtpy.QtGui import QRegularExpressionValidator + + well_res_pattern = QRegularExpression(r"^\s*\d+(\.\d+)?\s*(,\s*\d+(\.\d+)?\s*)*$") + self.well_resolutions_edit.setValidator(QRegularExpressionValidator(well_res_pattern)) layout.addRow("Well Resolutions (μm):", self.well_resolutions_edit) # Plate Resolution @@ -1111,6 +1119,37 @@ def _get_changes(self): if old_val != new_val: changes.append(("Search Area Ratio", str(old_val), str(new_val), False)) + # Views settings (live update) + old_val = self._get_config_bool("VIEWS", "generate_downsampled_well_images", False) + new_val = self.generate_downsampled_checkbox.isChecked() + if old_val != new_val: + changes.append(("Generate Downsampled Well Images", str(old_val), str(new_val), False)) + + old_val = self._get_config_bool("VIEWS", "display_plate_view", False) + new_val = self.display_plate_view_checkbox.isChecked() + if old_val != new_val: + changes.append(("Display Plate View", str(old_val), str(new_val), False)) + + old_val = self._get_config_value("VIEWS", "downsampled_well_resolutions_um", "5.0, 10.0, 20.0") + new_val = self.well_resolutions_edit.text() + if old_val != new_val: + changes.append(("Well Resolutions", old_val, new_val, False)) + + old_val = self._get_config_float("VIEWS", "downsampled_plate_resolution_um", 10.0) + new_val = self.plate_resolution_spinbox.value() + if not self._floats_equal(old_val, new_val): + changes.append(("Plate Resolution", f"{old_val} μm", f"{new_val} μm", False)) + + old_val = self._get_config_value("VIEWS", "downsampled_z_projection", "mip") + new_val = self.z_projection_combo.currentText() + if old_val != new_val: + changes.append(("Z-Projection Mode", old_val, new_val, False)) + + old_val = self._get_config_float("VIEWS", "mosaic_view_target_pixel_size_um", 2.0) + new_val = self.mosaic_pixel_size_spinbox.value() + if not self._floats_equal(old_val, new_val): + changes.append(("Mosaic Target Pixel Size", f"{old_val} μm", f"{new_val} μm", False)) + return changes def _save_and_close(self): diff --git a/software/tests/control/test_preferences_dialog.py b/software/tests/control/test_preferences_dialog.py index cbe0420cc..55b909192 100644 --- a/software/tests/control/test_preferences_dialog.py +++ b/software/tests/control/test_preferences_dialog.py @@ -215,6 +215,45 @@ def test_live_setting_no_restart(self, preferences_dialog): file_change = next(c for c in changes if c[0] == "File Saving Format") assert file_change[3] is False # does not require restart + def test_detect_views_generate_downsampled_change(self, preferences_dialog): + current = preferences_dialog.generate_downsampled_checkbox.isChecked() + preferences_dialog.generate_downsampled_checkbox.setChecked(not current) + changes = preferences_dialog._get_changes() + assert any(c[0] == "Generate Downsampled Well Images" for c in changes) + + def test_detect_views_display_plate_view_change(self, preferences_dialog): + # Config has display_plate_view=true, checkbox should be checked + preferences_dialog.display_plate_view_checkbox.setChecked(False) + changes = preferences_dialog._get_changes() + assert any(c[0] == "Display Plate View" for c in changes) + + def test_detect_views_well_resolutions_change(self, preferences_dialog): + preferences_dialog.well_resolutions_edit.setText("1.0, 2.0") + changes = preferences_dialog._get_changes() + assert any(c[0] == "Well Resolutions" for c in changes) + + def test_detect_views_plate_resolution_change(self, preferences_dialog): + preferences_dialog.plate_resolution_spinbox.setValue(25.0) + changes = preferences_dialog._get_changes() + assert any(c[0] == "Plate Resolution" for c in changes) + + def test_detect_views_z_projection_change(self, preferences_dialog): + preferences_dialog.z_projection_combo.setCurrentText("middle") + changes = preferences_dialog._get_changes() + assert any(c[0] == "Z-Projection Mode" for c in changes) + + def test_detect_views_mosaic_pixel_size_change(self, preferences_dialog): + preferences_dialog.mosaic_pixel_size_spinbox.setValue(5.0) + changes = preferences_dialog._get_changes() + assert any(c[0] == "Mosaic Target Pixel Size" for c in changes) + + def test_views_settings_are_live_update(self, preferences_dialog): + """Verify Views settings don't require restart.""" + preferences_dialog.generate_downsampled_checkbox.setChecked(True) + changes = preferences_dialog._get_changes() + views_change = next(c for c in changes if c[0] == "Generate Downsampled Well Images") + assert views_change[3] is False # does not require restart + class TestApplySettings: """Test settings application.""" @@ -413,12 +452,42 @@ def test_views_settings_applied_to_def(self, preferences_dialog): preferences_dialog.generate_downsampled_checkbox.setChecked(True) preferences_dialog.display_plate_view_checkbox.setChecked(True) + preferences_dialog.well_resolutions_edit.setText("2.5, 5.0, 15.0") preferences_dialog.plate_resolution_spinbox.setValue(20.0) + preferences_dialog.z_projection_combo.setCurrentText("middle") preferences_dialog.mosaic_pixel_size_spinbox.setValue(4.0) preferences_dialog._apply_live_settings() assert _def.GENERATE_DOWNSAMPLED_WELL_IMAGES is True assert _def.DISPLAY_PLATE_VIEW is True + assert _def.DOWNSAMPLED_WELL_RESOLUTIONS_UM == [2.5, 5.0, 15.0] assert _def.DOWNSAMPLED_PLATE_RESOLUTION_UM == 20.0 + assert _def.DOWNSAMPLED_Z_PROJECTION == _def.ZProjectionMode.MIDDLE assert _def.MOSAIC_VIEW_TARGET_PIXEL_SIZE_UM == 4.0 + + def test_well_resolutions_validator_accepts_valid_input(self, preferences_dialog): + """Test that validator accepts valid comma-separated numeric values.""" + validator = preferences_dialog.well_resolutions_edit.validator() + assert validator is not None + + # Valid inputs + from qtpy.QtGui import QValidator + + assert validator.validate("5.0, 10.0, 20.0", 0)[0] == QValidator.Acceptable + assert validator.validate("5, 10, 20", 0)[0] == QValidator.Acceptable + assert validator.validate("5.0,10.0,20.0", 0)[0] == QValidator.Acceptable + assert validator.validate(" 5.0 , 10.0 ", 0)[0] == QValidator.Acceptable + assert validator.validate("100", 0)[0] == QValidator.Acceptable + + def test_well_resolutions_validator_rejects_invalid_input(self, preferences_dialog): + """Test that validator rejects invalid inputs.""" + validator = preferences_dialog.well_resolutions_edit.validator() + + from qtpy.QtGui import QValidator + + # Invalid inputs should not be Acceptable + assert validator.validate("abc", 0)[0] != QValidator.Acceptable + assert validator.validate("5.0, abc, 10.0", 0)[0] != QValidator.Acceptable + assert validator.validate("-5.0, 10.0", 0)[0] != QValidator.Acceptable + assert validator.validate("", 0)[0] != QValidator.Acceptable From a66ce037818aec8798005f7dd82c2d8ccad5ac68 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 5 Jan 2026 17:16:25 -0800 Subject: [PATCH 3/7] feat: Improve CollapsibleGroupBox UI with arrow indicators and borders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace checkbox toggle with clickable arrow header (▼/▶) - Add visual border container around section content for clarity - Split Views tab into Plate View and Mosaic View collapsible sections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- software/control/widgets.py | 130 ++++++++++++++++++++++++++++-------- 1 file changed, 102 insertions(+), 28 deletions(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index 3cc006f11..3b1357bf5 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -131,22 +131,87 @@ def closeForReal(self, event): super().closeEvent(event) -class CollapsibleGroupBox(QGroupBox): - def __init__(self, title): - super(CollapsibleGroupBox, self).__init__(title) - self.setCheckable(True) - self.setChecked(True) - self.higher_layout = QVBoxLayout() - self.content = QVBoxLayout() - # self.content.setAlignment(Qt.AlignTop) - self.content_widget = QWidget() - self.content_widget.setLayout(self.content) - self.higher_layout.addWidget(self.content_widget) - self.setLayout(self.higher_layout) - self.toggled.connect(self.toggle_content) - - def toggle_content(self, state): - self.content_widget.setVisible(state) +class CollapsibleGroupBox(QWidget): + """A collapsible group box with arrow indicator for expand/collapse.""" + + def __init__(self, title, collapsed=False): + super().__init__() + self._collapsed = collapsed + self._title = title + + # Main layout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 8) + main_layout.setSpacing(0) + + # Header button with arrow + self._header = QPushButton() + self._header.setStyleSheet( + """ + QPushButton { + text-align: left; + padding: 8px; + font-weight: bold; + background-color: palette(button); + border: 1px solid palette(mid); + border-bottom: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + QPushButton:hover { + background-color: palette(light); + } + """ + ) + self._header.clicked.connect(self._toggle) + main_layout.addWidget(self._header) + + # Content widget with border to show grouping + self.content_widget = QFrame() + self.content_widget.setObjectName("collapsibleContent") + self.content_widget.setFrameShape(QFrame.StyledPanel) + self.content_widget.setStyleSheet( + """ + QFrame#collapsibleContent { + border: 1px solid palette(mid); + border-top: none; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + background-color: palette(base); + } + QFrame#collapsibleContent QLabel { + border: none; + background: transparent; + } + """ + ) + self.content = QVBoxLayout(self.content_widget) + self.content.setContentsMargins(15, 10, 10, 10) + main_layout.addWidget(self.content_widget) + + # Set initial state + self._update_header() + self.content_widget.setVisible(not collapsed) + + def _update_header(self): + arrow = "▼" if not self._collapsed else "▶" + self._header.setText(f"{arrow} {self._title}") + + def _toggle(self): + self._collapsed = not self._collapsed + self._update_header() + self.content_widget.setVisible(not self._collapsed) + + def setCollapsed(self, collapsed): + """Programmatically set collapsed state.""" + if self._collapsed != collapsed: + self._collapsed = collapsed + self._update_header() + self.content_widget.setVisible(not collapsed) + + def isCollapsed(self): + """Return current collapsed state.""" + return self._collapsed class ConfigEditor(QDialog): @@ -651,26 +716,24 @@ def _create_advanced_tab(self): def _create_views_tab(self): tab = QWidget() - layout = QFormLayout(tab) + layout = QVBoxLayout(tab) layout.setSpacing(10) - # Description label - desc_label = QLabel("Settings for downsampled well/plate views during acquisition.") - desc_label.setStyleSheet("color: #666; font-style: italic;") - desc_label.setWordWrap(True) - layout.addRow(desc_label) + # Plate View section + plate_group = CollapsibleGroupBox("Plate View") + plate_layout = QFormLayout() # Generate Downsampled Well Images self.generate_downsampled_checkbox = QCheckBox() self.generate_downsampled_checkbox.setChecked( self._get_config_bool("VIEWS", "generate_downsampled_well_images", False) ) - layout.addRow("Generate Downsampled Well Images:", self.generate_downsampled_checkbox) + plate_layout.addRow("Generate Downsampled Well Images:", self.generate_downsampled_checkbox) # Display Plate View self.display_plate_view_checkbox = QCheckBox() self.display_plate_view_checkbox.setChecked(self._get_config_bool("VIEWS", "display_plate_view", False)) - layout.addRow("Display Plate View:", self.display_plate_view_checkbox) + plate_layout.addRow("Display Plate View:", self.display_plate_view_checkbox) # Well Resolutions (comma-separated) self.well_resolutions_edit = QLineEdit() @@ -685,7 +748,7 @@ def _create_views_tab(self): well_res_pattern = QRegularExpression(r"^\s*\d+(\.\d+)?\s*(,\s*\d+(\.\d+)?\s*)*$") self.well_resolutions_edit.setValidator(QRegularExpressionValidator(well_res_pattern)) - layout.addRow("Well Resolutions (μm):", self.well_resolutions_edit) + plate_layout.addRow("Well Resolutions (μm):", self.well_resolutions_edit) # Plate Resolution self.plate_resolution_spinbox = QDoubleSpinBox() @@ -693,14 +756,21 @@ def _create_views_tab(self): self.plate_resolution_spinbox.setSingleStep(1.0) self.plate_resolution_spinbox.setValue(self._get_config_float("VIEWS", "downsampled_plate_resolution_um", 10.0)) self.plate_resolution_spinbox.setSuffix(" μm") - layout.addRow("Plate Resolution:", self.plate_resolution_spinbox) + plate_layout.addRow("Plate Resolution:", self.plate_resolution_spinbox) # Z-Projection Mode self.z_projection_combo = QComboBox() self.z_projection_combo.addItems(["mip", "middle"]) current_projection = self._get_config_value("VIEWS", "downsampled_z_projection", "mip") self.z_projection_combo.setCurrentText(current_projection) - layout.addRow("Z-Projection Mode:", self.z_projection_combo) + plate_layout.addRow("Z-Projection Mode:", self.z_projection_combo) + + plate_group.content.addLayout(plate_layout) + layout.addWidget(plate_group) + + # Mosaic View section + mosaic_group = CollapsibleGroupBox("Mosaic View") + mosaic_layout = QFormLayout() # Mosaic Target Pixel Size self.mosaic_pixel_size_spinbox = QDoubleSpinBox() @@ -710,8 +780,12 @@ def _create_views_tab(self): self._get_config_float("VIEWS", "mosaic_view_target_pixel_size_um", 2.0) ) self.mosaic_pixel_size_spinbox.setSuffix(" μm") - layout.addRow("Mosaic Target Pixel Size:", self.mosaic_pixel_size_spinbox) + mosaic_layout.addRow("Target Pixel Size:", self.mosaic_pixel_size_spinbox) + + mosaic_group.content.addLayout(mosaic_layout) + layout.addWidget(mosaic_group) + layout.addStretch() self.tab_widget.addTab(tab, "Views") def _get_config_value(self, section, option, default=""): From 4d3f2d205fb664d00f00e4b631565dbace9fe318 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 5 Jan 2026 17:38:44 -0800 Subject: [PATCH 4/7] feat: Add Display Mosaic View setting and load Views config on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Display Mosaic View" checkbox to control USE_NAPARI_FOR_MOSAIC_DISPLAY - Load Views settings from INI config file on application startup (_def.py) - Mark Display Plate View and Display Mosaic View as requiring restart - Fix gui_hcs.py to use control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY (not stale import) - Un-nest Plate View creation so it's independent of Mosaic View setting - Update tests for new setting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- software/control/_def.py | 45 +++++++++++++ software/control/gui_hcs.py | 63 ++++++++++--------- software/control/widgets.py | 33 +++++++--- .../tests/control/test_preferences_dialog.py | 10 ++- 4 files changed, 111 insertions(+), 40 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 0509561bb..4851486cc 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -1045,3 +1045,48 @@ def get_wellplate_settings(wellplate_format): # saving path if not (DEFAULT_SAVING_PATH.startswith(str(Path.home()))): DEFAULT_SAVING_PATH = str(Path.home()) + "/" + DEFAULT_SAVING_PATH.strip("/") + +# Load Views settings from config file at startup +# These values override the defaults above and are accessed via control._def.XXX +if CACHED_CONFIG_FILE_PATH and os.path.exists(CACHED_CONFIG_FILE_PATH): + try: + _views_config = ConfigParser() + _views_config.read(CACHED_CONFIG_FILE_PATH) + if _views_config.has_section("VIEWS"): + log.info("Loading Views settings from config file") + if _views_config.has_option("VIEWS", "display_plate_view"): + DISPLAY_PLATE_VIEW = _views_config.get("VIEWS", "display_plate_view").lower() in ("true", "1", "yes") + if _views_config.has_option("VIEWS", "display_mosaic_view"): + USE_NAPARI_FOR_MOSAIC_DISPLAY = _views_config.get("VIEWS", "display_mosaic_view").lower() in ( + "true", + "1", + "yes", + ) + if _views_config.has_option("VIEWS", "generate_downsampled_well_images"): + GENERATE_DOWNSAMPLED_WELL_IMAGES = _views_config.get( + "VIEWS", "generate_downsampled_well_images" + ).lower() in ("true", "1", "yes") + if _views_config.has_option("VIEWS", "downsampled_well_resolutions_um"): + try: + _res_str = _views_config.get("VIEWS", "downsampled_well_resolutions_um") + DOWNSAMPLED_WELL_RESOLUTIONS_UM = [float(x.strip()) for x in _res_str.split(",") if x.strip()] + except ValueError: + pass + if _views_config.has_option("VIEWS", "downsampled_plate_resolution_um"): + try: + DOWNSAMPLED_PLATE_RESOLUTION_UM = _views_config.getfloat("VIEWS", "downsampled_plate_resolution_um") + except ValueError: + pass + if _views_config.has_option("VIEWS", "downsampled_z_projection"): + DOWNSAMPLED_Z_PROJECTION = ZProjectionMode.convert_to_enum( + _views_config.get("VIEWS", "downsampled_z_projection") + ) + if _views_config.has_option("VIEWS", "mosaic_view_target_pixel_size_um"): + try: + MOSAIC_VIEW_TARGET_PIXEL_SIZE_UM = _views_config.getfloat( + "VIEWS", "mosaic_view_target_pixel_size_um" + ) + except ValueError: + pass + except Exception as e: + log.warning(f"Failed to load Views settings from config: {e}") diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index e337a3c1d..e36d4f2a9 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -792,16 +792,19 @@ def setupImageDisplayTabs(self): self.imageArrayDisplayWindow = core.ImageArrayDisplayWindow() self.imageDisplayTabs.addTab(self.imageArrayDisplayWindow.widget, "Multichannel Acquisition") - if USE_NAPARI_FOR_MOSAIC_DISPLAY: + # Use control._def.XXX (not star-imported copy) to get runtime config values + self.napariMosaicDisplayWidget = None + if control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY: self.napariMosaicDisplayWidget = widgets.NapariMosaicDisplayWidget( self.objectiveStore, self.camera, self.contrastManager ) self.imageDisplayTabs.addTab(self.napariMosaicDisplayWidget, "Mosaic View") - # Plate view for well-based acquisitions (only if enabled) - if control._def.DISPLAY_PLATE_VIEW: - self.napariPlateViewWidget = widgets.NapariPlateViewWidget(self.contrastManager) - self.imageDisplayTabs.addTab(self.napariPlateViewWidget, "Plate View") + # Plate view for well-based acquisitions (independent of mosaic view) + self.napariPlateViewWidget = None + if control._def.DISPLAY_PLATE_VIEW: + self.napariPlateViewWidget = widgets.NapariPlateViewWidget(self.contrastManager) + self.imageDisplayTabs.addTab(self.napariPlateViewWidget, "Plate View") # z plot self.zPlotWidget = widgets.SurfacePlotWidget() @@ -1276,7 +1279,7 @@ def makeNapariConnections(self): self.multipointController.image_to_display_multi.connect(self.imageArrayDisplayWindow.display_image) # Setup mosaic display widget connections - if USE_NAPARI_FOR_MOSAIC_DISPLAY: + if control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY: self.napari_connections["napariMosaicDisplayWidget"] = [ (self.multipointController.napari_layers_update, self.napariMosaicDisplayWidget.updateMosaic), (self.napariMosaicDisplayWidget.signal_coordinates_clicked, self.move_from_click_mm), @@ -1333,23 +1336,23 @@ def makeNapariConnections(self): ] ) - # Setup plate view widget connections (only if plate view is enabled) - # Use Qt.QueuedConnection explicitly for thread safety since these signals - # are emitted from the acquisition worker thread and received on the main thread. - # This ensures the slot is invoked in the receiver's thread event loop. - if control._def.DISPLAY_PLATE_VIEW and hasattr(self, "napariPlateViewWidget"): - self.napari_connections["napariPlateViewWidget"] = [ - ( - self.multipointController.plate_view_init, - self.napariPlateViewWidget.initPlateLayout, - Qt.QueuedConnection, - ), - ( - self.multipointController.plate_view_update, - self.napariPlateViewWidget.updatePlateView, - Qt.QueuedConnection, - ), - ] + # Setup plate view widget connections (independent of mosaic display) + # Use Qt.QueuedConnection explicitly for thread safety since these signals + # are emitted from the acquisition worker thread and received on the main thread. + # This ensures the slot is invoked in the receiver's thread event loop. + if self.napariPlateViewWidget is not None: + self.napari_connections["napariPlateViewWidget"] = [ + ( + self.multipointController.plate_view_init, + self.napariPlateViewWidget.initPlateLayout, + Qt.QueuedConnection, + ), + ( + self.multipointController.plate_view_update, + self.napariPlateViewWidget.updatePlateView, + Qt.QueuedConnection, + ), + ] # Make initial connections self.updateNapariConnections() @@ -1411,14 +1414,12 @@ def setAcquisitionDisplayTabs(self, selected_configurations, Nz, xy_mode=None): elif not self.live_only_mode: configs = [config.name for config in selected_configurations] print(configs) - if USE_NAPARI_FOR_MOSAIC_DISPLAY and Nz == 1: - # For well-based acquisitions (Select Wells or Load Coordinates), use Plate View - is_well_based = xy_mode is not None and xy_mode in ("Select Wells", "Load Coordinates") - if is_well_based and hasattr(self, "napariPlateViewWidget") and control._def.DISPLAY_PLATE_VIEW: - self.imageDisplayTabs.setCurrentWidget(self.napariPlateViewWidget) - else: - self.imageDisplayTabs.setCurrentWidget(self.napariMosaicDisplayWidget) - + # For well-based acquisitions (Select Wells or Load Coordinates), use Plate View if enabled + is_well_based = xy_mode is not None and xy_mode in ("Select Wells", "Load Coordinates") + if is_well_based and self.napariPlateViewWidget is not None and Nz == 1: + self.imageDisplayTabs.setCurrentWidget(self.napariPlateViewWidget) + elif control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY and Nz == 1: + self.imageDisplayTabs.setCurrentWidget(self.napariMosaicDisplayWidget) elif USE_NAPARI_FOR_MULTIPOINT: self.imageDisplayTabs.setCurrentWidget(self.napariMultiChannelWidget) else: diff --git a/software/control/widgets.py b/software/control/widgets.py index 3b1357bf5..773c6c541 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -772,6 +772,11 @@ def _create_views_tab(self): mosaic_group = CollapsibleGroupBox("Mosaic View") mosaic_layout = QFormLayout() + # Display Mosaic View + self.display_mosaic_view_checkbox = QCheckBox() + self.display_mosaic_view_checkbox.setChecked(self._get_config_bool("VIEWS", "display_mosaic_view", True)) + mosaic_layout.addRow("Display Mosaic View:", self.display_mosaic_view_checkbox) + # Mosaic Target Pixel Size self.mosaic_pixel_size_spinbox = QDoubleSpinBox() self.mosaic_pixel_size_spinbox.setRange(0.5, 20.0) @@ -911,6 +916,11 @@ def _apply_settings(self): self.config.set("VIEWS", "downsampled_well_resolutions_um", self.well_resolutions_edit.text()) self.config.set("VIEWS", "downsampled_plate_resolution_um", str(self.plate_resolution_spinbox.value())) self.config.set("VIEWS", "downsampled_z_projection", self.z_projection_combo.currentText()) + self.config.set( + "VIEWS", + "display_mosaic_view", + "true" if self.display_mosaic_view_checkbox.isChecked() else "false", + ) self.config.set("VIEWS", "mosaic_view_target_pixel_size_um", str(self.mosaic_pixel_size_spinbox.value())) # Save to file @@ -997,6 +1007,7 @@ def _apply_live_settings(self): self._log.warning(f"Invalid well resolutions format: {resolutions_str}") _def.DOWNSAMPLED_PLATE_RESOLUTION_UM = self.plate_resolution_spinbox.value() _def.DOWNSAMPLED_Z_PROJECTION = _def.ZProjectionMode.convert_to_enum(self.z_projection_combo.currentText()) + _def.USE_NAPARI_FOR_MOSAIC_DISPLAY = self.display_mosaic_view_checkbox.isChecked() _def.MOSAIC_VIEW_TARGET_PIXEL_SIZE_UM = self.mosaic_pixel_size_spinbox.value() def _get_changes(self): @@ -1202,7 +1213,7 @@ def _get_changes(self): old_val = self._get_config_bool("VIEWS", "display_plate_view", False) new_val = self.display_plate_view_checkbox.isChecked() if old_val != new_val: - changes.append(("Display Plate View", str(old_val), str(new_val), False)) + changes.append(("Display Plate View *", str(old_val), str(new_val), True)) old_val = self._get_config_value("VIEWS", "downsampled_well_resolutions_um", "5.0, 10.0, 20.0") new_val = self.well_resolutions_edit.text() @@ -1219,6 +1230,11 @@ def _get_changes(self): if old_val != new_val: changes.append(("Z-Projection Mode", old_val, new_val, False)) + old_val = self._get_config_bool("VIEWS", "display_mosaic_view", True) + new_val = self.display_mosaic_view_checkbox.isChecked() + if old_val != new_val: + changes.append(("Display Mosaic View *", str(old_val), str(new_val), True)) + old_val = self._get_config_float("VIEWS", "mosaic_view_target_pixel_size_um", 2.0) new_val = self.mosaic_pixel_size_spinbox.value() if not self._floats_equal(old_val, new_val): @@ -3895,8 +3911,7 @@ def __init__( self.channelConfigurationManager = channelConfigurationManager self.scanCoordinates = scanCoordinates self.focusMapWidget = focusMapWidget - if napariMosaicWidget is not None: - self.napariMosaicWidget = napariMosaicWidget + self.napariMosaicWidget = napariMosaicWidget self.performance_mode = False self.base_path_is_set = False self.location_list = np.empty((0, 3), dtype=float) @@ -5188,8 +5203,7 @@ def __init__( self.channelConfigurationManager = channelConfigurationManager self.scanCoordinates = scanCoordinates self.focusMapWidget = focusMapWidget - if napariMosaicWidget is not None: - self.napariMosaicWidget = napariMosaicWidget + self.napariMosaicWidget = napariMosaicWidget self.performance_mode = False self.tab_widget: Optional[QTabWidget] = tab_widget self.well_selection_widget: Optional[WellSelectionWidget] = well_selection_widget @@ -5692,7 +5706,7 @@ def add_components(self): self.multipointController.signal_region_progress.connect(self.update_region_progress) self.signal_acquisition_started.connect(self.display_progress_bar) self.eta_timer.timeout.connect(self.update_eta_display) - if not self.performance_mode: + if not self.performance_mode and self.napariMosaicWidget is not None: self.napariMosaicWidget.signal_layers_initialized.connect(self.enable_manual_ROI) # Connect save/clear coordinates button @@ -7153,8 +7167,7 @@ def __init__( self.objectiveStore = objectiveStore self.channelConfigurationManager = channelConfigurationManager self.scanCoordinates = scanCoordinates - if napariMosaicWidget is not None: - self.napariMosaicWidget = napariMosaicWidget + self.napariMosaicWidget = napariMosaicWidget self.performance_mode = False self.base_path_is_set = False @@ -9688,6 +9701,10 @@ def __init__(self, contrastManager, parent=None): super().__init__(parent) self.contrastManager = contrastManager self.viewer = napari.Viewer(show=False) + # Disable napari's native menu bar so it doesn't take over macOS global menu bar + if sys.platform == "darwin": + self.viewer.window.main_menu.setNativeMenuBar(False) + self.viewer.window.main_menu.hide() self.layout = QVBoxLayout() self.layout.addWidget(self.viewer.window._qt_window) diff --git a/software/tests/control/test_preferences_dialog.py b/software/tests/control/test_preferences_dialog.py index 55b909192..ef61fdd12 100644 --- a/software/tests/control/test_preferences_dialog.py +++ b/software/tests/control/test_preferences_dialog.py @@ -45,6 +45,7 @@ def sample_config(): config.set("VIEWS", "downsampled_well_resolutions_um", "5.0, 10.0, 20.0") config.set("VIEWS", "downsampled_plate_resolution_um", "10.0") config.set("VIEWS", "downsampled_z_projection", "mip") + config.set("VIEWS", "display_mosaic_view", "true") config.set("VIEWS", "mosaic_view_target_pixel_size_um", "2.0") return config @@ -225,7 +226,7 @@ def test_detect_views_display_plate_view_change(self, preferences_dialog): # Config has display_plate_view=true, checkbox should be checked preferences_dialog.display_plate_view_checkbox.setChecked(False) changes = preferences_dialog._get_changes() - assert any(c[0] == "Display Plate View" for c in changes) + assert any(c[0] == "Display Plate View *" for c in changes) def test_detect_views_well_resolutions_change(self, preferences_dialog): preferences_dialog.well_resolutions_edit.setText("1.0, 2.0") @@ -422,6 +423,9 @@ def test_plate_resolution_initialized(self, preferences_dialog): def test_z_projection_initialized(self, preferences_dialog): assert preferences_dialog.z_projection_combo.currentText() == "mip" + def test_display_mosaic_view_checkbox_initialized(self, preferences_dialog): + assert preferences_dialog.display_mosaic_view_checkbox.isChecked() is True + def test_mosaic_pixel_size_initialized(self, preferences_dialog): assert preferences_dialog.mosaic_pixel_size_spinbox.value() == 2.0 @@ -431,6 +435,7 @@ def test_views_settings_saved_to_file(self, preferences_dialog, temp_config_file preferences_dialog.well_resolutions_edit.setText("2.5, 5.0") preferences_dialog.plate_resolution_spinbox.setValue(15.0) preferences_dialog.z_projection_combo.setCurrentText("middle") + preferences_dialog.display_mosaic_view_checkbox.setChecked(False) preferences_dialog.mosaic_pixel_size_spinbox.setValue(3.0) preferences_dialog._apply_settings() @@ -445,6 +450,7 @@ def test_views_settings_saved_to_file(self, preferences_dialog, temp_config_file assert saved_config.get("VIEWS", "downsampled_well_resolutions_um") == "2.5, 5.0" assert saved_config.get("VIEWS", "downsampled_plate_resolution_um") == "15.0" assert saved_config.get("VIEWS", "downsampled_z_projection") == "middle" + assert saved_config.get("VIEWS", "display_mosaic_view") == "false" assert saved_config.get("VIEWS", "mosaic_view_target_pixel_size_um") == "3.0" def test_views_settings_applied_to_def(self, preferences_dialog): @@ -455,6 +461,7 @@ def test_views_settings_applied_to_def(self, preferences_dialog): preferences_dialog.well_resolutions_edit.setText("2.5, 5.0, 15.0") preferences_dialog.plate_resolution_spinbox.setValue(20.0) preferences_dialog.z_projection_combo.setCurrentText("middle") + preferences_dialog.display_mosaic_view_checkbox.setChecked(False) preferences_dialog.mosaic_pixel_size_spinbox.setValue(4.0) preferences_dialog._apply_live_settings() @@ -464,6 +471,7 @@ def test_views_settings_applied_to_def(self, preferences_dialog): assert _def.DOWNSAMPLED_WELL_RESOLUTIONS_UM == [2.5, 5.0, 15.0] assert _def.DOWNSAMPLED_PLATE_RESOLUTION_UM == 20.0 assert _def.DOWNSAMPLED_Z_PROJECTION == _def.ZProjectionMode.MIDDLE + assert _def.USE_NAPARI_FOR_MOSAIC_DISPLAY is False assert _def.MOSAIC_VIEW_TARGET_PIXEL_SIZE_UM == 4.0 def test_well_resolutions_validator_accepts_valid_input(self, preferences_dialog): From 24cb2f25876a74c5054dfd8d7d9c69c763f7898a Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 5 Jan 2026 18:55:27 -0800 Subject: [PATCH 5/7] fix: Add error handling for ZProjectionMode.convert_to_enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the enum conversion in try/except for consistency with adjacent float parsing, keeping the default value if conversion fails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- software/control/_def.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 4851486cc..d483401e7 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -1078,9 +1078,12 @@ def get_wellplate_settings(wellplate_format): except ValueError: pass if _views_config.has_option("VIEWS", "downsampled_z_projection"): - DOWNSAMPLED_Z_PROJECTION = ZProjectionMode.convert_to_enum( - _views_config.get("VIEWS", "downsampled_z_projection") - ) + try: + DOWNSAMPLED_Z_PROJECTION = ZProjectionMode.convert_to_enum( + _views_config.get("VIEWS", "downsampled_z_projection") + ) + except ValueError: + pass if _views_config.has_option("VIEWS", "mosaic_view_target_pixel_size_um"): try: MOSAIC_VIEW_TARGET_PIXEL_SIZE_UM = _views_config.getfloat( From 5fe81e393b830621c007046e75ddbcf5bdd331c6 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 5 Jan 2026 19:06:15 -0800 Subject: [PATCH 6/7] refactor: Revert control._def.XXX to direct variable access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The star import gets correct values since config loading happens during _def.py initialization, before other modules import from it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- software/control/gui_hcs.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index e36d4f2a9..901cd2e71 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -792,9 +792,8 @@ def setupImageDisplayTabs(self): self.imageArrayDisplayWindow = core.ImageArrayDisplayWindow() self.imageDisplayTabs.addTab(self.imageArrayDisplayWindow.widget, "Multichannel Acquisition") - # Use control._def.XXX (not star-imported copy) to get runtime config values self.napariMosaicDisplayWidget = None - if control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY: + if USE_NAPARI_FOR_MOSAIC_DISPLAY: self.napariMosaicDisplayWidget = widgets.NapariMosaicDisplayWidget( self.objectiveStore, self.camera, self.contrastManager ) @@ -802,7 +801,7 @@ def setupImageDisplayTabs(self): # Plate view for well-based acquisitions (independent of mosaic view) self.napariPlateViewWidget = None - if control._def.DISPLAY_PLATE_VIEW: + if DISPLAY_PLATE_VIEW: self.napariPlateViewWidget = widgets.NapariPlateViewWidget(self.contrastManager) self.imageDisplayTabs.addTab(self.napariPlateViewWidget, "Plate View") @@ -1279,7 +1278,7 @@ def makeNapariConnections(self): self.multipointController.image_to_display_multi.connect(self.imageArrayDisplayWindow.display_image) # Setup mosaic display widget connections - if control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY: + if USE_NAPARI_FOR_MOSAIC_DISPLAY: self.napari_connections["napariMosaicDisplayWidget"] = [ (self.multipointController.napari_layers_update, self.napariMosaicDisplayWidget.updateMosaic), (self.napariMosaicDisplayWidget.signal_coordinates_clicked, self.move_from_click_mm), @@ -1418,7 +1417,7 @@ def setAcquisitionDisplayTabs(self, selected_configurations, Nz, xy_mode=None): is_well_based = xy_mode is not None and xy_mode in ("Select Wells", "Load Coordinates") if is_well_based and self.napariPlateViewWidget is not None and Nz == 1: self.imageDisplayTabs.setCurrentWidget(self.napariPlateViewWidget) - elif control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY and Nz == 1: + elif USE_NAPARI_FOR_MOSAIC_DISPLAY and Nz == 1: self.imageDisplayTabs.setCurrentWidget(self.napariMosaicDisplayWidget) elif USE_NAPARI_FOR_MULTIPOINT: self.imageDisplayTabs.setCurrentWidget(self.napariMultiChannelWidget) From 88643411e1494b8f2c579cf3e095e1933e8907ed Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 5 Jan 2026 19:39:54 -0800 Subject: [PATCH 7/7] fix: Improve test naming and regex validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename test to clarify it tests Generate Downsampled setting only - Add docstring noting Display Plate/Mosaic View require restart - Fix regex to reject trailing commas (e.g., "5.0,") 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- software/control/widgets.py | 2 +- software/tests/control/test_preferences_dialog.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index 773c6c541..a64aa8834 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -746,7 +746,7 @@ def _create_views_tab(self): from qtpy.QtCore import QRegularExpression from qtpy.QtGui import QRegularExpressionValidator - well_res_pattern = QRegularExpression(r"^\s*\d+(\.\d+)?\s*(,\s*\d+(\.\d+)?\s*)*$") + well_res_pattern = QRegularExpression(r"^\s*\d+(\.\d+)?(\s*,\s*\d+(\.\d+)?)*\s*$") self.well_resolutions_edit.setValidator(QRegularExpressionValidator(well_res_pattern)) plate_layout.addRow("Well Resolutions (μm):", self.well_resolutions_edit) diff --git a/software/tests/control/test_preferences_dialog.py b/software/tests/control/test_preferences_dialog.py index ef61fdd12..c0e8e9e07 100644 --- a/software/tests/control/test_preferences_dialog.py +++ b/software/tests/control/test_preferences_dialog.py @@ -248,8 +248,12 @@ def test_detect_views_mosaic_pixel_size_change(self, preferences_dialog): changes = preferences_dialog._get_changes() assert any(c[0] == "Mosaic Target Pixel Size" for c in changes) - def test_views_settings_are_live_update(self, preferences_dialog): - """Verify Views settings don't require restart.""" + def test_generate_downsampled_does_not_require_restart(self, preferences_dialog): + """Verify 'Generate Downsampled Well Images' doesn't require restart. + + Note: Display Plate View and Display Mosaic View DO require restart + since they affect tab creation at startup. + """ preferences_dialog.generate_downsampled_checkbox.setChecked(True) changes = preferences_dialog._get_changes() views_change = next(c for c in changes if c[0] == "Generate Downsampled Well Images")