From 20feab5f406c73a9ea07bd193994a36eb9df171b Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 20 Jan 2025 17:42:05 +0800 Subject: [PATCH] modal: Add `confirm` Modal with default Ok, Cancel buttons. (#558) image - Added `overlay_closable` method to Modal to control overlay click to close modal. - Added `on_ok`, `on_cancel` callback method. - Added `confirm` to setup to use default confirm style (With a Ok, Cancel button). ## Break Changes - The `footer` method has been changed to with a fn not a element. --- Cargo.lock | 2 +- crates/story/src/modal_story.rs | 101 +++++++++----- crates/ui/Cargo.toml | 1 - crates/ui/locales/ui.yml | 9 ++ crates/ui/src/modal.rs | 232 +++++++++++++++++++++++++++++--- crates/ui/src/root.rs | 1 - 6 files changed, 290 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7460e02f..e43db36e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3439,7 +3439,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.95", diff --git a/crates/story/src/modal_story.rs b/crates/story/src/modal_story.rs index 41efc089..5afe771d 100644 --- a/crates/story/src/modal_story.rs +++ b/crates/story/src/modal_story.rs @@ -15,6 +15,7 @@ use ui::{ h_flex, input::TextInput, list::{List, ListDelegate, ListItem}, + modal::ModalButtonProps, notification::{Notification, NotificationType}, theme::ActiveTheme as _, v_flex, @@ -358,47 +359,49 @@ impl ModalStory { .child(dropdown.clone()) .child(date_picker.clone()), ) - .footer( - h_flex() - .gap_6() - .items_center() - .child(Button::new("confirm").primary().label("Confirm").on_click({ - let view = view.clone(); - let input1 = input1.clone(); - let date_picker = date_picker.clone(); - move |_, cx| { - cx.close_modal(); + .footer({ + let view = view.clone(); + let input1 = input1.clone(); + let date_picker = date_picker.clone(); + move |_, _, _cx| { + vec![ + Button::new("confirm").primary().label("Confirm").on_click({ + let view = view.clone(); + let input1 = input1.clone(); + let date_picker = date_picker.clone(); + move |_, cx| { + cx.close_modal(); - view.update(cx, |view, cx| { - view.selected_value = Some( - format!( - "Hello, {}, date: {}", - input1.read(cx).text(), - date_picker.read(cx).date() + view.update(cx, |view, cx| { + view.selected_value = Some( + format!( + "Hello, {}, date: {}", + input1.read(cx).text(), + date_picker.read(cx).date() + ) + .into(), ) - .into(), - ) - }); - } - })) - .child(Button::new("new-modal").label("Open Other Modal").on_click( - move |_, cx| { - cx.open_modal(move |modal, _| { - modal - .title("Other Modal") - .child("This is another modal.") - .min_h(px(300.)) - }); - }, - )) - .child( + }); + } + }), + Button::new("new-modal").label("Open Other Modal").on_click( + move |_, cx| { + cx.open_modal(move |modal, _| { + modal + .title("Other Modal") + .child("This is another modal.") + .min_h(px(300.)) + }); + }, + ), Button::new("cancel") .label("Cancel") .on_click(move |_, cx| { cx.close_modal(); }), - ), - ) + ] + } + }) }); self.input1.focus_handle(cx).focus(cx); @@ -553,9 +556,33 @@ impl Render for ModalStory { ) }) .child( - Button::new("show-modal") - .label("Open Modal...") - .on_click(cx.listener(|this, _, cx| this.show_modal(cx))), + h_flex().gap_3().flex_wrap().child( + Button::new("show-modal") + .label("Open Modal...") + .on_click(cx.listener(|this, _, cx| this.show_modal(cx))), + ) + .child( + Button::new("show-confirm-modal") + .label("Confirm Modal...") + .on_click(cx.listener(|_, _, cx| { + cx.open_modal(|modal, _| { + modal + .confirm() + .child("Are you sure to delete this item?") + .button_props( + ModalButtonProps::default() + .cancel_text("Abort") + .cancel_variant(ButtonVariant::Danger)) + .on_ok(|_, cx| { + cx.push_notification("You have pressed Ok."); + true + }).on_cancel(|_, cx| { + cx.push_notification("You have pressed Abort."); + true + }) + }); + })), + ) ) .child( h_flex() diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index c747f738..592c043c 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -10,7 +10,6 @@ doctest = false [dependencies] gpui.workspace = true - anyhow = "1" image = "0.25.1" itertools = "0.13.0" diff --git a/crates/ui/locales/ui.yml b/crates/ui/locales/ui.yml index 0a6d7ff7..e01bee4c 100644 --- a/crates/ui/locales/ui.yml +++ b/crates/ui/locales/ui.yml @@ -111,3 +111,12 @@ Dock: en: Expand zh-CN: 展开 zh-HK: 展開 +Modal: + ok: + en: OK + zh-CN: 确定 + zh-HK: 確定 + cancel: + en: Cancel + zh-CN: 取消 + zh-HK: 取消 diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index 75e0abe3..d4209014 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -6,34 +6,91 @@ use gpui::{ InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, WindowContext, }; +use rust_i18n::t; use crate::{ animation::cubic_bezier, - button::{Button, ButtonVariants as _}, + button::{Button, ButtonVariant, ButtonVariants as _}, + h_flex, theme::ActiveTheme as _, v_flex, ContextModal, IconName, Sizable as _, }; -actions!(modal, [Escape]); +actions!(modal, [Escape, Enter]); const CONTEXT: &str = "Modal"; pub fn init(cx: &mut AppContext) { - cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))]) + cx.bind_keys([ + KeyBinding::new("escape", Escape, Some(CONTEXT)), + KeyBinding::new("enter", Enter, Some(CONTEXT)), + ]); +} + +type RenderButtonFn = Box AnyElement>; +type FooterFn = Box Vec>; + +/// Modal button props. +pub struct ModalButtonProps { + ok_text: Option, + ok_variant: ButtonVariant, + cancel_text: Option, + cancel_variant: ButtonVariant, +} + +impl Default for ModalButtonProps { + fn default() -> Self { + Self { + ok_text: None, + ok_variant: ButtonVariant::Primary, + cancel_text: None, + cancel_variant: ButtonVariant::default(), + } + } +} + +impl ModalButtonProps { + /// Sets the text of the OK button. Default is `OK`. + pub fn ok_text(mut self, ok_text: impl Into) -> Self { + self.ok_text = Some(ok_text.into()); + self + } + + /// Sets the variant of the OK button. Default is `ButtonVariant::Primary`. + pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self { + self.ok_variant = ok_variant; + self + } + + /// Sets the text of the Cancel button. Default is `Cancel`. + pub fn cancel_text(mut self, cancel_text: impl Into) -> Self { + self.cancel_text = Some(cancel_text.into()); + self + } + + /// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`. + pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self { + self.cancel_variant = cancel_variant; + self + } } #[derive(IntoElement)] pub struct Modal { base: Div, title: Option, - footer: Option, + footer: Option, content: Div, width: Pixels, max_width: Option, margin_top: Option, on_close: Rc, + on_ok: Rc bool + 'static>, + on_cancel: Rc bool + 'static>, + button_props: ModalButtonProps, show_close: bool, overlay: bool, + overlay_closable: bool, keyboard: bool, /// This will be change when open the modal, the focus handle is create when open the modal. @@ -62,7 +119,7 @@ impl Modal { .border_color(cx.theme().border) .rounded_lg() .shadow_xl() - .min_h_48() + .min_h_24() .p_4() .gap_4(); @@ -80,7 +137,11 @@ impl Modal { layer_ix: 0, overlay_visible: true, on_close: Rc::new(|_, _| {}), + on_ok: Rc::new(|_, _| true), + on_cancel: Rc::new(|_, _| true), + button_props: ModalButtonProps::default(), show_close: true, + overlay_closable: true, } } @@ -91,8 +152,37 @@ impl Modal { } /// Set the footer of the modal. - pub fn footer(mut self, footer: impl IntoElement) -> Self { - self.footer = Some(footer.into_any_element()); + /// + /// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`. + /// + /// - First `RenderButtonFn` is the render function for the OK button. + /// - Second `RenderButtonFn` is the render function for the CANCEL button. + /// + /// When you set the footer, the footer will be placed default footer buttons. + pub fn footer(mut self, footer: F) -> Self + where + E: IntoElement, + F: Fn(RenderButtonFn, RenderButtonFn, &mut WindowContext) -> Vec + 'static, + { + self.footer = Some(Box::new(move |ok, cancel, cx| { + footer(ok, cancel, cx) + .into_iter() + .map(|e| e.into_any_element()) + .collect() + })); + self + } + + /// Set to use confirm modal, with OK and CANCEL buttons. + pub fn confirm(self) -> Self { + self.footer(|ok, cancel, cx| vec![cancel(cx), ok(cx)]) + .overlay_closable(false) + .show_close(false) + } + + /// Set the button props of the modal. + pub fn button_props(mut self, button_props: ModalButtonProps) -> Self { + self.button_props = button_props; self } @@ -105,6 +195,28 @@ impl Modal { self } + /// Sets the callback for when the modal is has been confirmed. + /// + /// The callback should return `true` to close the modal, if return `false` the modal will not be closed. + pub fn on_ok( + mut self, + on_ok: impl Fn(&ClickEvent, &mut WindowContext) -> bool + 'static, + ) -> Self { + self.on_ok = Rc::new(on_ok); + self + } + + /// Sets the callback for when the modal is has been canceled. + /// + /// The callback should return `true` to close the modal, if return `false` the modal will not be closed. + pub fn on_cancel( + mut self, + on_cancel: impl Fn(&ClickEvent, &mut WindowContext) -> bool + 'static, + ) -> Self { + self.on_cancel = Rc::new(on_cancel); + self + } + /// Sets the false to hide close icon, default: true pub fn show_close(mut self, show_close: bool) -> Self { self.show_close = show_close; @@ -135,6 +247,14 @@ impl Modal { self } + /// Set the overlay closable of the modal, defaults to `true`. + /// + /// When the overlay is clicked, the modal will be closed. + pub fn overlay_closable(mut self, overlay_closable: bool) -> Self { + self.overlay_closable = overlay_closable; + self + } + /// Set whether to support keyboard esc to close the modal, defaults to `true`. pub fn keyboard(mut self, keyboard: bool) -> Self { self.keyboard = keyboard; @@ -162,6 +282,61 @@ impl RenderOnce for Modal { fn render(self, cx: &mut WindowContext) -> impl gpui::IntoElement { let layer_ix = self.layer_ix; let on_close = self.on_close.clone(); + let on_ok = self.on_ok.clone(); + let on_cancel = self.on_cancel.clone(); + + let render_ok: RenderButtonFn = Box::new({ + let on_ok = on_ok.clone(); + let on_close = on_close.clone(); + let ok_text = self + .button_props + .ok_text + .unwrap_or_else(|| t!("Modal.ok").into()); + let ok_variant = self.button_props.ok_variant; + move |_| { + Button::new("ok") + .label(ok_text) + .with_variant(ok_variant) + .on_click({ + let on_ok = on_ok.clone(); + let on_close = on_close.clone(); + + move |_, cx| { + if on_ok(&ClickEvent::default(), cx) { + on_close(&ClickEvent::default(), cx); + cx.close_modal(); + } + } + }) + .into_any_element() + } + }); + let render_cancel: RenderButtonFn = Box::new({ + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); + let cancel_text = self + .button_props + .cancel_text + .unwrap_or_else(|| t!("Modal.cancel").into()); + let cancel_variant = self.button_props.cancel_variant; + move |_| { + Button::new("cancel") + .label(cancel_text) + .with_variant(cancel_variant) + .on_click({ + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); + move |_, cx| { + if on_cancel(&ClickEvent::default(), cx) { + on_close(&ClickEvent::default(), cx); + cx.close_modal(); + } + } + }) + .into_any_element() + } + }); + let window_paddings = crate::window_border::window_paddings(cx); let view_size = cx.viewport_size() - gpui::size( @@ -187,12 +362,16 @@ impl RenderOnce for Modal { .when(self.overlay_visible, |this| { this.bg(overlay_color(self.overlay, cx)) }) - .on_mouse_down(MouseButton::Left, { - let on_close = self.on_close.clone(); - move |_, cx| { - on_close(&ClickEvent::default(), cx); - cx.close_modal(); - } + .when(self.overlay_closable, |this| { + this.on_mouse_down(MouseButton::Left, { + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); + move |_, cx| { + on_cancel(&ClickEvent::default(), cx); + on_close(&ClickEvent::default(), cx); + cx.close_modal(); + } + }) }) .child( self.base @@ -201,16 +380,28 @@ impl RenderOnce for Modal { .track_focus(&self.focus_handle) .when(self.keyboard, |this| { this.on_action({ - let on_close = self.on_close.clone(); + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); move |_: &Escape, cx| { // FIXME: // // Here some Modal have no focus_handle, so it will not work will Escape key. // But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work. + on_cancel(&ClickEvent::default(), cx); on_close(&ClickEvent::default(), cx); cx.close_modal(); } }) + .on_action({ + let on_ok = on_ok.clone(); + let on_close = on_close.clone(); + move |_: &Enter, cx| { + if on_ok(&ClickEvent::default(), cx) { + on_close(&ClickEvent::default(), cx); + cx.close_modal(); + } + } + }) }) .absolute() .occlude() @@ -235,14 +426,23 @@ impl RenderOnce for Modal { .icon(IconName::Close) .on_click( move |_, cx| { + on_cancel(&ClickEvent::default(), cx); on_close(&ClickEvent::default(), cx); cx.close_modal(); }, ), ) }) - .child(self.content) - .children(self.footer) + .child(div().w_full().flex_1().child(self.content)) + .when(self.footer.is_some(), |this| { + let footer = self.footer.unwrap(); + + this.child(h_flex().gap_2().justify_end().children(footer( + render_ok, + render_cancel, + cx, + ))) + }) .with_animation( "slide-down", Animation::new(Duration::from_secs_f64(0.25)) diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 9150f216..ad67ff35 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -37,7 +37,6 @@ pub trait ContextModal: Sized { fn open_modal(&mut self, build: F) where F: Fn(Modal, &mut WindowContext) -> Modal + 'static; - /// Return true, if there is an active Modal. fn has_active_modal(&self) -> bool;