Add Calendar and DatePicker. (#117)

https://github.com/user-attachments/assets/dda87fa7-f080-418a-8dd7-ab8581eb3beb
This commit is contained in:
Jason Lee 2024-08-07 20:25:11 +08:00 committed by GitHub
parent e07f551317
commit ebdcf244e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1130 additions and 34 deletions

219
Cargo.lock generated
View file

@ -54,6 +54,21 @@ version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" 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]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.15" version = "0.6.15"
@ -115,6 +130,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110"
[[package]]
name = "arc-swap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]] [[package]]
name = "arg_enum_proc_macro" name = "arg_enum_proc_macro"
version = "0.3.4" version = "0.3.4"
@ -860,6 +881,20 @@ dependencies = [
"syn 2.0.71", "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]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@ -2122,6 +2157,17 @@ dependencies = [
"regex-syntax", "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]] [[package]]
name = "glow" name = "glow"
version = "0.13.1" version = "0.13.1"
@ -2270,6 +2316,7 @@ dependencies = [
"gpui", "gpui",
"log", "log",
"rust-embed", "rust-embed",
"serde",
"story", "story",
"ui", "ui",
"workspace", "workspace",
@ -2502,6 +2549,29 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 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]] [[package]]
name = "idna" name = "idna"
version = "0.5.0" version = "0.5.0"
@ -2512,6 +2582,22 @@ dependencies = [
"unicode-normalization", "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]] [[package]]
name = "image" name = "image"
version = "0.25.1" version = "0.25.1"
@ -2878,6 +2964,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "linkme" name = "linkme"
version = "0.3.27" version = "0.3.27"
@ -3174,6 +3266,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" 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]] [[package]]
name = "num" name = "num"
version = "0.4.3" version = "0.4.3"
@ -4205,6 +4306,57 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@ -4477,6 +4629,18 @@ dependencies = [
"serde", "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]] [[package]]
name = "servo_arc" name = "servo_arc"
version = "0.1.1" version = "0.1.1"
@ -4731,6 +4895,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"charts-rs", "charts-rs",
"chrono",
"fake", "fake",
"gpui", "gpui",
"regex", "regex",
@ -5142,6 +5307,18 @@ dependencies = [
"serde", "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]] [[package]]
name = "toml" name = "toml"
version = "0.8.15" version = "0.8.15"
@ -5170,6 +5347,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [ dependencies = [
"indexmap 2.2.6", "indexmap 2.2.6",
"serde",
"serde_spanned",
"toml_datetime", "toml_datetime",
"winnow 0.5.40", "winnow 0.5.40",
] ]
@ -5251,6 +5430,17 @@ dependencies = [
"tracing", "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]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.21.1" version = "0.21.1"
@ -5285,12 +5475,14 @@ name = "ui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"gpui", "gpui",
"image", "image",
"once_cell", "once_cell",
"paste", "paste",
"regex", "regex",
"resvg", "resvg",
"rust-i18n",
"serde", "serde",
"serde_json", "serde_json",
"smallvec", "smallvec",
@ -5869,6 +6061,15 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.57.0" version = "0.57.0"
@ -5993,6 +6194,15 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.42.2" version = "0.42.2"
@ -6392,6 +6602,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" 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]] [[package]]
name = "yazi" name = "yazi"
version = "0.1.6" version = "0.1.6"

View file

@ -49,7 +49,8 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs
- [x] Slider - [x] Slider
- [ ] Skeleton - [ ] Skeleton
- [ ] DatePicker - [ ] DatePicker
- [ ] DateTimePicker - [x] DatePicker
- [x] Calendar
- [ ] TimePicker - [ ] TimePicker
- [ ] DateRangePicker - [ ] DateRangePicker
- [ ] ColorPicker - [ ] ColorPicker

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>

After

Width:  |  Height:  |  Size: 325 B

1
assets/icons/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>

After

Width:  |  Height:  |  Size: 327 B

View file

@ -11,7 +11,7 @@ log.workspace = true
workspace.workspace = true workspace.workspace = true
ui.workspace = true ui.workspace = true
story.workspace = true story.workspace = true
serde.workspace = true
[lints] [lints]
workspace = true workspace = true

View file

@ -1,21 +1,29 @@
use gpui::*; use gpui::*;
use prelude::FluentBuilder as _; use prelude::FluentBuilder as _;
use private::serde::Deserialize;
use story::{ use story::{
ButtonStory, DropdownStory, IconStory, ImageStory, InputStory, ListStory, PickerStory, ButtonStory, CalendarStory, DropdownStory, IconStory, ImageStory, InputStory, ListStory,
PopupStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, SwitchStory, PickerStory, PopupStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer,
TableStory, TextStory, TooltipStory, SwitchStory, TableStory, TextStory, TooltipStory,
}; };
use workspace::{TitleBar, Workspace}; use workspace::{TitleBar, Workspace};
use std::sync::Arc; use std::sync::Arc;
use ui::{ use ui::{
button::{Button, ButtonStyle}, button::{Button, ButtonStyle},
popover::Popover,
popup_menu::PopupMenu,
theme::{ActiveTheme, Theme}, theme::{ActiveTheme, Theme},
Clickable as _, IconName, Sizable, Clickable as _, IconName, Sizable,
}; };
use crate::app_state::AppState; use crate::app_state::AppState;
#[derive(Clone, PartialEq, Eq, Deserialize)]
struct SelectLocale(SharedString);
impl_actions!(locale_switcher, [SelectLocale]);
actions!(workspace, [Open, CloseWindow]); actions!(workspace, [Open, CloseWindow]);
pub fn init(_app_state: Arc<AppState>, cx: &mut AppContext) { pub fn init(_app_state: Arc<AppState>, cx: &mut AppContext) {
@ -28,6 +36,7 @@ pub fn init(_app_state: Arc<AppState>, cx: &mut AppContext) {
pub struct StoryWorkspace { pub struct StoryWorkspace {
workspace: View<Workspace>, workspace: View<Workspace>,
locale_selector: View<LocaleSelector>,
} }
impl StoryWorkspace { impl StoryWorkspace {
@ -184,7 +193,20 @@ impl StoryWorkspace {
) )
.detach(); .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( pub fn new_local(
@ -276,6 +298,7 @@ impl Render for StoryWorkspace {
.px_2() .px_2()
.mr_3() .mr_3()
.gap_2() .gap_2()
.child(self.locale_selector.clone())
.child( .child(
Button::new("theme-mode", cx) Button::new("theme-mode", cx)
.map(|this| { .map(|this| {
@ -310,3 +333,53 @@ impl Render for StoryWorkspace {
.child(self.workspace.clone()) .child(self.workspace.clone())
} }
} }
struct LocaleSelector {
focus_handle: FocusHandle,
}
impl LocaleSelector {
pub fn new(cx: &mut ViewContext<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
fn on_select_locale(&mut self, locale: &SelectLocale, cx: &mut ViewContext<Self>) {
ui::set_locale(&locale.0);
cx.refresh();
}
}
impl Render for LocaleSelector {
fn render(&mut self, cx: &mut ViewContext<Self>) -> 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())),
)
})
}),
)
}
}

View file

@ -11,6 +11,7 @@ anyhow = "1"
workspace.workspace = true workspace.workspace = true
charts-rs = "0.3" charts-rs = "0.3"
regex = "1" regex = "1"
chrono = "0.4"
[lints] [lints]
workspace = true workspace = true

View file

@ -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<DatePicker>,
date_picker_small: View<DatePicker>,
date_picker_large: View<DatePicker>,
date_picker_value: Option<String>,
}
impl CalendarStory {
pub fn view(cx: &mut WindowContext) -> View<Self> {
cx.new_view(Self::new)
}
fn new(cx: &mut ViewContext<Self>) -> 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<Self>) -> 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())
}
}

View file

@ -1,4 +1,5 @@
mod button_story; mod button_story;
mod calendar_story;
mod dropdown_story; mod dropdown_story;
mod icon_story; mod icon_story;
mod image_story; mod image_story;
@ -16,6 +17,7 @@ mod tooltip_story;
mod webview_story; mod webview_story;
pub use button_story::ButtonStory; pub use button_story::ButtonStory;
pub use calendar_story::CalendarStory;
pub use dropdown_story::DropdownStory; pub use dropdown_story::DropdownStory;
pub use icon_story::IconStory; pub use icon_story::IconStory;
pub use image_story::ImageStory; pub use image_story::ImageStory;

View file

@ -24,6 +24,10 @@ once_cell = "1.19.0"
wry = "0" wry = "0"
smol = "1" smol = "1"
regex = "1" regex = "1"
rust-i18n = "3"
# Calendar
chrono = "0.4.38"
[lints] [lints]
workspace = true workspace = true

83
crates/ui/locales/ui.yml Normal file
View file

@ -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: 選擇日期

View file

@ -7,8 +7,8 @@ use gpui::{
}; };
use crate::{ use crate::{
button::{Button, ButtonStyle},
h_flex, h_flex,
input::ClearButton,
list::{self, List, ListDelegate, ListItem}, list::{self, List, ListDelegate, ListItem},
styled_ext::StyleSized, styled_ext::StyleSized,
theme::ActiveTheme, theme::ActiveTheme,
@ -591,14 +591,7 @@ where
.child(self.display_title(cx)), .child(self.display_title(cx)),
) )
.when(show_clean, |this| { .when(show_clean, |this| {
this.child( this.child(ClearButton::new(cx).on_click(cx.listener(Self::clean)))
Button::new("clean", cx)
.icon(IconName::CircleX)
.style(ButtonStyle::Ghost)
.with_size(px(14.))
.cursor_pointer()
.on_click(cx.listener(Self::clean)),
)
}) })
.when(!show_clean, |this| { .when(!show_clean, |this| {
let icon = match self.icon.clone() { let icon = match self.icon.clone() {

View file

@ -11,14 +11,16 @@ pub enum IconName {
ArrowRight, ArrowRight,
ArrowUp, ArrowUp,
Asterisk, Asterisk,
Calendar,
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronsUpDown,
ChevronUp, ChevronUp,
ChevronsUpDown,
CircleX, CircleX,
Close, Close,
Copy,
Dash, Dash,
Delete, Delete,
Ellipsis, Ellipsis,
@ -26,6 +28,7 @@ pub enum IconName {
Eye, Eye,
EyeOff, EyeOff,
GitHub, GitHub,
Globe,
Heart, Heart,
HeartOff, HeartOff,
Inbox, Inbox,
@ -33,6 +36,7 @@ pub enum IconName {
Loader, Loader,
LoaderCircle, LoaderCircle,
Maximize, Maximize,
Menu,
Minimize, Minimize,
Minus, Minus,
Moon, Moon,
@ -45,8 +49,6 @@ pub enum IconName {
Sun, Sun,
ThumbsDown, ThumbsDown,
ThumbsUp, ThumbsUp,
Menu,
Copy,
} }
impl IconName { impl IconName {
@ -57,14 +59,16 @@ impl IconName {
IconName::ArrowRight => "icons/arrow-right.svg", IconName::ArrowRight => "icons/arrow-right.svg",
IconName::ArrowUp => "icons/arrow-up.svg", IconName::ArrowUp => "icons/arrow-up.svg",
IconName::Asterisk => "icons/asterisk.svg", IconName::Asterisk => "icons/asterisk.svg",
IconName::Calendar => "icons/calendar.svg",
IconName::Check => "icons/check.svg", IconName::Check => "icons/check.svg",
IconName::ChevronDown => "icons/chevron-down.svg", IconName::ChevronDown => "icons/chevron-down.svg",
IconName::ChevronLeft => "icons/chevron-left.svg", IconName::ChevronLeft => "icons/chevron-left.svg",
IconName::ChevronRight => "icons/chevron-right.svg", IconName::ChevronRight => "icons/chevron-right.svg",
IconName::ChevronsUpDown => "icons/chevrons-up-down.svg",
IconName::ChevronUp => "icons/chevron-up.svg", IconName::ChevronUp => "icons/chevron-up.svg",
IconName::ChevronsUpDown => "icons/chevrons-up-down.svg",
IconName::CircleX => "icons/circle-x.svg", IconName::CircleX => "icons/circle-x.svg",
IconName::Close => "icons/close.svg", IconName::Close => "icons/close.svg",
IconName::Copy => "icons/copy.svg",
IconName::Dash => "icons/dash.svg", IconName::Dash => "icons/dash.svg",
IconName::Delete => "icons/delete.svg", IconName::Delete => "icons/delete.svg",
IconName::Ellipsis => "icons/ellipsis.svg", IconName::Ellipsis => "icons/ellipsis.svg",
@ -72,6 +76,7 @@ impl IconName {
IconName::Eye => "icons/eye.svg", IconName::Eye => "icons/eye.svg",
IconName::EyeOff => "icons/eye-off.svg", IconName::EyeOff => "icons/eye-off.svg",
IconName::GitHub => "icons/github.svg", IconName::GitHub => "icons/github.svg",
IconName::Globe => "icons/globe.svg",
IconName::Heart => "icons/heart.svg", IconName::Heart => "icons/heart.svg",
IconName::HeartOff => "icons/heart-off.svg", IconName::HeartOff => "icons/heart-off.svg",
IconName::Inbox => "icons/inbox.svg", IconName::Inbox => "icons/inbox.svg",
@ -79,6 +84,7 @@ impl IconName {
IconName::Loader => "icons/loader.svg", IconName::Loader => "icons/loader.svg",
IconName::LoaderCircle => "icons/loader-circle.svg", IconName::LoaderCircle => "icons/loader-circle.svg",
IconName::Maximize => "icons/maximize.svg", IconName::Maximize => "icons/maximize.svg",
IconName::Menu => "icons/menu.svg",
IconName::Minimize => "icons/minimize.svg", IconName::Minimize => "icons/minimize.svg",
IconName::Minus => "icons/minus.svg", IconName::Minus => "icons/minus.svg",
IconName::Moon => "icons/moon.svg", IconName::Moon => "icons/moon.svg",
@ -91,8 +97,6 @@ impl IconName {
IconName::Sun => "icons/sun.svg", IconName::Sun => "icons/sun.svg",
IconName::ThumbsDown => "icons/thumbs-down.svg", IconName::ThumbsDown => "icons/thumbs-down.svg",
IconName::ThumbsUp => "icons/thumbs-up.svg", IconName::ThumbsUp => "icons/thumbs-up.svg",
IconName::Menu => "icons/menu.svg",
IconName::Copy => "icons/copy.svg",
} }
.into() .into()
} }

View file

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

View file

@ -7,12 +7,12 @@ use std::ops::Range;
use super::blink_cursor::BlinkCursor; use super::blink_cursor::BlinkCursor;
use super::history::History; use super::history::History;
use crate::button::{Button, ButtonStyle}; use super::ClearButton;
use crate::indicator::Indicator; use crate::indicator::Indicator;
use crate::styled_ext::{Sizable, StyleSized}; use crate::styled_ext::{Sizable, StyleSized};
use crate::theme::ActiveTheme; use crate::theme::ActiveTheme;
use crate::{event::InteractiveElementExt as _, Size}; 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::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
actions, div, fill, point, px, relative, rems, size, AnyElement, AppContext, Bounds, 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.loading, |this| this.child(Indicator::new()))
.when( .when(
self.cleanable && !self.loading && !self.text.is_empty(), self.cleanable && !self.loading && !self.text.is_empty(),
|this| { |this| this.child(ClearButton::new(cx).on_click(cx.listener(Self::clean))),
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)),
)
},
) )
.children(suffix) .children(suffix)
} }

View file

@ -1,7 +1,9 @@
mod blink_cursor; mod blink_cursor;
mod clear_button;
mod history; mod history;
mod input; mod input;
mod otp_input; mod otp_input;
pub(crate) use clear_button::*;
pub use input::*; pub use input::*;
pub use otp_input::*; pub use otp_input::*;

View file

@ -9,6 +9,7 @@ mod selectable;
mod stack; mod stack;
mod styled_ext; mod styled_ext;
mod svg_img; mod svg_img;
mod time;
pub mod button; pub mod button;
pub mod checkbox; pub mod checkbox;
@ -36,6 +37,8 @@ pub mod theme;
pub mod tooltip; pub mod tooltip;
pub mod webview; pub mod webview;
use std::ops::Deref;
// re-export // re-export
pub use wry; pub use wry;
@ -45,20 +48,32 @@ pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle; pub use focusable::FocusableCycle;
pub use selectable::{Selectable, Selection}; pub use selectable::{Selectable, Selection};
pub use styled_ext::{Sizable, Size, StyledExt}; pub use styled_ext::{Sizable, Size, StyledExt};
pub use time::*;
pub use colors::*; pub use colors::*;
pub use icon::*; pub use icon::*;
pub use stack::*; pub use stack::*;
pub use svg_img::*; pub use svg_img::*;
rust_i18n::i18n!("locales", fallback = "en");
/// Initialize the UI module. /// Initialize the UI module.
pub fn init(cx: &mut gpui::AppContext) { pub fn init(cx: &mut gpui::AppContext) {
input::init(cx); input::init(cx);
list::init(cx); list::init(cx);
dropdown::init(cx); dropdown::init(cx);
date_picker::init(cx);
popover::init(cx); popover::init(cx);
popup_menu::init(cx); popup_menu::init(cx);
context_menu::init(cx); context_menu::init(cx);
table::init(cx); table::init(cx);
webview::init(cx) webview::init(cx)
} }
pub fn locale() -> impl Deref<Target = str> {
rust_i18n::locale()
}
pub fn set_locale(locale: &str) {
rust_i18n::set_locale(locale)
}

View file

@ -179,11 +179,12 @@ pub trait StyledExt: Styled + Sized {
impl<E: Styled> StyledExt for E {} impl<E: Styled> StyledExt for E {}
/// A size for elements. /// A size for elements.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Default, Copy, PartialEq, Eq, Debug)]
pub enum Size { pub enum Size {
Size(Pixels), Size(Pixels),
XSmall, XSmall,
Small, Small,
#[default]
Medium, Medium,
Large, Large,
} }

View file

@ -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<NaiveDate>,
current_year: i32,
current_month: u8,
}
impl Calendar {
pub fn new(_cx: &mut ViewContext<Self>) -> 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<NaiveDate>, cx: &mut ViewContext<Self>) {
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<NaiveDate> {
self.date
}
/// Returns the days of the month in a 2D vector to render on calendar.
fn days(&self) -> Vec<Vec<NaiveDate>> {
days_in_month(self.current_year, self.current_month as u32)
}
fn prev_month(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
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>) {
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<SharedString>,
cx: &mut ViewContext<Self>,
) -> 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<Self>,
) -> 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<CalendarEvent> for Calendar {}
impl Render for Calendar {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> 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)),
)
})),
)
}
}

