1use crossterm::event::{Event, KeyCode, KeyEventKind};
7use ratatui::{
8 layout::{Alignment, Constraint, Direction, Flex, Margin},
9 style::{Color, Modifier, Style},
10 text::Line,
11};
12use ratatui_kit_macros::{Props, component, element};
13
14use crate::{
15 AnyElement, Handler, Hooks, UseEffect, UseEventHandler, UseInputLayer, UseState,
16 components::{Border, Modal, Text, TextParagraph, View},
17 input::{EventPriority, EventResult, EventScope},
18};
19
20#[derive(Props)]
21pub struct ConfirmModalProps {
22 pub open: bool,
23 pub title: Line<'static>,
24 pub content: TextParagraph<'static>,
25 pub confirm_text: String,
26 pub cancel_text: String,
27 pub on_confirm: Handler<'static, ()>,
28 pub on_cancel: Handler<'static, ()>,
29 pub width: Constraint,
30 pub height: Constraint,
31 pub style: Style,
32 pub border_style: Style,
33 pub title_style: Style,
34 pub content_style: Style,
35 pub button_style: Style,
36 pub selected_button_style: Style,
37}
38
39impl Default for ConfirmModalProps {
40 fn default() -> Self {
41 Self {
42 open: false,
43 title: Line::from("Confirm"),
44 content: TextParagraph::from(""),
45 confirm_text: String::from("Confirm"),
46 cancel_text: String::from("Cancel"),
47 on_confirm: Handler::default(),
48 on_cancel: Handler::default(),
49 width: Constraint::Percentage(50),
50 height: Constraint::Length(10),
51 style: Style::default().dim(),
52 border_style: Style::default(),
53 title_style: Style::default(),
54 content_style: Style::default(),
55 button_style: Style::default(),
56 selected_button_style: Style::default()
57 .fg(Color::Cyan)
58 .add_modifier(Modifier::BOLD),
59 }
60 }
61}
62
63#[component]
64pub fn ConfirmModal(
65 props: &mut ConfirmModalProps,
66 mut hooks: Hooks,
67) -> impl Into<AnyElement<'static>> {
68 let mut confirm_selected = hooks.use_state(|| false);
69
70 let open = props.open;
71 hooks.use_effect(
72 move || {
73 if !open {
74 confirm_selected.set(false);
75 }
76 },
77 open,
78 );
79
80 let layer = hooks.use_input_layer(props.open, true);
81 let mut on_confirm = props.on_confirm.take();
82 let mut on_cancel = props.on_cancel.take();
83
84 hooks.use_event_handler(
85 EventScope::Layer(layer),
86 EventPriority::High,
87 move |event| {
88 let Event::Key(key) = event else {
89 return EventResult::Consumed;
90 };
91 if key.kind != KeyEventKind::Press {
92 return EventResult::Consumed;
93 }
94
95 match key.code {
96 KeyCode::Left | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab => {
97 confirm_selected.set(!confirm_selected.get());
98 }
99 KeyCode::Enter => {
100 if confirm_selected.get() {
101 on_confirm(());
102 } else {
103 on_cancel(());
104 }
105 }
106 KeyCode::Char('y') | KeyCode::Char('Y') => on_confirm(()),
107 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => on_cancel(()),
108 _ => {}
109 }
110
111 EventResult::Consumed
112 },
113 );
114
115 let confirm_selected = confirm_selected.get();
116 let button_width = confirm_button_width(&props.cancel_text, &props.confirm_text);
117
118 element!(Modal(
119 open: props.open,
120 layer: Some(layer),
121 width: props.width,
122 height: props.height,
123 style: props.style,
124 ) {
125 Border(
126 border_style: props.border_style,
127 top_title: props.title.clone().style(props.title_style).centered(),
128 ) {
129 View {
130 View(
131 height: Constraint::Fill(1),
132 margin: Margin::new(2, 2),
133 ) {
134 Text(
135 text: props.content.clone(),
136 style: props.content_style,
137 alignment: Alignment::Center,
138 wrap: true,
139 )
140 }
141 View(
142 justify_content: Flex::SpaceAround,
143 height: Constraint::Length(3),
144 flex_direction: Direction::Horizontal,
145 ) {
146 ConfirmButton(
147 label: props.cancel_text.clone(),
148 selected: !confirm_selected,
149 width: button_width,
150 style: props.button_style,
151 selected_style: props.selected_button_style,
152 )
153 ConfirmButton(
154 label: props.confirm_text.clone(),
155 selected: confirm_selected,
156 width: button_width,
157 style: props.button_style,
158 selected_style: props.selected_button_style,
159 )
160 }
161 }
162 }
163 })
164}
165
166#[derive(Default, Props)]
167struct ConfirmButtonProps {
168 label: String,
169 selected: bool,
170 width: u16,
171 style: Style,
172 selected_style: Style,
173}
174
175#[component]
176fn ConfirmButton(props: &ConfirmButtonProps, _hooks: Hooks) -> impl Into<AnyElement<'static>> {
177 let label_style = if props.selected {
178 selected_button_label_style(props.selected_style)
179 } else {
180 props.style
181 };
182 let border_style = button_border_style(label_style);
183 let label = if props.selected {
184 format!(" {} ", props.label)
185 } else {
186 props.label.clone()
187 };
188
189 element!(Border(
190 width: Constraint::Length(props.width),
191 height: Constraint::Length(3),
192 border_style: border_style,
193 ) {
194 Text(
195 text: Line::styled(label, label_style),
196 alignment: Alignment::Center,
197 )
198 })
199}
200
201fn selected_button_label_style(style: Style) -> Style {
202 let mut label_style = style;
203 if let Some(bg) = style.bg {
204 label_style.fg = Some(bg);
205 label_style.bg = None;
206 }
207 label_style.add_modifier(Modifier::BOLD)
208}
209
210fn button_border_style(style: Style) -> Style {
211 let mut border_style = style;
212 if let Some(bg) = style.bg {
213 border_style.fg = Some(bg);
214 border_style.bg = None;
215 }
216 border_style
217}
218
219fn confirm_button_width(cancel_text: &str, confirm_text: &str) -> u16 {
220 let label_width = cancel_text
221 .chars()
222 .count()
223 .max(confirm_text.chars().count())
224 .max(6);
225 label_width.saturating_add(6).min(u16::MAX as usize) as u16
226}