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 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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user