Coverage for openhcs/pyqt_gui/widgets/function_list_editor.py: 0.0%

466 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-01 18:33 +0000

1""" 

2Function List Editor Widget for PyQt6 GUI. 

3 

4Mirrors the Textual TUI FunctionListEditorWidget with sophisticated parameter forms. 

5Displays a scrollable list of function panes with Add/Load/Save/Code controls. 

6""" 

7 

8import logging 

9import os 

10from typing import List, Union, Dict, Any, Optional, Callable, Tuple 

11from pathlib import Path 

12 

13from PyQt6.QtWidgets import ( 

14 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, 

15 QScrollArea, QFrame 

16) 

17from PyQt6.QtCore import Qt, pyqtSignal 

18 

19from openhcs.processing.backends.lib_registry.registry_service import RegistryService 

20from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager 

21from openhcs.pyqt_gui.widgets.function_pane import FunctionPaneWidget 

22from openhcs.constants.constants import GroupBy, VariableComponents 

23from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

24from openhcs.pyqt_gui.widgets.shared.widget_strategies import _get_enum_display_text 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29class FunctionListEditorWidget(QWidget): 

30 """ 

31 Function list editor widget that mirrors Textual TUI functionality. 

32  

33 Displays functions with parameter editing, Add/Delete/Reset buttons, 

34 and Load/Save/Code functionality. 

35 """ 

36 

37 # Signals 

38 function_pattern_changed = pyqtSignal() 

39 

40 def __init__(self, initial_functions: Union[List, Dict, callable, None] = None, 

41 step_identifier: str = None, service_adapter=None, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None): 

42 super().__init__(parent) 

43 

44 # Initialize color scheme 

45 self.color_scheme = color_scheme or PyQt6ColorScheme() 

46 

47 # Initialize services (reuse existing business logic) 

48 self.registry_service = RegistryService() 

49 self.data_manager = PatternDataManager() 

50 self.service_adapter = service_adapter 

51 

52 # Step identifier for cache isolation 

53 self.step_identifier = step_identifier or f"widget_{id(self)}" 

54 

55 # Step configuration properties (mirrors Textual TUI) 

56 self.current_group_by = None # Current GroupBy setting from step editor 

57 self.current_variable_components = [] # Current VariableComponents list from step editor 

58 self.selected_channel = None # Currently selected channel 

59 self.available_channels = [] # Available channels from orchestrator 

60 self.is_dict_mode = False # Whether we're in channel-specific mode 

61 

62 # Component selection cache per GroupBy (mirrors Textual TUI) 

63 self.component_selections = {} 

64 

65 # Initialize pattern data and mode 

66 self._initialize_pattern_data(initial_functions) 

67 

68 # UI components 

69 self.function_panes = [] 

70 

71 self.setup_ui() 

72 self.setup_connections() 

73 

74 logger.debug(f"Function list editor initialized with {len(self.functions)} functions") 

75 

76 def _initialize_pattern_data(self, initial_functions): 

77 """Initialize pattern data from various input formats (mirrors Textual TUI logic).""" 

78 if initial_functions is None: 

79 self.pattern_data = [] 

80 self.is_dict_mode = False 

81 self.functions = [] 

82 elif callable(initial_functions): 

83 # Single callable: treat as [(callable, {})] 

84 self.pattern_data = [(initial_functions, {})] 

85 self.is_dict_mode = False 

86 self.functions = [(initial_functions, {})] 

87 elif isinstance(initial_functions, tuple) and len(initial_functions) == 2 and callable(initial_functions[0]) and isinstance(initial_functions[1], dict): 

88 # Single tuple (callable, kwargs): treat as [(callable, kwargs)] 

89 self.pattern_data = [initial_functions] 

90 self.is_dict_mode = False 

91 self.functions = [initial_functions] 

92 elif isinstance(initial_functions, list): 

93 self.pattern_data = initial_functions 

94 self.is_dict_mode = False 

95 self.functions = self._normalize_function_list(initial_functions) 

96 elif isinstance(initial_functions, dict): 

97 # Convert any integer keys to string keys for consistency 

98 normalized_dict = {} 

99 for key, value in initial_functions.items(): 

100 str_key = str(key) 

101 normalized_dict[str_key] = self._normalize_function_list(value) if value else [] 

102 

103 self.pattern_data = normalized_dict 

104 self.is_dict_mode = True 

