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}