diff --git a/AGENTS.md b/AGENTS.md index 9e14a22..b7d2fad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - 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. -- A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width. +- A component that fills its container says `min_w_full` on its root. As a window's root, or in a flex row, an auto width would shrink to its text's narrowest width. A percent width does not stretch in a flex box, and taffy 0.9 lays a flex box out once at unknown width while a block parent measures its content, then keeps that layout when the width comes out the same: its `w_full` children stay at content width (`forms::FormField`). Roots made before T30d still say `w_full`; T44 moves them. - A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match. - A block editor keeps one undo for the document: text and structure together, as snapshots through `forms::History`; it takes Undo and Redo before its fields do. - gpui calls focus listeners only while the window is active. A test that needs focus events activates its window with `window.activate_window()`. @@ -156,6 +156,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - The system time zone comes from `typography::format::system_zone`, which fails loud; jiff's `TimeZone::system()` falls back to UTC in silence. - A Select with nothing to pick stays shut: disabled, it shows its placeholder; open, it fails (`media::DeviceSelector` says no device was found). - A list opens with its cursor on the current choice: Select on the chosen row, Combobox on its value, MultiSelect on the first one ticked, SelectableList on its first selected row. A selection the owner makes, not the echo of the list's own pick, moves SelectableList's cursor there. +- An event repeats as RFC 5545 has it: weeks count from the start's own week, and a month or a year without the start's date is passed over (`calendar::Recurrence`). - A search step, a tool call or an agent's step says where it stands with `chat::StepState` and draws it with `chat::step_mark`. - A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space. - A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below. diff --git a/TASKS.md b/TASKS.md index fb1d72e..b091a9b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -109,7 +109,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T29d Mail · Sorting - [x] T30a Calendar · Months - [x] T30b Calendar · Time grid -- [ ] T30c Calendar · Agenda and people +- [x] T30c Calendar · Agenda and people - [ ] T30d Calendar · Editing - [ ] T31 Project — `tasks/ch31-43.md` - [ ] T32 Canvas & Design @@ -124,7 +124,7 @@ Per-component lines live in `tasks/`. Tags there: - [ ] T41 Maps - [ ] T42 Misc - [ ] T43 Library Tooling -- [ ] T44 Capture: every component, light and dark, plus motion clips +- [ ] T44 Capture: every component, light and dark, plus motion clips; roots still on `w_full` move to `min_w_full`, checked at 280px - [ ] T45 Website: `frontend/`, Vite 8, pnpm, light and dark, motion - [ ] T46 E2E: Playwright against the built site - [ ] T47 Ship: GitHub repo (public), CI, Pages deploy @@ -357,3 +357,4 @@ Per-component lines live in `tasks/`. Tags there: | T30a | 1 | PASS | | | T30b | 1 | FAIL | the week and day views froze the line at now and today: the moment read once went down as given and turned the ticker off | | T30b | 2 | PASS | | +| T30c | 1 | PASS | | diff --git a/examples/gallery/pages/calendar/editing.rs b/examples/gallery/pages/calendar/editing.rs new file mode 100644 index 0000000..9266014 --- /dev/null +++ b/examples/gallery/pages/calendar/editing.rs @@ -0,0 +1,140 @@ +use ely_gpui_component::{ + calendar::{AvailabilityPicker, EventDraft, EventEditor, Frequency, Recurrence, When}, + theme::{ActiveTheme, Radius}, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{ToSpan, civil::Weekday}; + +use super::{here, plan, quiet}; +use crate::{ + probe::probe, + ui::{change, keep, section, set}, +}; + +pub fn editor(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (zone, _) = here(); + let events = plan(window, cx); + let review = events + .read(cx) + .iter() + .find(|event| event.key.as_ref() == "review") + .cloned(); + let said = keep("calendar-editor-said", || None::, window, cx); + let shown = said.read(cx).clone(); + let theme = cx.theme(); + let body = match review { + Some(review) => { + let (saving, cancelling, deleting) = (said.clone(), said.clone(), said); + let (kept, dropped) = (events.clone(), events); + let draft = EventDraft { + repeat: Some(Recurrence { + weekdays: vec![Weekday::Monday, Weekday::Thursday], + ..Recurrence::new(Frequency::Weekly) + }), + reminders: vec![10, 1440], + ..EventDraft::of(&review) + }; + EventEditor::new("calendar-editor", draft) + .zone(zone) + .on_save(move |draft, _, cx| { + let draft = draft.clone(); + change(&kept, cx, |events| { + let event = events + .iter_mut() + .find(|event| event.key.as_ref() == "review") + .expect("the review is listed"); + event.title = draft.title.clone(); + event.when = draft.when; + event.hue = draft.hue; + event.place = (!draft.place.is_empty()).then(|| draft.place.clone()); + }); + set(&saving, Some(format!("Saved {}.", draft.title).into()), cx) + }) + .on_cancel(move |_, cx| set(&cancelling, Some("Left as it was.".into()), cx)) + .on_delete(move |_, cx| { + change(&dropped, cx, |events| { + events.retain(|event| event.key.as_ref() != "review") + }); + set(&deleting, Some("Deleted the review.".into()), cx) + }) + .into_any_element() + } + None => quiet("The review was deleted.".into(), cx).into_any_element(), + }; + section( + "EventEditor / RecurrenceEditor / ReminderPicker", + "An event's parts in a form: its title, whole days or hours, where, its color, how it repeats with the next days it falls on, its reminders and notes. Save waits for a title and an end after the start; saved, every view on the page shows the change.", + cx, + ) + .child(probe( + "calendar-editor", + div() + .w(px(480.)) + .flex() + .flex_col() + .gap_3() + .child( + div() + .p_5() + .border_1() + .border_color(theme.colors.border) + .rounded(theme.radius(Radius::Lg)) + .child(body), + ) + .children(shown.map(|shown| quiet(shown, cx))), + )) +} + +pub fn availability(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (zone, today) = here(); + let busy: Vec<_> = plan(window, cx) + .read(cx) + .iter() + .filter_map(|event| match event.when { + When::Timed { start, end } => Some((start, end)), + When::AllDay { .. } => None, + }) + .collect(); + let days: Vec<_> = (1..=9) + .map(|offset| { + today + .checked_add((offset as i64).days()) + .expect("a day ahead") + }) + .filter(|day| !matches!(day.weekday(), Weekday::Saturday | Weekday::Sunday)) + .take(5) + .collect(); + let picked = keep("calendar-slot", || None::, window, cx); + let chosen = *picked.read(cx); + let said = chosen.map(|start| { + let when = start + .to_zoned(zone.clone()) + .strftime("%A at %H:%M") + .to_string(); + SharedString::from(format!("Meet {when}.")) + }); + let picker = AvailabilityPicker::new("calendar-slots", days) + .zone(zone) + .busy(busy) + .length(45) + .on_pick(move |start, _, cx| set(&picked, Some(start), cx)); + let picker = match chosen { + Some(start) => picker.selected(start), + None => picker, + }; + section( + "AvailabilityPicker", + "Times to meet over the working days ahead: each day with its count of open slots, then the slots of the one chosen. What the page's events take is closed.", + cx, + ) + .child(probe( + "calendar-slots", + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_3() + .child(picker) + .children(said.map(|said| quiet(said, cx))), + )) +} diff --git a/examples/gallery/pages/calendar/mod.rs b/examples/gallery/pages/calendar/mod.rs index cb5f5b5..b5d3cf0 100644 --- a/examples/gallery/pages/calendar/mod.rs +++ b/examples/gallery/pages/calendar/mod.rs @@ -7,6 +7,7 @@ use gpui::{ }; use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; +mod editing; mod months; mod people; mod times; @@ -109,6 +110,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(times::day(window, cx)) .child(people::agenda(window, cx)) .child(people::details(window, cx)) + .child(editing::editor(window, cx)) + .child(editing::availability(window, cx)) .child(months::year(window, cx)) .child(months::marks(cx)) .into_any_element() diff --git a/src/calendar/availability.rs b/src/calendar/availability.rs new file mode 100644 index 0000000..667268d --- /dev/null +++ b/src/calendar/availability.rs @@ -0,0 +1,269 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div, +}; +use jiff::{Timestamp, civil::Date, tz::TimeZone}; + +use super::hours::moment; +use crate::{ + forms::{Choice, ChoiceChips}, + theme::{ActiveTheme, TextSize}, + typography::format, +}; + +type OnTime = Rc; + +/// Slots start on the half hour. +const STEP: i64 = 30; + +/// The starts, in minutes of `day`, of slots `length` long within `hours` that meet no busy stretch and begin after `now`. +pub(crate) fn open_slots( + day: Date, + hours: (i64, i64), + length: i64, + busy: &[(Timestamp, Timestamp)], + now: Timestamp, + zone: &TimeZone, +) -> Vec { + let mut starts = Vec::new(); + let mut start = hours.0; + while start + length <= hours.1 { + let (from, to) = (moment(day, start, zone), moment(day, start + length, zone)); + let free = busy + .iter() + .all(|(taken, until)| *until <= from || *taken >= to); + if free && from > now { + starts.push(start); + } + start += STEP; + } + starts +} + +/// Open times to meet over some days: each day with its count of open slots, then the slots of the one chosen. Busy stretches, the working hours and the past close the rest. A pick hands on the slot's start. +#[derive(IntoElement)] +pub struct AvailabilityPicker { + id: ElementId, + days: Vec, + busy: Vec<(Timestamp, Timestamp)>, + hours: (i64, i64), + length: i64, + zone: Option, + now: Option, + selected: Option, + on_pick: Option, +} + +impl AvailabilityPicker { + pub fn new(id: impl Into, days: impl IntoIterator) -> Self { + let id = id.into(); + let days: Vec = days.into_iter().collect(); + assert!(!days.is_empty(), "availability picker {id:?} has no days"); + Self { + id, + days, + busy: Vec::new(), + hours: (9 * 60, 17 * 60), + length: 30, + zone: None, + now: None, + selected: None, + on_pick: None, + } + } + + /// Stretches already taken, each from a moment to a later one. + pub fn busy(mut self, busy: impl IntoIterator) -> Self { + self.busy = busy.into_iter().collect(); + for (from, to) in &self.busy { + assert!( + to > from, + "a busy stretch from {from} ends before it starts" + ); + } + self + } + + /// The hours a slot may fall in, from the first to the last; nine to five otherwise. + pub fn hours(mut self, first: i8, last: i8) -> Self { + assert!( + first < last && last <= 24, + "working hours from {first} to {last}" + ); + self.hours = (i64::from(first) * 60, i64::from(last) * 60); + self + } + + /// How long a slot is, in minutes; half an hour otherwise. + pub fn length(mut self, minutes: u16) -> Self { + assert!(minutes > 0, "a slot takes some time"); + self.length = i64::from(minutes); + 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 + } + + /// The moment before which nothing is open; the clock's otherwise. + pub fn now(mut self, now: Timestamp) -> Self { + self.now = Some(now); + self + } + + /// The slot picked, by its start. + pub fn selected(mut self, start: Timestamp) -> Self { + self.selected = Some(start); + self + } + + /// Gets the start of each slot picked. + pub fn on_pick(mut self, handler: impl Fn(Timestamp, &mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for AvailabilityPicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("availability picker")); + let now = self.now.unwrap_or_else(Timestamp::now); + let open: Vec<(Date, Vec)> = self + .days + .iter() + .map(|day| { + let slots = open_slots(*day, self.hours, self.length, &self.busy, now, &zone); + (*day, slots) + }) + .collect(); + let picked_day = self + .selected + .map(|start| start.to_zoned(zone.clone()).date()) + .filter(|day| self.days.contains(day)); + let first_open = open + .iter() + .find(|(_, slots)| !slots.is_empty()) + .map(|(day, _)| *day); + let chosen = window.use_keyed_state((self.id.clone(), "day"), cx, |_, _| None::); + let shown = chosen.read(cx).or(picked_day).or(first_open); + let theme = cx.theme(); + let quiet = |text: &'static str| { + div() + .py_2() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_subtle) + .child(text) + }; + let Some(shown) = shown else { + return div() + .debug_selector(|| "availability-none".into()) + .child(quiet("No open time in these days.")); + }; + let days = ChoiceChips::new( + (self.id.clone(), "days"), + open.iter().map(|(day, slots)| { + let label = format!("{} · {}", day.strftime("%a %-d"), slots.len()); + let choice = Choice::new(day.to_string(), label); + match slots.is_empty() { + true => choice.disabled(), + false => choice, + } + }), + ) + .selected([shown.to_string()]) + .on_change(move |values, _, cx| { + let day: Date = values + .first() + .expect("a day is chosen") + .parse() + .expect("a day's value is its date"); + log::info!("availability picker: {day}"); + chosen.update(cx, |chosen, cx| { + *chosen = Some(day); + cx.notify(); + }) + }); + let slots = open + .iter() + .find(|(day, _)| *day == shown) + .map(|(_, slots)| slots.clone()) + .expect("the day shown is one of the days"); + let picked = self + .selected + .filter(|_| picked_day == Some(shown)) + .map(|start| { + let zoned = start.to_zoned(zone.clone()); + (i64::from(zoned.hour()) * 60 + i64::from(zoned.minute())).to_string() + }); + let times = (!slots.is_empty()).then(|| { + let (on_pick, zone) = (self.on_pick.clone(), zone.clone()); + ChoiceChips::new( + (self.id.clone(), format!("slots-{shown}")), + slots.iter().map(|minute| { + let label = format!("{:02}:{:02}", minute / 60, minute % 60); + Choice::new(minute.to_string(), label) + }), + ) + .selected(picked) + .on_change(move |values, window, cx| { + let minute: i64 = values + .first() + .expect("a slot is chosen") + .parse() + .expect("a slot's value is its minute"); + let start = moment(shown, minute, &zone); + log::info!("availability picker: picked {start}"); + if let Some(on_pick) = &on_pick { + on_pick(start, window, cx); + } + }) + }); + div() + .debug_selector(|| "availability".into()) + .min_w_full() + .flex() + .flex_col() + .gap_3() + .child(days) + .children(times) + .children(slots.is_empty().then(|| quiet("No open time this day."))) + } +} + +#[cfg(test)] +mod tests { + use jiff::{Timestamp, civil::date, tz::TimeZone}; + + use super::open_slots; + + 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 slots_fill_the_hours_around_what_is_busy_and_after_now() { + let busy = [(at(28, 10, 0), at(28, 11, 15))]; + let starts = open_slots( + date(2026, 9, 28), + (9 * 60, 12 * 60), + 30, + &busy, + at(28, 9, 10), + &TimeZone::UTC, + ); + assert_eq!( + starts, + [9 * 60 + 30, 11 * 60 + 30], + "09:00 has passed, 10:00 to 11:15 is taken, and the last slot ends at noon" + ); + } +} diff --git a/src/calendar/editor.rs b/src/calendar/editor.rs new file mode 100644 index 0000000..4e0744d --- /dev/null +++ b/src/calendar/editor.rs @@ -0,0 +1,457 @@ +use std::rc::Rc; + +use gpui::{ + App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Subscription, Window, div, prelude::*, +}; +use jiff::{SignedDuration, Timestamp, civil::DateTime, tz::TimeZone}; + +use super::{ + event::{Event, When}, + recurrence::RecurrenceEditor, + reminders::ReminderPicker, + repeat::Recurrence, +}; +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{ + ColorPalette, DatePicker, DateTimePicker, FormError, FormField, Input, InputEvent, Run, + Switch, TextInput, + }, + theme::ActiveTheme, + typography::format, +}; + +type OnDraft = Rc; + +/// The chart's hues by name, for the color an event wears. +const HUES: [&str; 8] = [ + "Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan", +]; + +/// An event's parts as an editor hands them back. +#[derive(Clone, Debug, PartialEq)] +pub struct EventDraft { + pub title: SharedString, + pub when: When, + pub place: SharedString, + pub hue: usize, + pub repeat: Option, + pub reminders: Vec, + pub notes: SharedString, +} + +impl EventDraft { + /// `event`'s parts, with no repeat, reminders or notes. + pub fn of(event: &Event) -> Self { + Self { + title: event.title.clone(), + when: event.when, + place: event.place.clone().unwrap_or_default(), + hue: event.hue, + repeat: None, + reminders: Vec::new(), + notes: SharedString::default(), + } + } + + /// Why it cannot be saved: no title, or an end before its start. + pub(crate) fn fault(&self) -> Option<&'static str> { + if self.title.trim().is_empty() { + return Some("Give it a title."); + } + let backward = match self.when { + When::Timed { start, end } => end <= start, + When::AllDay { first, last } => last < first, + }; + backward.then_some("It ends before it starts.") + } +} + +/// `when` as whole days, or as hours from nine to ten on its first day. +fn toggled(when: When, zone: &TimeZone) -> When { + match when { + When::Timed { start, end } => { + let first = start.to_zoned(zone.clone()).date(); + let last = end.to_zoned(zone.clone()).date().max(first); + When::AllDay { first, last } + } + When::AllDay { first, .. } => { + let at = |hour| { + first + .at(hour, 0, 0, 0) + .to_zoned(zone.clone()) + .expect("the zone holds the hour") + .timestamp() + }; + When::Timed { + start: at(9), + end: at(10), + } + } + } +} + +/// `when` with its start moved to `start`, its length kept. +fn moved(when: When, start: Timestamp) -> When { + let When::Timed { start: was, end } = when else { + panic!("only timed events move by the hour"); + }; + let length: SignedDuration = end.duration_since(was); + When::Timed { + start, + end: start + length, + } +} + +/// The editor's working copy: what the owner gave, the parts changed so far, and the fields that hold its words. +struct Editing { + seed: EventDraft, + draft: EventDraft, + title: Entity, + place: Entity, + notes: Entity, + _title: Subscription, +} + +impl Editing { + fn fill(&mut self, seed: EventDraft, cx: &mut Context) { + for (input, text) in [ + (&self.title, &seed.title), + (&self.place, &seed.place), + (&self.notes, &seed.notes), + ] { + input.update(cx, |input, cx| input.set_text(text.to_string(), cx)); + } + self.draft = seed.clone(); + self.seed = seed; + } + + /// The draft as it stands, its words read from the fields. + fn current(&self, cx: &App) -> EventDraft { + let words = + |input: &Entity| SharedString::from(input.read(cx).text().to_string()); + EventDraft { + title: words(&self.title).trim().to_string().into(), + place: words(&self.place).trim().to_string().into(), + notes: words(&self.notes), + ..self.draft.clone() + } + } +} + +/// An event's parts in a form: its title, whole days or hours from and until, where, its color, how it repeats, its reminders and notes. Save waits for a title and an end after the start; Delete shows with its handler. A new draft from the owner starts it over. +#[derive(IntoElement)] +pub struct EventEditor { + id: ElementId, + draft: EventDraft, + zone: Option, + on_save: Option, + on_cancel: Option, + on_delete: Option, +} + +impl EventEditor { + pub fn new(id: impl Into, draft: EventDraft) -> Self { + Self { + id: id.into(), + draft, + zone: None, + on_save: None, + on_cancel: None, + on_delete: None, + } + } + + /// The zone its hours read in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// Gets the draft to keep. + pub fn on_save( + mut self, + handler: impl Fn(&EventDraft, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_save = Some(Rc::new(handler)); + self + } + + pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_cancel = Some(Rc::new(handler)); + self + } + + /// Offers Delete, for an event that already exists. + pub fn on_delete(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_delete = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for EventEditor { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("event editor")); + let seed = self.draft.clone(); + let editing = window.use_keyed_state( + (self.id.clone(), "editing"), + cx, + |window, cx: &mut Context| { + let mut field = |placeholder: &'static str, text: &SharedString, lines: bool| { + cx.new(|cx| { + let input = TextInput::new(window, cx).placeholder(placeholder); + let mut input = match lines { + true => input.multi_line(3, 6), + false => input, + }; + input.set_text(text.to_string(), cx); + input + }) + }; + let title = field("Title", &seed.title, false); + let place = field("Where", &seed.place, false); + let notes = field("Notes", &seed.notes, true); + let watch = cx.subscribe(&title, |_, _, event: &InputEvent, cx| { + if *event == InputEvent::Changed { + cx.notify(); + } + }); + Editing { + seed: seed.clone(), + draft: seed.clone(), + title, + place, + notes, + _title: watch, + } + }, + ); + if editing.read(cx).seed != self.draft { + log::info!("event editor {:?}: started over", self.id); + editing.update(cx, |editing, cx| editing.fill(self.draft.clone(), cx)); + } + let draft = editing.read(cx).current(cx); + let edit = { + let editing = editing.clone(); + move |change: Rc| { + let editing = editing.clone(); + move |cx: &mut App| { + editing.update(cx, |editing, cx| { + change(&mut editing.draft); + cx.notify(); + }) + } + } + }; + let civil = |at: Timestamp| at.to_zoned(zone.clone()).datetime(); + let exact = { + let zone = zone.clone(); + move |at: DateTime| { + at.to_zoned(zone.clone()) + .expect("the zone holds the time") + .timestamp() + } + }; + let whole = matches!(draft.when, When::AllDay { .. }); + let toggle = { + let zone = zone.clone(); + let when = draft.when; + edit(Rc::new(move |draft| draft.when = toggled(when, &zone))) + }; + let span: Vec = match draft.when { + When::Timed { start, end } => { + let (at, until) = (exact.clone(), exact.clone()); + vec![ + DateTimePicker::new((self.id.clone(), "starts"), Some(civil(start))) + .on_change({ + let edit = edit.clone(); + move |picked, _, cx| { + let start = at(picked); + edit(Rc::new(move |draft| draft.when = moved(draft.when, start)))( + cx, + ) + } + }) + .into_any_element(), + DateTimePicker::new((self.id.clone(), "ends"), Some(civil(end))) + .on_change({ + let edit = edit.clone(); + move |picked, _, cx| { + let end = until(picked); + edit(Rc::new(move |draft| { + if let When::Timed { start, .. } = draft.when { + draft.when = When::Timed { start, end }; + } + }))(cx) + } + }) + .into_any_element(), + ] + } + When::AllDay { first, last } => vec![ + DatePicker::new((self.id.clone(), "first"), Some(first)) + .on_change({ + let edit = edit.clone(); + move |day, _, cx| { + let length = last - first; + edit(Rc::new(move |draft| { + let last = day.checked_add(length).expect("a day ahead"); + draft.when = When::AllDay { first: day, last }; + }))(cx) + } + }) + .into_any_element(), + DatePicker::new((self.id.clone(), "last"), Some(last)) + .on_change({ + let edit = edit.clone(); + move |day, _, cx| { + edit(Rc::new(move |draft| { + if let When::AllDay { first, .. } = draft.when { + draft.when = When::AllDay { first, last: day }; + } + }))(cx) + } + }) + .into_any_element(), + ], + }; + let theme = cx.theme(); + let chart = theme.colors.chart; + let (first, _) = Event { + key: self.id.to_string().into(), + title: draft.title.clone(), + when: draft.when, + hue: draft.hue, + place: None, + } + .days(&zone); + let fault = draft.fault(); + let save = { + let (editing, on_save, id) = (editing.clone(), self.on_save.clone(), self.id.clone()); + Button::new((self.id.clone(), "save"), "Save") + .variant(ButtonVariant::Primary) + .disabled(fault.is_some()) + .on_click(move |_, window, cx| { + let draft = editing.read(cx).current(cx); + log::info!("event editor {id:?}: save {}", draft.title); + if let Some(on_save) = &on_save { + on_save(&draft, window, cx); + } + }) + }; + let cancel = self.on_cancel.clone().map(|on_cancel| { + Button::new((self.id.clone(), "cancel"), "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| on_cancel(window, cx)) + }); + let delete = self.on_delete.clone().map(|on_delete| { + Button::new((self.id.clone(), "delete"), "Delete") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| on_delete(window, cx)) + }); + let (title, place, notes) = { + let editing = editing.read(cx); + ( + editing.title.clone(), + editing.place.clone(), + editing.notes.clone(), + ) + }; + div() + .debug_selector(|| "event-editor".into()) + .min_w_full() + .flex() + .flex_col() + .gap_4() + .child( + FormField::new((self.id.clone(), "title-field"), "Title") + .required() + .child(Input::new(&title)), + ) + .child( + Switch::new((self.id.clone(), "all-day"), whole) + .label("All day") + .on_change(move |_, _, cx| toggle(cx)), + ) + .child( + div().flex().flex_wrap().gap_3().children( + span.into_iter() + .zip(["Starts", "Ends"]) + .map(|(picker, label)| { + div().flex_1().min_w(theme.label_width()).child( + FormField::new((self.id.clone(), label), label).child(picker), + ) + }), + ), + ) + .children((fault == Some("It ends before it starts.")).then(|| { + div() + .debug_selector(|| "editor-fault".into()) + .child(FormError::new( + (self.id.clone(), "fault"), + "It ends before it starts.", + )) + })) + .child( + FormField::new((self.id.clone(), "place-field"), "Where").child(Input::new(&place)), + ) + .child( + FormField::new((self.id.clone(), "color-field"), "Color").child( + ColorPalette::new( + (self.id.clone(), "color"), + HUES.iter().zip(chart).map(|(name, color)| (*name, color)), + ) + .selected(chart[draft.hue]) + .on_change({ + let edit = edit.clone(); + move |color, _, cx| { + let hue = chart + .iter() + .position(|each| *each == color) + .expect("a hue of the chart"); + edit(Rc::new(move |draft| draft.hue = hue))(cx) + } + }), + ), + ) + .child( + FormField::new((self.id.clone(), "repeat-field"), "Repeat").child( + RecurrenceEditor::new((self.id.clone(), "repeat"), first, draft.repeat.clone()) + .on_change({ + let edit = edit.clone(); + move |rule, _, cx| { + edit(Rc::new(move |draft| draft.repeat = rule.clone()))(cx) + } + }), + ), + ) + .child( + FormField::new((self.id.clone(), "reminders-field"), "Reminders").child( + ReminderPicker::new((self.id.clone(), "reminders"), draft.reminders.clone()) + .on_change({ + let edit = edit.clone(); + move |minutes, _, cx| { + let minutes = minutes.to_vec(); + edit(Rc::new(move |draft| draft.reminders = minutes.clone()))(cx) + } + }), + ), + ) + .child( + FormField::new((self.id.clone(), "notes-field"), "Notes").child(Input::new(¬es)), + ) + .child( + div() + .debug_selector(|| "editor-actions".into()) + .flex() + .items_center() + .justify_between() + .gap_2() + .child(div().children(delete)) + .child(div().flex().gap_2().children(cancel).child(save)), + ) + } +} diff --git a/src/calendar/mod.rs b/src/calendar/mod.rs index 01a6853..33b9a95 100644 --- a/src/calendar/mod.rs +++ b/src/calendar/mod.rs @@ -1,8 +1,10 @@ mod agenda; mod allday; +mod availability; mod card; mod chip; mod days; +mod editor; mod event; mod grid; mod head; @@ -11,6 +13,9 @@ mod month; mod now; mod people; mod popover; +mod recurrence; +mod reminders; +mod repeat; mod weeks; mod year; @@ -19,13 +24,18 @@ mod tests; pub use agenda::AgendaView; pub use allday::AllDayRow; +pub use availability::AvailabilityPicker; pub use card::EventCard; pub use chip::EventChip; pub use days::{CalendarDayView, CalendarDays, CalendarWeekView}; +pub use editor::{EventDraft, EventEditor}; pub use event::{Event, When}; pub use grid::{TimeGrid, TimezoneOverlay}; pub use month::CalendarMonthView; pub use now::CurrentTimeIndicator; pub use people::{Answer, Attendee, AttendeeList}; pub use popover::EventPopover; +pub use recurrence::RecurrenceEditor; +pub use reminders::ReminderPicker; +pub use repeat::{Ends, Frequency, Recurrence}; pub use year::YearView; diff --git a/src/calendar/recurrence.rs b/src/calendar/recurrence.rs new file mode 100644 index 0000000..d0a39bd --- /dev/null +++ b/src/calendar/recurrence.rs @@ -0,0 +1,284 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::*, +}; +use jiff::{ + ToSpan, + civil::{Date, Weekday}, +}; + +use super::repeat::{Ends, Frequency, Recurrence}; +use crate::{ + forms::{Choice, ChoiceChips, DatePicker, NumberInput, RadioGroup, Select}, + theme::{ActiveTheme, ControlSize, TextSize}, +}; + +type OnRecurrence = Rc, &mut Window, &mut App)>; + +const FREQUENCIES: [(&str, &str, Option); 5] = [ + ("never", "Does not repeat", None), + ("daily", "Daily", Some(Frequency::Daily)), + ("weekly", "Weekly", Some(Frequency::Weekly)), + ("monthly", "Monthly", Some(Frequency::Monthly)), + ("yearly", "Yearly", Some(Frequency::Yearly)), +]; + +const WEEKDAYS: [(&str, &str, Weekday); 7] = [ + ("mon", "Mon", Weekday::Monday), + ("tue", "Tue", Weekday::Tuesday), + ("wed", "Wed", Weekday::Wednesday), + ("thu", "Thu", Weekday::Thursday), + ("fri", "Fri", Weekday::Friday), + ("sat", "Sat", Weekday::Saturday), + ("sun", "Sun", Weekday::Sunday), +]; + +fn frequency_key(frequency: Option) -> &'static str { + FREQUENCIES + .iter() + .find(|(_, _, each)| *each == frequency) + .map(|(key, _, _)| *key) + .expect("every frequency has a key") +} + +fn weekday_key(day: Weekday) -> &'static str { + WEEKDAYS + .iter() + .find(|(_, _, each)| *each == day) + .map(|(key, _, _)| *key) + .expect("every weekday has a key") +} + +/// Sets how an event repeats: not at all, or daily, weekly on its days, monthly or yearly, every so many, until a day or a count. A line under it reads the rule back with the next days it falls on. +#[derive(IntoElement)] +pub struct RecurrenceEditor { + id: ElementId, + first: Date, + value: Option, + on_change: Option, +} + +impl RecurrenceEditor { + /// For an event that starts on `first`. + pub fn new(id: impl Into, first: Date, value: Option) -> Self { + Self { + id: id.into(), + first, + value, + on_change: None, + } + } + + /// Gets the rule after each change, or none when it stops repeating. + pub fn on_change( + mut self, + handler: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RecurrenceEditor { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let set: OnRecurrence = { + let (id, on_change) = (self.id.clone(), self.on_change); + Rc::new(move |next, window, cx| { + log::info!("recurrence editor {id:?}: {next:?}"); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + }; + let rule = self.value.clone(); + let choices = FREQUENCIES.map(|(key, label, _)| Choice::new(key, label)); + let pick = { + let (set, rule) = (set.clone(), rule.clone()); + move |key: &SharedString, window: &mut Window, cx: &mut App| { + let frequency = FREQUENCIES + .iter() + .find(|(each, _, _)| *each == key.as_ref()) + .and_then(|(_, _, frequency)| *frequency); + let next = frequency.map(|frequency| match &rule { + Some(rule) => Recurrence { + frequency, + ..rule.clone() + }, + None => Recurrence::new(frequency), + }); + set(next, window, cx) + } + }; + let theme = cx.theme(); + let frequency = Select::new((self.id.clone(), "frequency"), choices) + .selected(frequency_key(rule.as_ref().map(|rule| rule.frequency))) + .on_change(pick); + let Some(rule) = rule else { + return div().min_w_full().child(frequency); + }; + let unit = match (rule.frequency, rule.interval) { + (Frequency::Daily, 1) => "day", + (Frequency::Daily, _) => "days", + (Frequency::Weekly, 1) => "week", + (Frequency::Weekly, _) => "weeks", + (Frequency::Monthly, 1) => "month", + (Frequency::Monthly, _) => "months", + (Frequency::Yearly, 1) => "year", + (Frequency::Yearly, _) => "years", + }; + let every = { + let (set, rule) = (set.clone(), rule.clone()); + NumberInput::new((self.id.clone(), "interval"), f64::from(rule.interval)) + .range(1.0, 99.0) + .size(ControlSize::Sm) + .on_change(move |value, window, cx| { + let interval = value as u16; + set( + Some(Recurrence { + interval, + ..rule.clone() + }), + window, + cx, + ) + }) + }; + let days = (rule.frequency == Frequency::Weekly).then(|| { + let (set, rule, first) = (set.clone(), rule.clone(), self.first); + let on: Vec<&str> = match rule.weekdays.is_empty() { + true => vec![weekday_key(first.weekday())], + false => rule.weekdays.iter().map(|day| weekday_key(*day)).collect(), + }; + ChoiceChips::new( + (self.id.clone(), "weekdays"), + WEEKDAYS.map(|(key, label, _)| Choice::new(key, label)), + ) + .multiple() + .selected(on) + .on_change(move |keys, window, cx| { + let weekdays = WEEKDAYS + .iter() + .filter(|(key, _, _)| keys.iter().any(|each| each.as_ref() == *key)) + .map(|(_, _, day)| *day) + .collect(); + set( + Some(Recurrence { + weekdays, + ..rule.clone() + }), + window, + cx, + ) + }) + }); + let ends = { + let (set, rule, first) = (set.clone(), rule.clone(), self.first); + let key = match rule.ends { + Ends::Never => "never", + Ends::On(_) => "on", + Ends::After(_) => "after", + }; + RadioGroup::new( + (self.id.clone(), "ends"), + [ + Choice::new("never", "Never"), + Choice::new("on", "On a day"), + Choice::new("after", "After a number of times"), + ], + ) + .selected(key) + .on_change(move |key, window, cx| { + let ends = match key.as_ref() { + "on" => Ends::On(first.checked_add(1.month()).expect("a month ahead")), + "after" => Ends::After(10), + _ => Ends::Never, + }; + set( + Some(Recurrence { + ends, + ..rule.clone() + }), + window, + cx, + ) + }) + }; + let until = match rule.ends { + Ends::On(last) => { + let (set, rule) = (set.clone(), rule.clone()); + Some( + DatePicker::new((self.id.clone(), "until"), Some(last)) + .on_change(move |last, window, cx| { + let ends = Ends::On(last); + set( + Some(Recurrence { + ends, + ..rule.clone() + }), + window, + cx, + ) + }) + .into_any_element(), + ) + } + Ends::After(count) => { + let (set, rule) = (set.clone(), rule.clone()); + Some( + NumberInput::new((self.id.clone(), "count"), f64::from(count)) + .range(1.0, 999.0) + .size(ControlSize::Sm) + .on_change(move |value, window, cx| { + let ends = Ends::After(value as u16); + set( + Some(Recurrence { + ends, + ..rule.clone() + }), + window, + cx, + ) + }) + .into_any_element(), + ) + } + Ends::Never => None, + }; + let next: Vec = rule + .days(self.first, 4) + .iter() + .map(|day| day.strftime("%a %b %-d").to_string()) + .collect(); + let quiet = |text: String| { + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(text) + }; + div() + .debug_selector(|| "recurrence".into()) + .min_w_full() + .flex() + .flex_col() + .gap_3() + .child(frequency) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(quiet("Every".into())) + .child(div().w_24().child(every)) + .child(quiet(unit.into())), + ) + .children(days) + .child(ends) + .children(until) + .child(quiet(rule.describe(self.first))) + .when(!next.is_empty(), |editor| { + editor.child(quiet(format!("Next: {}", next.join(", ")))) + }) + } +} diff --git a/src/calendar/reminders.rs b/src/calendar/reminders.rs new file mode 100644 index 0000000..50e41c9 --- /dev/null +++ b/src/calendar/reminders.rs @@ -0,0 +1,171 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + forms::{Choice, Select}, + primitives::IconName, + theme::ControlSize, +}; + +type OnMinutes = Rc; + +/// The reminders on offer, in minutes before an event, with their words. +pub(crate) const OFFSETS: [(u32, &str); 9] = [ + (0, "At the time"), + (5, "5 minutes before"), + (10, "10 minutes before"), + (15, "15 minutes before"), + (30, "30 minutes before"), + (60, "1 hour before"), + (120, "2 hours before"), + (1440, "1 day before"), + (10080, "1 week before"), +]; + +/// `minutes` sorted, each asserted to be on offer and to come once. +fn checked(minutes: &[u32]) -> Vec { + let mut sorted = minutes.to_vec(); + sorted.sort_unstable(); + for (ix, minute) in sorted.iter().enumerate() { + let offered = OFFSETS.iter().any(|(each, _)| each == minute); + assert!(offered, "no reminder {minute} minutes before is on offer"); + assert!( + ix == 0 || sorted[ix - 1] != *minute, + "reminder {minute} twice" + ); + } + sorted +} + +/// Reminders before an event: each picked from a list, with a way off. Add brings the nearest one not yet set. +#[derive(IntoElement)] +pub struct ReminderPicker { + id: ElementId, + minutes: Vec, + on_change: Option, +} + +impl ReminderPicker { + /// `minutes` before the event, each one of those on offer. + pub fn new(id: impl Into, minutes: impl IntoIterator) -> Self { + let minutes: Vec = minutes.into_iter().collect(); + Self { + id: id.into(), + minutes: checked(&minutes), + on_change: None, + } + } + + /// Gets the minutes before after each change, soonest first. + pub fn on_change(mut self, handler: impl Fn(&[u32], &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ReminderPicker { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let set: OnMinutes = { + let (id, on_change) = (self.id.clone(), self.on_change); + Rc::new(move |next, window, cx| { + let next = checked(next); + log::info!("reminder picker {id:?}: {next:?}"); + if let Some(on_change) = &on_change { + on_change(&next, window, cx); + } + }) + }; + let minutes = Rc::new(self.minutes); + let rows = minutes.iter().enumerate().map(|(ix, minute)| { + let choices = OFFSETS + .iter() + .filter(|(each, _)| each == minute || !minutes.contains(each)) + .map(|(each, words)| Choice::new(each.to_string(), *words)); + let (change, remove) = (set.clone(), set.clone()); + let (changing, removing) = (minutes.clone(), minutes.clone()); + div() + .flex() + .items_center() + .gap_2() + .child( + div().flex_1().min_w_0().child( + Select::new((self.id.clone(), format!("offset-{minute}")), choices) + .size(ControlSize::Sm) + .selected(minute.to_string()) + .on_change(move |value: &SharedString, window, cx| { + let picked: u32 = value.parse().expect("an offset is a count"); + let mut next = changing.to_vec(); + next[ix] = picked; + change(&next, window, cx) + }), + ), + ) + .child( + IconButton::new((self.id.clone(), format!("remove-{minute}")), IconName::X) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Remove reminder") + .on_click(move |_, window, cx| { + let next: Vec = removing + .iter() + .copied() + .filter(|each| each != &removing[ix]) + .collect(); + remove(&next, window, cx) + }), + ) + }); + let spare = OFFSETS + .iter() + .map(|(minute, _)| *minute) + .find(|minute| !minutes.contains(minute)); + let add = { + let (set, minutes) = (set.clone(), minutes.clone()); + Button::new((self.id.clone(), "add"), "Add reminder") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .icon(IconName::Plus) + .disabled(spare.is_none()) + .on_click(move |_, window, cx| { + let mut next = minutes.to_vec(); + next.push(spare.expect("Add waits for a reminder not yet set")); + set(&next, window, cx) + }) + }; + div() + .debug_selector(|| "reminders".into()) + .min_w_full() + .flex() + .flex_col() + .gap_2() + .children(rows.collect::>()) + .child(div().flex().child(add)) + } +} + +#[cfg(test)] +mod tests { + use super::checked; + + #[test] + fn reminders_sort_soonest_first() { + assert_eq!(checked(&[1440, 10, 0]), [0, 10, 1440]); + } + + #[test] + #[should_panic(expected = "no reminder 7 minutes before is on offer")] + fn a_reminder_is_one_on_offer() { + checked(&[7]); + } + + #[test] + #[should_panic(expected = "reminder 10 twice")] + fn a_reminder_comes_once() { + checked(&[10, 10]); + } +} diff --git a/src/calendar/repeat.rs b/src/calendar/repeat.rs new file mode 100644 index 0000000..4e69417 --- /dev/null +++ b/src/calendar/repeat.rs @@ -0,0 +1,308 @@ +use jiff::{ + ToSpan, + civil::{Date, Weekday}, +}; + +use crate::forms::{show_date, week_start, weekday_words}; + +/// How often an event comes back. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Frequency { + Daily, + Weekly, + Monthly, + Yearly, +} + +/// When a run of repeats stops: never, after a day, or after a count of them, the first among them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ends { + Never, + On(Date), + After(u16), +} + +/// An event's repeats: how often, every how many, on which weekdays for a weekly one, and when they stop. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Recurrence { + pub frequency: Frequency, + pub interval: u16, + pub weekdays: Vec, + pub ends: Ends, +} + +/// "1st", "2nd", "3rd", "11th", "22nd". +fn ordinal(day: i8) -> String { + let suffix = match (day % 10, day % 100) { + (_, 11..=13) => "th", + (1, _) => "st", + (2, _) => "nd", + (3, _) => "rd", + _ => "th", + }; + format!("{day}{suffix}") +} + +impl Recurrence { + /// Every one, on the first day's weekday for a weekly one, without end. + pub fn new(frequency: Frequency) -> Self { + Self { + frequency, + interval: 1, + weekdays: Vec::new(), + ends: Ends::Never, + } + } + + /// The weekdays a weekly one falls on, Monday first: its own, or the first day's. + fn on(&self, first: Date) -> Vec { + let mut days = match self.weekdays.is_empty() { + true => vec![first.weekday()], + false => self.weekdays.clone(), + }; + days.sort_by_key(|day| day.to_monday_zero_offset()); + days.dedup(); + days + } + + /// In words, for an event that starts on `first`: "Every 2 weeks on Monday and Thursday, until Dec 31, 2026". + pub fn describe(&self, first: Date) -> String { + assert!( + self.interval > 0, + "a recurrence comes back every one or more" + ); + let every = |one: &str, many: &str| match self.interval { + 1 => format!("Every {one}"), + n => format!("Every {n} {many}"), + }; + let what = match self.frequency { + Frequency::Daily => every("day", "days"), + Frequency::Weekly => { + let days: Vec = self + .on(first) + .iter() + .map(|day| day.to_sunday_zero_offset() as u8) + .collect(); + format!("{} on {}", every("week", "weeks"), weekday_words(&days)) + } + Frequency::Monthly => format!( + "{} on the {}", + every("month", "months"), + ordinal(first.day()) + ), + Frequency::Yearly => { + format!("{} on {}", every("year", "years"), first.strftime("%B %-d")) + } + }; + match self.ends { + Ends::Never => what, + Ends::On(last) => format!("{what}, until {}", show_date(last)), + Ends::After(1) => format!("{what}, once"), + Ends::After(count) => format!("{what}, {count} times"), + } + } + + /// The first `limit` days it falls on, `first` among them, before it ends. A month or a year without the first day's date is passed over. + pub fn days(&self, first: Date, limit: usize) -> Vec { + assert!( + self.interval > 0, + "a recurrence comes back every one or more" + ); + let cap = match self.ends { + Ends::After(count) => limit.min(usize::from(count)), + _ => limit, + }; + let within = |day: Date| match self.ends { + Ends::On(last) => day <= last, + _ => true, + }; + let step = i64::from(self.interval); + let mut days = Vec::with_capacity(cap); + let mut round: i64 = 0; + while days.len() < cap { + let candidates: Vec = match self.frequency { + Frequency::Daily => vec![ + first + .checked_add((round * step).days()) + .expect("a day ahead"), + ], + Frequency::Weekly => { + let monday = week_start(first) + .checked_add((round * step).weeks()) + .expect("a week ahead"); + self.on(first) + .into_iter() + .map(|day| { + monday + .checked_add(i64::from(day.to_monday_zero_offset()).days()) + .expect("a day of the week") + }) + .filter(|day| *day >= first) + .collect() + } + Frequency::Monthly => { + let month = first + .first_of_month() + .checked_add((round * step).months()) + .expect("a month ahead"); + Date::new(month.year(), month.month(), first.day()) + .into_iter() + .collect() + } + Frequency::Yearly => { + let year = first.year() + (round * step) as i16; + Date::new(year, first.month(), first.day()) + .into_iter() + .collect() + } + }; + for day in candidates { + if !within(day) || days.len() == cap { + return days; + } + days.push(day); + } + round += 1; + } + days + } +} + +#[cfg(test)] +mod tests { + use jiff::civil::{Weekday, date}; + + use super::{Ends, Frequency, Recurrence, ordinal}; + + fn every(frequency: Frequency, interval: u16) -> Recurrence { + Recurrence { + interval, + ..Recurrence::new(frequency) + } + } + + #[test] + fn ordinals_read_as_spoken() { + let said: Vec = [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31] + .map(ordinal) + .to_vec(); + assert_eq!( + said, + [ + "1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "23rd", "31st" + ] + ); + } + + #[test] + fn a_recurrence_reads_in_words() { + let sunday = date(2026, 9, 27); + assert_eq!(every(Frequency::Daily, 1).describe(sunday), "Every day"); + assert_eq!(every(Frequency::Daily, 3).describe(sunday), "Every 3 days"); + assert_eq!( + every(Frequency::Weekly, 1).describe(sunday), + "Every week on Sunday" + ); + let twice = Recurrence { + weekdays: vec![Weekday::Thursday, Weekday::Monday], + ..every(Frequency::Weekly, 2) + }; + assert_eq!( + twice.describe(sunday), + "Every 2 weeks on Monday and Thursday" + ); + let workdays = Recurrence { + weekdays: vec![ + Weekday::Monday, + Weekday::Tuesday, + Weekday::Wednesday, + Weekday::Thursday, + Weekday::Friday, + ], + ..every(Frequency::Weekly, 1) + }; + assert_eq!( + workdays.describe(sunday), + "Every week on Monday through Friday" + ); + assert_eq!( + every(Frequency::Monthly, 1).describe(sunday), + "Every month on the 27th" + ); + assert_eq!( + every(Frequency::Yearly, 1).describe(sunday), + "Every year on September 27" + ); + let until = Recurrence { + ends: Ends::On(date(2026, 12, 31)), + ..every(Frequency::Daily, 1) + }; + assert_eq!(until.describe(sunday), "Every day, until Dec 31, 2026"); + let counted = Recurrence { + ends: Ends::After(10), + ..every(Frequency::Monthly, 3) + }; + assert_eq!( + counted.describe(sunday), + "Every 3 months on the 27th, 10 times" + ); + } + + #[test] + fn a_weekly_one_falls_on_its_days_from_the_first() { + let twice = Recurrence { + weekdays: vec![Weekday::Monday, Weekday::Thursday], + ..every(Frequency::Weekly, 2) + }; + assert_eq!( + twice.days(date(2026, 9, 27), 4), + [ + date(2026, 10, 5), + date(2026, 10, 8), + date(2026, 10, 19), + date(2026, 10, 22) + ], + "weeks count from the start's own, whose Monday and Thursday have passed" + ); + let weekly = Recurrence { + interval: 1, + ..twice + }; + assert_eq!( + weekly.days(date(2026, 9, 27), 3), + [date(2026, 9, 28), date(2026, 10, 1), date(2026, 10, 5)] + ); + } + + #[test] + fn a_month_or_year_without_the_date_is_passed_over() { + assert_eq!( + every(Frequency::Monthly, 1).days(date(2027, 1, 31), 3), + [date(2027, 1, 31), date(2027, 3, 31), date(2027, 5, 31)] + ); + assert_eq!( + every(Frequency::Yearly, 1).days(date(2028, 2, 29), 2), + [date(2028, 2, 29), date(2032, 2, 29)] + ); + } + + #[test] + fn repeats_stop_after_a_count_or_a_day() { + let counted = Recurrence { + ends: Ends::After(3), + ..every(Frequency::Daily, 2) + }; + assert_eq!( + counted.days(date(2026, 9, 27), 10), + [date(2026, 9, 27), date(2026, 9, 29), date(2026, 10, 1)] + ); + let until = Recurrence { + ends: Ends::On(date(2026, 10, 10)), + ..every(Frequency::Weekly, 1) + }; + assert_eq!( + until.days(date(2026, 9, 27), 10), + [date(2026, 9, 27), date(2026, 10, 4)] + ); + } +} diff --git a/src/calendar/tests/editing.rs b/src/calendar/tests/editing.rs new file mode 100644 index 0000000..9fe982a --- /dev/null +++ b/src/calendar/tests/editing.rs @@ -0,0 +1,163 @@ +use gpui::{Entity, IntoElement, Modifiers, TestAppContext, VisualTestContext, point, px}; +use jiff::{civil::date, tz::TimeZone}; + +use super::{Planning, at, heard, note, planning, press, settle, tab_to}; +use crate::calendar::{ + AvailabilityPicker, EventDraft, EventEditor, RecurrenceEditor, ReminderPicker, When, +}; + +fn draft(title: &str, from: i8, to: i8) -> EventDraft { + EventDraft { + title: title.to_string().into(), + when: When::Timed { + start: at(22, from), + end: at(22, to), + }, + place: "".into(), + hue: 0, + repeat: None, + reminders: Vec::new(), + notes: "".into(), + } +} + +fn said(draft: &EventDraft) -> String { + match draft.when { + When::Timed { start, end } => format!("save {} {start} {end}", draft.title), + When::AllDay { first, last } => format!("save {} {first} to {last}", draft.title), + } +} + +fn editor(host: &Planning, owner: Entity) -> gpui::AnyElement { + let title = match host.month.day() { + 1 => "", + _ => "Crit", + }; + EventEditor::new("editor", draft(title, 9, 10)) + .zone(TimeZone::UTC) + .on_save(move |draft, _, cx| note(&owner, said(draft), cx)) + .into_any_element() +} + +/// Presses the editor's last action, Save, at the right end of its row. +fn save(cx: &mut VisualTestContext) { + let row = cx.debug_bounds("editor-actions").expect("the actions draw"); + cx.simulate_click( + point(row.right() - px(8.0), row.center().y), + Modifiers::none(), + ); + settle(cx); +} + +#[gpui::test] +fn save_waits_for_a_title_then_hands_on_the_draft(cx: &mut TestAppContext) { + let (host, cx) = planning(editor, cx); + save(cx); + assert!(heard(&host, cx).is_empty(), "no title, no save"); + tab_to(1, cx); + cx.simulate_input(" Model review "); + settle(cx); + save(cx); + assert_eq!( + heard(&host, cx), + [format!("save Model review {} {}", at(22, 9), at(22, 10))] + ); +} + +#[gpui::test] +fn all_day_turns_the_hours_into_their_day(cx: &mut TestAppContext) { + let (host, cx) = planning(editor, cx); + tab_to(1, cx); + cx.simulate_input("Fair"); + settle(cx); + tab_to(2, cx); + press("space", cx); + save(cx); + assert_eq!(heard(&host, cx), ["save Fair 2026-09-22 to 2026-09-22"]); +} + +fn backward(_: &Planning, owner: Entity) -> gpui::AnyElement { + EventEditor::new("editor", draft("Crit", 10, 9)) + .zone(TimeZone::UTC) + .on_save(move |draft, _, cx| note(&owner, said(draft), cx)) + .into_any_element() +} + +#[gpui::test] +fn an_end_before_the_start_says_so_and_holds_save(cx: &mut TestAppContext) { + let (host, cx) = planning(backward, cx); + assert!(cx.debug_bounds("editor-fault").is_some()); + save(cx); + assert!(heard(&host, cx).is_empty()); +} + +#[gpui::test] +fn a_new_draft_from_the_owner_starts_the_editor_over(cx: &mut TestAppContext) { + let (host, cx) = planning(editor, cx); + host.update(cx, |host, cx| { + host.month = date(2026, 10, 2); + cx.notify(); + }); + settle(cx); + save(cx); + assert_eq!( + heard(&host, cx), + [format!("save Crit {} {}", at(22, 9), at(22, 10))] + ); +} + +fn reminders(_: &Planning, owner: Entity) -> gpui::AnyElement { + ReminderPicker::new("reminders", [10]) + .on_change(move |minutes, _, cx| note(&owner, format!("{minutes:?}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn add_brings_the_nearest_reminder_not_yet_set(cx: &mut TestAppContext) { + let (host, cx) = planning(reminders, cx); + tab_to(3, cx); + press("space", cx); + tab_to(2, cx); + press("space", cx); + assert_eq!(heard(&host, cx), ["[0, 10]", "[]"], "Add, then the way off"); +} + +fn repeat(_: &Planning, owner: Entity) -> gpui::AnyElement { + RecurrenceEditor::new("repeat", date(2026, 9, 27), None) + .on_change(move |rule, _, cx| { + let words = rule.map(|rule| rule.describe(date(2026, 9, 27))); + note(&owner, format!("{words:?}"), cx) + }) + .into_any_element() +} + +#[gpui::test] +fn picking_a_frequency_starts_a_rule(cx: &mut TestAppContext) { + let (host, cx) = planning(repeat, cx); + tab_to(1, cx); + cx.simulate_keystrokes("down down down enter"); + settle(cx); + assert_eq!(heard(&host, cx), ["Some(\"Every week on Sunday\")"]); +} + +fn slots(_: &Planning, owner: Entity) -> gpui::AnyElement { + AvailabilityPicker::new("slots", [date(2026, 9, 28), date(2026, 9, 29)]) + .zone(TimeZone::UTC) + .hours(9, 12) + .busy([(at(28, 9), at(28, 10))]) + .now(at(27, 12)) + .on_pick(move |start, _, cx| note(&owner, format!("pick {start}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn a_slot_pick_hands_on_its_start(cx: &mut TestAppContext) { + let (host, cx) = planning(slots, cx); + tab_to(3, cx); + press("space", cx); + assert_eq!( + heard(&host, cx), + [format!("pick {}", at(28, 10))], + "09:00 to 10:00 is taken" + ); +} diff --git a/src/calendar/tests/mod.rs b/src/calendar/tests/mod.rs index 0405e88..c0993ff 100644 --- a/src/calendar/tests/mod.rs +++ b/src/calendar/tests/mod.rs @@ -7,6 +7,7 @@ use jiff::{Timestamp, civil::date, tz::TimeZone}; use super::{CalendarMonthView, Event, YearView}; +mod editing; mod people; mod times; use crate::{primitives::FocusNext, theme::Theme}; diff --git a/src/forms/date/mod.rs b/src/forms/date/mod.rs index 53d9756..ad18bc3 100644 --- a/src/forms/date/mod.rs +++ b/src/forms/date/mod.rs @@ -23,6 +23,7 @@ pub use picker::{DatePicker, DateRangePicker}; pub(crate) use picker::{Face, dropdown, picker_field}; pub use relative::{RelativeDatePicker, RelativeRange}; pub use rule::CronRule; +pub(crate) use rule::weekday_words; pub use time::{DateTimePicker, DurationPicker, TimePicker}; pub use week::WeekPicker; pub use zone::TimezoneSelect; diff --git a/src/forms/date/rule.rs b/src/forms/date/rule.rs index 3128962..e9c67ce 100644 --- a/src/forms/date/rule.rs +++ b/src/forms/date/rule.rs @@ -273,7 +273,7 @@ fn join(words: &[String]) -> String { } /// Weekdays in words, Monday first, with a run of three or more as a span. -fn weekday_words(values: &[u8]) -> String { +pub(crate) 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(); diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 5e1962f..2a085f8 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -63,7 +63,9 @@ pub use date::{ MonthPicker, QuarterPicker, RelativeDatePicker, RelativeRange, TimePicker, TimezoneSelect, WeekPicker, YearPicker, }; -pub(crate) use date::{Face, dropdown, month_grid, picker_field, show_span, week_start}; +pub(crate) use date::{ + Face, dropdown, month_grid, picker_field, show_date, show_span, week_start, weekday_words, +}; pub use expression::{ExpressionInput, evaluate, expression_highlights}; pub use files::{DropZone, FileInput, PICTURES}; pub(crate) use files::{browse, dropped}; diff --git a/src/forms/structure.rs b/src/forms/structure.rs index abc21ff..e54fbcb 100644 --- a/src/forms/structure.rs +++ b/src/forms/structure.rs @@ -275,7 +275,7 @@ impl RenderOnce for FormField { .flex() .flex_col() .gap_1p5() - .w_full() + .min_w_full() .child(self.label) .children(self.children) .children(note) diff --git a/src/forms/tests/structure.rs b/src/forms/tests/structure.rs index 9f992fa..bcfd2b6 100644 --- a/src/forms/tests/structure.rs +++ b/src/forms/tests/structure.rs @@ -1,6 +1,6 @@ use gpui::{ - AppContext as _, Context, Entity, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, - TestAppContext, VisualTestContext, Window, div, + AppContext as _, Context, Entity, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, + ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px, }; use super::setup; @@ -134,3 +134,36 @@ fn field_array_buttons_remove_their_own_row_and_add_one(cx: &mut TestAppContext) ["a", "c", "new"] ); } + +/// A field over a long line in a column that a padded block measures by content. +struct Narrow; + +impl Render for Narrow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let control = div().debug_selector(|| "narrow-control".into()).h_2(); + div().w(px(280.)).flex().flex_col().child( + div().p_5().child( + div() + .flex() + .flex_col() + .child(FormField::new("narrow", "Name").child(control)) + .child("A line long enough to wrap in a narrow card, so the column fills it."), + ), + ) + } +} + +#[gpui::test] +fn a_field_fills_a_column_its_block_measures_by_content(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Narrow); + cx.run_until_parked(); + let control = cx + .debug_bounds("narrow-control") + .expect("the control draws"); + assert_eq!( + control.size.width, + px(240.), + "the field spans the card inside its padding" + ); +} diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index a75ff18..2663136 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -409,7 +409,7 @@ ## T30d Calendar · Editing -- [ ] EventEditor -- [ ] RecurrenceEditor -- [ ] ReminderPicker -- [ ] AvailabilityPicker +- [x] EventEditor +- [x] RecurrenceEditor +- [x] ReminderPicker +- [x] AvailabilityPicker