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

422 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +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.textual_tui.services.function_registry_service import FunctionRegistryService 

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 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class FunctionListEditorWidget(QWidget): 

29 """ 

30 Function list editor widget that mirrors Textual TUI functionality. 

31  

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

33 and Load/Save/Code functionality. 

34 """ 

35 

36 # Signals 

37 function_pattern_changed = pyqtSignal() 

38 

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

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

41 super().__init__(parent) 

42 

43 # Initialize color scheme 

44 self.color_scheme = color_scheme or PyQt6ColorScheme() 

45 

46 # Initialize services (reuse existing business logic) 

47 self.registry_service = FunctionRegistryService() 

48 self.data_manager = PatternDataManager() 

49 self.service_adapter = service_adapter 

50 

51 # Step identifier for cache isolation 

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

53 

54 # Step configuration properties (mirrors Textual TUI) 

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

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

57 self.selected_channel = None # Currently selected channel 

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

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

60 

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

62 self.component_selections = {} 

63 

64 # Initialize pattern data and mode 

65 self._initialize_pattern_data(initial_functions) 

66 

67 # UI components 

68 self.function_panes = [] 

69 

70 self.setup_ui() 

71 self.setup_connections() 

72 

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

74 

75 def _initialize_pattern_data(self, initial_functions): 

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

77 if initial_functions is None: 

78 self.pattern_data = [] 

79 self.is_dict_mode = False 

80 self.functions = [] 

81 elif callable(initial_functions): 

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

83 self.is_dict_mode = False 

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

85 elif isinstance(initial_functions, list): 

86 self.pattern_data = initial_functions 

87 self.is_dict_mode = False 

88 self.functions = self._normalize_function_list(initial_functions) 

89 elif isinstance(initial_functions, dict): 

90 # Convert any integer keys to string keys for consistency 

91 normalized_dict = {} 

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

93 str_key = str(key) 

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

95 

96 self.pattern_data = normalized_dict 

97 self.is_dict_mode = True 

98 

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

100 if normalized_dict: 

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

102 self.functions = normalized_dict[self.selected_channel] 

103 else: 

104 self.selected_channel = None 

105 self.functions = [] 

106 else: 

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

108 self.pattern_data = [] 

109 self.is_dict_mode = False 

110 self.functions = [] 

111 

112 def _normalize_function_list(self, func_list): 

113 """Normalize function list using PatternDataManager.""" 

114 normalized = [] 

115 for item in func_list: 

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

117 if func: 

118 normalized.append((func, kwargs)) 

119 return normalized 

120 

121 def setup_ui(self): 

122 """Setup the user interface.""" 

123 layout = QVBoxLayout(self) 

124 layout.setContentsMargins(0, 0, 0, 0) 

125 layout.setSpacing(8) 

126 

127 # Header with controls (mirrors Textual TUI) 

128 header_layout = QHBoxLayout() 

129 

130 functions_label = QLabel("Functions") 

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

132 header_layout.addWidget(functions_label) 

133 

134 header_layout.addStretch() 

135 

136 # Control buttons (mirrors Textual TUI) 

137 add_btn = QPushButton("Add") 

138 add_btn.setMaximumWidth(60) 

139 add_btn.setStyleSheet(self._get_button_style()) 

140 add_btn.clicked.connect(self.add_function) 

141 header_layout.addWidget(add_btn) 

142 

143 load_btn = QPushButton("Load") 

144 load_btn.setMaximumWidth(60) 

145 load_btn.setStyleSheet(self._get_button_style()) 

146 load_btn.clicked.connect(self.load_function_pattern) 

147 header_layout.addWidget(load_btn) 

148 

149 save_btn = QPushButton("Save As") 

150 save_btn.setMaximumWidth(80) 

151 save_btn.setStyleSheet(self._get_button_style()) 

152 save_btn.clicked.connect(self.save_function_pattern) 

153 header_layout.addWidget(save_btn) 

154 

155 code_btn = QPushButton("Code") 

156 code_btn.setMaximumWidth(60) 