105 

106 # Set selected channel to first key and load its functions 

107 if normalized_dict: 

108 self.selected_channel = next(iter(normalized_dict.keys())) 

109 self.functions = normalized_dict[self.selected_channel] 

110 else: 

111 self.selected_channel = None 

112 self.functions = [] 

113 else: 

114 logger.warning(f"Unknown initial_functions type: {type(initial_functions)}") 

115 self.pattern_data = [] 

116 self.is_dict_mode = False 

117 self.functions = [] 

118 

119 def _normalize_function_list(self, func_list): 

120 """Normalize function list using PatternDataManager.""" 

121 # Handle single tuple (function, kwargs) case - wrap in list 

122 if isinstance(func_list, tuple) and len(func_list) == 2 and callable(func_list[0]) and isinstance(func_list[1], dict): 

123 func_list = [func_list] 

124 # Handle single callable case - wrap in list with empty kwargs 

125 elif callable(func_list): 

126 func_list = [(func_list, {})] 

127 # Handle empty or None case 

128 elif not func_list: 

129 return [] 

130 

131 normalized = [] 

132 for item in func_list: 

133 func, kwargs = self.data_manager.extract_func_and_kwargs(item) 

134 if func: 

135 normalized.append((func, kwargs)) 

136 return normalized 

137 

138 def setup_ui(self): 

139 """Setup the user interface.""" 

140 layout = QVBoxLayout(self) 

141 layout.setContentsMargins(0, 0, 0, 0) 

142 layout.setSpacing(8) 

143 

144 # Header with controls (mirrors Textual TUI) 

145 header_layout = QHBoxLayout() 

146 

147 functions_label = QLabel("Functions") 

148 functions_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; font-weight: bold; font-size: 14px;") 

149 header_layout.addWidget(functions_label) 

150 

151 header_layout.addStretch() 

152 

153 # Control buttons (mirrors Textual TUI) 

154 add_btn = QPushButton("Add") 

155 add_btn.setMaximumWidth(60) 

156 add_btn.setStyleSheet(self._get_button_style()) 

157 add_btn.clicked.connect(self.add_function) 

158 header_layout.addWidget(add_btn) 

159 

160 load_btn = QPushButton("Load") 

161 load_btn.setMaximumWidth(60) 

162 load_btn.setStyleSheet(self._get_button_style()) 

163 load_btn.clicked.connect(self.load_function_pattern) 

164 header_layout.addWidget(load_btn) 

165 

166 save_btn = QPushButton("Save As") 

167 save_btn.setMaximumWidth(80) 

168 save_btn.setStyleSheet(self._get_button_style()) 

169 save_btn.clicked.connect(self.save_function_pattern) 

170 header_layout.addWidget(save_btn) 

171 

172 code_btn = QPushButton("Code") 

173 code_btn.setMaximumWidth(60) 

174 code_btn.setStyleSheet(self._get_button_style()) 

175 code_btn.clicked.connect(self.edit_function_code) 

176 header_layout.addWidget(code_btn) 

177 

178 # Component selection button (mirrors Textual TUI) 

179 self.component_btn = QPushButton(self._get_component_button_text()) 

180 self.component_btn.setMaximumWidth(120) 

181 self.component_btn.setStyleSheet(self._get_button_style()) 

182 self.component_btn.clicked.connect(self.show_component_selection_dialog) 

183 self.component_btn.setEnabled(not self._is_component_button_disabled()) 

184 header_layout.addWidget(self.component_btn) 

185 

186 # Channel navigation buttons (only in dict mode with multiple channels, mirrors Textual TUI) 

187 self.prev_channel_btn = QPushButton("<") 

188 self.prev_channel_btn.setMaximumWidth(30) 

189 self.prev_channel_btn.setStyleSheet(self._get_button_style()) 

190 self.prev_channel_btn.clicked.connect(lambda: self._navigate_channel(-1)) 

191 header_layout.addWidget(self.prev_channel_btn) 

192 

193 self.next_channel_btn = QPushButton(">") 

194 self.next_channel_btn.setMaximumWidth(30) 

195 self.next_channel_btn.setStyleSheet(self._get_button_style()) 

196 self.next_channel_btn.clicked.connect(lambda: self._navigate_channel(1)) 

197 header_layout.addWidget(self.next_channel_btn) 

198 

199 # Update navigation button visibility 

