diff --git a/README.md b/README.md index 3f027352..99e932cf 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,10 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs - [x] Menu - [x] Popup Menu - [x] Context Menu -- [ ] Drawer -- [ ] Modal +- [x] Drawer +- [x] Modal +- [ ] Notification +- [ ] Toast ## Showcase diff --git a/crates/story/src/modal_story.rs b/crates/story/src/modal_story.rs index 72c9b318..897668ec 100644 --- a/crates/story/src/modal_story.rs +++ b/crates/story/src/modal_story.rs @@ -3,11 +3,13 @@ use std::{sync::Arc, time::Duration}; use fake::Fake; use gpui::{ div, prelude::FluentBuilder as _, px, FocusHandle, FocusableView, IntoElement, ParentElement, - Render, Styled, Task, Timer, View, ViewContext, VisualContext as _, WeakView, WindowContext, + Render, SharedString, Styled, Task, Timer, View, ViewContext, VisualContext as _, WeakView, + WindowContext, }; use ui::{ button::{Button, ButtonStyle}, + checkbox::Checkbox, date_picker::DatePicker, h_flex, input::TextInput, @@ -119,7 +121,7 @@ impl ListDelegate for ListItemDeletegate { if let Some(ix) = ix { self.confirmed_index = Some(ix); if let Some(item) = self.matches.get(ix) { - story.selected_value = Some(item.clone()); + story.selected_value = Some(SharedString::from(item.to_string())); } } cx.close_drawer(); @@ -139,10 +141,11 @@ impl ListDelegate for ListItemDeletegate { pub struct ModalStory { focus_handle: FocusHandle, drawer_placement: Option, - selected_value: Option>, + selected_value: Option, list: View>, input1: View, date_picker: View, + modal_overlay: bool, } impl ModalStory { @@ -231,6 +234,7 @@ impl ModalStory { list, input1, date_picker, + modal_overlay: true, } } @@ -244,9 +248,11 @@ impl ModalStory { Placement::Top | Placement::Bottom => px(160.), }; + let overlay = self.modal_overlay; cx.open_drawer(move |this, cx| { this.margin_top(px(33.)) .placement(placement) + .overlay(overlay) .size(px(400.)) .title("Drawer Title") .gap_4() @@ -298,6 +304,15 @@ impl Render for ModalStory { div().child( v_flex() .gap_6() + .child( + Checkbox::new("modal-overlay") + .label("Modal Overlay") + .checked(self.modal_overlay) + .on_click(cx.listener(|view, _, cx| { + view.modal_overlay = !view.modal_overlay; + cx.notify(); + })), + ) .child( h_flex() .items_start() @@ -339,7 +354,68 @@ impl Render for ModalStory { .text_color(gpui::red()), ), ) - }), + }) + .child( + Button::new("show-modal", cx) + .label("Open Modal...") + .on_click(cx.listener(|this, _, cx| { + let overlay = this.modal_overlay; + let input1 = this.input1.clone(); + let date_picker = this.date_picker.clone(); + let view = cx.view().clone(); + + cx.open_modal(move |modal, cx| { + modal + .title("Form Modal") + .overlay(overlay) + .child( + v_flex() + .gap_3() + .child("This is a modal dialog.") + .child("You can put anything here.") + .child(input1.clone()) + .child(date_picker.clone()), + ) + .footer( + h_flex() + .gap_6() + .items_center() + .child( + Button::new("confirm", cx) + .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() + ) + .into(), + ) + }); + } + }), + ) + .child( + Button::new("cancel", cx).label("Cancel").on_click( + |_, cx| { + cx.close_modal(); + }, + ), + ), + ) + }) + })), + ), ) } } diff --git a/crates/ui/src/drawer.rs b/crates/ui/src/drawer.rs index f81349a1..f32e0c4e 100644 --- a/crates/ui/src/drawer.rs +++ b/crates/ui/src/drawer.rs @@ -1,15 +1,15 @@ use std::{rc::Rc, time::Duration}; use gpui::{ - anchored, div, hsla, point, prelude::FluentBuilder as _, px, Animation, AnimationExt as _, + anchored, div, point, prelude::FluentBuilder as _, px, Animation, AnimationExt as _, AnyElement, ClickEvent, DefiniteLength, DismissEvent, Div, EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, Styled, WindowContext, }; use crate::{ - button::Button, h_flex, root::ContextModal as _, scroll::ScrollbarAxis, theme::ActiveTheme, - v_flex, IconName, Placement, Sizable, StyledExt as _, + button::Button, h_flex, modal::overlay_color, root::ContextModal as _, scroll::ScrollbarAxis, + theme::ActiveTheme, v_flex, IconName, Placement, Sizable, StyledExt as _, }; #[derive(IntoElement)] @@ -23,6 +23,7 @@ pub struct Drawer { footer: Option, content: Div, margin_top: Pixels, + overlay: bool, } impl Drawer { @@ -36,6 +37,7 @@ impl Drawer { footer: None, content: v_flex(), margin_top: px(0.), + overlay: true, on_close: Rc::new(|_, _| {}), } } @@ -83,6 +85,12 @@ impl Drawer { self } + /// Set whether the drawer should have an overlay, default is `true`. + pub fn overlay(mut self, overlay: bool) -> Self { + self.overlay = overlay; + self + } + /// Listen to the close event of the drawer. pub fn on_close( mut self, @@ -113,12 +121,6 @@ impl RenderOnce for Drawer { let size = cx.viewport_size(); let on_close = self.on_close.clone(); - let overlay_color = if cx.theme().mode.is_dark() { - hsla(0., 1., 1., 0.06) - } else { - hsla(0., 0., 0., 0.06) - }; - anchored() .position(point(px(0.), titlebar_height)) .snap_to_window() @@ -127,13 +129,15 @@ impl RenderOnce for Drawer { .occlude() .w(size.width) .h(size.height - titlebar_height) - .bg(overlay_color) - .on_mouse_down(MouseButton::Left, { - let on_close = self.on_close.clone(); - move |_, cx| { - on_close(&ClickEvent::default(), cx); - cx.close_drawer(); - } + .bg(overlay_color(self.overlay, cx)) + .when(self.overlay, |this| { + this.on_mouse_down(MouseButton::Left, { + let on_close = self.on_close.clone(); + move |_, cx| { + on_close(&ClickEvent::default(), cx); + cx.close_drawer(); + } + }) }) .child( v_flex() diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index fbb7f065..46223e30 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -20,6 +20,7 @@ pub mod input; pub mod label; pub mod link; pub mod list; +pub mod modal; pub mod popover; pub mod popup_menu; pub mod prelude; diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs new file mode 100644 index 00000000..661a331e --- /dev/null +++ b/crates/ui/src/modal.rs @@ -0,0 +1,169 @@ +use std::rc::Rc; + +use gpui::{ + anchored, div, hsla, prelude::FluentBuilder, px, AnyElement, Bounds, ClickEvent, Div, + FocusHandle, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, + Point, RenderOnce, Styled, WindowContext, +}; + +use crate::{ + button::Button, theme::ActiveTheme as _, v_flex, ContextModal, IconName, Sizable as _, +}; + +#[derive(IntoElement)] +pub struct Modal { + focus_handle: FocusHandle, + title: Option, + footer: Option, + content: Div, + width: Pixels, + max_width: Option, + margin_top: Option, + on_close: Rc, + overlay: bool, +} + +pub(crate) fn overlay_color(overlay: bool, cx: &WindowContext) -> Hsla { + if !overlay { + return hsla(0., 0., 0., 0.); + } + + if cx.theme().mode.is_dark() { + hsla(0., 1., 1., 0.06) + } else { + hsla(0., 0., 0., 0.06) + } +} + +impl Modal { + pub fn new(cx: &mut WindowContext) -> Self { + Self { + focus_handle: cx.focus_handle(), + title: None, + footer: None, + content: v_flex(), + margin_top: None, + width: px(480.), + max_width: None, + overlay: true, + on_close: Rc::new(|_, _| {}), + } + } + + /// Sets the title of the modal. + pub fn title(mut self, title: impl IntoElement) -> Self { + self.title = Some(title.into_any_element()); + self + } + + /// Set the footer of the modal. + pub fn footer(mut self, footer: impl IntoElement) -> Self { + self.footer = Some(footer.into_any_element()); + self + } + + /// Sets the callback for when the modal is closed. + pub fn on_close( + mut self, + on_close: impl Fn(&ClickEvent, &mut WindowContext) + 'static, + ) -> Self { + self.on_close = Rc::new(on_close); + self + } + + /// Set the top offset of the modal, defaults to None, will use the 1/10 of the viewport height. + pub fn margin_top(mut self, margin_top: Pixels) -> Self { + self.margin_top = Some(margin_top); + self + } + + /// Sets the width of the modal, defaults to 480px. + pub fn width(mut self, width: Pixels) -> Self { + self.width = width; + self + } + + /// Set the maximum width of the modal, defaults to `None`. + pub fn max_width(mut self, max_width: Pixels) -> Self { + self.max_width = Some(max_width); + self + } + + /// Set the overlay of the modal, defaults to `true`. + pub fn overlay(mut self, overlay: bool) -> Self { + self.overlay = overlay; + self + } +} + +impl ParentElement for Modal { + fn extend(&mut self, elements: impl IntoIterator) { + self.content.extend(elements); + } +} + +impl RenderOnce for Modal { + fn render(self, cx: &mut WindowContext) -> impl gpui::IntoElement { + let on_close = self.on_close.clone(); + let view_size = cx.viewport_size(); + let bounds = Bounds { + origin: Point::default(), + size: view_size, + }; + let y = self.margin_top.unwrap_or(view_size.height / 10.); + let x = bounds.center().x - self.width / 2.; + + anchored().snap_to_window().child( + div() + .occlude() + .w(view_size.width) + .h(view_size.height) + .bg(overlay_color(self.overlay, cx)) + .when(self.overlay, |this| { + this.on_mouse_down(MouseButton::Left, { + let on_close = self.on_close.clone(); + move |_, cx| { + on_close(&ClickEvent::default(), cx); + cx.close_modal(); + } + }) + }) + .child( + v_flex() + .id("modal") + .absolute() + .track_focus(&self.focus_handle) + .occlude() + .relative() + .left(x) + .top(y) + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .rounded_lg() + .shadow_xl() + .min_h_48() + .w(self.width) + .when_some(self.max_width, |this, w| this.max_w(w)) + .p_4() + .gap_4() + .children(self.title) + .child( + Button::new("close", cx) + .absolute() + .top_2() + .right_2() + .small() + .ghost() + .icon(IconName::Close) + .on_click(move |_, cx| { + on_close(&ClickEvent::default(), cx); + cx.close_modal(); + }), + ) + .child(self.content) + .children(self.footer), + ), + ) + } +} diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index d2348c06..cce1ba12 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -4,17 +4,25 @@ use gpui::{ }; use std::{ops::DerefMut, rc::Rc}; -use crate::{drawer::Drawer, theme::ActiveTheme}; +use crate::{drawer::Drawer, modal::Modal, theme::ActiveTheme}; /// Extension trait for [`WindowContext`] and [`ViewContext`] to add drawer functionality. pub trait ContextModal: Sized { - /// Opens a drawer. + /// Opens a Drawer. fn open_drawer(&mut self, build: F) where F: Fn(Drawer, &mut WindowContext) -> Drawer + 'static; - /// Closes the active drawer. + /// Closes the active Drawer. fn close_drawer(&mut self); + + /// Opens a Modal. + fn open_modal(&mut self, build: F) + where + F: Fn(Modal, &mut WindowContext) -> Modal + 'static; + + /// Closes the active Modal. + fn close_modal(&mut self); } impl<'a> ContextModal for WindowContext<'a> { @@ -34,6 +42,23 @@ impl<'a> ContextModal for WindowContext<'a> { cx.notify(); }) } + + fn open_modal(&mut self, build: F) + where + F: Fn(Modal, &mut WindowContext) -> Modal + 'static, + { + Root::update_root(self, move |root, cx| { + root.active_modal = Some(Rc::new(build)); + cx.notify(); + }) + } + + fn close_modal(&mut self) { + Root::update_root(self, |root, cx| { + root.active_modal = None; + cx.notify(); + }) + } } impl<'a, V> ContextModal for ViewContext<'a, V> { fn open_drawer(&mut self, build: F) @@ -46,10 +71,22 @@ impl<'a, V> ContextModal for ViewContext<'a, V> { fn close_drawer(&mut self) { self.deref_mut().close_drawer() } + + fn open_modal(&mut self, build: F) + where + F: Fn(Modal, &mut WindowContext) -> Modal + 'static, + { + self.deref_mut().open_modal(build) + } + + fn close_modal(&mut self) { + self.deref_mut().close_modal() + } } pub struct Root { active_drawer: Option Drawer + 'static>>, + active_modal: Option Modal + 'static>>, root_view: AnyView, } @@ -57,6 +94,7 @@ impl Root { pub fn new(root_view: AnyView, _cx: &mut ViewContext) -> Self { Self { active_drawer: None, + active_modal: None, root_view, } } @@ -77,13 +115,21 @@ impl Root { impl Render for Root { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { + let has_modal = self.active_modal.is_some(); + div() .size_full() .text_color(cx.theme().foreground) .child(self.root_view.clone()) - .when_some(self.active_drawer.clone(), |this, build| { - let drawer = Drawer::new(cx); - this.child(build(drawer, cx)) + .when(!has_modal, |this| { + this.when_some(self.active_drawer.clone(), |this, build| { + let drawer = Drawer::new(cx); + this.child(build(drawer, cx)) + }) + }) + .when_some(self.active_modal.clone(), |this, build| { + let modal = Modal::new(cx); + this.child(build(modal, cx)) }) } } diff --git a/crates/ui/src/time/calendar.rs b/crates/ui/src/time/calendar.rs index 9955b39e..0952a459 100644 --- a/crates/ui/src/time/calendar.rs +++ b/crates/ui/src/time/calendar.rs @@ -29,6 +29,19 @@ pub enum Date { Range(Option, Option), } +impl std::fmt::Display for Date { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Single(Some(date)) => write!(f, "{}", date), + Self::Single(None) => write!(f, "nil"), + Self::Range(Some(start), Some(end)) => write!(f, "{} - {}", start, end), + Self::Range(None, None) => write!(f, "nil"), + Self::Range(Some(start), None) => write!(f, "{} - nil", start), + Self::Range(None, Some(end)) => write!(f, "nil - {}", end), + } + } +} + impl From for Date { fn from(date: NaiveDate) -> Self { Self::Single(Some(date)) @@ -647,3 +660,34 @@ impl Render for Calendar { ) } } + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::Date; + + #[test] + fn test_date_to_string() { + let date = Date::Single(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap())); + assert_eq!(date.to_string(), "2024-08-03"); + + let date = Date::Single(None); + assert_eq!(date.to_string(), "nil"); + + let date = Date::Range( + Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), + Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap()), + ); + assert_eq!(date.to_string(), "2024-08-03 - 2024-08-05"); + + let date = Date::Range(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), None); + assert_eq!(date.to_string(), "2024-08-03 - nil"); + + let date = Date::Range(None, Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap())); + assert_eq!(date.to_string(), "nil - 2024-08-05"); + + let date = Date::Range(None, None); + assert_eq!(date.to_string(), "nil"); + } +}