use gpui::{
prelude::*, px, AnyElement, App, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
Styled, Window,
};
use herogpui_core::{element_id, Backdrop};
use herogpui_theme::ActiveTheme;
use crate::a11y::{self, A11y as _};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ModalSize {
Xs,
Sm,
#[default]
Md,
Lg,
Cover,
Full,
}
impl ModalSize {
pub const ALL: [ModalSize; 6] = [
ModalSize::Xs,
ModalSize::Sm,
ModalSize::Md,
ModalSize::Lg,
ModalSize::Cover,
ModalSize::Full,
];
fn max_width(self) -> Option<Pixels> {
match self {
ModalSize::Xs => Some(px(320.)),
ModalSize::Sm => Some(px(384.)),
ModalSize::Md => Some(px(448.)),
ModalSize::Lg => Some(px(512.)),
ModalSize::Cover | ModalSize::Full => None,
}
}
pub fn label(self) -> &'static str {
match self {
ModalSize::Xs => "Xs",
ModalSize::Sm => "Sm",
ModalSize::Md => "Md",
ModalSize::Lg => "Lg",
ModalSize::Cover => "Cover",
ModalSize::Full => "Full",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ModalPlacement {
#[default]
Auto,
Center,
Top,
Bottom,
}
pub(crate) fn placement_entry_offset(placement: ModalPlacement) -> (f32, f32) {
match placement {
ModalPlacement::Top => (0.0, 4.0),
ModalPlacement::Bottom => (0.0, -4.0),
ModalPlacement::Auto | ModalPlacement::Center => (0.0, 0.0),
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ModalScroll {
#[default]
Inside,
Outside,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum DismissReason {
CloseButton,
Escape,
Backdrop,
Drag,
}
pub type OnClose = std::sync::Arc<dyn Fn(&DismissReason, &mut Window, &mut App) + 'static>;
pub type OnOpenChange = std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
#[derive(IntoElement)]
pub struct Modal {
id: gpui::ElementId,
is_open: bool,
title: Option<SharedString>,
icon: Option<SharedString>,
icon_color: Option<herogpui_core::Color>,
size: ModalSize,
backdrop: Backdrop,
placement: ModalPlacement,
is_dismissible: bool,
is_keyboard_dismiss_disabled: bool,
scroll: ModalScroll,
on_open_change: Option<OnOpenChange>,
body: Vec<AnyElement>,
footer: Vec<AnyElement>,
on_close: Option<OnClose>,
radius: Option<Pixels>,
sx: Option<Box<gpui::StyleRefinement>>,
}
pub(crate) fn dialog_key(id: &gpui::ElementId, part: &'static str) -> gpui::ElementId {
element_id::scoped(id, part)
}
pub(crate) fn claim_dialog_focus(
id: &gpui::ElementId,
focus_handle: &gpui::FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let restore = window.use_keyed_state(dialog_key(id, "focus-return"), cx, |_, _| {
None::<gpui::FocusHandle>
});
if focus_handle.contains_focused(window, cx) {
return;
}
let previous = window.focused(cx).filter(|held| held != focus_handle);
if previous.is_some() && restore.read(cx).is_none() {
restore.update(cx, |slot, _| *slot = previous);
}
window.focus(focus_handle, cx);
}
pub(crate) fn release_dialog_focus(id: &gpui::ElementId, window: &mut Window, cx: &mut App) {
let restore = window.use_keyed_state(dialog_key(id, "focus-return"), cx, |_, _| {
None::<gpui::FocusHandle>
});
let previous = restore.read(cx).clone();
if let Some(previous) = previous {
restore.update(cx, |slot, _| *slot = None);
window.focus(&previous, cx);
}
}
pub(crate) trait CloseTriggerPart: 'static {
fn wire(&mut self, on_dismiss: Option<OnClose>, owner: gpui::ElementId, slot: usize);
}
pub(crate) fn take_close_triggers<T: CloseTriggerPart>(
children: &mut Vec<AnyElement>,
on_dismiss: Option<OnClose>,
owner: &gpui::ElementId,
first_slot: usize,
) -> Vec<AnyElement> {
let mut taken = Vec::new();
children.retain_mut(|child| {
if let Some(part) = child.downcast_mut::<T>() {
part.wire(on_dismiss.clone(), owner.clone(), first_slot + taken.len());
taken.push(std::mem::replace(child, gpui::div().into_any_element()));
false
} else {
true
}
});
taken
}
macro_rules! close_trigger_part {
($part:ident, $button_part:expr) => {
impl Default for $part {
fn default() -> Self {
Self::new()
}
}
impl gpui::ParentElement for $part {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements);
}
}
impl crate::modal::CloseTriggerPart for $part {
fn wire(
&mut self,
on_dismiss: Option<crate::modal::OnClose>,
owner: gpui::ElementId,
slot: usize,
) {
self.on_dismiss = on_dismiss;
self.owner = Some(owner);
self.slot = slot;
}
}
impl gpui::Element for $part {
type RequestLayoutState = gpui::AnyElement;
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let children = std::mem::take(&mut self.children);
let mut inner = match (self.on_dismiss.take(), self.owner.take()) {
(Some(on_dismiss), Some(owner)) => {
let button = crate::close_button::CloseButton::new(
herogpui_core::element_id::indexed(&owner, $button_part, self.slot),
)
.on_press(move |_, window, cx| {
on_dismiss(&crate::modal::DismissReason::CloseButton, window, cx)
});
if children.is_empty() {
button.into_any_element()
} else {
button
.icon(gpui::div().children(children))
.into_any_element()
}
}
_ => gpui::div().into_any_element(),
};
let layout = inner.request_layout(window, cx);
(layout, inner)
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
state: &mut Self::RequestLayoutState,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> Self::PrepaintState {
state.prepaint(window, cx);
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
state: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut gpui::Window,
cx: &mut gpui::App,
) {
state.paint(window, cx);
}
}
impl gpui::IntoElement for $part {
type Element = Self;
fn into_element(self) -> Self {
self
}
}
};
}
pub(crate) use close_trigger_part;
pub struct ModalCloseTrigger {
on_dismiss: Option<OnClose>,
owner: Option<gpui::ElementId>,
slot: usize,
children: Vec<AnyElement>,
}
impl ModalCloseTrigger {
pub fn new() -> Self {
Self {
on_dismiss: None,
owner: None,
slot: 0,
children: Vec::new(),
}
}
}
crate::close_trigger_part!(ModalCloseTrigger, "close-trigger");
impl Modal {
pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
self.id = id.into();
self
}
pub fn new() -> Self {
Self {
id: gpui::ElementId::Name("modal".into()),
is_open: false,
title: None,
icon: None,
icon_color: None,
size: ModalSize::Md,
backdrop: Backdrop::Opaque,
placement: ModalPlacement::Center,
is_dismissible: true,
is_keyboard_dismiss_disabled: false,
scroll: ModalScroll::default(),
on_open_change: None,
body: Vec::new(),
footer: Vec::new(),
on_close: None,
radius: None,
sx: None,
}
}
pub fn is_open(mut self, v: bool) -> Self {
self.is_open = v;
self
}
pub fn title(mut self, t: impl Into<SharedString>) -> Self {
self.title = Some(t.into());
self
}
pub fn icon(mut self, path: impl Into<SharedString>) -> Self {
self.icon = Some(path.into());
self
}
pub fn icon_color(mut self, color: herogpui_core::Color) -> Self {
self.icon_color = Some(color);
self
}
pub fn size(mut self, s: ModalSize) -> Self {
self.size = s;
self
}
pub fn is_dismissible(mut self, v: bool) -> Self {
self.is_dismissible = v;
self
}
pub fn backdrop(mut self, b: Backdrop) -> Self {
self.backdrop = b;
self
}
pub fn placement(mut self, p: ModalPlacement) -> Self {
self.placement = p;
self
}
pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
self.radius = Some(radius.into());
self
}
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
crate::util::refine_sx(&mut self.sx, style);
self
}
pub fn scroll(mut self, scroll: ModalScroll) -> Self {
self.scroll = scroll;
self
}
pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_open_change = Some(std::sync::Arc::new(f));
self
}
pub fn is_keyboard_dismiss_disabled(mut self, v: bool) -> Self {
self.is_keyboard_dismiss_disabled = v;
self
}
pub fn footer_child(mut self, el: impl IntoElement) -> Self {
self.footer.push(el.into_any_element());
self
}
pub fn on_close(mut self, f: impl Fn(&DismissReason, &mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(std::sync::Arc::new(f));
self
}
}
impl Default for Modal {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for Modal {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for Modal {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (phase, dismissal_token) = crate::util::overlay_scope(
window,
cx,
dialog_key(&self.id, "phase"),
self.is_open,
true,
);
if phase == crate::util::OverlayPhase::Closed {
release_dialog_focus(&self.id, window, cx);
return gpui::div().into_any_element();
}
let exiting = phase == crate::util::OverlayPhase::Exiting;
let focus =
window.use_keyed_state(dialog_key(&self.id, "focus"), cx, |_, cx| cx.focus_handle());
let focus_handle = focus.read(cx).clone();
claim_dialog_focus(&self.id, &focus_handle, window, cx);
let colors = cx.colors();
let dismiss: Option<OnClose> = match (self.on_close.clone(), self.on_open_change.clone()) {
(None, None) => None,
(close, open_change) => Some(crate::util::shared(
move |reason: &DismissReason, window: &mut Window, cx: &mut App| {
if let Some(f) = &close {
f(reason, window, cx);
}
if let Some(f) = &open_change {
f(&false, window, cx);
}
},
)),
};
let keyboard_dismiss = if self.is_keyboard_dismiss_disabled {
None
} else {
dismiss.clone()
};
let mut close_triggers =
take_close_triggers::<ModalCloseTrigger>(&mut self.body, dismiss.clone(), &self.id, 0);
close_triggers.extend(take_close_triggers::<ModalCloseTrigger>(
&mut self.footer,
dismiss.clone(),
&self.id,
close_triggers.len(),
));
let icon = self.icon.as_ref().map(|path| {
let (bg, fg) = match self.icon_color {
Some(color) => {
let role = cx.role(color);
(role.soft(), role.soft_foreground(colors.foreground))
}
None => (colors.default.color, colors.foreground),
};
gpui::div()
.flex()
.items_center()
.justify_center()
.flex_shrink_0()
.size(px(40.))
.rounded(crate::util::control_radius(cx))
.bg(bg)
.child(gpui::svg().size(px(20.)).path(path.clone()).text_color(fg))
});
let header = if self.title.is_some() || icon.is_some() {
Some(
gpui::div()
.flex()
.flex_col()
.gap(px(12.))
.children(icon)
.when_some(self.title.as_ref(), |el, title| {
el.child(
gpui::div()
.text_size(px(16.))
.line_height(px(24.))
.font_weight(gpui::FontWeight::MEDIUM)
.child(title.to_string()),
)
}),
)
} else {
None
};
let has_header = header.is_some();
let has_body = !self.body.is_empty();
let scroll_inside = self.scroll == ModalScroll::Inside;
let full = self.size == ModalSize::Full;
let panel_max = window.viewport_size().height - if full { px(0.) } else { px(80.) };
let inside_body_max = panel_max - px(48.);
let radius = self.radius;
let panel_radius = radius.unwrap_or_else(|| crate::util::container_radius(cx));
let panel = gpui::div()
.relative()
.flex()
.flex_col()
.w_full()
.when(
self.scroll == ModalScroll::Outside
&& matches!(
self.placement,
ModalPlacement::Center | ModalPlacement::Auto
),
gpui::Styled::my_auto,
)
.when(
self.scroll == ModalScroll::Outside && self.placement == ModalPlacement::Bottom,
gpui::Styled::mt_auto,
)
.when_some(self.size.max_width(), |e, w| e.max_w(w))
.when(
scroll_inside && matches!(self.size, ModalSize::Cover | ModalSize::Full),
|e| e.h_full().min_h_full(),
)
.when(self.scroll == ModalScroll::Outside, |e| e.flex_shrink_0())
.p(px(24.))
.when(self.scroll == ModalScroll::Inside, |e| e.max_h(panel_max))
.bg(colors.overlay.background)
.text_color(colors.foreground)
.when(!full, |e| {
e.rounded(panel_radius)
.shadow(cx.layout().overlay_shadow.clone())
})
.overflow_hidden()
.when_some(header, gpui::ParentElement::child)
.when(has_body, |panel| {
panel.child(
gpui::div()
.id(element_id::scoped(&self.id, "body"))
.flex()
.flex_col()
.gap(px(10.))
.when(has_header, |b| b.mt(px(8.)))
.text_size(px(14.))
.line_height(px(20.))
.text_color(colors.muted)
.mx(px(-3.))
.p(px(3.))
.when(scroll_inside, |b| {
b.max_h(inside_body_max).overflow_y_scroll()
})
.children(self.body),
)
});
let panel = panel
.id(element_id::scoped(&self.id, "dialog"))
.a11y_named(a11y::Role::Dialog, &a11y::Name::maybe(self.title.clone()));
let mut panel = if self.footer.is_empty() {
panel
} else {
panel.child(
gpui::div()
.flex()
.items_center()
.justify_end()
.gap(px(8.))
.when(has_header || has_body, |f| f.mt(px(20.)))
.children(self.footer),
)
};
for trigger in close_triggers {
panel = panel.child(
gpui::div()
.absolute()
.top(px(16.))
.right(px(16.))
.child(trigger),
);
}
let panel = if self.is_dismissible && !exiting {
if let Some(on_close) = dismiss.clone() {
crate::util::dismiss_on_press_outside_with_token(
panel,
dismissal_token.clone(),
move |window, cx| {
on_close(&DismissReason::Backdrop, window, cx);
crate::util::DismissResult::Handled
},
)
} else {
panel
}
} else {
panel
};
let backdrop_bg = match self.backdrop {
Backdrop::Opaque => colors.backdrop,
Backdrop::Blur => colors.backdrop.alpha(colors.backdrop.a * 0.6),
Backdrop::Transparent => gpui::transparent_black(),
};
let mut overlay = crate::util::trap_tab(
gpui::div()
.id(element_id::scoped(&self.id, "scroll"))
.track_focus(&focus_handle),
&focus_handle,
)
.absolute()
.inset_0()
.flex()
.p(px(40.))
.when(full, |e| e.p(px(0.)))
.when(self.scroll == ModalScroll::Outside, |e| {
e.flex_col().items_center().justify_start().overflow_y_scroll()
})
.when(
self.scroll == ModalScroll::Inside
&& matches!(
self.placement,
ModalPlacement::Center | ModalPlacement::Auto
),
|e| e.items_center().justify_center(),
)
.when(
self.scroll == ModalScroll::Inside && self.placement == ModalPlacement::Top,
|e| e.items_start().justify_center(),
)
.when(
self.scroll == ModalScroll::Inside
&& self.placement == ModalPlacement::Bottom,
|e| e.items_end().justify_center(),
);
if let Some(on_escape) = keyboard_dismiss {
overlay = crate::util::dismiss_on_escape_with_token(
overlay,
dismissal_token,
move |window, cx| {
on_escape(&DismissReason::Escape, window, cx);
crate::util::DismissResult::Handled
},
);
}
let scrim = gpui::div()
.id(element_id::scoped(&self.id, "backdrop"))
.absolute()
.inset_0()
.bg(backdrop_bg);
overlay = overlay.child(if exiting {
crate::anim::exiting(
scrim,
"modal-backdrop-out",
crate::anim::ZoomBox::default(),
crate::anim::Motion::BACKDROP_OUT,
cx,
)
} else {
crate::anim::entering(
scrim,
"modal-backdrop-anim",
crate::anim::Motion::BACKDROP_IN,
cx,
)
});
let zoom = crate::anim::ZoomBox {
width: self.size.max_width(),
radius: (!full).then_some(panel_radius),
slide_x: (!full && placement_entry_offset(self.placement).0 != 0.0)
.then(|| px(placement_entry_offset(self.placement).0)),
slide_y: (!full && placement_entry_offset(self.placement).1 != 0.0)
.then(|| px(placement_entry_offset(self.placement).1)),
..Default::default()
};
overlay = overlay.child(if exiting {
crate::anim::exiting(
panel,
"modal-panel-out",
zoom,
crate::anim::Motion::PANEL_OUT,
cx,
)
} else {
crate::anim::entering_zoom(
panel,
"modal-panel",
zoom,
crate::anim::Motion::PANEL_IN,
cx,
)
});
overlay = crate::util::apply_sx(overlay, &self.sx);
crate::util::window_overlay(overlay, window).into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::{placement_entry_offset, ModalPlacement};
#[test]
fn placement_entry_offsets_follow_the_painted_side() {
assert_eq!(placement_entry_offset(ModalPlacement::Top), (0.0, 4.0));
assert_eq!(placement_entry_offset(ModalPlacement::Bottom), (0.0, -4.0));
assert_eq!(placement_entry_offset(ModalPlacement::Auto), (0.0, 0.0));
assert_eq!(placement_entry_offset(ModalPlacement::Center), (0.0, 0.0));
}
}
crate::util::impl_component_styled!(Modal);