200 self._update_navigation_buttons() 

201 

202 header_layout.addStretch() 

203 layout.addLayout(header_layout) 

204 

205 # Scrollable function list (mirrors Textual TUI) 

206 self.scroll_area = QScrollArea() 

207 self.scroll_area.setWidgetResizable(True) 

208 self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

209 self.scroll_area.setStyleSheet(f""" 

210 QScrollArea {{ 

211 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; 

212 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)}; 

213 border-radius: 4px; 

214 }} 

215 """) 

216 

217 # Function list container 

218 self.function_container = QWidget() 

219 self.function_layout = QVBoxLayout(self.function_container) 

220 self.function_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 

221 self.function_layout.setSpacing(8) 

222 

223 # Populate function list 

224 self._populate_function_list() 

225 

226 self.scroll_area.setWidget(self.function_container) 

227 layout.addWidget(self.scroll_area) 

228 

229 def _get_button_style(self) -> str: 

230 """Get consistent button styling.""" 

231 return """ 

232 QPushButton { 

233 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)}; 

234 color: white; 

235 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)}; 

236 border-radius: 3px; 

237 padding: 6px 12px; 

238 font-size: 11px; 

239 } 

240 QPushButton:hover { 

241 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)}; 

242 } 

243 QPushButton:pressed { 

244 background-color: {self.color_scheme.to_hex(self.color_scheme.button_pressed_bg)}; 

245 } 

246 """ 

247 

248 def _populate_function_list(self): 

249 """Populate function list with panes (mirrors Textual TUI).""" 

250 # Clear existing panes 

251 for pane in self.function_panes: 

252 pane.setParent(None) 

253 self.function_panes.clear() 

254 

255 # Clear layout 

256 while self.function_layout.count(): 

257 child = self.function_layout.takeAt(0) 

258 if child.widget(): 

259 child.widget().setParent(None) 

260 

261 if not self.functions: 

262 # Show empty state 

263 empty_label = QLabel("No functions defined. Click 'Add' to begin.") 

264 empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 

265 empty_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)}; font-style: italic; padding: 20px;") 

266 self.function_layout.addWidget(empty_label) 

267 else: 

268 # Create function panes 

269 for i, func_item in enumerate(self.functions): 

270 pane = FunctionPaneWidget(func_item, i, self.service_adapter, color_scheme=self.color_scheme) 

271 

272 # Connect signals (using actual FunctionPaneWidget signal names) 

273 pane.move_function.connect(self._move_function) 

274 pane.add_function.connect(self._add_function_at_index) 

275 pane.remove_function.connect(self._remove_function) 

276 pane.parameter_changed.connect(self._on_parameter_changed) 

277 

278 self.function_panes.append(pane) 

279 self.function_layout.addWidget(pane) 

280 

281 def setup_connections(self): 

282 """Setup signal/slot connections.""" 

283 pass 

284 

285 def add_function(self): 

286 """Add a new function (mirrors Textual TUI).""" 

287 from openhcs.pyqt_gui.dialogs.function_selector_dialog import FunctionSelectorDialog 

288 

289 # Show function selector dialog (reuses Textual TUI logic) 

290 selected_function = FunctionSelectorDialog.select_function(parent=self) 

291 

292 if selected_function: 

293 # Add function to list (same logic as Textual TUI) 

294 new_func_item = (selected_function, {}) 

295 self.functions.append(new_func_item) 

296 self._update_pattern_data() 

297 self._populate_function_list() 

298 self.function_pattern_changed.emit() 

299 logger.debug(f"Added function: {selected_function.__name__}") 

300 

301 def load_function_pattern(self): 

302 """Load function pattern from file (mirrors Textual TUI).""" 

303 if self.service_adapter: 

304 from openhcs.core.path_cache import PathCacheKey 

305 

306 file_path = self.service_adapter.show_cached_file_dialog( 

307 cache_key=PathCacheKey.FUNCTION_PATTERNS, 

308 title="Load Function Pattern", 

309 file_filter="Function Files (*.func);;All Files (*)", 

310 mode="open" 

311 ) 

312 

313 if file_path: 

314 self._load_function_pattern_from_file(file_path) 

315 

316 def save_function_pattern(self): 

317 """Save function pattern to file (mirrors Textual TUI).""" 

318 if self.service_adapter: 

319 from openhcs.core.path_cache import PathCacheKey 