157 code_btn.setStyleSheet(self._get_button_style()) 

158 code_btn.clicked.connect(self.edit_function_code) 

159 header_layout.addWidget(code_btn) 

160 

161 # Component selection button (mirrors Textual TUI) 

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

163 self.component_btn.setMaximumWidth(120) 

164 self.component_btn.setStyleSheet(self._get_button_style()) 

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

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

167 header_layout.addWidget(self.component_btn) 

168 

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

170 self.prev_channel_btn = QPushButton("<") 

171 self.prev_channel_btn.setMaximumWidth(30) 

172 self.prev_channel_btn.setStyleSheet(self._get_button_style()) 

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

174 header_layout.addWidget(self.prev_channel_btn) 

175 

176 self.next_channel_btn = QPushButton(">") 

177 self.next_channel_btn.setMaximumWidth(30) 

178 self.next_channel_btn.setStyleSheet(self._get_button_style()) 

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

180 header_layout.addWidget(self.next_channel_btn) 

181 

182 # Update navigation button visibility 

183 self._update_navigation_buttons() 

184 

185 header_layout.addStretch() 

186 layout.addLayout(header_layout) 

187 

188 # Scrollable function list (mirrors Textual TUI) 

189 self.scroll_area = QScrollArea() 

190 self.scroll_area.setWidgetResizable(True) 

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

192 self.scroll_area.setStyleSheet(f""" 

193 QScrollArea {{ 

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

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

196 border-radius: 4px; 

197 }} 

198 """) 

199 

200 # Function list container 

201 self.function_container = QWidget() 

202 self.function_layout = QVBoxLayout(self.function_container) 

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

204 self.function_layout.setSpacing(8) 

205 

206 # Populate function list 

207 self._populate_function_list() 

208 

209 self.scroll_area.setWidget(self.function_container) 

210 layout.addWidget(self.scroll_area) 

211 

212 def _get_button_style(self) -> str: 

213 """Get consistent button styling.""" 

214 return """ 

215 QPushButton { 

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

217 color: white; 

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

219 border-radius: 3px; 

220 padding: 6px 12px; 

221 font-size: 11px; 

222 } 

223 QPushButton:hover { 

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

225 } 

226 QPushButton:pressed { 

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

228 } 

229 """ 

230 

231 def _populate_function_list(self): 

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

233 # Clear existing panes 

234 for pane in self.function_panes: 

235 pane.setParent(None) 

236 self.function_panes.clear() 

237 

238 # Clear layout 

239 while self.function_layout.count(): 

240 child = self.function_layout.takeAt(0) 

241 if child.widget(): 

242 child.widget().setParent(None) 

243 

244 if not self.functions: 

245 # Show empty state 

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

247 empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 

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

249 self.function_layout.addWidget(empty_label) 

250 else: 

251 # Create function panes 

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

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

254 

255 # Connect signals (using actual FunctionPaneWidget signal names) 

256 pane.move_function.connect(self._move_function) 

257 pane.add_function.connect(self._add_function_at_index) 

258 pane.remove_function.connect(self._remove_function) 

259 pane.parameter_changed.connect(self._on_parameter_changed) 

260 

261 self.function_panes.append(pane) 

262 self.function_layout.addWidget(pane) 

263 

264 def setup_connections(self): 

265 """Setup signal/slot connections.""" 

266 pass 

267 

268 def add_function(self): 

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

270 from openhcs.pyqt_gui.dialogs.function_selector_dialog import FunctionSelectorDialog 

271 

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

273 selected_function = FunctionSelectorDialog.select_function(parent=self) 

274 

275 if selected_function: 

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

277 new_func_item = (selected_function, {}) 

278 self.functions.append(new_func_item) 

279 self._update_pattern_data() 

280 self._populate_function_list() 

281 self.function_pattern_changed.emit() 

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

283 

284 def load_function_pattern(self): 

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

286 if self.service_adapter: 

287 from openhcs.core.path_cache import PathCacheKey 

288 

