From bcfada09f99f1e92067e57d0f4c532f00809c4f4 Mon Sep 17 00:00:00 2001 From: pleb Date: Sat, 11 Jul 2026 16:02:38 -0700 Subject: [PATCH] Make menu clickable --- src/plugin_helper/tui.py | 52 ++++++++++++++++++++++++++++++++++--- tests/test_plugin_helper.py | 37 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py index d6c09f5..4cfebe2 100644 --- a/src/plugin_helper/tui.py +++ b/src/plugin_helper/tui.py @@ -6,8 +6,10 @@ from pathlib import Path from typing import Any from rich.text import Text +from textual import events 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 .bsipa import BSIPA_PLUGIN_ID @@ -32,6 +34,43 @@ class InstallationChoice: state_root: Path +class ActivatableDataTable(DataTable): + """DataTable that activates a row on every click, not only a re-click.""" + + async def _on_click(self, event: events.Click) -> None: + # Textual dispatches `_on_click` for every class in the MRO, so we fully + # handle the click here and prevent DataTable's handler from also running. + self._set_hover_cursor(True) + meta = event.style.meta + if "row" not in meta or "column" not in meta: + event.prevent_default() + return + if self.cursor_type != "row" and meta.get("out_of_bounds", False): + event.prevent_default() + return + + row_index = meta["row"] + column_index = meta["column"] + is_header_click = self.show_header and row_index == -1 + is_row_label_click = self.show_row_labels and column_index == -1 + if is_header_click: + column = self.ordered_columns[column_index] + self.post_message( + DataTable.HeaderSelected(self, column.key, column_index, label=column.label) + ) + elif is_row_label_click: + row = self.ordered_rows[row_index] + self.post_message( + DataTable.RowLabelSelected(self, row.key, row_index, label=row.label) + ) + elif self.show_cursor and self.cursor_type != "none": + self.cursor_coordinate = Coordinate(row_index, column_index) + self._post_selected_message() + self._scroll_cursor_into_view(animate=True) + event.stop() + event.prevent_default() + + class PluginHelperTui(App[int]): CSS = """ #title { @@ -77,7 +116,7 @@ class PluginHelperTui(App[int]): def compose(self) -> ComposeResult: yield Header(show_clock=False) yield Static("", id="title") - yield DataTable(id="table") + yield ActivatableDataTable(id="table") yield Static("", id="status") yield Footer() @@ -90,6 +129,13 @@ class PluginHelperTui(App[int]): return self._show_installations() + async def on_data_table_row_selected(self, _event: DataTable.RowSelected) -> None: + if self.mode == "installations": + self.action_select() + return + if self.mode == "plugins": + await self.action_toggle_plugin() + def action_select(self) -> None: if self.mode != "installations": return @@ -312,7 +358,7 @@ class PluginHelperTui(App[int]): if self.setup_hint: self._set_status(self.setup_hint) else: - self._set_status("Enter selects an installation. q quits.") + self._set_status("Click or Enter selects an installation. q quits.") def _show_plugins(self, *, preserve_status: bool = False) -> None: if self.selected_installation is None: @@ -357,7 +403,7 @@ class PluginHelperTui(App[int]): if self.plugin_rows: back_hint = "" if len(self.choices) == 1 else " b returns to installations." self._set_status( - "Space toggles selected. d disables all. e enables all. " + "Click or Space toggles selected. d disables all. e enables all. " f"s saves known-good. g restores known-good.{back_hint}" ) else: diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index f67c079..099ba31 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -2636,6 +2636,43 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): self.assertNotIn("example", updated["plugins"]) self.assertIn("example", updated["disabledPlugins"]) + async def test_click_toggles_plugin_like_space(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) + self.assertEqual(app.plugin_rows[0]["status"], "enabled") + # Header is y=0; first data row is y=1. Click the second plugin row. + await pilot.click(DataTable, offset=(2, 2)) + await pilot.pause() + self.assertEqual(table.cursor_row, 1) + self.assertEqual(app.plugin_rows[1]["id"], "beta") + self.assertEqual(app.plugin_rows[1]["status"], "disabled") + + self.assertFalse((instance / "Plugins" / "Beta.dll").exists()) + self.assertTrue((instance / "Plugins" / "Alpha.dll").exists()) + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) + self.assertNotIn("beta", updated["plugins"]) + self.assertIn("beta", updated["disabledPlugins"]) + self.assertIn("alpha", updated["plugins"]) + + async def test_click_toggles_already_selected_plugin(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, instance, state = _make_tui_fixture(Path(tmp)) + + async with app.run_test() as pilot: + self.assertEqual(app.plugin_rows[0]["status"], "enabled") + await pilot.click(DataTable, offset=(2, 1)) + await pilot.pause() + self.assertEqual(app.plugin_rows[0]["status"], "disabled") + + self.assertFalse((instance / "Plugins" / "Example.dll").exists()) + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) + self.assertNotIn("example", updated["plugins"]) + 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))