Coverage for openhcs/textual_tui/widgets/button_list_widget.py: 0.0%

271 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +0000

1""" 

2Reusable ButtonListWidget - Enhanced SelectionList with integrated buttons. 

3 

4This widget implements two key patterns: 

51. Top button bar for global actions 

62. SelectionList with left-aligned Up/Down buttons for item reordering 

7 

8Used by PlateManager and PipelineEditor for consistent behavior. 

9""" 

10 

11from typing import List, Dict, Callable, Optional, Tuple 

12from textual.app import ComposeResult 

13from textual.containers import Vertical, Horizontal 

14from textual.widget import Widget 

15from textual.widgets import Button, SelectionList 

16from textual.reactive import reactive 

17from textual.strip import Strip 

18from textual.events import Click, Resize 

19 

20import logging 

21from textual import on 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26class ButtonConfig: 

27 """Configuration for a button in the button row.""" 

28 

29 def __init__( 

30 self, 

31 label: str, 

32 button_id: str, 

33 disabled: bool = False, 

34 compact: bool = True 

35 ): 

36 self.label = label 

37 self.button_id = button_id 

38 self.disabled = disabled 

39 self.compact = compact 

40 

41 

42class InlineButtonSelectionList(SelectionList): 

43 """SelectionList with ↑↓ buttons rendered directly in each line.""" 

44 

45 def __init__(self, on_item_moved_callback=None, **kwargs): 

46 super().__init__(**kwargs) 

47 self.on_item_moved_callback = on_item_moved_callback 

48 

49 def _calculate_content_width(self) -> int: 

50 """Calculate the width needed to display the longest item without truncation.""" 

51 if not self._options: 

52 return 0 

53 

54 # Find the longest text content 

55 max_width = 0 

56 for option in self._options: 

57 # Get the text content from the option 

58 text = str(option.prompt) 

59 # Add button width (7 characters: " ↑ ↓ ") plus some padding 

60 content_width = len(text) + 7 + 2 # buttons + padding 

61 max_width = max(max_width, content_width) 

62 

63 return max_width 

64 

65 def _calculate_option_height(self, text: str, available_width: int) -> int: 

66 """Calculate the height needed for an option based on text length and available width.""" 

67 if not text or available_width <= 7: # Need space for buttons 

68 return 1 

69 

70 # Account for button space (7 chars) and padding 

71 text_width = available_width - 7 - 2 # buttons + padding 

72 

73 if text_width <= 0: 

74 return 1 

75 

76 # Calculate how many lines needed for the text 

77 text_length = len(text) 

