Coverage for openhcs/textual_tui/windows/group_by_selector_window.py: 0.0%
131 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"""
2Group-by selector window with dual lists for component selection.
4Mathematical approach: move items between available and selected lists.
5"""
7import logging
8from typing import List, Callable, Optional
10from textual.app import ComposeResult
11from textual.containers import Container, Horizontal, Vertical
12from textual.widgets import Button, Static, ListView, ListItem, Label
14from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow
16logger = logging.getLogger(__name__)
19class GroupBySelectorWindow(BaseOpenHCSWindow):
20 """
21 Window for selecting components with dual-list interface.
23 Mathematical operations:
24 - Move →: selected += highlighted_available
25 - ← Move: selected -= highlighted_selected
26 - Select All: selected = available
27 - None: selected = []
28 """
30 DEFAULT_CSS = """
31 GroupBySelectorWindow {
32 width: 50; height: 20;
33 min-width: 50; min-height: 20;
34 }
36 /* Content area takes most space (like ConfigWindow) */
37 GroupBySelectorWindow .dialog-content {
38 height: 1fr;
39 width: 100%;
40 }
42 /* Top buttons row */
43 GroupBySelectorWindow #top_buttons {
44 height: auto;
45 align: center middle;
46 width: 100%;
47 }
49 /* Lists container fills remaining content space */
50 GroupBySelectorWindow #lists_container {
51 height: 1fr;
52 width: 100%;
53 }
55 /* Bottom buttons row - same as top buttons */
56 GroupBySelectorWindow #bottom_buttons {
57 height: auto;
58 align: center middle;
59 width: 100%;
60 }
62 /* All buttons styling */
63 GroupBySelectorWindow Button {
64 width: auto;
65 margin: 0 1;
66 }
68 /* Top buttons more compact */
69 GroupBySelectorWindow #top_buttons Button {
70 min-width: 4;
71 }
73 /* Bottom buttons standard size */
74 GroupBySelectorWindow #bottom_buttons Button {
75 min-width: 8;
76 }
78 /* List views fill container */
79 GroupBySelectorWindow ListView {
80 height: 1fr;
81 min-height: 3;
82 }
84 /* Static labels */
85 GroupBySelectorWindow Static {
86 height: auto;
87 padding: 0;
88 margin: 0;
89 }
90 """
92 def __init__(
93 self,
94 available_channels: List[str],
95 selected_channels: List[str],
96 on_result_callback: Callable[[Optional[List[str]]], None],
97 component_type: str = "channel", # Parameter for dynamic component type
98 orchestrator=None, # Add orchestrator for metadata access
99 **kwargs
100 ):
101 self.available_channels = available_channels.copy()
102 self.selected_channels = selected_channels.copy()
103 self.on_result_callback = on_result_callback
104 self.component_type = component_type # Store for dynamic labels
105 self.orchestrator = orchestrator # Store for metadata access
107 # Calculate initial lists
108 self.current_available = [ch for ch in self.available_channels if ch not in self.selected_channels]
109 self.current_selected = self.selected_channels.copy()
111 logger.debug(f"{component_type.title()} dialog: available={self.current_available}, selected={self.current_selected}")
113 # Dynamic title based on component type
114 title = f"Select {self.component_type.title()}s"
116 super().__init__(
117 window_id="group_by_selector",
118 title=title,
119 mode="temporary",
120 **kwargs
121 )
123 def _format_component_display(self, component_key: str) -> str:
124 """
125 Format component key for display with metadata if available.
127 Args:
128 component_key: Component key (e.g., "1", "2", "A01")
130 Returns:
131 Formatted display string (e.g., "Channel 1 | HOECHST 33342" or "Channel 1")
132 """
133 base_text = f"{self.component_type.title()} {component_key}"
135 # Get metadata name if orchestrator is available
136 if self.orchestrator:
137 # Convert component_type string back to GroupBy enum
138 from openhcs.constants.constants import GroupBy
139 group_by = GroupBy(self.component_type)
140 metadata_name = self.orchestrator.get_component_metadata(group_by, component_key)
142 if metadata_name:
143 return f"{base_text} | {metadata_name}"
145 return base_text
147 def _extract_channel_from_display(self, display_text: str) -> Optional[str]:
148 """Extract the channel key from formatted display text.
150 Display text format: "Channel 1 | HOECHST 33342" or "Channel 1"
151 Returns: "1"
152 """
153 try:
154 # Split by the first space to get "Channel" and "1 | ..." or "1"
155 parts = display_text.split(' ', 2)
156 if len(parts) >= 2:
157 # Get the part after "Channel" which is "1" or "1 | metadata"
158 key_part = parts[1]
159 # Split by " | " to separate key from metadata
160 key = key_part.split(' | ')[0]
161 return key
162 except Exception as e:
163 logger.debug(f"Could not extract channel from display text '{display_text}': {e}")
164 return None
166 def compose(self) -> ComposeResult:
167 """Compose the dual-list selector window - follow working window pattern."""
168 # Content area (like ConfigWindow does)
169 with Container(classes="dialog-content"):
170 # Top button row
171 with Horizontal(id="top_buttons"):
172 yield Button("→", id="move_right", compact=True)
173 yield Button("←", id="move_left", compact=True)
174 yield Button("All", id="select_all", compact=True)
175 yield Button("None", id="select_none", compact=True)
177 # Dual lists
178 with Horizontal(id="lists_container"):
179 with Vertical():
180 yield Static("Available")
181 yield ListView(id="available_list")
183 with Vertical():
184 yield Static("Selected")
185 yield ListView(id="selected_list")
187 # Bottom buttons in horizontal row (like top buttons)
188 with Horizontal(id="bottom_buttons"):
189 yield Button("OK", id="ok_btn", compact=True)
190 yield Button("Cancel", id="cancel_btn", compact=True)
192 def on_mount(self) -> None:
193 """Initialize the lists."""
194 self._update_lists()
196 def _update_lists(self) -> None:
197 """Update both list views with current data."""
198 # Update available list
199 available_list = self.query_one("#available_list", ListView)
200 available_list.clear()
201 for channel in sorted(self.current_available):
202 # Use enhanced formatting with metadata
203 label_text = self._format_component_display(channel)
204 available_list.append(ListItem(Label(label_text)))
206 # Update selected list
207 selected_list = self.query_one("#selected_list", ListView)
208 selected_list.clear()
209 for channel in sorted(self.current_selected):
210 # Use enhanced formatting with metadata
211 label_text = self._format_component_display(channel)
212 selected_list.append(ListItem(Label(label_text)))
214 # Clear selections to prevent stale index issues
215 available_list.index = None
216 selected_list.index = None
218 logger.debug(f"Updated lists: available={self.current_available}, selected={self.current_selected}")
220 def on_button_pressed(self, event: Button.Pressed) -> None:
221 """Handle button presses with simple mathematical operations."""
222 if event.button.id == "move_right":
223 self._move_right()
224 elif event.button.id == "move_left":
225 self._move_left()
226 elif event.button.id == "select_all":
227 self._select_all()
228 elif event.button.id == "select_none":
229 self._select_none()
230 elif event.button.id == "ok_btn":
231 self._handle_ok()
232 elif event.button.id == "cancel_btn":
233 self._handle_cancel()
235 def _move_right(self) -> None:
236 """Move highlighted available channels to selected."""
237 available_list = self.query_one("#available_list", ListView)
238 if available_list.index is not None and 0 <= available_list.index < len(available_list.children):
239 # Get the actual selected item from the ListView, not from our sorted list
240 selected_item = available_list.children[available_list.index]
241 if hasattr(selected_item, 'children') and selected_item.children:
242 label = selected_item.children[0] # Get the Label widget
243 if hasattr(label, 'renderable'):
244 # Extract the channel key from the formatted display text
245 display_text = str(label.renderable)
246 channel = self._extract_channel_from_display(display_text)
248 if channel and channel in self.current_available:
249 self.current_available.remove(channel)
250 self.current_selected.append(channel)
251 self._update_lists()
252 logger.debug(f"Moved {self.component_type} {channel} to selected")
254 def _move_left(self) -> None:
255 """Move highlighted selected channels to available."""
256 selected_list = self.query_one("#selected_list", ListView)
257 if selected_list.index is not None and 0 <= selected_list.index < len(selected_list.children):
258 # Get the actual selected item from the ListView, not from our sorted list
259 selected_item = selected_list.children[selected_list.index]
260 if hasattr(selected_item, 'children') and selected_item.children:
261 label = selected_item.children[0] # Get the Label widget
262 if hasattr(label, 'renderable'):
263 # Extract the channel key from the formatted display text
264 display_text = str(label.renderable)
265 channel = self._extract_channel_from_display(display_text)
267 if channel and channel in self.current_selected:
268 self.current_selected.remove(channel)
269 self.current_available.append(channel)
270 self._update_lists()
271 logger.debug(f"Moved {self.component_type} {channel} to available")
273 def _select_all(self) -> None:
274 """Select all available channels."""
275 self.current_selected = self.available_channels.copy()
276 self.current_available = []
277 self._update_lists()
278 logger.debug(f"Selected all {self.component_type}s")
280 def _select_none(self) -> None:
281 """Deselect all channels."""
282 self.current_available = self.available_channels.copy()
283 self.current_selected = []
284 self._update_lists()
285 logger.debug(f"Deselected all {self.component_type}s")
287 def _handle_ok(self) -> None:
288 """Handle OK button - return selected channels."""
289 if self.on_result_callback:
290 self.on_result_callback(self.current_selected)
291 self.close_window()
293 def _handle_cancel(self) -> None:
294 """Handle Cancel button - return None."""
295 if self.on_result_callback:
296 self.on_result_callback(None)
297 self.close_window()