The close affordance reuses Button (ghost, small) at the panel's top right and routes through on_change like escape and scrim clicks; it is hidden in alert mode. The dialog feature now depends on button, and AGENTS.md codifies preferring existing components over custom elements.
284 lines
9.0 KiB
Rust
284 lines
9.0 KiB
Rust
use gpui::prelude::FluentBuilder as _;
|
|
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
Animation, AnimationExt as _, AnyElement, App, ElementId, FontWeight, InteractiveElement as _,
|
|
IntoElement, KeyDownEvent, MouseButton, ParentElement as _, RenderOnce, Role, SharedString,
|
|
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, ease_out_quint, relative,
|
|
};
|
|
use gpui_native_icons::IconRole;
|
|
use gpui_native_theme::ActiveTheme as _;
|
|
|
|
use crate::button::{Button, ButtonVariant};
|
|
use crate::common::{Sizable, v_flex};
|
|
|
|
/// Requests the dialog's close: `false` closes.
|
|
pub type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
|
type ContentFn = Box<dyn Fn(&mut Window, &mut App) -> AnyElement>;
|
|
type ActionsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<AnyElement>>;
|
|
|
|
/// A centred panel over a dim scrim: the platform's modal.
|
|
#[derive(IntoElement)]
|
|
pub struct Dialog {
|
|
id: ElementId,
|
|
open: bool,
|
|
alert: bool,
|
|
title: Option<SharedString>,
|
|
a11y_label: Option<SharedString>,
|
|
on_change: Option<ChangeHandler>,
|
|
content: Option<ContentFn>,
|
|
actions: Option<ActionsFn>,
|
|
}
|
|
|
|
impl Dialog {
|
|
/// Creates a closed dialog.
|
|
pub fn new<I>(id: I) -> Self
|
|
where
|
|
I: Into<ElementId>,
|
|
{
|
|
Self {
|
|
id: id.into(),
|
|
open: false,
|
|
alert: false,
|
|
title: None,
|
|
a11y_label: None,
|
|
on_change: None,
|
|
content: None,
|
|
actions: None,
|
|
}
|
|
}
|
|
|
|
/// Sets the current open state.
|
|
pub fn open(mut self, open: bool) -> Self {
|
|
self.open = open;
|
|
self
|
|
}
|
|
|
|
/// Sets the alert mode.
|
|
pub fn alert(mut self, alert: bool) -> Self {
|
|
self.alert = alert;
|
|
self
|
|
}
|
|
|
|
/// Requests close through `on_change` on escape, scrim clicks, and the close button.
|
|
pub fn on_change<F>(mut self, handler: F) -> Self
|
|
where
|
|
F: Fn(bool, &mut Window, &mut App) + 'static,
|
|
{
|
|
self.on_change = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
/// Sets the dialog's title.
|
|
pub fn title<L>(mut self, title: L) -> Self
|
|
where
|
|
L: Into<SharedString>,
|
|
{
|
|
self.title = Some(title.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the body content between the title and the actions.
|
|
pub fn content<F>(mut self, content: F) -> Self
|
|
where
|
|
F: Fn(&mut Window, &mut App) -> AnyElement + 'static,
|
|
{
|
|
self.content = Some(Box::new(content));
|
|
self
|
|
}
|
|
|
|
/// Sets the footer actions, laid out right-aligned in the given order.
|
|
pub fn actions<F>(mut self, actions: F) -> Self
|
|
where
|
|
F: Fn(&mut Window, &mut App) -> Vec<AnyElement> + 'static,
|
|
{
|
|
self.actions = Some(Box::new(actions));
|
|
self
|
|
}
|
|
|
|
/// Overrides the accessibility label of the panel.
|
|
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
|
where
|
|
L: Into<SharedString>,
|
|
{
|
|
self.a11y_label = Some(label.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for Dialog {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let root = div().absolute().inset_0();
|
|
|
|
if !self.open {
|
|
return root;
|
|
}
|
|
|
|
let (
|
|
scrim_color,
|
|
border,
|
|
foreground,
|
|
muted_foreground,
|
|
surface,
|
|
radius,
|
|
shadows,
|
|
title_size,
|
|
enter_duration,
|
|
) = {
|
|
let theme = cx.theme();
|
|
(
|
|
theme.colors.overlay,
|
|
if theme.borders.overlays {
|
|
theme.colors.border
|
|
} else {
|
|
theme.colors.surface
|
|
},
|
|
theme.colors.foreground,
|
|
theme.colors.muted_foreground,
|
|
theme.colors.surface,
|
|
theme.radius.overlay,
|
|
theme.elevations.dialog.shadows(),
|
|
theme.sizes.medium.text_size,
|
|
theme.motion.overlay,
|
|
)
|
|
};
|
|
|
|
let role = if self.alert {
|
|
Role::AlertDialog
|
|
} else {
|
|
Role::Dialog
|
|
};
|
|
|
|
let label = self
|
|
.a11y_label
|
|
.clone()
|
|
.or_else(|| self.title.clone())
|
|
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
|
|
|
let on_change = self.on_change.clone();
|
|
let alert = self.alert;
|
|
let show_close = !alert && on_change.is_some();
|
|
|
|
// Keyboard focus rides the panel across frames.
|
|
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
|
|
|
|
let content = self.content.map(|build| build(window, cx));
|
|
let actions = self.actions.map(|build| build(window, cx));
|
|
|
|
let panel = v_flex()
|
|
.id("dialog-panel")
|
|
.track_focus(&focus)
|
|
.role(role)
|
|
.aria_label(label)
|
|
.min_w_64()
|
|
.max_w(relative(0.9))
|
|
.p_4()
|
|
.gap_3()
|
|
.bg(surface)
|
|
.text_color(foreground)
|
|
.rounded(radius)
|
|
.border_1()
|
|
.border_color(border)
|
|
.shadow(shadows)
|
|
.overflow_hidden()
|
|
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
|
.on_key_down({
|
|
let on_change = on_change.clone();
|
|
move |event: &KeyDownEvent, window, cx| {
|
|
if event.keystroke.key == "escape"
|
|
&& !alert
|
|
&& let Some(on_change) = &on_change
|
|
{
|
|
on_change(false, window, cx);
|
|
}
|
|
cx.stop_propagation();
|
|
}
|
|
})
|
|
.when(self.title.is_some() || show_close, |panel| {
|
|
panel.child(
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.justify_end()
|
|
.gap_2()
|
|
.when_some(self.title.clone(), |row, title| {
|
|
row.child(
|
|
div()
|
|
.flex_1()
|
|
.text_size(title_size)
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.child(title),
|
|
)
|
|
})
|
|
.when(show_close, |row| {
|
|
row.child(
|
|
Button::new("dialog-close")
|
|
.icon(IconRole::Close)
|
|
.variant(ButtonVariant::Ghost)
|
|
.small()
|
|
.debug_selector(|| "dialog-close".to_string())
|
|
.accessibility_label("Close")
|
|
.on_click({
|
|
let on_change = on_change.clone();
|
|
move |_, window, cx| {
|
|
if let Some(on_change) = &on_change {
|
|
on_change(false, window, cx);
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
.when_some(content, |panel, content| {
|
|
panel.child(div().text_color(muted_foreground).child(content))
|
|
})
|
|
.when_some(actions, |panel, actions| {
|
|
panel.child(div().flex().justify_end().gap_2().children(actions))
|
|
})
|
|
.child(
|
|
canvas(|_, _, _| {}, {
|
|
move |_, _, window, cx| {
|
|
if !focus.contains_focused(window, cx) {
|
|
window.focus(&focus, cx);
|
|
}
|
|
}
|
|
})
|
|
.absolute()
|
|
.inset_0(),
|
|
);
|
|
|
|
let scrim = div()
|
|
.absolute()
|
|
.inset_0()
|
|
.bg(scrim_color)
|
|
.occlude()
|
|
.when(!alert, |scrim| {
|
|
scrim.on_mouse_down(MouseButton::Left, {
|
|
let on_change = on_change.clone();
|
|
move |_, _window, cx| {
|
|
if let Some(on_change) = &on_change {
|
|
on_change(false, _window, cx);
|
|
}
|
|
cx.stop_propagation();
|
|
}
|
|
})
|
|
});
|
|
|
|
root.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.p_4()
|
|
.child(scrim.with_animation(
|
|
"dialog-scrim-enter",
|
|
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
|
|scrim, delta| scrim.opacity(delta),
|
|
))
|
|
.child(panel.with_animation(
|
|
"dialog-enter",
|
|
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
|
|panel, delta| panel.opacity(delta),
|
|
))
|
|
}
|
|
}
|