Make menu clickable

This commit is contained in:
pleb
2026-07-11 16:02:38 -07:00
parent 861ce587f3
commit bcfada09f9
2 changed files with 86 additions and 3 deletions
+49 -3
View File
@@ -6,8 +6,10 @@ 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.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.widgets import DataTable, Footer, Header, Static from textual.widgets import DataTable, Footer, Header, Static
from .bsipa import BSIPA_PLUGIN_ID from .bsipa import BSIPA_PLUGIN_ID
@@ -32,6 +34,43 @@ class InstallationChoice:
state_root: Path 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]): class PluginHelperTui(App[int]):
CSS = """ CSS = """
#title { #title {
@@ -77,7 +116,7 @@ class PluginHelperTui(App[int]):
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")
yield DataTable(id="table") yield ActivatableDataTable(id="table")
yield Static("", id="status") yield Static("", id="status")
yield Footer() yield Footer()
@@ -90,6 +129,13 @@ class PluginHelperTui(App[int]):
return return
self._show_installations() 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: def action_select(self) -> None:
if self.mode != "installations": if self.mode != "installations":
return return
@@ -312,7 +358,7 @@ class PluginHelperTui(App[int]):
if self.setup_hint: if self.setup_hint:
self._set_status(self.setup_hint) self._set_status(self.setup_hint)
else: 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: def _show_plugins(self, *, preserve_status: bool = False) -> None:
if self.selected_installation is None: if self.selected_installation is None:
@@ -357,7 +403,7 @@ class PluginHelperTui(App[int]):
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."
self._set_status( 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}" f"s saves known-good. g restores known-good.{back_hint}"
) )
else: else:
+37
View File
@@ -2636,6 +2636,43 @@ 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_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: async def test_space_toggle_keeps_cursor_on_selected_plugin(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
app, instance, _state = _make_two_plugin_tui_fixture(Path(tmp)) app, instance, _state = _make_two_plugin_tui_fixture(Path(tmp))