T06c forms date and time: calendar, date, range, time and date-time pickers, month, quarter, year and week pickers, relative ranges, time zones, durations, cron editor

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 07:27:42 -04:00
1 parent e39d623b58
commit 8b418b8e80
21 files changed
+3397 -146

No files matched your search

+1
View File
@@ -54,6 +54,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui's `test-support` swaps its executor: `block` fails on a real dispatcher. It stays behind this crate's `test-support` feature, never on for the gallery.
- A tracked `FocusHandle` is a Tab stop only when built with `.tab_stop(true)`. `tab_index` on the div reaches only handles gpui makes itself.
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger.
- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`).
- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint.
- Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones.
- Ids inside a reusable component carry its owner's id or `EntityId`. Twin ids share focus and click state.
+4 -1
View File
@@ -24,7 +24,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T05 Buttons & Actions
- [x] T06a Forms · Text
- [x] T06b Forms · Selection
- [ ] T06c Forms · Date & Time
- [x] T06c Forms · Date & Time
- [ ] T06d Forms · Color
- [ ] T06e Forms · Files
- [ ] T06f Forms · Other
@@ -125,3 +125,6 @@ Per-component lines live in `tasks/`. Tags there:
| T06b | 1 | FAIL | kept highlight and cascade trail went out of bounds; keyboard click reclosed MultiSelect and Cascader; disabled rows committed by keyboard; Combobox hid its owner's value; a disabled chosen radio left no Tab stop; a disabled Rating took arrows |
| T06b | 2 | FAIL | a free Combobox cleared the text it mounted with |
| T06b | 3 | PASS | |
| T06c | 1 | FAIL | range preview reseeded the month; disabled pickers still committed; month buttons left the cursor; hour column scrolled before layout; `*/1` day fields were not wildcards; next(0) ran on; clock fell back to UTC; zone offsets went stale; "never runs" overclaimed |
| T06c | 2 | FAIL | the description of an unstarred full day field ignored the OR rule |
| T06c | 3 | PASS | |
+254
View File
@@ -0,0 +1,254 @@
use std::time::Duration;
use ely_gpui_component::forms::{
Calendar, CronEditor, DatePicker, DateRangePicker, DateTimePicker, DurationPicker, MonthPicker,
QuarterPicker, RelativeDatePicker, RelativeRange, TimePicker, TimezoneSelect, WeekPicker,
YearPicker,
};
use gpui::{App, Div, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use jiff::civil::{Date, DateTime, Time, date, time};
use super::{
choices::{keep, set},
text::field,
};
use crate::{
probe::probe,
ui::{code, section, specimen, specimens},
};
/// Today, fixed so captures stay the same from day to day.
const TODAY: Date = date(2026, 9, 25);
fn wide(element: impl IntoElement, width: f32) -> Div {
div().w(px(width)).child(element)
}
pub fn calendar(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let day = keep("calendar-day", || date(2026, 9, 18), window, cx);
let now = *day.read(cx);
section(
"Calendar",
"Arrows move a cursor, Page keys turn the month, Enter picks. Today wears a ring.",
cx,
)
.child(specimen(
format!("picked {now}"),
probe(
"calendar",
Calendar::new("calendar-demo")
.selected(now)
.today(TODAY)
.on_pick(move |picked, _, cx| set(&day, picked, cx)),
),
cx,
))
}
pub fn pickers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let day = keep("picker-day", || Some(date(2026, 10, 2)), window, cx);
let span = keep(
"picker-span",
|| Some((date(2026, 9, 14), date(2026, 9, 18))),
window,
cx,
);
let at = keep("picker-at", || None::<DateTime>, window, cx);
let clock = keep("picker-time", || Some(time(9, 30, 0, 0)), window, cx);
let (day_now, span_now, at_now, clock_now) =
(*day.read(cx), *span.read(cx), *at.read(cx), *clock.read(cx));
section(
"DatePicker / DateRangePicker / DateTimePicker / TimePicker",
"Fields that open a calendar or time columns. Two clicks set a span.",
cx,
)
.child(
specimens()
.child(specimen(
"date",
probe(
"date-picker",
wide(
DatePicker::new("picker-day", day_now)
.today(TODAY)
.on_change(move |picked, _, cx| set(&day, Some(picked), cx)),
260.0,
),
),
cx,
))
.child(specimen(
"range",
probe(
"range-picker",
wide(
DateRangePicker::new("picker-span", span_now)
.today(TODAY)
.on_change(move |picked, _, cx| set(&span, Some(picked), cx)),
260.0,
),
),
cx,
)),
)
.child(
specimens()
.child(specimen(
"date and time",
wide(
DateTimePicker::new("picker-at", at_now)
.today(TODAY)
.on_change(move |picked, _, cx| set(&at, Some(picked), cx)),
260.0,
),
cx,
))
.child(specimen(
"time, 15-minute steps",
probe(
"time-picker",
wide(
TimePicker::new("picker-time", clock_now)
.step(15)
.on_change(move |picked: Time, _, cx| set(&clock, Some(picked), cx)),
260.0,
),
),
cx,
)),
)
}
pub fn periods(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let month = keep("period-month", || Some((2026, 9)), window, cx);
let quarter = keep("period-quarter", || Some((2026, 3)), window, cx);
let year = keep("period-year", || Some(2026), window, cx);
let week = keep("period-week", || Some((2026, 39)), window, cx);
let now = (
*month.read(cx),
*quarter.read(cx),
*year.read(cx),
*week.read(cx),
);
section(
"MonthPicker / YearPicker / QuarterPicker / WeekPicker",
"Pages of periods; arrows move, Page keys turn the page. Weeks follow ISO 8601.",
cx,
)
.child(
specimens()
.child(specimen(
"month",
probe(
"month-picker",
wide(
MonthPicker::new("period-month", now.0)
.today(TODAY)
.on_change(move |picked, _, cx| set(&month, Some(picked), cx)),
200.0,
),
),
cx,
))
.child(specimen(
"quarter",
wide(
QuarterPicker::new("period-quarter", now.1)
.today(TODAY)
.on_change(move |picked, _, cx| set(&quarter, Some(picked), cx)),
200.0,
),
cx,
))
.child(specimen(
"year",
wide(
YearPicker::new("period-year", now.2)
.today(TODAY)
.on_change(move |picked, _, cx| set(&year, Some(picked), cx)),
200.0,
),
cx,
))
.child(specimen(
"week",
wide(
WeekPicker::new("period-week", now.3)
.today(TODAY)
.on_change(move |picked, _, cx| set(&week, Some(picked), cx)),
200.0,
),
cx,
)),
)
}
pub fn more(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let range = keep(
"relative-range",
|| Some(RelativeRange::Last7Days),
window,
cx,
);
let zone = keep(
"zone-name",
|| SharedString::from("Europe/Lisbon"),
window,
cx,
);
let span = keep("duration-span", || Duration::from_secs(90 * 60), window, cx);
let zone_field = field("input-zone", window, cx, |input| {
input.placeholder("Search zones")
});
let (range_now, zone_now, span_now) = (*range.read(cx), zone.read(cx).clone(), *span.read(cx));
section(
"RelativeDatePicker / TimezoneSelect / DurationPicker",
"Spans named from today, every zone the system knows, and hours and minutes.",
cx,
)
.child(
specimens()
.child(specimen(
"relative",
div().w(px(260.0)).child(
RelativeDatePicker::new("relative-range", range_now)
.today(TODAY)
.on_change(move |picked, _, _, cx| set(&range, Some(picked), cx)),
),
cx,
))
.child(specimen(
"time zone",
div().w(px(260.0)).child(
TimezoneSelect::new("zone-name", &zone_field)
.selected(zone_now)
.on_change(move |picked, _, cx| set(&zone, picked.clone(), cx)),
),
cx,
))
.child(specimen(
format!("duration {} min", span_now.as_secs() / 60),
DurationPicker::new("duration-span", span_now)
.on_change(move |picked, _, cx| set(&span, picked, cx)),
cx,
)),
)
}
pub fn cron(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let rule = field("input-cron", window, cx, |input| {
input.placeholder("*/15 9-17 * * 1-5")
});
section(
"CronEditor",
"Five fields, read back in words with the next runs. Presets fill it in.",
cx,
)
.child(probe(
"cron",
div()
.w(px(520.0))
.child(CronEditor::new("cron-rule", &rule).now(TODAY.at(12, 0, 0, 0))),
))
.child(code("CronRule::parse(\"0 9 * * 1-5\")?.next(now, 3)", cx))
}
+41
View File
@@ -1,4 +1,5 @@
mod choices;
mod dates;
mod formats;
mod numbers;
mod pickers;
@@ -180,6 +181,41 @@ const SCRIPT: &[Step] = &[
Step::UpAt("stepper", 76.0, 16.0),
Step::Wait(300),
Step::Shot("values"),
Step::DownAt("calendar", 150.0, 150.0),
Step::UpAt("calendar", 150.0, 150.0),
Step::Key("right"),
Step::Key("pagedown"),
Step::Wait(400),
Step::Shot("calendar"),
Step::Click("date-picker"),
Step::Wait(300),
Step::Shot("date-open"),
Step::Key("right"),
Step::Key("enter"),
Step::Wait(300),
Step::Click("range-picker"),
Step::Wait(300),
Step::Key("enter"),
Step::Key("right"),
Step::Key("right"),
Step::Key("right"),
Step::Wait(200),
Step::Shot("range-open"),
Step::Key("enter"),
Step::Wait(300),
Step::Click("time-picker"),
Step::Wait(300),
Step::Shot("time-open"),
Step::Key("escape"),
Step::Click("month-picker"),
Step::Wait(300),
Step::Shot("month-open"),
Step::Key("escape"),
Step::Click("cron"),
Step::Key("secondary-a"),
Step::Type("*/15 9-17 * * 1-5"),
Step::Wait(200),
Step::Shot("cron"),
Step::Rest,
];
@@ -218,5 +254,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(values::rating(window, cx))
.child(values::sliders(window, cx))
.child(values::dials(window, cx))
.child(dates::calendar(window, cx))
.child(dates::pickers(window, cx))
.child(dates::periods(window, cx))
.child(dates::more(window, cx))
.child(dates::cron(window, cx))
.into_any_element()
}
+9 -40
View File
@@ -9,12 +9,12 @@ use gpui::{
use super::{
Choice,
check::tab_stop,
input::text_size,
options::{OnValues, float, option_row, surface},
select::{field_button, field_text},
};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
theme::{ActiveTheme, ControlSize, IconSize},
};
/// A node of a cascade: a value, its label, and the nodes under it.
@@ -279,26 +279,7 @@ impl RenderOnce for Cascader {
cx,
)
});
div()
.id(self.id)
.track_focus(&focus)
.relative()
.flex()
.items_center()
.gap_2()
.w_full()
.h(theme.control_height(self.size))
.px(theme.control_padding(self.size))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
colors.border_strong
})
.bg(colors.surface)
.text_size(theme.text_size(text_size(self.size)))
.cursor_pointer()
field_button(self.id, &focus, self.size, false, window, cx)
.on_click(move |event, _, cx| {
if matches!(event, ClickEvent::Mouse(_)) {
show(&click, !open, start.clone(), cx)
@@ -339,24 +320,12 @@ impl RenderOnce for Cascader {
cx.stop_propagation();
show(&keys, true, trail, cx);
})
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.text_color(if shown.is_empty() {
colors.fg_subtle
} else {
colors.fg
})
.child(if shown.is_empty() {
self.placeholder
} else {
SharedString::from(shown.join(" / "))
}),
)
.child(field_text(
(!shown.is_empty()).then(|| SharedString::from(shown.join(" / "))),
self.placeholder,
false,
cx,
))
.child(
Icon::new(IconName::ChevronsUpDown)
.size(IconSize::Xs)
+426
View File
@@ -0,0 +1,426 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, ElementId, FocusHandle, FontWeight, InteractiveElement,
IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled, Window, div,
prelude::*, transparent_black,
};
use jiff::{ToSpan, civil::Date};
use super::{
super::{check::tab_stop, options::Run},
zoned_now,
};
use crate::{
buttons::{ButtonVariant, IconButton},
layout::seeded::use_seeded,
motion,
primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
const WEEKDAYS: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
/// Monday of `date`'s week.
pub(crate) fn week_start(date: Date) -> Date {
date.saturating_sub(i64::from(date.weekday().to_monday_zero_offset()).days())
}
/// The 42 days shown for `month`: six weeks, Monday first.
pub(crate) fn month_grid(month: Date) -> Vec<Date> {
let start = week_start(month.first_of_month());
(0..42)
.map(|day| start.saturating_add(day.days()))
.collect()
}
pub(crate) type OnDate = Rc<dyn Fn(Date, &mut Window, &mut App)>;
type OnHover = Rc<dyn Fn(Option<Date>, &mut Window, &mut App)>;
type Turn = Rc<dyn Fn(Date, &mut App)>;
/// A month of days. Arrows move a cursor, Page keys turn the month, Enter picks.
#[derive(IntoElement)]
pub struct Calendar {
id: ElementId,
selected: Option<Date>,
span: Option<(Date, Date)>,
week: bool,
min: Option<Date>,
max: Option<Date>,
today: Option<Date>,
anchor: Option<Date>,
focus: Option<FocusHandle>,
on_pick: Option<OnDate>,
on_hover: Option<OnHover>,
on_escape: Option<Run>,
}
impl Calendar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
selected: None,
span: None,
week: false,
min: None,
max: None,
today: None,
anchor: None,
focus: None,
on_pick: None,
on_hover: None,
on_escape: None,
}
}
pub fn selected(mut self, date: Date) -> Self {
self.selected = Some(date);
self
}
/// Marks a run of days: ink at both ends, a band between.
pub fn span(mut self, start: Date, end: Date) -> Self {
assert!(start <= end, "span {start}..{end} runs backwards");
self.span = Some((start, end));
self
}
/// Shades the whole week under the pointer.
pub fn week(mut self) -> Self {
self.week = true;
self
}
pub fn min(mut self, date: Date) -> Self {
self.min = Some(date);
self
}
pub fn max(mut self, date: Date) -> Self {
self.max = Some(date);
self
}
/// The day ringed as today. Defaults to the system's date.
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn on_pick(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
pub(crate) fn on_hover(mut self, handler: OnHover) -> Self {
self.on_hover = Some(handler);
self
}
/// The day whose month opens first, when the marks move while picking.
pub(crate) fn anchor(mut self, date: Date) -> Self {
self.anchor = Some(date);
self
}
/// Focus owned by a picker that opens this calendar.
pub(crate) fn focus(mut self, handle: FocusHandle) -> Self {
self.focus = Some(handle);
self
}
pub(crate) fn on_escape(mut self, handler: Run) -> Self {
self.on_escape = Some(handler);
self
}
}
impl RenderOnce for Calendar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let today = self.today.unwrap_or_else(|| zoned_now().date());
let anchor = self
.anchor
.or(self.selected)
.or(self.span.map(|(start, _)| start))
.unwrap_or(today);
let month = use_seeded(
(self.id.clone(), "month"),
anchor.first_of_month(),
window,
cx,
);
let cursor = use_seeded((self.id.clone(), "cursor"), anchor, window, cx);
let turns = window.use_keyed_state((self.id.clone(), "turns"), cx, |_, _| (0usize, true));
let hover = window.use_keyed_state((self.id.clone(), "hover"), cx, |_, _| None::<Date>);
let focus = match self.focus {
Some(handle) => handle,
None => tab_stop((self.id.clone(), "focus").into(), true, window, cx),
};
let focused = focus.is_focused(window);
let (shown, at, (turned, forward), hovered) = (
month.read(cx).value,
cursor.read(cx).value,
*turns.read(cx),
*hover.read(cx),
);
let turn: Turn = {
let (month, turns) = (month.clone(), turns.clone());
Rc::new(move |next, cx| {
let first = next.first_of_month();
let current = month.read(cx).value;
if first == current {
return;
}
month.update(cx, |month, cx| {
month.value = first;
cx.notify();
});
turns.update(cx, |(count, forward), _| {
*count += 1;
*forward = first > current;
});
})
};
let (min, max) = (self.min, self.max);
let enabled = move |date: Date| {
min.is_none_or(|min| date >= min) && max.is_none_or(|max| date <= max)
};
let pick: OnDate = {
let (id, on_pick, turn, cursor) =
(self.id.clone(), self.on_pick, turn.clone(), cursor.clone());
Rc::new(move |date, window, cx| {
if !enabled(date) {
return;
}
log::info!("calendar {id:?}: {date}");
cursor.update(cx, |cursor, _| cursor.value = date);
turn(date, cx);
if let Some(on_pick) = &on_pick {
on_pick(date, window, cx);
}
})
};
let theme = cx.theme();
let colors = &theme.colors;
let cell = theme.control_height(ControlSize::Md);
let header = div()
.flex()
.items_center()
.justify_between()
.child(
div()
.pl_1()
.text_size(theme.text_size(TextSize::Base))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(shown.strftime("%B %Y").to_string()),
)
.child(
div()
.flex()
.gap_0p5()
.child({
let (turn, cursor) = (turn.clone(), cursor.clone());
IconButton::new((self.id.clone(), "prev"), IconName::ChevronLeft)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Previous month")
.on_click(move |_, _, cx| {
let next = at.saturating_sub(1.month());
cursor.update(cx, |cursor, _| cursor.value = next);
turn(next, cx)
})
})
.child({
let (turn, cursor) = (turn.clone(), cursor.clone());
IconButton::new((self.id.clone(), "next"), IconName::ChevronRight)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Next month")
.on_click(move |_, _, cx| {
let next = at.saturating_add(1.month());
cursor.update(cx, |cursor, _| cursor.value = next);
turn(next, cx)
})
}),
);
let weekdays = div().grid().grid_cols(7).children(WEEKDAYS.map(|day| {
div()
.flex()
.justify_center()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(day)
}));
let days: Vec<_> = month_grid(shown)
.into_iter()
.enumerate()
.map(|(ix, date)| {
let open = enabled(date);
let chosen = self.selected == Some(date)
|| self
.span
.is_some_and(|(start, end)| date == start || date == end);
let inside = self
.span
.is_some_and(|(start, end)| start < date && date < end)
|| (self.week
&& hovered.is_some_and(|over| week_start(over) == week_start(date)));
let fg = if chosen {
colors.on_accent
} else if !open {
colors.fg_disabled
} else if date.month() != shown.month() {
colors.fg_subtle
} else {
colors.fg
};
let border = if focused && date == at {
colors.focus
} else if date == today && !chosen {
colors.border_strong
} else {
transparent_black()
};
let (pick, hover, on_hover) = (pick.clone(), hover.clone(), self.on_hover.clone());
div()
.id(("day", ix))
.flex()
.items_center()
.justify_center()
.h(cell)
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(border)
.text_size(theme.text_size(TextSize::Sm))
.text_color(fg)
.when(date == today, |day| day.font_weight(FontWeight::SEMIBOLD))
.map(|day| {
if chosen {
day.bg(colors.accent)
} else if inside {
day.bg(colors.active)
} else {
day
}
})
.when(open, |day| {
day.cursor_pointer()
.when(!chosen && !inside, |day| {
day.hover(|style| style.bg(colors.hover))
})
.on_hover(move |over, window, cx| {
if *over {
hover.update(cx, |hover, cx| {
*hover = Some(date);
cx.notify();
});
if let Some(on_hover) = &on_hover {
on_hover(Some(date), window, cx);
}
}
})
.on_click(move |_, window, cx| pick(date, window, cx))
})
.child(date.day().to_string())
})
.collect();
let (leave, on_leave) = (hover, self.on_hover.clone());
let grid = div()
.id((self.id.clone(), "days"))
.grid()
.grid_cols(7)
.gap_0p5()
.on_hover(move |over, window, cx| {
if !*over {
leave.update(cx, |hover, cx| {
*hover = None;
cx.notify();
});
if let Some(on_leave) = &on_leave {
on_leave(None, window, cx);
}
}
})
.children(days);
let grid = if turned == 0 {
grid.into_any_element()
} else {
let travel = if forward {
motion::NUDGE * 3.0
} else {
motion::NUDGE * -3.0
};
grid.with_animation(
("calendar-turn", turned),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
move |grid, t| grid.opacity(t).ml(travel * (1.0 - t)),
)
.into_any_element()
};
let (keys, on_escape, on_move) = (pick, self.on_escape, self.on_hover);
div()
.id(self.id)
.track_focus(&focus)
.flex()
.flex_col()
.gap_2()
.w(cell * 8.0)
.p_2()
.rounded(theme.radius(Radius::Lg))
.on_key_down(move |event, window, cx| {
let next = match event.keystroke.key.as_str() {
"left" => at.saturating_sub(1.day()),
"right" => at.saturating_add(1.day()),
"up" => at.saturating_sub(1.week()),
"down" => at.saturating_add(1.week()),
"pageup" => at.saturating_sub(1.month()),
"pagedown" => at.saturating_add(1.month()),
"home" => week_start(at),
"end" => week_start(at).saturating_add(6.days()),
"enter" | "space" => {
cx.stop_propagation();
return keys(at, window, cx);
}
"escape" => {
if let Some(on_escape) = &on_escape {
cx.stop_propagation();
on_escape(window, cx);
}
return;
}
_ => return,
};
cx.stop_propagation();
cursor.update(cx, |cursor, cx| {
cursor.value = next;
cx.notify();
});
turn(next, cx);
if let Some(on_move) = &on_move {
on_move(Some(next), window, cx);
}
})
.child(header)
.child(weekdays)
.child(grid)
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::{month_grid, week_start};
#[test]
fn a_month_shows_six_weeks_from_a_monday() {
let days = month_grid(date(2026, 9, 17));
assert_eq!(days.len(), 42);
assert_eq!(days[0], date(2026, 8, 31));
assert_eq!(days[1], date(2026, 9, 1));
assert_eq!(days[41], date(2026, 10, 11));
assert_eq!(week_start(date(2026, 9, 27)), date(2026, 9, 21));
}
}
+119
View File
@@ -0,0 +1,119 @@
use gpui::{
App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled,
Window, div,
};
use jiff::civil::DateTime;
use super::{
super::{Input, TextInput},
CronRule, zoned_now,
};
use crate::{
buttons::{Button, ButtonVariant},
theme::{ActiveTheme, ControlSize, TextSize},
};
const PRESETS: [(&str, &str); 5] = [
("Hourly", "0 * * * *"),
("Daily at 9:00", "0 9 * * *"),
("Weekdays at 9:00", "0 9 * * 1-5"),
("Mondays at 9:00", "0 9 * * 1"),
("Monthly", "0 0 1 * *"),
];
/// A five-field cron rule, read back in words with its next runs.
#[derive(IntoElement)]
pub struct CronEditor {
id: ElementId,
state: Entity<TextInput>,
now: Option<DateTime>,
runs: usize,
}
impl CronEditor {
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
state: state.clone(),
now: None,
runs: 3,
}
}
/// The moment next runs count from. Defaults to the system's clock.
pub fn now(mut self, at: DateTime) -> Self {
self.now = Some(at);
self
}
/// How many next runs to list.
pub fn runs(mut self, count: usize) -> Self {
assert!(count > 0, "list at least one run");
self.runs = count;
self
}
}
impl RenderOnce for CronEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let text = self.state.read(cx).text().trim().to_string();
let rule = (!text.is_empty()).then(|| CronRule::parse(&text));
let now = self.now.unwrap_or_else(|| zoned_now().datetime());
let theme = cx.theme();
let colors = &theme.colors;
let presets = PRESETS.iter().enumerate().map(|(ix, (label, preset))| {
let state = self.state.clone();
Button::new(("preset", ix), *label)
.variant(ButtonVariant::Outline)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
log::info!("cron editor: preset {preset}");
state.update(cx, |input, cx| input.set_text(*preset, cx));
})
});
let (words, color, runs) = match &rule {
None => (
"Five fields: minute, hour, day of month, month, weekday.".to_string(),
colors.fg_subtle,
None,
),
Some(Err(problem)) => (problem.clone(), colors.danger, None),
Some(Ok(rule)) => {
let next = rule.next(now, self.runs);
let runs = if next.is_empty() {
"No run in the next five years.".to_string()
} else {
let times: Vec<String> = next
.iter()
.map(|at| at.strftime("%a %b %-d, %H:%M").to_string())
.collect();
format!("Next: {}", times.join(" \u{00b7} "))
};
(rule.describe(), colors.fg, Some(runs))
}
};
div()
.id(self.id)
.flex()
.flex_col()
.gap_2()
.child(div().flex().flex_wrap().gap_2().children(presets))
.child(
div()
.font_family(theme.mono_family.clone())
.child(Input::new(&self.state).invalid(matches!(rule, Some(Err(_))))),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(color)
.child(words),
)
.children(runs.map(|runs| {
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(runs)
}))
}
}
+86
View File
@@ -0,0 +1,86 @@
mod calendar;
mod cron;
mod period;
mod picker;
mod relative;
mod rule;
mod time;
mod week;
mod zone;
use jiff::{
Timestamp, Zoned,
civil::{Date, Time},
tz::TimeZone,
};
pub use calendar::Calendar;
pub use cron::CronEditor;
pub use period::{MonthPicker, QuarterPicker, YearPicker};
pub use picker::{DatePicker, DateRangePicker};
pub use relative::{RelativeDatePicker, RelativeRange};
pub use rule::CronRule;
pub use time::{DateTimePicker, DurationPicker, TimePicker};
pub use week::WeekPicker;
pub use zone::TimezoneSelect;
/// The system's clock in its own zone. An unknown zone stops here, not in UTC.
pub(crate) fn zoned_now() -> Zoned {
let zone = TimeZone::try_system().unwrap_or_else(|error| {
panic!("the system time zone is unknown; pass a fixed date: {error}")
});
Timestamp::now().to_zoned(zone)
}
/// A day as "Sep 25, 2026".
pub(crate) fn show_date(date: Date) -> String {
date.strftime("%b %-d, %Y").to_string()
}
/// A time as "09:30".
pub(crate) fn show_time(time: Time) -> String {
time.strftime("%H:%M").to_string()
}
/// A span as "Sep 3 – 17, 2026", naming only what differs.
pub(crate) fn show_span(start: Date, end: Date) -> String {
let dash = " \u{2013} ";
if start == end {
show_date(start)
} else if start.year() != end.year() {
format!("{}{dash}{}", show_date(start), show_date(end))
} else if start.month() != end.month() {
format!("{}{dash}{}", start.strftime("%b %-d"), show_date(end))
} else {
format!(
"{}{dash}{}",
start.strftime("%b %-d"),
end.strftime("%-d, %Y")
)
}
}
#[cfg(test)]
mod tests {
use jiff::civil::{date, time};
use super::{show_date, show_span, show_time};
#[test]
fn dates_times_and_spans_read_short() {
assert_eq!(show_date(date(2026, 9, 5)), "Sep 5, 2026");
assert_eq!(show_time(time(9, 5, 0, 0)), "09:05");
assert_eq!(
show_span(date(2026, 9, 3), date(2026, 9, 17)),
"Sep 3 \u{2013} 17, 2026"
);
assert_eq!(
show_span(date(2026, 9, 28), date(2026, 10, 2)),
"Sep 28 \u{2013} Oct 2, 2026"
);
assert_eq!(
show_span(date(2026, 12, 30), date(2027, 1, 2)),
"Dec 30, 2026 \u{2013} Jan 2, 2027"
);
}
}
+468
View File
@@ -0,0 +1,468 @@
use std::rc::Rc;
use gpui::{
App, Div, ElementId, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Stateful, StatefulInteractiveElement, Styled, Window, div,
prelude::*,
};
use jiff::civil::Date;
use super::{
super::options::{Run, float, surface},
picker::{Face, dropdown, picker_field},
zoned_now,
};
use crate::{
buttons::{ButtonVariant, IconButton},
layout::seeded::use_seeded,
primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
/// What a period grid shows: a page title, cells, and which one is chosen and which is now.
struct Page {
title: String,
cells: Vec<String>,
chosen: Option<usize>,
now: Option<usize>,
columns: u16,
}
type Turn = Rc<dyn Fn(i16, &mut Window, &mut App)>;
type PickPeriod = Rc<dyn Fn(i16, usize, &mut Window, &mut App)>;
type OnYear = Rc<dyn Fn(i16, &mut Window, &mut App)>;
type PickCell = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// What a period grid does: turn the page, pick a cell, close, and the focus it keeps.
struct Grid {
turn: Turn,
pick: PickCell,
close: Run,
focus: FocusHandle,
}
/// A page of periods under a header with arrows. Arrow keys move, Page keys turn, Enter picks.
fn period_grid(id: &ElementId, page: Page, grid: Grid, window: &mut Window, cx: &mut App) -> Div {
let Grid {
turn,
pick,
close,
focus,
} = grid;
let focus = &focus;
let count = page.cells.len();
let cursor = use_seeded((id.clone(), "cursor"), page.chosen.unwrap_or(0), window, cx);
let at = cursor.read(cx).value.min(count - 1);
let focused = focus.is_focused(window);
let theme = cx.theme();
let colors = &theme.colors;
let arrow = |key: &'static str, icon: IconName, by: i16| {
let turn = turn.clone();
IconButton::new((id.clone(), key), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| turn(by, window, cx))
};
let cells: Vec<_> = page
.cells
.into_iter()
.enumerate()
.map(|(ix, label)| {
let pick = pick.clone();
let chosen = page.chosen == Some(ix);
div()
.id(("period", ix))
.flex()
.items_center()
.justify_center()
.h(theme.control_height(ControlSize::Md))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused && ix == at {
colors.focus
} else if page.now == Some(ix) && !chosen {
colors.border_strong
} else {
gpui::transparent_black()
})
.cursor_pointer()
.map(|cell| {
if chosen {
cell.bg(colors.accent).text_color(colors.on_accent)
} else {
cell.text_color(colors.fg)
.hover(|style| style.bg(colors.hover))
}
})
.on_click(move |_, window, cx| pick(ix, window, cx))
.child(label)
})
.collect();
let columns = page.columns;
let keys = (cursor.clone(), pick.clone(), turn.clone());
div()
.track_focus(focus)
.flex()
.flex_col()
.gap_2()
.w(theme.control_height(ControlSize::Md) * 8.0)
.p_2()
.text_size(theme.text_size(TextSize::Sm))
.on_key_down(move |event, window, cx| {
let (cursor, pick, turn) = &keys;
let by = i16::try_from(columns).expect("a few columns");
let step = match event.keystroke.key.as_str() {
"left" => -1,
"right" => 1,
"up" => -by,
"down" => by,
"pageup" => return turn(-1, window, cx),
"pagedown" => return turn(1, window, cx),
"enter" | "space" => {
cx.stop_propagation();
return pick(at, window, cx);
}
"escape" => {
cx.stop_propagation();
return close(window, cx);
}
_ => return,
};
cx.stop_propagation();
let next = (at as i16 + step).clamp(0, count as i16 - 1) as usize;
cursor.update(cx, |cursor, cx| {
cursor.value = next;
cx.notify();
});
})
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
div()
.pl_1()
.text_size(theme.text_size(TextSize::Base))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(page.title),
)
.child(
div()
.flex()
.gap_0p5()
.child(arrow("prev", IconName::ChevronLeft, -1))
.child(arrow("next", IconName::ChevronRight, 1)),
),
)
.child(div().grid().grid_cols(columns).gap_1().children(cells))
}
/// A period picker's words, first page, page size, and what its cells mean.
struct Periods {
shown: Option<SharedString>,
placeholder: &'static str,
seed: i16,
step: i16,
build: Rc<dyn Fn(i16) -> Page>,
pick: PickPeriod,
}
/// The field and popup shared by month, quarter and year pickers.
fn period_picker(
id: ElementId,
periods: Periods,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let Periods {
shown,
placeholder,
seed,
step,
build,
pick,
} = periods;
let state = dropdown(&id, window, cx);
let page = use_seeded((id.clone(), "page"), seed, window, cx);
let inner = state.read(cx).inner.clone();
let key = id.clone();
picker_field(
id,
Face {
icon: IconName::Calendar,
shown,
placeholder: placeholder.into(),
size: ControlSize::default(),
disabled: false,
},
move |close, anchor, window, cx| {
let shown = page.read(cx).value;
let turn: Turn = {
let page = page.clone();
Rc::new(move |by, _, cx| {
page.update(cx, |page, cx| {
page.value += by * step;
cx.notify();
})
})
};
let chosen: PickCell = {
let (pick, close) = (pick.clone(), close.clone());
Rc::new(move |ix, window, cx| {
pick(shown, ix, window, cx);
close(window, cx);
})
};
let out = close.clone();
let grid = Grid {
turn,
pick: chosen,
close,
focus: inner.clone(),
};
let grid = period_grid(&key, build(shown), grid, window, cx);
float(
anchor,
6,
surface((key.clone(), "popup"), cx)
.on_mouse_down_out(move |_, window, cx| out(window, cx))
.child(grid),
window,
cx,
)
},
window,
cx,
)
}
fn today(fixed: Option<Date>) -> Date {
fixed.unwrap_or_else(|| zoned_now().date())
}
type OnPeriod = Rc<dyn Fn((i16, i8), &mut Window, &mut App)>;
/// A year's months as a grid. The value is a year and a month, 1 to 12.
#[derive(IntoElement)]
pub struct MonthPicker {
id: ElementId,
value: Option<(i16, i8)>,
today: Option<Date>,
on_change: Option<OnPeriod>,
}
impl MonthPicker {
pub fn new(id: impl Into<ElementId>, value: Option<(i16, i8)>) -> Self {
Self {
id: id.into(),
value,
today: None,
on_change: None,
}
}
/// The date treated as now. Defaults to the system's date.
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn on_change(
mut self,
handler: impl Fn((i16, i8), &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for MonthPicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (value, now) = (self.value, today(self.today));
let on_change = self.on_change;
let periods = Periods {
shown: value
.map(|(year, month)| format!("{} {year}", MONTH_NAMES[month as usize - 1]).into()),
placeholder: "Pick a month",
seed: value.map_or(now.year(), |(year, _)| year),
step: 1,
build: Rc::new(move |year| Page {
title: year.to_string(),
cells: MONTHS.iter().map(|month| month.to_string()).collect(),
chosen: value
.filter(|(chosen, _)| *chosen == year)
.map(|(_, month)| month as usize - 1),
now: (now.year() == year).then(|| now.month() as usize - 1),
columns: 3,
}),
pick: Rc::new(move |year, ix, window, cx| {
log::info!("month picker: {year}-{:02}", ix + 1);
if let Some(on_change) = &on_change {
on_change((year, ix as i8 + 1), window, cx);
}
}),
};
period_picker(self.id, periods, window, cx)
}
}
const MONTH_NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
/// A year's quarters. The value is a year and a quarter, 1 to 4.
#[derive(IntoElement)]
pub struct QuarterPicker {
id: ElementId,
value: Option<(i16, i8)>,
today: Option<Date>,
on_change: Option<OnPeriod>,
}
impl QuarterPicker {
pub fn new(id: impl Into<ElementId>, value: Option<(i16, i8)>) -> Self {
Self {
id: id.into(),
value,
today: None,
on_change: None,
}
}
/// The date treated as now. Defaults to the system's date.
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn on_change(
mut self,
handler: impl Fn((i16, i8), &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for QuarterPicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (value, now) = (self.value, today(self.today));
let on_change = self.on_change;
let quarter = (now.month() as usize - 1) / 3;
let periods = Periods {
shown: value.map(|(year, quarter)| format!("Q{quarter} {year}").into()),
placeholder: "Pick a quarter",
seed: value.map_or(now.year(), |(year, _)| year),
step: 1,
build: Rc::new(move |year| Page {
title: year.to_string(),
cells: (1..=4).map(|quarter| format!("Q{quarter}")).collect(),
chosen: value
.filter(|(chosen, _)| *chosen == year)
.map(|(_, quarter)| quarter as usize - 1),
now: (now.year() == year).then_some(quarter),
columns: 4,
}),
pick: Rc::new(move |year, ix, window, cx| {
log::info!("quarter picker: {year} Q{}", ix + 1);
if let Some(on_change) = &on_change {
on_change((year, ix as i8 + 1), window, cx);
}
}),
};
period_picker(self.id, periods, window, cx)
}
}
/// Twelve years to a page. The value is a year.
#[derive(IntoElement)]
pub struct YearPicker {
id: ElementId,
value: Option<i16>,
today: Option<Date>,
on_change: Option<OnYear>,
}
impl YearPicker {
pub fn new(id: impl Into<ElementId>, value: Option<i16>) -> Self {
Self {
id: id.into(),
value,
today: None,
on_change: None,
}
}
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn on_change(mut self, handler: impl Fn(i16, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
/// The first year of the twelve-year page holding `year`.
pub(crate) fn page_of(year: i16) -> i16 {
year - year.rem_euclid(12)
}
impl RenderOnce for YearPicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (value, now) = (self.value, today(self.today).year());
let on_change = self.on_change;
let periods = Periods {
shown: value.map(|year| year.to_string().into()),
placeholder: "Pick a year",
seed: page_of(value.unwrap_or(now)),
step: 12,
build: Rc::new(move |first| Page {
title: format!("{first} \u{2013} {}", first + 11),
cells: (first..first + 12).map(|year| year.to_string()).collect(),
chosen: value
.and_then(|year| (first..first + 12).position(|candidate| candidate == year)),
now: (first..first + 12).position(|candidate| candidate == now),
columns: 3,
}),
pick: Rc::new(move |first, ix, window, cx| {
let year = first + ix as i16;
log::info!("year picker: {year}");
if let Some(on_change) = &on_change {
on_change(year, window, cx);
}
}),
};
period_picker(self.id, periods, window, cx)
}
}
#[cfg(test)]
mod tests {
use super::page_of;
#[test]
fn twelve_year_pages_start_on_a_multiple_of_twelve() {
assert_eq!(page_of(2026), 2016);
assert_eq!(page_of(2028), 2028);
assert_eq!(page_of(-5), -12);
}
}
+388
View File
@@ -0,0 +1,388 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Bounds, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, RenderOnce, SharedString, Stateful, Styled, Window, canvas,
prelude::*,
};
use jiff::civil::Date;
use super::{
super::{
check::tab_stop,
options::{Run, float, surface},
select::{field_button, field_text},
},
Calendar,
calendar::OnDate,
show_date, show_span,
};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize},
};
type OnSpan = Rc<dyn Fn((Date, Date), &mut Window, &mut App)>;
/// Rows a popped calendar is about as tall as, for placing it.
pub(crate) const CALENDAR_ROWS: usize = 9;
/// A picker's open popup: where it hangs, and the focus its content takes.
pub(crate) struct Dropdown {
pub open: bool,
pub anchor: Bounds<Pixels>,
pub inner: FocusHandle,
pub start: Option<Date>,
pub preview: Option<Date>,
}
pub(crate) fn dropdown(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Dropdown> {
window.use_keyed_state((id.clone(), "dropdown"), cx, |_, cx| Dropdown {
open: false,
anchor: Bounds::default(),
inner: cx.focus_handle(),
start: None,
preview: None,
})
}
/// Opens or closes, moving focus into the popup or back to `trigger`.
pub(crate) fn toggle(
state: &Entity<Dropdown>,
open: bool,
trigger: &FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let inner = state.read(cx).inner.clone();
state.update(cx, |state, cx| {
state.open = open;
state.start = None;
state.preview = None;
cx.notify();
});
window.focus(if open { &inner } else { trigger });
}
/// What a picker field shows while closed, and how it sits.
pub(crate) struct Face {
pub icon: IconName,
pub shown: Option<SharedString>,
pub placeholder: SharedString,
pub size: ControlSize,
pub disabled: bool,
}
/// The trigger of a date or time picker: an icon, the value, and the popup when open.
pub(crate) fn picker_field(
id: ElementId,
face: Face,
popup: impl FnOnce(Run, Bounds<Pixels>, &mut Window, &mut App) -> AnyElement,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let Face {
icon,
shown,
placeholder,
size,
disabled,
} = face;
let trigger = tab_stop((id.clone(), "focus").into(), !disabled, window, cx);
let state = dropdown(&id, window, cx);
let (open, anchor, inner) = {
let state = state.read(cx);
(state.open, state.anchor, state.inner.clone())
};
if open && disabled {
log::info!("picker {id:?}: closed when disabled");
toggle(&state, false, &trigger, window, cx);
} else if open && !trigger.is_focused(window) && !inner.contains_focused(window, cx) {
log::info!("picker {id:?}: closed on blur");
state.update(cx, |state, _| state.open = false);
}
let open = state.read(cx).open;
let close: Run = {
let (state, trigger) = (state.clone(), trigger.clone());
Rc::new(move |window, cx| toggle(&state, false, &trigger, window, cx))
};
let popup = open.then(|| popup(close, anchor, window, cx));
let subtle = cx.theme().colors.fg_subtle;
let (click, keys, measure, focus) = (state.clone(), state.clone(), state, trigger.clone());
field_button(id, &trigger, size, disabled, window, cx)
.when(!disabled, |field| {
let focus_keys = focus.clone();
field
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
toggle(&click, !open, &focus, window, cx);
})
.on_key_down(move |event, window, cx| {
if !open && matches!(event.keystroke.key.as_str(), "down" | "enter" | "space") {
cx.stop_propagation();
toggle(&keys, true, &focus_keys, window, cx);
}
})
})
.child(Icon::new(icon).size(IconSize::Sm).color(subtle))
.child(field_text(shown, placeholder, disabled, cx))
.child(
canvas(
move |bounds, _, cx| {
if measure.read(cx).anchor != bounds {
measure.update(cx, |state, _| state.anchor = bounds);
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.children(popup)
}
/// A field that opens a calendar and shows the day chosen.
#[derive(IntoElement)]
pub struct DatePicker {
id: ElementId,
value: Option<Date>,
placeholder: SharedString,
bounds: (Option<Date>, Option<Date>),
today: Option<Date>,
size: ControlSize,
disabled: bool,
on_change: Option<OnDate>,
}
impl DatePicker {
pub fn new(id: impl Into<ElementId>, value: Option<Date>) -> Self {
Self {
id: id.into(),
value,
placeholder: SharedString::from("Pick a date"),
bounds: (None, None),
today: None,
size: ControlSize::default(),
disabled: false,
on_change: None,
}
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
self
}
/// The earliest and latest days that can be picked.
pub fn range(mut self, min: Option<Date>, max: Option<Date>) -> Self {
self.bounds = (min, max);
self
}
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_change(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
/// A calendar in a popup, with the picker's limits and focus.
pub(crate) fn popped(
id: &ElementId,
state: &Entity<Dropdown>,
(min, max, today): (Option<Date>, Option<Date>, Option<Date>),
cx: &App,
) -> Calendar {
let mut calendar = Calendar::new((id.clone(), "calendar")).focus(state.read(cx).inner.clone());
if let Some(min) = min {
calendar = calendar.min(min);
}
if let Some(max) = max {
calendar = calendar.max(max);
}
if let Some(today) = today {
calendar = calendar.today(today);
}
calendar
}
impl RenderOnce for DatePicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
let (value, limits, on_change) = (
self.value,
(self.bounds.0, self.bounds.1, self.today),
self.on_change,
);
let state = dropdown(&id, window, cx);
picker_field(
self.id,
Face {
icon: IconName::Calendar,
shown: value.map(|date| show_date(date).into()),
placeholder: self.placeholder,
size: self.size,
disabled: self.disabled,
},
move |close, anchor, window, cx| {
let mut calendar = popped(&id, &state, limits, cx);
if let Some(value) = value {
calendar = calendar.selected(value);
}
let (escape, done) = (close.clone(), close.clone());
float(
anchor,
CALENDAR_ROWS,
surface((id.clone(), "popup"), cx)
.on_mouse_down_out(move |_, window, cx| close(window, cx))
.child(calendar.on_escape(escape).on_pick(move |date, window, cx| {
if let Some(on_change) = &on_change {
on_change(date, window, cx);
}
done(window, cx);
})),
window,
cx,
)
},
window,
cx,
)
}
}
/// A field that picks a first and a last day. Two clicks set the span.
#[derive(IntoElement)]
pub struct DateRangePicker {
id: ElementId,
value: Option<(Date, Date)>,
placeholder: SharedString,
today: Option<Date>,
size: ControlSize,
on_change: Option<OnSpan>,
}
impl DateRangePicker {
pub fn new(id: impl Into<ElementId>, value: Option<(Date, Date)>) -> Self {
Self {
id: id.into(),
value,
placeholder: SharedString::from("Pick dates"),
today: None,
size: ControlSize::default(),
on_change: None,
}
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
self
}
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn on_change(
mut self,
handler: impl Fn((Date, Date), &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
/// The span between two picks, first day first.
pub(crate) fn ordered(a: Date, b: Date) -> (Date, Date) {
(a.min(b), a.max(b))
}
impl RenderOnce for DateRangePicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
let (value, today, on_change) = (self.value, self.today, self.on_change);
let state = dropdown(&id, window, cx);
picker_field(
self.id,
Face {
icon: IconName::CalendarRange,
shown: value.map(|(start, end)| show_span(start, end).into()),
placeholder: self.placeholder,
size: self.size,
disabled: false,
},
move |close, anchor, window, cx| {
let (start, preview) = (state.read(cx).start, state.read(cx).preview);
let span = match start {
Some(start) => Some(ordered(start, preview.unwrap_or(start))),
None => value,
};
let mut calendar = popped(&id, &state, (None, None, today), cx);
if let Some((first, last)) = span {
calendar = calendar.span(first, last);
}
if let Some(anchor) = start.or(value.map(|(first, _)| first)) {
calendar = calendar.anchor(anchor);
}
let (hover, pick, escape) = (state.clone(), state.clone(), close.clone());
float(
anchor,
CALENDAR_ROWS,
surface((id.clone(), "popup"), cx)
.on_mouse_down_out(move |_, window, cx| close(window, cx))
.child(
calendar
.on_escape(escape.clone())
.on_hover(Rc::new(move |date, _, cx| {
hover.update(cx, |state, cx| {
state.preview = date;
cx.notify();
})
}))
.on_pick(move |date, window, cx| {
let Some(first) = pick.read(cx).start else {
pick.update(cx, |state, cx| {
state.start = Some(date);
cx.notify();
});
return;
};
let span = ordered(first, date);
log::info!("date range picker: {} to {}", span.0, span.1);
if let Some(on_change) = &on_change {
on_change(span, window, cx);
}
escape(window, cx);
}),
),
window,
cx,
)
},
window,
cx,
)
}
}
+176
View File
@@ -0,0 +1,176 @@
use std::rc::Rc;
use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window};
use jiff::{ToSpan, civil::Date};
use super::{
super::{Choice, Select},
show_span, zoned_now,
};
use crate::theme::ControlSize;
/// A span named from today, such as the last seven days.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RelativeRange {
Today,
Yesterday,
Last7Days,
Last30Days,
ThisMonth,
LastMonth,
ThisQuarter,
ThisYear,
}
impl RelativeRange {
pub const ALL: [Self; 8] = [
Self::Today,
Self::Yesterday,
Self::Last7Days,
Self::Last30Days,
Self::ThisMonth,
Self::LastMonth,
Self::ThisQuarter,
Self::ThisYear,
];
pub fn label(self) -> &'static str {
match self {
Self::Today => "Today",
Self::Yesterday => "Yesterday",
Self::Last7Days => "Last 7 days",
Self::Last30Days => "Last 30 days",
Self::ThisMonth => "This month",
Self::LastMonth => "Last month",
Self::ThisQuarter => "This quarter",
Self::ThisYear => "This year",
}
}
/// The first and last day it covers, counted from `today`.
pub fn span(self, today: Date) -> (Date, Date) {
let first_of =
|month: i8| Date::new(today.year(), month, 1).expect("the first of a month exists");
match self {
Self::Today => (today, today),
Self::Yesterday => {
let day = today.saturating_sub(1.day());
(day, day)
}
Self::Last7Days => (today.saturating_sub(6.days()), today),
Self::Last30Days => (today.saturating_sub(29.days()), today),
Self::ThisMonth => (today.first_of_month(), today.last_of_month()),
Self::LastMonth => {
let last = today.first_of_month().saturating_sub(1.day());
(last.first_of_month(), last)
}
Self::ThisQuarter => {
let start = first_of((today.month() - 1) / 3 * 3 + 1);
(start, start.saturating_add(2.months()).last_of_month())
}
Self::ThisYear => (first_of(1), first_of(12).last_of_month()),
}
}
}
type OnRange = Rc<dyn Fn(RelativeRange, (Date, Date), &mut Window, &mut App)>;
/// A select of spans named from today; each row shows the days it covers.
#[derive(IntoElement)]
pub struct RelativeDatePicker {
id: ElementId,
value: Option<RelativeRange>,
today: Option<Date>,
size: ControlSize,
on_change: Option<OnRange>,
}
impl RelativeDatePicker {
pub fn new(id: impl Into<ElementId>, value: Option<RelativeRange>) -> Self {
Self {
id: id.into(),
value,
today: None,
size: ControlSize::default(),
on_change: None,
}
}
/// The date treated as today. Defaults to the system's date.
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn on_change(
mut self,
handler: impl Fn(RelativeRange, (Date, Date), &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for RelativeDatePicker {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let today = self.today.unwrap_or_else(|| zoned_now().date());
let choices = RelativeRange::ALL.map(|range| {
let (start, end) = range.span(today);
Choice::new(range.label(), range.label()).note(show_span(start, end))
});
let on_change = self.on_change;
let mut select = Select::new(self.id, choices)
.placeholder("Any time")
.size(self.size)
.on_change(move |label: &SharedString, window, cx| {
let range = RelativeRange::ALL
.into_iter()
.find(|range| range.label() == label.as_ref())
.expect("a label from the list");
if let Some(on_change) = &on_change {
on_change(range, range.span(today), window, cx);
}
});
if let Some(value) = self.value {
select = select.selected(value.label());
}
select
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::RelativeRange;
#[test]
fn spans_count_back_from_today() {
let today = date(2026, 9, 25);
assert_eq!(
RelativeRange::Last7Days.span(today),
(date(2026, 9, 19), today)
);
assert_eq!(
RelativeRange::LastMonth.span(today),
(date(2026, 8, 1), date(2026, 8, 31))
);
assert_eq!(
RelativeRange::ThisQuarter.span(today),
(date(2026, 7, 1), date(2026, 9, 30))
);
assert_eq!(
RelativeRange::ThisYear.span(today),
(date(2026, 1, 1), date(2026, 12, 31))
);
assert_eq!(
RelativeRange::Yesterday.span(date(2026, 3, 1)).0,
date(2026, 2, 28)
);
}
}
+379
View File
@@ -0,0 +1,379 @@
use jiff::{
ToSpan,
civil::{Date, DateTime},
};
const MONTHS: [&str; 12] = [
"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
];
const DAYS: [&str; 7] = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const DAY_NAMES: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const MONTH_NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
/// One field of a rule: the values it allows, whether they are all of them, and whether it began with `*`.
#[derive(Clone, Debug, PartialEq)]
struct Field {
values: Vec<u8>,
full: bool,
star: bool,
every: Option<u8>,
}
impl Field {
fn has(&self, value: u8) -> bool {
self.values.binary_search(&value).is_ok()
}
}
fn number(text: &str, names: &[&str], offset: u8, what: &str) -> Result<u8, String> {
if let Some(ix) = names
.iter()
.position(|name| name.eq_ignore_ascii_case(text))
{
return Ok(ix as u8 + offset);
}
text.parse::<u8>()
.map_err(|_| format!("{what} {text:?} is not a number"))
}
/// Parses one field over `min..=max`: `*`, values, ranges, lists and `/` steps.
fn field(
text: &str,
min: u8,
max: u8,
names: &[&str],
offset: u8,
what: &str,
) -> Result<Field, String> {
if text.is_empty() {
return Err(format!("the {what} field is empty"));
}
let mut values = Vec::new();
let mut every = None;
for part in text.split(',') {
let (span, step) = match part.split_once('/') {
Some((span, step)) => {
let step: u8 = step
.parse()
.ok()
.filter(|step| *step > 0)
.ok_or_else(|| format!("{what} step {step:?} must be a positive number"))?;
(span, step)
}
None => (part, 1),
};
let (low, high) = match span {
"*" => (min, max),
_ => match span.split_once('-') {
Some((low, high)) => (
number(low, names, offset, what)?,
number(high, names, offset, what)?,
),
None => {
let low = number(span, names, offset, what)?;
(low, if step > 1 { max } else { low })
}
},
};
for bound in [low, high] {
if !(min..=max).contains(&bound) {
return Err(format!("{what} {bound} is outside {min} to {max}"));
}
}
if low > high {
return Err(format!("{what} range {low}-{high} runs backwards"));
}
if span == "*" && step > 1 && text.split(',').count() == 1 {
every = Some(step);
}
values.extend((low..=high).step_by(step as usize));
}
values.sort_unstable();
values.dedup();
Ok(Field {
full: values.len() == usize::from(max - min) + 1,
values,
star: text.starts_with('*'),
every,
})
}
/// A standard five-field cron rule: minute, hour, day of month, month, day of week.
#[derive(Clone, Debug, PartialEq)]
pub struct CronRule {
minutes: Field,
hours: Field,
days: Field,
months: Field,
weekdays: Field,
}
impl CronRule {
pub fn parse(source: &str) -> Result<Self, String> {
let parts: Vec<&str> = source.split_whitespace().collect();
let [minute, hour, day, month, weekday] = parts.as_slice() else {
return Err(format!("a rule has five fields, not {}", parts.len()));
};
let mut weekdays = field(weekday, 0, 7, &DAYS, 0, "weekday")?;
for value in &mut weekdays.values {
if *value == 7 {
*value = 0;
}
}
weekdays.values.sort_unstable();
weekdays.values.dedup();
weekdays.full = weekdays.values.len() == 7;
Ok(Self {
minutes: field(minute, 0, 59, &[], 0, "minute")?,
hours: field(hour, 0, 23, &[], 0, "hour")?,
days: field(day, 1, 31, &[], 0, "day")?,
months: field(month, 1, 12, &MONTHS, 1, "month")?,
weekdays,
})
}
/// As in Cronie: a day field that begins with `*` makes both day fields apply; otherwise either one does.
fn runs_on(&self, date: Date) -> bool {
let day = self.days.has(date.day() as u8);
let weekday = self
.weekdays
.has(date.weekday().to_sunday_zero_offset() as u8);
let dated = if self.days.star || self.weekdays.star {
day && weekday
} else {
day || weekday
};
dated && self.months.has(date.month() as u8)
}
/// The next `count` times the rule fires after `after`, looking up to five years ahead.
pub fn next(&self, after: DateTime, count: usize) -> Vec<DateTime> {
let mut found = Vec::new();
if count == 0 {
return found;
}
let mut date = after.date();
for _ in 0..366 * 5 {
if self.runs_on(date) {
for hour in &self.hours.values {
for minute in &self.minutes.values {
let at = date.at(*hour as i8, *minute as i8, 0, 0);
if at > after {
found.push(at);
if found.len() == count {
return found;
}
}
}
}
}
match date.checked_add(1.day()) {
Ok(next) => date = next,
Err(_) => break,
}
}
found
}
/// The rule in plain words, such as "At 09:00, Monday through Friday".
pub fn describe(&self) -> String {
let (minutes, hours) = (&self.minutes, &self.hours);
let time = if minutes.full && hours.full {
"Every minute".to_string()
} else if let Some(every) = minutes.every {
if hours.full {
format!("Every {every} minutes")
} else {
format!("Every {every} minutes during hours {}", list(&hours.values))
}
} else if minutes.values.len() == 1 && hours.values.len() == 1 {
format!("At {:02}:{:02}", hours.values[0], minutes.values[0])
} else if minutes.values.len() == 1 && hours.full {
format!("At minute {} of every hour", minutes.values[0])
} else {
format!(
"At minute {} past hour {}",
list(&minutes.values),
list(&hours.values)
)
};
let weekdays = weekday_words(&self.weekdays.values);
let days = list(&self.days.values);
let both = self.days.star || self.weekdays.star;
let day = match (both, self.days.full, self.weekdays.full) {
(_, true, true) | (false, true, _) | (false, _, true) => String::new(),
(true, true, false) => format!(", {weekdays}"),
(true, false, true) => format!(", on day {days} of the month"),
(true, false, false) => {
format!(", on day {days} of the month if it falls on {weekdays}")
}
(false, false, false) => format!(", on day {days} or {weekdays}"),
};
let month = if self.months.full {
String::new()
} else {
let names: Vec<String> = self
.months
.values
.iter()
.map(|month| MONTH_NAMES[*month as usize - 1].to_string())
.collect();
format!(", in {}", join(&names))
};
format!("{time}{day}{month}")
}
}
/// Sorted values in words, with a run of three or more as "a through b".
fn list(values: &[u8]) -> String {
let mut words = Vec::new();
let mut run = values.iter().copied().peekable();
while let Some(first) = run.next() {
let mut last = first;
while run.peek() == Some(&(last + 1)) {
last = run.next().expect("peeked");
}
match last - first {
0 => words.push(first.to_string()),
1 => words.extend([first.to_string(), last.to_string()]),
_ => words.push(format!("{first} through {last}")),
}
}
join(&words)
}
/// "a", "a and b", "a, b and c".
fn join(words: &[String]) -> String {
match words {
[] => String::new(),
[one] => one.clone(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
}
}
/// Weekdays in words, Monday first, with a run of three or more as a span.
fn weekday_words(values: &[u8]) -> String {
let mut monday_first: Vec<u8> = values.iter().map(|day| (day + 6) % 7).collect();
monday_first.sort_unstable();
let name = |monday_zero: u8| DAY_NAMES[((monday_zero + 1) % 7) as usize].to_string();
let run = monday_first.windows(2).all(|pair| pair[1] == pair[0] + 1);
if run && monday_first.len() >= 3 {
return format!(
"{} through {}",
name(monday_first[0]),
name(monday_first[monday_first.len() - 1])
);
}
join(&monday_first.into_iter().map(name).collect::<Vec<_>>())
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::CronRule;
#[test]
fn rules_parse_lists_ranges_steps_and_names() {
assert!(CronRule::parse("*/15 9-17 * * MON-FRI").is_ok());
assert_eq!(
CronRule::parse("* * *"),
Err("a rule has five fields, not 3".into())
);
assert_eq!(
CronRule::parse("61 * * * *"),
Err("minute 61 is outside 0 to 59".into())
);
assert_eq!(
CronRule::parse("0 5-2 * * *"),
Err("hour range 5-2 runs backwards".into())
);
assert_eq!(
CronRule::parse("*/0 * * * *"),
Err("minute step \"0\" must be a positive number".into())
);
}
#[test]
fn next_runs_follow_the_rule() {
let rule = CronRule::parse("30 9 * * 1-5").unwrap();
let friday_noon = date(2026, 9, 25).at(12, 0, 0, 0);
assert_eq!(
rule.next(friday_noon, 2),
[
date(2026, 9, 28).at(9, 30, 0, 0),
date(2026, 9, 29).at(9, 30, 0, 0)
]
);
let either = CronRule::parse("0 0 1 * 0").unwrap();
assert_eq!(
either.next(date(2026, 9, 25).at(0, 0, 0, 0), 2),
[
date(2026, 9, 27).at(0, 0, 0, 0),
date(2026, 10, 1).at(0, 0, 0, 0)
]
);
let mondays = CronRule::parse("0 0 */1 * MON").unwrap();
assert_eq!(
mondays.next(date(2026, 9, 25).at(0, 0, 0, 0), 1),
[date(2026, 9, 28).at(0, 0, 0, 0)]
);
assert!(either.next(date(2026, 9, 25).at(0, 0, 0, 0), 0).is_empty());
assert!(
CronRule::parse("0 0 30 2 *")
.unwrap()
.next(date(2026, 1, 1).at(0, 0, 0, 0), 1)
.is_empty()
);
}
#[test]
fn rules_read_in_plain_words() {
let words = |rule: &str| CronRule::parse(rule).unwrap().describe();
assert_eq!(words("0 9 * * 1-5"), "At 09:00, Monday through Friday");
assert_eq!(words("*/15 * * * *"), "Every 15 minutes");
assert_eq!(
words("0 0 1 1,7 *"),
"At 00:00, on day 1 of the month, in January and July"
);
assert_eq!(
words("5 * * * 0,6"),
"At minute 5 of every hour, Saturday and Sunday"
);
assert_eq!(
words("*/15 9-17 * * 1-5"),
"Every 15 minutes during hours 9 through 17, Monday through Friday"
);
assert_eq!(words("0 0 1-31 * MON"), "At 00:00");
assert_eq!(words("0 0 1 * 0"), "At 00:00, on day 1 or Sunday");
assert_eq!(
words("0 0 */2 * MON"),
"At 00:00, on day 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29 and 31 of the month if it falls on Monday"
);
assert_eq!(
words("0,30 8-10,14 * * *"),
"At minute 0 and 30 past hour 8 through 10 and 14"
);
}
}
+492
View File
@@ -0,0 +1,492 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, Pixels,
RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, div, point,
prelude::*,
};
use jiff::{
ToSpan,
civil::{Date, DateTime, Time},
};
use super::{
super::{
NumberInput,
options::{Run, float, surface},
},
picker::{CALENDAR_ROWS, Dropdown, Face, dropdown, picker_field, popped},
show_date, show_time, zoned_now,
};
use crate::{
primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
type OnTime = Rc<dyn Fn(Time, &mut Window, &mut App)>;
type OnDateTime = Rc<dyn Fn(DateTime, &mut Window, &mut App)>;
type OnDuration = Rc<dyn Fn(Duration, &mut Window, &mut App)>;
/// Scroll handles of the two columns, and the time they last scrolled to.
#[derive(Default)]
struct Columns {
hours: ScrollHandle,
minutes: ScrollHandle,
shown: Option<Time>,
}
/// `time` moved by `minutes`, wrapping around midnight.
pub(crate) fn nudged(time: Time, minutes: i64) -> Time {
time.wrapping_add(minutes.minutes())
}
/// Hours and minutes as two scrolling columns. A click sets that part.
fn time_columns(
id: &ElementId,
time: Time,
step: u8,
on_pick: OnTime,
window: &mut Window,
cx: &mut App,
) -> Div {
let columns = window.use_keyed_state((id.clone(), "columns"), cx, |_, _| Columns::default());
let row = cx.theme().control_height(ControlSize::Sm);
if columns.read(cx).shown != Some(time) {
let row = row.to_pixels(window.rem_size());
let above = |ix: usize| point(Pixels::ZERO, -(row * ix.saturating_sub(2) as f32));
columns.update(cx, |columns, _| {
columns.hours.set_offset(above(time.hour() as usize));
columns
.minutes
.set_offset(above(time.minute() as usize / step as usize));
columns.shown = Some(time);
});
}
let (hours, minutes) = {
let columns = columns.read(cx);
(columns.hours.clone(), columns.minutes.clone())
};
let theme = cx.theme();
let colors = &theme.colors;
let cell = |key: &'static str, ix: usize, label: String, on: bool, pick: Run| {
div()
.id((key, ix))
.flex()
.flex_none()
.items_center()
.justify_center()
.h(row)
.px_3()
.rounded(theme.radius(Radius::Md))
.cursor_pointer()
.map(|cell| {
if on {
cell.bg(colors.accent).text_color(colors.on_accent)
} else {
cell.text_color(colors.fg)
.hover(|style| style.bg(colors.hover))
}
})
.on_click(move |_, window, cx| pick(window, cx))
.child(label)
};
let column = |key: &'static str, handle: &ScrollHandle| {
div()
.id((id.clone(), key))
.track_scroll(handle)
.overflow_y_scroll()
.max_h(theme.list_max_height())
.p_1()
.flex()
.flex_col()
};
let hour_cells = (0..24).map(|hour| {
let pick = on_pick.clone();
cell(
"hour",
hour as usize,
format!("{hour:02}"),
hour == time.hour(),
Rc::new(move |window, cx| {
pick(
time.with().hour(hour).build().expect("hours run 0 to 23"),
window,
cx,
)
}),
)
});
let minute_cells = (0..60).step_by(step as usize).map(|minute| {
let pick = on_pick.clone();
cell(
"minute",
minute as usize,
format!("{minute:02}"),
minute == time.minute(),
Rc::new(move |window, cx| {
pick(
time.with()
.minute(minute)
.build()
.expect("minutes run 0 to 59"),
window,
cx,
)
}),
)
});
div()
.flex()
.text_size(theme.text_size(TextSize::Sm))
.child(column("hours", &hours).children(hour_cells))
.child(
column("minutes", &minutes)
.border_l_1()
.border_color(colors.border)
.children(minute_cells),
)
}
/// Arrow keys on a time popup move by `step`; Escape and Enter close it.
fn time_keys(
state: &Entity<Dropdown>,
current: Time,
step: u8,
commit: OnTime,
close: Run,
cx: &App,
) -> Div {
div()
.track_focus(&state.read(cx).inner)
.on_key_down(move |event, window, cx| {
let by = match event.keystroke.key.as_str() {
"up" => -i64::from(step),
"down" => i64::from(step),
"escape" | "enter" => {
cx.stop_propagation();
return close(window, cx);
}
_ => return,
};
cx.stop_propagation();
commit(nudged(current, by), window, cx);
})
}
/// A field that opens hour and minute columns. Up and Down move by the step.
#[derive(IntoElement)]
pub struct TimePicker {
id: ElementId,
value: Option<Time>,
step: u8,
placeholder: SharedString,
size: ControlSize,
disabled: bool,
on_change: Option<OnTime>,
}
impl TimePicker {
pub fn new(id: impl Into<ElementId>, value: Option<Time>) -> Self {
Self {
id: id.into(),
value,
step: 5,
placeholder: SharedString::from("Pick a time"),
size: ControlSize::default(),
disabled: false,
on_change: None,
}
}
/// Minutes between choices; divides the hour.
pub fn step(mut self, minutes: u8) -> Self {
assert!(
minutes > 0 && 60 % minutes == 0,
"a {minutes}-minute step does not divide the hour"
);
self.step = minutes;
self
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_change(mut self, handler: impl Fn(Time, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TimePicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, step, value) = (self.id.clone(), self.step, self.value);
let state = dropdown(&id, window, cx);
let commit: OnTime = {
let (id, on_change) = (id.clone(), self.on_change);
Rc::new(move |time, window, cx| {
log::info!("time picker {id:?}: {time}");
if let Some(on_change) = &on_change {
on_change(time, window, cx);
}
})
};
picker_field(
self.id,
Face {
icon: IconName::Clock,
shown: value.map(|time| show_time(time).into()),
placeholder: self.placeholder,
size: self.size,
disabled: self.disabled,
},
move |close, anchor, window, cx| {
let time = value.unwrap_or(Time::midnight());
let columns = time_columns(&id, time, step, commit.clone(), window, cx);
let out = close.clone();
float(
anchor,
CALENDAR_ROWS,
surface((id.clone(), "popup"), cx)
.on_mouse_down_out(move |_, window, cx| out(window, cx))
.child(time_keys(&state, time, step, commit, close, cx).child(columns)),
window,
cx,
)
},
window,
cx,
)
}
}
/// A day and a time: a calendar beside hour and minute columns.
#[derive(IntoElement)]
pub struct DateTimePicker {
id: ElementId,
value: Option<DateTime>,
step: u8,
placeholder: SharedString,
today: Option<Date>,
size: ControlSize,
on_change: Option<OnDateTime>,
}
impl DateTimePicker {
pub fn new(id: impl Into<ElementId>, value: Option<DateTime>) -> Self {
Self {
id: id.into(),
value,
step: 5,
placeholder: SharedString::from("Pick a date and time"),
today: None,
size: ControlSize::default(),
on_change: None,
}
}
pub fn step(mut self, minutes: u8) -> Self {
assert!(
minutes > 0 && 60 % minutes == 0,
"a {minutes}-minute step does not divide the hour"
);
self.step = minutes;
self
}
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn on_change(
mut self,
handler: impl Fn(DateTime, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for DateTimePicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, step, value, today) = (self.id.clone(), self.step, self.value, self.today);
let state = dropdown(&id, window, cx);
let commit: OnDateTime = {
let (id, on_change) = (id.clone(), self.on_change);
Rc::new(move |at, window, cx| {
log::info!("date time picker {id:?}: {at}");
if let Some(on_change) = &on_change {
on_change(at, window, cx);
}
})
};
picker_field(
self.id,
Face {
icon: IconName::CalendarDays,
shown: value
.map(|at| format!("{} {}", show_date(at.date()), show_time(at.time())).into()),
placeholder: self.placeholder,
size: self.size,
disabled: false,
},
move |close, anchor, window, cx| {
let day = value.map_or_else(
|| today.unwrap_or_else(|| zoned_now().date()),
|at| at.date(),
);
let time = value.map_or(Time::midnight(), |at| at.time());
let mut calendar = popped(&id, &state, (None, None, today), cx);
if let Some(value) = value {
calendar = calendar.selected(value.date());
}
let (on_day, on_time) = (commit.clone(), commit);
let columns = time_columns(
&id,
time,
step,
Rc::new(move |time, window, cx| on_time(day.to_datetime(time), window, cx)),
window,
cx,
);
let out = close.clone();
float(
anchor,
CALENDAR_ROWS,
surface((id.clone(), "popup"), cx)
.flex()
.on_mouse_down_out(move |_, window, cx| out(window, cx))
.child(calendar.on_escape(close).on_pick(move |date, window, cx| {
on_day(date.to_datetime(time), window, cx)
}))
.child(
div()
.border_l_1()
.border_color(cx.theme().colors.border)
.child(columns),
),
window,
cx,
)
},
window,
cx,
)
}
}
/// Hours, minutes, and optionally seconds, each typed or stepped.
#[derive(IntoElement)]
pub struct DurationPicker {
id: ElementId,
value: Duration,
seconds: bool,
on_change: Option<OnDuration>,
}
impl DurationPicker {
pub fn new(id: impl Into<ElementId>, value: Duration) -> Self {
Self {
id: id.into(),
value,
seconds: false,
on_change: None,
}
}
/// Adds a seconds field.
pub fn seconds(mut self) -> Self {
self.seconds = true;
self
}
pub fn on_change(
mut self,
handler: impl Fn(Duration, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
/// Whole hours, minutes and seconds in `total`.
pub(crate) fn parts(total: Duration) -> [u64; 3] {
let secs = total.as_secs();
[secs / 3600, secs / 60 % 60, secs % 60]
}
impl RenderOnce for DurationPicker {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let now = parts(self.value);
let theme = cx.theme();
let width = theme.control_height(ControlSize::Md) * 4.0;
let units: &[(&str, &str, f64)] = if self.seconds {
&[
("hours", "h", 9999.0),
("minutes", "m", 59.0),
("seconds", "s", 59.0),
]
} else {
&[("hours", "h", 9999.0), ("minutes", "m", 59.0)]
};
let fields: Vec<_> = units
.iter()
.enumerate()
.map(|(ix, (key, unit, max))| {
let (id, on_change) = (self.id.clone(), self.on_change.clone());
div().w(width).child(
NumberInput::new((self.id.clone(), *key), now[ix] as f64)
.range(0.0, *max)
.suffix(div().child(*unit))
.on_change(move |value, window, cx| {
let mut next = now;
next[ix] = value as u64;
let total =
Duration::from_secs(next[0] * 3600 + next[1] * 60 + next[2]);
log::info!("duration picker {id:?}: {total:?}");
if let Some(on_change) = &on_change {
on_change(total, window, cx);
}
}),
)
})
.collect();
div().flex().items_center().gap_2().children(fields)
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use jiff::civil::time;
use super::{nudged, parts};
#[test]
fn times_wrap_and_durations_split() {
assert_eq!(nudged(time(23, 55, 0, 0), 10), time(0, 5, 0, 0));
assert_eq!(nudged(time(0, 0, 0, 0), -5), time(23, 55, 0, 0));
assert_eq!(
parts(Duration::from_secs(3 * 3600 + 25 * 60 + 9)),
[3, 25, 9]
);
}
}
+113
View File
@@ -0,0 +1,113 @@
use std::rc::Rc;
use gpui::{App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Window};
use jiff::{
ToSpan,
civil::{Date, ISOWeekDate, Weekday},
};
use super::{
super::options::{float, surface},
picker::{CALENDAR_ROWS, Face, dropdown, picker_field, popped},
};
use crate::{primitives::IconName, theme::ControlSize};
type OnWeek = Rc<dyn Fn((i16, i8), &mut Window, &mut App)>;
/// The Monday of an ISO week.
pub(crate) fn monday_of((year, week): (i16, i8)) -> Date {
ISOWeekDate::new(year, week, Weekday::Monday)
.unwrap_or_else(|error| panic!("week {week} of {year} does not exist: {error}"))
.date()
}
/// A calendar that picks whole weeks. The value is an ISO year and week.
#[derive(IntoElement)]
pub struct WeekPicker {
id: ElementId,
value: Option<(i16, i8)>,
today: Option<Date>,
on_change: Option<OnWeek>,
}
impl WeekPicker {
pub fn new(id: impl Into<ElementId>, value: Option<(i16, i8)>) -> Self {
Self {
id: id.into(),
value,
today: None,
on_change: None,
}
}
/// The date treated as now. Defaults to the system's date.
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
pub fn on_change(
mut self,
handler: impl Fn((i16, i8), &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for WeekPicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, value, fixed, on_change) =
(self.id.clone(), self.value, self.today, self.on_change);
let state = dropdown(&id, window, cx);
picker_field(
self.id,
Face {
icon: IconName::CalendarRange,
shown: value.map(|(year, week)| format!("Week {week}, {year}").into()),
placeholder: "Pick a week".into(),
size: ControlSize::default(),
disabled: false,
},
move |close, anchor, window, cx| {
let mut calendar = popped(&id, &state, (None, None, fixed), cx).week();
if let Some(value) = value {
let monday = monday_of(value);
calendar = calendar.span(monday, monday.saturating_add(6.days()));
}
let (escape, out, on_change) = (close.clone(), close.clone(), on_change.clone());
float(
anchor,
CALENDAR_ROWS,
surface((id.clone(), "popup"), cx)
.on_mouse_down_out(move |_, window, cx| out(window, cx))
.child(calendar.on_escape(escape).on_pick(move |date, window, cx| {
let week = date.iso_week_date();
log::info!("week picker: {}-W{:02}", week.year(), week.week());
if let Some(on_change) = &on_change {
on_change((week.year(), week.week()), window, cx);
}
close(window, cx);
})),
window,
cx,
)
},
window,
cx,
)
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::monday_of;
#[test]
fn iso_weeks_start_on_their_monday() {
assert_eq!(monday_of((2026, 39)), date(2026, 9, 21));
assert_eq!(monday_of((2027, 1)), date(2027, 1, 4));
}
}
+106
View File
@@ -0,0 +1,106 @@
use std::{rc::Rc, sync::LazyLock};
use gpui::{App, ElementId, Entity, IntoElement, RenderOnce, SharedString, Window};
use jiff::{
Timestamp,
tz::{self, Offset, TimeZone},
};
use super::super::{Choice, Combobox, TextInput, options::OnValue};
use crate::typography::format::MINUS;
/// An offset as "UTC+09:00", with a true minus sign west of Greenwich.
pub(crate) fn show_offset(offset: Offset) -> String {
let seconds = offset.seconds();
let sign = if seconds < 0 { MINUS } else { '+' };
let minutes = seconds.unsigned_abs() / 60;
format!("UTC{sign}{:02}:{:02}", minutes / 60, minutes % 60)
}
/// Every zone the system knows, by name.
static ZONES: LazyLock<Vec<(SharedString, TimeZone)>> = LazyLock::new(|| {
let mut zones: Vec<(SharedString, TimeZone)> = tz::db()
.available()
.filter_map(|name| match TimeZone::get(name.as_str()) {
Ok(zone) => Some((SharedString::from(name.as_str().to_string()), zone)),
Err(error) => {
log::error!("timezone select: skipped {}: {error}", name.as_str());
None
}
})
.collect();
zones.sort_by(|a, b| a.0.cmp(&b.0));
log::info!("timezone select: {} zones", zones.len());
zones
});
/// A searchable list of the system's time zones, each with its offset now.
#[derive(IntoElement)]
pub struct TimezoneSelect {
id: ElementId,
state: Entity<TextInput>,
selected: Option<SharedString>,
on_change: Option<OnValue>,
}
impl TimezoneSelect {
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
state: state.clone(),
selected: None,
on_change: None,
}
}
/// A zone by its IANA name, such as `Asia/Tokyo`.
pub fn selected(mut self, zone: impl Into<SharedString>) -> Self {
self.selected = Some(zone.into());
self
}
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TimezoneSelect {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let now = Timestamp::now();
let zones = ZONES.iter().map(|(name, zone)| {
Choice::new(name.clone(), name.clone()).note(show_offset(zone.to_offset(now)))
});
let mut combo = Combobox::new(self.id, &self.state, zones);
if let Some(zone) = self.selected {
combo = combo.selected(zone);
}
if let Some(on_change) = self.on_change {
combo = combo.on_change(move |zone, window, cx| on_change(zone, window, cx));
}
combo
}
}
#[cfg(test)]
mod tests {
use jiff::tz::Offset;
use super::show_offset;
#[test]
fn offsets_read_as_utc_plus_or_minus() {
assert_eq!(
show_offset(Offset::from_seconds(9 * 3600).unwrap()),
"UTC+09:00"
);
assert_eq!(
show_offset(Offset::from_seconds(-(5 * 3600 + 1800)).unwrap()),
"UTC\u{2212}05:30"
);
assert_eq!(show_offset(Offset::UTC), "UTC+00:00");
}
}
+6
View File
@@ -4,6 +4,7 @@ mod check;
mod checked;
mod chips;
mod combobox;
mod date;
mod expression;
mod group;
mod hotkey;
@@ -40,6 +41,11 @@ pub use check::{CheckState, Checkbox, CheckboxGroup};
pub use checked::{EmailInput, UrlInput};
pub use chips::ChoiceChips;
pub use combobox::Combobox;
pub use date::{
Calendar, CronEditor, CronRule, DatePicker, DateRangePicker, DateTimePicker, DurationPicker,
MonthPicker, QuarterPicker, RelativeDatePicker, RelativeRange, TimePicker, TimezoneSelect,
WeekPicker, YearPicker,
};
pub use expression::{ExpressionInput, evaluate, expression_highlights};
pub use group::{InputAddon, InputGroup};
pub use hotkey::HotkeyInput;
+129 -93
View File
@@ -1,9 +1,9 @@
use std::rc::Rc;
use gpui::{
App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, KeyDownEvent, MouseButton,
ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, Styled, Window, canvas, div,
prelude::*,
App, Bounds, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement,
KeyDownEvent, MouseButton, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString,
Stateful, Styled, Window, canvas, div, prelude::*,
};
use super::{
@@ -65,6 +65,66 @@ pub(crate) fn moved(event: &KeyDownEvent, rows: &[Choice], at: usize) -> Option<
}
}
/// The bordered box of a field that opens something: a select, a picker.
pub(crate) fn field_button(
id: impl Into<ElementId>,
focus: &FocusHandle,
size: ControlSize,
disabled: bool,
window: &Window,
cx: &App,
) -> Stateful<Div> {
let theme = cx.theme();
let colors = &theme.colors;
div()
.id(id)
.track_focus(focus)
.relative()
.flex()
.items_center()
.gap_2()
.w_full()
.h(theme.control_height(size))
.px(theme.control_padding(size))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focus.is_focused(window) {
colors.focus
} else {
colors.border_strong
})
.bg(if disabled {
colors.sunken
} else {
colors.surface
})
.text_size(theme.text_size(text_size(size)))
.when(!disabled, |field| field.cursor_pointer())
}
/// A field's value, or its placeholder in a quieter tone; long text ends in an ellipsis.
pub(crate) fn field_text(
text: Option<SharedString>,
placeholder: SharedString,
disabled: bool,
cx: &App,
) -> Div {
let colors = &cx.theme().colors;
let color = match (&text, disabled) {
(_, true) => colors.fg_disabled,
(Some(_), false) => colors.fg,
(None, false) => colors.fg_subtle,
};
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.text_color(color)
.child(text.unwrap_or(placeholder))
}
/// A button that opens a list and shows the one chosen.
#[derive(IntoElement)]
pub struct Select {
@@ -187,97 +247,73 @@ impl RenderOnce for Select {
);
let enter = pick.clone();
let chosen: Vec<SharedString> = shown.iter().map(|choice| choice.value.clone()).collect();
div()
.id(self.id.clone())
.track_focus(&focus)
.relative()
.flex()
.items_center()
.gap_2()
.w_full()
.h(theme.control_height(self.size))
.px(theme.control_padding(self.size))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
colors.border_strong
})
.bg(if self.disabled {
colors.sunken
} else {
colors.surface
})
.text_size(theme.text_size(text_size(self.size)))
.when(!self.disabled, |trigger| {
trigger
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
Picker::show(&toggle, !open, start, cx)
})
.on_key_down(move |event, window, cx| {
let at = keys.read(cx).highlighted;
let key = event.keystroke.key.as_str();
if !open {
if matches!(key, "down" | "up" | "enter" | "space") {
cx.stop_propagation();
Picker::show(&keys, true, start, cx);
}
return;
field_button(
self.id.clone(),
&focus,
self.size,
self.disabled,
window,
cx,
)
.when(!self.disabled, |trigger| {
trigger
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
Picker::show(&toggle, !open, start, cx)
})
.on_key_down(move |event, window, cx| {
let at = keys.read(cx).highlighted;
let key = event.keystroke.key.as_str();
if !open {
if matches!(key, "down" | "up" | "enter" | "space") {
cx.stop_propagation();
Picker::show(&keys, true, start, cx);
}
if let Some(to) = moved(event, &rows, at) {
cx.stop_propagation();
Picker::show(&keys, true, to, cx);
} else if matches!(key, "enter" | "space") {
cx.stop_propagation();
enter(at, window, cx);
} else if key == "escape" {
cx.stop_propagation();
Picker::show(&keys, false, at, cx);
}
})
})
.when_some(shown.and_then(|choice| choice.icon), |trigger, icon| {
trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
})
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.text_color(match (shown, self.disabled) {
(_, true) => colors.fg_disabled,
(Some(_), false) => colors.fg,
(None, false) => colors.fg_subtle,
})
.child(shown.map_or(self.placeholder, |choice| choice.label.clone())),
)
.child(
Icon::new(IconName::ChevronsUpDown)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
.child(measure_anchor(picker))
.when(open, |trigger| {
trigger.child(
Popup {
id: (self.id, "list").into(),
anchor,
rows: &choices,
highlighted: Some(highlighted),
checked: Some(&chosen),
pick,
dismiss: Some(Rc::new(move |_, cx| {
let at = close.read(cx).highlighted;
Picker::show(&close, false, at, cx)
})),
scroll: Some(&scroll),
return;
}
.render(window, cx),
)
})
if let Some(to) = moved(event, &rows, at) {
cx.stop_propagation();
Picker::show(&keys, true, to, cx);
} else if matches!(key, "enter" | "space") {
cx.stop_propagation();
enter(at, window, cx);
} else if key == "escape" {
cx.stop_propagation();
Picker::show(&keys, false, at, cx);
}
})
})
.when_some(shown.and_then(|choice| choice.icon), |trigger, icon| {
trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
})
.child(field_text(
shown.map(|choice| choice.label.clone()),
self.placeholder,
self.disabled,
cx,
))
.child(
Icon::new(IconName::ChevronsUpDown)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
.child(measure_anchor(picker))
.when(open, |trigger| {
trigger.child(
Popup {
id: (self.id, "list").into(),
anchor,
rows: &choices,
highlighted: Some(highlighted),
checked: Some(&chosen),
pick,
dismiss: Some(Rc::new(move |_, cx| {
let at = close.read(cx).highlighted;
Picker::show(&close, false, at, cx)
})),
scroll: Some(&scroll),
}
.render(window, cx),
)
})
}
}
+187
View File
@@ -0,0 +1,187 @@
use gpui::{Context, IntoElement, KeyUpEvent, Keystroke, Render, TestAppContext, Window};
use jiff::civil::{Date, Time, date, time};
use super::setup;
use crate::forms::{Calendar, DatePicker, DateRangePicker, TimePicker};
const TODAY: Date = date(2026, 9, 25);
struct Day {
value: Option<Date>,
disabled: bool,
}
impl Render for Day {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
DatePicker::new("day", self.value)
.today(TODAY)
.disabled(self.disabled)
.on_change(move |picked, _, cx| {
view.update(cx, |view, cx| {
view.value = Some(picked);
cx.notify();
})
})
}
}
#[gpui::test]
fn a_date_picker_opens_and_picks_from_the_keyboard(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Day {
value: Some(date(2026, 9, 10)),
disabled: false,
});
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("enter right enter");
assert_eq!(
view.read_with(cx, |view, _| view.value),
Some(date(2026, 9, 11))
);
}
struct Span {
value: Option<(Date, Date)>,
}
impl Render for Span {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
DateRangePicker::new("span", self.value)
.today(TODAY)
.on_change(move |picked, _, cx| {
view.update(cx, |view, cx| {
view.value = Some(picked);
cx.notify();
})
})
}
}
#[gpui::test]
fn a_range_picker_takes_two_picks_in_either_order(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Span { value: None });
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("enter enter left left enter");
assert_eq!(
view.read_with(cx, |view, _| view.value),
Some((date(2026, 9, 23), TODAY))
);
}
struct Clock {
value: Option<Time>,
}
impl Render for Clock {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
TimePicker::new("clock", self.value)
.step(15)
.on_change(move |picked, _, cx| {
view.update(cx, |view, cx| {
view.value = Some(picked);
cx.notify();
})
})
}
}
#[gpui::test]
fn a_time_picker_steps_with_the_arrows(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Clock {
value: Some(time(9, 30, 0, 0)),
});
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("enter down down");
assert_eq!(
view.read_with(cx, |view, _| view.value),
Some(time(10, 0, 0, 0))
);
}
struct Bounded {
picked: Option<Date>,
max: Option<Date>,
}
impl Render for Bounded {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let calendar = Calendar::new("bounded").selected(TODAY).today(TODAY);
let calendar = match self.max {
Some(max) => calendar.max(max),
None => calendar,
};
calendar.on_pick(move |picked, _, cx| {
view.update(cx, |view, cx| {
view.picked = Some(picked);
cx.notify();
})
})
}
}
#[gpui::test]
fn a_calendar_refuses_days_past_its_max(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Bounded {
picked: None,
max: Some(TODAY),
});
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("right enter");
assert_eq!(view.read_with(cx, |view, _| view.picked), None);
cx.simulate_keystrokes("left enter");
assert_eq!(view.read_with(cx, |view, _| view.picked), Some(TODAY));
}
#[gpui::test]
fn a_picker_disabled_while_open_stops_picking(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Day {
value: Some(date(2026, 9, 10)),
disabled: false,
});
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("enter");
view.update(cx, |view, cx| {
view.disabled = true;
cx.notify();
});
cx.simulate_keystrokes("right enter");
assert_eq!(
view.read_with(cx, |view, _| view.value),
Some(date(2026, 9, 10))
);
}
#[gpui::test]
fn the_month_buttons_carry_the_cursor(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Bounded {
picked: None,
max: None,
});
cx.update(|window, _| {
window.focus_next();
window.focus_next();
window.focus_next();
});
cx.simulate_keystrokes("enter");
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("enter").unwrap(),
});
cx.update(|window, _| {
window.focus_prev();
window.focus_prev();
});
cx.simulate_keystrokes("enter");
assert_eq!(
view.read_with(cx, |view, _| view.picked),
Some(date(2026, 10, 25))
);
}
+1
View File
@@ -1,4 +1,5 @@
mod choices;
mod dates;
use gpui::{
AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke,
+1 -1
View File
@@ -6,7 +6,7 @@ mod page;
mod panes;
mod scroll;
mod scroll_aids;
mod seeded;
pub(crate) mod seeded;
mod sheet;
mod sidebar;
mod split;
+11 -11
View File
@@ -223,17 +223,17 @@
## T06c Forms · Date & Time
- [ ] Calendar
- [ ] DatePicker
- [ ] DateRangePicker
- [ ] TimePicker
- [ ] DateTimePicker
- [ ] MonthPicker / YearPicker / QuarterPicker
- [ ] WeekPicker
- [ ] RelativeDatePicker
- [ ] TimezoneSelect
- [ ] DurationPicker
- [ ] CronEditor
- [x] Calendar
- [x] DatePicker
- [x] DateRangePicker
- [x] TimePicker
- [x] DateTimePicker
- [x] MonthPicker / YearPicker / QuarterPicker
- [x] WeekPicker
- [x] RelativeDatePicker
- [x] TimezoneSelect
- [x] DurationPicker
- [x] CronEditor
## T06d Forms · Color