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

1"""Reusable confirmation modal dialog.""" 

2 

3from __future__ import annotations 

4 

5from enum import StrEnum 

6from pathlib import Path 

7from typing import ClassVar 

8 

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 

16 

17from lilbee.cli.tui import messages as msg 

18 

19_CSS_FILE = Path(__file__).parent / "confirm_pill.tcss" 

20 

21 

22class ConfirmPill(Static, can_focus=True): 

23 """Focusable dialog pill; Enter, Space or a click picks the answer it stands for.""" 

24 

25 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") 

26 

27 BINDINGS: ClassVar[list[BindingType]] = [ 

28 Binding("enter", "select", "Pick", show=False), 

29 Binding("space", "select", "Pick", show=False), 

30 ] 

31 

32 class Picked(Message): 

33 """A pill was picked, carrying the answer it stands for.""" 

34 

35 def __init__(self, answer: bool | StrEnum) -> None: 

36 super().__init__() 

37 self.answer = answer 

38 

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 

44 

45 def action_select(self) -> None: 

46 self.post_message(self.Picked(self._answer)) 

47 

48 def on_click(self, event: events.Click) -> None: 

49 event.stop() 

50 self.action_select() 

51 

52 

53class ConfirmDialog(ModalScreen[bool]): 

54 """Modal yes/no dialog that returns True (confirmed) or False (cancelled).""" 

55 

56 CSS_PATH = "confirm_dialog.tcss" 

57 

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 ] 

69 

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 

75 

76 def on_mount(self) -> None: 

77 if self._focus_no: 

78 self.query_one("#confirm-no", ConfirmPill).focus() 

79 

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) 

89 

90 @on(ConfirmPill.Picked) 

91 def _on_pill_picked(self, event: ConfirmPill.Picked) -> None: 

92 event.stop() 

93 self.dismiss(event.answer is True) 

94 

95 def action_confirm(self) -> None: 

96 self.dismiss(True) 

97 

98 def action_cancel(self) -> None: 

99 self.dismiss(False)