diff --git a/Cargo.lock b/Cargo.lock index 39b62bca..ee04e5d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,21 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.15" @@ -115,6 +130,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -860,6 +881,20 @@ dependencies = [ "syn 2.0.71", ] +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.6", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2122,6 +2157,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "glow" version = "0.13.1" @@ -2270,6 +2316,7 @@ dependencies = [ "gpui", "log", "rust-embed", + "serde", "story", "ui", "workspace", @@ -2502,6 +2549,29 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core 0.52.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.5.0" @@ -2512,6 +2582,22 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.1" @@ -2878,6 +2964,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linkme" version = "0.3.27" @@ -3174,6 +3266,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normpath" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "num" version = "0.4.3" @@ -4205,6 +4306,57 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust-i18n" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd94370631e5658a0a23635f7f47e43d06a00ad948e0bb5de79b00d85b880c" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355763801dcf287e777e42def7c578410783477b804b1107852119e0b2518396" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.71", +] + +[[package]] +name = "rust-i18n-support" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399801f4d955abf1c3ce3ce2215dc76bd40beb4ae39e3a84936b21a79ce2caa5" +dependencies = [ + "arc-swap", + "globwalk", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "toml 0.7.8", + "triomphe", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4477,6 +4629,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" +dependencies = [ + "indexmap 1.9.3", + "ryu", + "serde", + "yaml-rust", +] + [[package]] name = "servo_arc" version = "0.1.1" @@ -4731,6 +4895,7 @@ version = "0.1.0" dependencies = [ "anyhow", "charts-rs", + "chrono", "fake", "gpui", "regex", @@ -5142,6 +5307,18 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.19.15", +] + [[package]] name = "toml" version = "0.8.15" @@ -5170,6 +5347,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.2.6", + "serde", + "serde_spanned", "toml_datetime", "winnow 0.5.40", ] @@ -5251,6 +5430,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "triomphe" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6631e42e10b40c0690bf92f404ebcfe6e1fdb480391d15f17cc8e96eeed5369" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "ttf-parser" version = "0.21.1" @@ -5285,12 +5475,14 @@ name = "ui" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "gpui", "image", "once_cell", "paste", "regex", "resvg", + "rust-i18n", "serde", "serde_json", "smallvec", @@ -5869,6 +6061,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.57.0" @@ -5993,6 +6194,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -6392,6 +6602,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yazi" version = "0.1.6" diff --git a/README.md b/README.md index e591a334..8f012932 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs - [x] Slider - [ ] Skeleton - [ ] DatePicker - - [ ] DateTimePicker + - [x] DatePicker + - [x] Calendar - [ ] TimePicker - [ ] DateRangePicker - [ ] ColorPicker diff --git a/assets/icons/calendar.svg b/assets/icons/calendar.svg new file mode 100644 index 00000000..2724a480 --- /dev/null +++ b/assets/icons/calendar.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/globe.svg b/assets/icons/globe.svg new file mode 100644 index 00000000..2082a439 --- /dev/null +++ b/assets/icons/globe.svg @@ -0,0 +1 @@ + diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 1b7c5d42..99cda77b 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -11,7 +11,7 @@ log.workspace = true workspace.workspace = true ui.workspace = true story.workspace = true - +serde.workspace = true [lints] workspace = true diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index e5449fb4..384ce4e3 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -1,21 +1,29 @@ use gpui::*; use prelude::FluentBuilder as _; +use private::serde::Deserialize; use story::{ - ButtonStory, DropdownStory, IconStory, ImageStory, InputStory, ListStory, PickerStory, - PopupStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, SwitchStory, - TableStory, TextStory, TooltipStory, + ButtonStory, CalendarStory, DropdownStory, IconStory, ImageStory, InputStory, ListStory, + PickerStory, PopupStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, + SwitchStory, TableStory, TextStory, TooltipStory, }; use workspace::{TitleBar, Workspace}; use std::sync::Arc; use ui::{ button::{Button, ButtonStyle}, + popover::Popover, + popup_menu::PopupMenu, theme::{ActiveTheme, Theme}, Clickable as _, IconName, Sizable, }; use crate::app_state::AppState; +#[derive(Clone, PartialEq, Eq, Deserialize)] +struct SelectLocale(SharedString); + +impl_actions!(locale_switcher, [SelectLocale]); + actions!(workspace, [Open, CloseWindow]); pub fn init(_app_state: Arc, cx: &mut AppContext) { @@ -28,6 +36,7 @@ pub fn init(_app_state: Arc, cx: &mut AppContext) { pub struct StoryWorkspace { workspace: View, + locale_selector: View, } impl StoryWorkspace { @@ -184,7 +193,20 @@ impl StoryWorkspace { ) .detach(); - Self { workspace } + StoryContainer::add_pane( + "Calendar", + "A calendar component.", + CalendarStory::view(cx).into(), + workspace.clone(), + cx, + ) + .detach(); + + let locale_selector = cx.new_view(LocaleSelector::new); + Self { + workspace, + locale_selector, + } } pub fn new_local( @@ -276,6 +298,7 @@ impl Render for StoryWorkspace { .px_2() .mr_3() .gap_2() + .child(self.locale_selector.clone()) .child( Button::new("theme-mode", cx) .map(|this| { @@ -310,3 +333,53 @@ impl Render for StoryWorkspace { .child(self.workspace.clone()) } } + +struct LocaleSelector { + focus_handle: FocusHandle, +} + +impl LocaleSelector { + pub fn new(cx: &mut ViewContext) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } + + fn on_select_locale(&mut self, locale: &SelectLocale, cx: &mut ViewContext) { + ui::set_locale(&locale.0); + cx.refresh(); + } +} + +impl Render for LocaleSelector { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let focus_handle = self.focus_handle.clone(); + let locale = ui::locale().to_string(); + + div() + .id("locale-selector") + .track_focus(&focus_handle) + .on_action(cx.listener(Self::on_select_locale)) + .child( + Popover::new("locale-selector") + .anchor(AnchorCorner::TopRight) + .trigger(Button::new("btn", cx).small().ghost().icon(IconName::Globe)) + .content(move |cx| { + let focus_handle = focus_handle.clone(); + PopupMenu::build(cx, |this, _cx| { + this.track_focus(focus_handle) + .menu_with_check( + "English", + locale == "en", + Box::new(SelectLocale("en".into())), + ) + .menu_with_check( + "简体中文", + locale == "zh-CN", + Box::new(SelectLocale("zh-CN".into())), + ) + }) + }), + ) + } +} diff --git a/crates/story/Cargo.toml b/crates/story/Cargo.toml index 748039d9..3bf1e984 100644 --- a/crates/story/Cargo.toml +++ b/crates/story/Cargo.toml @@ -11,6 +11,7 @@ anyhow = "1" workspace.workspace = true charts-rs = "0.3" regex = "1" +chrono = "0.4" [lints] workspace = true diff --git a/crates/story/src/calendar_story.rs b/crates/story/src/calendar_story.rs new file mode 100644 index 00000000..1f638d59 --- /dev/null +++ b/crates/story/src/calendar_story.rs @@ -0,0 +1,67 @@ +use gpui::{ + px, IntoElement, ParentElement as _, Render, Styled as _, View, ViewContext, + VisualContext as _, WindowContext, +}; +use ui::{ + date_picker::{DatePicker, DatePickerEvent}, + v_flex, Sizable as _, +}; + +pub struct CalendarStory { + date_picker: View, + date_picker_small: View, + date_picker_large: View, + date_picker_value: Option, +} + +impl CalendarStory { + pub fn view(cx: &mut WindowContext) -> View { + cx.new_view(Self::new) + } + + fn new(cx: &mut ViewContext) -> Self { + let date_picker = cx.new_view(|cx| { + DatePicker::new("date_picker_medium", cx) + .default_now() + .cleanable(true) + .width(px(220.)) + }); + let date_picker_large = cx.new_view(|cx| { + DatePicker::new("date_picker_large", cx) + .large() + .date_format("%Y-%m-%d") + .width(px(300.)) + }); + let date_picker_small = cx.new_view(|cx| { + DatePicker::new("date_picker_small", cx) + .default_now() + .small() + .width(px(180.)) + }); + + cx.subscribe(&date_picker, |this, _, ev, _| match ev { + DatePickerEvent::Change(date) => { + this.date_picker_value = date.map(|d| d.to_string()); + } + }) + .detach(); + + Self { + date_picker, + date_picker_large, + date_picker_small, + date_picker_value: None, + } + } +} + +impl Render for CalendarStory { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .gap_3() + .child(self.date_picker.clone()) + .child(self.date_picker_small.clone()) + .child(self.date_picker_large.clone()) + .child(format!("Date picker value: {:?}", self.date_picker_value).into_element()) + } +} diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 3bf3de88..0c010aa3 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -1,4 +1,5 @@ mod button_story; +mod calendar_story; mod dropdown_story; mod icon_story; mod image_story; @@ -16,6 +17,7 @@ mod tooltip_story; mod webview_story; pub use button_story::ButtonStory; +pub use calendar_story::CalendarStory; pub use dropdown_story::DropdownStory; pub use icon_story::IconStory; pub use image_story::ImageStory; diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 0e957848..d14d9dcd 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -24,6 +24,10 @@ once_cell = "1.19.0" wry = "0" smol = "1" regex = "1" +rust-i18n = "3" + +# Calendar +chrono = "0.4.38" [lints] workspace = true diff --git a/crates/ui/locales/ui.yml b/crates/ui/locales/ui.yml new file mode 100644 index 00000000..733af8f2 --- /dev/null +++ b/crates/ui/locales/ui.yml @@ -0,0 +1,83 @@ +_version: 2 +Calendar: + week.0: + en: Su + zh-CN: 日 + zh-HK: 日 + week.1: + en: Mo + zh-CN: 一 + zh-HK: 一 + week.2: + en: Tu + zh-CN: 二 + zh-HK: 二 + week.3: + en: We + zh-CN: 三 + zh-HK: 三 + week.4: + en: Th + zh-CN: 四 + zh-HK: 四 + week.5: + en: Fr + zh-CN: 五 + zh-HK: 五 + week.6: + en: Sa + zh-CN: 六 + zh-HK: 六 + month.January: + en: January + zh-CN: 一月 + zh-HK: 一月 + month.February: + en: February + zh-CN: 二月 + zh-HK: 二月 + month.March: + en: March + zh-CN: 三月 + zh-HK: 三月 + month.April: + en: April + zh-CN: 四月 + zh-HK: 四月 + month.May: + en: May + zh-CN: 五月 + zh-HK: 五月 + month.June: + en: June + zh-CN: 六月 + zh-HK: 六月 + month.July: + en: July + zh-CN: 七月 + zh-HK: 七月 + month.August: + en: August + zh-CN: 八月 + zh-HK: 八月 + month.September: + en: September + zh-CN: 九月 + zh-HK: 九月 + month.October: + en: October + zh-CN: 十月 + zh-HK: 十月 + month.November: + en: November + zh-CN: 十一月 + zh-HK: 十一月 + month.December: + en: December + zh-CN: 十二月 + zh-HK: 十二月 +DatePicker: + placeholder: + en: Select date + zh-CN: 选择日期 + zh-HK: 選擇日期 diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 221a72b4..4bf4adc0 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -7,8 +7,8 @@ use gpui::{ }; use crate::{ - button::{Button, ButtonStyle}, h_flex, + input::ClearButton, list::{self, List, ListDelegate, ListItem}, styled_ext::StyleSized, theme::ActiveTheme, @@ -591,14 +591,7 @@ where .child(self.display_title(cx)), ) .when(show_clean, |this| { - this.child( - Button::new("clean", cx) - .icon(IconName::CircleX) - .style(ButtonStyle::Ghost) - .with_size(px(14.)) - .cursor_pointer() - .on_click(cx.listener(Self::clean)), - ) + this.child(ClearButton::new(cx).on_click(cx.listener(Self::clean))) }) .when(!show_clean, |this| { let icon = match self.icon.clone() { diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 637afe56..12be983c 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -11,14 +11,16 @@ pub enum IconName { ArrowRight, ArrowUp, Asterisk, + Calendar, Check, ChevronDown, ChevronLeft, ChevronRight, - ChevronsUpDown, ChevronUp, + ChevronsUpDown, CircleX, Close, + Copy, Dash, Delete, Ellipsis, @@ -26,6 +28,7 @@ pub enum IconName { Eye, EyeOff, GitHub, + Globe, Heart, HeartOff, Inbox, @@ -33,6 +36,7 @@ pub enum IconName { Loader, LoaderCircle, Maximize, + Menu, Minimize, Minus, Moon, @@ -45,8 +49,6 @@ pub enum IconName { Sun, ThumbsDown, ThumbsUp, - Menu, - Copy, } impl IconName { @@ -57,14 +59,16 @@ impl IconName { IconName::ArrowRight => "icons/arrow-right.svg", IconName::ArrowUp => "icons/arrow-up.svg", IconName::Asterisk => "icons/asterisk.svg", + IconName::Calendar => "icons/calendar.svg", IconName::Check => "icons/check.svg", IconName::ChevronDown => "icons/chevron-down.svg", IconName::ChevronLeft => "icons/chevron-left.svg", IconName::ChevronRight => "icons/chevron-right.svg", - IconName::ChevronsUpDown => "icons/chevrons-up-down.svg", IconName::ChevronUp => "icons/chevron-up.svg", + IconName::ChevronsUpDown => "icons/chevrons-up-down.svg", IconName::CircleX => "icons/circle-x.svg", IconName::Close => "icons/close.svg", + IconName::Copy => "icons/copy.svg", IconName::Dash => "icons/dash.svg", IconName::Delete => "icons/delete.svg", IconName::Ellipsis => "icons/ellipsis.svg", @@ -72,6 +76,7 @@ impl IconName { IconName::Eye => "icons/eye.svg", IconName::EyeOff => "icons/eye-off.svg", IconName::GitHub => "icons/github.svg", + IconName::Globe => "icons/globe.svg", IconName::Heart => "icons/heart.svg", IconName::HeartOff => "icons/heart-off.svg", IconName::Inbox => "icons/inbox.svg", @@ -79,6 +84,7 @@ impl IconName { IconName::Loader => "icons/loader.svg", IconName::LoaderCircle => "icons/loader-circle.svg", IconName::Maximize => "icons/maximize.svg", + IconName::Menu => "icons/menu.svg", IconName::Minimize => "icons/minimize.svg", IconName::Minus => "icons/minus.svg", IconName::Moon => "icons/moon.svg", @@ -91,8 +97,6 @@ impl IconName { IconName::Sun => "icons/sun.svg", IconName::ThumbsDown => "icons/thumbs-down.svg", IconName::ThumbsUp => "icons/thumbs-up.svg", - IconName::Menu => "icons/menu.svg", - IconName::Copy => "icons/copy.svg", } .into() } diff --git a/crates/ui/src/input/clear_button.rs b/crates/ui/src/input/clear_button.rs new file mode 100644 index 00000000..0908e137 --- /dev/null +++ b/crates/ui/src/input/clear_button.rs @@ -0,0 +1,15 @@ +use gpui::{px, Styled as _, WindowContext}; + +use crate::{button::Button, IconName, Sizable as _}; + +pub(crate) struct ClearButton {} + +impl ClearButton { + pub fn new(cx: &mut WindowContext) -> Button { + Button::new("clean", cx) + .icon(IconName::CircleX) + .ghost() + .with_size(px(14.)) + .cursor_pointer() + } +} diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index a2225045..2fab373d 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -7,12 +7,12 @@ use std::ops::Range; use super::blink_cursor::BlinkCursor; use super::history::History; -use crate::button::{Button, ButtonStyle}; +use super::ClearButton; use crate::indicator::Indicator; use crate::styled_ext::{Sizable, StyleSized}; use crate::theme::ActiveTheme; use crate::{event::InteractiveElementExt as _, Size}; -use crate::{Clickable as _, IconName, StyledExt as _}; +use crate::{Clickable as _, StyledExt as _}; use gpui::prelude::FluentBuilder as _; use gpui::{ actions, div, fill, point, px, relative, rems, size, AnyElement, AppContext, Bounds, @@ -1066,16 +1066,7 @@ impl Render for TextInput { .when(self.loading, |this| this.child(Indicator::new())) .when( self.cleanable && !self.loading && !self.text.is_empty(), - |this| { - this.child( - Button::new("clean-text", cx) - .icon(IconName::Close) - .style(ButtonStyle::Ghost) - .with_size(px(15.)) - .cursor_pointer() - .on_click(cx.listener(Self::clean)), - ) - }, + |this| this.child(ClearButton::new(cx).on_click(cx.listener(Self::clean))), ) .children(suffix) } diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index e1649142..17fbc6fd 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,7 +1,9 @@ mod blink_cursor; +mod clear_button; mod history; mod input; mod otp_input; +pub(crate) use clear_button::*; pub use input::*; pub use otp_input::*; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index f4528879..af5377d3 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -9,6 +9,7 @@ mod selectable; mod stack; mod styled_ext; mod svg_img; +mod time; pub mod button; pub mod checkbox; @@ -36,6 +37,8 @@ pub mod theme; pub mod tooltip; pub mod webview; +use std::ops::Deref; + // re-export pub use wry; @@ -45,20 +48,32 @@ pub use event::InteractiveElementExt; pub use focusable::FocusableCycle; pub use selectable::{Selectable, Selection}; pub use styled_ext::{Sizable, Size, StyledExt}; +pub use time::*; pub use colors::*; pub use icon::*; pub use stack::*; pub use svg_img::*; +rust_i18n::i18n!("locales", fallback = "en"); + /// Initialize the UI module. pub fn init(cx: &mut gpui::AppContext) { input::init(cx); list::init(cx); dropdown::init(cx); + date_picker::init(cx); popover::init(cx); popup_menu::init(cx); context_menu::init(cx); table::init(cx); webview::init(cx) } + +pub fn locale() -> impl Deref { + rust_i18n::locale() +} + +pub fn set_locale(locale: &str) { + rust_i18n::set_locale(locale) +} diff --git a/crates/ui/src/styled_ext.rs b/crates/ui/src/styled_ext.rs index 842f07e6..323365b7 100644 --- a/crates/ui/src/styled_ext.rs +++ b/crates/ui/src/styled_ext.rs @@ -179,11 +179,12 @@ pub trait StyledExt: Styled + Sized { impl StyledExt for E {} /// A size for elements. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Default, Copy, PartialEq, Eq, Debug)] pub enum Size { Size(Pixels), XSmall, Small, + #[default] Medium, Large, } diff --git a/crates/ui/src/time/calendar.rs b/crates/ui/src/time/calendar.rs new file mode 100644 index 00000000..03c88d55 --- /dev/null +++ b/crates/ui/src/time/calendar.rs @@ -0,0 +1,222 @@ +use std::borrow::Cow; + +use chrono::{Datelike, Local, NaiveDate}; +use gpui::{ + prelude::FluentBuilder as _, ClickEvent, EventEmitter, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled as _, ViewContext, +}; +use rust_i18n::t; + +use crate::{ + button::Button, + h_flex, + theme::{ActiveTheme, Colorize}, + v_flex, Clickable, IconName, StyledExt, +}; + +use super::utils::days_in_month; + +pub enum CalendarEvent { + /// The user selected a date. + Selected(NaiveDate), +} + +pub struct Calendar { + date: Option, + current_year: i32, + current_month: u8, +} + +impl Calendar { + pub fn new(_cx: &mut ViewContext) -> Self { + let today = Local::now().naive_local().date(); + Self { + date: None, + current_month: today.month() as u8, + current_year: today.year(), + } + } + + /// Set the date of the calendar. + pub fn set_date(&mut self, date: Option, cx: &mut ViewContext) { + self.date = date; + if let Some(date) = date { + self.current_month = date.month() as u8; + self.current_year = date.year(); + } + cx.notify() + } + + /// Get the date of the calendar. + pub fn date(&self) -> Option { + self.date + } + + /// Returns the days of the month in a 2D vector to render on calendar. + fn days(&self) -> Vec> { + days_in_month(self.current_year, self.current_month as u32) + } + + fn prev_month(&mut self, _: &ClickEvent, cx: &mut ViewContext) { + self.current_month = if self.current_month == 1 { + 12 + } else { + self.current_month - 1 + }; + self.current_year = if self.current_month == 12 { + self.current_year - 1 + } else { + self.current_year + }; + cx.notify() + } + + fn next_month(&mut self, _: &ClickEvent, cx: &mut ViewContext) { + self.current_month = if self.current_month == 12 { + 1 + } else { + self.current_month + 1 + }; + self.current_year = if self.current_month == 1 { + self.current_year + 1 + } else { + self.current_year + }; + cx.notify() + } + + fn month_name(&self) -> SharedString { + match self.current_month { + 1 => t!("Calendar.month.January"), + 2 => t!("Calendar.month.February"), + 3 => t!("Calendar.month.March"), + 4 => t!("Calendar.month.April"), + 5 => t!("Calendar.month.May"), + 6 => t!("Calendar.month.June"), + 7 => t!("Calendar.month.July"), + 8 => t!("Calendar.month.August"), + 9 => t!("Calendar.month.September"), + 10 => t!("Calendar.month.October"), + 11 => t!("Calendar.month.November"), + 12 => t!("Calendar.month.December"), + _ => Cow::Borrowed(""), + } + .into() + } + + fn render_week( + &self, + week: impl Into, + cx: &mut ViewContext, + ) -> impl IntoElement { + h_flex() + .w_9() + .h_9() + .rounded_md() + .justify_center() + .text_color(cx.theme().muted_foreground) + .text_sm() + .child(week.into()) + } + + fn render_item( + &self, + ix: usize, + d: &NaiveDate, + cx: &mut ViewContext, + ) -> impl IntoElement { + let day = d.day(); + let is_current_month = d.month() == self.current_month as u32; + let is_active = match self.date { + Some(date) => date == *d, + None => false, + }; + + let date = *d; + + h_flex() + .id(ix) + .w_9() + .h_9() + .rounded_lg() + .justify_center() + .cursor_pointer() + .when(!is_current_month, |this| { + this.text_color(cx.theme().muted_foreground.opacity(0.3)) + }) + .when(!is_active, |this| { + this.hover(|this| { + this.bg(cx.theme().accent) + .text_color(cx.theme().accent_foreground) + }) + }) + .when(is_active, |this| { + this.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + }) + .on_click(cx.listener(move |view, _: &ClickEvent, cx| { + view.set_date(Some(date), cx); + cx.emit(CalendarEvent::Selected(date)) + })) + .child(day.to_string()) + } +} + +impl EventEmitter for Calendar {} + +impl Render for Calendar { + fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { + let weeks: [SharedString; 7] = [ + t!("Calendar.week.0").into(), + t!("Calendar.week.1").into(), + t!("Calendar.week.2").into(), + t!("Calendar.week.3").into(), + t!("Calendar.week.4").into(), + t!("Calendar.week.5").into(), + t!("Calendar.week.6").into(), + ]; + + v_flex().gap_0p5().text_sm().child( + v_flex() + .child( + h_flex() + .gap_0p5() + .justify_between() + .items_center() + .child( + Button::new("prev", cx) + .icon(IconName::ArrowLeft) + .ghost() + .on_click(cx.listener(Self::prev_month)), + ) + .child( + h_flex() + .justify_center() + .font_semibold() + .gap_3() + // TODO: Add menu to select month and year + .child(self.current_year.to_string()) + .child(self.month_name()), + ) + .child( + Button::new("next", cx) + .icon(IconName::ArrowRight) + .ghost() + .on_click(cx.listener(Self::next_month)), + ), + ) + .child( + h_flex() + .gap_0p5() + .children(weeks.iter().map(|week| self.render_week(week.clone(), cx))), + ) + .children(self.days().iter().map(|week| { + h_flex().gap_0p5().children( + week.iter() + .enumerate() + .map(|(ix, d)| self.render_item(ix, d, cx)), + ) + })), + ) + } +} diff --git a/crates/ui/src/time/date_picker.rs b/crates/ui/src/time/date_picker.rs new file mode 100644 index 00000000..f0b17c0e --- /dev/null +++ b/crates/ui/src/time/date_picker.rs @@ -0,0 +1,228 @@ +use chrono::{Local, NaiveDate}; +use gpui::{ + deferred, div, prelude::FluentBuilder as _, px, AppContext, ElementId, EventEmitter, + FocusHandle, FocusableView, InteractiveElement as _, KeyBinding, Length, MouseButton, + ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, View, + ViewContext, VisualContext as _, +}; +use rust_i18n::t; + +use crate::{ + dropdown::Escape, h_flex, input::ClearButton, styled_ext::StyleSized as _, + theme::ActiveTheme as _, Clickable, Icon, IconName, Sizable, Size, StyledExt as _, +}; + +use super::calendar::{Calendar, CalendarEvent}; + +pub fn init(cx: &mut AppContext) { + let context = Some("DatePicker"); + cx.bind_keys([KeyBinding::new("escape", Escape, context)]) +} + +#[derive(Clone)] +pub enum DatePickerEvent { + Change(Option), +} + +pub struct DatePicker { + id: ElementId, + focus_handle: FocusHandle, + date: Option, + cleanable: bool, + placeholder: Option, + open: bool, + size: Size, + width: Length, + date_format: SharedString, + calendar: View, +} + +impl DatePicker { + pub fn new(id: impl Into, cx: &mut ViewContext) -> Self { + let calendar = cx.new_view(Calendar::new); + + cx.subscribe(&calendar, |this, _, ev: &CalendarEvent, cx| match ev { + CalendarEvent::Selected(date) => { + this.update_date(Some(*date), cx); + } + }) + .detach(); + + Self { + id: id.into(), + focus_handle: cx.focus_handle(), + date: None, + calendar, + open: false, + size: Size::default(), + width: Length::Auto, + date_format: "%Y/%m/%d".into(), + cleanable: false, + placeholder: None, + } + } + + /// Set the date format of the date picker to display in Input, default: "%Y/%m/%d". + pub fn date_format(mut self, format: impl Into) -> Self { + self.date_format = format.into(); + self + } + + /// Set the placeholder of the date picker, default: "". + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = Some(placeholder.into()); + self + } + + /// Set true to show the clear button when the input field is not empty. + pub fn cleanable(mut self, cleanable: bool) -> Self { + self.cleanable = cleanable; + self + } + + /// Set width of the date picker input field, default is `Length::Auto`. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Set the default date of the date picker. + pub fn default_now(mut self) -> Self { + self.date = Some(Local::now().naive_local().date()); + self + } + + /// Get the date of the date picker. + pub fn date(&self) -> Option { + self.date + } + + /// Set the date of the date picker. + pub fn set_date(&mut self, date: Option, cx: &mut ViewContext) { + self.update_date(date, cx); + } + + fn update_date(&mut self, date: Option, cx: &mut ViewContext) { + self.date = date; + self.calendar.update(cx, |view, cx| { + view.set_date(date, cx); + }); + self.open = false; + cx.emit(DatePickerEvent::Change(date)); + cx.notify(); + } + + fn escape(&mut self, _: &Escape, cx: &mut ViewContext) { + self.open = false; + cx.notify(); + } + + fn clean(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext) { + self.update_date(None, cx); + } + + fn toggle_calendar(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext) { + self.open = !self.open; + cx.notify(); + } +} + +impl EventEmitter for DatePicker {} +impl Sizable for DatePicker { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} +impl FocusableView for DatePicker { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for DatePicker { + fn render(&mut self, cx: &mut ViewContext) -> impl gpui::IntoElement { + let is_focused = self.focus_handle.is_focused(cx); + let show_clean = self.cleanable && self.date.is_some(); + let placeholder = self + .placeholder + .clone() + .unwrap_or_else(|| t!("DatePicker.placeholder").into()); + let display_title = self + .date + .map(|date| date.format(&self.date_format).to_string()) + .unwrap_or(placeholder.to_string()); + + div() + .id(self.id.clone()) + .key_context("DatePicker") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::escape)) + .w_full() + .relative() + .map(|this| match self.width { + Length::Definite(l) => this.flex_none().w(l), + Length::Auto => this.w_full(), + }) + .input_text_size(self.size) + .child( + div() + .id("date-picker-input") + .relative() + .flex() + .items_center() + .justify_between() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().input) + .rounded(px(cx.theme().radius)) + .shadow_sm() + .cursor_pointer() + .overflow_hidden() + .input_text_size(self.size) + .when(is_focused, |this| this.outline(cx)) + .input_size(self.size) + .when(!self.open, |this| { + this.on_click(cx.listener(Self::toggle_calendar)) + }) + .child( + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_1() + .child(div().w_full().overflow_hidden().child(display_title)) + .when(show_clean, |this| { + this.child(ClearButton::new(cx).on_click(cx.listener(Self::clean))) + }) + .when(!show_clean, |this| { + this.child( + Icon::new(IconName::Calendar) + .text_color(cx.theme().muted_foreground), + ) + }), + ), + ) + .when(self.open, |this| { + this.child( + deferred( + div() + .track_focus(&self.focus_handle) + .absolute() + .mt_1() + .overflow_hidden() + .rounded_lg() + .p_3() + .w(px(300.)) + .elevation_2(cx) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|view, _, cx| view.escape(&Escape, cx)), + ) + .child(self.calendar.clone()), + ) + .with_priority(1), + ) + }) + } +} diff --git a/crates/ui/src/time/mod.rs b/crates/ui/src/time/mod.rs new file mode 100644 index 00000000..2e8b2b15 --- /dev/null +++ b/crates/ui/src/time/mod.rs @@ -0,0 +1,3 @@ +pub mod calendar; +pub mod date_picker; +mod utils; diff --git a/crates/ui/src/time/utils.rs b/crates/ui/src/time/utils.rs new file mode 100644 index 00000000..d32c5313 --- /dev/null +++ b/crates/ui/src/time/utils.rs @@ -0,0 +1,170 @@ +use chrono::{Datelike, Duration, NaiveDate}; + +trait NaiveDateExt { + fn days_in_month(&self) -> i32; + fn is_leap_year(&self) -> bool; +} + +impl NaiveDateExt for chrono::NaiveDate { + fn days_in_month(&self) -> i32 { + let month = self.month(); + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if self.is_leap_year() { + 29 + } else { + 28 + } + } + _ => panic!("Invalid month: {}", month), + } + } + + fn is_leap_year(&self) -> bool { + let year = self.year(); + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + } +} + +pub(crate) fn days_in_month(year: i32, month: u32) -> Vec> { + let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let num_days = date.days_in_month(); + let start_weekday = date.weekday().num_days_from_sunday(); + + // Get the days in the month, 2023-02 will returns + // "29|30|31| 1| 2| 3| 4", + // " 5| 6| 7| 8| 9|10|11", + // "12|13|14|15|16|17|18", + // "19|20|21|22|23|24|25", + // "26|27|28| 1| 2| 3| 4", + let mut days = vec![]; + for n in 0..5 { + let mut week_days = vec![]; + for weekday in 0..7 { + let (mut y, mut m) = (year, month); + + // If the day is less than the start weekday, we need to go back to the previous month. + if n == 0 && weekday < start_weekday { + m = if m == 1 { 12 } else { m - 1 }; + y = if m == 1 { year - 1 } else { y }; + } + + // If start_weekday is 3, and n is 0 and weekday is 3, then day is 1. + // If start_weekday is 3, and n is 1 and weekday is 4, then day is 9. + let day = n * 7 + weekday as i32 - start_weekday as i32; + + // If the day is greater than the number of days in the month, we need to go to the next month. + if day > num_days { + m = if m == 12 { 1 } else { m + 1 }; + y = if m == 1 { year + 1 } else { y }; + } + + #[allow(clippy::expect_fun_call)] + let date = date + .checked_add_signed(Duration::days(day as i64)) + .expect(&format!("invalid date {}-{} days {}", y, m, day)); + week_days.push(date); + } + + days.push(week_days); + } + + days +} + +#[cfg(test)] +mod tests { + use chrono::{Datelike, NaiveDate}; + + use super::{days_in_month, NaiveDateExt}; + + #[test] + fn test_days_in_month() { + assert_eq!( + NaiveDate::from_ymd_opt(2024, 2, 1).unwrap().days_in_month(), + 29 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 2, 1).unwrap().days_in_month(), + 28 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 1, 1).unwrap().days_in_month(), + 31 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 4, 1).unwrap().days_in_month(), + 30 + ); + } + + #[test] + fn test_days() { + #[track_caller] + fn assert_case(date: NaiveDate, expected: Vec<&str>) { + let out = days_in_month(date.year(), date.month()) + .iter() + .map(|week| { + week.iter() + .map(|d| { + if d.year() == date.year() && d.month() == date.month() { + format!("{:2}", d.day()) + } else if d.year() == date.year() { + format!("{}-{}", d.month(), d.day()) + } else { + format!("{}-{}-{}", d.year(), d.month(), d.day()) + } + }) + .collect::>() + .join("|") + }) + .collect::>(); + + assert_eq!(out, expected); + } + + assert_case( + NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(), + vec![ + "7-28|7-29|7-30|7-31| 1| 2| 3", + " 4| 5| 6| 7| 8| 9|10", + "11|12|13|14|15|16|17", + "18|19|20|21|22|23|24", + "25|26|27|28|29|30|31", + ], + ); + assert_case( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + vec![ + "2024-12-29|2024-12-30|2024-12-31| 1| 2| 3| 4", + " 5| 6| 7| 8| 9|10|11", + "12|13|14|15|16|17|18", + "19|20|21|22|23|24|25", + "26|27|28|29|30|31|2-1", + ], + ); + + assert_case( + NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(), + vec![ + "1-28|1-29|1-30|1-31| 1| 2| 3", + " 4| 5| 6| 7| 8| 9|10", + "11|12|13|14|15|16|17", + "18|19|20|21|22|23|24", + "25|26|27|28|29|3-1|3-2", + ], + ); + assert_case( + NaiveDate::from_ymd_opt(2023, 2, 20).unwrap(), + vec![ + "1-29|1-30|1-31| 1| 2| 3| 4", + " 5| 6| 7| 8| 9|10|11", + "12|13|14|15|16|17|18", + "19|20|21|22|23|24|25", + "26|27|28|3-1|3-2|3-3|3-4", + ], + ); + } +}