78 lines_needed = max(1, (text_length + text_width - 1) // text_width) # Ceiling division 

79 

80 # Cap at 3 lines maximum to prevent excessive height 

81 return min(lines_needed, 3) 

82 

83 

84 

85 def _update_content_width(self): 

86 """Update the widget width based on content.""" 

87 content_width = self._calculate_content_width() 

88 if content_width > 0: 

89 # Set minimum width to content width but allow expansion to fill container 

90 self.styles.min_width = content_width 

91 self.styles.width = "100%" # Fill available space 

92 else: 

93 # Reset to auto width when no content 

94 self.styles.min_width = 0 

95 self.styles.width = "auto" 

96 

97 def render_line(self, y: int) -> Strip: 

98 """Override to add ↑↓ buttons at start of each line with horizontal scrolling support.""" 

99 # Get original line from SelectionList 

100 original = super().render_line(y) 

101 

102 # Only add buttons if we have options and this is a valid line 

103 if y < self.option_count: 

104 # Add button text with proper styling 

105 from textual.strip import Strip 

106 from rich.segment import Segment 

107 from rich.style import Style 

108 

109 # Create styled button segments 

110 button_style = Style(color="white", bold=True) 

111 up_segment = Segment(" ↑ ", button_style) 

112 down_segment = Segment(" ↓ ", button_style) 

113 space_segment = Segment(" ") 

114 

115 buttons = Strip([up_segment, down_segment, space_segment]) 

116 

117 # Combine strips - don't truncate, let overflow-x handle scrolling 

118 combined = Strip.join([buttons, original]) 

119 

120 # Allow the strip to be wider than the widget for horizontal scrolling 

121 # The overflow-x: auto CSS will handle the scrolling 

122 return combined 

123 else: 

124 return original 

125 

126 @on(Click) 

127 def handle_click(self, event: Click) -> None: 

128 """Handle clicks on ↑↓ buttons, pass other clicks to SelectionList.""" 

129 # Get content offset to account for padding/borders 

130 content_offset = event.get_content_offset(self) 

131 if content_offset is None: 

132 # Click outside content area - let it bubble up normally 

133 return 

134 

135 # Use content-relative coordinates 

136 x, y = content_offset.x, content_offset.y 

137 

138 # Check if click is in button area (first 7 characters: " ↑ ↓ ") 

139 if x < 7 and y < self.option_count: 

140 row = int(y) # Ensure integer row 

141 if 0 <= x <= 2: # ↑ button area 

142 if row > 0 and self.on_item_moved_callback: 

143 self.on_item_moved_callback(row, row - 1) 

144 event.stop() # Stop event - this was a button click 

145 return 

146 elif 3 <= x <= 5: # ↓ button area 

147 if row < self.option_count - 1 and self.on_item_moved_callback: 

148 self.on_item_moved_callback(row, row + 1) 

149 event.stop() # Stop event - this was a button click 

150 return 

151 

152 # Not a button click - let normal SelectionList behavior continue 

153 # DO NOT call event.stop() here - let the event bubble to SelectionList 

154 

155 

156 

157 

158 

159class ButtonListWidget(Widget): 

160 """ 

161 A widget that combines a button row with an enhanced SelectionList. 

162 

163 Layout: 

164 - Button row at top (height: auto) 

165 - Enhanced SelectionList with inline up/down buttons on each item 

166 """ 

167 

168 # Reactive properties for data and selection 

169 items: reactive[List[Dict]] = reactive([]) 

170 selected_item: reactive[str] = reactive("") # First selected item (for backward compatibility) 

171 highlighted_item: reactive[str] = reactive("") # Currently highlighted item (blue highlight) 

172 selected_items: reactive[List[str]] = reactive([]) # All selected items (checkmarks) 

173 

174 # Button width calculation constants 

175 BUTTON_MARGIN_WIDTH = 4 # Total horizontal margin/padding for button width calculation 

176 

177 def __init__( 

178 self, 

179 button_configs: List[ButtonConfig], 

180 list_id: str = "content_list", 

181 container_id: str = "content_container", 

182 on_button_pressed: Optional[Callable[[str], None]] = None, 

183 on_selection_changed: Optional[Callable[[List[str]], None]] = None, 

184 on_item_moved: Optional[Callable[[int, int], None]] = None, 

185 **kwargs 

186 ): 

187 """ 

188 Initialize ButtonListWidget. 

189  

190 Args: 

191 button_configs: List of ButtonConfig objects defining the buttons 

192 list_id: ID for the SelectionList widget 

193 container_id: ID for the ScrollableContainer 

194 on_button_pressed: Callback for button press events (button_id) 

195 on_selection_changed: Callback for selection changes (selected_values) 

196 on_item_moved: Callback for item reordering (from_index, to_index) 

197 """ 

198 super().__init__(**kwargs) 

199 self.button_configs = button_configs 

200 self.list_id = list_id 

201 self.container_id = container_id 

202 self.on_button_pressed_callback = on_button_pressed 

203 self.on_selection_changed_callback = on_selection_changed 

204 self.on_item_moved_callback = on_item_moved 

205 

206 def compose(self) -> ComposeResult: 

207 """Compose the button-list layout.""" 

208 with Vertical(): 

209 # Button rows - natural wrapping based on content width 

210 from textual.containers import Container 

211 with Container(classes="button-rows") as button_container: 

212 button_container.styles.height = "auto" # CRITICAL: Take only needed height 

213 self.button_container = button_container # Store reference for dynamic sizing 

214 

215 # We'll add button rows dynamically in on_mount 

216 pass 

217 

218 # Use SelectionList with overlaid buttons wrapped in ScrollableContainer for horizontal scrolling 

219 from textual.containers import ScrollableContainer 

220 with ScrollableContainer() as scroll_container: 

221 scroll_container.styles.height = "1fr" # CRITICAL: Fill remaining space 

222 selection_list = InlineButtonSelectionList( 

223 id=self.list_id, 

224 on_item_moved_callback=self.on_item_moved_callback 

225 ) 

226 yield selection_list 

227 

228 def on_mount(self) -> None: 

229 """Called when widget is mounted.""" 

230 self._update_button_states() 

231 # Update the SelectionList when mounted 

232 if self.items: 

233 self.call_later(self._update_selection_list) 

234 # Set up button rows 

235 self.call_later(self._create_button_rows) 

236 

237 def watch_items(self, items: List[Dict]) -> None: 

238 """Automatically update UI when items reactive property changes.""" 

239 logger.debug(f"watch_items called with {len(items)} items") 

240 # Update the SelectionList 

241 self._update_selection_list() 

242 # Update button states 

243 self._update_button_states() 

244 

245 def watch_selected_item(self, item_value: str) -> None: 

246 """Automatically update UI when selected_item changes.""" 

247 self._update_button_states() 

248 logger.debug(f"Selected item: {item_value}") 

249 

250 def format_item_for_display(self, item: Dict) -> Tuple[str, str]: 

251 """ 

252 Format an item for display in the SelectionList. 

253 

254 Subclasses should override this method. 

255 

256 Args: 

257 item: Item dictionary 

258 

259 Returns: 

260 Tuple of (display_text, value) 

261 """ 

262 # Default implementation - subclasses should override 

263 name = item.get('name', 'Unknown') 

264 value = item.get('path', item.get('id', str(item))) 

265 return name, value 

266 

267 def _sanitize_id(self, value: str) -> str: 

268 """ 

269 Sanitize a value for use as a Textual widget ID. 

270 

271 Textual IDs must contain only letters, numbers, underscores, or hyphens, 

272 and must not begin with a number. 

273 """ 

274 import re 

275 # Replace invalid characters with underscores 

276 sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', value) 

277 # Ensure it doesn't start with a number 

278 if sanitized and sanitized[0].isdigit(): 

279 sanitized = f"item_{sanitized}" 

280 # Ensure it's not empty 

281 if not sanitized: 

282 sanitized = "item_unknown" 

283 return sanitized 

284 

285 @on(SelectionList.SelectedChanged) 

286 def handle_selected_changed(self, event: SelectionList.SelectedChanged) -> None: 

287 """Handle SelectionList selection changes (checkmarks).""" 

288 # Get all selected items (checkmarks) 

289 selected_values = event.selection_list.selected 

290 self.selected_items = list(selected_values) 

291 

292 # Update selected_item for backward compatibility (first selected item) 

293 if selected_values: 

294 self.selected_item = selected_values[0] 

295 else: 

296 self.selected_item = "" 

297 

298 # Notify callback if provided 

299 if self.on_selection_changed_callback: 

300 self.on_selection_changed_callback(selected_values) 

301 

302 # Update button states 

303 self._update_button_states() 

304 

305 @on(SelectionList.SelectionHighlighted) 

306 def handle_highlight_changed(self, event: SelectionList.SelectionHighlighted) -> None: 

307 """Handle SelectionList highlight changes (blue highlight).""" 

308 try: 

309 # Get the highlighted item using the selection_list's highlighted property 

310 selection_list = event.selection_list 

311 highlighted_index = selection_list.highlighted 

312 

313 if highlighted_index is not None and 0 <= highlighted_index < len(self.items): 

314 # Get the value for the highlighted item 

315 highlighted_item = self.items[highlighted_index] 

316 _, value = self.format_item_for_display(highlighted_item) 

317 self.highlighted_item = value 

318 logger.debug(f"Highlight changed to index {highlighted_index}: {value}") 

319 else: 

320 self.highlighted_item = "" 

321 logger.debug("No highlight (cleared)") 

322 except Exception as e: 

323 logger.warning(f"Failed to handle highlight change: {e}") 

324 self.highlighted_item = "" 

325 

326 # Update button states 

327 self._update_button_states() 

328 

329 @on(Button.Pressed) 

330 async def handle_button_pressed(self, event: Button.Pressed) -> None: 

331 """Handle button presses from the top button bar (supports both sync and async callbacks).""" 

332 button_id = event.button.id 

333 

334 # CRITICAL: Stop event propagation 

335 event.stop() 

336 

337 # Notify callback if provided (support both sync and async callbacks) 

338 if self.on_button_pressed_callback: 

339 import inspect 

340 

341 if inspect.iscoroutinefunction(self.on_button_pressed_callback): 

342 # Async callback - await it 

343 await self.on_button_pressed_callback(button_id) 

344 else: 

345 # Sync callback - call normally 

346 self.on_button_pressed_callback(button_id) 

347 

348 def get_selection_state(self) -> Tuple[List[Dict], str]: 

349 """ 

350 Get current selection state. 

351 

352 Returns: 

353 Tuple of (selected_items, selection_mode) 

354 """ 

355 # Use the selected_item from our custom list 

356 if self.selected_item: 

357 # Find the selected item 

358 selected_items = [] 

359 for item in self.items: 

360 _, value = self.format_item_for_display(item) 

361 if value == self.selected_item: 

362 selected_items.append(item) 

363 break 

364 

365 return selected_items, "cursor" 

366 else: 

367 return [], "empty" 

368 

369 def _update_selection_list(self) -> None: 

370 """Update the InlineButtonSelectionList with current items.""" 

371 if not self.is_mounted: 

372 logger.debug("Widget not mounted yet, skipping list update") 

373 return 

374 

375 try: 

376 # Get the InlineButtonSelectionList instance 

377 selection_list = self.query_one(f"#{self.list_id}", InlineButtonSelectionList) 

378 

379 # Clear existing options 

380 selection_list.clear_options() 

381 

382 # Add options for each item - SelectionList uses simple tuples (text, value) 

383 options = [] 

384 for item in self.items: 

385 display_text, value = self.format_item_for_display(item) 

386 options.append((display_text, value)) 

387 logger.info(f"Adding option: {display_text}") 

388 

389 selection_list.add_options(options) 

390 logger.debug(f"Added {len(options)} options to SelectionList") 

391 

392 # Force refresh the SelectionList display 

393 selection_list.refresh() 

394 logger.debug("Called refresh() on SelectionList") 

395 

396 # Force refresh the SelectionList display 

397 try: 

398 selection_list.refresh() 

399 logger.debug("Called refresh() on SelectionList") 

400 except Exception as e: 

401 logger.warning(f"Failed to refresh SelectionList: {e}") 

402 

403 # Set selection if we have a selected item 

404 if self.selected_item: 

405 try: 

406 selection_list.highlighted = self.selected_item 

407 except Exception: 

408 pass # Item not found, ignore 

409 

410 # Update content width for horizontal scrolling (only when items change) 

411 selection_list._update_content_width() 

412 

413 except Exception as e: 

414 logger.error(f"Failed to update InlineButtonSelectionList: {e}", exc_info=True) 

415 

416 def _delayed_update_display(self, retry_count: int = 0) -> None: 

417 """Update the display - called when widget is mounted or as fallback.""" 

418 try: 

419 self._update_selection_list() 

420 except Exception as e: 

421 # Limit retries to prevent infinite loops 

422 if retry_count < 5: # Max 5 retries 

423 logger.warning(f"Delayed update failed (widget may not be ready), retry {retry_count + 1}/5: {e}") 

424 self.call_later(lambda: self._delayed_update_display(retry_count + 1)) 

425 else: 

426 logger.error(f"Delayed update failed after 5 retries, giving up: {e}") 

427 

428 def action_add_item_buttons(self) -> None: 

429 """Add buttons to list items - not needed with InlineButtonSelectionList.""" 

430 # This is a no-op since the InlineButtonSelectionList handles button rendering 

431 pass 

432 

433 def _create_button_rows(self) -> None: 

434 """Create button rows by calculating width from text length + CSS margins.""" 

435 if not hasattr(self, 'button_container') or not self.is_mounted: 

436 return 

437 

438 try: 

439 # Clear existing content 

440 self.button_container.remove_children() 

441 

442 # Get container width 

443 container_width = self.button_container.size.width 

444 if container_width <= 0: 

445 # Fallback: create single row 

446 self._create_single_row() 

447 return 

448 

449 # Calculate button widths from text length + margin/padding 

450 # Uses class constant for consistent width calculation 

451 button_widths = [] 

452 for config in self.button_configs: 

453 text_width = len(config.label) 

454 total_width = text_width + self.BUTTON_MARGIN_WIDTH 

455 button_widths.append((config, total_width)) 

456 

457 # Calculate minimum rows needed and distribute evenly 

458 import math 

459 

460 total_width = sum(width for _, width in button_widths) 

461 min_rows_needed = math.ceil(total_width / container_width) if container_width > 0 else 1 

462 

463 # Ensure we have at least 1 row 

464 min_rows_needed = max(1, min_rows_needed) 

465 

466 # Create empty rows 

467 rows = [[] for _ in range(min_rows_needed)] 

468 row_widths = [0] * min_rows_needed 

469 

470 # Distribute buttons to balance width across rows 

471 for config, width in button_widths: 

472 # Find row with smallest current width 

473 lightest_row_idx = min(range(min_rows_needed), key=lambda i: row_widths[i]) 

474 

475 # Add button to that row 

476 rows[lightest_row_idx].append(config) 

477 row_widths[lightest_row_idx] += width 

478 

479 # Create the final button rows 

480 for row_configs in rows: 

481 row = Horizontal() 

482 row.styles.height = "auto" 

483 row.styles.width = "100%" 

484 

485 # Mount the row first 

486 self.button_container.mount(row) 

487 

488 # Then mount buttons to the row 

489 for config in row_configs: 

490 button = Button( 

491 config.label, 

492 id=config.button_id, 

493 disabled=config.disabled, 

494 compact=config.compact 

495 ) 

496 row.mount(button) 

497 

498 

499 

500 except Exception as e: 

501 logger.error(f"Failed to create button rows: {e}", exc_info=True) 

502 # Fallback to single row 

503 self._create_single_row() 

504 

505 def _create_single_row(self) -> None: 

506 """Fallback: create a single row with all buttons.""" 

507 row = Horizontal() 

508 row.styles.height = "auto" 

509 row.styles.width = "100%" 

510 

511 # Mount the row first 

512 self.button_container.mount(row) 

513 

514 # Then mount buttons to the row 

515 for config in self.button_configs: 

516 button = Button( 

517 config.label, 

518 id=config.button_id, 

519 disabled=config.disabled, 

520 compact=config.compact 

521 ) 

522 row.mount(button) 

523 

524 @on(Resize) 

525 def handle_resize(self, event: Resize) -> None: 

526 """Handle container resize to update button layout.""" 

527 # Recreate button rows when container is resized 

528 self.call_later(self._create_button_rows) 

529 

530 def _update_button_states(self) -> None: 

531 """ 

532 Update button enabled/disabled states. 

533 

534 Subclasses should override this method to implement specific button logic. 

535 """ 

536 

537 # Default implementation - subclasses should override 

538 has_items = len(self.items) > 0 

539 has_selection = bool(self.selected_item) 

540 

541 # Basic logic - enable buttons based on data availability 

542 buttons_found = 0 

543 for config in self.button_configs: 

544 try: 

545 button = self.query_one(f"#{config.button_id}", Button) 

546 buttons_found += 1 

547 # Default: disable if no items, enable if has selection 

548 if "add" in config.button_id.lower(): 

549 button.disabled = False # Add always enabled 

550 else: 

551 button.disabled = not has_selection # Others need selection 

552 except Exception as e: 

553 # Button might not be mounted yet 

554 

555 pass 

556 

557