"""
Group By Selector Dialog for PyQt6 GUI.
Mirrors the Textual TUI GroupBySelectorWindow functionality with dual list selection.
"""
import logging
from typing import List, Optional, Callable, Any
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QPushButton,
QLabel, QWidget
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from pyqt_reactive.forms.ui_utils import format_enum_display
logger = logging.getLogger(__name__)
[docs]
class GroupBySelectorDialog(QDialog):
"""
Group by selector dialog that mirrors Textual TUI GroupBySelectorWindow.
Uses dual list selection with mathematical operations for moving items.
"""
# Signals
selection_changed = pyqtSignal(list) # Selected components
[docs]
def __init__(
self,
available_components: List[str],
selected_components: List[str],
group_by: Any,
metadata_lookup: Optional[Callable[[Any, str], Optional[str]]] = None,
parent=None,
):
"""
Initialize group by selector dialog.
Args:
available_components: List of available components
selected_components: List of currently selected components
group_by: GroupBy enum for component type
metadata_lookup: Optional callback to map (group_by, component_key) -> display name
parent: Parent widget
"""
super().__init__(parent)
self.available_components = available_components.copy()
self.selected_components = selected_components.copy()
self.group_by = group_by
self.metadata_lookup = metadata_lookup
# Calculate initial lists (same logic as Textual TUI) - sorted for consistency
self.current_available = sorted([ch for ch in self.available_components if ch not in self.selected_components])
self.current_selected = sorted(self.selected_components.copy())
self.setup_ui()
self.setup_connections()
self.update_lists()
logger.debug(f"Group by selector initialized: {len(self.current_available)} available, {len(self.current_selected)} selected")
[docs]
def setup_ui(self):
"""Setup the user interface (mirrors Textual TUI layout)."""
component_display = format_enum_display(self.group_by).title()
self.setWindowTitle(f"Select {component_display}s")
self.setModal(True)
self.resize(500, 400)
layout = QVBoxLayout(self)
# Title
title_label = QLabel(f"Select {component_display}s")
title_font = QFont()
title_font.setBold(True)
title_font.setPointSize(12)
title_label.setFont(title_font)
layout.addWidget(title_label)
# Top button row (mirrors Textual TUI)
top_button_layout = QHBoxLayout()
self.move_right_btn = QPushButton("→")
self.move_right_btn.setMaximumWidth(40)
self.move_right_btn.setToolTip("Move selected items to selected list")
top_button_layout.addWidget(self.move_right_btn)
self.move_left_btn = QPushButton("←")
self.move_left_btn.setMaximumWidth(40)
self.move_left_btn.setToolTip("Move selected items to available list")
top_button_layout.addWidget(self.move_left_btn)
top_button_layout.addStretch()
self.select_all_btn = QPushButton("All")
self.select_all_btn.setMaximumWidth(50)
self.select_all_btn.setToolTip("Select all available items")
top_button_layout.addWidget(self.select_all_btn)
self.select_none_btn = QPushButton("None")
self.select_none_btn.setMaximumWidth(50)
self.select_none_btn.setToolTip("Clear all selections")
top_button_layout.addWidget(self.select_none_btn)
layout.addLayout(top_button_layout)
# Dual lists container (mirrors Textual TUI)
lists_layout = QHBoxLayout()
# Available list
available_container = QVBoxLayout()
available_label = QLabel("Available")
available_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
available_container.addWidget(available_label)
self.available_list = QListWidget()
self.available_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
available_container.addWidget(self.available_list)
available_widget = QWidget()
available_widget.setLayout(available_container)
lists_layout.addWidget(available_widget)
# Selected list
selected_container = QVBoxLayout()
selected_label = QLabel("Selected")
selected_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
selected_container.addWidget(selected_label)
self.selected_list = QListWidget()
self.selected_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
selected_container.addWidget(self.selected_list)
selected_widget = QWidget()
selected_widget.setLayout(selected_container)
lists_layout.addWidget(selected_widget)
layout.addLayout(lists_layout)
# Bottom buttons (mirrors Textual TUI dialog-buttons)
button_layout = QHBoxLayout()
button_layout.addStretch()
ok_btn = QPushButton("OK")
ok_btn.setDefault(True)
button_layout.addWidget(ok_btn)
cancel_btn = QPushButton("Cancel")
button_layout.addWidget(cancel_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
# Connect buttons
ok_btn.clicked.connect(self.accept_selection)
cancel_btn.clicked.connect(self.reject)
[docs]
def setup_connections(self):
"""Setup signal/slot connections."""
# Movement buttons (mirrors Textual TUI button handling)
self.move_right_btn.clicked.connect(self.move_right)
self.move_left_btn.clicked.connect(self.move_left)
self.select_all_btn.clicked.connect(self.select_all)
self.select_none_btn.clicked.connect(self.select_none)
# Double-click for quick movement
self.available_list.itemDoubleClicked.connect(lambda: self.move_right())
self.selected_list.itemDoubleClicked.connect(lambda: self.move_left())
[docs]
def update_lists(self):
"""Update both list widgets (mirrors Textual TUI _update_lists)."""
# Clear and populate available list
self.available_list.clear()
for item in sorted(self.current_available):
display_text = self._format_component_display(item)
self.available_list.addItem(display_text)
# Clear and populate selected list (sorted for consistency)
self.selected_list.clear()
for item in sorted(self.current_selected):
display_text = self._format_component_display(item)
self.selected_list.addItem(display_text)
logger.debug(f"Updated lists: available={self.current_available}, selected={self.current_selected}")
def _format_component_display(self, component_key: str) -> str:
"""
Format component key for display with metadata if available (mirrors Textual TUI).
Args:
component_key: Component key (e.g., "1", "2", "A01")
Returns:
Formatted display string (e.g., "Channel 1 | HOECHST 33342" or "Channel 1")
"""
component_display = format_enum_display(self.group_by).title()
base_text = f"{component_display} {component_key}"
# Get metadata name if callback is available
if self.metadata_lookup:
metadata_name = self.metadata_lookup(self.group_by, component_key)
if metadata_name:
return f"{base_text} | {metadata_name}"
return base_text
def _extract_component_key(self, display_text: str) -> str:
"""
Extract component key from formatted display text.
Args:
display_text: Formatted text like "Channel 1 | HOECHST 33342" or "Channel 1"
Returns:
Component key like "1"
"""
# Extract the key from "Component_Type KEY" or "Component_Type KEY | metadata"
parts = display_text.split(' | ')[0] # Remove metadata part if present
component_key = parts.split(' ')[-1] # Get the last part (the key)
return component_key
[docs]
def move_right(self):
"""Move selected items from available to selected (mirrors Textual TUI _move_right)."""
selected_items = [item.text() for item in self.available_list.selectedItems()]
for display_text in selected_items:
component_key = self._extract_component_key(display_text)
if component_key in self.current_available:
self.current_available.remove(component_key)
self.current_selected.append(component_key)
self.update_lists()
[docs]
def move_left(self):
"""Move selected items from selected to available (mirrors Textual TUI _move_left)."""
selected_items = [item.text() for item in self.selected_list.selectedItems()]
for display_text in selected_items:
component_key = self._extract_component_key(display_text)
if component_key in self.current_selected:
self.current_selected.remove(component_key)
self.current_available.append(component_key)
self.update_lists()
[docs]
def select_all(self):
"""Select all available items (mirrors Textual TUI _select_all)."""
self.current_selected.extend(self.current_available)
self.current_available.clear()
self.update_lists()
[docs]
def select_none(self):
"""Clear all selections (mirrors Textual TUI _select_none)."""
self.current_available.extend(self.current_selected)
self.current_selected.clear()
self.update_lists()
[docs]
def accept_selection(self):
"""Accept the current selection."""
# UX FIX: Users often select items in the "Available" list and press OK
# expecting that to become the selection. Mirror that expectation by
# moving any currently-highlighted available items to the selected set
# before closing.
try:
if self.available_list.selectedItems():
self.move_right()
except Exception:
# Best-effort; fall back to current_selected
pass
self.selection_changed.emit(self.current_selected.copy())
self.accept()
[docs]
def get_selected_components(self) -> List[str]:
"""Get the selected components."""
return self.current_selected.copy()
[docs]
@staticmethod
def select_components(
available_components: List[str],
selected_components: List[str],
group_by: Any,
metadata_lookup: Optional[Callable[[Any, str], Optional[str]]] = None,
parent=None,
) -> Optional[List[str]]:
"""
Static method to show group by selector and return selected components.
Args:
available_components: List of available components
selected_components: List of currently selected components
group_by: GroupBy enum for component type
metadata_lookup: Optional callback to map (group_by, component_key) -> display name
parent: Parent widget
Returns:
Selected components or None if cancelled
"""
dialog = GroupBySelectorDialog(
available_components,
selected_components,
group_by,
metadata_lookup,
parent,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.get_selected_components()
return None