Coverage for src/lilbee/cli/tui/widgets/confirm_dialog.py: 100%
54 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Reusable confirmation modal dialog."""
3from __future__ import annotations
5from enum import StrEnum
6from pathlib import Path
7from typing import ClassVar
9from textual import events, on
10from textual.app import ComposeResult
11from textual.binding import Binding, BindingType
12from textual.containers import Center, Horizontal, Vertical
13from textual.message import Message
14from textual.screen import ModalScreen
15from textual.widgets import Label, Static
17from lilbee.cli.tui import messages as msg
19_CSS_FILE = Path(__file__).parent / "confirm_pill.tcss"
22class ConfirmPill(Static, can_focus=True):
23 """Focusable dialog pill; Enter, Space or a click picks the answer it stands for."""
25 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
27 BINDINGS: ClassVar[list[BindingType]] = [
28 Binding("enter", "select", "Pick", show=False),
29 Binding("space", "select", "Pick", show=False),
30 ]
32 class Picked(Message):
33 """A pill was picked, carrying the answer it stands for."""
35 def __init__(self, answer: bool | StrEnum) -> None:
36 super().__init__()
37 self.answer = answer
39 def __init__(
40 self, label: str, *, pill_id: str, answer: bool | StrEnum, primary: bool = False
41 ) -> None:
42 super().__init__(label, id=pill_id, classes="-primary" if primary else None)
43 self._answer = answer
45 def action_select(self) -> None:
46 self.post_message(self.Picked(self._answer))
48 def on_click(self, event: events.Click) -> None:
49 event.stop()
50 self.action_select()
53class ConfirmDialog(ModalScreen[bool]):
54 """Modal yes/no dialog that returns True (confirmed) or False (cancelled)."""
56 CSS_PATH = "confirm_dialog.tcss"
58 BINDINGS: ClassVar[list[BindingType]] = [
59 Binding("y", "confirm", "Yes", show=True),
60 # Fallback for when no pill holds focus; a focused pill takes enter itself.
61 Binding("enter", "confirm", "Confirm", show=False),
62 Binding("n", "cancel", "No", show=True),
63 Binding("escape", "cancel", "Cancel", show=False),
64 # `app.` because the focus actions live on the App, as Screen's own
65 # tab binding has them.
66 Binding("left", "app.focus_previous", "Previous", show=False),
67 Binding("right", "app.focus_next", "Next", show=False),
68 ]
70 def __init__(self, title: str, message: str, *, focus_no: bool = False) -> None:
71 super().__init__()
72 self._title = title
73 self._message = message
74 self._focus_no = focus_no
76 def on_mount(self) -> None:
77 if self._focus_no:
78 self.query_one("#confirm-no", ConfirmPill).focus()
80 def compose(self) -> ComposeResult:
81 with Vertical():
82 yield Static(self._title, id="confirm-title", markup=False)
83 yield Label(self._message, id="confirm-message", markup=False)
84 with Center(), Horizontal(id="confirm-buttons"):
85 yield ConfirmPill(
86 msg.CONFIRM_YES_LABEL, pill_id="confirm-yes", answer=True, primary=True
87 )
88 yield ConfirmPill(msg.CONFIRM_NO_LABEL, pill_id="confirm-no", answer=False)
90 @on(ConfirmPill.Picked)
91 def _on_pill_picked(self, event: ConfirmPill.Picked) -> None:
92 event.stop()
93 self.dismiss(event.answer is True)
95 def action_confirm(self) -> None:
96 self.dismiss(True)
98 def action_cancel(self) -> None:
99 self.dismiss(False)