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::{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/// ConfirmModal 组件的主题 slot。遮罩(背景变暗)委托给 [`Modal`] 的 `ModalTheme`,
23/// 本 slot 只负责边框/标题/正文/按钮样式;选中按钮的 `BOLD` 由主题承接。
24#[non_exhaustive]
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct ConfirmModalTheme {
27    /// 边框样式。
28    pub border_style: Style,
29    /// 标题样式。
30    pub title_style: Style,
31    /// 正文样式。
32    pub content_style: Style,
33    /// 未选中按钮样式。
34    pub button_style: Style,
35    /// 选中按钮样式(含 `BOLD`)。
36    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    // 遮罩样式覆盖(透传给 `Modal`)。`None` 用 `ModalTheme`(默认 DIM)。
69    pub style: Option<Style>,
70    // 以下样式覆盖:`None` 用 `ConfirmModalTheme`,`Some(s)` 以 `theme.patch(s)` 覆盖。
71    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    // 主题解析:每个 slot 铺底,对应 props 的 Option<Style> 在上 patch(None → 用主题)。
156    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}