From cd5ed088c7903f901a9d6233e138001c2385627c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 02:49:37 -0400 Subject: [PATCH] =?UTF-8?q?Calendar=20=C2=B7=20Months:=20a=20month=20view,?= =?UTF-8?q?=20a=20year=20view,=20event=20chips=20and=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalendarMonthView lays a month in week rows. An event across days lies over them as a bar in the first free lane, cut at the week's edge; the rest sit by the hour as chips, and a crowded day keeps the rest behind "N more". Arrows move a day, Page keys turn the month, and Enter or a double press opens the day; the cursor holds through the owner's echo of a turn. Days sit at sevenths of their row, as the bars do, so text never widens a column. YearView shows twelve small months; a day with something on it wears a dot in its first event's hue. EventChip is an event in a line and EventCard a block with its hours and place. MiniCalendar points to forms::Calendar. Palette::hue is now the one home for a chart color by index, for mail labels and events alike. --- AGENTS.md | 3 +- TASKS.md | 8 +- examples/gallery/pages/calendar/mod.rs | 89 +++++ examples/gallery/pages/calendar/months.rs | 173 +++++++++ examples/gallery/pages/mod.rs | 2 + src/calendar/card.rs | 119 ++++++ src/calendar/chip.rs | 146 +++++++ src/calendar/event.rs | 165 ++++++++ src/calendar/head.rs | 66 ++++ src/calendar/mod.rs | 16 + src/calendar/month.rs | 446 ++++++++++++++++++++++ src/calendar/tests.rs | 267 +++++++++++++ src/calendar/weeks.rs | 213 +++++++++++ src/calendar/year.rs | 353 +++++++++++++++++ src/forms/date/mod.rs | 1 + src/forms/mod.rs | 2 +- src/lib.rs | 1 + src/mail/labels.rs | 6 +- src/theme/calendar.rs | 24 ++ src/theme/mod.rs | 2 + src/theme/palette.rs | 8 + tasks/ch21-30.md | 31 +- 22 files changed, 2121 insertions(+), 20 deletions(-) create mode 100644 examples/gallery/pages/calendar/mod.rs create mode 100644 examples/gallery/pages/calendar/months.rs create mode 100644 src/calendar/card.rs create mode 100644 src/calendar/chip.rs create mode 100644 src/calendar/event.rs create mode 100644 src/calendar/head.rs create mode 100644 src/calendar/mod.rs create mode 100644 src/calendar/month.rs create mode 100644 src/calendar/tests.rs create mode 100644 src/calendar/weeks.rs create mode 100644 src/calendar/year.rs create mode 100644 src/theme/calendar.rs diff --git a/AGENTS.md b/AGENTS.md index ae9fca2..879dc4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires. - A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it. - gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. -- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. A field that reports its words as they change keeps what it sent, so the owner's echo leaves the caret alone (`mail::SignatureEditor`). +- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. A field that reports its words as they change keeps what it sent, so the owner's echo leaves the caret alone (`mail::SignatureEditor`). A cursor inside a seeded view stays while it lies in what is shown, so the owner's echo of a turn leaves it (`calendar::CalendarMonthView`, `YearView`). - The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. gpui's mac window stops its display link while occluded, so a capture advances a moving picture only on the draws the harness forces. - Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one. - The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level. @@ -136,6 +136,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`). - Anything pressable is a Tab stop: a row, card or toggle takes `tab_index(0)` and `focus_ring`, with a 1px border that stays transparent where it shows none; gpui presses it on the release of Enter or Space. - A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). A bare `ListItem` press is the pointer's only. Titled sections of such lists go through `lists::Sections`, for ChannelList, MemberList and MailboxList; each owner builds its rows and keeps its own checks. +- Columns that must line up across rows sit absolutely at fractions of their row, as the bars over them do (`calendar::CalendarMonthView`): with real text, gpui 0.2.2 lets a `flex_1` cell's text widen it, and the test platform's fixed advances hide it. - A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does. - A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up. - A callback field holds a named `type` alias; clippy's type_complexity rejects `Option>`. A chapter shares one alias per shape. diff --git a/TASKS.md b/TASKS.md index 9932f57..e762013 100644 --- a/TASKS.md +++ b/TASKS.md @@ -106,8 +106,11 @@ Per-component lines live in `tasks/`. Tags there: - [x] T29a Mail · Boxes and lists - [x] T29b Mail · Reading - [x] T29c Mail · Writing -- [ ] T29d Mail · Sorting -- [ ] T30 Calendar +- [x] T29d Mail · Sorting +- [ ] T30a Calendar · Months +- [ ] T30b Calendar · Time grid +- [ ] T30c Calendar · Agenda and people +- [ ] T30d Calendar · Editing - [ ] T31 Project — `tasks/ch31-43.md` - [ ] T32 Canvas & Design - [ ] T33 DB & Dev Tools @@ -350,3 +353,4 @@ Per-component lines live in `tasks/`. Tags there: | T29c | 2 | PASS | | | T29d | 1 | FAIL | the time dialog's rule for a time ahead of now had no test; its test found that a picker opened in a dialog panicked on a deferred draw inside another | | T29d | 2 | FAIL | a raise inside a raise took its place in the queue as its namespace, so one appearing before it rebuilt its state and dropped its focus | +| T29d | 3 | PASS | | diff --git a/examples/gallery/pages/calendar/mod.rs b/examples/gallery/pages/calendar/mod.rs new file mode 100644 index 0000000..dd0dbd0 --- /dev/null +++ b/examples/gallery/pages/calendar/mod.rs @@ -0,0 +1,89 @@ +use ely_gpui_component::{ + calendar::Event, + theme::{ActiveTheme, TextSize}, +}; +use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div}; +use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; + +mod months; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 30, + slug: "calendar", + title: "Calendar", + summary: "Days, weeks, months and years, and what happens in them.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::DownAt("calendar-month", 285.0, 230.0), + Step::UpAt("calendar-month", 285.0, 230.0), + Step::Key("right"), + Step::Key("down"), + Step::Wait(150), + Step::Shot("month-cursor"), + Step::Key("pagedown"), + Step::Wait(200), + Step::Shot("month-next"), + Step::Key("pageup"), + Step::DownAt("calendar-year", 150.0, 150.0), + Step::UpAt("calendar-year", 150.0, 150.0), + Step::Key("right"), + Step::Wait(150), + Step::Shot("year-cursor"), +]; + +/// What a demo says it did, under it. +fn quiet(text: SharedString, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(text) +} + +/// The gallery's zone and today in it. +fn here() -> (TimeZone, Date) { + let zone = TimeZone::try_system().expect("the gallery reads the system time zone"); + let today = Timestamp::now().to_zoned(zone.clone()).date(); + (zone, today) +} + +/// A studio's weeks around today: meetings, a crowded day, an offsite, a trip past a weekend, a night shoot past midnight. +fn events(today: Date, zone: &TimeZone) -> Vec { + let day = |offset: i64| today.checked_add(offset.days()).expect("a day nearby"); + let at = |offset: i64, hour: i8, minute: i8| { + day(offset) + .at(hour, minute, 0, 0) + .to_zoned(zone.clone()) + .expect("the zone holds the hour") + .timestamp() + }; + vec![ + Event::timed("standup", "Studio stand-up", at(0, 9, 30), at(0, 9, 45), 0), + Event::timed("review", "Design review", at(0, 11, 0), at(0, 12, 0), 3).place("Model room"), + Event::timed("lunch", "Lunch with Ana", at(0, 12, 30), at(0, 13, 30), 5), + Event::timed("samples", "Plaster samples", at(0, 15, 0), at(0, 16, 0), 2), + Event::timed("client", "Client call", at(0, 17, 0), at(0, 17, 30), 1), + Event::timed("crit", "Crit", at(-3, 14, 0), at(-3, 15, 30), 3).place("Studio 2"), + Event::timed("print", "Print run", at(-9, 10, 0), at(-9, 11, 0), 2), + Event::all_day("offsite", "Studio offsite", day(2), day(4), 4), + Event::timed("night", "Night shoot", at(5, 21, 0), at(6, 2, 0), 6).place("Atrium"), + Event::all_day("trip", "Site visit · Lisbon", day(9), day(13), 7), + Event::timed("models", "Model photos", at(11, 10, 0), at(11, 12, 0), 0), + Event::all_day("launch", "Launch", day(18), day(18), 1), + ] +} + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(months::month(window, cx)) + .child(months::mini(window, cx)) + .child(months::year(window, cx)) + .child(months::marks(cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/calendar/months.rs b/examples/gallery/pages/calendar/months.rs new file mode 100644 index 0000000..8ba7e0d --- /dev/null +++ b/examples/gallery/pages/calendar/months.rs @@ -0,0 +1,173 @@ +use ely_gpui_component::{ + calendar::{CalendarMonthView, Event, EventCard, EventChip, YearView}, + forms::Calendar, +}; +use gpui::{ + App, ElementId, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px, +}; +use jiff::civil::Date; + +use super::{events, here, quiet}; +use crate::{ + probe::probe, + ui::{change, keep, section, set}, +}; + +/// The day picked in the small calendar, the month shown, and what the last press said. +#[derive(Clone)] +struct Viewing { + day: Date, + month: Date, + said: Option, +} + +/// The month shown and the day picked in the small calendar, shared by both sections. +fn viewing(window: &mut Window, cx: &mut App) -> Entity { + let (_, today) = here(); + keep( + "calendar-viewing", + || Viewing { + day: today, + month: today.first_of_month(), + said: None, + }, + window, + cx, + ) +} + +pub fn month(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (zone, today) = here(); + let viewing = viewing(window, cx); + let now = viewing.read(cx).clone(); + let (turned, opened, pressed) = (viewing.clone(), viewing.clone(), viewing); + let view = CalendarMonthView::new("calendar-month", now.month, events(today, &zone)) + .zone(zone) + .on_month(move |first, _, cx| change(&turned, cx, |viewing| viewing.month = first)) + .on_day(move |day, _, cx| { + let said = format!("Opened {}.", day.strftime("%A, %B %-d")); + change(&opened, cx, |viewing| viewing.said = Some(said.into())) + }) + .on_event(move |key, _, cx| { + let said = format!("Pressed {key}."); + change(&pressed, cx, |viewing| viewing.said = Some(said.into())) + }); + section( + "CalendarMonthView", + "A month in week rows. What spans days lies across them, the rest sits by the hour, and a crowded day keeps the rest behind N more. Arrows move a day, Page keys turn the month, and Enter or a double press opens the day.", + cx, + ) + .child(probe( + "calendar-month", + div() + .w(px(800.)) + .flex() + .flex_col() + .gap_3() + .child(view) + .children(now.said.map(|said| quiet(said, cx))), + )) +} + +pub fn mini(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let viewing = viewing(window, cx); + let day = viewing.read(cx).day; + section( + "MiniCalendar → forms::Calendar", + "A month at a glance is forms::Calendar. Here a day picked in it turns the month view above to its month.", + cx, + ) + .child(probe( + "calendar-mini", + div().w(px(252.)).child(Calendar::new("calendar-mini").selected(day).on_pick( + move |day, _, cx| { + change(&viewing, cx, |viewing| { + viewing.day = day; + viewing.month = day.first_of_month(); + }) + }, + )), + )) +} + +pub fn year(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (zone, today) = here(); + let said = keep("calendar-year-said", || None::, window, cx); + let shown = said.read(cx).clone(); + section( + "YearView", + "Twelve small months. A day with something on it wears a dot in its first event's hue; arrows move a day, Page keys a month, and Enter or a double press opens the day.", + cx, + ) + .child(probe( + "calendar-year", + div() + .w(px(900.)) + .flex() + .flex_col() + .gap_3() + .child( + YearView::new("calendar-year", today.year(), events(today, &zone)) + .zone(zone) + .on_day(move |day, _, cx| { + let opened = format!("Opened {}.", day.strftime("%A, %B %-d")); + set(&said, Some(opened.into()), cx) + }), + ) + .children(shown.map(|shown| quiet(shown, cx))), + )) +} + +pub fn marks(cx: &mut App) -> impl IntoElement + use<> { + let (zone, today) = here(); + let events = events(today, &zone); + let event = |key: &str| -> Event { + events + .iter() + .find(|event| event.key.as_ref() == key) + .cloned() + .expect("the demo holds the event") + }; + let chip = |key: &str| { + EventChip::new( + (ElementId::from("calendar-chip"), key.to_string()), + event(key), + ) + .zone(zone.clone()) + }; + let card = |key: &str| { + div().w(px(220.)).h(px(58.)).child( + EventCard::new( + (ElementId::from("calendar-card"), key.to_string()), + event(key), + ) + .zone(zone.clone()), + ) + }; + section( + "EventCard / EventChip", + "An event in a line, by its hour or across whole days, and as a block with its hours and place, the way a time grid holds it.", + cx, + ) + .child(probe( + "calendar-marks", + div() + .flex() + .flex_wrap() + .items_start() + .gap_8() + .child( + div() + .w(px(200.)) + .flex() + .flex_col() + .gap_1() + .child(chip("review")) + .child(chip("offsite")) + .child(chip("night")), + ) + .child(card("review")) + .child(card("night")) + .child(card("offsite")), + )) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index c79ccea..d50a6d0 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,5 +1,6 @@ mod agent; mod buttons; +mod calendar; mod charts; mod chat; mod collab; @@ -73,6 +74,7 @@ pub const ALL: &[Page] = &[ files::PAGE, messaging::PAGE, mail::PAGE, + calendar::PAGE, theme::PAGE, ]; diff --git a/src/calendar/card.rs b/src/calendar/card.rs new file mode 100644 index 0000000..7e49e95 --- /dev/null +++ b/src/calendar/card.rs @@ -0,0 +1,119 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::*, relative, +}; +use jiff::tz::TimeZone; + +use super::{ + chip::tint, + event::{Event, hours}, +}; +use crate::{ + forms::Run, + primitives::FocusRing, + theme::{ActiveTheme, Radius, TextSize}, + typography::{Ellipsis, format}, +}; + +/// An event as a block: a rule in its hue, its title, and under it its hours and place. It fills the box it is given, as a time grid sizes it by the hour. Pressable with `on_click`. +#[derive(IntoElement)] +pub struct EventCard { + id: ElementId, + event: Event, + zone: Option, + on_click: Option, +} + +impl EventCard { + pub fn new(id: impl Into, event: Event) -> Self { + Self { + id: id.into(), + event, + zone: None, + on_click: None, + } + } + + /// The zone its hours read in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for EventCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("event card")); + let theme = cx.theme(); + let colors = &theme.colors; + let tint = tint(&self.event, cx); + let detail = match &self.event.place { + Some(place) => format!("{} · {place}", hours(&self.event, &zone)), + None => hours(&self.event, &zone), + }; + let key = self.event.key.clone(); + div() + .id(self.id) + .debug_selector(move || format!("event-card {key}")) + .relative() + .size_full() + .overflow_hidden() + .flex() + .flex_col() + .gap_0p5() + .pl_2p5() + .pr_1p5() + .py_1() + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(gpui::transparent_black()) + .bg(tint.alpha(0.14)) + .line_height(relative(1.25)) + .when_some(self.on_click, |card, on_click| { + let key = self.event.key.clone(); + card.tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_click(move |_, window, cx| { + log::info!("event card: {key}"); + on_click(window, cx) + }) + }) + .child( + div() + .absolute() + .top_1() + .bottom_1() + .left_1() + .w_0p5() + .rounded_full() + .bg(tint), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(Ellipsis::new(self.event.title.clone())), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(Ellipsis::new(detail)), + ) + } +} diff --git a/src/calendar/chip.rs b/src/calendar/chip.rs new file mode 100644 index 0000000..e963b5c --- /dev/null +++ b/src/calendar/chip.rs @@ -0,0 +1,146 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::*, relative, +}; +use jiff::tz::TimeZone; + +use super::event::{Event, When}; +use crate::{ + forms::Run, + primitives::{FocusRing, tab_stop}, + theme::{ActiveTheme, Radius, TextSize}, + typography::{Ellipsis, format}, +}; + +/// Event `event`'s color: the chart color at its hue. +pub(crate) fn tint(event: &Event, cx: &App) -> Hsla { + cx.theme() + .colors + .hue(event.hue, format_args!("event {}", event.key)) +} + +/// An event in a line: a dot, the hour it starts and its title; or, for whole days, its title on a wash of its color. Pressable with `on_click`. +#[derive(IntoElement)] +pub struct EventChip { + id: ElementId, + event: Event, + zone: Option, + before: bool, + after: bool, + on_click: Option, +} + +impl EventChip { + pub fn new(id: impl Into, event: Event) -> Self { + Self { + id: id.into(), + event, + zone: None, + before: false, + after: false, + on_click: None, + } + } + + /// The zone its hour reads in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// Squares the ends where the event runs on past the chip, as a bar cut at a week's edge. + pub(crate) fn open(mut self, before: bool, after: bool) -> Self { + self.before = before; + self.after = after; + self + } + + pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for EventChip { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("event chip")); + let focus = tab_stop( + (self.id.clone(), "focus").into(), + self.on_click.is_some(), + window, + cx, + ); + let theme = cx.theme(); + let colors = &theme.colors; + let tint = tint(&self.event, cx); + let round = theme.radius(Radius::Sm); + let spans = self.event.spans(&zone); + let hour = match self.event.when { + When::Timed { start, .. } if !spans => { + Some(format::datetime(start, &zone, "%H:%M").expect("a fixed pattern formats")) + } + _ => None, + }; + let key = self.event.key.clone(); + div() + .id(self.id.clone()) + .debug_selector(move || format!("event-chip {key}")) + .track_focus(&focus) + .flex() + .items_center() + .gap_1() + .w_full() + .h(theme.calendar().chip) + .px_1p5() + .rounded(round) + .when(self.before, |chip| chip.rounded_l_none()) + .when(self.after, |chip| chip.rounded_r_none()) + .border_1() + .border_color(gpui::transparent_black()) + .focus_ring(cx) + .text_size(theme.text_size(TextSize::Xs)) + .line_height(relative(1.0)) + .text_color(colors.fg) + .map(|chip| match &hour { + Some(_) => chip.hover(|style| style.bg(colors.hover)), + None => chip.bg(tint.alpha(0.18)).font_weight(FontWeight::MEDIUM), + }) + .when_some(self.on_click, |chip, on_click| { + let key = self.event.key.clone(); + chip.cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_click(move |_, window, cx| { + log::info!("event chip: {key}"); + on_click(window, cx) + }) + }) + .when(hour.is_some(), |chip| { + chip.child( + div() + .flex_none() + .size(theme.status_dot()) + .rounded_full() + .bg(tint), + ) + }) + .children(hour.map(|hour| { + div() + .min_w_0() + .text_color(colors.fg_muted) + .child(Ellipsis::new(hour)) + })) + .child( + div() + .flex_1() + .min_w_0() + .child(Ellipsis::new(self.event.title.clone())), + ) + } +} diff --git a/src/calendar/event.rs b/src/calendar/event.rs new file mode 100644 index 0000000..7bdeed2 --- /dev/null +++ b/src/calendar/event.rs @@ -0,0 +1,165 @@ +use gpui::SharedString; +use jiff::{SignedDuration, Timestamp, civil::Date, tz::TimeZone}; + +use crate::typography::format; + +/// When an event happens: from one moment to a later one, or whole days from the first to the last. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum When { + Timed { start: Timestamp, end: Timestamp }, + AllDay { first: Date, last: Date }, +} + +/// Something on a calendar: its key, its title, when it happens, its hue among the theme's chart colors, and where. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + pub key: SharedString, + pub title: SharedString, + pub when: When, + pub hue: usize, + pub place: Option, +} + +impl Event { + /// From `start` to `end`, which comes later. + pub fn timed( + key: impl Into, + title: impl Into, + start: Timestamp, + end: Timestamp, + hue: usize, + ) -> Self { + let key = key.into(); + assert!(end > start, "event {key}: it ends before it starts"); + Self { + key, + title: title.into(), + when: When::Timed { start, end }, + hue, + place: None, + } + } + + /// Whole days from `first` to `last`. + pub fn all_day( + key: impl Into, + title: impl Into, + first: Date, + last: Date, + hue: usize, + ) -> Self { + let key = key.into(); + assert!( + last >= first, + "event {key}: its last day comes before its first" + ); + Self { + key, + title: title.into(), + when: When::AllDay { first, last }, + hue, + place: None, + } + } + + pub fn place(mut self, place: impl Into) -> Self { + self.place = Some(place.into()); + self + } + + /// The first and last days it touches in `zone`. An end at midnight touches no part of that day. + pub(crate) fn days(&self, zone: &TimeZone) -> (Date, Date) { + match self.when { + When::AllDay { first, last } => (first, last), + When::Timed { start, end } => { + let first = start.to_zoned(zone.clone()).date(); + let before_end = end - SignedDuration::from_nanos(1); + (first, before_end.to_zoned(zone.clone()).date().max(first)) + } + } + } + + /// Whether it shows as a bar across days: all day, or timed past midnight. + pub(crate) fn spans(&self, zone: &TimeZone) -> bool { + let (first, last) = self.days(zone); + matches!(self.when, When::AllDay { .. }) || first != last + } +} + +/// Events with each key asserted once. +pub(crate) fn unique(events: impl IntoIterator) -> Vec { + let events: Vec = events.into_iter().collect(); + for (ix, event) in events.iter().enumerate() { + let twin = events[..ix].iter().any(|other| other.key == event.key); + assert!(!twin, "event {} twice", event.key); + } + events +} + +/// When `event` happens, read in `zone`: its hours, its hours with their days across midnight, all day, or its span of days. +pub(crate) fn hours(event: &Event, zone: &TimeZone) -> String { + let dash = " \u{2013} "; + match event.when { + When::AllDay { first, last } if first == last => "All day".into(), + When::AllDay { first, last } if first.month() == last.month() => { + format!("{}{dash}{}", first.strftime("%b %-d"), last.strftime("%-d")) + } + When::AllDay { first, last } => { + format!( + "{}{dash}{}", + first.strftime("%b %-d"), + last.strftime("%b %-d") + ) + } + When::Timed { start, end } => { + let pattern = match event.spans(zone) { + true => "%a %H:%M", + false => "%H:%M", + }; + let at = |moment| format::datetime(moment, zone, pattern).expect("a fixed pattern"); + format!("{}{dash}{}", at(start), at(end)) + } + } +} + +#[cfg(test)] +mod tests { + use jiff::{Timestamp, civil::date, tz::TimeZone}; + + use super::{Event, hours}; + + fn at(day: i8, hour: i8, minute: i8) -> Timestamp { + date(2026, 9, day) + .at(hour, minute, 0, 0) + .to_zoned(TimeZone::UTC) + .expect("a UTC time") + .timestamp() + } + + #[test] + fn hours_read_by_the_clock_across_midnight_and_by_days() { + let utc = &TimeZone::UTC; + let said = |event: Event| hours(&event, utc); + assert_eq!( + said(Event::timed("a", "A", at(22, 9, 30), at(22, 10, 15), 0)), + "09:30 \u{2013} 10:15" + ); + assert_eq!( + said(Event::timed("b", "B", at(21, 22, 0), at(22, 2, 0), 0)), + "Mon 22:00 \u{2013} Tue 02:00" + ); + let day = |first: i8, last| (date(2026, 9, first), last); + let (first, last) = day(22, date(2026, 9, 22)); + assert_eq!(said(Event::all_day("c", "C", first, last, 0)), "All day"); + let (first, last) = day(22, date(2026, 9, 24)); + assert_eq!( + said(Event::all_day("d", "D", first, last, 0)), + "Sep 22 \u{2013} 24" + ); + let (first, last) = day(29, date(2026, 10, 2)); + assert_eq!( + said(Event::all_day("e", "E", first, last, 0)), + "Sep 29 \u{2013} Oct 2" + ); + } +} diff --git a/src/calendar/head.rs b/src/calendar/head.rs new file mode 100644 index 0000000..b340206 --- /dev/null +++ b/src/calendar/head.rs @@ -0,0 +1,66 @@ +use std::rc::Rc; + +use gpui::{App, Div, ElementId, FontWeight, ParentElement, Styled, Window, div}; +use jiff::civil::Date; + +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, TextSize}, +}; + +pub(crate) type OnDay = Rc; +/// Steps a view back (-1), to today (0) or on (1). +pub(crate) type Step = Rc; + +/// A view's title, and beside it Today and the arrows back and on; the arrows drop below a narrow title. +pub(crate) fn head( + id: &ElementId, + title: String, + back: &str, + on: &str, + step: Step, + cx: &App, +) -> Div { + let theme = cx.theme(); + let button = |name: &str, icon: IconName, tip: &str, by: i32| { + let step = step.clone(); + IconButton::new((id.clone(), name.to_string()), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(tip.to_string()) + .on_click(move |_, window, cx| step(by, window, cx)) + }; + let today = { + let step = step.clone(); + Button::new((id.clone(), "today"), "Today") + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| step(0, window, cx)) + }; + div() + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .text_size(theme.text_size(TextSize::Lg)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.colors.fg) + .child(title), + ) + .child( + div() + .flex_none() + .flex() + .items_center() + .gap_1() + .child(today) + .child(button("back", IconName::ChevronLeft, back, -1)) + .child(button("on", IconName::ChevronRight, on, 1)), + ) +} diff --git a/src/calendar/mod.rs b/src/calendar/mod.rs new file mode 100644 index 0000000..7ee5245 --- /dev/null +++ b/src/calendar/mod.rs @@ -0,0 +1,16 @@ +mod card; +mod chip; +mod event; +mod head; +mod month; +mod weeks; +mod year; + +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use card::EventCard; +pub use chip::EventChip; +pub use event::{Event, When}; +pub use month::CalendarMonthView; +pub use year::YearView; diff --git a/src/calendar/month.rs b/src/calendar/month.rs new file mode 100644 index 0000000..7cc0c9d --- /dev/null +++ b/src/calendar/month.rs @@ -0,0 +1,446 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::*, + relative, rems, +}; +use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; + +use super::{ + chip::EventChip, + event::{Event, unique}, + head::{OnDay, Step, head}, + weeks::{Bar, bars, day_rows, singles, weeks}, +}; +use crate::{ + forms::OnValue, + layout::seeded::use_seeded, + primitives::{FocusRing, tab_stop}, + theme::{ActiveTheme, Radius, TextSize}, + typography::{Ellipsis, format}, +}; + +const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +/// A month of days in week rows, each with its events: what spans days lies across them as bars, the rest by the hour as chips, and what does not fit waits behind "N more". Arrows move a day, Page keys turn the month, and Enter or a double press opens the day. +#[derive(IntoElement)] +pub struct CalendarMonthView { + id: ElementId, + month: Date, + events: Vec, + today: Option, + zone: Option, + on_day: Option, + on_event: Option, + on_month: Option, +} + +impl CalendarMonthView { + pub fn new( + id: impl Into, + month: Date, + events: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + month, + events: unique(events), + today: None, + zone: None, + on_day: None, + on_event: None, + on_month: None, + } + } + + /// The day marked as today; the zone's own otherwise. + pub fn today(mut self, date: Date) -> Self { + self.today = Some(date); + self + } + + /// The zone its days and hours read in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// Gets a day opened: Enter on it, a double press, or its "N more". + pub fn on_day(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self { + self.on_day = Some(Rc::new(handler)); + self + } + + /// Gets the key of an event pressed. + pub fn on_event( + mut self, + handler: impl Fn(&gpui::SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_event = Some(Rc::new(handler)); + self + } + + /// Gets the first day of each month turned to. + pub fn on_month(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self { + self.on_month = Some(Rc::new(handler)); + self + } +} + +/// What a week row draws with: the view's id and zone, its events, today, the cursor, and what presses do. +struct Row<'a> { + id: &'a ElementId, + events: &'a [Event], + zone: &'a TimeZone, + shown: Date, + today: Date, + cursor: Option, + pick: OnDay, + open: OnDay, + on_event: Option, +} + +/// Sizes of a month's rows in pixels: a chip, the gap between rows, and the cell's inset. +struct Metrics { + chip: Pixels, + gap: Pixels, + pad: Pixels, + rows: usize, +} + +impl Metrics { + fn pitch(&self) -> Pixels { + self.chip + self.gap + } + + /// A day's height: its number, then its rows of events. + fn height(&self) -> Pixels { + self.pad * 2.0 + self.chip + self.pitch() * self.rows as f32 + } +} + +impl Row<'_> { + fn chip(&self, id: String, event: &Event) -> EventChip { + let chip = EventChip::new((self.id.clone(), id), event.clone()).zone(self.zone.clone()); + match self.on_event.clone() { + Some(on_event) => { + let key = event.key.clone(); + chip.on_click(move |window, cx| on_event(&key, window, cx)) + } + None => chip, + } + } + + fn bar(&self, bar: &Bar, monday: Date, sizes: &Metrics) -> AnyElement { + let event = &self.events[bar.event]; + let span = (bar.to - bar.from + 1) as f32; + div() + .absolute() + .top(sizes.pad + sizes.pitch() * (bar.lane + 1) as f32) + .left(relative(bar.from as f32 / 7.0)) + .w(relative(span / 7.0)) + .when(!bar.before, |slot| slot.pl_1()) + .when(!bar.after, |slot| slot.pr_1()) + .child( + self.chip(format!("bar-{}-{monday}", event.key), event) + .open(bar.before, bar.after), + ) + .into_any_element() + } + + fn day( + &self, + day: Date, + column: usize, + lanes: usize, + hidden: usize, + sizes: &Metrics, + cx: &App, + ) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + let on = singles(self.events, day, self.zone); + let (shown, more) = day_rows(on.len(), hidden, sizes.rows - lanes); + let label = match day.day() { + 1 => day.strftime("%b %-d").to_string(), + number => number.to_string(), + }; + let fg = if day.first_of_month() == self.shown { + colors.fg + } else { + colors.fg_subtle + }; + let (pick, open) = (self.pick.clone(), self.open.clone()); + let more = (more > 0).then(|| { + let open = self.open.clone(); + div() + .id((self.id.clone(), format!("more-{day}"))) + .debug_selector(move || format!("more {day}")) + .h(sizes.chip) + .flex() + .items_center() + .px_1p5() + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(gpui::transparent_black()) + .focus_ring(cx) + .tab_index(0) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .cursor_pointer() + .hover(|style| style.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| open(day, window, cx)) + .child(div().min_w_0().child(Ellipsis::new(format!("{more} more")))) + }); + div() + .id((self.id.clone(), format!("day-{day}"))) + .debug_selector(move || format!("day {day}")) + .absolute() + .top_0() + .left(relative(column as f32 / 7.0)) + .w(relative(1.0 / 7.0)) + .h_full() + .overflow_hidden() + .p_1() + .flex() + .flex_col() + .gap_0p5() + .border_r_1() + .border_b_1() + .border_color(colors.border) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + if event.click_count == 2 { + open(day, window, cx); + } else { + pick(day, window, cx); + } + }) + .when(self.cursor == Some(day), |cell| { + cell.child( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .border_1() + .border_color(colors.focus), + ) + }) + .child( + div().h(sizes.chip).flex().items_center().child( + div() + .px_1p5() + .rounded_full() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(fg) + .map(|number| match day == self.today { + true => number + .bg(colors.accent) + .text_color(colors.on_accent) + .font_weight(FontWeight::SEMIBOLD), + false => number, + }) + .child(label), + ), + ) + .children((0..lanes).map(|_| div().flex_none().h(sizes.chip))) + .children(on.iter().take(shown).map(|&ix| { + let event = &self.events[ix]; + self.chip(format!("chip-{}", event.key), event) + })) + .children(more) + .into_any_element() + } + + fn week(&self, monday: Date, sizes: &Metrics, cx: &App) -> AnyElement { + let bars = bars(self.events, monday, self.zone); + let lanes = bars + .iter() + .map(|bar| bar.lane + 1) + .max() + .unwrap_or(0) + .min(sizes.rows - 1); + let days = (0..7).map(|column| { + let day = monday + .checked_add((column as i64).days()) + .expect("a week has seven days"); + let hidden = bars + .iter() + .filter(|bar| bar.lane >= lanes && bar.from <= column && column <= bar.to) + .count(); + self.day(day, column, lanes, hidden, sizes, cx) + }); + div() + .relative() + .h(sizes.height()) + .children(days.collect::>()) + .children( + bars.iter() + .filter(|bar| bar.lane < lanes) + .map(|bar| self.bar(bar, monday, sizes)), + ) + .into_any_element() + } +} + +impl RenderOnce for CalendarMonthView { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("calendar month view")); + let today = self + .today + .unwrap_or_else(|| Timestamp::now().to_zoned(zone.clone()).date()); + let month = use_seeded( + (self.id.clone(), "month"), + self.month.first_of_month(), + window, + cx, + ); + let shown = month.read(cx).value; + let start = match today.first_of_month() == shown { + true => today, + false => shown, + }; + let cursor = window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| start); + if cursor.read(cx).first_of_month() != shown { + cursor.update(cx, |cursor, _| *cursor = start); + } + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let at = *cursor.read(cx); + let turn: OnDay = { + let (id, month, on_month) = (self.id.clone(), month.clone(), self.on_month.clone()); + Rc::new(move |day, window, cx| { + let first = day.first_of_month(); + if month.read(cx).value == first { + return; + } + log::info!("calendar month view {id:?}: {}", first.strftime("%B %Y")); + month.update(cx, |month, cx| { + month.value = first; + cx.notify(); + }); + if let Some(on_month) = &on_month { + on_month(first, window, cx); + } + }) + }; + let pick: OnDay = { + let cursor = cursor.clone(); + Rc::new(move |day, window, cx| { + cursor.update(cx, |cursor, cx| { + *cursor = day; + cx.notify(); + }); + turn(day, window, cx); + }) + }; + let open: OnDay = { + let (id, on_day) = (self.id.clone(), self.on_day.clone()); + Rc::new(move |day, window, cx| { + log::info!("calendar month view {id:?}: open {day}"); + if let Some(on_day) = &on_day { + on_day(day, window, cx); + } + }) + }; + let step: Step = { + let pick = pick.clone(); + Rc::new(move |by, window, cx| { + let day = match by { + 0 => today, + by => at + .checked_add(by.months()) + .expect("a month lies on either side"), + }; + pick(day, window, cx) + }) + }; + let keys = { + let (focus, pick, open) = (focus.clone(), pick.clone(), open.clone()); + move |event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App| { + if !focus.is_focused(window) { + return; + } + let by = match event.keystroke.key.as_str() { + "left" => 1.day().negate(), + "right" => 1.day(), + "up" => 1.week().negate(), + "down" => 1.week(), + "pageup" => 1.month().negate(), + "pagedown" => 1.month(), + "enter" => { + cx.stop_propagation(); + return open(at, window, cx); + } + _ => return, + }; + cx.stop_propagation(); + pick(at.checked_add(by).expect("a day nearby"), window, cx) + } + }; + let theme = cx.theme(); + let colors = &theme.colors; + let rem = window.rem_size(); + let sizes = Metrics { + chip: theme.calendar().chip.to_pixels(rem), + gap: rems(0.125).to_pixels(rem), + pad: rems(0.25).to_pixels(rem), + rows: theme.calendar().day_rows, + }; + let row = Row { + id: &self.id, + events: &self.events, + zone: &zone, + shown, + today, + cursor: focus.is_focused(window).then_some(at), + pick, + open, + on_event: self.on_event.clone(), + }; + let weekdays = div().flex().children(WEEKDAYS.map(|name| { + div() + .flex_none() + .w(relative(1.0 / 7.0)) + .px_1p5() + .pb_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(name) + })); + let rows: Vec = weeks(shown) + .into_iter() + .map(|monday| row.week(monday, &sizes, cx)) + .collect(); + let title = shown.strftime("%B %Y").to_string(); + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .child(head( + &self.id, + title, + "Previous month", + "Next month", + step, + cx, + )) + .child( + div().flex().flex_col().child(weekdays).child( + div() + .id((self.id.clone(), "grid")) + .debug_selector(|| "month-grid".into()) + .track_focus(&focus) + .on_key_down(keys) + .border_t_1() + .border_l_1() + .border_color(colors.border) + .children(rows), + ), + ) + } +} diff --git a/src/calendar/tests.rs b/src/calendar/tests.rs new file mode 100644 index 0000000..6a0bd61 --- /dev/null +++ b/src/calendar/tests.rs @@ -0,0 +1,267 @@ +use gpui::{ + Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, MouseButton, + MouseDownEvent, MouseUpEvent, ParentElement, Render, Styled, TestAppContext, VisualTestContext, + Window, div, px, +}; +use jiff::{Timestamp, civil::date, tz::TimeZone}; + +use super::{CalendarMonthView, Event, YearView}; +use crate::{primitives::FocusNext, theme::Theme}; + +/// A view that shows one calendar part, keeps the month it was told, and what it heard. +struct Planning { + part: fn(&Planning, Entity) -> gpui::AnyElement, + month: jiff::civil::Date, + heard: Vec, +} + +impl Render for Planning { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div().w(px(900.0)).child((self.part)(self, cx.entity())) + } +} + +fn settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +fn planning( + part: fn(&Planning, Entity) -> gpui::AnyElement, + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (host, cx) = cx.add_window_view(|_, _| Planning { + part, + month: date(2026, 9, 1), + heard: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (host, cx) +} + +fn note(owner: &Entity, what: String, cx: &mut gpui::App) { + owner.update(cx, |host, cx| { + host.heard.push(what); + cx.notify(); + }); +} + +fn heard(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |host, _| host.heard.clone()) +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +/// Moves focus to the `nth` Tab stop from none. +fn tab_to(nth: usize, cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.blur(); + for _ in 0..nth { + window.focus_next(); + } + }); + settle(cx); +} + +fn at(day: i8, hour: i8) -> Timestamp { + date(2026, 9, day) + .at(hour, 0, 0, 0) + .to_zoned(TimeZone::UTC) + .expect("a UTC time") + .timestamp() +} + +/// Five meetings on the 16th, and a trip across the weekend after it. +fn events() -> Vec { + let mut events: Vec = (0..5) + .map(|ix| { + let hour = 9 + ix as i8; + Event::timed( + format!("m{ix}"), + format!("Meeting {ix}"), + at(16, hour), + at(16, hour + 1), + ix, + ) + }) + .collect(); + events.push(Event::all_day( + "trip", + "Trip", + date(2026, 9, 18), + date(2026, 9, 22), + 5, + )); + events +} + +fn month(_: &Planning, owner: Entity) -> gpui::AnyElement { + let (days, keys, months) = (owner.clone(), owner.clone(), owner); + CalendarMonthView::new("month", date(2026, 9, 1), events()) + .zone(TimeZone::UTC) + .today(date(2026, 9, 16)) + .on_day(move |day, _, cx| note(&days, format!("open {day}"), cx)) + .on_event(move |key, _, cx| note(&keys, format!("event {key}"), cx)) + .on_month(move |first, _, cx| note(&months, format!("month {first}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn arrows_move_the_day_and_enter_opens_it(cx: &mut TestAppContext) { + let (host, cx) = planning(month, cx); + tab_to(4, cx); + press("right", cx); + press("down", cx); + press("enter", cx); + assert_eq!( + heard(&host, cx), + ["open 2026-09-24"], + "from today, the 16th" + ); +} + +#[gpui::test] +fn page_down_turns_the_month(cx: &mut TestAppContext) { + let (host, cx) = planning(month, cx); + tab_to(4, cx); + press("pagedown", cx); + press("enter", cx); + assert_eq!(heard(&host, cx), ["month 2026-10-01", "open 2026-10-16"]); +} + +#[gpui::test] +fn a_crowded_day_opens_from_its_more(cx: &mut TestAppContext) { + let (host, cx) = planning(month, cx); + let more = cx + .debug_bounds("more 2026-09-16") + .expect("the 16th holds more than fits"); + cx.simulate_click(more.center(), Modifiers::none()); + settle(cx); + assert_eq!(heard(&host, cx), ["open 2026-09-16"]); +} + +#[gpui::test] +fn a_chip_names_its_event_and_leaves_the_day(cx: &mut TestAppContext) { + let (host, cx) = planning(month, cx); + let chip = cx + .debug_bounds("event-chip m0") + .expect("the first meeting shows"); + let origin = chip.origin + gpui::point(px(4.0), px(4.0)); + for click_count in [1, 2] { + cx.simulate_event(MouseDownEvent { + button: MouseButton::Left, + position: origin, + modifiers: Modifiers::none(), + click_count, + first_mouse: false, + }); + cx.simulate_event(MouseUpEvent { + button: MouseButton::Left, + position: origin, + modifiers: Modifiers::none(), + click_count, + }); + settle(cx); + } + assert_eq!( + heard(&host, cx), + ["event m0", "event m0"], + "a double press on a chip does not open its day" + ); +} + +#[test] +#[should_panic(expected = "event m0 twice")] +fn an_event_is_listed_once() { + let twin = Event::timed("m0", "Again", at(1, 9), at(1, 10), 0); + let _ = CalendarMonthView::new("month", date(2026, 9, 1), [events()[0].clone(), twin]); +} + +fn year(_: &Planning, owner: Entity) -> gpui::AnyElement { + let (days, years) = (owner.clone(), owner); + YearView::new("year", 2026, events()) + .zone(TimeZone::UTC) + .today(date(2026, 9, 16)) + .on_day(move |day, _, cx| note(&days, format!("open {day}"), cx)) + .on_year(move |year, _, cx| note(&years, format!("year {year}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn a_year_walks_by_day_and_month_into_the_next(cx: &mut TestAppContext) { + let (host, cx) = planning(year, cx); + tab_to(4, cx); + for key in [ + "pagedown", "pagedown", "pagedown", "pagedown", "right", "enter", + ] { + press(key, cx); + } + assert_eq!(heard(&host, cx), ["year 2027", "open 2027-01-17"]); +} + +/// A month view whose owner keeps the month and hands each turn back. +fn echoing(host: &Planning, owner: Entity) -> gpui::AnyElement { + let (days, months) = (owner.clone(), owner); + CalendarMonthView::new("month", host.month, events()) + .zone(TimeZone::UTC) + .today(date(2026, 9, 16)) + .on_day(move |day, _, cx| note(&days, format!("open {day}"), cx)) + .on_month(move |first, _, cx| { + months.update(cx, |host, cx| { + host.month = first; + cx.notify(); + }) + }) + .into_any_element() +} + +#[gpui::test] +fn the_owners_echo_of_a_turn_keeps_the_day(cx: &mut TestAppContext) { + let (host, cx) = planning(echoing, cx); + tab_to(4, cx); + press("pagedown", cx); + press("enter", cx); + assert_eq!(heard(&host, cx), ["open 2026-10-16"]); +} + +/// A year view whose owner keeps the year and hands each turn back. +fn echoing_year(host: &Planning, owner: Entity) -> gpui::AnyElement { + let (days, years) = (owner.clone(), owner); + YearView::new("year", host.month.year(), events()) + .zone(TimeZone::UTC) + .today(date(2026, 9, 16)) + .on_day(move |day, _, cx| note(&days, format!("open {day}"), cx)) + .on_year(move |year, _, cx| { + years.update(cx, |host, cx| { + host.month = date(year, 1, 1); + cx.notify(); + }) + }) + .into_any_element() +} + +#[gpui::test] +fn the_owners_echo_of_a_year_keeps_the_day(cx: &mut TestAppContext) { + let (host, cx) = planning(echoing_year, cx); + tab_to(4, cx); + for key in ["pagedown", "pagedown", "pagedown", "pagedown", "enter"] { + press(key, cx); + } + assert_eq!(heard(&host, cx), ["open 2027-01-16"]); +} diff --git a/src/calendar/weeks.rs b/src/calendar/weeks.rs new file mode 100644 index 0000000..59dbe1d --- /dev/null +++ b/src/calendar/weeks.rs @@ -0,0 +1,213 @@ +use std::cmp::Reverse; + +use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; + +use super::event::{Event, When}; +use crate::forms::week_start; + +/// An event that spans days, laid across a week row: its lane from the top, its first and last column, and whether it runs on past either end. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Bar { + pub event: usize, + pub lane: usize, + pub from: usize, + pub to: usize, + pub before: bool, + pub after: bool, +} + +/// The Mondays of the week rows that hold `month`'s days: four to six. +pub(crate) fn weeks(month: Date) -> Vec { + let last = month.last_of_month(); + let mut monday = week_start(month.first_of_month()); + let mut mondays = Vec::with_capacity(6); + while monday <= last { + mondays.push(monday); + monday = monday.checked_add(1.week()).expect("a week follows"); + } + mondays +} + +fn column(monday: Date, day: Date) -> usize { + let days = monday.until(day).expect("days apart").get_days(); + usize::try_from(days).expect("a day in its week") +} + +/// Bars for the week from `monday`: earliest first and longer first, each in the first lane free across its days. +pub(crate) fn bars(events: &[Event], monday: Date, zone: &TimeZone) -> Vec { + let sunday = monday.checked_add(6.days()).expect("a week ends"); + let mut spans: Vec<(usize, Date, Date)> = events + .iter() + .enumerate() + .filter(|(_, event)| event.spans(zone)) + .map(|(ix, event)| { + let (first, last) = event.days(zone); + (ix, first, last) + }) + .filter(|&(_, first, last)| first <= sunday && last >= monday) + .collect(); + spans.sort_by_key(|&(ix, first, last)| (first, Reverse(last), ix)); + let mut ends: Vec = Vec::new(); + spans + .into_iter() + .map(|(event, first, last)| { + let (from, to) = (first.max(monday), last.min(sunday)); + let lane = ends + .iter() + .position(|end| *end < from) + .unwrap_or(ends.len()); + match ends.get_mut(lane) { + Some(end) => *end = to, + None => ends.push(to), + } + Bar { + event, + lane, + from: column(monday, from), + to: column(monday, to), + before: first < monday, + after: last > sunday, + } + }) + .collect() +} + +fn start(event: &Event) -> Timestamp { + match event.when { + When::Timed { start, .. } => start, + When::AllDay { .. } => panic!("event {}: an all-day event spans", event.key), + } +} + +/// The events on `day` alone, by start. +pub(crate) fn singles(events: &[Event], day: Date, zone: &TimeZone) -> Vec { + let mut on: Vec = events + .iter() + .enumerate() + .filter(|(_, event)| !event.spans(zone) && event.days(zone).0 == day) + .map(|(ix, _)| ix) + .collect(); + on.sort_by_key(|&ix| (start(&events[ix]), ix)); + on +} + +/// What a day shows under its week's bars: how many of its `singles` fit in `room` rows, and how many more wait, counting `hidden` bars past the lanes shown. +pub(crate) fn day_rows(singles: usize, hidden: usize, room: usize) -> (usize, usize) { + if hidden == 0 && singles <= room { + return (singles, 0); + } + let shown = room.saturating_sub(1).min(singles); + (shown, singles - shown + hidden) +} + +#[cfg(test)] +mod tests { + use jiff::{Timestamp, civil::date, tz::TimeZone}; + + use super::{Bar, bars, day_rows, singles, weeks}; + use crate::calendar::Event; + + fn at(day: i8, hour: i8) -> Timestamp { + date(2026, 9, day) + .at(hour, 0, 0, 0) + .to_zoned(TimeZone::UTC) + .expect("a UTC time") + .timestamp() + } + + fn bar(event: usize, lane: usize, from: usize, to: usize) -> Bar { + Bar { + event, + lane, + from, + to, + before: false, + after: false, + } + } + + #[test] + fn a_month_takes_the_weeks_that_hold_its_days() { + assert_eq!( + weeks(date(2026, 9, 15)), + [ + date(2026, 8, 31), + date(2026, 9, 7), + date(2026, 9, 14), + date(2026, 9, 21), + date(2026, 9, 28) + ] + ); + assert_eq!(weeks(date(2027, 2, 1)).len(), 4, "a February from a Monday"); + assert_eq!( + weeks(date(2026, 8, 1)).len(), + 6, + "an August from a Saturday" + ); + } + + #[test] + fn bars_take_the_first_free_lane_and_break_at_the_week() { + let events = [ + Event::all_day( + "offsite", + "Offsite", + date(2026, 9, 22), + date(2026, 9, 24), + 0, + ), + Event::all_day("trip", "Trip", date(2026, 9, 24), date(2026, 9, 29), 1), + Event::all_day("fair", "Fair", date(2026, 9, 25), date(2026, 9, 26), 2), + Event::timed("night", "Night shoot", at(21, 22), at(22, 2), 3), + ]; + let utc = &TimeZone::UTC; + assert_eq!( + bars(&events, date(2026, 9, 21), utc), + [ + bar(3, 0, 0, 1), + bar(0, 1, 1, 3), + Bar { + after: true, + ..bar(1, 0, 3, 6) + }, + bar(2, 1, 4, 5), + ] + ); + assert_eq!( + bars(&events, date(2026, 9, 28), utc), + [Bar { + before: true, + ..bar(1, 0, 0, 1) + }] + ); + } + + #[test] + fn a_day_shows_what_fits_and_counts_the_rest() { + assert_eq!(day_rows(3, 0, 3), (3, 0)); + assert_eq!(day_rows(5, 0, 3), (2, 3), "two chips, then 3 more"); + assert_eq!(day_rows(0, 1, 1), (0, 1), "a hidden bar still counts"); + assert_eq!(day_rows(2, 1, 2), (1, 2)); + } + + #[test] + fn a_days_own_events_come_by_the_hour() { + let events = [ + Event::timed("late", "Late", at(22, 14), at(22, 15), 0), + Event::timed("early", "Early", at(22, 9), at(22, 10), 0), + Event::all_day("all", "All", date(2026, 9, 22), date(2026, 9, 22), 0), + Event::timed("next", "Next", at(23, 9), at(23, 10), 0), + ]; + assert_eq!(singles(&events, date(2026, 9, 22), &TimeZone::UTC), [1, 0]); + } + + #[test] + fn an_end_at_midnight_leaves_the_next_day_alone() { + let late = Event::timed("late", "Late", at(22, 20), at(23, 0), 0); + assert_eq!( + late.days(&TimeZone::UTC), + (date(2026, 9, 22), date(2026, 9, 22)) + ); + assert!(!late.spans(&TimeZone::UTC)); + } +} diff --git a/src/calendar/year.rs b/src/calendar/year.rs new file mode 100644 index 0000000..f02b40e --- /dev/null +++ b/src/calendar/year.rs @@ -0,0 +1,353 @@ +use std::{collections::BTreeMap, rc::Rc}; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, Styled, Window, div, prelude::*, +}; +use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; + +use super::{ + chip::tint, + event::{Event, unique}, + head::{OnDay, Step, head}, +}; +use crate::{ + forms::month_grid, + layout::seeded::use_seeded, + primitives::tab_stop, + theme::{ActiveTheme, TextSize}, + typography::format, +}; + +const INITIALS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"]; + +type OnYear = Rc; + +/// The hue of the first event on each day of `year` in `zone`. +fn marks(events: &[Event], year: i16, zone: &TimeZone) -> BTreeMap { + let mut marks = BTreeMap::new(); + for (ix, event) in events.iter().enumerate() { + let (first, last) = event.days(zone); + let mut day = first; + while day <= last { + if day.year() == year { + marks.entry(day).or_insert(ix); + } + day = day.tomorrow().expect("a day follows"); + } + } + marks +} + +/// A year in twelve small months; a day with something on it wears a dot in its first event's hue. Arrows move a day, Page keys a month, and Enter or a double press opens the day. +#[derive(IntoElement)] +pub struct YearView { + id: ElementId, + year: i16, + events: Vec, + today: Option, + zone: Option, + on_day: Option, + on_year: Option, +} + +impl YearView { + pub fn new( + id: impl Into, + year: i16, + events: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + year, + events: unique(events), + today: None, + zone: None, + on_day: None, + on_year: None, + } + } + + /// The day marked as today; the zone's own otherwise. + pub fn today(mut self, date: Date) -> Self { + self.today = Some(date); + self + } + + /// The zone its days read in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// Gets a day opened: Enter on it or a double press. + pub fn on_day(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self { + self.on_day = Some(Rc::new(handler)); + self + } + + /// Gets each year turned to. + pub fn on_year(mut self, handler: impl Fn(i16, &mut Window, &mut App) + 'static) -> Self { + self.on_year = Some(Rc::new(handler)); + self + } +} + +/// One small month: its name, the weekdays, and six weeks with only its own days shown. +struct Mini<'a> { + id: &'a ElementId, + today: Date, + cursor: Option, + marks: &'a BTreeMap, + pick: &'a OnDay, + open: &'a OnDay, +} + +impl Mini<'_> { + fn month(&self, first: Date, cx: &App) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + let cell = theme.calendar().mini_day; + let initials = INITIALS.map(|initial| { + div() + .size(cell) + .flex() + .items_center() + .justify_center() + .text_color(colors.fg_subtle) + .child(initial) + }); + let days = month_grid(first).into_iter().map(|day| { + if day.first_of_month() != first { + return div().size(cell).into_any_element(); + } + let (pick, open) = (self.pick.clone(), self.open.clone()); + let today = day == self.today; + div() + .id((self.id.clone(), format!("day-{day}"))) + .debug_selector(move || format!("year-day {day}")) + .relative() + .size(cell) + .flex() + .flex_col() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(match self.cursor == Some(day) { + true => colors.focus, + false => gpui::transparent_black(), + }) + .when(today, |day| day.bg(colors.accent)) + .text_color(match today { + true => colors.on_accent, + false => colors.fg, + }) + .when(today, |day| day.font_weight(FontWeight::SEMIBOLD)) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + if event.click_count == 2 { + open(day, window, cx); + } else { + pick(day, window, cx); + } + }) + .child(day.day().to_string()) + .children(self.marks.get(&day).map(|tint| { + div() + .absolute() + .bottom_0p5() + .size(theme.status_dot()) + .rounded_full() + .bg(*tint) + })) + .into_any_element() + }); + div() + .flex_none() + .w(cell * 7.0) + .flex() + .flex_col() + .gap_1() + .child( + div() + .px_1() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(first.strftime("%B").to_string()), + ) + .child( + div() + .flex() + .flex_wrap() + .text_size(theme.text_size(TextSize::Xs)) + .children(initials) + .children(days.collect::>()), + ) + .into_any_element() + } +} + +impl RenderOnce for YearView { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("year view")); + let today = self + .today + .unwrap_or_else(|| Timestamp::now().to_zoned(zone.clone()).date()); + let year = use_seeded((self.id.clone(), "year"), self.year, window, cx); + let shown = year.read(cx).value; + let start = match today.year() == shown { + true => today, + false => Date::new(shown, 1, 1).expect("a year has a first day"), + }; + let cursor = window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| start); + if cursor.read(cx).year() != shown { + cursor.update(cx, |cursor, _| *cursor = start); + } + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let at = *cursor.read(cx); + let pick: OnDay = { + let (id, year, cursor, on_year) = ( + self.id.clone(), + year.clone(), + cursor.clone(), + self.on_year.clone(), + ); + Rc::new(move |day, window, cx| { + cursor.update(cx, |cursor, cx| { + *cursor = day; + cx.notify(); + }); + if year.read(cx).value == day.year() { + return; + } + log::info!("year view {id:?}: {}", day.year()); + year.update(cx, |year, cx| { + year.value = day.year(); + cx.notify(); + }); + if let Some(on_year) = &on_year { + on_year(day.year(), window, cx); + } + }) + }; + let open: OnDay = { + let (id, on_day) = (self.id.clone(), self.on_day.clone()); + Rc::new(move |day, window, cx| { + log::info!("year view {id:?}: open {day}"); + if let Some(on_day) = &on_day { + on_day(day, window, cx); + } + }) + }; + let step: Step = { + let pick = pick.clone(); + Rc::new(move |by, window, cx| { + let day = match by { + 0 => today, + by => at + .checked_add(by.years()) + .expect("a year lies on either side"), + }; + pick(day, window, cx) + }) + }; + let keys = { + let (focus, pick, open) = (focus.clone(), pick.clone(), open.clone()); + move |event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App| { + if !focus.is_focused(window) { + return; + } + let by = match event.keystroke.key.as_str() { + "left" => 1.day().negate(), + "right" => 1.day(), + "up" => 1.week().negate(), + "down" => 1.week(), + "pageup" => 1.month().negate(), + "pagedown" => 1.month(), + "enter" => { + cx.stop_propagation(); + return open(at, window, cx); + } + _ => return, + }; + cx.stop_propagation(); + pick(at.checked_add(by).expect("a day nearby"), window, cx) + } + }; + let tints = marks(&self.events, shown, &zone) + .into_iter() + .map(|(day, ix)| (day, tint(&self.events[ix], cx))) + .collect(); + let mini = Mini { + id: &self.id, + today, + cursor: focus.is_focused(window).then_some(at), + marks: &tints, + pick: &pick, + open: &open, + }; + let months: Vec = (1..=12) + .map(|month| { + let first = Date::new(shown, month, 1).expect("a month has a first day"); + mini.month(first, cx) + }) + .collect(); + div() + .w_full() + .flex() + .flex_col() + .gap_4() + .child(head( + &self.id, + shown.to_string(), + "Previous year", + "Next year", + step, + cx, + )) + .child( + div() + .id((self.id.clone(), "months")) + .debug_selector(|| "year-months".into()) + .track_focus(&focus) + .on_key_down(keys) + .flex() + .flex_wrap() + .gap_x_8() + .gap_y_6() + .children(months), + ) + } +} + +#[cfg(test)] +mod tests { + use jiff::{civil::date, tz::TimeZone}; + + use super::marks; + use crate::calendar::Event; + + #[test] + fn a_day_takes_the_first_events_mark_within_the_year() { + let events = [ + Event::all_day("trip", "Trip", date(2026, 12, 30), date(2027, 1, 2), 0), + Event::all_day( + "offsite", + "Offsite", + date(2026, 12, 31), + date(2026, 12, 31), + 1, + ), + ]; + let marks = marks(&events, 2026, &TimeZone::UTC); + assert_eq!( + marks.into_iter().collect::>(), + [(date(2026, 12, 30), 0), (date(2026, 12, 31), 0)], + "the trip marks its days in 2026 only, and first" + ); + } +} diff --git a/src/forms/date/mod.rs b/src/forms/date/mod.rs index 3e2ae3d..53d9756 100644 --- a/src/forms/date/mod.rs +++ b/src/forms/date/mod.rs @@ -16,6 +16,7 @@ use jiff::{ use crate::typography::format::system_zone; pub use calendar::Calendar; +pub(crate) use calendar::{month_grid, week_start}; pub use cron::CronEditor; pub use period::{MonthPicker, QuarterPicker, YearPicker}; pub use picker::{DatePicker, DateRangePicker}; diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 6622ab5..b4da18c 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -63,7 +63,7 @@ pub use date::{ MonthPicker, QuarterPicker, RelativeDatePicker, RelativeRange, TimePicker, TimezoneSelect, WeekPicker, YearPicker, }; -pub(crate) use date::{Face, dropdown, picker_field}; +pub(crate) use date::{Face, dropdown, month_grid, picker_field, week_start}; pub use expression::{ExpressionInput, evaluate, expression_highlights}; pub use files::{DropZone, FileInput, PICTURES}; pub(crate) use files::{browse, dropped}; diff --git a/src/lib.rs b/src/lib.rs index 808d473..a0cebc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent; mod assets; pub mod buttons; +pub mod calendar; pub mod charts; pub mod chat; pub mod collab; diff --git a/src/mail/labels.rs b/src/mail/labels.rs index e575c5d..a52ca8b 100644 --- a/src/mail/labels.rs +++ b/src/mail/labels.rs @@ -37,11 +37,7 @@ impl Label { /// Label `name`'s dot in the chart color at `hue`, centered in an icon's box. pub(super) fn hue_dot(name: &str, hue: usize, cx: &App) -> Div { let theme = cx.theme(); - let tint = *theme - .colors - .chart - .get(hue) - .unwrap_or_else(|| panic!("label {name}: no chart hue {hue}")); + let tint = theme.colors.hue(hue, format_args!("label {name}")); div() .flex_none() .size(theme.icon_size(IconSize::Sm)) diff --git a/src/theme/calendar.rs b/src/theme/calendar.rs new file mode 100644 index 0000000..edb5b22 --- /dev/null +++ b/src/theme/calendar.rs @@ -0,0 +1,24 @@ +use gpui::Rems; + +use super::{Theme, tokens::px_to_rems}; + +/// Calendar's measures. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CalendarSizes { + /// An event's chip in a day, tall. + pub chip: Rems, + /// Rows of events a month's day holds under its number. + pub day_rows: usize, + /// A day in a year's small months, square. + pub mini_day: Rems, +} + +impl Theme { + pub fn calendar(&self) -> CalendarSizes { + CalendarSizes { + chip: px_to_rems(20.0), + day_rows: 3, + mini_day: px_to_rems(28.0), + } + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 4a50f30..3ea09c3 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -1,3 +1,4 @@ +mod calendar; mod chart; mod files; mod generative; @@ -11,6 +12,7 @@ use std::time::{Duration, Instant}; use gpui::{App, Global, SharedString, WindowAppearance}; use crate::motion; +pub use calendar::CalendarSizes; pub use chart::ChartSizes; pub use files::FileSizes; pub use generative::GenerativeSizes; diff --git a/src/theme/palette.rs b/src/theme/palette.rs index eed482f..96dcbdb 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -118,6 +118,14 @@ fn c8(hex: [u32; N]) -> [Hsla; N] { } impl Palette { + /// The chart color at `hue`; a hue past the chart fails, naming `owner`. + pub fn hue(&self, hue: usize, owner: impl std::fmt::Display) -> Hsla { + *self + .chart + .get(hue) + .unwrap_or_else(|| panic!("{owner}: no chart hue {hue}")) + } + pub fn light(high_contrast: bool) -> Self { let mut palette = Self { bg: c(0xfcfaf7), diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 0a5bcf1..8ebc9ff 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -385,22 +385,31 @@ - [x] LabelPicker - [x] SnoozePicker -## T30 Calendar — `src/calendar` +## T30a Calendar · Months — `src/calendar` + +- [x] CalendarMonthView +- [x] YearView +- [x] MiniCalendar → `forms::Calendar` +- [x] EventCard / EventChip + +## T30b Calendar · Time grid -- [ ] CalendarMonthView - [ ] CalendarWeekView - [ ] CalendarDayView -- [ ] AgendaView -- [ ] YearView -- [ ] MiniCalendar → `forms::Calendar` -- [ ] EventCard / EventChip -- [ ] EventEditor -- [ ] EventPopover - [ ] TimeGrid — drag to create, resize - [ ] AllDayRow - [ ] CurrentTimeIndicator -- [ ] RecurrenceEditor -- [ ] AvailabilityPicker - [ ] TimezoneOverlay -- [ ] ReminderPicker + +## T30c Calendar · Agenda and people + +- [ ] AgendaView +- [ ] EventPopover - [ ] AttendeeList + +## T30d Calendar · Editing + +- [ ] EventEditor +- [ ] RecurrenceEditor +- [ ] ReminderPicker +- [ ] AvailabilityPicker