Keep TUI cursor stable after plugin toggle

This commit is contained in:
2026-07-05 14:05:35 -07:00
parent 400e69289c
commit 74ac0a1c1d
3 changed files with 94 additions and 1 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ one `[[profiles]]` entry, that profile is selected automatically when
py -m venv .venv py -m venv .venv
.\.venv\Scripts\Activate.ps1 .\.venv\Scripts\Activate.ps1
py -m pip install -e . py -m pip install -e .
py -m plugin_helper menu py -m plugin_helper
py -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1 py -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1
``` ```
+5
View File
@@ -256,6 +256,9 @@ 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
if self.mode == "plugins" and self.plugin_rows:
selected_row = self._cursor_index(len(self.plugin_rows))
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)
@@ -285,6 +288,8 @@ class PluginHelperTui(App[int]):
str(plugin["fileCount"]), str(plugin["fileCount"]),
plugin["asset"], 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 not preserve_status:
if self.plugin_rows: if self.plugin_rows:
back_hint = "" if len(self.choices) == 1 else " b returns to installations." back_hint = "" if len(self.choices) == 1 else " b returns to installations."
+88
View File
@@ -1471,6 +1471,75 @@ sha256 = "{sha256_file(asset)}"
return PluginHelperTui(choices=[choice], repo_root=repo), instance, state return PluginHelperTui(choices=[choice], repo_root=repo), instance, state
def _make_two_plugin_tui_fixture(root: Path) -> tuple[PluginHelperTui, Path, Path]:
repo = root / "repo"
instance_root = root / "instances"
instance = instance_root / "1.40.8"
state = root / "state"
(repo / "registry").mkdir(parents=True)
(repo / "locks").mkdir()
(instance / "Beat Saber_Data").mkdir(parents=True)
(instance / "Plugins").mkdir()
plugins_state: dict[str, dict] = {}
lock_entries: list[str] = []
registry_entries: list[str] = []
for plugin_id, name, filename in (
("alpha", "Alpha", "Alpha.dll"),
("beta", "Beta", "Beta.dll"),
):
asset = plugin_downloads_dir(state, "1.40.8", plugin_id) / filename
asset.write_bytes(f"{plugin_id} dll".encode())
(instance / "Plugins" / filename).write_bytes(f"{plugin_id} dll".encode())
registry_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
name = "{name}"
repo = "owner/{plugin_id}"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip()
)
lock_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
repo = "owner/{plugin_id}"
tag = "v1.0.0"
asset = "{filename}"
sha256 = "{sha256_file(asset)}"
""".lstrip()
)
plugins_state[plugin_id] = {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": f"Plugins/{filename}", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
(repo / "registry" / "plugins.toml").write_text("".join(registry_entries), encoding="utf-8")
(repo / "locks" / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
{"".join(lock_entries)}
""".lstrip(),
encoding="utf-8",
)
save_installed_state(
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}},
)
choice = InstallationChoice(
install_id="test",
install_label="Test Install",
instance_name="1.40.8",
instance_path=instance,
state_root=state,
)
return PluginHelperTui(choices=[choice], repo_root=repo), instance, state
class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None: async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None:
choices = [ choices = [
@@ -1523,6 +1592,25 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("example", updated["plugins"]) self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"]) self.assertIn("example", updated["disabledPlugins"])
async def test_space_toggle_keeps_cursor_on_selected_plugin(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("down")
await pilot.pause()
self.assertEqual(table.cursor_row, 1)
self.assertEqual(app.plugin_rows[table.cursor_row]["id"], "beta")
await pilot.press("space")
await pilot.pause()
self.assertEqual(table.cursor_row, 1)
self.assertEqual(app.plugin_rows[table.cursor_row]["id"], "beta")
self.assertFalse((instance / "Plugins" / "Beta.dll").exists())
self.assertTrue((instance / "Plugins" / "Alpha.dll").exists())
async def test_space_enables_disabled_plugin_from_asset(self) -> None: async def test_space_enables_disabled_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True) app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)