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

275 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +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, Any, Callable, Optional, Tuple, cast, Iterable 

12from textual.app import ComposeResult 

13from textual.containers import Vertical, Horizontal, ScrollableContainer 

14from textual.widget import Widget 

15from textual.widgets import Button, SelectionList, Static 

16from textual.widgets._selection_list import Selection 

17from textual.reactive import reactive 

18from textual.strip import Strip 

19from textual.events import Click, Resize 

20from rich.segment import Segment 

21from rich.style import Style 

22 

23import logging 

24from textual import on 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29class ButtonConfig: 

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

31 

32 def __init__( 

33 self, 

34 label: str, 

35 button_id: str, 

36 disabled: bool = False, 

37 compact: bool = True 

38 ): 

39 self.label = label 

40 self.button_id = button_id 

41 self.disabled = disabled 

42 self.compact = compact 

43 

44 

45class InlineButtonSelectionList(SelectionList): 

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

47 

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

49 super().__init__(**kwargs) 

50 self.on_item_moved_callback = on_item_moved_callback 

51 

52 def _calculate_content_width(self) -> int: 

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

54 if not self._options: 

55 return 0 

56 

57 # Find the longest text content 

58 max_width = 0 

59 for option in self._options: 

60 # Get the text content from the option 

61 text = str(option.prompt) 

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

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

64 max_width = max(max_width, content_width) 

65 

66 return max_width 

67 

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

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

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

71 return 1 

72 

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

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

75 

76 if text_width <= 0: 

77 return 1 

78 

79 # Calculate how many lines needed for the text 

80 text_length = len(text) 

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

82 

83 # Cap at 3 lines maximum to prevent excessive height 

84 return min(lines_needed, 3) 

85 

86 

87 

88 def _update_content_width(self): 

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

90 content_width = self._calculate_content_width() 

91 if content_width > 0: 

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

93 self.styles.min_width = content_width 

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

95 else: 

96 # Reset to auto width when no content 

97 self.styles.min_width = 0 

98 self.styles.width = "auto" 

99 

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

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

102 # Get original line from SelectionList 

103 original = super().render_line(y) 

104 

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

106 if y < self.option_count: 

107 # Add button text with proper styling 

108 from textual.strip import Strip 

109 from rich.segment import Segment 

110 from rich.style import Style 

111 

112 # Create styled button segments 

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

114 up_segment = Segment(" ↑ ", button_style) 

115 down_segment = Segment(" ↓ ", button_style) 

116 space_segment = Segment(" ") 

117 

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

119 

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

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

122 

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

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

125 return combined 

126 else: 

127 return original 

128 

129 @on(Click) 

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

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

132 # Get content offset to account for padding/borders 

133 content_offset = event.get_content_offset(self) 

134 if content_offset is None: 

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

136 return 

137 

138 # Use content-relative coordinates 

139 x, y = content_offset.x, content_offset.y 

140 

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

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

143 row = int(y) # Ensure integer row 

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

145 if row > 0 and self.on_item_moved_callback: 

146 self.on_item_moved_callback(row, row - 1) 

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

148 return 

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

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

151 self.on_item_moved_callback(row, row + 1) 

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

153 return 

154 

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

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

157 

158 

159 

160 

161 

162class ButtonListWidget(Widget): 

163 """ 

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

165 

166 Layout: 

167 - Button row at top (height: auto) 

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

169 """ 

170 

171 # Reactive properties for data and selection 

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

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

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

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

176 

177 # Button width calculation constants 

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

179 

180 def __init__( 

181 self, 

182 button_configs: List[ButtonConfig], 

183 list_id: str = "content_list", 

184 container_id: str = "content_container", 

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

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

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

188 **kwargs 

189 ): 

190 """ 

191 Initialize ButtonListWidget. 

192  

193 Args: 

194 button_configs: List of ButtonConfig objects defining the buttons 

195 list_id: ID for the SelectionList widget 

196 container_id: ID for the ScrollableContainer 

197 on_button_pressed: Callback for button press events (button_id) 

198 on_selection_changed: Callback for selection changes (selected_values) 

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

200 """ 

201 super().__init__(**kwargs) 

202 self.button_configs = button_configs 

203 self.list_id = list_id 

204 self.container_id = container_id 

