1use std::{rc::Rc, time::Duration};
2
3use gpui::{
4 anchored, div, hsla, point, prelude::FluentBuilder, px, relative, Animation, AnimationExt as _,
5 AnyElement, App, Axis, Bounds, BoxShadow, ClickEvent, Div, Edges, FocusHandle, Hsla,
6 InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
7 RenderOnce, SharedString, StyleRefinement, Styled, Window,
8};
9use rust_i18n::t;
10
11use crate::{
12 actions::{Cancel, Confirm},
13 animation::cubic_bezier,
14 button::{Button, ButtonVariant, ButtonVariants as _},
15 h_flex, v_flex, ActiveTheme as _, ContextModal, IconName, Root, Sizable as _, StyledExt,
16};
17
18const CONTEXT: &str = "Modal";
19pub(crate) fn init(cx: &mut App) {
20 cx.bind_keys([
21 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
22 KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
23 ]);
24}
25
26type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
27type FooterFn =
28 Box<dyn Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<AnyElement>>;
29
30pub struct ModalButtonProps {
32 ok_text: Option<SharedString>,
33 ok_variant: ButtonVariant,
34 cancel_text: Option<SharedString>,
35 cancel_variant: ButtonVariant,
36}
37
38impl Default for ModalButtonProps {
39 fn default() -> Self {
40 Self {
41 ok_text: None,
42 ok_variant: ButtonVariant::Primary,
43 cancel_text: None,
44 cancel_variant: ButtonVariant::default(),
45 }
46 }
47}
48
49impl ModalButtonProps {
50 pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
52 self.ok_text = Some(ok_text.into());
53 self
54 }
55
56 pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
58 self.ok_variant = ok_variant;
59 self
60 }
61
62 pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
64 self.cancel_text = Some(cancel_text.into());
65 self
66 }
67
68 pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
70 self.cancel_variant = cancel_variant;
71 self
72 }
73}
74
75#[derive(IntoElement)]
76pub struct Modal {
77 style: StyleRefinement,
78 title: Option<AnyElement>,
79 footer: Option<FooterFn>,
80 content: Div,
81 width: Pixels,
82 max_width: Option<Pixels>,
83 margin_top: Option<Pixels>,
84
85 on_close: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
86 on_ok: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>,
87 on_cancel: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>,
88 button_props: ModalButtonProps,
89 show_close: bool,
90 overlay: bool,
91 overlay_closable: bool,
92 keyboard: bool,
93
94 pub(crate) focus_handle: FocusHandle,
96 pub(crate) layer_ix: usize,
97 pub(crate) overlay_visible: bool,
98}
99
100pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
101 if !overlay {
102 return hsla(0., 0., 0., 0.);
103 }
104
105 cx.theme().overlay
106}
107
108impl Modal {
109 pub fn new(_: &mut Window, cx: &mut App) -> Self {
110 Self {
111 focus_handle: cx.focus_handle(),
112 style: StyleRefinement::default(),
113 title: None,
114 footer: None,
115 content: v_flex(),
116 margin_top: None,
117 width: px(480.),
118 max_width: None,
119 overlay: true,
120 keyboard: true,
121 layer_ix: 0,
122 overlay_visible: false,
123 on_close: Rc::new(|_, _, _| {}),
124 on_ok: None,
125 on_cancel: Rc::new(|_, _, _| true),
126 button_props: ModalButtonProps::default(),
127 show_close: true,
128 overlay_closable: true,
129 }
130 }
131
132 pub fn title(mut self, title: impl IntoElement) -> Self {
134 self.title = Some(title.into_any_element());
135 self
136 }
137
138 pub fn footer<E, F>(mut self, footer: F) -> Self
147 where
148 E: IntoElement,
149 F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
150 {
151 self.footer = Some(Box::new(move |ok, cancel, window, cx| {
152 footer(ok, cancel, window, cx)
153 .into_iter()
154 .map(|e| e.into_any_element())
155 .collect()
156 }));
157 self
158 }
159
160 pub fn confirm(self) -> Self {
164 self.footer(|ok, cancel, window, cx| vec![cancel(window, cx), ok(window, cx)])
165 .overlay_closable(false)
166 .show_close(false)
167 }
168
169 pub fn alert(self) -> Self {
173 self.footer(|ok, _, window, cx| vec![ok(window, cx)])
174 .overlay_closable(false)
175 .show_close(false)
176 }
177
178 pub fn button_props(mut self, button_props: ModalButtonProps) -> Self {
180 self.button_props = button_props;
181 self
182 }
183
184 pub fn on_close(
188 mut self,
189 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
190 ) -> Self {
191 self.on_close = Rc::new(on_close);
192 self
193 }
194
195 pub fn on_ok(
199 mut self,
200 on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
201 ) -> Self {
202 self.on_ok = Some(Rc::new(on_ok));
203 self
204 }
205
206 pub fn on_cancel(
210 mut self,
211 on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
212 ) -> Self {
213 self.on_cancel = Rc::new(on_cancel);
214 self
215 }
216
217 pub fn show_close(mut self, show_close: bool) -> Self {
219 self.show_close = show_close;
220 self
221 }
222
223 pub fn margin_top(mut self, margin_top: Pixels) -> Self {
225 self.margin_top = Some(margin_top);
226 self
227 }
228
229 pub fn width(mut self, width: Pixels) -> Self {
231 self.width = width;
232 self
233 }
234
235 pub fn max_w(mut self, max_width: Pixels) -> Self {
237 self.max_width = Some(max_width);
238 self
239 }
240
241 pub fn overlay(mut self, overlay: bool) -> Self {
243 self.overlay = overlay;
244 self
245 }
246
247 pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
251 self.overlay_closable = overlay_closable;
252 self
253 }
254
255 pub fn keyboard(mut self, keyboard: bool) -> Self {
257 self.keyboard = keyboard;
258 self
259 }
260
261 pub(crate) fn has_overlay(&self) -> bool {
262 self.overlay
263 }
264}
265
266impl ParentElement for Modal {
267 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
268 self.content.extend(elements);
269 }
270}
271
272impl Styled for Modal {
273 fn style(&mut self) -> &mut gpui::StyleRefinement {
274 &mut self.style
275 }
276}
277
278impl RenderOnce for Modal {
279 fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
280 let layer_ix = self.layer_ix;
281 let on_close = self.on_close.clone();
282 let on_ok = self.on_ok.clone();
283 let on_cancel = self.on_cancel.clone();
284
285 let render_ok: RenderButtonFn = Box::new({
286 let on_ok = on_ok.clone();
287 let on_close = on_close.clone();
288 let ok_text = self
289 .button_props
290 .ok_text
291 .unwrap_or_else(|| t!("Modal.ok").into());
292 let ok_variant = self.button_props.ok_variant;
293 move |_, _| {
294 Button::new("ok")
295 .label(ok_text)
296 .with_variant(ok_variant)
297 .on_click({
298 let on_ok = on_ok.clone();
299 let on_close = on_close.clone();
300
301 move |_, window, cx| {
302 if let Some(on_ok) = &on_ok {
303 if !on_ok(&ClickEvent::default(), window, cx) {
304 return;
305 }
306 }
307
308 on_close(&ClickEvent::default(), window, cx);
309 window.close_modal(cx);
310 }
311 })
312 .into_any_element()
313 }
314 });
315 let render_cancel: RenderButtonFn = Box::new({
316 let on_cancel = on_cancel.clone();
317 let on_close = on_close.clone();
318 let cancel_text = self
319 .button_props
320 .cancel_text
321 .unwrap_or_else(|| t!("Modal.cancel").into());
322 let cancel_variant = self.button_props.cancel_variant;
323 move |_, _| {
324 Button::new("cancel")
325 .label(cancel_text)
326 .with_variant(cancel_variant)
327 .on_click({
328 let on_cancel = on_cancel.clone();
329 let on_close = on_close.clone();
330 move |_, window, cx| {
331 if !on_cancel(&ClickEvent::default(), window, cx) {
332 return;
333 }
334
335 on_close(&ClickEvent::default(), window, cx);
336 window.close_modal(cx);
337 }
338 })
339 .into_any_element()
340 }
341 });
342
343 let window_paddings = crate::window_border::window_paddings(window);
344 let view_size = window.viewport_size()
345 - gpui::size(
346 window_paddings.left + window_paddings.right,
347 window_paddings.top + window_paddings.bottom,
348 );
349 let bounds = Bounds {
350 origin: Point::default(),
351 size: view_size,
352 };
353 let offset_top = px(layer_ix as f32 * 16.);
354 let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
355 let x = bounds.center().x - self.width / 2.;
356
357 let base_size = window.text_style().font_size;
358 let rem_size = window.rem_size();
359
360 let mut paddings = Edges::all(px(24.));
361 if let Some(pl) = self.style.padding.left {
362 paddings.left = pl.to_pixels(base_size, rem_size);
363 }
364 if let Some(pr) = self.style.padding.right {
365 paddings.right = pr.to_pixels(base_size, rem_size);
366 }
367 if let Some(pt) = self.style.padding.top {
368 paddings.top = pt.to_pixels(base_size, rem_size);
369 }
370 if let Some(pb) = self.style.padding.bottom {
371 paddings.bottom = pb.to_pixels(base_size, rem_size);
372 }
373
374 let animation = Animation::new(Duration::from_secs_f64(0.25))
375 .with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
376
377 anchored()
378 .position(point(window_paddings.left, window_paddings.top))
379 .snap_to_window()
380 .child(
381 div()
382 .id("modal")
383 .w(view_size.width)
384 .h(view_size.height)
385 .when(self.overlay_visible, |this| {
386 this.occlude().bg(overlay_color(self.overlay, cx))
387 })
388 .when(self.overlay_closable, |this| {
389 if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() {
391 return this;
392 }
393
394 this.on_mouse_down(MouseButton::Left, {
395 let on_cancel = on_cancel.clone();
396 let on_close = on_close.clone();
397 move |_, window, cx| {
398 on_cancel(&ClickEvent::default(), window, cx);
399 on_close(&ClickEvent::default(), window, cx);
400 window.close_modal(cx);
401 }
402 })
403 })
404 .child(
405 v_flex()
406 .id(layer_ix)
407 .bg(cx.theme().background)
408 .border_1()
409 .border_color(cx.theme().border)
410 .rounded(cx.theme().radius_lg)
411 .min_h_24()
412 .pt(paddings.top)
413 .pb(paddings.bottom)
414 .gap(paddings.top.min(px(16.)))
415 .refine_style(&self.style)
416 .px_0()
417 .key_context(CONTEXT)
418 .track_focus(&self.focus_handle)
419 .tab_group()
420 .when(self.keyboard, |this| {
421 this.on_action({
422 let on_cancel = on_cancel.clone();
423 let on_close = on_close.clone();
424 move |_: &Cancel, window, cx| {
425 on_cancel(&ClickEvent::default(), window, cx);
430 on_close(&ClickEvent::default(), window, cx);
431 window.close_modal(cx);
432 }
433 })
434 .on_action({
435 let on_ok = on_ok.clone();
436 let on_close = on_close.clone();
437 let has_footer = self.footer.is_some();
438 move |_: &Confirm, window, cx| {
439 if let Some(on_ok) = &on_ok {
440 if on_ok(&ClickEvent::default(), window, cx) {
441 on_close(&ClickEvent::default(), window, cx);
442 window.close_modal(cx);
443 }
444 } else if has_footer {
445 window.close_modal(cx);
446 }
447 }
448 })
449 })
450 .absolute()
452 .occlude()
453 .relative()
454 .left(x)
455 .top(y)
456 .w(self.width)
457 .when_some(self.max_width, |this, w| this.max_w(w))
458 .when_some(self.title, |this, title| {
459 this.child(
460 div()
461 .pl(paddings.left)
462 .pr(paddings.right)
463 .line_height(relative(1.))
464 .font_semibold()
465 .child(title),
466 )
467 })
468 .children(self.show_close.then(|| {
469 Button::new("close")
470 .absolute()
471 .top(paddings.top - px(3.))
472 .right(paddings.right - px(3.))
473 .small()
474 .ghost()
475 .icon(IconName::Close)
476 .on_click({
477 let on_cancel = self.on_cancel.clone();
478 let on_close = self.on_close.clone();
479 move |_, window, cx| {
480 on_cancel(&ClickEvent::default(), window, cx);
481 on_close(&ClickEvent::default(), window, cx);
482 window.close_modal(cx);
483 }
484 })
485 }))
486 .child(
487 div().w_full().flex_1().overflow_hidden().child(
488 v_flex()
489 .pl(paddings.left)
490 .pr(paddings.right)
491 .scrollable(Axis::Vertical)
492 .child(self.content),
493 ),
494 )
495 .when_some(self.footer, |this, footer| {
496 this.child(
497 h_flex()
498 .gap_2()
499 .pl(paddings.left)
500 .pr(paddings.right)
501 .line_height(relative(1.))
502 .justify_end()
503 .children(footer(render_ok, render_cancel, window, cx)),
504 )
505 })
506 .with_animation("slide-down", animation.clone(), move |this, delta| {
507 let y_offset = px(0.) + delta * px(30.);
508 let shadow = vec![
510 BoxShadow {
511 color: hsla(0., 0., 0., 0.1 * delta),
512 offset: point(px(0.), px(20.)),
513 blur_radius: px(25.),
514 spread_radius: px(-5.),
515 },
516 BoxShadow {
517 color: hsla(0., 0., 0., 0.1 * delta),
518 offset: point(px(0.), px(8.)),
519 blur_radius: px(10.),
520 spread_radius: px(-6.),
521 },
522 ];
523 this.top(y + y_offset).shadow(shadow)
524 }),
525 )
526 .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
527 )
528 }
529}