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 typing import Any
from rich.text import Text from rich.text import Text
from textual import events from textual import events, on
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.coordinate import Coordinate 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 .bsipa import BSIPA_PLUGIN_ID
from .installer import disable_plugin from .installer import disable_plugin
@@ -78,6 +78,14 @@ class PluginHelperTui(App[int]):
text-style: bold; text-style: bold;
} }
#filter {
margin: 0 1;
}
#filter.-hidden {
display: none;
}
#status { #status {
padding: 0 1; padding: 0 1;
color: $text-muted; color: $text-muted;
@@ -86,6 +94,8 @@ class PluginHelperTui(App[int]):
BINDINGS = [ BINDINGS = [
Binding("enter", "select", "Select", priority=True), Binding("enter", "select", "Select", priority=True),
Binding("space", "toggle_plugin", "Toggle", 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("d", "disable_all_plugins", "Disable all", priority=True),
Binding("e", "enable_all_plugins", "Enable all", priority=True), Binding("e", "enable_all_plugins", "Enable all", priority=True),
Binding("s", "save_known_good", "Save known-good", priority=True), Binding("s", "save_known_good", "Save known-good", priority=True),
@@ -110,19 +120,42 @@ class PluginHelperTui(App[int]):
self.mode = "installations" self.mode = "installations"
self.selected_installation: InstallationChoice | None = None self.selected_installation: InstallationChoice | None = None
self.plugin_rows: list[dict[str, Any]] = [] self.plugin_rows: list[dict[str, Any]] = []
self.filter_query = ""
self.status_message = "" self.status_message = ""
self._busy = False self._busy = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=False) yield Header(show_clock=False)
yield Static("", id="title") 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 ActivatableDataTable(id="table")
yield Static("", id="status") yield Static("", id="status")
yield Footer() 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: def on_mount(self) -> None:
table = self.query_one(DataTable) table = self.query_one(DataTable)
table.cursor_type = "row" table.cursor_type = "row"
filter_input = self.query_one("#filter", Input)
filter_input.can_focus = False
if len(self.choices) == 1: if len(self.choices) == 1:
self.selected_installation = self.choices[0] self.selected_installation = self.choices[0]
self._show_plugins() self._show_plugins()
@@ -149,6 +182,7 @@ class PluginHelperTui(App[int]):
if self.mode == "plugins": if self.mode == "plugins":
if len(self.choices) == 1: if len(self.choices) == 1:
return return
self._clear_filter_state()
self._show_installations() self._show_installations()
def action_refresh(self) -> None: def action_refresh(self) -> None:
@@ -159,13 +193,63 @@ class PluginHelperTui(App[int]):
else: else:
self._show_installations() 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: async def action_toggle_plugin(self) -> None:
if self._busy or self.mode != "plugins" or self.selected_installation is None: if self._busy or self.mode != "plugins" or self.selected_installation is None:
return return
index = self._cursor_index(len(self.plugin_rows)) rows = self._filtered_plugins()
index = self._cursor_index(len(rows))
if index is None: if index is None:
return return
plugin = self.plugin_rows[index] plugin = rows[index]
plugin_id = plugin["id"] plugin_id = plugin["id"]
target = self.selected_installation target = self.selected_installation
self._busy = True self._busy = True
@@ -344,6 +428,7 @@ class PluginHelperTui(App[int]):
def _show_installations(self) -> None: def _show_installations(self) -> None:
self.mode = "installations" self.mode = "installations"
self.plugin_rows = [] self.plugin_rows = []
self._clear_filter_state()
self._set_title("Choose Beat Saber Installation") self._set_title("Choose Beat Saber Installation")
table = self.query_one(DataTable) table = self.query_one(DataTable)
table.clear(columns=True) table.clear(columns=True)
@@ -365,9 +450,12 @@ class PluginHelperTui(App[int]):
self._show_installations() self._show_installations()
return return
target = self.selected_installation target = self.selected_installation
selected_row: int | None = None selected_id: str | None = None
if self.mode == "plugins" and self.plugin_rows: 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.mode = "plugins"
self._set_title(f"{target.install_label} / {target.instance_name}") self._set_title(f"{target.install_label} / {target.instance_name}")
table = self.query_one(DataTable) table = self.query_one(DataTable)
@@ -388,7 +476,22 @@ class PluginHelperTui(App[int]):
self._set_status(f"Could not load plugins: {exc}") self._set_status(f"Could not load plugins: {exc}")
return 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( table.add_row(
self._status_marker(plugin["status"]), self._status_marker(plugin["status"]),
plugin["name"], plugin["name"],
@@ -397,17 +500,64 @@ class PluginHelperTui(App[int]):
str(plugin["fileCount"]), str(plugin["fileCount"]),
plugin["asset"], plugin["asset"],
) )
if selected_row is not None and self.plugin_rows: if selected_id is not None and filtered:
table.move_cursor(row=min(selected_row, len(self.plugin_rows) - 1)) for index, plugin in enumerate(filtered):
if not preserve_status: if plugin["id"] == selected_id:
if self.plugin_rows: table.move_cursor(row=index)
back_hint = "" if len(self.choices) == 1 else " b returns to installations." break
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}"
)
else: 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: def _cursor_index(self, row_count: int) -> int | None:
table = self.query_one(DataTable) table = self.query_one(DataTable)
+47 -3
View File
@@ -11,7 +11,7 @@ from zipfile import ZipFile
from rich.text import Text from rich.text import Text
from textual.coordinate import Coordinate 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.bootstrap import _run_ipa, build_bootstrap_command, ensure_healthy_bootstrap
from plugin_helper.bsipa import check_bsipa_health, planning_requires_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) table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2) self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations") self.assertEqual(app.mode, "installations")
self.assertEqual(str(table.get_cell_at(Coordinate(0, 3))), "/tmp/state-linux/installs/linux") self.assertEqual(table.get_cell_at(Coordinate(0, 3)), str(Path("/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(1, 3)), str(Path("/tmp/state-windows/installs/windows")))
async def test_single_instance_skips_installation_picker(self) -> None: async def test_single_instance_skips_installation_picker(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -2809,6 +2809,50 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("example", updated.get("disabledPlugins", {})) self.assertNotIn("example", updated.get("disabledPlugins", {}))
self.assertIn("hash mismatch", app.status_message) 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__": if __name__ == "__main__":
unittest.main() unittest.main()