Files
0kmtui/mux-0kmtui/src/mux_0kmtui/widgets/server_selector.py
2026-03-02 23:47:47 +01:00

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