320 

321 file_path = self.service_adapter.show_cached_file_dialog( 

322 cache_key=PathCacheKey.FUNCTION_PATTERNS, 

323 title="Save Function Pattern", 

324 file_filter="Function Files (*.func);;All Files (*)", 

325 mode="save" 

326 ) 

327 

328 if file_path: 

329 self._save_function_pattern_to_file(file_path) 

330 

331 def edit_function_code(self): 

332 """Edit function pattern as code (simple and direct).""" 

333 logger.debug("Edit function code clicked - opening code editor") 

334 

335 # Validation guard: Check for empty patterns 

336 if not self.functions and not self.pattern_data: 

337 if self.service_adapter: 

338 self.service_adapter.show_info_dialog("No function pattern to edit. Add functions first.") 

339 return 

340 

341 try: 

342 # Update pattern data first 

343 self._update_pattern_data() 

344 

345 # Generate complete Python code with imports 

346 python_code = self._generate_complete_python_code() 

347 

348 # Create simple code editor service 

349 from openhcs.pyqt_gui.services.simple_code_editor import SimpleCodeEditorService 

350 editor_service = SimpleCodeEditorService(self) 

351 

352 # Check if user wants external editor (check environment variable) 

353 use_external = os.environ.get('OPENHCS_USE_EXTERNAL_EDITOR', '').lower() in ('1', 'true', 'yes') 

354 

355 # Launch editor with callback 

356 editor_service.edit_code( 

357 initial_content=python_code, 

358 title="Edit Function Pattern", 

359 callback=self._handle_edited_pattern, 

360 use_external=use_external 

361 ) 

362 

363 except Exception as e: 

364 logger.error(f"Failed to launch code editor: {e}") 

365 if self.service_adapter: 

366 self.service_adapter.show_error_dialog(f"Failed to launch code editor: {str(e)}") 

367 

368 def _generate_complete_python_code(self) -> str: 

369 """Generate complete Python code with imports (following debug module approach).""" 

370 # Use complete function pattern code generation from pickle_to_python 

371 from openhcs.debug.pickle_to_python import generate_complete_function_pattern_code 

372 

373 # Disable clean_mode to preserve all parameters when same function appears multiple times 

374 # This prevents parsing issues when the same function has different parameter sets 

375 return generate_complete_function_pattern_code(self.pattern_data, clean_mode=False) 

376 

377 def _handle_edited_pattern(self, edited_code: str) -> None: 

378 """Handle the edited pattern code from code editor.""" 

379 try: 

380 # Ensure we have a string 

381 if not isinstance(edited_code, str): 

382 logger.error(f"Expected string, got {type(edited_code)}: {edited_code}") 

383 if self.service_adapter: 

384 self.service_adapter.show_error_dialog("Invalid code format received from editor") 

385 return 

386 

387 # CRITICAL FIX: Execute code with lazy dataclass constructor patching to preserve None vs concrete distinction 

388 namespace = {} 

389 with self._patch_lazy_constructors(): 

390 exec(edited_code, namespace) 

391 

392 # Get the pattern from the namespace 

393 if 'pattern' in namespace: 

394 new_pattern = namespace['pattern'] 

395 self._apply_edited_pattern(new_pattern) 

396 else: 

397 if self.service_adapter: 

398 self.service_adapter.show_error_dialog("No 'pattern = ...' assignment found in edited code") 

399 

400 except SyntaxError as e: 

401 if self.service_adapter: 

402 self.service_adapter.show_error_dialog(f"Invalid Python syntax: {e}") 

403 except Exception as e: 

404 logger.error(f"Failed to parse edited pattern: {e}") 

405 if self.service_adapter: 

406 self.service_adapter.show_error_dialog(f"Failed to parse edited pattern: {str(e)}") 

407 

408 def _apply_edited_pattern(self, new_pattern): 

409 """Apply the edited pattern back to the UI.""" 

410 try: 

411 if self.is_dict_mode: 

412 if isinstance(new_pattern, dict): 

413 self.pattern_data = new_pattern 

414 # Update current channel if it exists in new pattern 

415 if self.selected_channel and self.selected_channel in new_pattern: 

416 self.functions = self._normalize_function_list(new_pattern[self.selected_channel]) 

417 else: 

418 # Select first channel 

419 if new_pattern: 

420 self.selected_channel = next(iter(new_pattern)) 

