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
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2Reusable ButtonListWidget - Enhanced SelectionList with integrated buttons.
4This widget implements two key patterns:
51. Top button bar for global actions
62. SelectionList with left-aligned Up/Down buttons for item reordering
8Used by PlateManager and PipelineEditor for consistent behavior.
9"""
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
20import logging
21from textual import on
23logger = logging.getLogger(__name__)
26class ButtonConfig:
27 """Configuration for a button in the button row."""
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
42class InlineButtonSelectionList(SelectionList):
43 """SelectionList with ↑↓ buttons rendered directly in each line."""
45 def __init__(self, on_item_moved_callback=None, **kwargs):
46 super().__init__(**kwargs)
47 self.on_item_moved_callback = on_item_moved_callback
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
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)
63 return max_width
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
70 # Account for button space (7 chars) and padding
71 text_width = available_width - 7 - 2 # buttons + padding
73 if text_width <= 0:
74 return 1
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
80 # Cap at 3 lines maximum to prevent excessive height
81 return min(lines_needed, 3)
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"
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)
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
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(" ")
115 buttons = Strip([up_segment, down_segment, space_segment])
117 # Combine strips - don't truncate, let overflow-x handle scrolling
118 combined = Strip.join([buttons, original])
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
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
135 # Use content-relative coordinates
136 x, y = content_offset.x, content_offset.y
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
152 # Not a button click - let normal SelectionList behavior continue
153 # DO NOT call event.stop() here - let the event bubble to SelectionList
159class ButtonListWidget(Widget):
160 """
161 A widget that combines a button row with an enhanced SelectionList.
163 Layout:
164 - Button row at top (height: auto)
165 - Enhanced SelectionList with inline up/down buttons on each item
166 """
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)
174 # Button width calculation constants
175 BUTTON_MARGIN_WIDTH = 4 # Total horizontal margin/padding for button width calculation
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.
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
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
215 # We'll add button rows dynamically in on_mount
216 pass
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
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)
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()
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}")
250 def format_item_for_display(self, item: Dict) -> Tuple[str, str]:
251 """
252 Format an item for display in the SelectionList.
254 Subclasses should override this method.
256 Args:
257 item: Item dictionary
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
267 def _sanitize_id(self, value: str) -> str:
268 """
269 Sanitize a value for use as a Textual widget ID.
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
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)
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 = ""
298 # Notify callback if provided
299 if self.on_selection_changed_callback:
300 self.on_selection_changed_callback(selected_values)
302 # Update button states
303 self._update_button_states()
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
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 = ""
326 # Update button states
327 self._update_button_states()
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
334 # CRITICAL: Stop event propagation
335 event.stop()
337 # Notify callback if provided (support both sync and async callbacks)
338 if self.on_button_pressed_callback:
339 import inspect
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)
348 def get_selection_state(self) -> Tuple[List[Dict], str]:
349 """
350 Get current selection state.
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
365 return selected_items, "cursor"
366 else:
367 return [], "empty"
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
375 try:
376 # Get the InlineButtonSelectionList instance
377 selection_list = self.query_one(f"#{self.list_id}", InlineButtonSelectionList)
379 # Clear existing options
380 selection_list.clear_options()
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}")
389 selection_list.add_options(options)
390 logger.debug(f"Added {len(options)} options to SelectionList")
392 # Force refresh the SelectionList display
393 selection_list.refresh()
394 logger.debug("Called refresh() on SelectionList")
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}")
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
410 # Update content width for horizontal scrolling (only when items change)
411 selection_list._update_content_width()
413 except Exception as e:
414 logger.error(f"Failed to update InlineButtonSelectionList: {e}", exc_info=True)
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}")
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
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
438 try:
439 # Clear existing content
440 self.button_container.remove_children()
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
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))
457 # Calculate minimum rows needed and distribute evenly
458 import math
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
463 # Ensure we have at least 1 row
464 min_rows_needed = max(1, min_rows_needed)
466 # Create empty rows
467 rows = [[] for _ in range(min_rows_needed)]
468 row_widths = [0] * min_rows_needed
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])
475 # Add button to that row
476 rows[lightest_row_idx].append(config)
477 row_widths[lightest_row_idx] += width
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%"
485 # Mount the row first
486 self.button_container.mount(row)
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)
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()
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%"
511 # Mount the row first
512 self.button_container.mount(row)
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)
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)
530 def _update_button_states(self) -> None:
531 """
532 Update button enabled/disabled states.
534 Subclasses should override this method to implement specific button logic.
535 """
537 # Default implementation - subclasses should override
538 has_items = len(self.items) > 0
539 has_selection = bool(self.selected_item)
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
555 pass