Skip to main content

ratatui_kit/components/
confirm_modal.rs

1// ConfirmModal 组件:带输入互斥的确认弹窗。
2//
3// 组件内部自开独占输入层并把同一层传给 `Modal`,封装父级 handler + Modal
4// 的三件套配对,避免背景组件处理确认弹窗期间的按键。
5
6use 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}