421 self.functions = self._normalize_function_list(new_pattern[self.selected_channel]) 

422 else: 

423 self.functions = [] 

424 else: 

425 raise ValueError("Expected dict pattern for dict mode") 

426 else: 

427 if isinstance(new_pattern, list): 

428 self.pattern_data = new_pattern 

429 self.functions = self._normalize_function_list(new_pattern) 

430 elif callable(new_pattern): 

431 # Single callable: treat as [(callable, {})] 

432 self.pattern_data = [(new_pattern, {})] 

433 self.functions = [(new_pattern, {})] 

434 elif isinstance(new_pattern, tuple) and len(new_pattern) == 2 and callable(new_pattern[0]) and isinstance(new_pattern[1], dict): 

435 # Single tuple (callable, kwargs): treat as [(callable, kwargs)] 

436 self.pattern_data = [new_pattern] 

437 self.functions = [new_pattern] 

438 else: 

439 raise ValueError(f"Expected list, callable, or (callable, dict) tuple pattern for list mode, got {type(new_pattern)}") 

440 

441 # Refresh the UI and notify of changes 

442 self._populate_function_list() 

443 self.function_pattern_changed.emit() 

444 

445 except Exception as e: 

446 if self.service_adapter: 

447 self.service_adapter.show_error_dialog(f"Failed to apply edited pattern: {str(e)}") 

448 

449 def _patch_lazy_constructors(self): 

450 """Context manager that patches lazy dataclass constructors to preserve None vs concrete distinction.""" 

451 from contextlib import contextmanager 

452 from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService 

453 import dataclasses 

454 

455 @contextmanager 

456 def patch_context(): 

457 # Store original constructors 

458 original_constructors = {} 

459 

460 # Find all lazy dataclass types that need patching 

461 from openhcs.core.config import LazyZarrConfig, LazyStepMaterializationConfig, LazyWellFilterConfig 

462 lazy_types = [LazyZarrConfig, LazyStepMaterializationConfig, LazyWellFilterConfig] 

463 

464 # Add any other lazy types that might be used 

465 for lazy_type in lazy_types: 

466 if LazyDefaultPlaceholderService.has_lazy_resolution(lazy_type): 

467 # Store original constructor 

468 original_constructors[lazy_type] = lazy_type.__init__ 

469 

470 # Create patched constructor that uses raw values 

471 def create_patched_init(original_init, dataclass_type): 

472 def patched_init(self, **kwargs): 

473 # Use raw value approach instead of calling original constructor 

474 # This prevents lazy resolution during code execution 

475 for field in dataclasses.fields(dataclass_type): 

476 value = kwargs.get(field.name, None) 

477 object.__setattr__(self, field.name, value) 

478 

479 # Initialize any required lazy dataclass attributes 

480 if hasattr(dataclass_type, '_is_lazy_dataclass'): 

481 object.__setattr__(self, '_is_lazy_dataclass', True) 

482 

483 return patched_init 

484 

485 # Apply the patch 

486 lazy_type.__init__ = create_patched_init(original_constructors[lazy_type], lazy_type) 

487 

488 try: 

489 yield 

490 finally: 

491 # Restore original constructors 

492 for lazy_type, original_init in original_constructors.items(): 

493 lazy_type.__init__ = original_init 

494 

495 return patch_context() 

496 

497 def _move_function(self, index, direction): 

498 """Move function up or down.""" 

499 if 0 <= index < len(self.functions): 

500 new_index = index + direction 

501 if 0 <= new_index < len(self.functions): 

502 # Swap functions 

503 self.functions[index], self.functions[new_index] = self.functions[new_index], self.functions[index] 

504 self._update_pattern_data() 

505 self._populate_function_list() 

506 self.function_pattern_changed.emit() 

507 

508 def _add_function_at_index(self, index): 

509 """Add function at specific index (mirrors Textual TUI).""" 

510 from openhcs.pyqt_gui.dialogs.function_selector_dialog import FunctionSelectorDialog 

511 

512 # Show function selector dialog (reuses Textual TUI logic) 

513 selected_function = FunctionSelectorDialog.select_function(parent=self) 

514 

515 if selected_function: 

516 # Insert function at specific index (same logic as Textual TUI) 

517 new_func_item = (selected_function, {}) 

518 self.functions.insert(index, new_func_item) 

