Coverage for openhcs/pyqt_gui/widgets/shared/widget_strategies.py: 0.0%
404 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""Magicgui-based PyQt6 Widget Creation with OpenHCS Extensions"""
3import dataclasses
4import logging
5from enum import Enum
6from pathlib import Path
7from typing import Any, Dict, Type, Callable, Optional, Union
9from PyQt6.QtWidgets import QCheckBox, QLineEdit, QComboBox, QGroupBox, QVBoxLayout, QSpinBox, QDoubleSpinBox
10from magicgui.widgets import create_widget
11from magicgui.type_map import register_type
13from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import (
14 NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox
15)
16from openhcs.pyqt_gui.widgets.enhanced_path_widget import EnhancedPathWidget
17from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
18from openhcs.ui.shared.widget_creation_registry import resolve_optional, is_enum, is_list_of_enums, get_enum_from_list
20logger = logging.getLogger(__name__)
23def _get_enum_display_text(enum_value: Enum) -> str:
24 """
25 Get display text for enum value, handling nested enums.
27 For simple enums like VariableComponents.SITE, returns the string value.
28 For nested enums like GroupBy.CHANNEL = VariableComponents.CHANNEL,
29 returns the nested enum's string value.
30 """
31 if isinstance(enum_value.value, Enum):
32 # Nested enum (e.g., GroupBy.CHANNEL = VariableComponents.CHANNEL)
33 return enum_value.value.value
34 elif isinstance(enum_value.value, str):
35 # Simple string enum
36 return enum_value.value
37 else:
38 # Fallback to string representation
39 return str(enum_value.value)
42@dataclasses.dataclass(frozen=True)
43class WidgetConfig:
44 """Immutable widget configuration constants."""
45 NUMERIC_RANGE_MIN: int = -999999
46 NUMERIC_RANGE_MAX: int = 999999
47 FLOAT_PRECISION: int = 6
50def create_enhanced_path_widget(param_name: str = "", current_value: Any = None, parameter_info: Any = None):
51 """Factory function for OpenHCS enhanced path widgets."""
52 return EnhancedPathWidget(param_name, current_value, parameter_info, PyQt6ColorScheme())
55def _create_none_aware_int_widget():
56 """Factory function for NoneAwareIntEdit widgets."""
57 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import NoneAwareIntEdit
58 return NoneAwareIntEdit()
61def _create_none_aware_checkbox():
62 """Factory function for NoneAwareCheckBox widgets."""
63 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
64 return NoneAwareCheckBox()
67def _create_direct_int_widget(current_value: Any = None):
68 """Fast path: Create int widget directly without magicgui overhead."""
69 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import NoneAwareIntEdit
70 widget = NoneAwareIntEdit()
71 if current_value is not None:
72 widget.set_value(current_value)
73 return widget
76def _create_direct_float_widget(current_value: Any = None):
77 """Fast path: Create float widget directly without magicgui overhead."""
78 widget = NoScrollDoubleSpinBox()
79 widget.setRange(WidgetConfig.NUMERIC_RANGE_MIN, WidgetConfig.NUMERIC_RANGE_MAX)
80 widget.setDecimals(WidgetConfig.FLOAT_PRECISION)
81 if current_value is not None:
82 widget.setValue(float(current_value))
83 else:
84 widget.clear()
85 return widget
88def _create_direct_bool_widget(current_value: Any = None):
89 """Fast path: Create bool widget directly without magicgui overhead."""
90 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
91 widget = NoneAwareCheckBox()
92 if current_value is not None:
93 widget.setChecked(bool(current_value))
94 return widget
97def convert_widget_value_to_type(value: Any, param_type: Type) -> Any:
98 """
99 PyQt-specific type conversions for widget values.
101 Handles conversions that are specific to how PyQt widgets represent values
102 (e.g., Path widgets return strings, tuple/list fields are edited as string literals).
104 Args:
105 value: The raw value from the widget
106 param_type: The target parameter type
108 Returns:
109 The converted value ready for the service layer
110 """
111 # Handle Path widgets - they return strings that need conversion
112 try:
113 if param_type is Path and isinstance(value, str):
114 return Path(value) if value else None
115 except Exception:
116 pass
118 # Handle tuple/list typed configs written as strings in UI
119 try:
120 from typing import get_origin, get_args
121 import ast
122 origin = get_origin(param_type)
123 args = get_args(param_type)
124 if origin in (tuple, list) and isinstance(value, str):
125 # Safely parse string literal into Python object
126 try:
127 parsed = ast.literal_eval(value)
128 except Exception:
129 return value # Return original if parse fails
130 if parsed is not None:
131 # Coerce to the annotated container type
132 if origin is tuple:
133 parsed = tuple(parsed if isinstance(parsed, (list, tuple)) else [parsed])
134 elif origin is list and not isinstance(parsed, list):
135 parsed = [parsed]
136 # Optionally enforce inner type if annotated
137 if args:
138 inner = args[0]
139 try:
140 parsed = tuple(inner(x) for x in parsed) if origin is tuple else [inner(x) for x in parsed]
141 except Exception:
142 pass
143 return parsed
144 except Exception:
145 pass
147 return value
150def register_openhcs_widgets():
151 """Register OpenHCS custom widgets with magicgui type system."""
152 # Register using string widget types that magicgui recognizes
153 register_type(int, widget_type="SpinBox")
154 register_type(float, widget_type="FloatSpinBox")
155 register_type(Path, widget_type="FileEdit")
161# Functional widget replacement registry
162WIDGET_REPLACEMENT_REGISTRY: Dict[Type, callable] = {
163 str: lambda current_value, **kwargs: create_string_fallback_widget(current_value=current_value),
164 bool: lambda current_value, **kwargs: (
165 lambda w: (w.set_value(current_value), w)[1]
166 )(_create_none_aware_checkbox()),
167 int: lambda current_value, **kwargs: (
168 lambda w: (w.set_value(current_value), w)[1]
169 )(_create_none_aware_int_widget()),
170 float: lambda current_value, **kwargs: (
171 lambda w: (w.setValue(float(current_value)), w)[1] if current_value is not None else w
172 )(NoScrollDoubleSpinBox()),
173 Path: lambda current_value, param_name, parameter_info, **kwargs:
174 create_enhanced_path_widget(param_name, current_value, parameter_info),
175}
177# String fallback widget for any type magicgui cannot handle
178def create_string_fallback_widget(current_value: Any, **kwargs) -> QLineEdit:
179 """Create string fallback widget for unsupported types."""
180 # Import here to avoid circular imports
181 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import NoneAwareLineEdit
183 # Use NoneAwareLineEdit for proper None handling
184 widget = NoneAwareLineEdit()
185 widget.set_value(current_value)
186 return widget
189def create_enum_widget_unified(enum_type: Type, current_value: Any, **kwargs) -> QComboBox:
190 """Unified enum widget creator with consistent display text."""
191 from openhcs.ui.shared.ui_utils import format_enum_display
193 widget = NoScrollComboBox()
195 # Add all enum items
196 for enum_value in enum_type:
197 display_text = format_enum_display(enum_value)
198 widget.addItem(display_text, enum_value)
200 # Set current selection
201 if current_value and hasattr(current_value, '__class__') and isinstance(current_value, enum_type):
202 for i in range(widget.count()):
203 if widget.itemData(i) == current_value:
204 widget.setCurrentIndex(i)
205 break
207 return widget
209# Functional configuration registry
210CONFIGURATION_REGISTRY: Dict[Type, callable] = {
211 int: lambda widget: widget.setRange(WidgetConfig.NUMERIC_RANGE_MIN, WidgetConfig.NUMERIC_RANGE_MAX)
212 if hasattr(widget, 'setRange') else None,
213 float: lambda widget: (
214 widget.setRange(WidgetConfig.NUMERIC_RANGE_MIN, WidgetConfig.NUMERIC_RANGE_MAX)
215 if hasattr(widget, 'setRange') else None,
216 widget.setDecimals(WidgetConfig.FLOAT_PRECISION)
217 if hasattr(widget, 'setDecimals') else None
218 )[-1],
219}
222@dataclasses.dataclass(frozen=True)
223class MagicGuiWidgetFactory:
224 """OpenHCS widget factory using functional mapping dispatch."""
226 def create_widget(self, param_name: str, param_type: Type, current_value: Any,
227 widget_id: str, parameter_info: Any = None) -> Any:
228 """Create widget using functional registry dispatch."""
229 from openhcs.utils.performance_monitor import timer
231 with timer(" resolve_optional", threshold_ms=0.1):
232 resolved_type = resolve_optional(param_type)
234 # Handle direct List[Enum] types - create multi-selection checkbox group
235 if is_list_of_enums(resolved_type):
236 with timer(" create checkbox group", threshold_ms=0.5):
237 return self._create_checkbox_group_widget(param_name, resolved_type, current_value)
239 # Extract enum from list wrapper for other cases
240 with timer(" extract enum value", threshold_ms=0.1):
241 extracted_value = (current_value[0] if isinstance(current_value, list) and
242 len(current_value) == 1 and isinstance(current_value[0], Enum)
243 else current_value)
245 # Handle direct enum types
246 if is_enum(resolved_type):
247 with timer(" create enum widget", threshold_ms=0.5):
248 return create_enum_widget_unified(resolved_type, extracted_value)
250 # OPTIMIZATION: Fast path for simple types - bypass magicgui overhead (~0.3ms per widget)
251 # This saves ~36ms for 120 widgets
252 if resolved_type == int:
253 with timer(" create int widget (fast path)", threshold_ms=0.5):
254 return _create_direct_int_widget(extracted_value)
255 elif resolved_type == float:
256 with timer(" create float widget (fast path)", threshold_ms=0.5):
257 return _create_direct_float_widget(extracted_value)
258 elif resolved_type == bool:
259 with timer(" create bool widget (fast path)", threshold_ms=0.5):
260 return _create_direct_bool_widget(extracted_value)
261 elif resolved_type == str:
262 with timer(" create string widget (fast path)", threshold_ms=0.5):
263 return create_string_fallback_widget(current_value=extracted_value)
265 # Check for OpenHCS custom widget replacements
266 with timer(" registry lookup", threshold_ms=0.1):
267 replacement_factory = WIDGET_REPLACEMENT_REGISTRY.get(resolved_type)
269 if replacement_factory:
270 with timer(f" call replacement factory for {resolved_type.__name__ if hasattr(resolved_type, '__name__') else resolved_type}", threshold_ms=0.5):
271 widget = replacement_factory(
272 current_value=extracted_value,
273 param_name=param_name,
274 parameter_info=parameter_info
275 )
276 else:
277 # Try magicgui for complex types, with string fallback for unsupported types
278 try:
279 # Handle None values to prevent magicgui from converting None to literal "None" string
280 with timer(" prepare magicgui value", threshold_ms=0.1):
281 magicgui_value = extracted_value
282 if extracted_value is None:
283 # Use appropriate default values for magicgui to prevent "None" string conversion
284 # CRITICAL FIX: Use minimal defaults that won't look like concrete user values
285 if resolved_type == int:
286 magicgui_value = 0 # magicgui needs a value, placeholder will override display
287 elif resolved_type == float:
288 magicgui_value = 0.0 # magicgui needs a value, placeholder will override display
289 elif resolved_type == bool:
290 magicgui_value = False
291 elif hasattr(resolved_type, '__origin__') and resolved_type.__origin__ is list:
292 magicgui_value = [] # Empty list for List[T] types
293 elif hasattr(resolved_type, '__origin__') and resolved_type.__origin__ is tuple:
294 magicgui_value = () # Empty tuple for tuple[T, ...] types
295 # For other types, let magicgui handle None (might still cause issues but less common)
297 with timer(f" magicgui.create_widget({param_name}, {resolved_type.__name__ if hasattr(resolved_type, '__name__') else resolved_type})", threshold_ms=0.0):
298 widget = create_widget(annotation=resolved_type, value=magicgui_value)
300 # Check if magicgui returned a basic QWidget (which indicates failure)
301 with timer(" check magicgui result", threshold_ms=0.1):
302 if hasattr(widget, 'native') and type(widget.native).__name__ == 'QWidget':
303 logger.warning(f"magicgui returned basic QWidget for {param_name} ({resolved_type}), using fallback")
304 widget = create_string_fallback_widget(current_value=extracted_value)
305 elif type(widget).__name__ == 'QWidget':
306 logger.warning(f"magicgui returned basic QWidget for {param_name} ({resolved_type}), using fallback")
307 widget = create_string_fallback_widget(current_value=extracted_value)
308 else:
309 # If original value was None, clear the widget to show placeholder behavior
310 if extracted_value is None and hasattr(widget, 'native'):
311 native_widget = widget.native
312 if hasattr(native_widget, 'setText'):
313 native_widget.setText("") # Clear text for None values
314 elif hasattr(native_widget, 'setChecked') and resolved_type == bool:
315 native_widget.setChecked(False) # Uncheck for None bool values
317 # Extract native PyQt6 widget from magicgui wrapper if needed
318 if hasattr(widget, 'native'):
319 native_widget = widget.native
320 native_widget._magicgui_widget = widget # Store reference for signal connections
321 widget = native_widget
322 except Exception as e:
323 # Fallback to string widget for any type magicgui cannot handle
324 # Use DEBUG level since this is expected for complex Union types (e.g., well_filter)
325 logger.debug(f"Widget creation failed for {param_name} ({resolved_type}): {e}")
326 widget = create_string_fallback_widget(current_value=extracted_value)
328 # Functional configuration dispatch
329 with timer(" apply widget configuration", threshold_ms=0.1):
330 configurator = CONFIGURATION_REGISTRY.get(resolved_type, lambda w: w)
331 configurator(widget)
333 return widget
335 def _create_checkbox_group_widget(self, param_name: str, param_type: Type, current_value: Any):
336 """Create multi-selection checkbox group for List[Enum] parameters."""
337 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
339 enum_type = get_enum_from_list(param_type)
340 widget = QGroupBox(param_name.replace('_', ' ').title())
341 layout = QVBoxLayout(widget)
343 # Store checkboxes for value retrieval
344 widget._checkboxes = {}
346 for enum_value in enum_type:
347 checkbox = NoneAwareCheckBox()
348 checkbox.setText(enum_value.value)
349 checkbox.setObjectName(f"{param_name}_{enum_value.value}")
350 widget._checkboxes[enum_value] = checkbox
351 layout.addWidget(checkbox)
353 # Set current values (check boxes for items in the list)
354 if current_value and isinstance(current_value, list):
355 for enum_value in current_value:
356 if enum_value in widget._checkboxes:
357 widget._checkboxes[enum_value].setChecked(True)
359 # Add method to get selected values
360 def get_selected_values():
361 return [enum_val for enum_val, checkbox in widget._checkboxes.items()
362 if checkbox.isChecked()]
363 widget.get_selected_values = get_selected_values
365 return widget
368# Registry pattern removed - use create_pyqt6_widget from widget_creation_registry.py instead
371class PlaceholderConfig:
372 """Declarative placeholder configuration."""
373 PLACEHOLDER_PREFIX = "Pipeline default: "
374 # Stronger styling that overrides application theme
375 PLACEHOLDER_STYLE = "color: #888888 !important; font-style: italic !important; opacity: 0.7;"
376 INTERACTION_HINTS = {
377 'checkbox': 'click to set your own value',
378 'combobox': 'select to set your own value'
379 }
382# Functional placeholder strategies
383PLACEHOLDER_STRATEGIES: Dict[str, Callable[[Any, str], None]] = {
384 'setPlaceholderText': lambda widget, text: _apply_lineedit_placeholder(widget, text),
385 'setSpecialValueText': lambda widget, text: _apply_spinbox_placeholder(widget, text),
386}
389def _extract_default_value(placeholder_text: str) -> str:
390 """Extract default value from placeholder text, handling any prefix dynamically."""
391 # CRITICAL FIX: Handle dynamic prefixes like "Pipeline default:", "Step default:", etc.
392 # Look for the pattern "prefix: value" and extract the value part
393 if ':' in placeholder_text:
394 # Split on the first colon and take the part after it
395 parts = placeholder_text.split(':', 1)
396 if len(parts) == 2:
397 value = parts[1].strip()
398 else:
399 value = placeholder_text.strip()
400 else:
401 # Fallback: if no colon, use the whole text
402 value = placeholder_text.strip()
404 # Handle enum values like "Microscope.AUTO" -> "AUTO"
405 if '.' in value and not value.startswith('('): # Avoid breaking "(none)" values
406 enum_parts = value.split('.')
407 if len(enum_parts) == 2:
408 # Return just the enum member name
409 return enum_parts[1]
411 return value
414def _extract_numeric_value_from_placeholder(placeholder_text: str) -> Optional[Union[int, float]]:
415 """
416 Extract numeric value from placeholder text for integer/float fields.
418 Args:
419 placeholder_text: Full placeholder text like "Pipeline default: 42"
421 Returns:
422 Numeric value if found and valid, None otherwise
423 """
424 try:
425 # Extract the value part after the prefix
426 value_str = placeholder_text.replace(PlaceholderConfig.PLACEHOLDER_PREFIX, "").strip()
428 # Try to parse as int first, then float
429 if value_str.isdigit() or (value_str.startswith('-') and value_str[1:].isdigit()):
430 return int(value_str)
431 else:
432 # Try float parsing
433 return float(value_str)
434 except (ValueError, AttributeError):
435 return None
438def _apply_placeholder_styling(widget: Any, interaction_hint: str, placeholder_text: str) -> None:
439 """Apply consistent placeholder styling and tooltip."""
440 # Get widget-specific styling that's strong enough to override application theme
441 widget_type = type(widget).__name__
443 if widget_type == "QComboBox" or widget_type == "NoScrollComboBox":
444 # For editable comboboxes, style the line edit to show placeholder styling
445 # The native placeholder text will automatically appear gray/italic
446 if widget.isEditable():
447 style = """
448 QComboBox QLineEdit {
449 color: #888888 !important;
450 font-style: italic !important;
451 }
452 """
453 else:
454 # Fallback for non-editable comboboxes (shouldn't happen with new approach)
455 style = """
456 QComboBox {
457 color: #888888 !important;
458 font-style: italic !important;
459 opacity: 0.7;
460 }
461 """
462 elif widget_type == "QCheckBox":
463 # Strong checkbox-specific styling
464 style = """
465 QCheckBox {
466 color: #888888 !important;
467 font-style: italic !important;
468 opacity: 0.7;
469 }
470 """
471 else:
472 # Fallback to general styling
473 style = PlaceholderConfig.PLACEHOLDER_STYLE
475 widget.setStyleSheet(style)
476 widget.setToolTip(f"{placeholder_text} ({interaction_hint})")
477 widget.setProperty("is_placeholder_state", True)
480def _apply_lineedit_placeholder(widget: Any, text: str) -> None:
481 """Apply placeholder to line edit with proper state tracking."""
482 # Clear existing text so placeholder becomes visible
483 widget.clear()
484 widget.setPlaceholderText(text)
485 # Set placeholder state property for consistency with other widgets
486 widget.setProperty("is_placeholder_state", True)
487 # Add tooltip for consistency
488 widget.setToolTip(text)
491def _apply_spinbox_placeholder(widget: Any, text: str) -> None:
492 """Apply placeholder to spinbox showing full placeholder text with prefix."""
493 # CRITICAL FIX: Always show the full placeholder text, not just the numeric value
494 # This ensures users see "Pipeline default: 1" instead of just "1"
495 widget.setSpecialValueText(text)
497 # Set widget to minimum value to show the special value text
498 if hasattr(widget, 'minimum'):
499 widget.setValue(widget.minimum())
501 # Apply visual styling to indicate this is a placeholder
502 _apply_placeholder_styling(
503 widget,
504 'change value to set your own',
505 text # Keep full text in tooltip
506 )
509def _apply_checkbox_placeholder(widget: QCheckBox, placeholder_text: str) -> None:
510 """Apply placeholder to checkbox showing preview of inherited value.
512 Shows the actual inherited boolean value (checked/unchecked) with gray/translucent styling.
513 This gives users a visual preview of what the value will be if they don't override it.
514 """
515 try:
516 default_value = _extract_default_value(placeholder_text).lower() == 'true'
518 # Block signals to prevent checkbox state changes from triggering parameter updates
519 widget.blockSignals(True)
520 try:
521 # Set the checkbox to show the inherited value
522 widget.setChecked(default_value)
524 # Mark as placeholder state for NoneAwareCheckBox
525 if hasattr(widget, '_is_placeholder'):
526 widget._is_placeholder = True
527 finally:
528 widget.blockSignals(False)
530 # Set tooltip and property to indicate this is a placeholder state
531 widget.setToolTip(f"{placeholder_text} ({PlaceholderConfig.INTERACTION_HINTS['checkbox']})")
532 widget.setProperty("is_placeholder_state", True)
534 # Trigger repaint to show gray styling
535 widget.update()
536 except Exception as e:
537 widget.setToolTip(placeholder_text)
540def _apply_path_widget_placeholder(widget: Any, placeholder_text: str) -> None:
541 """Apply placeholder to Path widget by targeting the inner QLineEdit."""
542 try:
543 # Path widgets have a path_input attribute that's a QLineEdit
544 if hasattr(widget, 'path_input'):
545 # Clear any existing text and apply placeholder to the inner QLineEdit
546 widget.path_input.clear()
547 widget.path_input.setPlaceholderText(placeholder_text)
548 widget.path_input.setProperty("is_placeholder_state", True)
549 widget.path_input.setToolTip(placeholder_text)
550 else:
551 # Fallback to tooltip if structure is different
552 widget.setToolTip(placeholder_text)
553 except Exception:
554 widget.setToolTip(placeholder_text)
557def _apply_combobox_placeholder(widget: QComboBox, placeholder_text: str) -> None:
558 """Apply placeholder to combobox while preserving None (no concrete selection).
560 Strategy:
561 - Set currentIndex to -1 (no selection) to represent None
562 - Use NoScrollComboBox's custom paintEvent to show placeholder
563 - Display only the inherited enum value (no 'Pipeline default:' prefix)
564 - Dropdown shows only real enum items (no duplicate placeholder item)
565 """
566 try:
567 default_value = _extract_default_value(placeholder_text)
569 # Find matching item using robust enum matching to get display text
570 matching_index = next(
571 (i for i in range(widget.count())
572 if _item_matches_value(widget, i, default_value)),
573 -1
574 )
575 placeholder_display = (
576 widget.itemText(matching_index) if matching_index >= 0 else default_value
577 )
579 # Block signals so this visual change doesn't emit change events
580 widget.blockSignals(True)
581 try:
582 # Set to no selection (index -1) to represent None
583 widget.setCurrentIndex(-1)
585 # Use our custom setPlaceholder method for NoScrollComboBox
586 if hasattr(widget, 'setPlaceholder'):
587 widget.setPlaceholder(placeholder_display)
588 # Fallback for editable comboboxes
589 elif widget.isEditable():
590 widget.lineEdit().setPlaceholderText(placeholder_display)
591 finally:
592 widget.blockSignals(False)
594 # Don't apply placeholder styling - our paintEvent handles the gray/italic styling
595 # Just set the tooltip
596 widget.setToolTip(f"{placeholder_text} ({PlaceholderConfig.INTERACTION_HINTS['combobox']})")
597 widget.setProperty("is_placeholder_state", True)
598 except Exception:
599 widget.setToolTip(placeholder_text)
602def _item_matches_value(widget: QComboBox, index: int, target_value: str) -> bool:
603 """Check if combobox item matches target value using robust enum matching."""
604 item_data = widget.itemData(index)
605 item_text = widget.itemText(index)
606 target_normalized = target_value.upper()
608 # Primary: Match enum name (most reliable)
609 if item_data and hasattr(item_data, 'name'):
610 if item_data.name.upper() == target_normalized:
611 return True
613 # Secondary: Match enum value (case-insensitive)
614 if item_data and hasattr(item_data, 'value'):
615 if str(item_data.value).upper() == target_normalized:
616 return True
618 # Tertiary: Match display text (case-insensitive)
619 if item_text.upper() == target_normalized:
620 return True
622 return False
625# Declarative widget-to-strategy mapping
626WIDGET_PLACEHOLDER_STRATEGIES: Dict[Type, Callable[[Any, str], None]] = {
627 QCheckBox: _apply_checkbox_placeholder,
628 QComboBox: _apply_combobox_placeholder,
629 QSpinBox: _apply_spinbox_placeholder,
630 QDoubleSpinBox: _apply_spinbox_placeholder,
631 NoScrollSpinBox: _apply_spinbox_placeholder,
632 NoScrollDoubleSpinBox: _apply_spinbox_placeholder,
633 NoScrollComboBox: _apply_combobox_placeholder,
634 QLineEdit: _apply_lineedit_placeholder, # Add standard QLineEdit support
635}
637# Add Path widget support dynamically to avoid import issues
638def _register_path_widget_strategy():
639 """Register Path widget strategy dynamically to avoid circular imports."""
640 try:
641 from openhcs.pyqt_gui.widgets.enhanced_path_widget import EnhancedPathWidget
642 WIDGET_PLACEHOLDER_STRATEGIES[EnhancedPathWidget] = _apply_path_widget_placeholder
643 except ImportError:
644 pass # Path widget not available
646def _register_none_aware_lineedit_strategy():
647 """Register NoneAwareLineEdit strategy dynamically to avoid circular imports."""
648 try:
649 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import NoneAwareLineEdit
650 WIDGET_PLACEHOLDER_STRATEGIES[NoneAwareLineEdit] = _apply_lineedit_placeholder
651 except ImportError:
652 pass # NoneAwareLineEdit not available
654def _register_none_aware_checkbox_strategy():
655 """Register NoneAwareCheckBox strategy dynamically to avoid circular imports."""
656 try:
657 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
658 WIDGET_PLACEHOLDER_STRATEGIES[NoneAwareCheckBox] = _apply_checkbox_placeholder
659 except ImportError:
660 pass # NoneAwareCheckBox not available
662# Register widget strategies
663_register_path_widget_strategy()
664_register_none_aware_lineedit_strategy()
665_register_none_aware_checkbox_strategy()
667# Functional signal connection registry
668SIGNAL_CONNECTION_REGISTRY: Dict[str, callable] = {
669 'stateChanged': lambda widget, param_name, callback:
670 widget.stateChanged.connect(lambda: callback(param_name, widget.isChecked())),
671 'textChanged': lambda widget, param_name, callback:
672 widget.textChanged.connect(lambda v: callback(param_name,
673 widget.get_value() if hasattr(widget, 'get_value') else v)),
674 'valueChanged': lambda widget, param_name, callback:
675 widget.valueChanged.connect(lambda v: callback(param_name, v)),
676 'currentIndexChanged': lambda widget, param_name, callback:
677 widget.currentIndexChanged.connect(lambda: callback(param_name,
678 widget.currentData() if hasattr(widget, 'currentData') else widget.currentText())),
679 'path_changed': lambda widget, param_name, callback:
680 widget.path_changed.connect(lambda v: callback(param_name, v)),
681 # Magicgui-specific widget signals
682 'changed': lambda widget, param_name, callback:
683 widget.changed.connect(lambda: callback(param_name, widget.value)),
684 # Checkbox group signal (custom attribute for multi-selection widgets)
685 'get_selected_values': lambda widget, param_name, callback:
686 PyQt6WidgetEnhancer._connect_checkbox_group_signals(widget, param_name, callback),
687}
693@dataclasses.dataclass(frozen=True)
694class PyQt6WidgetEnhancer:
695 """Widget enhancement using functional dispatch patterns."""
697 @staticmethod
698 def apply_placeholder_text(widget: Any, placeholder_text: str) -> None:
699 """Apply placeholder using declarative widget-strategy mapping."""
700 # Direct widget type mapping for enhanced placeholders
701 widget_strategy = WIDGET_PLACEHOLDER_STRATEGIES.get(type(widget))
702 if widget_strategy:
703 return widget_strategy(widget, placeholder_text)
705 # Method-based fallback for standard widgets
706 strategy = next(
707 (strategy for method_name, strategy in PLACEHOLDER_STRATEGIES.items()
708 if hasattr(widget, method_name)),
709 lambda w, t: w.setToolTip(t) if hasattr(w, 'setToolTip') else None
710 )
711 strategy(widget, placeholder_text)
713 @staticmethod
714 def apply_global_config_placeholder(widget: Any, field_name: str, global_config: Any = None) -> None:
715 """
716 Apply placeholder to standalone widget using global config.
718 This method allows applying placeholders to widgets that are not part of
719 a dataclass form by directly using the global configuration.
721 Args:
722 widget: The widget to apply placeholder to
723 field_name: Name of the field in the global config
724 global_config: Global config instance (uses thread-local if None)
725 """
726 try:
727 if global_config is None:
728 from openhcs.core.config import _current_pipeline_config
729 if hasattr(_current_pipeline_config, 'value') and _current_pipeline_config.value:
730 global_config = _current_pipeline_config.value
731 else:
732 return # No global config available
734 # Get the field value from global config
735 if hasattr(global_config, field_name):
736 field_value = getattr(global_config, field_name)
738 # Format the placeholder text appropriately for different types
739 if hasattr(field_value, 'name'): # Enum
740 from openhcs.ui.shared.ui_utils import format_enum_placeholder
741 placeholder_text = format_enum_placeholder(field_value)
742 else:
743 placeholder_text = f"Pipeline default: {field_value}"
745 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text)
746 except Exception:
747 # Silently fail if placeholder can't be applied
748 pass
750 @staticmethod
751 def connect_change_signal(widget: Any, param_name: str, callback: Any) -> None:
752 """Connect signal with placeholder state management."""
753 magicgui_widget = PyQt6WidgetEnhancer._get_magicgui_wrapper(widget)
755 # Create placeholder-aware callback wrapper
756 def create_wrapped_callback(original_callback, value_getter):
757 def wrapped():
758 PyQt6WidgetEnhancer._clear_placeholder_state(widget)
759 original_callback(param_name, value_getter())
760 return wrapped
762 # Prioritize magicgui signals
763 if magicgui_widget and hasattr(magicgui_widget, 'changed'):
764 magicgui_widget.changed.connect(
765 create_wrapped_callback(callback, lambda: magicgui_widget.value)
766 )
767 return
769 # Fallback to native PyQt6 signals
770 connector = next(
771 (connector for signal_name, connector in SIGNAL_CONNECTION_REGISTRY.items()
772 if hasattr(widget, signal_name)),
773 None
774 )
776 if connector:
777 placeholder_aware_callback = lambda pn, val: (
778 PyQt6WidgetEnhancer._clear_placeholder_state(widget),
779 callback(pn, val)
780 )[-1]
781 connector(widget, param_name, placeholder_aware_callback)
782 else:
783 raise ValueError(f"Widget {type(widget).__name__} has no supported change signal")
785 @staticmethod
786 def _connect_checkbox_group_signals(widget: Any, param_name: str, callback: Any) -> None:
787 """Connect signals for checkbox group widgets."""
788 if hasattr(widget, '_checkboxes'):
789 # Connect to each checkbox's stateChanged signal
790 for checkbox in widget._checkboxes.values():
791 checkbox.stateChanged.connect(
792 lambda: callback(param_name, widget.get_selected_values())
793 )
795 @staticmethod
796 def _clear_placeholder_state(widget: Any) -> None:
797 """Clear placeholder state using functional approach."""
798 if not widget.property("is_placeholder_state"):
799 return
801 widget.setStyleSheet("")
802 widget.setProperty("is_placeholder_state", False)
804 # Clean tooltip using functional pattern
805 current_tooltip = widget.toolTip()
806 cleaned_tooltip = next(
807 (current_tooltip.replace(f" ({hint})", "")
808 for hint in PlaceholderConfig.INTERACTION_HINTS.values()
809 if f" ({hint})" in current_tooltip),
810 current_tooltip
811 )
812 widget.setToolTip(cleaned_tooltip)
814 @staticmethod
815 def _get_magicgui_wrapper(widget: Any) -> Any:
816 """Get magicgui wrapper if widget was created by magicgui."""
817 # Check if widget has a reference to its magicgui wrapper
818 if hasattr(widget, '_magicgui_widget'):
819 return widget._magicgui_widget
820 # If widget itself is a magicgui widget, return it
821 if hasattr(widget, 'changed') and hasattr(widget, 'value'):
822 return widget
823 return None
825 @staticmethod
826 def set_widget_value(widget: Any, value: Any) -> None:
827 """
828 Set widget value without triggering signals.
830 Args:
831 widget: Widget to update
832 value: New value
833 """
834 # Temporarily block signals to avoid recursion
835 widget.blockSignals(True)
837 try:
838 if isinstance(widget, QCheckBox):
839 widget.setChecked(bool(value))
840 elif isinstance(widget, (QSpinBox, NoScrollSpinBox)):
841 widget.setValue(int(value) if value is not None else 0)
842 elif isinstance(widget, (QDoubleSpinBox, NoScrollDoubleSpinBox)):
843 widget.setValue(float(value) if value is not None else 0.0)
844 elif isinstance(widget, (QComboBox, NoScrollComboBox)):
845 for i in range(widget.count()):
846 if widget.itemData(i) == value:
847 widget.setCurrentIndex(i)
848 break
849 elif isinstance(widget, QLineEdit):
850 widget.setText(str(value) if value is not None else "")
851 # Handle magicgui widgets
852 elif hasattr(widget, 'value'):
853 widget.value = value
854 finally:
855 widget.blockSignals(False)