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

View File

@@ -56,12 +56,16 @@ class SingleClickDataTable(DataTable):
return self.data_table
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)
meta = event.style.meta
if "row" in meta and self.cursor_type == "row":
row_index: int = meta["row"]
if row_index >= 0: # skip header row
if meta and "row" in meta and self.cursor_type == "row":
row_index: int = int(meta["row"])
if row_index >= 0:
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))
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":
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(
self, event: SingleClickDataTable.RowClicked
@@ -408,6 +415,10 @@ class FuzzForgeApp(App[None]):
def _handle_hub_row(self, idx: int) -> None:
"""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):
return
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 image in self._active_builds:
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
if is_ready:
@@ -432,10 +447,15 @@ class FuzzForgeApp(App[None]):
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(
BuildImageScreen(server_name, image, hub_name),
callback=lambda confirmed, sn=server_name, im=image, hn=hub_name:
self._on_build_confirmed(confirmed, sn, im, hn),
callback=_on_build_dialog_done,
)
def _on_build_confirmed(self, confirmed: bool, server_name: str, image: str, hub_name: str) -> None:

View File

@@ -14,6 +14,10 @@ from textual.screen import ModalScreen
from textual.widgets import Button, Label
class _NoFocusButton(Button):
can_focus = False
class BuildImageScreen(ModalScreen[bool]):
"""Quick confirmation before starting a background Docker/Podman build."""
@@ -38,16 +42,10 @@ class BuildImageScreen(ModalScreen[bool]):
id="confirm-text",
)
with Horizontal(classes="dialog-buttons"):
yield Button("Build", variant="success", id="btn-build")
yield Button("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()
yield _NoFocusButton("Build", variant="primary", id="btn-build")
yield _NoFocusButton("Cancel", variant="default", id="btn-cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.id == "btn-build":
self.dismiss(True)
elif event.button.id == "btn-cancel":

View File

@@ -14,6 +14,10 @@ from textual.screen import ModalScreen
from textual.widgets import Button, Label, Log
class _NoFocusButton(Button):
can_focus = False
class BuildLogScreen(ModalScreen[None]):
"""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 Log(id="build-log", auto_scroll=True)
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:
self.query_one("#btn-close", Button).focus()
self._flush_log()
self.set_interval(0.5, self._poll_log)