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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2Function List Editor Widget for PyQt6 GUI.
4Mirrors the Textual TUI FunctionListEditorWidget with sophisticated parameter forms.
5Displays a scrollable list of function panes with Add/Load/Save/Code controls.
6"""
8import logging
9import os
10from typing import List, Union, Dict, Any, Optional, Callable, Tuple
11from pathlib import Path
13from PyQt6.QtWidgets import (
14 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
15 QScrollArea, QFrame
16)
17from PyQt6.QtCore import Qt, pyqtSignal
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
25logger = logging.getLogger(__name__)
28class FunctionListEditorWidget(QWidget):
29 """
30 Function list editor widget that mirrors Textual TUI functionality.
32 Displays functions with parameter editing, Add/Delete/Reset buttons,
33 and Load/Save/Code functionality.
34 """
36 # Signals
37 function_pattern_changed = pyqtSignal()
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)
43 # Initialize color scheme
44 self.color_scheme = color_scheme or PyQt6ColorScheme()
46 # Initialize services (reuse existing business logic)
47 self.registry_service = FunctionRegistryService()
48 self.data_manager = PatternDataManager()
49 self.service_adapter = service_adapter
51 # Step identifier for cache isolation
52 self.step_identifier = step_identifier or f"widget_{id(self)}"
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
61 # Component selection cache per GroupBy (mirrors Textual TUI)
62 self.component_selections = {}
64 # Initialize pattern data and mode
65 self._initialize_pattern_data(initial_functions)
67 # UI components
68 self.function_panes = []
70 self.setup_ui()
71 self.setup_connections()
73 logger.debug(f"Function list editor initialized with {len(self.functions)} functions")
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 []
96 self.pattern_data = normalized_dict
97 self.is_dict_mode = True
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 = []
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
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)
127 # Header with controls (mirrors Textual TUI)
128 header_layout = QHBoxLayout()
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)
134 header_layout.addStretch()
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)
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)
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)
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)
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)
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)
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)
182 # Update navigation button visibility
183 self._update_navigation_buttons()
185 header_layout.addStretch()
186 layout.addLayout(header_layout)
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 """)
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)
206 # Populate function list
207 self._populate_function_list()
209 self.scroll_area.setWidget(self.function_container)
210 layout.addWidget(self.scroll_area)
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 """
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()
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)
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)
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)
261 self.function_panes.append(pane)
262 self.function_layout.addWidget(pane)
264 def setup_connections(self):
265 """Setup signal/slot connections."""
266 pass
268 def add_function(self):
269 """Add a new function (mirrors Textual TUI)."""
270 from openhcs.pyqt_gui.dialogs.function_selector_dialog import FunctionSelectorDialog
272 # Show function selector dialog (reuses Textual TUI logic)
273 selected_function = FunctionSelectorDialog.select_function(parent=self)
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__}")
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
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 )
296 if file_path:
297 self._load_function_pattern_from_file(file_path)
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
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 )
311 if file_path:
312 self._save_function_pattern_to_file(file_path)
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")
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
324 try:
325 # Update pattern data first
326 self._update_pattern_data()
328 # Generate complete Python code with imports
329 python_code = self._generate_complete_python_code()
331 # Create simple code editor service
332 from openhcs.pyqt_gui.services.simple_code_editor import SimpleCodeEditorService
333 editor_service = SimpleCodeEditorService(self)
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')
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 )
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)}")
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
356 return generate_complete_function_pattern_code(self.pattern_data, clean_mode=False)
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
368 # Execute the code (it has all necessary imports)
369 namespace = {}
370 exec(edited_code, namespace)
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")
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)}")
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")
413 # Refresh the UI and notify of changes
414 self._populate_function_list()
415 self.function_pattern_changed.emit()
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)}")
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()
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
436 # Show function selector dialog (reuses Textual TUI logic)
437 selected_function = FunctionSelectorDialog.select_function(parent=self)
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__}")
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()
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()
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")
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")
477 def get_current_functions(self):
478 """Get current function list."""
479 return self.functions.copy()
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
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
495 return self.pattern_data
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()
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"
508 # Use group_by.value.title() for dynamic component type display
509 component_type = self.current_group_by.value.title()
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"
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
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 )
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
539 # Check if component selection is disabled
540 if self._is_component_button_disabled():
541 logger.debug("Component selection is disabled")
542 return
544 # Get available components from orchestrator using current group_by - MUST exist, no fallbacks
545 orchestrator = self._get_current_orchestrator()
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"
550 # Get current selection from pattern data (mirrors Textual TUI logic)
551 selected_components = self._get_current_component_selection()
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 )
562 if result is not None:
563 self._handle_component_selection(result)
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']
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
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]
582 # Orchestrator must be initialized
583 assert orchestrator.is_initialized(), f"Orchestrator for plate {current_plate} is not initialized"
585 return orchestrator
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()))
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)
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}")
604 # Update pattern structure based on component selection (mirrors Textual TUI)
605 self._update_components(new_components)
607 # Update component button text and navigation
608 self._refresh_component_button()
609 logger.debug(f"Updated components: {new_components}")
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)
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
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]
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 {}
644 # Create a persistent storage for deselected components (mirrors Textual TUI)
645 if not hasattr(self, '_deselected_components_storage'):
646 self._deselected_components_storage = {}
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}")
654 new_pattern = {}
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] = []
669 self.pattern_data = new_pattern
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]
676 # Update UI to reflect changes
677 self._populate_function_list()
678 self._update_navigation_buttons()
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())
686 # Also update navigation buttons when component button is refreshed
687 self._update_navigation_buttons()
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)
697 self.prev_channel_btn.setVisible(show_nav)
698 self.next_channel_btn.setVisible(show_nav)
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
705 channels = sorted(self.pattern_data.keys())
706 if len(channels) <= 1:
707 return
709 try:
710 current_index = channels.index(self.selected_channel)
711 new_index = (current_index + direction) % len(channels)
712 new_channel = channels[new_index]
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}")
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
724 # Save current functions first
725 old_channel = self.selected_channel
726 logger.debug(f"Switching from channel {old_channel} to {channel}")
728 self._update_pattern_data()
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 = []
738 # Update UI
739 self._refresh_component_button()
740 self._populate_function_list()
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