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

1"""Magicgui-based PyQt6 Widget Creation with OpenHCS Extensions""" 

2 

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 

9 

10from PyQt6.QtWidgets import QCheckBox, QLineEdit, QComboBox, QGroupBox, QVBoxLayout, QSpinBox, QDoubleSpinBox 

11from magicgui.widgets import create_widget 

12from magicgui.type_map import register_type 

13 

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 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24def _get_enum_display_text(enum_value: Enum) -> str: 

25 """ 

26 Get display text for enum value, handling nested enums. 

27 

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) 

41 

42 

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 

49 

50 

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

54 

55 

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

60 

61 

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

66 

67 

68def convert_widget_value_to_type(value: Any, param_type: Type) -> Any: 

69 """ 

70 PyQt-specific type conversions for widget values. 

71 

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

74 

75 Args: 

76 value: The raw value from the widget 

77 param_type: The target parameter type 

78 

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 

88 

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 

117 

118 return value 

119 

120 

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

127 

128 

129 

130 

131 

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} 

147 

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 

153 

154 # Use NoneAwareLineEdit for proper None handling 

155 widget = NoneAwareLineEdit() 

156 widget.set_value(current_value) 

157 return widget 

158 

159 

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 

163 

164 widget = NoScrollComboBox() 

165 

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) 

170 

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 

177 

178 return widget 

179 

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} 

191 

192 

193@dataclasses.dataclass(frozen=True) 

194class MagicGuiWidgetFactory: 

195 """OpenHCS widget factory using functional mapping dispatch.""" 

196 

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) 

201 

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) 

205 

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) 

210 

211 # Handle direct enum types 

212 if is_enum(resolved_type): 

213 return create_enum_widget_unified(resolved_type, extracted_value) 

214 

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) 

246 

247 widget = create_widget(annotation=resolved_type, value=magicgui_value) 

248 

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 

264 

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) 

274 

275 # Functional configuration dispatch 

276 configurator = CONFIGURATION_REGISTRY.get(resolved_type, lambda w: w) 

277 configurator(widget) 

278 

279 return widget 

280 

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 

285 

286 enum_type = get_enum_from_list(param_type) 

287 widget = QGroupBox(param_name.replace('_', ' ').title()) 

288 layout = QVBoxLayout(widget) 

289 

290 # Store checkboxes for value retrieval 

291 widget._checkboxes = {} 

292 

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) 

299 

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) 

305 

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 

311 

312 return widget 

313 

314 

315# Registry pattern removed - use create_pyqt6_widget from widget_creation_registry.py instead 

316 

317 

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 } 

327 

328 

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} 

334 

335 

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

350 

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] 

357 

358 return value 

359 

360 

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. 

364 

365 Args: 

366 placeholder_text: Full placeholder text like "Pipeline default: 42" 

367 

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

374 

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 

383 

384 

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__ 

389 

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 

421 

422 widget.setStyleSheet(style) 

423 widget.setToolTip(f"{placeholder_text} ({interaction_hint})") 

424 widget.setProperty("is_placeholder_state", True) 

425 

426 

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) 

436 

437 

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) 

443 

444 # Set widget to minimum value to show the special value text 

445 if hasattr(widget, 'minimum'): 

446 widget.setValue(widget.minimum()) 

447 

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 ) 

454 

455 

456def _apply_checkbox_placeholder(widget: QCheckBox, placeholder_text: str) -> None: 

457 """Apply placeholder to checkbox showing preview of inherited value. 

458 

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' 

465 

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) 

471 

472 # Mark as placeholder state for NoneAwareCheckBox 

473 if hasattr(widget, '_is_placeholder'): 

474 widget._is_placeholder = True 

475 finally: 

476 widget.blockSignals(False) 

477 

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) 

481 

482 # Trigger repaint to show gray styling 

483 widget.update() 

484 except Exception as e: 

485 widget.setToolTip(placeholder_text) 

486 

487 

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) 

503 

504 

505def _apply_combobox_placeholder(widget: QComboBox, placeholder_text: str) -> None: 

506 """Apply placeholder to combobox while preserving None (no concrete selection). 

507 

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) 

516 

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 ) 

526 

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) 

532 

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) 

541 

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) 

548 

549 

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

555 

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 

560 

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 

565 

566 # Tertiary: Match display text (case-insensitive) 

567 if item_text.upper() == target_normalized: 

568 return True 

569 

570 return False 

571 

572 

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} 

584 

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 

593 

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 

601 

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 

609 

610# Register widget strategies 

611_register_path_widget_strategy() 

612_register_none_aware_lineedit_strategy() 

613_register_none_aware_checkbox_strategy() 

614 

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} 

636 

637 

638 

639 

640 

641@dataclasses.dataclass(frozen=True) 

642class PyQt6WidgetEnhancer: 

643 """Widget enhancement using functional dispatch patterns.""" 

644 

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) 

652 

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) 

660 

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. 

665 

666 This method allows applying placeholders to widgets that are not part of 

667 a dataclass form by directly using the global configuration. 

668 

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 

681 

682 # Get the field value from global config 

683 if hasattr(global_config, field_name): 

684 field_value = getattr(global_config, field_name) 

685 

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}" 

692 

693 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

694 except Exception: 

695 # Silently fail if placeholder can't be applied 

696 pass 

697 

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) 

702 

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 

709 

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 

716 

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 ) 

723 

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

732 

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 ) 

742 

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 

748 

749 widget.setStyleSheet("") 

750 widget.setProperty("is_placeholder_state", False) 

751 

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) 

761 

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