205 self.on_button_pressed_callback = on_button_pressed 

206 self.on_selection_changed_callback = on_selection_changed 

207 self.on_item_moved_callback = on_item_moved 

208 

209 def compose(self) -> ComposeResult: 

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

211 with Vertical(): 

212 # Button rows - natural wrapping based on content width 

213 from textual.containers import Container 

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

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

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

217 

218 # We'll add button rows dynamically in on_mount 

219 pass 

220 

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

222 from textual.containers import ScrollableContainer 

223 with ScrollableContainer() as scroll_container: 

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

225 selection_list = InlineButtonSelectionList( 

226 id=self.list_id, 

227 on_item_moved_callback=self.on_item_moved_callback 

228 ) 

229 yield selection_list 

230 

231 def on_mount(self) -> None: 

232 """Called when widget is mounted.""" 

233 self._update_button_states() 

234 # Update the SelectionList when mounted 

235 if self.items: 

236 self.call_later(self._update_selection_list) 

237 # Set up button rows 

238 self.call_later(self._create_button_rows) 

239 

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

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

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

243 # Update the SelectionList 

244 self._update_selection_list() 

245 # Update button states 

246 self._update_button_states() 

247 

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

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

250 self._update_button_states() 

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

252 

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

254 """ 

255 Format an item for display in the SelectionList. 

256 

257 Subclasses should override this method. 

258 

259 Args: 

260 item: Item dictionary 

261 

262 Returns: 

263 Tuple of (display_text, value) 

264 """ 

265 # Default implementation - subclasses should override 

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

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

268 return name, value 

269 

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

271 """ 

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

273 

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

275 and must not begin with a number. 

276 """ 

277 import re 

278 # Replace invalid characters with underscores 

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

280 # Ensure it doesn't start with a number 

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

282 sanitized = f"item_{sanitized}" 

283 # Ensure it's not empty 

284 if not sanitized: 

285 sanitized = "item_unknown" 

286 return sanitized 

287 

288 @on(SelectionList.SelectedChanged) 

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

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

291 # Get all selected items (checkmarks) 

292 selected_values = event.selection_list.selected 

293 self.selected_items = list(selected_values) 

294 

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

296 if selected_values: 

297 self.selected_item = selected_values[0] 

298 else: 

299 self.selected_item = "" 

300 

301 # Notify callback if provided 

302 if self.on_selection_changed_callback: 

303 self.on_selection_changed_callback(selected_values) 

304 

305 # Update button states 

306 self._update_button_states() 

307 

308 @on(SelectionList.SelectionHighlighted) 

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

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

311 try: 

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

313 selection_list = event.selection_list 

314 highlighted_index = selection_list.highlighted 

315 

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

317 # Get the value for the highlighted item 

318 highlighted_item = self.items[highlighted_index] 

319 _, value = self.format_item_for_display(highlighted_item) 

320 self.highlighted_item = value 

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

322 else: 

323 self.highlighted_item = "" 

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

325 except Exception as e: 

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

327 self.highlighted_item = "" 

328 

329 # Update button states 

330 self._update_button_states() 

331 

332 @on(Button.Pressed) 

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

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

335 button_id = event.button.id 

336 

337 # CRITICAL: Stop event propagation 

338 event.stop() 

339 

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

341 if self.on_button_pressed_callback: 

342 import asyncio 

343 import inspect 

344 

345 if inspect.iscoroutinefunction(self.on_button_pressed_callback): 

346 # Async callback - await it 

347 await self.on_button_pressed_callback(button_id) 

348 else: 

349 # Sync callback - call normally 

350 self.on_button_pressed_callback(button_id) 

351 

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

353 """ 

354 Get current selection state. 

355 

356 Returns: 

357 Tuple of (selected_items, selection_mode) 

358 """ 

359 # Use the selected_item from our custom list 

360 if self.selected_item: 

361 # Find the selected item 

362 selected_items = [] 

363 for item in self.items: 

364 _, value = self.format_item_for_display(item) 

365 if value == self.selected_item: 

366 selected_items.append(item) 

367 break 

368 

369 return selected_items, "cursor" 

370 else: 

371 return [], "empty" 

372 

373 def _update_selection_list(self) -> None: 

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

375 if not self.is_mounted: 

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

377 return 

378 

379 try: 