519 self._update_pattern_data() 

520 self._populate_function_list() 

521 self.function_pattern_changed.emit() 

522 logger.debug(f"Added function at index {index}: {selected_function.__name__}") 

523 

524 def _remove_function(self, index): 

525 """Remove function at index.""" 

526 if 0 <= index < len(self.functions): 

527 self.functions.pop(index) 

528 self._update_pattern_data() 

529 self._populate_function_list() 

530 self.function_pattern_changed.emit() 

531 

532 def _on_parameter_changed(self, index, param_name, value): 

533 """Handle parameter change from function pane.""" 

534 if 0 <= index < len(self.functions): 

535 func, kwargs = self.functions[index] 

536 kwargs[param_name] = value 

537 self.functions[index] = (func, kwargs) 

538 self._update_pattern_data() 

539 self.function_pattern_changed.emit() 

540 

541 

542 

543 def _load_function_pattern_from_file(self, file_path): 

544 """Load function pattern from file.""" 

545 # TODO: Implement file loading 

546 logger.debug(f"Load function pattern from {file_path} - TODO: implement") 

547 

548 def _save_function_pattern_to_file(self, file_path): 

549 """Save function pattern to file.""" 

550 # TODO: Implement file saving 

551 logger.debug(f"Save function pattern to {file_path} - TODO: implement") 

552 

553 def get_current_functions(self): 

554 """Get current function list.""" 

555 return self.functions.copy() 

556 

557 @property 

558 def current_pattern(self): 

559 """Get the current pattern data (for parent widgets to access).""" 

560 self._update_pattern_data() # Ensure it's up to date 

561 

562 # Migration fix: Convert any integer keys to string keys for compatibility 

563 # with pattern detection system which always uses string component values 

564 if isinstance(self.pattern_data, dict): 

565 migrated_pattern = {} 

566 for key, value in self.pattern_data.items(): 

567 str_key = str(key) 

568 migrated_pattern[str_key] = value 

569 return migrated_pattern 

570 

571 return self.pattern_data 

572 

573 def set_functions(self, functions): 

574 """Set function list and refresh display.""" 

575 self.functions = functions.copy() if functions else [] 

576 self._update_pattern_data() 

577 self._populate_function_list() 

578 

579 def _get_component_button_text(self) -> str: 

580 """Get text for the component selection button (mirrors Textual TUI).""" 

581 if self.current_group_by is None or self.current_group_by == GroupBy.NONE: 

582 return "Component: None" 

583 

584 # Use the existing _get_enum_display_text function for consistent enum display handling 

585 component_type = _get_enum_display_text(self.current_group_by).title() 

586 

587 if self.is_dict_mode and self.selected_channel is not None: 

588 # Try to get metadata name for the selected component 

589 display_name = self._get_component_display_name(self.selected_channel) 

590 return f"{component_type}: {display_name}" 

591 return f"{component_type}: None" 

592 

593 def _get_component_display_name(self, component_key: str) -> str: 

594 """Get display name for component key, using metadata if available (mirrors Textual TUI).""" 

595 orchestrator = self._get_current_orchestrator() 

596 if orchestrator and self.current_group_by: 

597 metadata_name = orchestrator.metadata_cache.get_component_metadata(self.current_group_by, component_key) 

598 if metadata_name: 

599 return metadata_name 

600 return component_key 

601 

602 def _is_component_button_disabled(self) -> bool: 

603 """Check if component selection button should be disabled (mirrors Textual TUI).""" 

604 return ( 

605 self.current_group_by is None or 

606 self.current_group_by == GroupBy.NONE or 

607 (self.current_variable_components and 

608 self.current_group_by.value in [vc.value for vc in self.current_variable_components]) 

609 ) 

610 

611 def show_component_selection_dialog(self): 

612 """Show the component selection dialog (mirrors Textual TUI).""" 

613 from openhcs.pyqt_gui.dialogs.group_by_selector_dialog import GroupBySelectorDialog 

614 

615 # Check if component selection is disabled 

616 if self._is_component_button_disabled(): 

617 logger.debug("Component selection is disabled") 

618 return 

619 

620 # Get available components from orchestrator using current group_by - MUST exist, no fallbacks 

621 orchestrator = self._get_current_orchestrator() 

622 

623 available_components = orchestrator.get_component_keys(self.current_group_by) 

