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:
21 files changed
+3397
-146
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
mod choices;
|
||||
mod dates;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke,
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user