180 lines
5.9 KiB
Python
180 lines
5.9 KiB
Python
"""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
|