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