Add / filter for the plugin menu list
This commit is contained in:
+167
-17
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user