624 assert available_components, f"No {self.current_group_by.value} values found in current plate" 

625 

626 # Get current selection from pattern data (mirrors Textual TUI logic) 

627 selected_components = self._get_current_component_selection() 

628 

629 # Show group by selector dialog (reuses Textual TUI logic) 

630 result = GroupBySelectorDialog.select_components( 

631 available_components=available_components, 

632 selected_components=selected_components, 

633 group_by=self.current_group_by, 

634 orchestrator=orchestrator, 

635 parent=self 

636 ) 

637 

638 if result is not None: 

639 self._handle_component_selection(result) 

640 

641 def _get_current_orchestrator(self): 

642 """Get current orchestrator instance - MUST exist, no fallbacks allowed.""" 

643 # Use stored main window reference to get plate manager 

644 main_window = self.main_window 

645 plate_manager_window = main_window.floating_windows['plate_manager'] 

646 

647 # Find the actual plate manager widget 

648 plate_manager_widget = None 

649 for child in plate_manager_window.findChildren(QWidget): 

650 if hasattr(child, 'orchestrators') and hasattr(child, 'selected_plate_path'): 

651 plate_manager_widget = child 

652 break 

653 

654 # Get current plate from plate manager's selection 

655 current_plate = plate_manager_widget.selected_plate_path 

656 orchestrator = plate_manager_widget.orchestrators[current_plate] 

657 

658 # Orchestrator must be initialized 

659 assert orchestrator.is_initialized(), f"Orchestrator for plate {current_plate} is not initialized" 

660 

661 return orchestrator 

662 

663 def _get_current_component_selection(self): 

664 """Get current component selection from pattern data (mirrors Textual TUI logic).""" 

665 # If in dict mode, return the keys of the dict as the current selection (sorted) 

666 if self.is_dict_mode and isinstance(self.pattern_data, dict): 

667 return sorted(list(self.pattern_data.keys())) 

668 

669 # If not in dict mode, check the cache (sorted) 

670 cached_selection = self.component_selections.get(self.current_group_by, []) 

671 return sorted(cached_selection) 

672 

673 def _handle_component_selection(self, new_components): 

674 """Handle component selection result (mirrors Textual TUI).""" 

675 # Save selection to cache for current group_by 

676 if self.current_group_by is not None and self.current_group_by != GroupBy.NONE: 

677 self.component_selections[self.current_group_by] = new_components 

678 logger.debug(f"Step '{self.step_identifier}': Cached selection for {self.current_group_by.value}: {new_components}") 

679 

680 # Update pattern structure based on component selection (mirrors Textual TUI) 

681 self._update_components(new_components) 

682 

683 # Update component button text and navigation 

684 self._refresh_component_button() 

685 logger.debug(f"Updated components: {new_components}") 

686 

687 def _update_components(self, new_components): 

688 """Update function pattern structure based on component selection (mirrors Textual TUI).""" 

689 # Sort new components for consistent ordering 

690 if new_components: 

691 new_components = sorted(new_components) 

692 

693 if not new_components: 

694 # No components selected - revert to list mode 

695 if self.is_dict_mode: 

696 # Save current functions to list mode 

697 self.pattern_data = self.functions 

698 self.is_dict_mode = False 

699 self.selected_channel = None 

700 logger.debug("Reverted to list mode (no components selected)") 

701 else: 

702 # Use component strings directly - no conversion needed 

703 component_keys = new_components 

704 

705 # Components selected - ensure dict mode 

706 if not self.is_dict_mode: 

707 # Convert to dict mode 

708 current_functions = self.functions 

709 self.pattern_data = {component_keys[0]: current_functions} 

710 self.is_dict_mode = True 

711 self.selected_channel = component_keys[0] 

712 

713 # Add other components with empty functions 

714 for component_key in component_keys[1:]: 

715 self.pattern_data[component_key] = [] 

716 else: 

717 # Already in dict mode - update components 

718 old_pattern = self.pattern_data.copy() if isinstance(self.pattern_data, dict) else {} 

719 

720 # Create a persistent storage for deselected components (mirrors Textual TUI) 

721 if not hasattr(self, '_deselected_components_storage'): 

722 self._deselected_components_storage = {} 

723 

724 # Save currently deselected components to storage 

725 for old_key, old_functions in old_pattern.items(): 

726 if old_key not in component_keys: 

727 self._deselected_components_storage[old_key] = old_functions 

