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

1""" 

2Group-by selector window with dual lists for component selection. 

3 

4Mathematical approach: move items between available and selected lists. 

5""" 

6 

7import logging 

8from typing import List, Callable, Optional 

9 

10from textual.app import ComposeResult 

11from textual.containers import Container, Horizontal, Vertical 

12from textual.widgets import Button, Static, ListView, ListItem, Label 

13 

14from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class GroupBySelectorWindow(BaseOpenHCSWindow): 

20 """ 

21 Window for selecting components with dual-list interface. 

22 

23 Mathematical operations: 

24 - Move →: selected += highlighted_available 

25 - ← Move: selected -= highlighted_selected 

26 - Select All: selected = available 

27 - None: selected = [] 

28 """ 

29 

30 DEFAULT_CSS = """ 

31 GroupBySelectorWindow { 

32 width: 50; height: 20; 

33 min-width: 50; min-height: 20; 

34 } 

35 

36 /* Content area takes most space (like ConfigWindow) */ 

37 GroupBySelectorWindow .dialog-content { 

38 height: 1fr; 

39 width: 100%; 

40 } 

41 

42 /* Top buttons row */ 

43 GroupBySelectorWindow #top_buttons { 

44 height: auto; 

45 align: center middle; 

46 width: 100%; 

47 } 

48 

49 /* Lists container fills remaining content space */ 

50 GroupBySelectorWindow #lists_container { 

51 height: 1fr; 

52 width: 100%; 

53 } 

54 

55 /* Bottom buttons row - same as top buttons */ 

56 GroupBySelectorWindow #bottom_buttons { 

57 height: auto; 

58 align: center middle; 

59 width: 100%; 

60 } 

61 

62 /* All buttons styling */ 

63 GroupBySelectorWindow Button { 

64 width: auto; 

65 margin: 0 1; 

66 } 

67 

68 /* Top buttons more compact */ 

69 GroupBySelectorWindow #top_buttons Button { 

70 min-width: 4; 

71 } 

72 

73 /* Bottom buttons standard size */ 

74 GroupBySelectorWindow #bottom_buttons Button { 

75 min-width: 8; 

76 } 

77 

78 /* List views fill container */ 

79 GroupBySelectorWindow ListView { 

80 height: 1fr; 

81 min-height: 3; 

82 } 

83 

84 /* Static labels */ 

85 GroupBySelectorWindow Static { 

86 height: auto; 

87 padding: 0; 

88 margin: 0; 

89 } 

90 """ 

91 

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 

106 

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() 

110 

111 logger.debug(f"{component_type.title()} dialog: available={self.current_available}, selected={self.current_selected}") 

112 

113 # Dynamic title based on component type 

114 title = f"Select {self.component_type.title()}s" 

115 

116 super().__init__( 

117 window_id="group_by_selector", 

118 title=title, 

119 mode="temporary", 

120 **kwargs 

121 ) 

122 

123 def _format_component_display(self, component_key: str) -> str: 

124 """ 

125 Format component key for display with metadata if available. 

126 

127 Args: 

128 component_key: Component key (e.g., "1", "2", "A01") 

129 

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}" 

134 

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) 

141 

142 if metadata_name: 

143 return f"{base_text} | {metadata_name}" 

144 

145 return base_text 

146 

147 def _extract_channel_from_display(self, display_text: str) -> Optional[str]: 

148 """Extract the channel key from formatted display text. 

149 

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 

165 

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) 

176 

177 # Dual lists 

178 with Horizontal(id="lists_container"): 

179 with Vertical(): 

180 yield Static("Available") 

181 yield ListView(id="available_list") 

182 

183 with Vertical(): 

184 yield Static("Selected") 

185 yield ListView(id="selected_list") 

186 

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) 

191 

192 def on_mount(self) -> None: 

193 """Initialize the lists.""" 

194 self._update_lists() 

195 

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))) 

205 

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))) 

213 

214 # Clear selections to prevent stale index issues 

215 available_list.index = None 

216 selected_list.index = None 

217 

218 logger.debug(f"Updated lists: available={self.current_available}, selected={self.current_selected}") 

219 

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() 

234 

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) 

247 

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") 

253 

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) 

266 

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") 

272 

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") 

279 

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") 

286 

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() 

292 

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()