380 # Get the InlineButtonSelectionList instance 

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

382 

383 # Clear existing options 

384 selection_list.clear_options() 

385 

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

387 options = [] 

388 for item in self.items: 

389 display_text, value = self.format_item_for_display(item) 

390 options.append((display_text, value)) 

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

392 

393 selection_list.add_options(options) 

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

395 

396 # Force refresh the SelectionList display 

397 selection_list.refresh() 

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

399 

400 # Force refresh the SelectionList display 

401 try: 

402 selection_list.refresh() 

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

404 except Exception as e: 

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

406 

407 # Set selection if we have a selected item 

408 if self.selected_item: 

409 try: 

410 selection_list.highlighted = self.selected_item 

411 except Exception: 

412 pass # Item not found, ignore 

413 

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

415 selection_list._update_content_width() 

416 

417 except Exception as e: 

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

419 

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

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

422 try: 

423 self._update_selection_list() 

424 except Exception as e: 

425 # Limit retries to prevent infinite loops 

426 if retry_count < 5: # Max 5 retries 

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

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

429 else: 

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

431 

432 def action_add_item_buttons(self) -> None: 

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

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

435 pass 

436 

437 def _create_button_rows(self) -> None: 

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

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

440 return 

441 

442 try: 

443 # Clear existing content 

444 self.button_container.remove_children() 

445 

446 # Get container width 

447 container_width = self.button_container.size.width 

448 if container_width <= 0: 

449 # Fallback: create single row 

450 self._create_single_row() 

451 return 

452 

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

454 # Uses class constant for consistent width calculation 

455 button_widths = [] 

456 for config in self.button_configs: 

457 text_width = len(config.label) 

458 total_width = text_width + self.BUTTON_MARGIN_WIDTH 

459 button_widths.append((config, total_width)) 

460 

461 # Calculate minimum rows needed and distribute evenly 

462 import math 

463 

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

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

466 

467 # Ensure we have at least 1 row 

468 min_rows_needed = max(1, min_rows_needed) 

469 

470 # Create empty rows 

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

472 row_widths = [0] * min_rows_needed 

473 

474 # Distribute buttons to balance width across rows 

475 for config, width in button_widths: 

476 # Find row with smallest current width 

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

478 

479 # Add button to that row 

480 rows[lightest_row_idx].append(config) 

481 row_widths[lightest_row_idx] += width 

482 

483 # Create the final button rows 

484 for row_configs in rows: 

485 row = Horizontal() 

486 row.styles.height = "auto" 

487 row.styles.width = "100%" 

488 

489 # Mount the row first 

490 self.button_container.mount(row) 

491 

492 # Then mount buttons to the row 

493 for config in row_configs: 

494 button = Button( 

495 config.label, 

496 id=config.button_id, 

497 disabled=config.disabled, 

498 compact=config.compact 

499 ) 

500 row.mount(button) 

501 

502 

503 

504 except Exception as e: 

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

506 # Fallback to single row 

507 self._create_single_row() 

508 

509 def _create_single_row(self) -> None: 

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

511 row = Horizontal() 

512 row.styles.height = "auto" 

513 row.styles.width = "100%" 

514 

515 # Mount the row first 

516 self.button_container.mount(row) 

517 

518 # Then mount buttons to the row 

519 for config in self.button_configs: 

520 button = Button( 

521 config.label, 

522 id=config.button_id, 

523 disabled=config.disabled, 

524 compact=config.compact 

525 ) 

526 row.mount(button) 

527 

528 @on(Resize) 

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

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

531 # Recreate button rows when container is resized 

532 self.call_later(self._create_button_rows) 

533 

534 def _update_button_states(self) -> None: 

535 """ 

536 Update button enabled/disabled states. 

537 

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

539 """ 

540 

541 # Default implementation - subclasses should override 

542 has_items = len(self.items) > 0 

543 has_selection = bool(self.selected_item) 

544 

545 # Basic logic - enable buttons based on data availability 

546 buttons_found = 0 

547 for config in self.button_configs: 

548 try: 

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

550 buttons_found += 1 

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

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

553 button.disabled = False # Add always enabled 

554 else: 

555 button.disabled = not has_selection # Others need selection 

556 except Exception as e: 

557 # Button might not be mounted yet 

558 

559 pass 

560 

561