"""Server selection widget with multi-select support.""" from textual.app import ComposeResult from textual.containers import Vertical from textual.message import Message from textual.widgets import Static, SelectionList, Input from textual.widgets.selection_list import Selection from ..config import VPSHost class ServerSelector(Vertical): """Widget for selecting multiple VPS servers with search/filter support.""" DEFAULT_CSS = """ ServerSelector { border: solid $primary; height: 100%; } ServerSelector .selector-header { height: 1; background: $primary; color: $text; padding: 0 1; } ServerSelector #search-input { height: 1; border: none; padding: 0 1; background: $surface; } ServerSelector #search-input:focus { border: none; } ServerSelector .selector-info { height: 1; background: $surface; color: $text-muted; padding: 0 1; } ServerSelector SelectionList { height: 1fr; } """ class SelectionChanged(Message): """Message sent when selection changes.""" def __init__(self, selected: list[VPSHost]) -> None: super().__init__() self.selected = selected def __init__( self, hosts: list[VPSHost], *, name: str | None = None, id: str | None = None, classes: str | None = None, ) -> None: super().__init__(name=name, id=id, classes=classes) self._hosts = {h.name: h for h in hosts} self._selected_names: set[str] = set() self._current_filter: str = "" def compose(self) -> ComposeResult: yield Static("Servers (Space=toggle)", classes="selector-header") yield Input(placeholder="Filter by name or tag...", id="search-input") yield Static("0 selected", id="selection-info", classes="selector-info") yield SelectionList[str]( *self._build_selections(), id="server-list", ) def _build_selections(self) -> list[Selection[str]]: """Build selection items, filtered by current search.""" selections = [] filter_lower = self._current_filter.lower() for host in self._hosts.values(): # Check if host matches filter (name or any tag) if filter_lower: name_match = filter_lower in host.name.lower() tag_match = any(filter_lower in tag.lower() for tag in host.tags) if not (name_match or tag_match): continue # Build display label with tags if host.tags: tags_str = " [" + ", ".join(host.tags) + "]" label = f"{host.name}{tags_str}" else: label = host.name is_selected = host.name in self._selected_names selections.append(Selection(label, host.name, is_selected)) return selections def _rebuild_list(self) -> None: """Rebuild the selection list with current filter.""" selection_list = self.query_one("#server-list", SelectionList) selection_list.clear_options() for selection in self._build_selections(): selection_list.add_option(selection) self._update_info() def on_input_changed(self, event: Input.Changed) -> None: """Handle search input changes.""" if event.input.id == "search-input": self._current_filter = event.value self._rebuild_list() def on_selection_list_selected_changed( self, event: SelectionList.SelectedChanged ) -> None: """Handle selection changes.""" # Update internal tracking of selected names visible_selected = set(event.selection_list.selected) # For visible items, sync with the selection list for option in event.selection_list._options: name = option.value if name in visible_selected: self._selected_names.add(name) elif name in self._selected_names: # Only remove if it's visible and was deselected self._selected_names.discard(name) self._update_info() self.post_message(self.SelectionChanged(self.get_selected())) def _update_info(self) -> None: """Update the selection info label.""" info = self.query_one("#selection-info", Static) count = len(self._selected_names) visible_count = len(self.query_one("#server-list", SelectionList)._options) total_count = len(self._hosts) if self._current_filter: info.update(f"{count} selected ({visible_count}/{total_count} shown)") else: info.update(f"{count} selected" if count != 1 else "1 selected") def get_selected(self) -> list[VPSHost]: """Get currently selected hosts (including filtered-out ones).""" return [ self._hosts[name] for name in self._selected_names if name in self._hosts ] def select_all(self) -> None: """Select all visible servers.""" selection_list = self.query_one("#server-list", SelectionList) selection_list.select_all() # Also add to internal tracking for option in selection_list._options: self._selected_names.add(option.value) self._update_info() def deselect_all(self) -> None: """Deselect all visible servers.""" selection_list = self.query_one("#server-list", SelectionList) # Remove visible items from internal tracking for option in selection_list._options: self._selected_names.discard(option.value) selection_list.deselect_all() self._update_info() def get_all_tags(self) -> set[str]: """Get all unique tags from all hosts.""" tags = set() for host in self._hosts.values(): tags.update(host.tags) return tags