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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +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, 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
23import logging
24from textual import on
26logger = logging.getLogger(__name__)
29class ButtonConfig:
30 """Configuration for a button in the button row."""
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
45class InlineButtonSelectionList(SelectionList):
46 """SelectionList with ↑↓ buttons rendered directly in each line."""
48 def __init__(self, on_item_moved_callback=None, **kwargs):
49 super().__init__(**kwargs)
50 self.on_item_moved_callback = on_item_moved_callback
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
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)
66 return max_width
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
73 # Account for button space (7 chars) and padding
74 text_width = available_width - 7 - 2 # buttons + padding
76 if text_width <= 0:
77 return 1
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
83 # Cap at 3 lines maximum to prevent excessive height
84 return min(lines_needed, 3)
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"
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)
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
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(" ")
118 buttons = Strip([up_segment, down_segment, space_segment])
120 # Combine strips - don't truncate, let overflow-x handle scrolling
121 combined = Strip.join([buttons, original])
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
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
138 # Use content-relative coordinates
139 x, y = content_offset.x, content_offset.y
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
155 # Not a button click - let normal SelectionList behavior continue
156 # DO NOT call event.stop() here - let the event bubble to SelectionList
162class ButtonListWidget(Widget):
163 """
164 A widget that combines a button row with an enhanced SelectionList.
166 Layout:
167 - Button row at top (height: auto)
168 - Enhanced SelectionList with inline up/down buttons on each item
169 """
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)
177 # Button width calculation constants
178 BUTTON_MARGIN_WIDTH = 4 # Total horizontal margin/padding for button width calculation
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.
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
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
218 # We'll add button rows dynamically in on_mount
219 pass
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
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)
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()
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}")
253 def format_item_for_display(self, item: Dict) -> Tuple[str, str]:
254 """
255 Format an item for display in the SelectionList.
257 Subclasses should override this method.
259 Args:
260 item: Item dictionary
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
270 def _sanitize_id(self, value: str) -> str:
271 """
272 Sanitize a value for use as a Textual widget ID.
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
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)
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 = ""
301 # Notify callback if provided
302 if self.on_selection_changed_callback:
303 self.on_selection_changed_callback(selected_values)
305 # Update button states
306 self._update_button_states()
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
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 = ""
329 # Update button states
330 self._update_button_states()
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
337 # CRITICAL: Stop event propagation
338 event.stop()
340 # Notify callback if provided (support both sync and async callbacks)
341 if self.on_button_pressed_callback:
342 import asyncio
343 import inspect
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)
352 def get_selection_state(self) -> Tuple[List[Dict], str]:
353 """
354 Get current selection state.
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
369 return selected_items, "cursor"
370 else:
371 return [], "empty"
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
379 try:
380 # Get the InlineButtonSelectionList instance
381 selection_list = self.query_one(f"#{self.list_id}", InlineButtonSelectionList)
383 # Clear existing options
384 selection_list.clear_options()
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}")
393 selection_list.add_options(options)
394 logger.debug(f"Added {len(options)} options to SelectionList")
396 # Force refresh the SelectionList display
397 selection_list.refresh()
398 logger.debug("Called refresh() on SelectionList")
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}")
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
414 # Update content width for horizontal scrolling (only when items change)
415 selection_list._update_content_width()
417 except Exception as e:
418 logger.error(f"Failed to update InlineButtonSelectionList: {e}", exc_info=True)
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}")
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
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
442 try:
443 # Clear existing content
444 self.button_container.remove_children()
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
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))
461 # Calculate minimum rows needed and distribute evenly
462 import math
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
467 # Ensure we have at least 1 row
468 min_rows_needed = max(1, min_rows_needed)
470 # Create empty rows
471 rows = [[] for _ in range(min_rows_needed)]
472 row_widths = [0] * min_rows_needed
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])
479 # Add button to that row
480 rows[lightest_row_idx].append(config)
481 row_widths[lightest_row_idx] += width
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%"
489 # Mount the row first
490 self.button_container.mount(row)
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)
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()
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%"
515 # Mount the row first
516 self.button_container.mount(row)
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)
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)
534 def _update_button_states(self) -> None:
535 """
536 Update button enabled/disabled states.
538 Subclasses should override this method to implement specific button logic.
539 """
541 # Default implementation - subclasses should override
542 has_items = len(self.items) > 0
543 has_selection = bool(self.selected_item)
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
559 pass