Coverage for openhcs/pyqt_gui/widgets/shared/widget_strategies.py: 0.0%
346 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
1"""Magicgui-based PyQt6 Widget Creation with OpenHCS Extensions"""
3import dataclasses
4import logging
5from dataclasses import dataclass, field
6from enum import Enum
7from pathlib import Path
8from typing import Any, Dict, Type, Callable, Optional, Union
10from PyQt6.QtWidgets import QCheckBox, QLineEdit, QComboBox, QGroupBox, QVBoxLayout, QSpinBox, QDoubleSpinBox
11from magicgui.widgets import create_widget
12from magicgui.type_map import register_type
14from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import (
15 NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox
16)
17from openhcs.pyqt_gui.widgets.enhanced_path_widget import EnhancedPathWidget
18from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
19from openhcs.ui.shared.widget_creation_registry import resolve_optional, is_enum, is_list_of_enums, get_enum_from_list
21logger = logging.getLogger(__name__)
24def _get_enum_display_text(enum_value: Enum) -> str:
25 """
26 Get display text for enum value, handling nested enums.
28 For simple enums like VariableComponents.SITE, returns the string value.
29 For nested enums like GroupBy.CHANNEL = VariableComponents.CHANNEL,
30 returns the nested enum's string value.
31 """
32 if isinstance(enum_value.value, Enum):
33 # Nested enum (e.g., GroupBy.CHANNEL = VariableComponents.CHANNEL)
34 return enum_value.value.value
35 elif isinstance(enum_value.value, str):
36 # Simple string enum
37 return enum_value.value
38 else:
39 # Fallback to string representation
40 return str(enum_value.value)
43@dataclasses.dataclass(frozen=True)
44class WidgetConfig:
45 """Immutable widget configuration constants."""
46 NUMERIC_RANGE_MIN: int = -999999
47 NUMERIC_RANGE_MAX: int = 999999
48 FLOAT_PRECISION: int = 6
51def create_enhanced_path_widget(param_name: str = "", current_value: Any = None, parameter_info: Any = None):
52 """Factory function for OpenHCS enhanced path widgets."""
53 return EnhancedPathWidget(param_name, current_value, parameter_info, PyQt6ColorScheme())
56def _create_none_aware_int_widget():
57 """Factory function for NoneAwareIntEdit widgets."""
58 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import NoneAwareIntEdit
59 return NoneAwareIntEdit()
62def _create_none_aware_checkbox():
63 """Factory function for NoneAwareCheckBox widgets."""
64 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
65 return NoneAwareCheckBox()
68def convert_widget_value_to_type(value: Any, param_type: Type) -> Any:
69 """
70 PyQt-specific type conversions for widget values.
72 Handles conversions that are specific to how PyQt widgets represent values
73 (e.g., Path widgets return strings, tuple/list fields are edited as string literals).
75 Args:
76 value: The raw value from the widget
77 param_type: The target parameter type
79 Returns:
80 The converted value ready for the service layer
81 """
82 # Handle Path widgets - they return strings that need conversion
83 try:
84 if param_type is Path and isinstance(value, str):
85 return Path(value) if value else None
86 except Exception:
87 pass
89 # Handle tuple/list typed configs written as strings in UI
90 try:
91 from typing import get_origin, get_args
92 import ast
93 origin = get_origin(param_type)
94 args = get_args(param_type)
95 if origin in (tuple, list) and isinstance(value, str):
96 # Safely parse string literal into Python object
97 try:
98 parsed = ast.literal_eval(value)
99 except Exception:
100 return value # Return original if parse fails
101 if parsed is not None:
102 # Coerce to the annotated container type
103 if origin is tuple:
104 parsed = tuple(parsed if isinstance(parsed, (list, tuple)) else [parsed])
105 elif origin is list and not isinstance(parsed, list):
106 parsed = [parsed]
107 # Optionally enforce inner type if annotated
108 if args:
109 inner = args[0]
110 try:
111 parsed = tuple(inner(x) for x in parsed) if origin is tuple else [inner(x) for x in parsed]
112 except Exception:
113 pass
114 return parsed
115 except Exception:
116 pass
118 return value
121def register_openhcs_widgets():
122 """Register OpenHCS custom widgets with magicgui type system."""
123 # Register using string widget types that magicgui recognizes
124 register_type(int, widget_type="SpinBox")
125 register_type(float, widget_type="FloatSpinBox")
126 register_type(Path, widget_type="FileEdit")
132# Functional widget replacement registry
133WIDGET_REPLACEMENT_REGISTRY: Dict[Type, callable] = {
134 str: lambda current_value, **kwargs: create_string_fallback_widget(current_value=current_value),
135 bool: lambda current_value, **kwargs: (
136 lambda w: (w.set_value(current_value), w)[1]
137 )(_create_none_aware_checkbox()),
138 int: lambda current_value, **kwargs: (
139 lambda w: (w.set_value(current_value), w)[1]
140 )(_create_none_aware_int_widget()),
141 float: lambda current_value, **kwargs: (
142 lambda w: (w.setValue(float(current_value)), w)[1] if current_value is not None else w
143 )(NoScrollDoubleSpinBox()),
144 Path: lambda current_value, param_name, parameter_info, **kwargs:
145 create_enhanced_path_widget(param_name, current_value, parameter_info),
146}
148# String fallback widget for any type magicgui cannot handle
149def create_string_fallback_widget(current_value: Any, **kwargs) -> QLineEdit:
150 """Create string fallback widget for unsupported types."""
151 # Import here to avoid circular imports
152 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import NoneAwareLineEdit
154 # Use NoneAwareLineEdit for proper None handling
155 widget = NoneAwareLineEdit()
156 widget.set_value(current_value)
157 return widget
160def create_enum_widget_unified(enum_type: Type, current_value: Any, **kwargs) -> QComboBox:
161 """Unified enum widget creator with consistent display text."""
162 from openhcs.ui.shared.ui_utils import format_enum_display
164 widget = NoScrollComboBox()
166 # Add all enum items
167 for enum_value in enum_type:
168 display_text = format_enum_display(enum_value)
169 widget.addItem(display_text, enum_value)
171 # Set current selection
172 if current_value and hasattr(current_value, '__class__') and isinstance(current_value, enum_type):
173 for i in range(widget.count()):
174 if widget.itemData(i) == current_value:
175 widget.setCurrentIndex(i)
176 break
178 return widget
180# Functional configuration registry
181CONFIGURATION_REGISTRY: Dict[Type, callable] = {
182 int: lambda widget: widget.setRange(WidgetConfig.NUMERIC_RANGE_MIN, WidgetConfig.NUMERIC_RANGE_MAX)
183 if hasattr(widget, 'setRange') else None,
184 float: lambda widget: (
185 widget.setRange(WidgetConfig.NUMERIC_RANGE_MIN, WidgetConfig.NUMERIC_RANGE_MAX)
186 if hasattr(widget, 'setRange') else None,
187 widget.setDecimals(WidgetConfig.FLOAT_PRECISION)
188 if hasattr(widget, 'setDecimals') else None
189 )[-1],
190}
193@dataclasses.dataclass(frozen=True)
194class MagicGuiWidgetFactory:
195 """OpenHCS widget factory using functional mapping dispatch."""
197 def create_widget(self, param_name: str, param_type: Type, current_value: Any,
198 widget_id: str, parameter_info: Any = None) -> Any:
199 """Create widget using functional registry dispatch."""
200 resolved_type = resolve_optional(param_type)
202 # Handle direct List[Enum] types - create multi-selection checkbox group
203 if is_list_of_enums(resolved_type):
204 return self._create_checkbox_group_widget(param_name, resolved_type, current_value)
206 # Extract enum from list wrapper for other cases
207 extracted_value = (current_value[0] if isinstance(current_value, list) and
208 len(current_value) == 1 and isinstance(current_value[0], Enum)
209 else current_value)
211 # Handle direct enum types
212 if is_enum(resolved_type):
213 return create_enum_widget_unified(resolved_type, extracted_value)
215 # Check for OpenHCS custom widget replacements
216 replacement_factory = WIDGET_REPLACEMENT_REGISTRY.get(resolved_type)
217 if replacement_factory:
218 widget = replacement_factory(
219 current_value=extracted_value,
220 param_name=param_name,
221 parameter_info=parameter_info
222 )
223 else:
224 # For string types, use our NoneAwareLineEdit instead of magicgui
225 if resolved_type == str:
226 widget = create_string_fallback_widget(current_value=extracted_value)
227 else:
228 # Try magicgui for non-string types, with string fallback for unsupported types
229 try:
230 # Handle None values to prevent magicgui from converting None to literal "None" string
231 magicgui_value = extracted_value
232 if extracted_value is None:
233 # Use appropriate default values for magicgui to prevent "None" string conversion
234 # CRITICAL FIX: Use minimal defaults that won't look like concrete user values
235 if resolved_type == int:
236 magicgui_value = 0 # magicgui needs a value, placeholder will override display
237 elif resolved_type == float:
238 magicgui_value = 0.0 # magicgui needs a value, placeholder will override display
239 elif resolved_type == bool:
240 magicgui_value = False
241 elif hasattr(resolved_type, '__origin__') and resolved_type.__origin__ is list:
242 magicgui_value = [] # Empty list for List[T] types
243 elif hasattr(resolved_type, '__origin__') and resolved_type.__origin__ is tuple:
244 magicgui_value = () # Empty tuple for tuple[T, ...] types
245 # For other types, let magicgui handle None (might still cause issues but less common)
247 widget = create_widget(annotation=resolved_type, value=magicgui_value)
249 # Check if magicgui returned a basic QWidget (which indicates failure)
250 if hasattr(widget, 'native') and type(widget.native).__name__ == 'QWidget':
251 logger.warning(f"magicgui returned basic QWidget for {param_name} ({resolved_type}), using fallback")
252 widget = create_string_fallback_widget(current_value=extracted_value)
253 elif type(widget).__name__ == 'QWidget':
254 logger.warning(f"magicgui returned basic QWidget for {param_name} ({resolved_type}), using fallback")
255 widget = create_string_fallback_widget(current_value=extracted_value)
256 else:
257 # If original value was None, clear the widget to show placeholder behavior
258 if extracted_value is None and hasattr(widget, 'native'):
259 native_widget = widget.native
260 if hasattr(native_widget, 'setText'):
261 native_widget.setText("") # Clear text for None values
262 elif hasattr(native_widget, 'setChecked') and resolved_type == bool:
263 native_widget.setChecked(False) # Uncheck for None bool values
265 # Extract native PyQt6 widget from magicgui wrapper if needed
266 if hasattr(widget, 'native'):
267 native_widget = widget.native
268 native_widget._magicgui_widget = widget # Store reference for signal connections
269 widget = native_widget
270 except Exception as e:
271 # Fallback to string widget for any type magicgui cannot handle
272 logger.warning(f"Widget creation failed for {param_name} ({resolved_type}): {e}", exc_info=True)
273 widget = create_string_fallback_widget(current_value=extracted_value)
275 # Functional configuration dispatch
276 configurator = CONFIGURATION_REGISTRY.get(resolved_type, lambda w: w)
277 configurator(widget)
279 return widget
281 def _create_checkbox_group_widget(self, param_name: str, param_type: Type, current_value: Any):
282 """Create multi-selection checkbox group for List[Enum] parameters."""
283 from PyQt6.QtWidgets import QGroupBox, QVBoxLayout
284 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
286 enum_type = get_enum_from_list(param_type)
287 widget = QGroupBox(param_name.replace('_', ' ').title())
288 layout = QVBoxLayout(widget)
290 # Store checkboxes for value retrieval
291 widget._checkboxes = {}
293 for enum_value in enum_type:
294 checkbox = NoneAwareCheckBox()
295 checkbox.setText(enum_value.value)
296 checkbox.setObjectName(f"{param_name}_{enum_value.value}")
297 widget._checkboxes[enum_value] = checkbox
298 layout.addWidget(checkbox)
300 # Set current values (check boxes for items in the list)
301 if current_value and isinstance(current_value, list):
302 for enum_value in current_value:
303 if enum_value in widget._checkboxes:
304 widget._checkboxes[enum_value].setChecked(True)
306 # Add method to get selected values
307 def get_selected_values():
308 return [enum_val for enum_val, checkbox in widget._checkboxes.items()
309 if checkbox.isChecked()]
310 widget.get_selected_values = get_selected_values
312 return widget
315# Registry pattern removed - use create_pyqt6_widget from widget_creation_registry.py instead
318class PlaceholderConfig:
319 """Declarative placeholder configuration."""
320 PLACEHOLDER_PREFIX = "Pipeline default: "
321 # Stronger styling that overrides application theme
322 PLACEHOLDER_STYLE = "color: #888888 !important; font-style: italic !important; opacity: 0.7;"
323 INTERACTION_HINTS = {
324 'checkbox': 'click to set your own value',
325 'combobox': 'select to set your own value'
326 }
329# Functional placeholder strategies
330PLACEHOLDER_STRATEGIES: Dict[str, Callable[[Any, str], None]] = {
331 'setPlaceholderText': lambda widget, text: _apply_lineedit_placeholder(widget, text),
332 'setSpecialValueText': lambda widget, text: _apply_spinbox_placeholder(widget, text),
333}
336def _extract_default_value(placeholder_text: str) -> str:
337 """Extract default value from placeholder text, handling any prefix dynamically."""
338 # CRITICAL FIX: Handle dynamic prefixes like "Pipeline default:", "Step default:", etc.
339 # Look for the pattern "prefix: value" and extract the value part
340 if ':' in placeholder_text:
341 # Split on the first colon and take the part after it
342 parts = placeholder_text.split(':', 1)
343 if len(parts) == 2:
344 value = parts[1].strip()
345 else:
346 value = placeholder_text.strip()
347 else:
348 # Fallback: if no colon, use the whole text
349 value = placeholder_text.strip()
351 # Handle enum values like "Microscope.AUTO" -> "AUTO"
352 if '.' in value and not value.startswith('('): # Avoid breaking "(none)" values
353 enum_parts = value.split('.')
354 if len(enum_parts) == 2:
355 # Return just the enum member name
356 return enum_parts[1]
358 return value
361def _extract_numeric_value_from_placeholder(placeholder_text: str) -> Optional[Union[int, float]]:
362 """
363 Extract numeric value from placeholder text for integer/float fields.
365 Args:
366 placeholder_text: Full placeholder text like "Pipeline default: 42"
368 Returns:
369 Numeric value if found and valid, None otherwise
370 """
371 try:
372 # Extract the value part after the prefix
373 value_str = placeholder_text.replace(PlaceholderConfig.PLACEHOLDER_PREFIX, "").strip()
375 # Try to parse as int first, then float
376 if value_str.isdigit() or (value_str.startswith('-') and value_str[1:].isdigit()):
377 return int(value_str)
378 else:
379 # Try float parsing
380 return float(value_str)
381 except (ValueError, AttributeError):
382 return None
385def _apply_placeholder_styling(widget: Any, interaction_hint: str, placeholder_text: str) -> None:
386 """Apply consistent placeholder styling and tooltip."""
387 # Get widget-specific styling that's strong enough to override application theme
388 widget_type = type(widget).__name__
390 if widget_type == "QComboBox" or widget_type == "NoScrollComboBox":
391 # For editable comboboxes, style the line edit to show placeholder styling
392 # The native placeholder text will automatically appear gray/italic
393 if widget.isEditable():
394 style = """
395 QComboBox QLineEdit {
396 color: #888888 !important;
397 font-style: italic !important;
398 }
399 """
400 else:
401 # Fallback for non-editable comboboxes (shouldn't happen with new approach)
402 style = """
403 QComboBox {
404 color: #888888 !important;
405 font-style: italic !important;
406 opacity: 0.7;
407 }
408 """
409 elif widget_type == "QCheckBox":
410 # Strong checkbox-specific styling
411 style = """
412 QCheckBox {
413 color: #888888 !important;
414 font-style: italic !important;
415 opacity: 0.7;
416 }
417 """
418 else:
419 # Fallback to general styling
420 style = PlaceholderConfig.PLACEHOLDER_STYLE
422 widget.setStyleSheet(style)
423 widget.setToolTip(f"{placeholder_text} ({interaction_hint})")
424 widget.setProperty("is_placeholder_state", True)
427def _apply_lineedit_placeholder(widget: Any, text: str) -> None:
428 """Apply placeholder to line edit with proper state tracking."""
429 # Clear existing text so placeholder becomes visible
430 widget.clear()
431 widget.setPlaceholderText(text)
432 # Set placeholder state property for consistency with other widgets
433 widget.setProperty("is_placeholder_state", True)
434 # Add tooltip for consistency
435 widget.setToolTip(text)
438def _apply_spinbox_placeholder(widget: Any, text: str) -> None:
439 """Apply placeholder to spinbox showing full placeholder text with prefix."""
440 # CRITICAL FIX: Always show the full placeholder text, not just the numeric value
441 # This ensures users see "Pipeline default: 1" instead of just "1"
442 widget.setSpecialValueText(text)
444 # Set widget to minimum value to show the special value text
445 if hasattr(widget, 'minimum'):
446 widget.setValue(widget.minimum())
448 # Apply visual styling to indicate this is a placeholder
449 _apply_placeholder_styling(
450 widget,
451 'change value to set your own',
452 text # Keep full text in tooltip
453 )
456def _apply_checkbox_placeholder(widget: QCheckBox, placeholder_text: str) -> None:
457 """Apply placeholder to checkbox showing preview of inherited value.
459 Shows the actual inherited boolean value (checked/unchecked) with gray/translucent styling.
460 This gives users a visual preview of what the value will be if they don't override it.
461 """
462 try:
463 from PyQt6.QtCore import Qt
464 default_value = _extract_default_value(placeholder_text).lower() == 'true'
466 # Block signals to prevent checkbox state changes from triggering parameter updates
467 widget.blockSignals(True)
468 try:
469 # Set the checkbox to show the inherited value
470 widget.setChecked(default_value)
472 # Mark as placeholder state for NoneAwareCheckBox
473 if hasattr(widget, '_is_placeholder'):
474 widget._is_placeholder = True
475 finally:
476 widget.blockSignals(False)
478 # Set tooltip and property to indicate this is a placeholder state
479 widget.setToolTip(f"{placeholder_text} ({PlaceholderConfig.INTERACTION_HINTS['checkbox']})")
480 widget.setProperty("is_placeholder_state", True)
482 # Trigger repaint to show gray styling
483 widget.update()
484 except Exception as e:
485 widget.setToolTip(placeholder_text)
488def _apply_path_widget_placeholder(widget: Any, placeholder_text: str) -> None:
489 """Apply placeholder to Path widget by targeting the inner QLineEdit."""
490 try:
491 # Path widgets have a path_input attribute that's a QLineEdit
492 if hasattr(widget, 'path_input'):
493 # Clear any existing text and apply placeholder to the inner QLineEdit
494 widget.path_input.clear()
495 widget.path_input.setPlaceholderText(placeholder_text)
496 widget.path_input.setProperty("is_placeholder_state", True)
497 widget.path_input.setToolTip(placeholder_text)
498 else:
499 # Fallback to tooltip if structure is different
500 widget.setToolTip(placeholder_text)
501 except Exception:
502 widget.setToolTip(placeholder_text)
505def _apply_combobox_placeholder(widget: QComboBox, placeholder_text: str) -> None:
506 """Apply placeholder to combobox while preserving None (no concrete selection).
508 Strategy:
509 - Set currentIndex to -1 (no selection) to represent None
510 - Use NoScrollComboBox's custom paintEvent to show placeholder
511 - Display only the inherited enum value (no 'Pipeline default:' prefix)
512 - Dropdown shows only real enum items (no duplicate placeholder item)
513 """
514 try:
515 default_value = _extract_default_value(placeholder_text)
517 # Find matching item using robust enum matching to get display text
518 matching_index = next(
519 (i for i in range(widget.count())
520 if _item_matches_value(widget, i, default_value)),
521 -1
522 )
523 placeholder_display = (
524 widget.itemText(matching_index) if matching_index >= 0 else default_value
525 )
527 # Block signals so this visual change doesn't emit change events
528 widget.blockSignals(True)
529 try:
530 # Set to no selection (index -1) to represent None
531 widget.setCurrentIndex(-1)
533 # Use our custom setPlaceholder method for NoScrollComboBox
534 if hasattr(widget, 'setPlaceholder'):
535 widget.setPlaceholder(placeholder_display)
536 # Fallback for editable comboboxes
537 elif widget.isEditable():
538 widget.lineEdit().setPlaceholderText(placeholder_display)
539 finally:
540 widget.blockSignals(False)
542 # Don't apply placeholder styling - our paintEvent handles the gray/italic styling
543 # Just set the tooltip
544 widget.setToolTip(f"{placeholder_text} ({PlaceholderConfig.INTERACTION_HINTS['combobox']})")
545 widget.setProperty("is_placeholder_state", True)
546 except Exception:
547 widget.setToolTip(placeholder_text)
550def _item_matches_value(widget: QComboBox, index: int, target_value: str) -> bool:
551 """Check if combobox item matches target value using robust enum matching."""
552 item_data = widget.itemData(index)
553 item_text = widget.itemText(index)
554 target_normalized = target_value.upper()
556 # Primary: Match enum name (most reliable)
557 if item_data and hasattr(item_data, 'name'):
558 if item_data.name.upper() == target_normalized:
559 return True
561 # Secondary: Match enum value (case-insensitive)
562 if item_data and hasattr(item_data, 'value'):
563 if str(item_data.value).upper() == target_normalized:
564 return True
566 # Tertiary: Match display text (case-insensitive)
567 if item_text.upper() == target_normalized:
568 return True
570 return False
573# Declarative widget-to-strategy mapping
574WIDGET_PLACEHOLDER_STRATEGIES: Dict[Type, Callable[[Any, str], None]] = {
575 QCheckBox: _apply_checkbox_placeholder,
576 QComboBox: _apply_combobox_placeholder,
577 QSpinBox: _apply_spinbox_placeholder,
578 QDoubleSpinBox: _apply_spinbox_placeholder,
579 NoScrollSpinBox: _apply_spinbox_placeholder,
580 NoScrollDoubleSpinBox: _apply_spinbox_placeholder,
581 NoScrollComboBox: _apply_combobox_placeholder,
582 QLineEdit: _apply_lineedit_placeholder, # Add standard QLineEdit support
583}
585# Add Path widget support dynamically to avoid import issues
586def _register_path_widget_strategy():
587 """Register Path widget strategy dynamically to avoid circular imports."""
588 try:
589 from openhcs.pyqt_gui.widgets.enhanced_path_widget import EnhancedPathWidget
590 WIDGET_PLACEHOLDER_STRATEGIES[EnhancedPathWidget] = _apply_path_widget_placeholder
591 except ImportError:
592 pass # Path widget not available
594def _register_none_aware_lineedit_strategy():
595 """Register NoneAwareLineEdit strategy dynamically to avoid circular imports."""
596 try:
597 from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import NoneAwareLineEdit
598 WIDGET_PLACEHOLDER_STRATEGIES[NoneAwareLineEdit] = _apply_lineedit_placeholder
599 except ImportError:
600 pass # NoneAwareLineEdit not available
602def _register_none_aware_checkbox_strategy():
603 """Register NoneAwareCheckBox strategy dynamically to avoid circular imports."""
604 try:
605 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
606 WIDGET_PLACEHOLDER_STRATEGIES[NoneAwareCheckBox] = _apply_checkbox_placeholder
607 except ImportError:
608 pass # NoneAwareCheckBox not available
610# Register widget strategies
611_register_path_widget_strategy()
612_register_none_aware_lineedit_strategy()
613_register_none_aware_checkbox_strategy()
615# Functional signal connection registry
616SIGNAL_CONNECTION_REGISTRY: Dict[str, callable] = {
617 'stateChanged': lambda widget, param_name, callback:
618 widget.stateChanged.connect(lambda: callback(param_name, widget.isChecked())),
619 'textChanged': lambda widget, param_name, callback:
620 widget.textChanged.connect(lambda v: callback(param_name,
621 widget.get_value() if hasattr(widget, 'get_value') else v)),
622 'valueChanged': lambda widget, param_name, callback:
623 widget.valueChanged.connect(lambda v: callback(param_name, v)),
624 'currentIndexChanged': lambda widget, param_name, callback:
625 widget.currentIndexChanged.connect(lambda: callback(param_name,
626 widget.currentData() if hasattr(widget, 'currentData') else widget.currentText())),
627 'path_changed': lambda widget, param_name, callback:
628 widget.path_changed.connect(lambda v: callback(param_name, v)),
629 # Magicgui-specific widget signals
630 'changed': lambda widget, param_name, callback:
631 widget.changed.connect(lambda: callback(param_name, widget.value)),
632 # Checkbox group signal (custom attribute for multi-selection widgets)
633 'get_selected_values': lambda widget, param_name, callback:
634 PyQt6WidgetEnhancer._connect_checkbox_group_signals(widget, param_name, callback),
635}
641@dataclasses.dataclass(frozen=True)
642class PyQt6WidgetEnhancer:
643 """Widget enhancement using functional dispatch patterns."""
645 @staticmethod
646 def apply_placeholder_text(widget: Any, placeholder_text: str) -> None:
647 """Apply placeholder using declarative widget-strategy mapping."""
648 # Direct widget type mapping for enhanced placeholders
649 widget_strategy = WIDGET_PLACEHOLDER_STRATEGIES.get(type(widget))
650 if widget_strategy:
651 return widget_strategy(widget, placeholder_text)
653 # Method-based fallback for standard widgets
654 strategy = next(
655 (strategy for method_name, strategy in PLACEHOLDER_STRATEGIES.items()
656 if hasattr(widget, method_name)),
657 lambda w, t: w.setToolTip(t) if hasattr(w, 'setToolTip') else None
658 )
659 strategy(widget, placeholder_text)
661 @staticmethod
662 def apply_global_config_placeholder(widget: Any, field_name: str, global_config: Any = None) -> None:
663 """
664 Apply placeholder to standalone widget using global config.
666 This method allows applying placeholders to widgets that are not part of
667 a dataclass form by directly using the global configuration.
669 Args:
670 widget: The widget to apply placeholder to
671 field_name: Name of the field in the global config
672 global_config: Global config instance (uses thread-local if None)
673 """
674 try:
675 if global_config is None:
676 from openhcs.core.config import _current_pipeline_config
677 if hasattr(_current_pipeline_config, 'value') and _current_pipeline_config.value:
678 global_config = _current_pipeline_config.value
679 else:
680 return # No global config available
682 # Get the field value from global config
683 if hasattr(global_config, field_name):
684 field_value = getattr(global_config, field_name)
686 # Format the placeholder text appropriately for different types
687 if hasattr(field_value, 'name'): # Enum
688 from openhcs.ui.shared.ui_utils import format_enum_placeholder
689 placeholder_text = format_enum_placeholder(field_value)
690 else:
691 placeholder_text = f"Pipeline default: {field_value}"
693 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text)
694 except Exception:
695 # Silently fail if placeholder can't be applied
696 pass
698 @staticmethod
699 def connect_change_signal(widget: Any, param_name: str, callback: Any) -> None:
700 """Connect signal with placeholder state management."""
701 magicgui_widget = PyQt6WidgetEnhancer._get_magicgui_wrapper(widget)
703 # Create placeholder-aware callback wrapper
704 def create_wrapped_callback(original_callback, value_getter):
705 def wrapped():
706 PyQt6WidgetEnhancer._clear_placeholder_state(widget)
707 original_callback(param_name, value_getter())
708 return wrapped
710 # Prioritize magicgui signals
711 if magicgui_widget and hasattr(magicgui_widget, 'changed'):
712 magicgui_widget.changed.connect(
713 create_wrapped_callback(callback, lambda: magicgui_widget.value)
714 )
715 return
717 # Fallback to native PyQt6 signals
718 connector = next(
719 (connector for signal_name, connector in SIGNAL_CONNECTION_REGISTRY.items()
720 if hasattr(widget, signal_name)),
721 None
722 )
724 if connector:
725 placeholder_aware_callback = lambda pn, val: (
726 PyQt6WidgetEnhancer._clear_placeholder_state(widget),
727 callback(pn, val)
728 )[-1]
729 connector(widget, param_name, placeholder_aware_callback)
730 else:
731 raise ValueError(f"Widget {type(widget).__name__} has no supported change signal")
733 @staticmethod
734 def _connect_checkbox_group_signals(widget: Any, param_name: str, callback: Any) -> None:
735 """Connect signals for checkbox group widgets."""
736 if hasattr(widget, '_checkboxes'):
737 # Connect to each checkbox's stateChanged signal
738 for checkbox in widget._checkboxes.values():
739 checkbox.stateChanged.connect(
740 lambda: callback(param_name, widget.get_selected_values())
741 )
743 @staticmethod
744 def _clear_placeholder_state(widget: Any) -> None:
745 """Clear placeholder state using functional approach."""
746 if not widget.property("is_placeholder_state"):
747 return
749 widget.setStyleSheet("")
750 widget.setProperty("is_placeholder_state", False)
752 # Clean tooltip using functional pattern
753 current_tooltip = widget.toolTip()
754 cleaned_tooltip = next(
755 (current_tooltip.replace(f" ({hint})", "")
756 for hint in PlaceholderConfig.INTERACTION_HINTS.values()
757 if f" ({hint})" in current_tooltip),
758 current_tooltip
759 )
760 widget.setToolTip(cleaned_tooltip)
762 @staticmethod
763 def _get_magicgui_wrapper(widget: Any) -> Any:
764 """Get magicgui wrapper if widget was created by magicgui."""
765 # Check if widget has a reference to its magicgui wrapper
766 if hasattr(widget, '_magicgui_widget'):
767 return widget._magicgui_widget
768 # If widget itself is a magicgui widget, return it
769 if hasattr(widget, 'changed') and hasattr(widget, 'value'):
770 return widget
771 return None