use crate::a11y::{A11y, Announce};
use crate::element_id::scoped;
use crate::elements::button::button;
use crate::elements::icon_button::icon_button;
use crate::icons::Icons;
use crate::layout::h_stack;
use crate::theme::{ActiveTheme, ControlSize, Themeable};
use crate::traits::accessible::Accessible;
use crate::traits::control_sized::ControlSized;
use gpui::{
AnyElement, App, Context, DismissEvent, ElementId, EventEmitter, FocusHandle, Focusable,
IntoElement, KeyBinding, ParentElement, Render, Role, SharedString, Styled, Window, actions,
deferred, div, prelude::*, px,
};
use std::rc::Rc;
actions!(dialog, [Close]);
pub const DIALOG_CONTEXT: &str = "Dialog";
pub struct DialogOpened;
pub struct DialogClosed;
pub struct DialogConfirmed;
pub struct DialogCancelled;
#[derive(Clone)]
pub struct Confirmation {
confirm_label: SharedString,
cancel_label: SharedString,
destructive: bool,
on_confirm: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
on_cancel: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
}
impl Default for Confirmation {
fn default() -> Self {
Self {
confirm_label: "Confirm".into(),
cancel_label: "Cancel".into(),
destructive: true,
on_confirm: None,
on_cancel: None,
}
}
}
pub struct Dialog {
id: ElementId,
title: Option<SharedString>,
description: Option<SharedString>,
content: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
footer: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
close_on_escape: bool,
close_on_backdrop_click: bool,
show_close_button: bool,
confirmation: Option<Confirmation>,
size: ControlSize,
}
pub fn dialog(id: impl Into<ElementId>) -> Dialog {
Dialog::new(id)
}
impl Dialog {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
title: None,
description: None,
content: None,
footer: None,
close_on_escape: true,
close_on_backdrop_click: true,
show_close_button: true,
confirmation: None,
size: ControlSize::default(),
}
}
pub fn confirm(
mut self,
question: impl Into<SharedString>,
consequence: impl Into<SharedString>,
) -> Self {
self.title = Some(question.into());
self.description = Some(consequence.into());
self.confirmation = Some(self.confirmation.take().unwrap_or_default());
self.show_close_button = false;
self
}
fn with_confirmation(mut self, refine: impl FnOnce(&mut Confirmation)) -> Self {
debug_assert!(
self.confirmation.is_some(),
"this builder refines a confirmation and does not create one — call \
`.confirm(question, consequence)` first, so the dialog has something to \
confirm"
);
if let Some(confirmation) = self.confirmation.as_mut() {
refine(confirmation);
}
self
}
pub fn confirm_label(self, label: impl Into<SharedString>) -> Self {
let label = label.into();
self.with_confirmation(|confirmation| confirmation.confirm_label = label)
}
pub fn cancel_label(self, label: impl Into<SharedString>) -> Self {
let label = label.into();
self.with_confirmation(|confirmation| confirmation.cancel_label = label)
}
pub fn destructive(self, destructive: bool) -> Self {
self.with_confirmation(|confirmation| confirmation.destructive = destructive)
}
pub fn on_confirm(self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
let handler = Rc::new(handler);
self.with_confirmation(|confirmation| confirmation.on_confirm = Some(handler))
}
pub fn on_cancel(self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
let handler = Rc::new(handler);
self.with_confirmation(|confirmation| confirmation.on_cancel = Some(handler))
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn content(
mut self,
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
self.content = Some(Rc::new(content));
self
}
pub fn footer(
mut self,
footer: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
self.footer = Some(Rc::new(footer));
self
}
pub fn close_on_escape(mut self, close: bool) -> Self {
self.close_on_escape = close;
self
}
pub fn close_on_backdrop_click(mut self, close: bool) -> Self {
self.close_on_backdrop_click = close;
self
}
pub fn show_close_button(mut self, show: bool) -> Self {
self.show_close_button = show;
self
}
}
impl ControlSized for Dialog {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
pub struct DialogState {
id: ElementId,
title: Option<SharedString>,
description: Option<SharedString>,
content: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
footer: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
close_on_escape: bool,
close_on_backdrop_click: bool,
show_close_button: bool,
confirmation: Option<Confirmation>,
size: ControlSize,
is_open: bool,
focus_handle: Option<FocusHandle>,
cancel_focus_handle: Option<FocusHandle>,
}
impl EventEmitter<DialogOpened> for DialogState {}
impl EventEmitter<DialogClosed> for DialogState {}
impl EventEmitter<DialogConfirmed> for DialogState {}
impl EventEmitter<DialogCancelled> for DialogState {}
impl EventEmitter<DismissEvent> for DialogState {}
impl DialogState {
pub fn new(dialog: Dialog) -> Self {
Self {
id: dialog.id,
title: dialog.title,
description: dialog.description,
content: dialog.content,
footer: dialog.footer,
close_on_escape: dialog.close_on_escape,
close_on_backdrop_click: dialog.close_on_backdrop_click,
show_close_button: dialog.show_close_button,
confirmation: dialog.confirmation,
size: dialog.size,
is_open: false,
focus_handle: None,
cancel_focus_handle: None,
}
}
pub fn is_open(&self) -> bool {
self.is_open
}
pub fn is_confirmation(&self) -> bool {
self.confirmation.is_some()
}
pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_open {
return;
}
self.is_open = true;
let focus_handle = cx.focus_handle();
window.focus(&focus_handle, cx);
self.focus_handle = Some(focus_handle);
if self.is_confirmation() {
let cancel = cx.focus_handle();
window.focus(&cancel, cx);
self.cancel_focus_handle = Some(cancel);
}
cx.emit(DialogOpened);
cx.notify();
}
pub fn close(&mut self, cx: &mut Context<Self>) {
if !self.is_open {
return;
}
self.is_open = false;
self.focus_handle = None;
self.cancel_focus_handle = None;
cx.emit(DialogClosed);
cx.emit(DismissEvent);
cx.notify();
}
pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_open {
self.close(cx);
} else {
self.open(window, cx);
}
}
pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.is_open {
return;
}
let Some(handler) = self
.confirmation
.as_ref()
.map(|confirmation| confirmation.on_confirm.clone())
else {
return;
};
self.close(cx);
cx.emit(DialogConfirmed);
if let Some(handler) = handler {
handler(window, cx);
}
}
pub fn cancel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.is_open {
return;
}
let handler = self
.confirmation
.as_ref()
.and_then(|confirmation| confirmation.on_cancel.clone());
self.close(cx);
cx.emit(DialogCancelled);
if let Some(handler) = handler {
handler(window, cx);
}
}
fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_confirmation() {
self.cancel(window, cx);
} else {
self.close(cx);
}
}
fn handle_close(&mut self, _: &Close, window: &mut Window, cx: &mut Context<Self>) {
if self.close_on_escape {
self.dismiss(window, cx);
}
}
}
impl Accessible for DialogState {
fn a11y(&self) -> A11y {
let role = if self.is_confirmation() {
Role::AlertDialog
} else {
Role::Dialog
};
let mut a11y = A11y::new(role);
if let Some(title) = self.title.clone() {
a11y = a11y.name(title);
}
if let Some(description) = self.description.clone() {
a11y = a11y.description(description);
}
a11y
}
}
impl Focusable for DialogState {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.focus_handle
.clone()
.unwrap_or_else(|| cx.focus_handle())
}
}
impl Render for DialogState {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
if !self.is_open {
return div().into_any_element();
}
let focus_handle = self.focus_handle.clone();
let cancel_focus_handle = self.cancel_focus_handle.clone();
let close_on_backdrop_click = self.close_on_backdrop_click;
let show_close_button = self.show_close_button;
let title = self.title.clone();
let description = self.description.clone();
let content = self.content.clone();
let confirmation = self.confirmation.clone();
let footer = self.footer.clone().filter(|_| confirmation.is_none());
let size = self.size;
let footer_gap = theme.control(size).gap;
let announcement = confirmation.is_some().then(|| self.a11y());
let backdrop_color = theme.overlay();
let surface_color = theme.surface();
let border_color = theme.border();
let fg_color = theme.fg();
let fg_muted_color = theme.fg_muted();
deferred(
div()
.id(self.id.clone())
.key_context(DIALOG_CONTEXT)
.when_some(focus_handle, |this, handle| {
this.track_focus(&handle)
.on_action(cx.listener(Self::handle_close))
})
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(backdrop_color)
.when(close_on_backdrop_click, |this| {
this.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|this, _event, window, cx| {
this.dismiss(window, cx);
}),
)
})
.child(
div()
.id(scoped(&self.id, "panel"))
.when_some(announcement, |this, a11y| this.announce(a11y))
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.min_w(px(320.))
.max_w(px(500.))
.bg(surface_color)
.border_1()
.border_color(border_color)
.rounded_lg()
.shadow_xl()
.flex()
.flex_col()
.when(title.is_some() || show_close_button, |this| {
this.child(
div()
.flex()
.items_start()
.justify_between()
.p_4()
.when(
description.is_some()
|| content.is_some()
|| footer.is_some(),
|this| this.pb_0(),
)
.when_some(title.clone(), |this, title| {
this.child(
div()
.flex_1()
.text_base()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(fg_color)
.child(title),
)
})
.when(show_close_button, |this| {
this.child(
icon_button(
scoped(&self.id, "close"),
Icons::cross_1(),
)
.on_click(
cx.listener(|this, _, window, cx| {
this.dismiss(window, cx);
}),
),
)
}),
)
})
.when_some(description, |this, desc| {
this.child(
div()
.px_4()
.pt_2()
.when(
content.is_none()
&& footer.is_none()
&& confirmation.is_none(),
|this| this.pb_4(),
)
.text_sm()
.text_color(fg_muted_color)
.child(desc),
)
})
.when_some(content, |this, content| {
this.child(
div()
.px_4()
.pt_2()
.when(footer.is_none() && confirmation.is_none(), |this| {
this.pb_4()
})
.child(content(window, cx)),
)
})
.when_some(footer, |this, footer| {
this.child(
div()
.flex()
.justify_end()
.gap_2()
.p_4()
.child(footer(window, cx)),
)
})
.when_some(confirmation, |this, confirmation| {
this.child(
h_stack()
.justify_end()
.gap(footer_gap)
.p_4()
.child(
button(
scoped(&self.id, "cancel"),
confirmation.cancel_label.clone(),
)
.control_size(size)
.when_some(cancel_focus_handle, |cancel, handle| {
cancel.focus_handle(handle)
})
.on_click(
cx.listener(|this, _, window, cx| {
this.cancel(window, cx);
}),
),
)
.child(
button(
scoped(&self.id, "confirm"),
confirmation.confirm_label.clone(),
)
.control_size(size)
.when(confirmation.destructive, |confirm| {
confirm.destructive()
})
.on_click(
cx.listener(|this, _, window, cx| {
this.confirm(window, cx);
}),
),
),
)
}),
),
)
.with_priority(10)
.into_any_element()
}
}
pub fn bind_dialog_keys(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Close, Some(DIALOG_CONTEXT))]);
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use std::cell::RefCell;
fn confirmation() -> Dialog {
dialog("delete-project").confirm(
"Delete this project?",
"Its 42 tasks are deleted with it. This cannot be undone.",
)
}
#[test]
fn a_confirmation_announces_an_alert_dialog_named_by_its_question() {
let state = DialogState::new(confirmation());
assert!(state.is_confirmation());
assert_eq!(
state.a11y(),
A11y::new(Role::AlertDialog)
.name("Delete this project?")
.description("Its 42 tasks are deleted with it. This cannot be undone.")
);
}
#[test]
fn a_plain_dialog_is_not_announced() {
let state = DialogState::new(dialog("plain").title("Settings"));
assert!(!state.is_confirmation());
assert_eq!(state.a11y(), A11y::new(Role::Dialog).name("Settings"));
}
#[test]
fn an_untitled_dialog_has_no_name_to_announce() {
assert!(
DialogState::new(dialog("plain"))
.a11y()
.is_missing_a_required_name()
);
}
#[test]
#[should_panic(expected = "refines a confirmation and does not create one")]
fn a_label_builder_does_not_conjure_a_confirmation() {
let _ = dialog("plain").title("Settings").confirm_label("Delete");
}
#[test]
fn the_confirming_answer_is_destructive_unless_told_otherwise() {
let loud = DialogState::new(confirmation());
assert!(loud.confirmation.as_ref().unwrap().destructive);
let quiet = DialogState::new(confirmation().destructive(false));
assert!(!quiet.confirmation.as_ref().unwrap().destructive);
}
#[test]
fn a_destructive_button_reports_its_variant() {
use crate::elements::button::{ButtonVariant, button};
let plain = button("save", "Save");
assert_eq!(
crate::traits::button::Button::variant(&plain),
ButtonVariant::Filled
);
assert_eq!(
crate::traits::button::Button::variant(&button("delete", "Delete").destructive()),
ButtonVariant::Destructive
);
}
#[gpui::test]
fn escape_cancels_a_confirmation_and_never_confirms_it(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let confirmed = Rc::new(RefCell::new(0usize));
let cancelled = Rc::new(RefCell::new(0usize));
let (yes, no) = (confirmed.clone(), cancelled.clone());
let window = cx.add_window(move |_window, _cx| {
DialogState::new(
confirmation()
.on_confirm(move |_, _| *yes.borrow_mut() += 1)
.on_cancel(move |_, _| *no.borrow_mut() += 1),
)
});
window
.update(cx, |state, window, cx| {
state.open(window, cx);
assert!(state.is_open());
state.handle_close(&Close, window, cx);
})
.unwrap();
assert_eq!(*confirmed.borrow(), 0, "Escape confirmed a destruction");
assert_eq!(*cancelled.borrow(), 1);
window
.update(cx, |state, _window, _cx| assert!(!state.is_open()))
.unwrap();
}
#[gpui::test]
fn dismissing_a_plain_dialog_just_closes_it(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let window =
cx.add_window(|_window, _cx| DialogState::new(dialog("plain").title("Settings")));
window
.update(cx, |state, window, cx| {
state.open(window, cx);
state.dismiss(window, cx);
assert!(!state.is_open());
})
.unwrap();
}
#[gpui::test]
fn confirming_runs_its_handler_exactly_once(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let ran = Rc::new(RefCell::new(0usize));
let counter = ran.clone();
let window = cx.add_window(move |_window, _cx| {
DialogState::new(confirmation().on_confirm(move |_, _| *counter.borrow_mut() += 1))
});
window
.update(cx, |state, window, cx| {
state.open(window, cx);
state.confirm(window, cx);
state.confirm(window, cx);
assert!(!state.is_open());
})
.unwrap();
assert_eq!(*ran.borrow(), 1);
}
#[gpui::test]
fn a_confirmation_opens_with_the_safe_action_focused(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let window = cx.add_window(|_window, _cx| DialogState::new(confirmation()));
window
.update(cx, |state, window, cx| {
state.open(window, cx);
let cancel = state
.cancel_focus_handle
.clone()
.expect("a confirmation mints a handle for its safe answer");
assert_eq!(
window.focused(cx),
Some(cancel),
"a confirmation opened on something other than Cancel"
);
})
.unwrap();
}
#[gpui::test]
fn a_plain_dialog_focuses_only_its_root(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let window =
cx.add_window(|_window, _cx| DialogState::new(dialog("plain").title("Settings")));
window
.update(cx, |state, window, cx| {
state.open(window, cx);
assert!(state.cancel_focus_handle.is_none());
assert_eq!(window.focused(cx), state.focus_handle.clone());
})
.unwrap();
}
}