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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +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.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
26logger = logging.getLogger(__name__)
29class FunctionListEditorWidget(QWidget):
30 """
31 Function list editor widget that mirrors Textual TUI functionality.
33 Displays functions with parameter editing, Add/Delete/Reset buttons,
34 and Load/Save/Code functionality.
35 """
37 # Signals
38 function_pattern_changed = pyqtSignal()
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)
44 # Initialize color scheme
45 self.color_scheme = color_scheme or PyQt6ColorScheme()
47 # Initialize services (reuse existing business logic)
48 self.registry_service = RegistryService()
49 self.data_manager = PatternDataManager()
50 self.service_adapter = service_adapter
52 # Step identifier for cache isolation
53 self.step_identifier = step_identifier or f"widget_{id(self)}"
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
62 # Component selection cache per GroupBy (mirrors Textual TUI)
63 self.component_selections = {}
65 # Initialize pattern data and mode
66 self._initialize_pattern_data(initial_functions)
68 # UI components
69 self.function_panes = []
71 self.setup_ui()
72 self.setup_connections()
74 logger.debug(f"Function list editor initialized with {len(self.functions)} functions")
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 []
103 self.pattern_data = normalized_dict
104 self.is_dict_mode = True
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 = []
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 []
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
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)
144 # Header with controls (mirrors Textual TUI)
145 header_layout = QHBoxLayout()
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)
151 header_layout.addStretch()
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)
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)
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)
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)
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)
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)
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)
199 # Update navigation button visibility
200 self._update_navigation_buttons()
202 header_layout.addStretch()
203 layout.addLayout(header_layout)
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 """)
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)
223 # Populate function list
224 self._populate_function_list()
226 self.scroll_area.setWidget(self.function_container)
227 layout.addWidget(self.scroll_area)
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 """
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()
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)
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)
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)
278 self.function_panes.append(pane)
279 self.function_layout.addWidget(pane)
281 def setup_connections(self):
282 """Setup signal/slot connections."""
283 pass
285 def add_function(self):
286 """Add a new function (mirrors Textual TUI)."""
287 from openhcs.pyqt_gui.dialogs.function_selector_dialog import FunctionSelectorDialog
289 # Show function selector dialog (reuses Textual TUI logic)
290 selected_function = FunctionSelectorDialog.select_function(parent=self)
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__}")
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
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 )
313 if file_path:
314 self._load_function_pattern_from_file(file_path)
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
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 )
328 if file_path:
329 self._save_function_pattern_to_file(file_path)
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")
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
341 try:
342 # Update pattern data first
343 self._update_pattern_data()
345 # Generate complete Python code with imports
346 python_code = self._generate_complete_python_code()
348 # Create simple code editor service
349 from openhcs.pyqt_gui.services.simple_code_editor import SimpleCodeEditorService
350 editor_service = SimpleCodeEditorService(self)
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')
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 )
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)}")
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
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)
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
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)
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")
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)}")
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)}")
441 # Refresh the UI and notify of changes
442 self._populate_function_list()
443 self.function_pattern_changed.emit()
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)}")
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
455 @contextmanager
456 def patch_context():
457 # Store original constructors
458 original_constructors = {}
460 # Find all lazy dataclass types that need patching
461 from openhcs.core.config import LazyZarrConfig, LazyStepMaterializationConfig, LazyWellFilterConfig
462 lazy_types = [LazyZarrConfig, LazyStepMaterializationConfig, LazyWellFilterConfig]
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__
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)
479 # Initialize any required lazy dataclass attributes
480 if hasattr(dataclass_type, '_is_lazy_dataclass'):
481 object.__setattr__(self, '_is_lazy_dataclass', True)
483 return patched_init
485 # Apply the patch
486 lazy_type.__init__ = create_patched_init(original_constructors[lazy_type], lazy_type)
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
495 return patch_context()
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()
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
512 # Show function selector dialog (reuses Textual TUI logic)
513 selected_function = FunctionSelectorDialog.select_function(parent=self)
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__}")
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()
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()
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")
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")
553 def get_current_functions(self):
554 """Get current function list."""
555 return self.functions.copy()
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
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
571 return self.pattern_data
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()
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"
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()
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"
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
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 )
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
615 # Check if component selection is disabled
616 if self._is_component_button_disabled():
617 logger.debug("Component selection is disabled")
618 return
620 # Get available components from orchestrator using current group_by - MUST exist, no fallbacks
621 orchestrator = self._get_current_orchestrator()
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"
626 # Get current selection from pattern data (mirrors Textual TUI logic)
627 selected_components = self._get_current_component_selection()
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 )
638 if result is not None:
639 self._handle_component_selection(result)
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']
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
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]
658 # Orchestrator must be initialized
659 assert orchestrator.is_initialized(), f"Orchestrator for plate {current_plate} is not initialized"
661 return orchestrator
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()))
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)
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}")
680 # Update pattern structure based on component selection (mirrors Textual TUI)
681 self._update_components(new_components)
683 # Update component button text and navigation
684 self._refresh_component_button()
685 logger.debug(f"Updated components: {new_components}")
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)
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
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]
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 {}
720 # Create a persistent storage for deselected components (mirrors Textual TUI)
721 if not hasattr(self, '_deselected_components_storage'):
722 self._deselected_components_storage = {}
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}")
730 new_pattern = {}
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] = []
745 self.pattern_data = new_pattern
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]
752 # Update UI to reflect changes
753 self._populate_function_list()
754 self._update_navigation_buttons()
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())
762 # Also update navigation buttons when component button is refreshed
763 self._update_navigation_buttons()
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)
775 self.prev_channel_btn.setVisible(show_nav)
776 self.next_channel_btn.setVisible(show_nav)
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
783 channels = sorted(self.pattern_data.keys())
784 if len(channels) <= 1:
785 return
787 try:
788 current_index = channels.index(self.selected_channel)
789 new_index = (current_index + direction) % len(channels)
790 new_channel = channels[new_index]
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}")
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
802 # Save current functions first
803 old_channel = self.selected_channel
804 logger.debug(f"Switching from channel {old_channel} to {channel}")
806 self._update_pattern_data()
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 = []
816 # Update UI
817 self._refresh_component_button()
818 self._populate_function_list()
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