diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py index 4cfebe2..cacee73 100644 --- a/src/plugin_helper/tui.py +++ b/src/plugin_helper/tui.py @@ -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) diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 099ba31..c0bbfb1 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -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()