Coverage for src/lilbee/cli/tui/widgets/confirm_dialog.py: 100%

46 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

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

2 

3from __future__ import annotations 

4 

5from typing import ClassVar 

6 

7from textual import events, on 

8from textual.app import ComposeResult 

9from textual.binding import Binding, BindingType 

10from textual.containers import Center, Horizontal, Vertical 

11from textual.message import Message 

12from textual.screen import ModalScreen 

13from textual.widgets import Label, Static 

14 

15from lilbee.cli.tui import messages as msg 

16 

17 

18class ConfirmPill(Static, can_focus=True): 

19 """Focusable yes/no pill; Enter, Space or a click picks it.""" 

20 

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

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

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

24 ] 

25 

26 class Picked(Message): 

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

28 

29 def __init__(self, answer: bool) -> None: 

30 super().__init__() 

31 self.answer = answer 

32 

33 def __init__(self, label: str, *, pill_id: str, answer: bool) -> None: 

34 super().__init__(label, id=pill_id) 

35 self._answer = answer 

36 

37 def action_select(self) -> None: 

38 self.post_message(self.Picked(self._answer)) 

39 

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

41 event.stop() 

42 self.action_select() 

43 

44 

45class ConfirmDialog(ModalScreen[bool]): 

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

47 

48 CSS_PATH = "confirm_dialog.tcss" 

49 

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

51 Binding("y", "confirm", "Yes", show=True), 

52 # Fallback for when no pill holds focus; a focused pill takes enter itself. 

53 Binding("enter", "confirm", "Confirm", show=False), 

54 Binding("n", "cancel", "No", show=True), 

55 Binding("escape", "cancel", "Cancel", show=False), 

56 # `app.` because the focus actions live on the App, as Screen's own 

57 # tab binding has them. 

58 Binding("left", "app.focus_previous", "Previous", show=False), 

59 Binding("right", "app.focus_next", "Next", show=False), 

60 ] 

61 

62 def __init__(self, title: str, message: str) -> None: 

63 super().__init__() 

64 self._title = title 

65 self._message = message 

66 

67 def compose(self) -> ComposeResult: 

68 with Vertical(): 

69 yield Static(self._title, id="confirm-title") 

70 yield Label(self._message, id="confirm-message") 

71 with Center(), Horizontal(id="confirm-buttons"): 

72 yield ConfirmPill(msg.CONFIRM_YES_LABEL, pill_id="confirm-yes", answer=True) 

73 yield ConfirmPill(msg.CONFIRM_NO_LABEL, pill_id="confirm-no", answer=False) 

74 

75 @on(ConfirmPill.Picked) 

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

77 event.stop() 

78 self.dismiss(event.answer) 

79 

80 def action_confirm(self) -> None: 

81 self.dismiss(True) 

82 

83 def action_cancel(self) -> None: 

84 self.dismiss(False)