use crate::motion::pop_in;
use gpui::{
App, Bounds, Context, Element, ElementId, Entity, GlobalElementId, Hsla, InspectorElementId,
IntoElement, LayoutId, MouseButton, Pixels, Render, SharedString, Window, actions, div,
prelude::*, px,
};
use liora_core::{Config, push_portal};
use liora_icons::Icon;
use liora_icons_lucide::IconName;
actions!(date_time_picker, [DateTimePickerClose]);
use crate::{DateValue, TimeValue};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct DateTimeValue {
pub date: DateValue,
pub time: TimeValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DateTimePickerType {
#[default]
DateTime,
DateTimeRange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DateTimePickerSelection {
Single(Option<DateTimeValue>),
Range {
start: Option<DateTimeValue>,
end: Option<DateTimeValue>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RangeEndpoint {
Start,
End,
}
pub struct DateTimePicker {
id: SharedString,
picker_type: DateTimePickerType,
value: Option<DateTimeValue>,
range_start: Option<DateTimeValue>,
range_end: Option<DateTimeValue>,
editing_endpoint: RangeEndpoint,
view_year: i32,
view_month: u32,
draft_date: DateValue,
draft_time: TimeValue,
is_open: bool,
placeholder: SharedString,
display_format: SharedString,
range_separator: SharedString,
width: Option<Pixels>,
disabled: bool,
minute_step: u32,
second_step: u32,
show_seconds: bool,
last_bounds: Option<Bounds<Pixels>>,
close_on_click_outside: bool,
close_on_escape: bool,
on_change: Option<Box<dyn Fn(Option<DateTimeValue>, &mut Window, &mut App) + 'static>>,
on_range_change: Option<
Box<dyn Fn(Option<DateTimeValue>, Option<DateTimeValue>, &mut Window, &mut App) + 'static>,
>,
on_selection_change:
Option<Box<dyn Fn(DateTimePickerSelection, &mut Window, &mut App) + 'static>>,
}
impl DateTimeValue {
pub fn new(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> Option<Self> {
Some(Self {
date: DateValue::new(year, month, day)?,
time: TimeValue::new(hour, minute, second)?,
})
}
pub fn from_parts(date: DateValue, time: TimeValue) -> Self {
Self { date, time }
}
pub fn format(&self) -> String {
format!("{} {}", self.date.format(), self.time.format())
}
}
impl DateTimePicker {
pub fn new() -> Self {
let default_date = DateValue {
year: 2026,
month: 5,
day: 8,
};
let default_time = TimeValue {
hour: 0,
minute: 0,
second: 0,
};
Self {
id: liora_core::unique_id("date-time-picker"),
picker_type: DateTimePickerType::DateTime,
value: None,
range_start: None,
range_end: None,
editing_endpoint: RangeEndpoint::Start,
view_year: default_date.year,
view_month: default_date.month,
draft_date: default_date,
draft_time: default_time,
is_open: false,
placeholder: "请选择日期时间".into(),
display_format: "YYYY-MM-DD HH:mm:ss".into(),
range_separator: " 至 ".into(),
width: None,
disabled: false,
minute_step: 1,
second_step: 1,
show_seconds: true,
last_bounds: None,
close_on_click_outside: true,
close_on_escape: true,
on_change: None,
on_range_change: None,
on_selection_change: None,
}
}
pub fn id(mut self, id: impl Into<SharedString>) -> Self {
self.id = id.into();
self
}
pub fn picker_type(mut self, picker_type: DateTimePickerType) -> Self {
self.picker_type = picker_type;
if picker_type.is_range() && self.placeholder == SharedString::from("请选择日期时间")
{
self.placeholder = "请选择日期时间范围".into();
}
self
}
pub fn date_time(self) -> Self {
self.picker_type(DateTimePickerType::DateTime)
}
pub fn date_time_range(self) -> Self {
self.picker_type(DateTimePickerType::DateTimeRange)
}
pub fn value(mut self, value: DateTimeValue) -> Self {
self.view_year = value.date.year;
self.view_month = value.date.month;
self.draft_date = value.date;
self.draft_time = value.time;
self.value = Some(value);
self
}
pub fn range(mut self, start: DateTimeValue, end: DateTimeValue) -> Self {
let (start, end) = ordered_pair(start, end);
self.view_year = start.date.year;
self.view_month = start.date.month;
self.draft_date = start.date;
self.draft_time = start.time;
self.range_start = Some(start);
self.range_end = Some(end);
self.editing_endpoint = RangeEndpoint::End;
self
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn format(mut self, format: impl Into<SharedString>) -> Self {
self.display_format = format.into();
self
}
pub fn range_separator(mut self, separator: impl Into<SharedString>) -> Self {
self.range_separator = separator.into();
self
}
pub fn width(mut self, width: impl Into<Pixels>) -> Self {
self.width = Some(width.into());
self
}
pub fn width_md(self) -> Self {
self.width(px(280.0))
}
pub fn width_lg(self) -> Self {
self.width(px(460.0))
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn minute_step(mut self, step: u32) -> Self {
self.minute_step = step.clamp(1, 60);
self
}
pub fn second_step(mut self, step: u32) -> Self {
self.second_step = step.clamp(1, 60);
self
}
pub fn without_seconds(mut self) -> Self {
self.show_seconds = false;
self.display_format = "YYYY-MM-DD HH:mm".into();
self
}
pub fn close_on_escape(mut self, close: bool) -> Self {
self.close_on_escape = close;
self
}
pub fn close_on_click_outside(mut self, close: bool) -> Self {
self.close_on_click_outside = close;
self
}
pub fn register_key_bindings(cx: &mut App) {
cx.bind_keys([gpui::KeyBinding::new("escape", DateTimePickerClose, None)]);
}
fn close_on_escape_action(
&mut self,
_: &DateTimePickerClose,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.close_on_escape && self.is_open {
self.close(cx);
}
}
pub fn on_change(
mut self,
f: impl Fn(Option<DateTimeValue>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Box::new(f));
self
}
pub fn on_range_change(
mut self,
f: impl Fn(Option<DateTimeValue>, Option<DateTimeValue>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_range_change = Some(Box::new(f));
self
}
pub fn on_selection_change(
mut self,
f: impl Fn(DateTimePickerSelection, &mut Window, &mut App) + 'static,
) -> Self {
self.on_selection_change = Some(Box::new(f));
self
}
pub fn value_ref(&self) -> Option<DateTimeValue> {
self.value
}
pub fn range_ref(&self) -> (Option<DateTimeValue>, Option<DateTimeValue>) {
(self.range_start, self.range_end)
}
fn is_range(&self) -> bool {
self.picker_type.is_range()
}
fn has_display_value(&self) -> bool {
if self.is_range() {
self.range_start.is_some()
} else {
self.value.is_some()
}
}
fn display_text(&self) -> String {
if self.is_range() {
match (self.range_start, self.range_end) {
(Some(start), Some(end)) => format!(
"{}{}{}",
self.format_value(start),
self.range_separator,
self.format_value(end)
),
(Some(start), None) => {
format!("{}{}", self.format_value(start), self.range_separator)
}
_ => self.placeholder.to_string(),
}
} else {
self.value
.map(|value| self.format_value(value))
.unwrap_or_else(|| self.placeholder.to_string())
}
}
fn format_value(&self, value: DateTimeValue) -> String {
format_date_time_value(value, self.display_format.as_ref())
}
fn toggle_open(&mut self, cx: &mut Context<Self>) {
if self.disabled {
return;
}
self.is_open = !self.is_open;
if self.is_open {
self.sync_draft_from_value();
}
cx.notify();
}
fn close(&mut self, cx: &mut Context<Self>) {
if self.is_open {
self.is_open = false;
cx.notify();
}
}
fn sync_draft_from_value(&mut self) {
let seed = if self.is_range() {
match self.editing_endpoint {
RangeEndpoint::Start => self.range_start.or(self.range_end),
RangeEndpoint::End => self.range_end.or(self.range_start),
}
} else {
self.value
};
if let Some(value) = seed {
self.draft_date = value.date;
self.draft_time = value.time;
self.view_year = value.date.year;
self.view_month = value.date.month;
}
}
fn select_date(&mut self, date: DateValue, cx: &mut Context<Self>) {
self.draft_date = date;
self.view_year = date.year;
self.view_month = date.month;
if self.is_range() {
let next = DateTimeValue::from_parts(date, self.draft_time);
match (self.range_start, self.range_end) {
(None, _) | (Some(_), Some(_)) => {
self.range_start = Some(next);
self.range_end = None;
self.editing_endpoint = RangeEndpoint::Start;
}
(Some(start), None) => {
let (start, end) = ordered_pair(start, next);
self.range_start = Some(start);
self.range_end = Some(end);
self.editing_endpoint = RangeEndpoint::End;
self.draft_date = end.date;
self.draft_time = end.time;
}
}
}
cx.notify();
}
fn select_time(&mut self, time: TimeValue, cx: &mut Context<Self>) {
self.draft_time = time;
if self.is_range() {
let next = DateTimeValue::from_parts(self.draft_date, time);
match self.editing_endpoint {
RangeEndpoint::Start => self.range_start = Some(next),
RangeEndpoint::End => self.range_end = Some(next),
}
if let (Some(start), Some(end)) = (self.range_start, self.range_end) {
let (start, end) = ordered_pair(start, end);
self.range_start = Some(start);
self.range_end = Some(end);
}
}
cx.notify();
}
fn edit_endpoint(&mut self, endpoint: RangeEndpoint, cx: &mut Context<Self>) {
self.editing_endpoint = endpoint;
if let Some(value) = match endpoint {
RangeEndpoint::Start => self.range_start,
RangeEndpoint::End => self.range_end,
} {
self.draft_date = value.date;
self.draft_time = value.time;
self.view_year = value.date.year;
self.view_month = value.date.month;
}
cx.notify();
}
fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_range() {
if let (Some(start), Some(end)) = (self.range_start, self.range_end) {
let (start, end) = ordered_pair(start, end);
self.range_start = Some(start);
self.range_end = Some(end);
}
} else {
self.value = Some(DateTimeValue::from_parts(self.draft_date, self.draft_time));
}
self.is_open = false;
self.emit_change(window, cx);
cx.notify();
}
fn emit_change(&self, window: &mut Window, cx: &mut App) {
if self.is_range() {
if let Some(ref on_range_change) = self.on_range_change {
on_range_change(self.range_start, self.range_end, window, cx);
}
if let Some(ref on_selection_change) = self.on_selection_change {
on_selection_change(
DateTimePickerSelection::Range {
start: self.range_start,
end: self.range_end,
},
window,
cx,
);
}
} else {
if let Some(ref on_change) = self.on_change {
on_change(self.value, window, cx);
}
if let Some(ref on_selection_change) = self.on_selection_change {
on_selection_change(DateTimePickerSelection::Single(self.value), window, cx);
}
}
}
fn shift_month(&mut self, delta: i32, cx: &mut Context<Self>) {
let month_index = self.view_year * 12 + self.view_month as i32 - 1 + delta;
self.view_year = month_index.div_euclid(12);
self.view_month = month_index.rem_euclid(12) as u32 + 1;
cx.notify();
}
fn shift_year(&mut self, delta: i32, cx: &mut Context<Self>) {
self.view_year += delta;
cx.notify();
}
}
impl DateTimePickerType {
fn is_range(self) -> bool {
matches!(self, DateTimePickerType::DateTimeRange)
}
}
impl Render for DateTimePicker {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.global::<Config>().theme.clone();
let entity = cx.entity().clone();
let display = self.display_text();
let range_start_text = self.range_start.map(|value| self.format_value(value));
let range_end_text = self.range_end.map(|value| self.format_value(value));
let range_separator = self.range_separator.clone();
let is_range = self.is_range();
let has_value = self.has_display_value();
let border_color = if self.is_open {
theme.primary.base
} else {
theme.neutral.border
};
if self.is_open {
let entity = entity.clone();
let picker_id = self.id.clone();
let bounds = self.last_bounds;
let close_on_click_outside = self.close_on_click_outside;
push_portal(
move |_window, _cx| {
let (top, left, width) = if let Some(bounds) = bounds {
(bounds.bottom() + px(4.0), bounds.left(), bounds.size.width)
} else {
(px(100.0), px(100.0), px(520.0))
};
let close_entity = entity.clone();
div()
.absolute()
.top_0()
.left_0()
.size_full()
.bg(gpui::transparent_black())
.when(close_on_click_outside, |s| {
s.on_mouse_down(MouseButton::Left, move |_, _, cx| {
close_entity.update(cx, |picker, cx| picker.close(cx));
})
})
.child(pop_in(
format!("{}-panel-motion", picker_id),
div()
.absolute()
.top(top)
.left(left)
.w(width.max(px(620.0)))
.child(render_date_time_panel(picker_id, entity, _cx)),
))
.into_any_element()
},
cx,
);
}
div()
.relative()
.when_some(self.width, |s, width| s.w(width))
.when(self.width.is_none(), |s| s.w(px(260.0)))
.h(px(34.0))
.id(format!("{}-trigger", self.id))
.flex()
.items_center()
.gap_2()
.px_3()
.bg(if self.disabled {
theme.neutral.hover
} else {
theme.neutral.card
})
.border_1()
.border_color(border_color)
.rounded(px(theme.radius.md))
.cursor_pointer()
.hover(|s| s.cursor_pointer().border_color(theme.primary.base))
.child(div().flex_1().min_w(px(0.0)).child(if is_range {
render_range_trigger_text(
range_start_text,
range_end_text,
range_separator,
self.placeholder.clone(),
has_value,
&theme,
)
} else {
div()
.text_size(px(theme.font_size.md))
.text_color(if has_value {
theme.neutral.text_1
} else {
theme.neutral.placeholder
})
.child(display)
.into_any_element()
}))
.child(
Icon::new(IconName::CalendarDays)
.size(px(16.0))
.color(theme.neutral.icon),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.size_full()
.child(DateTimePickerBoundsCapturer { picker: entity }),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, _, cx| {
this.toggle_open(cx);
}),
)
.on_action(cx.listener(Self::close_on_escape_action))
}
}
fn render_range_trigger_text(
start: Option<String>,
end: Option<String>,
separator: SharedString,
placeholder: SharedString,
has_value: bool,
theme: &liora_theme::Theme,
) -> gpui::AnyElement {
if !has_value {
return div()
.text_size(px(theme.font_size.md))
.text_color(theme.neutral.placeholder)
.child(placeholder)
.into_any_element();
}
let start = start.unwrap_or_default();
let end = end.unwrap_or_else(|| "请选择结束".to_string());
div()
.flex()
.items_center()
.gap_2()
.w_full()
.child(range_value_text(start, true, theme))
.child(
div()
.flex_shrink_0()
.px_2()
.py_1()
.rounded(px(theme.radius.sm))
.bg(theme.neutral.hover)
.text_xs()
.text_color(theme.neutral.text_3)
.child(separator),
)
.child(range_value_text(end, false, theme))
.into_any_element()
}
fn range_value_text(
text: impl Into<SharedString>,
filled: bool,
theme: &liora_theme::Theme,
) -> impl IntoElement {
div()
.flex_1()
.min_w(px(0.0))
.px_1()
.text_size(px(theme.font_size.md))
.text_color(if filled {
theme.neutral.text_1
} else {
theme.neutral.text_3
})
.child(text.into())
}
fn render_date_time_panel(
id: SharedString,
picker: Entity<DateTimePicker>,
cx: &mut App,
) -> gpui::AnyElement {
let theme = cx.global::<Config>().theme.clone();
let is_range = picker.update(cx, |picker, _| picker.is_range());
div()
.id(format!("{}-panel", id))
.cursor_default()
.occlude()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.flex()
.flex_col()
.p_3()
.gap_3()
.bg(theme.neutral.card)
.border_1()
.border_color(theme.neutral.border)
.rounded(px(theme.radius.md))
.shadow_lg()
.child(if is_range {
render_range_summary(id.clone(), picker.clone(), cx, &theme).into_any_element()
} else {
div().into_any_element()
})
.child(
div()
.flex()
.gap_3()
.child(render_calendar_panel(id.clone(), picker.clone(), cx))
.child(render_time_panel(id.clone(), picker.clone(), cx)),
)
.child(render_footer(id, picker, &theme))
.into_any_element()
}
fn render_range_summary(
id: SharedString,
picker: Entity<DateTimePicker>,
cx: &mut App,
theme: &liora_theme::Theme,
) -> impl IntoElement {
let (start, end, editing, format, separator) = picker.update(cx, |picker, _| {
(
picker.range_start,
picker.range_end,
picker.editing_endpoint,
picker.display_format.clone(),
picker.range_separator.clone(),
)
});
let start_picker = picker.clone();
let end_picker = picker.clone();
div()
.flex()
.items_center()
.gap_2()
.child(endpoint_chip(
format!("{}-start-chip", id),
"开始",
start
.map(|v| format_date_time_value(v, format.as_ref()))
.unwrap_or_else(|| "未选择".to_string()),
editing == RangeEndpoint::Start,
theme.clone(),
start_picker,
RangeEndpoint::Start,
))
.child(
div()
.px_2()
.py_1()
.rounded(px(theme.radius.sm))
.bg(theme.neutral.hover)
.text_xs()
.text_color(theme.neutral.text_3)
.child(separator),
)
.child(endpoint_chip(
format!("{}-end-chip", id),
"结束",
end.map(|v| format_date_time_value(v, format.as_ref()))
.unwrap_or_else(|| "未选择".to_string()),
editing == RangeEndpoint::End,
theme.clone(),
end_picker,
RangeEndpoint::End,
))
}
fn endpoint_chip(
id: impl Into<SharedString>,
label: &'static str,
value: String,
active: bool,
theme: liora_theme::Theme,
picker: Entity<DateTimePicker>,
endpoint: RangeEndpoint,
) -> impl IntoElement {
div()
.id(id.into())
.flex_1()
.min_w(px(0.0))
.cursor_pointer()
.rounded(px(theme.radius.md))
.border_1()
.border_color(if active {
theme.primary.base
} else {
theme.neutral.border
})
.bg(if active {
theme.primary.light_9
} else {
theme.neutral.body
})
.px_3()
.py_2()
.hover(|s| s.cursor_pointer().border_color(theme.primary.base))
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
picker.update(cx, |picker, cx| picker.edit_endpoint(endpoint, cx));
})
.child(
div()
.text_xs()
.text_color(theme.neutral.text_3)
.child(label),
)
.child(
div()
.text_sm()
.text_color(theme.neutral.text_1)
.child(value),
)
}
fn render_calendar_panel(
id: SharedString,
picker: Entity<DateTimePicker>,
cx: &mut App,
) -> gpui::AnyElement {
let theme = cx.global::<Config>().theme.clone();
let (view_year, view_month, selected, range_start, range_end) =
picker.update(cx, |picker, _| {
(
picker.view_year,
picker.view_month,
picker.value.map(|value| value.date),
picker.range_start.map(|value| value.date),
picker.range_end.map(|value| value.date),
)
});
let days = calendar_cells(view_year, view_month);
let picker_prev_year = picker.clone();
let picker_prev_month = picker.clone();
let picker_next_month = picker.clone();
let picker_next_year = picker.clone();
let weekdays = ["一", "二", "三", "四", "五", "六", "日"];
div()
.w(px(292.0))
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
div()
.flex()
.items_center()
.gap_1()
.child(nav_button(
format!("{}-prev-year", id),
IconName::ChevronsLeft,
theme.neutral.icon,
picker_prev_year,
|picker, cx| picker.shift_year(-1, cx),
))
.child(nav_button(
format!("{}-prev-month", id),
IconName::ChevronLeft,
theme.neutral.icon,
picker_prev_month,
|picker, cx| picker.shift_month(-1, cx),
)),
)
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::BOLD)
.text_color(theme.neutral.text_1)
.child(format!("{} 年 {:02} 月", view_year, view_month)),
)
.child(
div()
.flex()
.items_center()
.gap_1()
.child(nav_button(
format!("{}-next-month", id),
IconName::ChevronRight,
theme.neutral.icon,
picker_next_month,
|picker, cx| picker.shift_month(1, cx),
))
.child(nav_button(
format!("{}-next-year", id),
IconName::ChevronsRight,
theme.neutral.icon,
picker_next_year,
|picker, cx| picker.shift_year(1, cx),
)),
),
)
.child(div().flex().children(weekdays.into_iter().map(|day| {
div()
.flex_1()
.h(px(28.0))
.flex()
.items_center()
.justify_center()
.text_xs()
.text_color(theme.neutral.text_3)
.child(day)
})))
.child(
div()
.flex()
.flex_col()
.children(days.chunks(7).enumerate().map(|(week_idx, week)| {
let id = id.clone();
let week_picker = picker.clone();
let week_theme = theme.clone();
div()
.flex()
.children(week.iter().enumerate().map(move |(day_idx, cell)| {
let is_current_month = cell.month == view_month;
let is_selected = selected == Some(*cell)
|| range_start == Some(*cell)
|| range_end == Some(*cell);
let in_range = is_between(*cell, range_start, range_end);
let picker = week_picker.clone();
let date = *cell;
selectable_date_cell(
format!("{}-day-{}-{}", id, week_idx, day_idx),
cell.day.to_string(),
is_selected,
in_range,
is_current_month,
week_theme.clone(),
picker,
date,
)
}))
})),
)
.into_any_element()
}
fn render_time_panel(
id: SharedString,
picker: Entity<DateTimePicker>,
cx: &mut App,
) -> gpui::AnyElement {
let theme = cx.global::<Config>().theme.clone();
let (selected, minute_step, second_step, show_seconds) = picker.update(cx, |picker, _| {
(
picker.draft_time,
picker.minute_step,
picker.second_step,
picker.show_seconds,
)
});
let hours: Vec<u32> = (0..24).collect();
let minutes = stepped_values(minute_step);
let seconds = stepped_values(second_step);
let preview = if show_seconds {
format!(
"{:02}:{:02}:{:02}",
selected.hour, selected.minute, selected.second
)
} else {
format!("{:02}:{:02}", selected.hour, selected.minute)
};
div()
.w(px(if show_seconds { 276.0 } else { 208.0 }))
.flex()
.flex_col()
.gap_2()
.p_2()
.rounded(px(theme.radius.lg))
.border_1()
.border_color(theme.neutral.border)
.bg(theme.neutral.body)
.child(
div()
.h(px(34.0))
.flex()
.items_center()
.justify_between()
.px_1()
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::BOLD)
.text_color(theme.neutral.text_1)
.child("时间"),
)
.child(
div()
.px_2()
.py_1()
.rounded(px(theme.radius.sm))
.bg(theme.primary.light_9)
.text_sm()
.font_weight(gpui::FontWeight::BOLD)
.text_color(theme.primary.base)
.child(preview),
),
)
.child(
div()
.flex()
.gap_1()
.p_1()
.rounded(px(theme.radius.md))
.border_1()
.border_color(theme.neutral.border)
.bg(theme.neutral.card)
.child(time_column(
format!("{}-hour", id),
"时",
hours,
selected.hour,
&theme,
picker.clone(),
move |current, hour| TimeValue { hour, ..current },
))
.child(time_column(
format!("{}-minute", id),
"分",
minutes,
selected.minute,
&theme,
picker.clone(),
move |current, minute| TimeValue { minute, ..current },
))
.when(show_seconds, |s| {
s.child(time_column(
format!("{}-second", id),
"秒",
seconds,
selected.second,
&theme,
picker.clone(),
move |current, second| TimeValue { second, ..current },
))
}),
)
.into_any_element()
}
fn render_footer(
id: SharedString,
picker: Entity<DateTimePicker>,
theme: &liora_theme::Theme,
) -> impl IntoElement {
let confirm_picker = picker.clone();
let cancel_picker = picker.clone();
div()
.flex()
.items_center()
.justify_end()
.gap_2()
.pt_2()
.child(
div()
.id(format!("{}-cancel", id))
.cursor_pointer()
.px_3()
.py_1()
.rounded(px(theme.radius.sm))
.text_sm()
.text_color(theme.neutral.text_2)
.hover(|s| s.cursor_pointer().bg(theme.neutral.hover))
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
cancel_picker.update(cx, |picker, cx| picker.close(cx));
})
.child("取消"),
)
.child(
div()
.id(format!("{}-confirm", id))
.cursor_pointer()
.px_3()
.py_1()
.rounded(px(theme.radius.sm))
.bg(theme.primary.base)
.text_sm()
.text_color(theme.neutral.card)
.hover(|s| s.cursor_pointer().bg(theme.primary.hover))
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
confirm_picker.update(cx, |picker, cx| picker.confirm(window, cx));
})
.child("确定"),
)
}
fn selectable_date_cell(
id: impl Into<SharedString>,
label: impl Into<SharedString>,
is_selected: bool,
in_range: bool,
is_current_scope: bool,
theme: liora_theme::Theme,
picker: Entity<DateTimePicker>,
date: DateValue,
) -> impl IntoElement {
div()
.id(id.into())
.flex_1()
.h(px(34.0))
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.rounded(px(theme.radius.sm))
.bg(if is_selected {
theme.primary.base
} else if in_range {
theme.primary.light_9
} else {
theme.neutral.card
})
.text_color(if is_selected {
theme.neutral.card
} else if is_current_scope {
theme.neutral.text_1
} else {
theme.neutral.text_3.opacity(0.55)
})
.hover(|s| {
if is_selected {
s.cursor_pointer()
} else {
s.cursor_pointer().bg(theme.neutral.hover)
}
})
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
picker.update(cx, |picker, cx| picker.select_date(date, cx));
})
.child(div().text_sm().child(label.into()))
}
fn nav_button(
id: impl Into<SharedString>,
icon: IconName,
icon_color: Hsla,
picker: Entity<DateTimePicker>,
action: impl Fn(&mut DateTimePicker, &mut Context<DateTimePicker>) + 'static,
) -> impl IntoElement {
div()
.id(id.into())
.cursor_pointer()
.p_1()
.rounded(px(4.0))
.hover(|s| s.cursor_pointer().bg(gpui::black().opacity(0.04)))
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
picker.update(cx, |picker, cx| action(picker, cx));
})
.child(Icon::new(icon).size(px(18.0)).color(icon_color))
}
fn time_column(
id: impl Into<SharedString>,
title: &'static str,
values: Vec<u32>,
selected: u32,
theme: &liora_theme::Theme,
picker: Entity<DateTimePicker>,
build_value: impl Fn(TimeValue, u32) -> TimeValue + Clone + 'static,
) -> impl IntoElement {
let id = id.into();
div()
.flex_1()
.min_w(px(58.0))
.flex()
.flex_col()
.child(
div()
.h(px(24.0))
.flex()
.items_center()
.justify_center()
.text_xs()
.font_weight(gpui::FontWeight::BOLD)
.text_color(theme.neutral.text_3)
.child(title),
)
.child(
div()
.id(format!("{}-scroll", id))
.max_h(px(210.0))
.overflow_y_scroll()
.flex()
.flex_col()
.gap_1()
.children(values.into_iter().map(move |value| {
let is_selected = selected == value;
let picker = picker.clone();
let build_value = build_value.clone();
time_option(
format!("{}-{}", id, value),
value,
is_selected,
theme.clone(),
picker,
build_value,
)
})),
)
}
fn time_option(
id: impl Into<SharedString>,
value: u32,
is_selected: bool,
theme: liora_theme::Theme,
picker: Entity<DateTimePicker>,
build_value: impl Fn(TimeValue, u32) -> TimeValue + 'static,
) -> impl IntoElement {
div()
.id(id.into())
.h(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(theme.radius.sm))
.cursor_pointer()
.bg(if is_selected {
theme.primary.base
} else {
gpui::transparent_black()
})
.text_color(if is_selected {
theme.neutral.card
} else {
theme.neutral.text_1
})
.hover(|s| {
if is_selected {
s.cursor_pointer().bg(theme.primary.hover)
} else {
s.cursor_pointer().bg(theme.neutral.hover)
}
})
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
picker.update(cx, |picker, cx| {
let next = build_value(picker.draft_time, value);
picker.select_time(next, cx);
});
})
.child(
div()
.text_sm()
.font_weight(if is_selected {
gpui::FontWeight::BOLD
} else {
gpui::FontWeight::NORMAL
})
.child(format!("{:02}", value)),
)
}
fn stepped_values(step: u32) -> Vec<u32> {
let step = step.clamp(1, 60) as usize;
(0..60).step_by(step).collect()
}
fn ordered_pair(a: DateTimeValue, b: DateTimeValue) -> (DateTimeValue, DateTimeValue) {
if a <= b { (a, b) } else { (b, a) }
}
fn is_between(value: DateValue, start: Option<DateValue>, end: Option<DateValue>) -> bool {
matches!((start, end), (Some(start), Some(end)) if value > start && value < end)
}
fn format_date_time_value(value: DateTimeValue, format: &str) -> String {
format
.replace("YYYY", &format!("{:04}", value.date.year))
.replace("YY", &format!("{:02}", value.date.year.rem_euclid(100)))
.replace("MM", &format!("{:02}", value.date.month))
.replace("M", &value.date.month.to_string())
.replace("DD", &format!("{:02}", value.date.day))
.replace("D", &value.date.day.to_string())
.replace("HH", &format!("{:02}", value.time.hour))
.replace("H", &value.time.hour.to_string())
.replace("mm", &format!("{:02}", value.time.minute))
.replace("m", &value.time.minute.to_string())
.replace("ss", &format!("{:02}", value.time.second))
.replace("s", &value.time.second.to_string())
}
fn calendar_cells(year: i32, month: u32) -> Vec<DateValue> {
let first_weekday = weekday_monday_based(year, month, 1);
let prev_month_index = year * 12 + month as i32 - 2;
let prev_year = prev_month_index.div_euclid(12);
let prev_month = prev_month_index.rem_euclid(12) as u32 + 1;
let current_days = days_in_month(year, month);
let prev_days = days_in_month(prev_year, prev_month);
let mut cells = Vec::with_capacity(42);
for i in (0..first_weekday).rev() {
cells.push(DateValue {
year: prev_year,
month: prev_month,
day: prev_days - i,
});
}
for day in 1..=current_days {
cells.push(DateValue { year, month, day });
}
let next_month_index = year * 12 + month as i32;
let next_year = next_month_index.div_euclid(12);
let next_month = next_month_index.rem_euclid(12) as u32 + 1;
let mut next_day = 1;
while cells.len() < 42 {
cells.push(DateValue {
year: next_year,
month: next_month,
day: next_day,
});
next_day += 1;
}
cells
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 30,
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn weekday_monday_based(year: i32, month: u32, day: u32) -> u32 {
let mut y = year;
let mut m = month as i32;
if m < 3 {
m += 12;
y -= 1;
}
let k = y % 100;
let j = y / 100;
let h = (day as i32 + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j).rem_euclid(7);
((h + 5) % 7) as u32
}
struct DateTimePickerBoundsCapturer {
picker: Entity<DateTimePicker>,
}
impl IntoElement for DateTimePickerBoundsCapturer {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for DateTimePickerBoundsCapturer {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_id2: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = gpui::Style::default();
style.size.width = gpui::relative(1.0).into();
style.size.height = gpui::relative(1.0).into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_id2: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_rl: &mut Self::RequestLayoutState,
_window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
self.picker.update(cx, |picker, _| {
picker.last_bounds = Some(bounds);
});
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_id2: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_rl: &mut Self::RequestLayoutState,
_ps: &mut Self::PrepaintState,
_window: &mut Window,
_cx: &mut App,
) {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn date_time_picker_width_helpers_set_demo_widths() {
assert_eq!(DateTimePicker::new().width_md().width, Some(px(280.0)));
assert_eq!(DateTimePicker::new().width_lg().width, Some(px(460.0)));
}
}