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

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

2 

3import dataclasses 

4import logging 

5from enum import Enum 

6from pathlib import Path 

7from typing import Any, Dict, Type, Callable, Optional, Union 

8 

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

10from magicgui.widgets import create_widget 

11from magicgui.type_map import register_type 

12 

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 

19 

20logger = logging.getLogger(__name__) 

21 

22 

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

24 """ 

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

26 

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) 

40 

41 

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 

48 

49 

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

53 

54 

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

59 

60 

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

65 

66 

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 

74 

75 

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 

86 

87 

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 

95 

96 

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

98 """ 

99 PyQt-specific type conversions for widget values. 

100 

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

103 

104 Args: 

105 value: The raw value from the widget 

106 param_type: The target parameter type 

107 

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 

117 

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 

146 

147 return value 

148 

149 

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

156 

157 

158 

159 

160 

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} 

176 

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 

182 

183 # Use NoneAwareLineEdit for proper None handling 

184 widget = NoneAwareLineEdit() 

185 widget.set_value(current_value) 

186 return widget 

187 

188 

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 

192 

193 widget = NoScrollComboBox() 

194 

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) 

199 

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 

206 

207 return widget 

208 

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} 

220 

221 

222@dataclasses.dataclass(frozen=True) 

223class MagicGuiWidgetFactory: 

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

225 

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 

230 

231 with timer(" resolve_optional", threshold_ms=0.1): 

232 resolved_type = resolve_optional(param_type) 

233 

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) 

238 

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) 

244 

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) 

249 

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) 

264 

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) 

268 

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) 

296 

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) 

299 

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 

316 

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) 

327 

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) 

332 

333 return widget 

334 

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 

338 

339 enum_type = get_enum_from_list(param_type) 

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

341 layout = QVBoxLayout(widget) 

342 

343 # Store checkboxes for value retrieval 

344 widget._checkboxes = {} 

345 

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) 

352 

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) 

358 

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 

364 

365 return widget 

366 

367 

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

369 

370 

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 } 

380 

381 

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} 

387 

388 

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

403 

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] 

410 

411 return value 

412 

413 

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. 

417 

418 Args: 

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

420 

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

427 

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 

436 

437 

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__ 

442 

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 

474 

475 widget.setStyleSheet(style) 

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

477 widget.setProperty("is_placeholder_state", True) 

478 

479 

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) 

489 

490 

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) 

496 

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

498 if hasattr(widget, 'minimum'): 

499 widget.setValue(widget.minimum()) 

500 

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 ) 

507 

508 

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

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

511 

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' 

517 

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) 

523 

524 # Mark as placeholder state for NoneAwareCheckBox 

525 if hasattr(widget, '_is_placeholder'): 

526 widget._is_placeholder = True 

527 finally: 

528 widget.blockSignals(False) 

529 

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) 

533 

534 # Trigger repaint to show gray styling 

535 widget.update() 

536 except Exception as e: 

537 widget.setToolTip(placeholder_text) 

538 

539 

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) 

555 

556 

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

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

559 

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) 

568 

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 ) 

578 

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) 

584 

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) 

593 

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) 

600 

601 

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

607 

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 

612 

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 

617 

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

619 if item_text.upper() == target_normalized: 

620 return True 

621 

622 return False 

623 

624 

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} 

636 

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 

645 

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 

653 

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 

661 

662# Register widget strategies 

663_register_path_widget_strategy() 

664_register_none_aware_lineedit_strategy() 

665_register_none_aware_checkbox_strategy() 

666 

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} 

688 

689 

690 

691 

692 

693@dataclasses.dataclass(frozen=True) 

694class PyQt6WidgetEnhancer: 

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

696 

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) 

704 

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) 

712 

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. 

717 

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

719 a dataclass form by directly using the global configuration. 

720 

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 

733 

734 # Get the field value from global config 

735 if hasattr(global_config, field_name): 

736 field_value = getattr(global_config, field_name) 

737 

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

744 

745 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

746 except Exception: 

747 # Silently fail if placeholder can't be applied 

748 pass 

749 

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) 

754 

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 

761 

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 

768 

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 ) 

775 

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

784 

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 ) 

794 

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 

800 

801 widget.setStyleSheet("") 

802 widget.setProperty("is_placeholder_state", False) 

803 

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) 

813 

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 

824 

825 @staticmethod 

826 def set_widget_value(widget: Any, value: Any) -> None: 

827 """ 

828 Set widget value without triggering signals. 

829 

830 Args: 

831 widget: Widget to update 

832 value: New value 

833 """ 

834 # Temporarily block signals to avoid recursion 

835 widget.blockSignals(True) 

836 

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)