Coverage for openhcs/pyqt_gui/windows/dual_editor_window.py: 0.0%
292 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"""
2Dual Editor Window for PyQt6
4Step and function editing dialog with tabbed interface.
5Uses hybrid approach: extracted business logic + clean PyQt6 UI.
6"""
8import logging
9from typing import Optional, Callable, Any, Dict
11from PyQt6.QtWidgets import (
12 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
13 QTabWidget, QWidget, QFrame, QTextEdit, QScrollArea
14)
15from PyQt6.QtCore import Qt, pyqtSignal
16from PyQt6.QtGui import QFont
18from openhcs.core.steps.function_step import FunctionStep
19from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager
21from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
22logger = logging.getLogger(__name__)
25class DualEditorWindow(QDialog):
26 """
27 PyQt6 Dual Editor Window.
29 Step and function editing dialog with tabbed interface for comprehensive editing.
30 Preserves all business logic from Textual version with clean PyQt6 UI.
31 """
33 # Signals
34 step_saved = pyqtSignal(object) # FunctionStep
35 step_cancelled = pyqtSignal()
36 changes_detected = pyqtSignal(bool) # has_changes
38 def __init__(self, step_data: Optional[FunctionStep] = None, is_new: bool = False,
39 on_save_callback: Optional[Callable] = None, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
40 """
41 Initialize the dual editor window.
43 Args:
44 step_data: FunctionStep to edit (None for new step)
45 is_new: Whether this is a new step
46 on_save_callback: Function to call when step is saved
47 parent: Parent widget
48 """
49 super().__init__(parent)
51 # Make window non-modal (like plate manager and pipeline editor)
52 self.setModal(False)
54 # Initialize color scheme
55 self.color_scheme = color_scheme or PyQt6ColorScheme()
57 # Business logic state (extracted from Textual version)
58 self.is_new = is_new
59 self.on_save_callback = on_save_callback
61 # Pattern management (extracted from Textual version)
62 self.pattern_manager = PatternDataManager()
64 if step_data:
65 self.editing_step = step_data
66 else:
67 self.editing_step = self.pattern_manager.create_new_step()
69 # Store original for change detection
70 self.original_step = self.pattern_manager.clone_pattern(self.editing_step)
72 # Change tracking
73 self.has_changes = False
74 self.current_tab = "step"
76 # UI components
77 self.tab_widget: Optional[QTabWidget] = None
78 self.step_editor: Optional[QWidget] = None
79 self.func_editor: Optional[QWidget] = None
81 # Setup UI
82 self.setup_ui()
83 self.setup_connections()
85 logger.debug(f"Dual editor window initialized (new={is_new})")
87 def setup_ui(self):
88 """Setup the user interface."""
89 title = "New Step" if self.is_new else f"Edit Step: {getattr(self.editing_step, 'name', 'Unknown')}"
90 self.setWindowTitle(title)
91 # Keep non-modal (already set in __init__)
92 self.setMinimumSize(800, 600)
93 self.resize(1000, 700)
95 layout = QVBoxLayout(self)
96 layout.setSpacing(10)
98 # Header
99 header_label = QLabel(title)
100 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
101 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; padding: 10px;")
102 layout.addWidget(header_label)
104 # Tabbed content
105 self.tab_widget = QTabWidget()
106 self.tab_widget.setStyleSheet(f"""
107 QTabWidget::pane {{
108 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
109 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
110 }}
111 QTabBar::tab {{
112 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
113 color: white;
114 padding: 8px 16px;
115 margin-right: 2px;
116 border-top-left-radius: 4px;
117 border-top-right-radius: 4px;
118 }}
119 QTabBar::tab:selected {{
120 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
121 }}
122 QTabBar::tab:hover {{
123 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
124 }}
125 """)
127 # Create tabs
128 self.create_step_tab()
129 self.create_function_tab()
131 layout.addWidget(self.tab_widget)
133 # Button panel
134 button_panel = self.create_button_panel()
135 layout.addWidget(button_panel)
137 # Set styling
138 self.setStyleSheet(f"""
139 QDialog {{
140 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
141 color: white;
142 }}
143 """)
145 def create_step_tab(self):
146 """Create the step settings tab (using dedicated widget)."""
147 from openhcs.pyqt_gui.widgets.step_parameter_editor import StepParameterEditorWidget
149 # Create step parameter editor widget (mirrors Textual TUI)
150 self.step_editor = StepParameterEditorWidget(self.editing_step, service_adapter=None, color_scheme=self.color_scheme)
152 # Connect parameter changes
153 self.step_editor.step_parameter_changed.connect(self.detect_changes)
155 self.tab_widget.addTab(self.step_editor, "Step Settings")
157 def create_function_tab(self):
158 """Create the function pattern tab (using dedicated widget)."""
159 from openhcs.pyqt_gui.widgets.function_list_editor import FunctionListEditorWidget
161 # Convert step func to function list format
162 initial_functions = self._convert_step_func_to_list()
164 # Create function list editor widget (mirrors Textual TUI)
165 step_id = getattr(self.editing_step, 'name', 'unknown_step')
166 self.func_editor = FunctionListEditorWidget(
167 initial_functions=initial_functions,
168 step_identifier=step_id,
169 service_adapter=None
170 )
172 # Store main window reference for orchestrator access (find it through parent chain)
173 main_window = self._find_main_window()
174 if main_window:
175 self.func_editor.main_window = main_window
177 # Initialize step configuration settings in function editor (mirrors Textual TUI)
178 self.func_editor.current_group_by = self.editing_step.group_by
179 self.func_editor.current_variable_components = self.editing_step.variable_components or []
181 # Refresh component button to show correct text and state (mirrors Textual TUI reactive updates)
182 self.func_editor._refresh_component_button()
184 # Connect function pattern changes
185 self.func_editor.function_pattern_changed.connect(self._on_function_pattern_changed)
187 self.tab_widget.addTab(self.func_editor, "Function Pattern")
189 def _on_function_pattern_changed(self):
190 """Handle function pattern changes from function editor."""
191 # Update step func from function editor - use current_pattern to get full pattern data
192 current_pattern = self.func_editor.current_pattern
193 self.editing_step.func = current_pattern
194 self.detect_changes()
195 logger.debug(f"Function pattern changed: {current_pattern}")
199 def create_button_panel(self) -> QWidget:
200 """
201 Create the button panel with save/cancel actions.
203 Returns:
204 Widget containing action buttons
205 """
206 panel = QFrame()
207 panel.setFrameStyle(QFrame.Shape.Box)
208 panel.setStyleSheet(f"""
209 QFrame {{
210 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
211 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
212 border-radius: 3px;
213 padding: 10px;
214 }}
215 """)
217 layout = QHBoxLayout(panel)
219 # Changes indicator
220 self.changes_label = QLabel("")
221 self.changes_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_warning)}; font-style: italic;")
222 layout.addWidget(self.changes_label)
224 layout.addStretch()
226 # Cancel button
227 cancel_button = QPushButton("Cancel")
228 cancel_button.setMinimumWidth(80)
229 cancel_button.clicked.connect(self.cancel_edit)
230 cancel_button.setStyleSheet(f"""
231 QPushButton {{
232 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
233 color: white;
234 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.status_error)};
235 border-radius: 3px;
236 padding: 8px;
237 }}
238 QPushButton:hover {{
239 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
240 }}
241 """)
242 layout.addWidget(cancel_button)
244 # Save button
245 self.save_button = QPushButton("Save")
246 self.save_button.setMinimumWidth(80)
247 self.save_button.setEnabled(False) # Initially disabled
248 self.save_button.clicked.connect(self.save_edit)
249 self.save_button.setStyleSheet(f"""
250 QPushButton {{
251 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
252 color: white;
253 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
254 border-radius: 3px;
255 padding: 8px;
256 }}
257 QPushButton:hover {{
258 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
259 }}
260 QPushButton:disabled {{
261 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
262 color: {self.color_scheme.to_hex(self.color_scheme.border_light)};
263 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.separator_color)};
264 }}
265 """)
266 layout.addWidget(self.save_button)
268 return panel
270 def setup_connections(self):
271 """Setup signal/slot connections."""
272 # Tab change tracking
273 self.tab_widget.currentChanged.connect(self.on_tab_changed)
275 # Change detection
276 self.changes_detected.connect(self.on_changes_detected)
277 func_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; font-weight: bold; font-size: 14px;")
278 header_layout.addWidget(func_label)
280 header_layout.addStretch()
282 # Function management buttons (mirrors Textual TUI)
283 add_btn = QPushButton("Add")
284 add_btn.setMaximumWidth(60)
285 add_btn.setStyleSheet(self._get_button_style())
286 add_btn.clicked.connect(self.add_function)
287 header_layout.addWidget(add_btn)
289 load_btn = QPushButton("Load")
290 load_btn.setMaximumWidth(60)
291 load_btn.setStyleSheet(self._get_button_style())
292 load_btn.clicked.connect(self.load_function_pattern)
293 header_layout.addWidget(load_btn)
295 save_as_btn = QPushButton("Save As")
296 save_as_btn.setMaximumWidth(80)
297 save_as_btn.setStyleSheet(self._get_button_style())
298 save_as_btn.clicked.connect(self.save_function_pattern)
299 header_layout.addWidget(save_as_btn)
301 code_btn = QPushButton("Code")
302 code_btn.setMaximumWidth(60)
303 code_btn.setStyleSheet(self._get_button_style())
304 code_btn.clicked.connect(self.edit_function_code)
305 header_layout.addWidget(code_btn)
307 layout.addLayout(header_layout)
309 # Function list scroll area (mirrors Textual TUI)
310 self.function_scroll = QScrollArea()
311 self.function_scroll.setWidgetResizable(True)
312 self.function_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
313 self.function_scroll.setStyleSheet(f"""
314 QScrollArea {{
315 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
316 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
317 border-radius: 4px;
318 }}
319 """)
321 # Function list container
322 self.function_container = QWidget()
323 self.function_layout = QVBoxLayout(self.function_container)
324 self.function_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
325 self.function_layout.setSpacing(8)
327 # Initialize function list from step
328 self.function_panes = []
329 self._populate_function_list()
331 self.function_scroll.setWidget(self.function_container)
332 layout.addWidget(self.function_scroll)
334 return frame
336 def _get_button_style(self) -> str:
337 """Get consistent button styling."""
338 return """
339 QPushButton {
340 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
341 color: white;
342 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)};
343 border-radius: 3px;
344 padding: 6px 12px;
345 font-size: 11px;
346 }
347 QPushButton:hover {
348 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
349 }
350 QPushButton:pressed {
351 background-color: {self.color_scheme.to_hex(self.color_scheme.button_pressed_bg)};
352 }
353 """
355 def _populate_function_list(self):
356 """Populate function list from current step (mirrors Textual TUI)."""
357 # Clear existing panes
358 for pane in self.function_panes:
359 pane.setParent(None)
360 self.function_panes.clear()
362 # Convert step func to function list
363 functions = self._convert_step_func_to_list()
365 if not functions:
366 # Show empty state
367 empty_label = QLabel("No functions defined. Click 'Add' to begin.")
368 empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
369 empty_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)}; font-style: italic; padding: 20px;")
370 self.function_layout.addWidget(empty_label)
371 else:
372 # Create function panes
373 for i, func_item in enumerate(functions):
374 pane = self._create_function_pane(func_item, i)
375 self.function_panes.append(pane)
376 self.function_layout.addWidget(pane)
378 def _convert_step_func_to_list(self):
379 """Convert step func to initial pattern format for function list editor."""
380 if not hasattr(self.editing_step, 'func') or not self.editing_step.func:
381 return []
383 # Return the step func directly - the function list editor will handle the conversion
384 return self.editing_step.func
388 def _find_main_window(self):
389 """Find the main window through the parent chain."""
390 try:
391 # Navigate up the parent chain to find OpenHCSMainWindow
392 current = self.parent()
393 while current:
394 # Check if this is the main window (has floating_windows attribute)
395 if hasattr(current, 'floating_windows') and hasattr(current, 'service_adapter'):
396 logger.debug(f"Found main window: {type(current).__name__}")
397 return current
398 current = current.parent()
400 logger.warning("Could not find main window in parent chain")
401 return None
403 except Exception as e:
404 logger.error(f"Error finding main window: {e}")
405 return None
407 def _get_current_plate_from_pipeline_editor(self):
408 """Get current plate from pipeline editor (mirrors Textual TUI pattern)."""
409 try:
410 # Navigate up to find pipeline editor widget
411 current = self.parent()
412 while current:
413 # Check if this is a pipeline editor widget
414 if hasattr(current, 'current_plate') and hasattr(current, 'pipeline_steps'):
415 current_plate = getattr(current, 'current_plate', None)
416 if current_plate:
417 logger.debug(f"Found current plate from pipeline editor: {current_plate}")
418 return current_plate
420 # Check children for pipeline editor widget
421 for child in current.findChildren(QWidget):
422 if hasattr(child, 'current_plate') and hasattr(child, 'pipeline_steps'):
423 current_plate = getattr(child, 'current_plate', None)
424 if current_plate:
425 logger.debug(f"Found current plate from pipeline editor child: {current_plate}")
426 return current_plate
428 current = current.parent()
430 logger.warning("Could not find current plate from pipeline editor")
431 return None
433 except Exception as e:
434 logger.error(f"Error getting current plate from pipeline editor: {e}")
435 return None
437 # Old function pane methods removed - now using dedicated FunctionListEditorWidget
439 def create_button_panel(self) -> QWidget:
440 """
441 Create the button panel with save/cancel actions.
443 Returns:
444 Widget containing action buttons
445 """
446 panel = QFrame()
447 panel.setFrameStyle(QFrame.Shape.Box)
448 panel.setStyleSheet(f"""
449 QFrame {{
450 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
451 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
452 border-radius: 3px;
453 padding: 10px;
454 }}
455 """)
457 layout = QHBoxLayout(panel)
459 # Changes indicator
460 self.changes_label = QLabel("")
461 self.changes_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_warning)}; font-style: italic;")
462 layout.addWidget(self.changes_label)
464 layout.addStretch()
466 # Cancel button
467 cancel_button = QPushButton("Cancel")
468 cancel_button.setMinimumWidth(80)
469 cancel_button.clicked.connect(self.cancel_edit)
470 cancel_button.setStyleSheet(f"""
471 QPushButton {{
472 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
473 color: white;
474 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.status_error)};
475 border-radius: 3px;
476 padding: 8px;
477 }}
478 QPushButton:hover {{
479 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
480 }}
481 """)
482 layout.addWidget(cancel_button)
484 # Save button
485 self.save_button = QPushButton("Save")
486 self.save_button.setMinimumWidth(80)
487 self.save_button.clicked.connect(self.save_step)
488 self.save_button.setStyleSheet(f"""
489 QPushButton {{
490 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
491 color: white;
492 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
493 border-radius: 3px;
494 padding: 8px;
495 }}
496 QPushButton:hover {{
497 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
498 }}
499 """)
500 layout.addWidget(self.save_button)
502 return panel
504 def setup_connections(self):
505 """Setup signal/slot connections."""
506 self.tab_widget.currentChanged.connect(self.on_tab_changed)
507 self.changes_detected.connect(self.on_changes_detected)
509 def get_function_info(self) -> str:
510 """
511 Get function information for display.
513 Returns:
514 Function information string
515 """
516 if not self.editing_step or not hasattr(self.editing_step, 'func'):
517 return "No function assigned"
519 func = self.editing_step.func
520 func_name = getattr(func, '__name__', 'Unknown Function')
521 func_module = getattr(func, '__module__', 'Unknown Module')
523 info = f"Function: {func_name}\n"
524 info += f"Module: {func_module}\n"
526 # Add parameter info if available
527 if hasattr(self.editing_step, 'parameters'):
528 params = self.editing_step.parameters
529 if params:
530 info += f"\nParameters ({len(params)}):\n"
531 for param_name, param_value in params.items():
532 info += f" {param_name}: {param_value}\n"
534 return info
536 def on_step_parameter_changed(self, param_name: str, value):
537 """Handle step parameter changes from form manager."""
538 try:
539 # Update the editing step
540 setattr(self.editing_step, param_name, value)
541 self.detect_changes()
542 logger.debug(f"Step parameter changed: {param_name} = {value}")
543 except Exception as e:
544 logger.error(f"Failed to update step parameter {param_name}: {e}")
546 def on_tab_changed(self, index: int):
547 """Handle tab changes."""
548 tab_names = ["step", "function"]
549 if 0 <= index < len(tab_names):
550 self.current_tab = tab_names[index]
551 logger.debug(f"Tab changed to: {self.current_tab}")
553 def detect_changes(self):
554 """Detect if changes have been made."""
555 has_changes = False
557 # Check step parameters
558 for attr in ['name', 'variable_components', 'group_by', 'force_disk_output', 'input_dir', 'output_dir']:
559 original_value = getattr(self.original_step, attr, None)
560 current_value = getattr(self.editing_step, attr, None)
561 if original_value != current_value:
562 has_changes = True
563 break
565 # Check function pattern
566 if not has_changes:
567 original_func = getattr(self.original_step, 'func', None)
568 current_func = getattr(self.editing_step, 'func', None)
569 # Simple comparison - could be enhanced for deep comparison
570 has_changes = str(original_func) != str(current_func)
572 if has_changes != self.has_changes:
573 self.has_changes = has_changes
574 self.changes_detected.emit(has_changes)
576 def on_changes_detected(self, has_changes: bool):
577 """Handle changes detection."""
578 if has_changes:
579 self.changes_label.setText("● Unsaved changes")
580 self.save_button.setEnabled(True)
581 else:
582 self.changes_label.setText("")
583 self.save_button.setEnabled(False)
585 def save_step(self):
586 """Save the edited step."""
587 try:
588 # Validate step
589 if not hasattr(self.editing_step, 'name') or not self.editing_step.name.strip():
590 from PyQt6.QtWidgets import QMessageBox
591 QMessageBox.warning(self, "Validation Error", "Step name cannot be empty.")
592 return
594 # Emit signals and call callback
595 self.step_saved.emit(self.editing_step)
597 if self.on_save_callback:
598 self.on_save_callback(self.editing_step)
600 self.accept()
601 logger.debug(f"Step saved: {getattr(self.editing_step, 'name', 'Unknown')}")
603 except Exception as e:
604 logger.error(f"Failed to save step: {e}")
605 from PyQt6.QtWidgets import QMessageBox
606 QMessageBox.critical(self, "Save Error", f"Failed to save step:\n{e}")
608 def cancel_edit(self):
609 """Cancel editing and close dialog."""
610 if self.has_changes:
611 from PyQt6.QtWidgets import QMessageBox
612 reply = QMessageBox.question(
613 self,
614 "Unsaved Changes",
615 "You have unsaved changes. Are you sure you want to cancel?",
616 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
617 QMessageBox.StandardButton.No
618 )
620 if reply != QMessageBox.StandardButton.Yes:
621 return
623 self.step_cancelled.emit()
624 self.reject()
625 logger.debug("Step editing cancelled")
629 def closeEvent(self, event):
630 """Handle dialog close event."""
631 if self.has_changes:
632 from PyQt6.QtWidgets import QMessageBox
633 reply = QMessageBox.question(
634 self,
635 "Unsaved Changes",
636 "You have unsaved changes. Are you sure you want to close?",
637 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
638 QMessageBox.StandardButton.No
639 )
641 if reply != QMessageBox.StandardButton.Yes:
642 event.ignore()
643 return
645 event.accept()