289 file_path = self.service_adapter.show_cached_file_dialog( 

290 cache_key=PathCacheKey.FUNCTION_PATTERNS, 

291 title="Load Function Pattern", 

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

293 mode="open" 

294 ) 

295 

296 if file_path: 

297 self._load_function_pattern_from_file(file_path) 

298 

299 def save_function_pattern(self): 

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

301 if self.service_adapter: 

302 from openhcs.core.path_cache import PathCacheKey 

303 

304 file_path = self.service_adapter.show_cached_file_dialog( 

305 cache_key=PathCacheKey.FUNCTION_PATTERNS, 

306 title="Save Function Pattern", 

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

308 mode="save" 

309 ) 

310 

311 if file_path: 

312 self._save_function_pattern_to_file(file_path) 

313 

314 def edit_function_code(self): 

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

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

317 

318 # Validation guard: Check for empty patterns 

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

320 if self.service_adapter: 

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

322 return 

323 

324 try: 

325 # Update pattern data first 

326 self._update_pattern_data() 

327 

328 # Generate complete Python code with imports 

329 python_code = self._generate_complete_python_code() 

330 

331 # Create simple code editor service 

332 from openhcs.pyqt_gui.services.simple_code_editor import SimpleCodeEditorService 

333 editor_service = SimpleCodeEditorService(self) 

334 

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

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

337 

338 # Launch editor with callback 

339 editor_service.edit_code( 

340 initial_content=python_code, 

341 title="Edit Function Pattern", 

342 callback=self._handle_edited_pattern, 

343 use_external=use_external 

344 ) 

345 

346 except Exception as e: 

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

348 if self.service_adapter: 

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

350 

351 def _generate_complete_python_code(self) -> str: 

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

353 # Use complete function pattern code generation from pickle_to_python 

354 from openhcs.debug.pickle_to_python import generate_complete_function_pattern_code 

355 

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

357 

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

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

360 try: 

361 # Ensure we have a string 

362 if not isinstance(edited_code, str): 

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

364 if self.service_adapter: 

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

366 return 

367 

368 # Execute the code (it has all necessary imports) 

369 namespace = {} 

370 exec(edited_code, namespace) 

371 

372 # Get the pattern from the namespace 

373 if 'pattern' in namespace: 

374 new_pattern = namespace['pattern'] 

375 self._apply_edited_pattern(new_pattern) 

376 else: 

377 if self.service_adapter: 

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

379 

380 except SyntaxError as e: 

381 if self.service_adapter: 

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

383 except Exception as e: 

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

385 if self.service_adapter: 

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

387 

388 def _apply_edited_pattern(self, new_pattern): 

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

390 try: 

391 if self.is_dict_mode: 

392 if isinstance(new_pattern, dict): 

393 self.pattern_data = new_pattern 

394 # Update current channel if it exists in new pattern 

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

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

397 else: 

398 # Select first channel 

399 if new_pattern: 

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

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

402 else: 

403 self.functions = [] 

404 else: 

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

406 else: 

407 if isinstance(new_pattern, list): 

408 self.pattern_data = new_pattern 

409 self.functions = self._normalize_function_list(new_pattern) 

410 else: 

411 raise ValueError("Expected list pattern for list mode") 

412 

413 # Refresh the UI and notify of changes 

414 self._populate_function_list() 

415 self.function_pattern_changed.emit() 

416 

417 except Exception as e: 

418 if self.service_adapter: 

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

420 

421 def _move_function(self, index, direction): 

422 """Move function up or down.""" 

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

424 new_index = index + direction 

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

426 # Swap functions 

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

428 self._update_pattern_data() 

429 self._populate_function_list() 

430 self.function_pattern_changed.emit() 

431 

432 def _add_function_at_index(self, index): 

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

434 from openhcs.pyqt_gui.dialogs.function_selector_dialog import FunctionSelectorDialog 

435 

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

437 selected_function = FunctionSelectorDialog.select_function(parent=self) 

438 

439 if selected_function: 

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

441 new_func_item = (selected_function, {}) 

