Jason Lee 2024-08-13 17:41:12 +08:00 committed by GitHub
parent 63f333d8ad
commit b934ff76e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 370 additions and 28 deletions

View file

@ -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

View file

@ -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();
},
),
),
)
})
})),
),
)
}
}

View file

@ -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()

View file

@ -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
View 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),
),
)
}
}

View file

@ -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))
})
}
}

View file

@ -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");
}
}