diff --git a/fuzzforge-cli/src/fuzzforge_cli/tui/app.py b/fuzzforge-cli/src/fuzzforge_cli/tui/app.py index 0c8d94b..611e0a6 100644 --- a/fuzzforge-cli/src/fuzzforge_cli/tui/app.py +++ b/fuzzforge-cli/src/fuzzforge_cli/tui/app.py @@ -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: diff --git a/fuzzforge-cli/src/fuzzforge_cli/tui/screens/build_image.py b/fuzzforge-cli/src/fuzzforge_cli/tui/screens/build_image.py index e03c9b5..30c6a2c 100644 --- a/fuzzforge-cli/src/fuzzforge_cli/tui/screens/build_image.py +++ b/fuzzforge-cli/src/fuzzforge_cli/tui/screens/build_image.py @@ -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": diff --git a/fuzzforge-cli/src/fuzzforge_cli/tui/screens/build_log.py b/fuzzforge-cli/src/fuzzforge_cli/tui/screens/build_log.py index c04bf58..ba96311 100644 --- a/fuzzforge-cli/src/fuzzforge_cli/tui/screens/build_log.py +++ b/fuzzforge-cli/src/fuzzforge_cli/tui/screens/build_log.py @@ -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)