442 self.functions.insert(index, new_func_item) 

443 self._update_pattern_data() 

444 self._populate_function_list() 

445 self.function_pattern_changed.emit() 

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

447 

448 def _remove_function(self, index): 

449 """Remove function at index.""" 

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

451 self.functions.pop(index) 

452 self._update_pattern_data() 

453 self._populate_function_list() 

454 self.function_pattern_changed.emit() 

455 

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

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

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

459 func, kwargs = self.functions[index] 

460 kwargs[param_name] = value 

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

462 self._update_pattern_data() 

463 self.function_pattern_changed.emit() 

464 

465 

466 

467 def _load_function_pattern_from_file(self, file_path): 

468 """Load function pattern from file.""" 

469 # TODO: Implement file loading 

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

471 

472 def _save_function_pattern_to_file(self, file_path): 

473 """Save function pattern to file.""" 

474 # TODO: Implement file saving 

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

476 

477 def get_current_functions(self): 

478 """Get current function list.""" 

479 return self.functions.copy() 

480 

481 @property 

482 def current_pattern(self): 

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

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

485 

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

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

488 if isinstance(self.pattern_data, dict): 

489 migrated_pattern = {} 

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

491 str_key = str(key) 

492 migrated_pattern[str_key] = value 

493 return migrated_pattern 

494 

495 return self.pattern_data 

496 

497 def set_functions(self, functions): 

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

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

500 self._update_pattern_data() 

501 self._populate_function_list() 

502 

503 def _get_component_button_text(self) -> str: 

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

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

506 return "Component: None" 

507 

508 # Use group_by.value.title() for dynamic component type display 

509 component_type = self.current_group_by.value.title() 

510 

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

512 # Try to get metadata name for the selected component 

513 display_name = self._get_component_display_name(self.selected_channel) 

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

515 return f"{component_type}: None" 

516 

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

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

519 orchestrator = self._get_current_orchestrator() 

520 if orchestrator and self.current_group_by: 

521 metadata_name = orchestrator.get_component_metadata(self.current_group_by, component_key) 

522 if metadata_name: 

523 return metadata_name 

524 return component_key 

525 

526 def _is_component_button_disabled(self) -> bool: 

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