View file

@ -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<NaiveDate>),
}
pub struct DatePicker {
id: ElementId,
focus_handle: FocusHandle,
date: Option<NaiveDate>,
cleanable: bool,
placeholder: Option<SharedString>,
open: bool,
size: Size,
width: Length,
date_format: SharedString,
calendar: View<Calendar>,
}
impl DatePicker {
pub fn new(id: impl Into<ElementId>, cx: &mut ViewContext<Self>) -> 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<SharedString>) -> Self {
self.date_format = format.into();
self
}
/// Set the placeholder of the date picker, default: "".
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> 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<Length>) -> 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<NaiveDate> {
self.date
}
/// Set the date of the date picker.
pub fn set_date(&mut self, date: Option<NaiveDate>, cx: &mut ViewContext<Self>) {
self.update_date(date, cx);
}
fn update_date(&mut self, date: Option<NaiveDate>, cx: &mut ViewContext<Self>) {
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>) {
self.open = false;
cx.notify();
}
fn clean(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext<Self>) {
self.update_date(None, cx);
}
fn toggle_calendar(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext<Self>) {
self.open = !self.open;
cx.notify();
}
}
impl EventEmitter<DatePickerEvent> for DatePicker {}
impl Sizable for DatePicker {
fn with_size(mut self, size: impl Into<Size>) -> 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<Self>) -> 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),
)
})
}
}

View file

@ -0,0 +1,3 @@
pub mod calendar;
pub mod date_picker;
mod utils;

170
crates/ui/src/time/utils.rs Normal file
View file

@ -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<Vec<NaiveDate>> {
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::<Vec<_>>()
.join("|")
})
.collect::<Vec<_>>();
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",
],
);
}
}