Add Modal (#138)
https://github.com/user-attachments/assets/f4c2f9dd-2508-4251-a861-1af3af7542a3
This commit is contained in:
parent
63f333d8ad
commit
b934ff76e6
7 changed files with 370 additions and 28 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Placement>,
|
||||
selected_value: Option<Arc<String>>,
|
||||
selected_value: Option<SharedString>,
|
||||
list: View<List<ListItemDeletegate>>,
|
||||
input1: View<TextInput>,
|
||||
date_picker: View<DatePicker>,
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AnyElement>,
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
169
crates/ui/src/modal.rs
Normal file
169
crates/ui/src/modal.rs
Normal file
|
|
@ -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<AnyElement>,
|
||||
footer: Option<AnyElement>,
|
||||
content: Div,
|
||||
width: Pixels,
|
||||
max_width: Option<Pixels>,
|
||||
margin_top: Option<Pixels>,
|
||||
on_close: Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>,
|
||||
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<Item = AnyElement>) {
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<F>(&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<F>(&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<F>(&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<F>(&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<F>(&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<Rc<dyn Fn(Drawer, &mut WindowContext) -> Drawer + 'static>>,
|
||||
active_modal: Option<Rc<dyn Fn(Modal, &mut WindowContext) -> Modal + 'static>>,
|
||||
root_view: AnyView,
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +94,7 @@ impl Root {
|
|||
pub fn new(root_view: AnyView, _cx: &mut ViewContext<Self>) -> 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<Self>) -> 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,19 @@ pub enum Date {
|
|||
Range(Option<NaiveDate>, Option<NaiveDate>),
|
||||
}
|
||||
|
||||
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<NaiveDate> 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue