Skip to main content

ccf_gpui_widgets/widgets/
confirmation_dialog.rs

1//! Confirmation dialog widget
2//!
3//! A modal dialog for confirming user actions or displaying information.
4//! Supports different styles and configurable buttons.
5//!
6//! # Dialog Styles
7//!
8//! - **Info**: Single primary button. Click-outside, Escape, or Enter dismisses.
9//! - **Default**: Primary and secondary buttons. Click-outside or Escape triggers secondary. Enter triggers primary.
10//! - **Warning**: Same as Default but with orange title for emphasis.
11//! - **Danger**: Red primary button. Click-outside does nothing. Escape triggers secondary.
12//!   Enter does NOT trigger primary (must click explicitly).
13//!
14//! # Button Configuration
15//!
16//! - **Primary**: Always shown (colored based on style)
17//! - **Secondary**: Optional second button (gray). Use `secondary_label()` to enable.
18//! - **Tertiary**: Optional third button (gray). Use `tertiary_label()` to enable.
19//!
20//! # Key Mappings
21//!
22//! Use `map_key()` to bind keys to buttons. For example, map "y" to Primary and "n" to Secondary.
23//!
24//! # Example
25//!
26//! ```ignore
27//! use ccf_gpui_widgets::widgets::{ConfirmationDialog, DialogStyle, DialogButton};
28//!
29//! // Simple info dialog
30//! let info = cx.new(|cx| {
31//!     ConfirmationDialog::new("Success", "Your changes have been saved.", cx)
32//!         .style(DialogStyle::Info)
33//! });
34//!
35//! // Two-button confirmation
36//! let confirm = cx.new(|cx| {
37//!     ConfirmationDialog::new("Confirm", "Are you sure?", cx)
38//!         .primary_label("Yes")
39//!         .secondary_label("No")
40//!         .map_key("y", DialogButton::Primary)
41//!         .map_key("n", DialogButton::Secondary)
42//! });
43//!
44//! // Three-button save dialog
45//! let save = cx.new(|cx| {
46//!     ConfirmationDialog::new("Unsaved Changes", "Save before closing?", cx)
47//!         .primary_label("Save")
48//!         .secondary_label("Cancel")
49//!         .tertiary_label("Don't Save")
50//!         .map_key("y", DialogButton::Primary)
51//!         .map_key("n", DialogButton::Tertiary)
52//! });
53//!
54//! // Subscribe to dialog events
55//! cx.subscribe(&dialog, |this, _dialog, event: &ConfirmationDialogEvent, cx| {
56//!     match event {
57//!         ConfirmationDialogEvent::Primary => { /* OK/Yes/Save clicked */ }
58//!         ConfirmationDialogEvent::Secondary => { /* Cancel/No clicked */ }
59//!         ConfirmationDialogEvent::Tertiary => { /* Third button clicked */ }
60//!     }
61//! }).detach();
62//! ```
63
64use std::collections::HashMap;
65
66use gpui::prelude::*;
67use gpui::*;
68
69use crate::theme::{get_theme_or, Theme};
70use super::button::{primary_button, secondary_button, danger_button};
71use super::focus_navigation::with_focus_actions;
72
73/// Dialog style/severity (controls primary button color)
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub enum DialogStyle {
76    /// Informational dialog (blue primary button, easy to dismiss)
77    Info,
78    /// Normal confirmation dialog (blue primary button)
79    #[default]
80    Default,
81    /// Warning dialog (orange title, blue primary button)
82    Warning,
83    /// Danger dialog (red primary button, harder to confirm)
84    Danger,
85}
86
87/// Which button a key or action should trigger
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum DialogButton {
90    /// Primary button (colored based on style)
91    Primary,
92    /// Secondary button (gray)
93    Secondary,
94    /// Tertiary button (gray)
95    Tertiary,
96}
97
98/// Events emitted by ConfirmationDialog
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum ConfirmationDialogEvent {
101    /// Primary button clicked (OK, Yes, Save, Delete, etc.)
102    Primary,
103    /// Secondary button clicked (Cancel, No, etc.)
104    Secondary,
105    /// Tertiary button clicked (Don't Save, etc.)
106    Tertiary,
107}
108
109/// Confirmation dialog widget
110pub struct ConfirmationDialog {
111    title: SharedString,
112    message: SharedString,
113    style: DialogStyle,
114    primary_label: SharedString,
115    secondary_label: Option<SharedString>,
116    tertiary_label: Option<SharedString>,
117    key_mappings: HashMap<String, DialogButton>,
118    focus_handle: FocusHandle,
119    custom_theme: Option<Theme>,
120    /// Saved focus handle to restore when dialog is dismissed
121    previous_focus: Option<FocusHandle>,
122}
123
124impl EventEmitter<ConfirmationDialogEvent> for ConfirmationDialog {}
125
126impl Focusable for ConfirmationDialog {
127    fn focus_handle(&self, _cx: &App) -> FocusHandle {
128        self.focus_handle.clone()
129    }
130}
131
132impl ConfirmationDialog {
133    /// Create a new confirmation dialog
134    pub fn new(
135        title: impl Into<SharedString>,
136        message: impl Into<SharedString>,
137        cx: &mut Context<Self>,
138    ) -> Self {
139        Self {
140            title: title.into(),
141            message: message.into(),
142            style: DialogStyle::default(),
143            primary_label: "OK".into(),
144            secondary_label: None,
145            tertiary_label: None,
146            key_mappings: HashMap::new(),
147            focus_handle: cx.focus_handle().tab_stop(true),
148            custom_theme: None,
149            previous_focus: None,
150        }
151    }
152
153    /// Set primary button label (builder pattern)
154    #[must_use]
155    pub fn primary_label(mut self, label: impl Into<SharedString>) -> Self {
156        self.primary_label = label.into();
157        self
158    }
159
160    /// Set secondary button label (builder pattern)
161    /// Setting this enables the secondary button.
162    #[must_use]
163    pub fn secondary_label(mut self, label: impl Into<SharedString>) -> Self {
164        self.secondary_label = Some(label.into());
165        self
166    }
167
168    /// Set tertiary button label (builder pattern)
169    /// Setting this enables the tertiary button.
170    #[must_use]
171    pub fn tertiary_label(mut self, label: impl Into<SharedString>) -> Self {
172        self.tertiary_label = Some(label.into());
173        self
174    }
175
176    /// Map a key to a button (builder pattern)
177    /// Keys are case-insensitive (both "y" and "Y" will match).
178    #[must_use]
179    pub fn map_key(mut self, key: impl Into<String>, button: DialogButton) -> Self {
180        let key_lower = key.into().to_lowercase();
181        self.key_mappings.insert(key_lower, button);
182        self
183    }
184
185    /// Set dialog style (builder pattern)
186    #[must_use]
187    pub fn style(mut self, style: DialogStyle) -> Self {
188        self.style = style;
189        self
190    }
191
192    /// Set custom theme (builder pattern)
193    #[must_use]
194    pub fn theme(mut self, theme: Theme) -> Self {
195        self.custom_theme = Some(theme);
196        self
197    }
198
199    /// Get the focus handle
200    pub fn focus_handle(&self) -> &FocusHandle {
201        &self.focus_handle
202    }
203
204    fn emit_button(&mut self, button: DialogButton, window: &mut Window, cx: &mut Context<Self>) {
205        // Restore focus to the element that was focused before the dialog was shown
206        if let Some(prev_focus) = self.previous_focus.take() {
207            window.focus(&prev_focus);
208        }
209
210        let event = match button {
211            DialogButton::Primary => ConfirmationDialogEvent::Primary,
212            DialogButton::Secondary => ConfirmationDialogEvent::Secondary,
213            DialogButton::Tertiary => ConfirmationDialogEvent::Tertiary,
214        };
215        cx.emit(event);
216    }
217}
218
219impl Render for ConfirmationDialog {
220    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
221        let theme = get_theme_or(cx, self.custom_theme.as_ref());
222        let title = self.title.clone();
223        let message = self.message.clone();
224        let primary_label = self.primary_label.clone();
225        let secondary_label = self.secondary_label.clone();
226        let tertiary_label = self.tertiary_label.clone();
227        let style = self.style;
228        let focus_handle = self.focus_handle.clone();
229        let key_mappings = self.key_mappings.clone();
230        let is_danger = style == DialogStyle::Danger;
231        let is_info = style == DialogStyle::Info;
232        let has_secondary = secondary_label.is_some();
233        let has_tertiary = tertiary_label.is_some();
234
235        // Save the current focus and focus the dialog when it first renders
236        if !focus_handle.is_focused(window) {
237            // Save the currently focused element before we take focus
238            if self.previous_focus.is_none() {
239                self.previous_focus = window.focused(cx);
240            }
241            focus_handle.focus(window);
242        }
243
244        // Title color based on style
245        let title_color = match style {
246            DialogStyle::Info => theme.primary,
247            DialogStyle::Default => theme.text_primary,
248            DialogStyle::Warning => theme.warning,
249            DialogStyle::Danger => theme.error,
250        };
251
252        // Build primary button based on style
253        let primary_button_element = match style {
254            DialogStyle::Danger => {
255                danger_button("dialog_primary", &primary_label, true, cx)
256                    .on_click(cx.listener(|dialog, _event: &ClickEvent, window, cx| {
257                        dialog.emit_button(DialogButton::Primary, window, cx);
258                    }))
259            }
260            _ => {
261                primary_button("dialog_primary", &primary_label, true, cx)
262                    .on_click(cx.listener(|dialog, _event: &ClickEvent, window, cx| {
263                        dialog.emit_button(DialogButton::Primary, window, cx);
264                    }))
265            }
266        };
267
268        // Build buttons container
269        let mut buttons = div()
270            .w_full()
271            .flex()
272            .flex_row()
273            .gap_3()
274            .justify_end();
275
276        // Add tertiary button (leftmost of the optional buttons)
277        if let Some(label) = &tertiary_label {
278            buttons = buttons.child(
279                secondary_button("dialog_tertiary", label, cx)
280                    .on_click(cx.listener(|dialog, _event: &ClickEvent, window, cx| {
281                        dialog.emit_button(DialogButton::Tertiary, window, cx);
282                    }))
283            );
284        }
285
286        // Add secondary button
287        if let Some(label) = &secondary_label {
288            buttons = buttons.child(
289                secondary_button("dialog_secondary", label, cx)
290                    .on_click(cx.listener(|dialog, _event: &ClickEvent, window, cx| {
291                        dialog.emit_button(DialogButton::Secondary, window, cx);
292                    }))
293            );
294        }
295
296        // Add primary button (rightmost)
297        buttons = buttons.child(primary_button_element);
298
299        // Dialog box
300        let dialog_box = with_focus_actions(
301            div()
302                .id("ccf_confirmation_dialog_box")
303                .track_focus(&focus_handle)
304                .tab_stop(true)
305                .occlude(),
306            cx,
307        )
308        // Tab navigation responds on keydown for immediate feedback
309        .on_key_down(cx.listener(|_dialog, event: &KeyDownEvent, window, _cx| {
310                if event.keystroke.key.as_str() == "tab" {
311                    if event.keystroke.modifiers.shift {
312                        window.focus_prev();
313                    } else {
314                        window.focus_next();
315                    }
316                }
317            }))
318            // Dismissal actions respond on keyup to avoid race conditions when
319            // the dialog is launched by a keydown - if we dismissed on keydown,
320            // the keyup would fire on the restored-focus element and potentially
321            // re-launch the dialog
322            .on_key_up(cx.listener(move |dialog, event: &KeyUpEvent, window, cx| {
323                let key = event.keystroke.key.as_str().to_lowercase();
324
325                // Check custom key mappings first
326                if let Some(&button) = key_mappings.get(&key) {
327                    // Only trigger if the button exists
328                    let can_trigger = match button {
329                        DialogButton::Primary => true,
330                        DialogButton::Secondary => has_secondary,
331                        DialogButton::Tertiary => has_tertiary,
332                    };
333                    if can_trigger {
334                        dialog.emit_button(button, window, cx);
335                        return;
336                    }
337                }
338
339                // Default key behaviors
340                match key.as_str() {
341                    "escape" => {
342                        // Escape: triggers secondary if exists, otherwise primary (for Info)
343                        if has_secondary {
344                            dialog.emit_button(DialogButton::Secondary, window, cx);
345                        } else {
346                            dialog.emit_button(DialogButton::Primary, window, cx);
347                        }
348                    }
349                    "enter" => {
350                        // Enter: triggers primary (except for Danger style)
351                        if !is_danger {
352                            dialog.emit_button(DialogButton::Primary, window, cx);
353                        }
354                    }
355                    _ => {}
356                }
357            }))
358            .bg(rgb(theme.bg_secondary))
359            .border_1()
360            .border_color(rgb(theme.border_default))
361            .rounded_lg()
362            .shadow_lg()
363            .min_w(px(320.0))
364            .max_w(px(480.0))
365            .p(px(24.0))
366            .child(
367                div()
368                    .text_lg()
369                    .font_weight(FontWeight::BOLD)
370                    .text_color(rgb(title_color))
371                    .child(title)
372            )
373            .child(
374                div()
375                    .mt_4()
376                    .text_sm()
377                    .text_color(rgb(theme.text_muted))
378                    .child(message)
379            )
380            .child(
381                div()
382                    .mt_4()
383                    .child(buttons)
384            );
385
386        // Use deferred for proper overlay behavior
387        deferred(
388            div()
389                .id("ccf_confirmation_dialog")
390                .absolute()
391                .inset_0()
392                .occlude()
393                .flex()
394                .items_center()
395                .justify_center()
396                .bg(rgba(0x000000aa))
397                .on_mouse_down(MouseButton::Left, cx.listener(move |dialog, _event, window, cx| {
398                    // Click-outside behavior
399                    if is_info {
400                        // Info: click-outside dismisses (Primary)
401                        dialog.emit_button(DialogButton::Primary, window, cx);
402                    } else if !is_danger && has_secondary {
403                        // Default/Warning with secondary: click-outside triggers Secondary
404                        dialog.emit_button(DialogButton::Secondary, window, cx);
405                    }
406                    // Danger: click-outside does nothing
407                }))
408                .child(dialog_box)
409        )
410    }
411}