528 return ( 

529 self.current_group_by is None or 

530 self.current_group_by == GroupBy.NONE or 

531 (self.current_variable_components and 

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

533 ) 

534 

535 def show_component_selection_dialog(self): 

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

537 from openhcs.pyqt_gui.dialogs.group_by_selector_dialog import GroupBySelectorDialog 

538 

539 # Check if component selection is disabled 

540 if self._is_component_button_disabled(): 

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

542 return 

543 

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

545 orchestrator = self._get_current_orchestrator() 

546 

547 available_components = orchestrator.get_component_keys(self.current_group_by) 

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

549 

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

551 selected_components = self._get_current_component_selection() 

552 

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

554 result = GroupBySelectorDialog.select_components( 

555 available_components=available_components, 

556 selected_components=selected_components, 

557 component_type=self.current_group_by.value, 

558 orchestrator=orchestrator, 

559 parent=self 

560 ) 

561 

562 if result is not None: 

563 self._handle_component_selection(result) 

564 

565 def _get_current_orchestrator(self): 

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

567 # Use stored main window reference to get plate manager 

568 main_window = self.main_window 

569 plate_manager_window = main_window.floating_windows['plate_manager'] 

570 

571 # Find the actual plate manager widget 

572 plate_manager_widget = None 

573 for child in plate_manager_window.findChildren(QWidget): 

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

575 plate_manager_widget = child 

576 break 

577 

578 # Get current plate from plate manager's selection 

579 current_plate = plate_manager_widget.selected_plate_path 

580 orchestrator = plate_manager_widget.orchestrators[current_plate] 

581 

582 # Orchestrator must be initialized 

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

584 

585 return orchestrator 

586 

587 def _get_current_component_selection(self): 

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

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

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

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

592 

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

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

595 return sorted(cached_selection) 

596 

597 def _handle_component_selection(self, new_components): 

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

599 # Save selection to cache for current group_by 

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

601 self.component_selections[self.current_group_by] = new_components 

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

603 

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

605 self._update_components(new_components) 

606 

607 # Update component button text and navigation 

608 self._refresh_component_button() 

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

610 

611 def _update_components(self, new_components): 

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

613 # Sort new components for consistent ordering 

614 if new_components: 

615 new_components = sorted(new_components) 

616 

617 if not new_components: 

618 # No components selected - revert to list mode 

619 if self.is_dict_mode: 

620 # Save current functions to list mode 

621 self.pattern_data = self.functions 

622 self.is_dict_mode = False 

623 self.selected_channel = None 

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

625 else: 

626 # Use component strings directly - no conversion needed 

627 component_keys = new_components 

628 

629 # Components selected - ensure dict mode 

630 if not self.is_dict_mode: 

631 # Convert to dict mode 

632 current_functions = self.functions 

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

634 self.is_dict_mode = True 

635 self.selected_channel = component_keys[0] 

636 

637 # Add other components with empty functions 

638 for component_key in component_keys[1:]: 

639 self.pattern_data[component_key] = [] 

640 else: 

641 # Already in dict mode - update components 

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

643 

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

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

646 self._deselected_components_storage = {} 

647 

648 # Save currently deselected components to storage 

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

650 if old_key not in component_keys: 

651 self._deselected_components_storage[old_key] = old_functions 

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

653 

654 new_pattern = {} 

655 

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

657 for component_key in component_keys: 

658 if component_key in old_pattern: 

659 # Component was already selected - keep its functions 

660 new_pattern[component_key] = old_pattern[component_key] 

661 elif component_key in self._deselected_components_storage: 

662 # Component was previously deselected - restore its functions 

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

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

665 else: 

666 # New component - start with empty functions 

667 new_pattern[component_key] = [] 

668 

669 self.pattern_data = new_pattern 

670 

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

672 if self.selected_channel not in component_keys: 

673 self.selected_channel = component_keys[0] 

674 self.functions = new_pattern[self.selected_channel] 

675 

676 # Update UI to reflect changes 

677 self._populate_function_list() 

678 self._update_navigation_buttons() 

679 

680 def _refresh_component_button(self): 

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

682 if hasattr(self, 'component_btn'): 

683 self.component_btn.setText(self._get_component_button_text()) 

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

685 

686 # Also update navigation buttons when component button is refreshed 

687 self._update_navigation_buttons() 

688 

689 def _update_navigation_buttons(self): 

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

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

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

693 show_nav = (self.is_dict_mode and 

694 isinstance(self.pattern_data, dict) and 

695 len(self.pattern_data) > 1) 

696 

697 self.prev_channel_btn.setVisible(show_nav) 

698 self.next_channel_btn.setVisible(show_nav) 

699 

700 def _navigate_channel(self, direction: int): 

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

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

703 return 

704 

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

706 if len(channels) <= 1: 

707 return 

708 

709 try: 

710 current_index = channels.index(self.selected_channel) 

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

712 new_channel = channels[new_index] 

713 

714 self._switch_to_channel(new_channel) 

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

716 except (ValueError, IndexError): 

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

718 

719 def _switch_to_channel(self, channel: str): 

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

721 if not self.is_dict_mode: 

722 return 

723 

724 # Save current functions first 

725 old_channel = self.selected_channel 

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

727 

728 self._update_pattern_data() 

729 

730 # Switch to new channel 

731 self.selected_channel = channel 

732 if isinstance(self.pattern_data, dict): 

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

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

735 else: 

736 self.functions = [] 

737 

738 # Update UI 

739 self._refresh_component_button() 

740 self._populate_function_list() 

741 

742 def _update_pattern_data(self): 

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

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

745 # Save current functions to the selected channel 

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

747 self.pattern_data = {} 

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

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

750 else: 

751 # List mode - pattern_data is just the functions list 

752 self.pattern_data = self.functions