728 logger.debug(f"Saved {len(old_functions)} functions for deselected component {old_key}") 

729 

730 new_pattern = {} 

731 

732 # Restore functions for components (from current pattern or storage) 

733 for component_key in component_keys: 

734 if component_key in old_pattern: 

735 # Component was already selected - keep its functions 

736 new_pattern[component_key] = old_pattern[component_key] 

737 elif component_key in self._deselected_components_storage: 

738 # Component was previously deselected - restore its functions 

739 new_pattern[component_key] = self._deselected_components_storage[component_key] 

740 logger.debug(f"Restored {len(new_pattern[component_key])} functions for reselected component {component_key}") 

741 else: 

742 # New component - start with empty functions 

743 new_pattern[component_key] = [] 

744 

745 self.pattern_data = new_pattern 

746 

747 # Update selected channel if current one is no longer available 

748 if self.selected_channel not in component_keys: 

749 self.selected_channel = component_keys[0] 

750 self.functions = new_pattern[self.selected_channel] 

751 

752 # Update UI to reflect changes 

753 self._populate_function_list() 

754 self._update_navigation_buttons() 

755 

756 def _refresh_component_button(self): 

757 """Refresh the component button text and state (mirrors Textual TUI).""" 

758 if hasattr(self, 'component_btn'): 

759 self.component_btn.setText(self._get_component_button_text()) 

760 self.component_btn.setEnabled(not self._is_component_button_disabled()) 

761 

762 # Also update navigation buttons when component button is refreshed 

763 self._update_navigation_buttons() 

764 

765 

766 

767 def _update_navigation_buttons(self): 

768 """Update visibility of channel navigation buttons (mirrors Textual TUI).""" 

769 if hasattr(self, 'prev_channel_btn') and hasattr(self, 'next_channel_btn'): 

770 # Show navigation buttons only in dict mode with multiple channels 

771 show_nav = (self.is_dict_mode and 

772 isinstance(self.pattern_data, dict) and 

773 len(self.pattern_data) > 1) 

774 

775 self.prev_channel_btn.setVisible(show_nav) 

776 self.next_channel_btn.setVisible(show_nav) 

777 

778 def _navigate_channel(self, direction: int): 

779 """Navigate to next/previous channel (with looping, mirrors Textual TUI).""" 

780 if not self.is_dict_mode or not isinstance(self.pattern_data, dict): 

781 return 

782 

783 channels = sorted(self.pattern_data.keys()) 

784 if len(channels) <= 1: 

785 return 

786 

787 try: 

788 current_index = channels.index(self.selected_channel) 

789 new_index = (current_index + direction) % len(channels) 

790 new_channel = channels[new_index] 

791 

792 self._switch_to_channel(new_channel) 

793 logger.debug(f"Navigated to channel {new_channel}") 

794 except (ValueError, IndexError): 

795 logger.warning(f"Failed to navigate channels: current={self.selected_channel}, channels={channels}") 

796 

797 def _switch_to_channel(self, channel: str): 

798 """Switch to editing functions for a specific channel (mirrors Textual TUI).""" 

799 if not self.is_dict_mode: 

800 return 

801 

802 # Save current functions first 

803 old_channel = self.selected_channel 

804 logger.debug(f"Switching from channel {old_channel} to {channel}") 

805 

806 self._update_pattern_data() 

807 

808 # Switch to new channel 

809 self.selected_channel = channel 

810 if isinstance(self.pattern_data, dict): 

811 self.functions = self.pattern_data.get(channel, []) 

812 logger.debug(f"Loaded {len(self.functions)} functions for channel {channel}") 

813 else: 

814 self.functions = [] 

815 

816 # Update UI 

817 self._refresh_component_button() 

818 self._populate_function_list() 

819 

820 def _update_pattern_data(self): 

821 """Update pattern_data based on current functions and mode (mirrors Textual TUI).""" 

822 if self.is_dict_mode and self.selected_channel is not None: 

823 # Save current functions to the selected channel 

824 if not isinstance(self.pattern_data, dict): 

825 self.pattern_data = {} 

826 logger.debug(f"Saving {len(self.functions)} functions to channel {self.selected_channel}") 

827 self.pattern_data[self.selected_channel] = self.functions.copy() 

828 else: 

829 # List mode - pattern_data is just the functions list 

830 self.pattern_data = self.functions