tui: fix single-click buttons and double-modal push

This commit is contained in:
AFredefon
2026-03-11 05:30:29 +01:00
parent 1891a43189
commit b975d285c6
3 changed files with 41 additions and 20 deletions
+30 -10
View File
@@ -56,12 +56,16 @@ class SingleClickDataTable(DataTable):
return self.data_table return self.data_table
async def _on_click(self, event: events.Click) -> None: # type: ignore[override] async def _on_click(self, event: events.Click) -> None: # type: ignore[override]
"""Forward to parent, then post RowClicked for single-click detection.""" """Forward to parent, then post RowClicked on every mouse click.
The hub table is handled exclusively via RowClicked. RowSelected is
intentionally NOT used for the hub table to avoid double-dispatch.
"""
await super()._on_click(event) await super()._on_click(event)
meta = event.style.meta meta = event.style.meta
if "row" in meta and self.cursor_type == "row": if meta and "row" in meta and self.cursor_type == "row":
row_index: int = meta["row"] row_index: int = int(meta["row"])
if row_index >= 0: # skip header row if row_index >= 0:
self.post_message(SingleClickDataTable.RowClicked(self, row_index)) self.post_message(SingleClickDataTable.RowClicked(self, row_index))
@@ -371,11 +375,14 @@ class FuzzForgeApp(App[None]):
self._hub_rows.append((name, image, hub_name, is_ready)) self._hub_rows.append((name, image, hub_name, is_ready))
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle Enter-key row selection on the agents table.""" """Handle Enter-key row selection (agents table only).
Hub table uses RowClicked exclusively — wiring it to RowSelected too
would cause a double push on every click since Textual 8 fires
RowSelected on ALL clicks, not just second-click-on-same-row.
"""
if event.data_table.id == "agents-table": if event.data_table.id == "agents-table":
self._handle_agent_row(event.cursor_row) self._handle_agent_row(event.cursor_row)
elif event.data_table.id == "hub-table":
self._handle_hub_row(event.cursor_row)
def on_single_click_data_table_row_clicked( def on_single_click_data_table_row_clicked(
self, event: SingleClickDataTable.RowClicked self, event: SingleClickDataTable.RowClicked
@@ -408,6 +415,10 @@ class FuzzForgeApp(App[None]):
def _handle_hub_row(self, idx: int) -> None: def _handle_hub_row(self, idx: int) -> None:
"""Handle a click on a hub table row.""" """Handle a click on a hub table row."""
# Guard: never push two build dialogs at once (double-click protection)
if getattr(self, "_build_dialog_open", False):
return
if idx < 0 or idx >= len(self._hub_rows): if idx < 0 or idx >= len(self._hub_rows):
return return
row_data = self._hub_rows[idx] row_data = self._hub_rows[idx]
@@ -419,7 +430,11 @@ class FuzzForgeApp(App[None]):
# If a build is already running, open the live log viewer # If a build is already running, open the live log viewer
if image in self._active_builds: if image in self._active_builds:
from fuzzforge_cli.tui.screens.build_log import BuildLogScreen from fuzzforge_cli.tui.screens.build_log import BuildLogScreen
self.push_screen(BuildLogScreen(image)) self._build_dialog_open = True
self.push_screen(
BuildLogScreen(image),
callback=lambda _: setattr(self, "_build_dialog_open", False),
)
return return
if is_ready: if is_ready:
@@ -432,10 +447,15 @@ class FuzzForgeApp(App[None]):
from fuzzforge_cli.tui.screens.build_image import BuildImageScreen from fuzzforge_cli.tui.screens.build_image import BuildImageScreen
self._build_dialog_open = True
def _on_build_dialog_done(confirmed: bool, sn: str = server_name, im: str = image, hn: str = hub_name) -> None:
self._build_dialog_open = False
self._on_build_confirmed(confirmed, sn, im, hn)
self.push_screen( self.push_screen(
BuildImageScreen(server_name, image, hub_name), BuildImageScreen(server_name, image, hub_name),
callback=lambda confirmed, sn=server_name, im=image, hn=hub_name: callback=_on_build_dialog_done,
self._on_build_confirmed(confirmed, sn, im, hn),
) )
def _on_build_confirmed(self, confirmed: bool, server_name: str, image: str, hub_name: str) -> None: def _on_build_confirmed(self, confirmed: bool, server_name: str, image: str, hub_name: str) -> None:
@@ -14,6 +14,10 @@ from textual.screen import ModalScreen
from textual.widgets import Button, Label from textual.widgets import Button, Label
class _NoFocusButton(Button):
can_focus = False
class BuildImageScreen(ModalScreen[bool]): class BuildImageScreen(ModalScreen[bool]):
"""Quick confirmation before starting a background Docker/Podman build.""" """Quick confirmation before starting a background Docker/Podman build."""
@@ -38,16 +42,10 @@ class BuildImageScreen(ModalScreen[bool]):
id="confirm-text", id="confirm-text",
) )
with Horizontal(classes="dialog-buttons"): with Horizontal(classes="dialog-buttons"):
yield Button("Build", variant="success", id="btn-build") yield _NoFocusButton("Build", variant="primary", id="btn-build")
yield Button("Cancel", variant="default", id="btn-cancel") yield _NoFocusButton("Cancel", variant="default", id="btn-cancel")
def on_mount(self) -> None:
# Ensure a widget is focused so both buttons respond to a single click.
# Default to Cancel so Build is never pre-selected.
self.query_one("#btn-cancel", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.id == "btn-build": if event.button.id == "btn-build":
self.dismiss(True) self.dismiss(True)
elif event.button.id == "btn-cancel": elif event.button.id == "btn-cancel":
@@ -14,6 +14,10 @@ from textual.screen import ModalScreen
from textual.widgets import Button, Label, Log from textual.widgets import Button, Label, Log
class _NoFocusButton(Button):
can_focus = False
class BuildLogScreen(ModalScreen[None]): class BuildLogScreen(ModalScreen[None]):
"""Live log viewer for a background build job managed by the app.""" """Live log viewer for a background build job managed by the app."""
@@ -30,10 +34,9 @@ class BuildLogScreen(ModalScreen[None]):
yield Label("", id="build-status") yield Label("", id="build-status")
yield Log(id="build-log", auto_scroll=True) yield Log(id="build-log", auto_scroll=True)
with Horizontal(classes="dialog-buttons"): with Horizontal(classes="dialog-buttons"):
yield Button("Close", variant="default", id="btn-close") yield _NoFocusButton("Close", variant="default", id="btn-close")
def on_mount(self) -> None: def on_mount(self) -> None:
self.query_one("#btn-close", Button).focus()
self._flush_log() self._flush_log()
self.set_interval(0.5, self._poll_log) self.set_interval(0.5, self._poll_log)