From 8b418b8e80e95ac1b9e96874a1891104eb384787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 07:27:42 -0400 Subject: [PATCH] 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 --- AGENTS.md | 1 + TASKS.md | 5 +- examples/gallery/pages/forms/dates.rs | 254 +++++++++++++ examples/gallery/pages/forms/mod.rs | 41 +++ src/forms/cascade.rs | 49 +-- src/forms/date/calendar.rs | 426 ++++++++++++++++++++++ src/forms/date/cron.rs | 119 +++++++ src/forms/date/mod.rs | 86 +++++ src/forms/date/period.rs | 468 ++++++++++++++++++++++++ src/forms/date/picker.rs | 388 ++++++++++++++++++++ src/forms/date/relative.rs | 176 +++++++++ src/forms/date/rule.rs | 379 ++++++++++++++++++++ src/forms/date/time.rs | 492 ++++++++++++++++++++++++++ src/forms/date/week.rs | 113 ++++++ src/forms/date/zone.rs | 106 ++++++ src/forms/mod.rs | 6 + src/forms/select.rs | 222 +++++++----- src/forms/tests/dates.rs | 187 ++++++++++ src/forms/tests/mod.rs | 1 + src/layout/mod.rs | 2 +- tasks/ch01-10.md | 22 +- 21 files changed, 3397 insertions(+), 146 deletions(-) create mode 100644 examples/gallery/pages/forms/dates.rs create mode 100644 src/forms/date/calendar.rs create mode 100644 src/forms/date/cron.rs create mode 100644 src/forms/date/mod.rs create mode 100644 src/forms/date/period.rs create mode 100644 src/forms/date/picker.rs create mode 100644 src/forms/date/relative.rs create mode 100644 src/forms/date/rule.rs create mode 100644 src/forms/date/time.rs create mode 100644 src/forms/date/week.rs create mode 100644 src/forms/date/zone.rs create mode 100644 src/forms/tests/dates.rs diff --git a/AGENTS.md b/AGENTS.md index 16067e3..2569b0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TASKS.md b/TASKS.md index b782ce9..1ed211e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/forms/dates.rs b/examples/gallery/pages/forms/dates.rs new file mode 100644 index 0000000..30f5352 --- /dev/null +++ b/examples/gallery/pages/forms/dates.rs @@ -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::, 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)) +} diff --git a/examples/gallery/pages/forms/mod.rs b/examples/gallery/pages/forms/mod.rs index d47005e..4bdf8d7 100644 --- a/examples/gallery/pages/forms/mod.rs +++ b/examples/gallery/pages/forms/mod.rs @@ -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() } diff --git a/src/forms/cascade.rs b/src/forms/cascade.rs index 6bdccfe..7936d69 100644 --- a/src/forms/cascade.rs +++ b/src/forms/cascade.rs @@ -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) diff --git a/src/forms/date/calendar.rs b/src/forms/date/calendar.rs new file mode 100644 index 0000000..6f5fb5f --- /dev/null +++ b/src/forms/date/calendar.rs @@ -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 { + let start = week_start(month.first_of_month()); + (0..42) + .map(|day| start.saturating_add(day.days())) + .collect() +} + +pub(crate) type OnDate = Rc; +type OnHover = Rc, &mut Window, &mut App)>; +type Turn = Rc; + +/// A month of days. Arrows move a cursor, Page keys turn the month, Enter picks. +#[derive(IntoElement)] +pub struct Calendar { + id: ElementId, + selected: Option, + span: Option<(Date, Date)>, + week: bool, + min: Option, + max: Option, + today: Option, + anchor: Option, + focus: Option, + on_pick: Option, + on_hover: Option, + on_escape: Option, +} + +impl Calendar { + pub fn new(id: impl Into) -> 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::); + 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)); + } +} diff --git a/src/forms/date/cron.rs b/src/forms/date/cron.rs new file mode 100644 index 0000000..0c6f875 --- /dev/null +++ b/src/forms/date/cron.rs @@ -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, + now: Option, + runs: usize, +} + +impl CronEditor { + pub fn new(id: impl Into, state: &Entity) -> 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 = 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) + })) + } +} diff --git a/src/forms/date/mod.rs b/src/forms/date/mod.rs new file mode 100644 index 0000000..785e557 --- /dev/null +++ b/src/forms/date/mod.rs @@ -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" + ); + } +} diff --git a/src/forms/date/period.rs b/src/forms/date/period.rs new file mode 100644 index 0000000..f108865 --- /dev/null +++ b/src/forms/date/period.rs @@ -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, + chosen: Option, + now: Option, + columns: u16, +} + +type Turn = Rc; +type PickPeriod = Rc; +type OnYear = Rc; +type PickCell = Rc; + +/// 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, + placeholder: &'static str, + seed: i16, + step: i16, + build: Rc 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
{ + 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 { + fixed.unwrap_or_else(|| zoned_now().date()) +} + +type OnPeriod = Rc; + +/// 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, + on_change: Option, +} + +impl MonthPicker { + pub fn new(id: impl Into, 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, + on_change: Option, +} + +impl QuarterPicker { + pub fn new(id: impl Into, 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, + today: Option, + on_change: Option, +} + +impl YearPicker { + pub fn new(id: impl Into, value: Option) -> 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); + } +} diff --git a/src/forms/date/picker.rs b/src/forms/date/picker.rs new file mode 100644 index 0000000..95a1268 --- /dev/null +++ b/src/forms/date/picker.rs @@ -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; + +/// 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, + pub inner: FocusHandle, + pub start: Option, + pub preview: Option, +} + +pub(crate) fn dropdown(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity { + 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, + 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, + 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, &mut Window, &mut App) -> AnyElement, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + 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, + placeholder: SharedString, + bounds: (Option, Option), + today: Option, + size: ControlSize, + disabled: bool, + on_change: Option, +} + +impl DatePicker { + pub fn new(id: impl Into, value: Option) -> 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) -> Self { + self.placeholder = text.into(); + self + } + + /// The earliest and latest days that can be picked. + pub fn range(mut self, min: Option, max: Option) -> 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, + (min, max, today): (Option, Option, Option), + 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, + size: ControlSize, + on_change: Option, +} + +impl DateRangePicker { + pub fn new(id: impl Into, 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) -> 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, + ) + } +} diff --git a/src/forms/date/relative.rs b/src/forms/date/relative.rs new file mode 100644 index 0000000..3020f99 --- /dev/null +++ b/src/forms/date/relative.rs @@ -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; + +/// A select of spans named from today; each row shows the days it covers. +#[derive(IntoElement)] +pub struct RelativeDatePicker { + id: ElementId, + value: Option, + today: Option, + size: ControlSize, + on_change: Option, +} + +impl RelativeDatePicker { + pub fn new(id: impl Into, value: Option) -> 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) + ); + } +} diff --git a/src/forms/date/rule.rs b/src/forms/date/rule.rs new file mode 100644 index 0000000..3128962 --- /dev/null +++ b/src/forms/date/rule.rs @@ -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, + full: bool, + star: bool, + every: Option, +} + +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 { + if let Some(ix) = names + .iter() + .position(|name| name.eq_ignore_ascii_case(text)) + { + return Ok(ix as u8 + offset); + } + text.parse::() + .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 { + 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 { + 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 { + 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 = 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 = 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::>()) +} + +#[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" + ); + } +} diff --git a/src/forms/date/time.rs b/src/forms/date/time.rs new file mode 100644 index 0000000..3d641d9 --- /dev/null +++ b/src/forms/date/time.rs @@ -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; +type OnDateTime = Rc; +type OnDuration = Rc; + +/// Scroll handles of the two columns, and the time they last scrolled to. +#[derive(Default)] +struct Columns { + hours: ScrollHandle, + minutes: ScrollHandle, + shown: Option