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",
+ ],
+ );
+ }
+}