Add / filter for the plugin menu list

This commit is contained in:
pleb
2026-07-12 14:42:46 -07:00
parent 4b544e0858
commit 17d50e1239
2 changed files with 214 additions and 20 deletions
+167 -17
View File
@@ -6,11 +6,11 @@ from pathlib import Path
from typing import Any
from rich.text import Text
from textual import events
from textual import events, on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.coordinate import Coordinate
from textual.widgets import DataTable, Footer, Header, Static
from textual.widgets import DataTable, Footer, Header, Input, Static
from .bsipa import BSIPA_PLUGIN_ID
from .installer import disable_plugin
@@ -78,6 +78,14 @@ class PluginHelperTui(App[int]):
text-style: bold;
}
#filter {
margin: 0 1;
}
#filter.-hidden {
display: none;
}
#status {
padding: 0 1;
color: $text-muted;
@@ -86,6 +94,8 @@ class PluginHelperTui(App[int]):
BINDINGS = [
Binding("enter", "select", "Select", priority=True),
Binding("space", "toggle_plugin", "Toggle", priority=True),
Binding("/", "start_filter", "Filter", priority=True),
Binding("escape", "clear_filter", "Clear filter", show=False, priority=True),
Binding("d", "disable_all_plugins", "Disable all", priority=True),
Binding("e", "enable_all_plugins", "Enable all", priority=True),
Binding("s", "save_known_good", "Save known-good", priority=True),
@@ -110,19 +120,42 @@ class PluginHelperTui(App[int]):
self.mode = "installations"
self.selected_installation: InstallationChoice | None = None
self.plugin_rows: list[dict[str, Any]] = []
self.filter_query = ""
self.status_message = ""
self._busy = False
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
yield Static("", id="title")
filter_input = Input(placeholder="Filter plugins by name or id...", id="filter", classes="-hidden")
filter_input.can_focus = False
yield filter_input
yield ActivatableDataTable(id="table")
yield Static("", id="status")
yield Footer()
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
if self._filter_input_focused():
if action in {
"select",
"toggle_plugin",
"start_filter",
"disable_all_plugins",
"enable_all_plugins",
"save_known_good",
"restore_known_good",
"back",
}:
return False
elif action == "clear_filter" and not self.filter_query and not self._filter_visible():
return False
return True
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.cursor_type = "row"
filter_input = self.query_one("#filter", Input)
filter_input.can_focus = False
if len(self.choices) == 1:
self.selected_installation = self.choices[0]
self._show_plugins()
@@ -149,6 +182,7 @@ class PluginHelperTui(App[int]):
if self.mode == "plugins":
if len(self.choices) == 1:
return
self._clear_filter_state()
self._show_installations()
def action_refresh(self) -> None:
@@ -159,13 +193,63 @@ class PluginHelperTui(App[int]):
else:
self._show_installations()
def action_start_filter(self) -> None:
if self.mode != "plugins" or self._busy:
return
filter_input = self.query_one("#filter", Input)
self._set_filter_visible(True)
filter_input.can_focus = True
filter_input.focus()
self._set_status("Type to filter. Enter keeps filter. Esc clears.")
def action_clear_filter(self) -> None:
if self.mode != "plugins":
return
had_query = bool(self.filter_query)
was_focused = self._filter_input_focused()
if not had_query and not was_focused and not self._filter_visible():
return
self._clear_filter_state()
self.query_one(DataTable).focus()
if had_query:
self._render_plugin_rows()
self._set_status("Filter cleared.")
else:
self._set_plugin_status_hint()
@on(Input.Changed, "#filter")
def _on_filter_changed(self, event: Input.Changed) -> None:
if self.mode != "plugins":
return
self.filter_query = event.value
self._render_plugin_rows()
filtered = self._filtered_plugins()
if self.filter_query.strip():
self._set_status(f"Filter: {len(filtered)}/{len(self.plugin_rows)} plugins.")
else:
self._set_status("Type to filter. Enter keeps filter. Esc clears.")
@on(Input.Submitted, "#filter")
def _on_filter_submitted(self, _event: Input.Submitted) -> None:
if self.mode != "plugins":
return
if not self.filter_query.strip():
self._set_filter_visible(False)
self.query_one(DataTable).focus()
filtered = self._filtered_plugins()
if self.filter_query.strip():
self._set_status(f"Filtered to {len(filtered)}/{len(self.plugin_rows)} plugins. / edits filter.")
else:
self._set_plugin_status_hint()
async def action_toggle_plugin(self) -> None:
if self._busy or self.mode != "plugins" or self.selected_installation is None:
return
index = self._cursor_index(len(self.plugin_rows))
rows = self._filtered_plugins()
index = self._cursor_index(len(rows))
if index is None:
return
plugin = self.plugin_rows[index]
plugin = rows[index]
plugin_id = plugin["id"]
target = self.selected_installation
self._busy = True
@@ -344,6 +428,7 @@ class PluginHelperTui(App[int]):
def _show_installations(self) -> None:
self.mode = "installations"
self.plugin_rows = []
self._clear_filter_state()
self._set_title("Choose Beat Saber Installation")
table = self.query_one(DataTable)
table.clear(columns=True)
@@ -365,9 +450,12 @@ class PluginHelperTui(App[int]):
self._show_installations()
return
target = self.selected_installation
selected_row: int | None = None
selected_id: str | None = None
if self.mode == "plugins" and self.plugin_rows:
selected_row = self._cursor_index(len(self.plugin_rows))
filtered = self._filtered_plugins()
selected_row = self._cursor_index(len(filtered))
if selected_row is not None:
selected_id = filtered[selected_row]["id"]
self.mode = "plugins"
self._set_title(f"{target.install_label} / {target.instance_name}")
table = self.query_one(DataTable)
@@ -388,7 +476,22 @@ class PluginHelperTui(App[int]):
self._set_status(f"Could not load plugins: {exc}")
return
for plugin in self.plugin_rows:
self._render_plugin_rows(selected_id=selected_id)
if not preserve_status:
if self.plugin_rows:
self._set_plugin_status_hint()
else:
self._set_status("No version-locked plugins for this installation.")
def _render_plugin_rows(self, *, selected_id: str | None = None) -> None:
table = self.query_one(DataTable)
if selected_id is None and self.mode == "plugins" and table.row_count:
selected_row = self._cursor_index(table.row_count)
if selected_row is not None:
selected_id = str(table.get_row_at(selected_row)[2])
table.clear()
filtered = self._filtered_plugins()
for plugin in filtered:
table.add_row(
self._status_marker(plugin["status"]),
plugin["name"],
@@ -397,17 +500,64 @@ class PluginHelperTui(App[int]):
str(plugin["fileCount"]),
plugin["asset"],
)
if selected_row is not None and self.plugin_rows:
table.move_cursor(row=min(selected_row, len(self.plugin_rows) - 1))
if not preserve_status:
if self.plugin_rows:
back_hint = "" if len(self.choices) == 1 else " b returns to installations."
self._set_status(
"Click or Space toggles selected. d disables all. e enables all. "
f"s saves known-good. g restores known-good.{back_hint}"
)
if selected_id is not None and filtered:
for index, plugin in enumerate(filtered):
if plugin["id"] == selected_id:
table.move_cursor(row=index)
break
else:
self._set_status("No version-locked plugins for this installation.")
table.move_cursor(row=0)
elif filtered:
table.move_cursor(row=0)
def _filtered_plugins(self) -> list[dict[str, Any]]:
query = self.filter_query.strip().lower()
if not query:
return list(self.plugin_rows)
return [
plugin
for plugin in self.plugin_rows
if query in str(plugin.get("name", "")).lower()
or query in str(plugin.get("id", "")).lower()
or query in str(plugin.get("asset", "")).lower()
]
def _clear_filter_state(self) -> None:
self.filter_query = ""
try:
filter_input = self.query_one("#filter", Input)
except Exception:
return
if filter_input.value:
with filter_input.prevent(Input.Changed):
filter_input.value = ""
filter_input.can_focus = False
self._set_filter_visible(False)
def _set_filter_visible(self, visible: bool) -> None:
filter_input = self.query_one("#filter", Input)
filter_input.set_class(not visible, "-hidden")
def _filter_visible(self) -> bool:
try:
return not self.query_one("#filter", Input).has_class("-hidden")
except Exception:
return False
def _filter_input_focused(self) -> bool:
focused = self.focused
return focused is not None and getattr(focused, "id", None) == "filter"
def _set_plugin_status_hint(self) -> None:
if not self.plugin_rows:
self._set_status("No version-locked plugins for this installation.")
return
back_hint = "" if len(self.choices) == 1 else " b returns to installations."
filter_hint = f" / filters ({len(self._filtered_plugins())}/{len(self.plugin_rows)})." if self.filter_query.strip() else " / filters."
self._set_status(
"Click or Space toggles selected. d disables all. e enables all. "
f"s saves known-good. g restores known-good.{filter_hint}{back_hint}"
)
def _cursor_index(self, row_count: int) -> int | None:
table = self.query_one(DataTable)
+47 -3
View File
@@ -11,7 +11,7 @@ from zipfile import ZipFile
from rich.text import Text
from textual.coordinate import Coordinate
from textual.widgets import DataTable
from textual.widgets import DataTable, Input
from plugin_helper.bootstrap import _run_ipa, build_bootstrap_command, ensure_healthy_bootstrap
from plugin_helper.bsipa import check_bsipa_health, planning_requires_bootstrap
@@ -2606,8 +2606,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations")
self.assertEqual(str(table.get_cell_at(Coordinate(0, 3))), "/tmp/state-linux/installs/linux")
self.assertEqual(str(table.get_cell_at(Coordinate(1, 3))), "/tmp/state-windows/installs/windows")
self.assertEqual(table.get_cell_at(Coordinate(0, 3)), str(Path("/tmp/state-linux/installs/linux")))
self.assertEqual(table.get_cell_at(Coordinate(1, 3)), str(Path("/tmp/state-windows/installs/windows")))
async def test_single_instance_skips_installation_picker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@@ -2809,6 +2809,50 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("example", updated.get("disabledPlugins", {}))
self.assertIn("hash mismatch", app.status_message)
async def test_slash_filters_plugin_list(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
await pilot.press("/")
await pilot.pause()
filter_input = app.query_one("#filter", Input)
self.assertFalse(filter_input.has_class("-hidden"))
self.assertTrue(filter_input.has_focus)
await pilot.press("b", "e", "t")
await pilot.pause()
self.assertEqual(app.filter_query, "bet")
self.assertEqual(table.row_count, 1)
self.assertEqual(app._filtered_plugins()[0]["id"], "beta")
await pilot.press("enter")
await pilot.pause()
self.assertFalse(filter_input.has_focus)
self.assertEqual(table.row_count, 1)
await pilot.press("space")
await pilot.pause()
self.assertEqual(app.plugin_rows[1]["id"], "beta")
self.assertEqual(app.plugin_rows[1]["status"], "disabled")
async def test_escape_clears_plugin_filter(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
table = app.query_one(DataTable)
await pilot.press("/")
await pilot.press("a", "l")
await pilot.pause()
self.assertEqual(table.row_count, 1)
self.assertEqual(app._filtered_plugins()[0]["id"], "alpha")
await pilot.press("escape")
await pilot.pause()
self.assertEqual(app.filter_query, "")
self.assertEqual(table.row_count, 2)
self.assertTrue(app.query_one("#filter", Input).has_class("-hidden"))
self.assertIn("Filter cleared", app.status_message)
if __name__ == "__main__":
unittest.main()