use crate::gpui_compat::element_id;
use crate::motion::{fade_in, pop_in};
use gpui::{
AnyElement, App, Context, FocusHandle, Focusable, IntoElement, KeyBinding, MouseButton, Render,
SharedString, Window, actions, div, prelude::*, px,
};
use liora_core::Config;
use liora_icons::Icon;
use liora_icons_lucide::IconName;
use std::{collections::HashMap, sync::Arc};
type DialogCloseCallback = Arc<dyn Fn(&mut Window, &mut App) + 'static>;
#[derive(Default)]
struct ActiveDialogRuntime {
close_on_escape: HashMap<SharedString, bool>,
on_close: HashMap<SharedString, DialogCloseCallback>,
}
impl gpui::Global for ActiveDialogRuntime {}
struct DialogEscapeInterceptorInstalled;
impl gpui::Global for DialogEscapeInterceptorInstalled {}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DialogEscapeDecision {
Close(SharedString),
Block,
Ignore,
}
fn ensure_dialog_runtime(cx: &mut App) {
if !cx.has_global::<ActiveDialogRuntime>() {
cx.set_global(ActiveDialogRuntime::default());
}
if !cx.has_global::<DialogEscapeInterceptorInstalled>() {
cx.intercept_keystrokes(|event, window, cx| {
if event.keystroke.key == "escape" {
close_top_dialog_from_window(window, cx);
}
})
.detach();
cx.set_global(DialogEscapeInterceptorInstalled);
}
}
fn register_dialog_runtime(
id: SharedString,
close_on_escape: bool,
on_close: DialogCloseCallback,
cx: &mut App,
) {
ensure_dialog_runtime(cx);
let runtime = cx.global_mut::<ActiveDialogRuntime>();
runtime.close_on_escape.insert(id.clone(), close_on_escape);
runtime.on_close.insert(id, on_close);
}
fn unregister_dialog_runtime(id: &SharedString, cx: &mut App) {
if !cx.has_global::<ActiveDialogRuntime>() {
return;
}
let runtime = cx.global_mut::<ActiveDialogRuntime>();
runtime.close_on_escape.remove(id);
runtime.on_close.remove(id);
}
fn clear_dialog_runtime(cx: &mut App) {
if !cx.has_global::<ActiveDialogRuntime>() {
return;
}
let runtime = cx.global_mut::<ActiveDialogRuntime>();
runtime.close_on_escape.clear();
runtime.on_close.clear();
}
fn active_modal_top_id(cx: &App) -> Option<SharedString> {
cx.try_global::<liora_core::ActiveModal>()
.and_then(|modal| modal.0.last().map(|entry| entry.id.clone()))
}
fn dialog_escape_decision_for(
runtime: Option<&ActiveDialogRuntime>,
active_modal_id: Option<&SharedString>,
) -> DialogEscapeDecision {
let Some(runtime) = runtime else {
return DialogEscapeDecision::Ignore;
};
let Some(id) = active_modal_id else {
return DialogEscapeDecision::Ignore;
};
if !runtime.close_on_escape.contains_key(id) && !runtime.on_close.contains_key(id) {
return DialogEscapeDecision::Ignore;
}
if runtime.close_on_escape.get(id).copied().unwrap_or(true) {
DialogEscapeDecision::Close(id.clone())
} else {
DialogEscapeDecision::Block
}
}
fn dialog_escape_decision(cx: &App) -> DialogEscapeDecision {
let active_modal_id = active_modal_top_id(cx);
dialog_escape_decision_for(
cx.try_global::<ActiveDialogRuntime>(),
active_modal_id.as_ref(),
)
}
fn close_dialog_by_id(id: SharedString, window: &mut Window, cx: &mut App) {
let callback = cx
.try_global::<ActiveDialogRuntime>()
.and_then(|runtime| runtime.on_close.get(&id).cloned());
if let Some(callback) = callback {
callback(window, cx);
} else {
unregister_dialog_runtime(&id, cx);
liora_core::clear_modal(&id, cx);
cx.refresh_windows();
}
}
fn close_top_dialog_from_window(window: &mut Window, cx: &mut App) {
match dialog_escape_decision(cx) {
DialogEscapeDecision::Close(id) => {
close_dialog_by_id(id, window, cx);
cx.stop_propagation();
}
DialogEscapeDecision::Block => {
cx.stop_propagation();
}
DialogEscapeDecision::Ignore => {}
}
}
fn close_top_dialog_if_escape_enabled(cx: &mut App) {
match dialog_escape_decision(cx) {
DialogEscapeDecision::Close(id) => {
if let Some(window) = cx.active_window() {
cx.defer(move |cx| {
let _ = window.update(cx, |_, window, cx| {
close_dialog_by_id(id, window, cx);
});
});
}
}
DialogEscapeDecision::Block => {}
DialogEscapeDecision::Ignore => cx.propagate(),
}
}
actions!(
dialog,
[
#[doc = "Keyboard action that closes the active dialog when dismissal is allowed."]
DialogClose
]
);
pub struct Dialog {
id: SharedString,
title: SharedString,
content: Arc<dyn Fn(&mut Window, &mut Context<DialogView>) -> AnyElement + 'static>,
close_on_click_outside: bool,
close_on_escape: bool,
animated: bool,
on_close: Arc<dyn Fn(&mut Window, &mut App) + 'static>,
}
pub struct DialogView {
id: SharedString,
title: SharedString,
content: Arc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static>,
close_on_click_outside: bool,
close_on_escape: bool,
animated: bool,
on_close: Arc<dyn Fn(&mut Window, &mut App) + 'static>,
focus_handle: FocusHandle,
focus_requested: bool,
}
impl DialogView {
fn new(
id: SharedString,
title: SharedString,
content: Arc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static>,
close_on_click_outside: bool,
close_on_escape: bool,
animated: bool,
on_close: impl Fn(&mut Window, &mut App) + 'static,
focus_handle: FocusHandle,
focus_requested: bool,
) -> Self {
Self {
id,
title,
content,
close_on_click_outside,
close_on_escape,
animated,
on_close: Arc::new(on_close),
focus_handle,
focus_requested,
}
}
}
impl Focusable for DialogView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for DialogView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.global::<Config>().theme.clone();
let id = self.id.clone();
let title = self.title.clone();
let content_fn = self.content.clone();
let on_close = self.on_close.clone();
let on_close_for_close_button = on_close.clone();
let on_close_for_outside_click = on_close.clone();
let on_close_for_escape = on_close;
let close_on_click_outside = self.close_on_click_outside;
let close_on_escape = self.close_on_escape;
let animated = self.animated;
let focus_handle = self.focus_handle(cx);
if !self.focus_requested && animated {
self.focus_requested = true;
let focus_handle = focus_handle.clone();
window.defer(cx, move |window, cx| window.focus(&focus_handle, cx));
} else if !self.focus_requested {
self.focus_requested = true;
}
let panel = div()
.w_full()
.max_w(px(420.0))
.min_w(px(0.0))
.mx_4()
.bg(theme.neutral.card)
.cursor_default()
.rounded(px(theme.radius.md))
.shadow_xl()
.on_mouse_move(|_, _, cx| {
cx.stop_propagation();
})
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
}) .child(
div()
.p_4()
.min_w(px(0.0))
.border_b_1()
.border_color(theme.neutral.border)
.flex()
.justify_between()
.items_center()
.child(
div()
.min_w(px(0.0))
.flex_1()
.font_weight(gpui::FontWeight::BOLD)
.text_color(theme.neutral.text_1)
.whitespace_normal()
.child(title),
)
.child(
div()
.id(element_id(format!("{id}-close-btn")))
.cursor_pointer()
.child(
Icon::new(IconName::X)
.size(px(16.0))
.color(theme.neutral.icon),
)
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
on_close_for_close_button(window, cx);
}),
),
)
.child(
div()
.p_4()
.min_w(px(0.0))
.text_color(theme.neutral.text_2)
.overflow_hidden()
.child(content_fn(window, cx)),
);
let overlay = div()
.id(id.clone())
.track_focus(&focus_handle)
.absolute()
.size_full()
.cursor_default()
.bg(theme.neutral.overlay)
.flex()
.items_center()
.justify_center()
.on_mouse_move(|_, _, cx| {
cx.stop_propagation();
})
.when(close_on_click_outside, |s| {
s.on_mouse_down(MouseButton::Left, {
let on_close = on_close_for_outside_click.clone();
move |_, window, cx| {
on_close(window, cx);
}
})
})
.when(close_on_escape, |s| {
s.on_action(cx.listener({
let on_close = on_close_for_escape.clone();
move |_, _action: &DialogClose, window, cx| {
on_close(window, cx);
}
}))
});
if animated {
fade_in(
element_id(format!("{id}-overlay-motion")),
overlay.child(pop_in(element_id(format!("{id}-panel-motion")), panel)),
)
.into_any_element()
} else {
overlay.child(panel).into_any_element()
}
}
}
#[cfg(test)]
mod motion_tests {
#[test]
fn dialog_panel_is_responsive_and_text_wraps() {
let source = include_str!("dialog.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(source.contains(".w_full()"));
assert!(source.contains(".max_w(px(420.0))"));
assert!(source.contains(".min_w(px(0.0))"));
assert!(source.contains(".mx_4()"));
assert!(source.contains(".overflow_hidden()"));
assert!(source.contains(".whitespace_normal()"));
}
#[test]
fn dialog_uses_liora_motion_on_overlay_and_panel() {
let source = include_str!("dialog.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(source.contains("fade_in("));
assert!(source.contains("pop_in("));
assert!(source.contains("panel-motion"));
}
#[test]
fn dialog_can_skip_intro_motion_for_latency_sensitive_surfaces() {
let source = include_str!("dialog.rs");
let impl_source = source
.rsplit("\nimpl Dialog {")
.next()
.expect("Dialog builder implementation should exist");
assert!(source.contains("animated: bool"));
assert!(source.contains("if animated {"));
assert!(source.contains("overlay.child(panel).into_any_element()"));
assert!(impl_source.contains("pub fn animated(mut self, animated: bool) -> Self"));
assert!(impl_source.contains("pub fn immediate(self) -> Self"));
assert!(impl_source.contains("self.animated(false)"));
assert!(
impl_source.contains("pub fn show_in_window(self, window: &mut Window, cx: &mut App)")
);
assert!(impl_source.contains("window.focus(&focus_handle, cx)"));
assert!(impl_source.contains("window.refresh()"));
}
#[test]
fn dialog_exposes_on_close_for_host_state_cleanup() {
let source = include_str!("dialog.rs");
let impl_source = source
.rsplit("\nimpl Dialog {")
.next()
.expect("Dialog builder implementation should exist");
assert!(source.contains("on_close: Arc<dyn Fn(&mut Window, &mut App)"));
assert!(impl_source.contains("pub fn on_close("));
assert!(impl_source.contains("user_on_close(window, cx);"));
}
#[test]
fn dialog_supports_focus_and_global_escape_dismissal() {
let full_source = include_str!("dialog.rs");
let source = full_source.split("#[cfg(test)]").next().unwrap();
assert!(source.contains("impl Focusable for DialogView"));
assert!(source.contains("focus_handle: FocusHandle"));
assert!(source.contains(".track_focus(&focus_handle)"));
assert!(source.contains("!self.focus_requested && animated"));
assert!(source.contains("window.defer(cx, move |window, cx|"));
assert!(source.contains("window.focus(&focus_handle, cx)"));
assert!(source.contains("s.on_action(cx.listener({"));
assert!(source.contains("struct ActiveDialogRuntime"));
assert!(source.contains("struct DialogEscapeInterceptorInstalled"));
assert!(source.contains("cx.intercept_keystrokes"));
assert!(source.contains(".detach();"));
assert!(full_source.contains("cx.on_action(|_: &DialogClose, cx|"));
assert!(source.contains("close_top_dialog_from_window"));
assert!(source.contains("close_top_dialog_if_escape_enabled"));
assert!(full_source.contains("unregister_dialog_runtime(&id_for_close, cx)"));
assert!(full_source.contains("cx.refresh_windows();"));
}
}
#[cfg(test)]
mod runtime_tests {
use super::*;
fn runtime_with(entries: &[(&str, bool)]) -> ActiveDialogRuntime {
let mut runtime = ActiveDialogRuntime::default();
for (id, close_on_escape) in entries {
let id: SharedString = id.to_string().into();
runtime.close_on_escape.insert(id, *close_on_escape);
}
runtime
}
#[test]
fn dialog_escape_decision_ignores_without_active_dialog_match() {
assert_eq!(
dialog_escape_decision_for(None, Some(&"dialog-a".into())),
DialogEscapeDecision::Ignore
);
let runtime = runtime_with(&[("dialog-a", true)]);
assert_eq!(
dialog_escape_decision_for(Some(&runtime), Some(&"tour-a".into())),
DialogEscapeDecision::Ignore
);
assert_eq!(
dialog_escape_decision_for(Some(&runtime), None),
DialogEscapeDecision::Ignore
);
}
#[test]
fn dialog_escape_decision_respects_top_dialog_close_policy() {
let runtime = runtime_with(&[("dialog-a", true), ("dialog-b", false)]);
assert_eq!(
dialog_escape_decision_for(Some(&runtime), Some(&"dialog-b".into())),
DialogEscapeDecision::Block
);
assert_eq!(
dialog_escape_decision_for(Some(&runtime), Some(&"dialog-a".into())),
DialogEscapeDecision::Close("dialog-a".into())
);
let runtime = runtime_with(&[("dialog-a", false), ("dialog-b", true)]);
assert_eq!(
dialog_escape_decision_for(Some(&runtime), Some(&"dialog-b".into())),
DialogEscapeDecision::Close("dialog-b".into())
);
}
#[test]
fn dialog_escape_decision_uses_active_modal_as_source_of_truth() {
let runtime = runtime_with(&[("dialog-a", true), ("stale-dialog", false)]);
assert_eq!(
dialog_escape_decision_for(Some(&runtime), Some(&"dialog-a".into())),
DialogEscapeDecision::Close("dialog-a".into())
);
assert_eq!(
dialog_escape_decision_for(Some(&runtime), Some(&"tour-a".into())),
DialogEscapeDecision::Ignore
);
}
#[test]
fn dialog_escape_interceptor_is_process_once_not_runtime_state() {
let source = include_str!("dialog.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
let runtime_struct = source
.split("struct ActiveDialogRuntime")
.nth(1)
.and_then(|part| {
part.split("impl gpui::Global for ActiveDialogRuntime")
.next()
})
.expect("ActiveDialogRuntime should be defined before its Global impl");
assert!(source.contains("struct DialogEscapeInterceptorInstalled"));
assert!(source.contains("!cx.has_global::<DialogEscapeInterceptorInstalled>()"));
assert!(source.contains("cx.set_global(DialogEscapeInterceptorInstalled)"));
assert!(source.contains(".detach();"));
assert!(!runtime_struct.contains("Subscription"));
assert!(!runtime_struct.contains("escape_interceptor"));
assert!(!runtime_struct.contains("stack"));
}
}
impl Dialog {
pub fn register_key_bindings(cx: &mut App) {
ensure_dialog_runtime(cx);
cx.bind_keys([KeyBinding::new("escape", DialogClose, None)]);
cx.on_action(|_: &DialogClose, cx| {
close_top_dialog_if_escape_enabled(cx);
});
}
pub fn new() -> Self {
Self {
id: liora_core::unique_id("dialog"),
title: SharedString::default(),
content: Arc::new(|_, _| div().child("Dialog Content").into_any_element()),
close_on_click_outside: true,
close_on_escape: true,
animated: true,
on_close: Arc::new(|_, _| {}),
}
}
pub fn id(mut self, id: impl Into<SharedString>) -> Self {
self.id = id.into();
self
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = title.into();
self
}
pub fn close_on_click_outside(mut self, c: bool) -> Self {
self.close_on_click_outside = c;
self
}
pub fn close_on_escape(mut self, c: bool) -> Self {
self.close_on_escape = c;
self
}
pub fn animated(mut self, animated: bool) -> Self {
self.animated = animated;
self
}
pub fn immediate(self) -> Self {
self.animated(false)
}
pub fn on_close(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Arc::new(f);
self
}
pub fn content<F, E>(mut self, f: F) -> Self
where
F: Fn(&mut Window, &mut Context<DialogView>) -> E + 'static,
E: IntoElement,
{
self.content = Arc::new(move |window, cx| f(window, cx).into_any_element());
self
}
pub fn show(self, cx: &mut App) {
self.show_with_window(None, cx);
}
pub fn show_in_window(self, window: &mut Window, cx: &mut App) {
self.show_with_window(Some(window), cx);
}
fn show_with_window(self, focus_window: Option<&mut Window>, cx: &mut App) {
let focus_requested = focus_window.is_some() || !self.animated;
let id = self.id;
let title = self.title;
let content = self.content;
let close_on_click_outside = self.close_on_click_outside;
let close_on_escape = self.close_on_escape;
let animated = self.animated;
let user_on_close = self.on_close;
let id_for_close = id.clone();
let close_callback: DialogCloseCallback = Arc::new(move |window, cx| {
unregister_dialog_runtime(&id_for_close, cx);
liora_core::clear_modal(&id_for_close, cx);
user_on_close(window, cx);
cx.refresh_windows();
});
let id_for_view = id.clone();
let close_callback_for_view = close_callback.clone();
let view = cx.new(move |cx| {
let focus_handle = cx.focus_handle();
DialogView::new(
id_for_view.clone(),
title,
content,
close_on_click_outside,
close_on_escape,
animated,
move |window, cx| {
close_callback_for_view(window, cx);
},
focus_handle,
focus_requested,
)
});
let view_for_focus = view.clone();
register_dialog_runtime(id.clone(), close_on_escape, close_callback, cx);
liora_core::set_active_modal(id, view.into(), cx);
if let Some(window) = focus_window {
let focus_handle = view_for_focus.read(cx).focus_handle(cx);
window.focus(&focus_handle, cx);
window.refresh();
}
cx.refresh_windows();
}
pub fn close(cx: &mut App) {
clear_dialog_runtime(cx);
liora_core::clear_active_modal(cx);
cx.refresh_windows();
}
pub fn close_id(id: impl Into<SharedString>, cx: &mut App) {
let id = id.into();
unregister_dialog_runtime(&id, cx);
liora_core::clear_modal(&id, cx);
cx.refresh_windows();
}
}