diff --git a/AGENTS.md b/AGENTS.md index 879dc4a..c544d24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. - A window keeps one `feedback::Toaster` and renders one `ToastViewport`; anything holding the entity pushes to it. Messages share `primitives::Severity`. - Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. `taken` adds file kinds by extension, for drops and the dialog alike (`DropZone::kinds`). -- A stroke drawn with the pointer goes through `forms::pen`: a press starts it, a drag extends it, and the lift hands it over, in or out of the pad (`SignaturePad`, `generative::InpaintCanvas`). +- A stroke drawn with the pointer goes through `forms::pen`: a press starts it, a drag extends it, and the lift hands it over, in or out of the pad (`SignaturePad`, `generative::InpaintCanvas`, `calendar::TimeGrid`'s drag on empty time). - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. - `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus, through `layout::bring_into_view` along the box's axis; the canvas measures the stop with its ring (`messaging::GifPicker`). Never poll layout with `request_animation_frame`. - gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view. diff --git a/TASKS.md b/TASKS.md index e762013..de730f6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -107,7 +107,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T29b Mail · Reading - [x] T29c Mail · Writing - [x] T29d Mail · Sorting -- [ ] T30a Calendar · Months +- [x] T30a Calendar · Months - [ ] T30b Calendar · Time grid - [ ] T30c Calendar · Agenda and people - [ ] T30d Calendar · Editing @@ -354,3 +354,4 @@ Per-component lines live in `tasks/`. Tags there: | 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 | | +| T30a | 1 | PASS | | diff --git a/examples/gallery/pages/calendar/mod.rs b/examples/gallery/pages/calendar/mod.rs index dd0dbd0..b0efc6b 100644 --- a/examples/gallery/pages/calendar/mod.rs +++ b/examples/gallery/pages/calendar/mod.rs @@ -2,13 +2,16 @@ use ely_gpui_component::{ calendar::Event, theme::{ActiveTheme, TextSize}, }; -use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div}; +use gpui::{ + AnyElement, App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, +}; use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; mod months; +mod times; use super::Page; -use crate::script::Step; +use crate::{script::Step, ui::keep}; pub const PAGE: Page = Page { number: 30, @@ -35,6 +38,13 @@ const SCRIPT: &[Step] = &[ Step::Key("right"), Step::Wait(150), Step::Shot("year-cursor"), + Step::DownAt("calendar-week", 560.0, 300.0), + Step::DragTo("calendar-week", 560.0, 380.0), + Step::Wait(150), + Step::Shot("week-drafting"), + Step::UpAt("calendar-week", 560.0, 380.0), + Step::Wait(200), + Step::Shot("week-made"), ]; /// What a demo says it did, under it. @@ -53,6 +63,12 @@ fn here() -> (TimeZone, Date) { (zone, today) } +/// The page's events, which a drag on the week or day adds to. +fn plan(window: &mut Window, cx: &mut App) -> Entity> { + let (zone, today) = here(); + keep("calendar-events", || events(today, &zone), window, cx) +} + /// 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"); @@ -83,6 +99,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { div() .child(months::month(window, cx)) .child(months::mini(window, cx)) + .child(times::week(window, cx)) + .child(times::day(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 index 8ba7e0d..667c158 100644 --- a/examples/gallery/pages/calendar/months.rs +++ b/examples/gallery/pages/calendar/months.rs @@ -7,7 +7,7 @@ use gpui::{ }; use jiff::civil::Date; -use super::{events, here, quiet}; +use super::{events, here, plan, quiet}; use crate::{ probe::probe, ui::{change, keep, section, set}, @@ -37,11 +37,12 @@ fn viewing(window: &mut Window, cx: &mut App) -> Entity { } pub fn month(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { - let (zone, today) = here(); + let (zone, _) = 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)) + let events = plan(window, cx).read(cx).clone(); + let view = CalendarMonthView::new("calendar-month", now.month, events) .zone(zone) .on_month(move |first, _, cx| change(&turned, cx, |viewing| viewing.month = first)) .on_day(move |day, _, cx| { @@ -92,6 +93,7 @@ pub fn mini(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { pub fn year(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let (zone, today) = here(); + let events = plan(window, cx).read(cx).clone(); let said = keep("calendar-year-said", || None::, window, cx); let shown = said.read(cx).clone(); section( @@ -107,7 +109,7 @@ pub fn year(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .flex_col() .gap_3() .child( - YearView::new("calendar-year", today.year(), events(today, &zone)) + YearView::new("calendar-year", today.year(), events) .zone(zone) .on_day(move |day, _, cx| { let opened = format!("Opened {}.", day.strftime("%A, %B %-d")); diff --git a/examples/gallery/pages/calendar/times.rs b/examples/gallery/pages/calendar/times.rs new file mode 100644 index 0000000..9bead79 --- /dev/null +++ b/examples/gallery/pages/calendar/times.rs @@ -0,0 +1,103 @@ +use ely_gpui_component::calendar::{ + CalendarDayView, CalendarWeekView, Event, TimezoneOverlay, When, +}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, tz::TimeZone}; + +use super::{here, plan, quiet}; +use crate::{ + probe::probe, + ui::{change, keep, section, set}, +}; + +/// Adds an event from a drag and says so. +fn creating( + events: &Entity>, + said: &Entity>, +) -> impl Fn(Timestamp, Timestamp, &mut Window, &mut App) + 'static { + let (events, said) = (events.clone(), said.clone()); + move |start, end, _, cx| { + change(&events, cx, |events| { + let key = format!("made-{}", events.len()); + events.push(Event::timed(key, "New event", start, end, 4)); + }); + set(&said, Some("Made a new event.".into()), cx) + } +} + +/// Moves an event's end to where its edge was dragged. +fn resizing( + events: &Entity>, +) -> impl Fn(&SharedString, Timestamp, &mut Window, &mut App) + 'static { + let events = events.clone(); + move |key, end, _, cx| { + change(&events, cx, |events| { + let event = events + .iter_mut() + .find(|event| event.key == *key) + .expect("a resized event is listed"); + let When::Timed { start, .. } = event.when else { + panic!("event {key}: only timed events resize"); + }; + event.when = When::Timed { start, end }; + }) + } +} + +pub fn week(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (zone, today) = here(); + let events = plan(window, cx); + let said = keep("calendar-week-said", || None::, window, cx); + let (shown, listed) = (said.read(cx).clone(), events.read(cx).clone()); + let (create, resize) = (creating(&events, &said), resizing(&events)); + let pressed = said; + section( + "CalendarWeekView / TimeGrid / AllDayRow / CurrentTimeIndicator", + "A week under its days' names: whole days lie above, the rest by their hours, and events that overlap share the width. A drag on empty time makes an event of a quarter hour or more, and a drag on an event's lower edge moves its end. Today carries a line at the minute now.", + cx, + ) + .child(probe( + "calendar-week", + div() + .w(px(800.)) + .flex() + .flex_col() + .gap_3() + .child( + div().h(px(560.)).child( + CalendarWeekView::new("calendar-week", today, listed) + .zone(zone) + .on_create(create) + .on_resize(resize) + .on_event(move |key, _, cx| { + set(&pressed, Some(format!("Pressed {key}.").into()), cx) + }), + ), + ) + .children(shown.map(|shown| quiet(shown, cx))), + )) +} + +pub fn day(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (zone, today) = here(); + let events = plan(window, cx); + let said = keep("calendar-day-said", || None::, window, cx); + let listed = events.read(cx).clone(); + let (create, resize) = (creating(&events, &said), resizing(&events)); + let tokyo = TimeZone::get("Asia/Tokyo").expect("the system knows Tokyo"); + section( + "CalendarDayView / TimezoneOverlay", + "One day, with another zone's hours beside its own, so a call across zones reads in both.", + cx, + ) + .child(probe( + "calendar-day", + div().w(px(420.)).h(px(480.)).child( + CalendarDayView::new("calendar-day", today, listed) + .zone(zone) + .overlay(TimezoneOverlay::new(tokyo)) + .on_create(create) + .on_resize(resize), + ), + )) +} diff --git a/src/calendar/allday.rs b/src/calendar/allday.rs new file mode 100644 index 0000000..3810ffc --- /dev/null +++ b/src/calendar/allday.rs @@ -0,0 +1,151 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, relative, rems, +}; +use jiff::{civil::Date, tz::TimeZone}; + +use super::{ + chip::EventChip, + event::{Event, When, unique}, + weeks::bars, +}; +use crate::{ + forms::OnValue, + theme::{ActiveTheme, TextSize}, + typography::format, +}; + +/// The whole days of a run of days, as bars across their columns in lanes, beside a label in the hours' column. It grows a lane at a time. +#[derive(IntoElement)] +pub struct AllDayRow { + id: ElementId, + days: Vec, + events: Vec, + zone: Option, + gutters: usize, + on_event: Option, +} + +impl AllDayRow { + pub fn new( + id: impl Into, + days: impl IntoIterator, + events: impl IntoIterator, + ) -> Self { + let id = id.into(); + let days: Vec = days.into_iter().collect(); + assert!(!days.is_empty(), "all-day row {id:?} has no days"); + Self { + id, + days, + events: unique(events), + zone: None, + gutters: 1, + on_event: None, + } + } + + /// The zone its days read in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// Gets the key of an event pressed. + pub fn on_event( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_event = Some(Rc::new(handler)); + self + } + + /// The hours' columns beside it, one per zone. + pub(crate) fn gutters(mut self, gutters: usize) -> Self { + self.gutters = gutters; + self + } +} + +impl RenderOnce for AllDayRow { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("all-day row")); + let whole: Vec = self + .events + .into_iter() + .filter(|event| matches!(event.when, When::AllDay { .. })) + .collect(); + let count = self.days.len(); + let bars = bars(&whole, self.days[0], count, &zone); + let lanes = bars.iter().map(|bar| bar.lane + 1).max().unwrap_or(1); + let theme = cx.theme(); + let rem = window.rem_size(); + let sizes = theme.calendar(); + let (chip, gap, pad) = ( + sizes.chip.to_pixels(rem), + rems(0.125).to_pixels(rem), + rems(0.25).to_pixels(rem), + ); + let gutters = sizes.gutter.to_pixels(rem) * self.gutters as f32; + let pitch = chip + gap; + let chips = bars.iter().map(|bar| { + let event = &whole[bar.event]; + let chip = EventChip::new( + (self.id.clone(), format!("bar-{}", event.key)), + event.clone(), + ) + .zone(zone.clone()) + .open(bar.before, bar.after); + let chip = 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, + }; + let span = (bar.to - bar.from + 1) as f32; + div() + .absolute() + .top(pad + pitch * bar.lane as f32) + .left(relative(bar.from as f32 / count as f32)) + .w(relative(span / count as f32)) + .when(!bar.before, |slot| slot.pl_0p5()) + .when(!bar.after, |slot| slot.pr_0p5()) + .child(chip) + }); + div() + .relative() + .w_full() + .h(pad * 2.0 + pitch * lanes as f32 - gap) + .border_b_1() + .border_color(theme.colors.border) + .child( + div() + .absolute() + .top_0() + .left_0() + .w(gutters) + .h_full() + .flex() + .justify_end() + .pt_1p5() + .pr_2() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child("All day"), + ) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(gutters) + .right_0() + .children(chips.collect::>()), + ) + } +} diff --git a/src/calendar/days.rs b/src/calendar/days.rs new file mode 100644 index 0000000..c0bc787 --- /dev/null +++ b/src/calendar/days.rs @@ -0,0 +1,289 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::*, relative, +}; +use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; + +use super::{ + allday::AllDayRow, + event::{Event, unique}, + grid::{OnResize, OnSpan, TimeGrid, TimezoneOverlay}, + head::{OnDay, Step, head}, +}; +use crate::{ + forms::{OnValue, show_span, week_start}, + layout::seeded::use_seeded, + theme::{ActiveTheme, ControlSize, TextSize}, + typography::format, +}; + +/// A week of days under their names. +pub type CalendarWeekView = CalendarDays<7>; +/// One day under its name. +pub type CalendarDayView = CalendarDays<1>; + +/// `DAYS` days under their names: their whole days above, their hours below, where a drag makes an event and a drag on an event's lower edge moves its end. The arrows step by the view's days; Today comes back. It fills its box. +#[derive(IntoElement)] +pub struct CalendarDays { + id: ElementId, + day: Date, + events: Vec, + zone: Option, + now: Option, + overlay: Option, + on_create: Option, + on_resize: Option, + on_event: Option, + on_step: Option, +} + +impl CalendarDays { + /// The days holding `day`: its week, or the day itself. + pub fn new( + id: impl Into, + day: Date, + events: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + day, + events: unique(events), + zone: None, + now: None, + overlay: None, + on_create: None, + on_resize: None, + on_event: None, + on_step: None, + } + } + + /// 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 it takes as now, for today and the line at the hour; the clock's otherwise. + pub fn now(mut self, now: Timestamp) -> Self { + self.now = Some(now); + self + } + + /// Another zone's hours beside its own. + pub fn overlay(mut self, overlay: TimezoneOverlay) -> Self { + self.overlay = Some(overlay); + self + } + + /// Gets the start and end of a stretch dragged out on empty time. + pub fn on_create( + mut self, + handler: impl Fn(Timestamp, Timestamp, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_create = Some(Rc::new(handler)); + self + } + + /// Gets an event's key and its new end as its lower edge is dragged. + pub fn on_resize( + mut self, + handler: impl Fn(&SharedString, Timestamp, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resize = Some(Rc::new(handler)); + self + } + + /// Gets the key of an event pressed. + pub fn on_event( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_event = Some(Rc::new(handler)); + self + } + + /// Gets the first day shown after each step. + pub fn on_step(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self { + self.on_step = Some(Rc::new(handler)); + self + } + + /// The first day shown for `day`: its Monday in a week, the day itself alone. + fn first(day: Date) -> Date { + match DAYS { + 7 => week_start(day), + _ => day, + } + } +} + +/// The days' names at their columns, today's number lit, beside the zones' names over the hours. +fn names(days: &[Date], today: Date, zones: &[String], window: &Window, cx: &App) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + let gutter = theme.calendar().gutter.to_pixels(window.rem_size()); + let gutters = gutter * zones.len() as f32; + let count = days.len() as f32; + let labels = zones.iter().enumerate().map(|(ix, zone)| { + div() + .absolute() + .top_0() + .bottom_0() + .left(gutter * ix as f32) + .w(gutter) + .flex() + .items_end() + .justify_end() + .pb_1() + .pr_2() + .text_color(colors.fg_subtle) + .child(zone.clone()) + }); + let columns = days.iter().enumerate().map(|(ix, day)| { + let lit = *day == today; + div() + .absolute() + .top_0() + .bottom_0() + .left(relative(ix as f32 / count)) + .w(relative(1.0 / count)) + .overflow_hidden() + .flex() + .flex_col() + .items_center() + .justify_end() + .pb_1() + .child( + div() + .text_color(colors.fg_subtle) + .child(day.strftime("%a").to_string()), + ) + .child( + div() + .px_1p5() + .rounded_full() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .when(lit, |number| { + number.bg(colors.accent).text_color(colors.on_accent) + }) + .child(day.day().to_string()), + ) + }); + div() + .relative() + .flex_none() + .w_full() + .h(theme.control_height(ControlSize::Lg) * 1.25) + .border_b_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Xs)) + .children(labels) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(gutters) + .right_0() + .children(columns), + ) + .into_any_element() +} + +impl RenderOnce for CalendarDays { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| format::system_zone("calendar days")); + let now = self.now.unwrap_or_else(Timestamp::now); + let today = now.to_zoned(zone.clone()).date(); + let first = use_seeded( + (self.id.clone(), "first"), + Self::first(self.day), + window, + cx, + ); + let shown = first.read(cx).value; + let days: Vec = (0..DAYS as i64) + .map(|ix| shown.checked_add(ix.days()).expect("a day follows")) + .collect(); + let step: Step = { + let (id, on_step) = (self.id.clone(), self.on_step.clone()); + Rc::new(move |by, window, cx| { + let next = match by { + 0 => Self::first(today), + by => shown + .checked_add((i64::from(by) * DAYS as i64).days()) + .expect("days on either side"), + }; + log::info!("calendar days {id:?}: from {next}"); + first.update(cx, |first, cx| { + first.value = next; + cx.notify(); + }); + if let Some(on_step) = &on_step { + on_step(next, window, cx); + } + }) + }; + let (title, back, on) = match DAYS { + 7 => ( + show_span(days[0], days[DAYS - 1]), + "Previous week", + "Next week", + ), + _ => ( + shown.strftime("%A, %B %-d, %Y").to_string(), + "Previous day", + "Next day", + ), + }; + let mut zones = vec![format::datetime(now, &zone, "%Z").expect("a fixed pattern")]; + if let Some(overlay) = &self.overlay { + zones.insert( + 0, + format::datetime(now, &overlay.zone, "%Z").expect("a fixed pattern"), + ); + } + let whole = AllDayRow::new( + (self.id.clone(), "all-day"), + days.clone(), + self.events.clone(), + ) + .zone(zone.clone()) + .gutters(zones.len()); + let whole = match self.on_event.clone() { + Some(on_event) => whole.on_event(move |key, window, cx| on_event(key, window, cx)), + None => whole, + }; + let mut grid = TimeGrid::new((self.id.clone(), "grid"), days.clone(), self.events) + .zone(zone) + .now(now); + grid.overlay = self.overlay; + grid.on_create = self.on_create; + grid.on_resize = self.on_resize; + grid.on_event = self.on_event; + div() + .size_full() + .flex() + .flex_col() + .gap_3() + .child(head(&self.id, title, back, on, step, cx)) + .child( + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .child(names(&days, today, &zones, window, cx)) + .child(whole) + .child(div().flex_1().min_h_0().child(grid)), + ) + } +} diff --git a/src/calendar/grid.rs b/src/calendar/grid.rs new file mode 100644 index 0000000..13502d6 --- /dev/null +++ b/src/calendar/grid.rs @@ -0,0 +1,448 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, DragMoveEvent, ElementId, EmptyView, Entity, EntityId, InteractiveElement, + IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, SharedString, + StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative, +}; +use jiff::{Timestamp, civil::Date, tz::TimeZone}; + +use super::{ + card::EventCard, + event::{Event, When, unique}, + hours::{Block, SNAP, blocks, dragged, moment, snap}, + now::CurrentTimeIndicator, +}; +use crate::{ + forms::{Drawing, OnValue, Stroke, pen}, + layout::on_axis, + theme::{ActiveTheme, Radius, TextSize}, + typography::format, +}; + +/// Where a grid first opens: half past seven, or an hour and a half before now when that is later. +const MORNING: i64 = 7 * 60 + 30; + +/// Gets the start and end of a stretch dragged out on the grid. +pub(crate) type OnSpan = Rc; +/// Gets an event's key and its new end. +pub(crate) type OnResize = Rc; + +/// Another zone's clock beside a time grid's hours, so a day reads in both places. +#[derive(Clone, Debug)] +pub struct TimezoneOverlay { + pub(crate) zone: TimeZone, +} + +impl TimezoneOverlay { + pub fn new(zone: TimeZone) -> Self { + Self { zone } + } +} + +/// A drag on an event's lower edge, from the grid that owns it. +struct Resize { + owner: EntityId, + key: SharedString, +} + +/// Days side by side under a column of hours. Each event is a card by its hours, and events that overlap share the width. A drag on empty time makes an event of a quarter hour or more; a drag on an event's lower edge moves its end. Today carries a line at the minute now. It fills its box and scrolls. +#[derive(IntoElement)] +pub struct TimeGrid { + id: ElementId, + days: Vec, + events: Vec, + zone: Option, + now: Option, + pub(crate) overlay: Option, + pub(crate) on_create: Option, + pub(crate) on_resize: Option, + pub(crate) on_event: Option, +} + +impl TimeGrid { + pub fn new( + id: impl Into, + days: impl IntoIterator, + events: impl IntoIterator, + ) -> Self { + let id = id.into(); + let days: Vec = days.into_iter().collect(); + assert!(!days.is_empty(), "time grid {id:?} has no days"); + Self { + id, + days, + events: unique(events), + zone: None, + now: None, + overlay: None, + on_create: None, + on_resize: None, + on_event: None, + } + } + + /// The zone its hours read in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// The moment its line marks, on the day it falls; the clock's otherwise. + pub fn now(mut self, now: Timestamp) -> Self { + self.now = Some(now); + self + } + + /// Another zone's hours beside its own. + pub fn overlay(mut self, overlay: TimezoneOverlay) -> Self { + self.overlay = Some(overlay); + self + } + + /// Gets the start and end of a stretch dragged out on empty time. + pub fn on_create( + mut self, + handler: impl Fn(Timestamp, Timestamp, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_create = Some(Rc::new(handler)); + self + } + + /// Gets an event's key and its new end as its lower edge is dragged. + pub fn on_resize( + mut self, + handler: impl Fn(&SharedString, Timestamp, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resize = Some(Rc::new(handler)); + self + } + + /// Gets the key of an event pressed. + pub fn on_event( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_event = Some(Rc::new(handler)); + self + } +} + +/// What a grid's parts draw with: its id and drag owner, an hour's and a chip's height, its zone and events. +struct Frame<'a> { + id: &'a ElementId, + owner: EntityId, + hour: Pixels, + chip: Pixels, + zone: &'a TimeZone, + events: &'a [Event], +} + +impl Frame<'_> { + fn y(&self, minutes: i64) -> Pixels { + self.hour * (minutes as f32 / 60.0) + } + + /// Hour labels down a gutter, each centered on its line, read in `zone`. + fn labels( + &self, + day: Date, + zone: &TimeZone, + left: Pixels, + width: Pixels, + cx: &App, + ) -> AnyElement { + let theme = cx.theme(); + div() + .absolute() + .top_0() + .left(left) + .w(width) + .h_full() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .children((1..24).map(|hour| { + let at = moment(day, hour * 60, self.zone); + let label = format::datetime(at, zone, "%H:%M").expect("a fixed pattern"); + div() + .absolute() + .top(self.y(hour * 60)) + .left_0() + .w_full() + .h_0() + .flex() + .items_center() + .justify_end() + .pr_2() + .child(label) + })) + .into_any_element() + } + + /// A card for `block`, and a handle on its lower edge when the event ends there. + fn card( + &self, + day: Date, + column: &Entity, + block: &Block, + grid: &TimeGrid, + ) -> AnyElement { + let event = &self.events[block.event]; + let key = event.key.clone(); + let card = EventCard::new( + (self.id.clone(), format!("card-{key}-{day}")), + event.clone(), + ) + .zone(self.zone.clone()); + let card = match grid.on_event.clone() { + Some(on_event) => { + let key = key.clone(); + card.on_click(move |window, cx| on_event(&key, window, cx)) + } + None => card, + }; + let ends = matches!( + event.when, + When::Timed { end, .. } if end == moment(day, block.to, self.zone) + ); + let handle = grid.on_resize.clone().filter(|_| ends).map(|on_resize| { + let (owner, hour, zone, from, to) = ( + self.owner, + self.hour, + self.zone.clone(), + block.from, + block.to, + ); + let (drag, column) = (key.clone(), column.clone()); + div() + .id((self.id.clone(), format!("end-{key}-{day}"))) + .debug_selector({ + let key = key.clone(); + move || format!("end {key} {day}") + }) + .absolute() + .bottom_0() + .left_0() + .right_0() + .h_1p5() + .cursor_row_resize() + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_drag( + Resize { + owner, + key: key.clone(), + }, + |_, _, _, cx| cx.new(|_| EmptyView), + ) + .on_drag_move(move |event: &DragMoveEvent, window, cx| { + let held = event.drag(cx); + if held.owner != owner || held.key != drag { + return; + } + let top = column.read(cx).bounds.top(); + let end = snap((event.event.position.y - top) / hour * 60.0).max(from + SNAP); + if end != to { + log::info!("time grid: {drag} ends at minute {end} of {day}"); + on_resize(&drag, moment(day, end, &zone), window, cx); + } + }) + }); + div() + .absolute() + .top(self.y(block.from)) + .h(self.y(block.to - block.from).max(self.chip)) + .left(relative(block.lane as f32 / block.lanes as f32)) + .w(relative(1.0 / block.lanes as f32)) + .pr_0p5() + .pb_0p5() + .child(card) + .children(handle) + .into_any_element() + } +} + +/// The quarter hours a stroke down a column covers, where an hour is `hour` tall. +fn stretch(stroke: &Stroke, hour: Pixels) -> Option<(i64, i64)> { + let [first, .., last] = stroke.as_slice() else { + return None; + }; + dragged(f32::from(first.y), f32::from(last.y), f32::from(hour)) +} + +impl TimeGrid { + /// Day `day`'s column: its cards, the stretch being dragged out, and the line at now when it is today. + fn column( + &self, + frame: &Frame, + day: Date, + ix: usize, + today: Date, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let drawing = + window.use_keyed_state((self.id.clone(), format!("draw-{day}")), cx, |_, _| { + Drawing::default() + }); + let draft = stretch(&drawing.read(cx).stroke, frame.hour); + let cards: Vec = blocks(&self.events, day, frame.zone) + .iter() + .map(|block| frame.card(day, &drawing, block, self)) + .collect(); + let colors = &cx.theme().colors; + let (radius, count) = (cx.theme().radius(Radius::Sm), self.days.len() as f32); + let measure = drawing.clone(); + let pad = div() + .id((self.id.clone(), format!("day-{day}"))) + .debug_selector(move || format!("column {day}")) + .absolute() + .top_0() + .h_full() + .left(relative(ix as f32 / count)) + .w(relative(1.0 / count)) + .border_l_1() + .border_color(colors.border) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).bounds != bounds { + measure.update(cx, |drawing, _| drawing.bounds = bounds); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .children(cards) + .children(draft.map(|(from, to)| { + div() + .absolute() + .top(frame.y(from)) + .h(frame.y(to - from)) + .left_0() + .right_0() + .pr_0p5() + .child( + div() + .size_full() + .rounded(radius) + .border_1() + .border_color(colors.accent.opacity(0.5)) + .bg(colors.accent.alpha(0.08)), + ) + })) + .children((day == today).then(|| { + let line = + CurrentTimeIndicator::new((self.id.clone(), "now")).zone(frame.zone.clone()); + match self.now { + Some(now) => line.now(now), + None => line, + } + })); + let Some(on_create) = self.on_create.clone() else { + return pad.into_any_element(); + }; + let (zone, hour) = (frame.zone.clone(), frame.hour); + pen(pad, &drawing, move |stroke, window, cx| { + if let Some((from, to)) = stretch(&stroke, hour) { + log::info!("time grid: made minutes {from} to {to} of {day}"); + on_create(moment(day, from, &zone), moment(day, to, &zone), window, cx); + } + }) + .into_any_element() + } +} + +impl RenderOnce for TimeGrid { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .clone() + .unwrap_or_else(|| format::system_zone("time grid")); + let now = self + .now + .unwrap_or_else(Timestamp::now) + .to_zoned(zone.clone()); + let today = now.date(); + let rem = window.rem_size(); + let sizes = cx.theme().calendar(); + let (hour, chip, gutter) = ( + sizes.hour.to_pixels(rem), + sizes.chip.to_pixels(rem), + sizes.gutter.to_pixels(rem), + ); + let gutters = gutter * (1 + usize::from(self.overlay.is_some())) as f32; + let scroll = + window.use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new()); + let owner = scroll.entity_id(); + let scroll = scroll.read(cx).clone(); + let placed = window.use_keyed_state((self.id.clone(), "placed"), cx, |_, _| false); + if !*placed.read(cx) { + let minute = i64::from(now.hour()) * 60 + i64::from(now.minute()); + let shown = self.days.contains(&today).then_some(minute - 90); + let top = shown.unwrap_or(0).max(MORNING); + scroll.set_offset(Point::new(Pixels::ZERO, hour * (top as f32 / -60.0))); + placed.update(cx, |placed, _| *placed = true); + } + let frame = Frame { + id: &self.id, + owner, + hour, + chip, + zone: &zone, + events: &self.events, + }; + let first = self.days[0]; + let mut labels = vec![frame.labels(first, &zone, gutters - gutter, gutter, cx)]; + if let Some(overlay) = &self.overlay { + labels.push(frame.labels(first, &overlay.zone, Pixels::ZERO, gutter, cx)); + } + let columns: Vec = self + .days + .iter() + .enumerate() + .map(|(ix, day)| self.column(&frame, *day, ix, today, window, cx)) + .collect(); + let border = cx.theme().colors.border; + let lines = (1..24).map(|line| { + div() + .absolute() + .top(frame.y(line * 60)) + .left(gutters) + .right_0() + .border_t_1() + .border_color(border) + }); + on_axis( + div() + .id((self.id.clone(), "scroll")) + .debug_selector(|| "time-grid".into()) + .size_full() + .overflow_y_scroll() + .track_scroll(&scroll), + ) + .child( + div() + .relative() + .w_full() + .h(hour * 24.0) + .children(labels) + .children(lines) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(gutters) + .right_0() + .children(columns), + ), + ) + } +} diff --git a/src/calendar/hours.rs b/src/calendar/hours.rs new file mode 100644 index 0000000..fabaa08 --- /dev/null +++ b/src/calendar/hours.rs @@ -0,0 +1,204 @@ +use jiff::{SignedDuration, Timestamp, civil::Date, tz::TimeZone}; + +use super::event::{Event, When}; + +/// Minutes in a day. +pub(crate) const DAY: i64 = 24 * 60; + +/// The step a drag snaps to, in minutes. +pub(crate) const SNAP: i64 = 15; + +/// An event's stretch of `day` in `zone`, in minutes from midnight: clipped to the day, as a night that runs past midnight shows on both. +pub(crate) fn minutes(event: &Event, day: Date, zone: &TimeZone) -> Option<(i64, i64)> { + let When::Timed { start, end } = event.when else { + return None; + }; + let midnight = day + .to_zoned(zone.clone()) + .expect("the zone holds the day") + .timestamp(); + let from = start.duration_since(midnight).as_mins(); + let to = end.duration_since(midnight).as_mins(); + (from < DAY && to > 0).then(|| (from.max(0), to.min(DAY))) +} + +/// A block in a day column: which event, its minutes, and its lane among the lanes of the blocks it overlaps. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Block { + pub event: usize, + pub from: i64, + pub to: i64, + pub lane: usize, + pub lanes: usize, +} + +/// A day's timed events as blocks: those that overlap share the width in lanes, earliest first, each in the first lane free. +pub(crate) fn blocks(events: &[Event], day: Date, zone: &TimeZone) -> Vec { + let mut spans: Vec<(usize, i64, i64)> = events + .iter() + .enumerate() + .filter_map(|(ix, event)| minutes(event, day, zone).map(|(from, to)| (ix, from, to))) + .collect(); + spans.sort_by_key(|&(ix, from, to)| (from, -to, ix)); + let mut blocks: Vec = Vec::with_capacity(spans.len()); + let mut cluster = 0; + let mut reach = i64::MIN; + let mut ends: Vec = Vec::new(); + for (event, from, to) in spans { + if from >= reach { + close(&mut blocks[cluster..]); + cluster = blocks.len(); + ends.clear(); + } + 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), + } + reach = reach.max(to); + blocks.push(Block { + event, + from, + to, + lane, + lanes: 0, + }); + } + close(&mut blocks[cluster..]); + blocks +} + +/// Gives each block of a cluster the cluster's count of lanes. +fn close(cluster: &mut [Block]) { + let lanes = cluster + .iter() + .map(|block| block.lane + 1) + .max() + .unwrap_or(0); + for block in cluster { + block.lanes = lanes; + } +} + +/// `minutes` to the nearest step of `SNAP`, inside the day. +pub(crate) fn snap(minutes: f32) -> i64 { + let step = SNAP as f32; + ((minutes / step).round() as i64 * SNAP).clamp(0, DAY) +} + +/// The quarter hours a drag from `from` to `to` covers, in pixels down a column where an hour is `hour` tall; none under a quarter. +pub(crate) fn dragged(from: f32, to: f32, hour: f32) -> Option<(i64, i64)> { + let (top, bottom) = (from.min(to), from.max(to)); + let (start, end) = (snap(top / hour * 60.0), snap(bottom / hour * 60.0)); + (end - start >= SNAP).then_some((start, end)) +} + +/// The moment `minutes` after `day`'s midnight in `zone`. +pub(crate) fn moment(day: Date, minutes: i64, zone: &TimeZone) -> Timestamp { + let midnight = day + .to_zoned(zone.clone()) + .expect("the zone holds the day") + .timestamp(); + midnight + SignedDuration::from_mins(minutes) +} + +#[cfg(test)] +mod tests { + use jiff::{Timestamp, civil::date, tz::TimeZone}; + + use super::{Block, blocks, dragged, minutes, moment, snap}; + use crate::calendar::Event; + + 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() + } + + fn block(event: usize, from: i64, to: i64, lane: usize, lanes: usize) -> Block { + Block { + event, + from, + to, + lane, + lanes, + } + } + + #[test] + fn a_night_shows_on_both_days_it_touches() { + let night = Event::timed("night", "Night", at(22, 21, 0), at(23, 2, 30), 0); + let utc = &TimeZone::UTC; + assert_eq!( + minutes(&night, date(2026, 9, 22), utc), + Some((21 * 60, 24 * 60)) + ); + assert_eq!(minutes(&night, date(2026, 9, 23), utc), Some((0, 150))); + assert_eq!(minutes(&night, date(2026, 9, 24), utc), None); + let all = Event::all_day("all", "All", date(2026, 9, 22), date(2026, 9, 22), 0); + assert_eq!( + minutes(&all, date(2026, 9, 22), utc), + None, + "whole days sit above" + ); + } + + #[test] + fn overlapping_blocks_share_the_width_and_the_rest_take_it_all() { + let events = [ + Event::timed("a", "A", at(22, 9, 0), at(22, 10, 0), 0), + Event::timed("b", "B", at(22, 9, 30), at(22, 11, 0), 0), + Event::timed("c", "C", at(22, 10, 0), at(22, 10, 30), 0), + Event::timed("d", "D", at(22, 13, 0), at(22, 14, 0), 0), + ]; + assert_eq!( + blocks(&events, date(2026, 9, 22), &TimeZone::UTC), + [ + block(0, 540, 600, 0, 2), + block(1, 570, 660, 1, 2), + block(2, 600, 630, 0, 2), + block(3, 780, 840, 0, 1), + ], + "C takes A's lane once A ends; D stands alone" + ); + } + + #[test] + fn a_drag_snaps_to_quarters_inside_the_day() { + assert_eq!(snap(7.0), 0); + assert_eq!(snap(8.0), 15); + assert_eq!(snap(9.0 * 60.0 + 22.0), 9 * 60 + 15); + assert_eq!(snap(-30.0), 0); + assert_eq!(snap(25.0 * 60.0), 24 * 60); + assert_eq!( + moment(date(2026, 9, 22), 9 * 60 + 15, &TimeZone::UTC), + at(22, 9, 15) + ); + } + + #[test] + fn a_drag_either_way_covers_its_quarters() { + let hour = 48.0; + assert_eq!(dragged(9.0 * hour, 10.5 * hour, hour), Some((540, 630))); + assert_eq!( + dragged(10.5 * hour, 9.0 * hour, hour), + Some((540, 630)), + "upward" + ); + assert_eq!( + dragged(9.0 * hour, 9.1 * hour, hour), + None, + "under a quarter" + ); + assert_eq!( + dragged(9.0 * hour, 9.25 * hour, hour), + Some((540, 555)), + "a quarter" + ); + } +} diff --git a/src/calendar/mod.rs b/src/calendar/mod.rs index 7ee5245..e67f607 100644 --- a/src/calendar/mod.rs +++ b/src/calendar/mod.rs @@ -1,16 +1,25 @@ +mod allday; mod card; mod chip; +mod days; mod event; +mod grid; mod head; +mod hours; mod month; +mod now; mod weeks; mod year; #[cfg(all(test, feature = "test-support"))] mod tests; +pub use allday::AllDayRow; pub use card::EventCard; pub use chip::EventChip; +pub use days::{CalendarDayView, CalendarDays, CalendarWeekView}; pub use event::{Event, When}; +pub use grid::{TimeGrid, TimezoneOverlay}; pub use month::CalendarMonthView; +pub use now::CurrentTimeIndicator; pub use year::YearView; diff --git a/src/calendar/month.rs b/src/calendar/month.rs index 7cc0c9d..dc1e1cf 100644 --- a/src/calendar/month.rs +++ b/src/calendar/month.rs @@ -255,7 +255,7 @@ impl Row<'_> { } fn week(&self, monday: Date, sizes: &Metrics, cx: &App) -> AnyElement { - let bars = bars(self.events, monday, self.zone); + let bars = bars(self.events, monday, 7, self.zone); let lanes = bars .iter() .map(|bar| bar.lane + 1) diff --git a/src/calendar/now.rs b/src/calendar/now.rs new file mode 100644 index 0000000..264dee7 --- /dev/null +++ b/src/calendar/now.rs @@ -0,0 +1,78 @@ +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div, +}; +use jiff::{Timestamp, tz::TimeZone}; + +use crate::{ + theme::ActiveTheme, + typography::{format, fresh}, +}; + +/// A line across a day of a time grid at the minute now, with a dot where it starts. It moves with the clock. +#[derive(IntoElement)] +pub struct CurrentTimeIndicator { + id: ElementId, + zone: Option, + now: Option, +} + +impl CurrentTimeIndicator { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + zone: None, + now: None, + } + } + + /// The zone its minute reads in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// The moment it marks; the clock's otherwise. + pub fn now(mut self, now: Timestamp) -> Self { + self.now = Some(now); + self + } +} + +impl RenderOnce for CurrentTimeIndicator { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("current time indicator")); + if self.now.is_none() { + fresh((self.id.clone(), "clock"), window, cx); + } + let now = self.now.unwrap_or_else(Timestamp::now).to_zoned(zone); + let minute = f32::from(now.hour()) * 60.0 + f32::from(now.minute()); + let theme = cx.theme(); + let (hour, dot) = (theme.calendar().hour, theme.status_dot() * 1.5); + div() + .debug_selector(|| "now-line".into()) + .absolute() + .top(hour * (minute / 60.0)) + .left_0() + .right_0() + .h_0() + .child( + div() + .absolute() + .left_0() + .right_0() + .border_t_2() + .border_color(theme.colors.accent), + ) + .child( + div() + .absolute() + .top(dot * -0.5) + .left(dot * -0.5) + .size(dot) + .rounded_full() + .bg(theme.colors.accent), + ) + } +} diff --git a/src/calendar/tests.rs b/src/calendar/tests/mod.rs similarity index 99% rename from src/calendar/tests.rs rename to src/calendar/tests/mod.rs index 6a0bd61..5634cc0 100644 --- a/src/calendar/tests.rs +++ b/src/calendar/tests/mod.rs @@ -6,6 +6,8 @@ use gpui::{ use jiff::{Timestamp, civil::date, tz::TimeZone}; use super::{CalendarMonthView, Event, YearView}; + +mod times; use crate::{primitives::FocusNext, theme::Theme}; /// A view that shows one calendar part, keeps the month it was told, and what it heard. diff --git a/src/calendar/tests/times.rs b/src/calendar/tests/times.rs new file mode 100644 index 0000000..bc614cd --- /dev/null +++ b/src/calendar/tests/times.rs @@ -0,0 +1,228 @@ +use gpui::{ + Bounds, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, Styled, + TestAppContext, VisualTestContext, div, point, px, +}; +use jiff::{civil::date, tz::TimeZone}; + +use super::{Planning, at, heard, note, planning, press, settle, tab_to}; +use crate::calendar::{AllDayRow, CalendarWeekView, Event, TimeGrid}; + +/// Tuesday the 22nd with a review at 13:00, its clock at noon. +fn grid(_: &Planning, owner: Entity) -> gpui::AnyElement { + let (made, moved, pressed) = (owner.clone(), owner.clone(), owner); + let review = Event::timed("review", "Review", at(22, 13), at(22, 14), 0); + div() + .h(px(600.0)) + .child( + TimeGrid::new("grid", [date(2026, 9, 22)], [review]) + .zone(TimeZone::UTC) + .now(at(22, 12)) + .on_create(move |start, end, _, cx| note(&made, format!("made {start} {end}"), cx)) + .on_resize(move |key, end, _, cx| note(&moved, format!("end {key} {end}"), cx)) + .on_event(move |key, _, cx| note(&pressed, format!("event {key}"), cx)), + ) + .into_any_element() +} + +fn column(cx: &mut VisualTestContext) -> Bounds { + cx.debug_bounds("column 2026-09-22") + .expect("the day's column draws") +} + +/// Where minute `minutes` of the day lies down its column: an hour is 48 tall. +fn at_minute(column: Bounds, minutes: f32) -> Pixels { + column.top() + px(48.0 * minutes / 60.0) +} + +fn drag(from: gpui::Point, to: gpui::Point, cx: &mut VisualTestContext) { + let none = Modifiers::none(); + cx.simulate_mouse_down(from, MouseButton::Left, none); + for step in 1..=4 { + let share = step as f32 / 4.0; + let at = point(from.x, from.y + (to.y - from.y) * share); + cx.simulate_mouse_move(at, MouseButton::Left, none); + } + cx.simulate_mouse_up(to, MouseButton::Left, none); + settle(cx); +} + +#[gpui::test] +fn a_drag_on_empty_time_makes_an_event_by_quarters(cx: &mut TestAppContext) { + let (host, cx) = planning(grid, cx); + let column = column(cx); + let x = column.center().x; + let from = point(x, at_minute(column, 16.0 * 60.0 + 4.0)); + let to = point(x, at_minute(column, 17.0 * 60.0 + 28.0)); + drag(from, to, cx); + assert_eq!( + heard(&host, cx), + [format!( + "made {} {}", + at(22, 16), + at(22, 17) + jiff::SignedDuration::from_mins(30) + )] + ); +} + +#[gpui::test] +fn a_drag_on_an_events_lower_edge_moves_its_end(cx: &mut TestAppContext) { + let (host, cx) = planning(grid, cx); + let edge = cx + .debug_bounds("end review 2026-09-22") + .expect("the review's edge draws"); + let from = edge.center(); + drag(from, point(from.x, from.y + px(48.0)), cx); + let heard = heard(&host, cx); + assert_eq!( + heard.last().map(String::as_str), + Some(format!("end review {}", at(22, 15)).as_str()), + "{heard:?}" + ); +} + +#[gpui::test] +fn a_press_on_a_card_names_its_event_and_makes_nothing(cx: &mut TestAppContext) { + let (host, cx) = planning(grid, cx); + let card = cx + .debug_bounds("event-card review") + .expect("the review draws"); + cx.simulate_click(card.center(), Modifiers::none()); + settle(cx); + assert_eq!(heard(&host, cx), ["event review"]); +} + +#[gpui::test] +fn the_now_line_sits_at_its_minute_on_today(cx: &mut TestAppContext) { + let (_, cx) = planning(grid, cx); + let column = column(cx); + let line = cx.debug_bounds("now-line").expect("today carries the line"); + assert_eq!(line.top(), at_minute(column, 12.0 * 60.0)); +} + +fn week(_: &Planning, owner: Entity) -> gpui::AnyElement { + let steps = owner; + div() + .h(px(600.0)) + .child( + CalendarWeekView::new("week", date(2026, 9, 23), []) + .zone(TimeZone::UTC) + .now(at(16, 9)) + .on_step(move |first, _, cx| note(&steps, format!("step {first}"), cx)), + ) + .into_any_element() +} + +#[gpui::test] +fn a_week_steps_on_and_comes_back_to_today(cx: &mut TestAppContext) { + let (host, cx) = planning(week, cx); + tab_to(3, cx); + press("space", cx); + tab_to(1, cx); + press("space", cx); + assert_eq!( + heard(&host, cx), + ["step 2026-09-28", "step 2026-09-14"], + "on from the week of the 23rd, then back to the week of the 16th" + ); +} + +fn whole_days(_: &Planning, _: Entity) -> gpui::AnyElement { + let days = (21..=27).map(|day| date(2026, 9, day)); + AllDayRow::new( + "all-day", + days, + [ + Event::all_day("fair", "Fair", date(2026, 9, 22), date(2026, 9, 24), 0), + Event::all_day("trip", "Trip", date(2026, 9, 23), date(2026, 9, 25), 1), + ], + ) + .zone(TimeZone::UTC) + .into_any_element() +} + +#[gpui::test] +fn whole_days_that_meet_take_lanes(cx: &mut TestAppContext) { + let (_, cx) = planning(whole_days, cx); + let fair = cx.debug_bounds("event-chip fair").expect("the fair draws"); + let trip = cx.debug_bounds("event-chip trip").expect("the trip draws"); + assert!(trip.top() > fair.bottom(), "{fair:?} {trip:?}"); + assert!(trip.left() > fair.left(), "the trip starts a day later"); +} + +/// Two grids, each with a review of the same key. +fn twins(_: &Planning, owner: Entity) -> gpui::AnyElement { + let one = |id: &'static str, owner: Entity| { + let review = Event::timed("review", "Review", at(22, 13), at(22, 14), 0); + div().h(px(600.0)).w(px(300.0)).child( + TimeGrid::new(id, [date(2026, 9, 22)], [review]) + .zone(TimeZone::UTC) + .now(at(22, 12)) + .on_resize(move |key, end, _, cx| note(&owner, format!("{id} {key} {end}"), cx)), + ) + }; + div() + .flex() + .child(one("left", owner.clone())) + .child(one("right", owner)) + .into_any_element() +} + +#[gpui::test] +fn a_drag_moves_only_its_own_grids_event(cx: &mut TestAppContext) { + let (host, cx) = planning(twins, cx); + let edge = cx + .debug_bounds("end review 2026-09-22") + .expect("an edge draws"); + let from = edge.center(); + drag(from, point(from.x, from.y + px(48.0)), cx); + let heard = heard(&host, cx); + let grids: Vec<&str> = heard + .iter() + .map(|said| &said[..said.find(' ').expect("a grid")]) + .collect(); + assert!( + !grids.is_empty() && grids.iter().all(|grid| *grid == grids[0]), + "{heard:?}" + ); +} + +/// A night from Tuesday 21:00 to Wednesday 02:00. +fn nights(_: &Planning, owner: Entity) -> gpui::AnyElement { + let night = Event::timed("night", "Night", at(22, 21), at(23, 2), 0); + div() + .h(px(600.0)) + .child( + TimeGrid::new("nights", [date(2026, 9, 22), date(2026, 9, 23)], [night]) + .zone(TimeZone::UTC) + .now(at(16, 12)) + .on_resize(move |key, end, _, cx| note(&owner, format!("end {key} {end}"), cx)), + ) + .into_any_element() +} + +#[gpui::test] +fn only_the_day_an_event_ends_holds_its_edge(cx: &mut TestAppContext) { + let (_, cx) = planning(nights, cx); + assert!( + cx.debug_bounds("end night 2026-09-22").is_none(), + "it goes on past midnight" + ); + assert!(cx.debug_bounds("end night 2026-09-23").is_some()); +} + +fn opened(cx: &mut VisualTestContext) -> Pixels { + let top = cx.debug_bounds("time-grid").expect("the grid draws").top(); + top - column(cx).top() +} + +#[gpui::test] +fn a_grid_on_today_opens_an_hour_and_a_half_before_now(cx: &mut TestAppContext) { + let (_, cx) = planning(grid, cx); + assert_eq!(opened(cx), px(48.0 * 10.5), "at noon, from 10:30"); +} + +#[gpui::test] +fn a_grid_without_today_opens_on_the_morning(cx: &mut TestAppContext) { + let (_, cx) = planning(nights, cx); + assert_eq!(opened(cx), px(48.0 * 7.5), "from 07:30"); +} diff --git a/src/calendar/weeks.rs b/src/calendar/weeks.rs index 59dbe1d..c883dd6 100644 --- a/src/calendar/weeks.rs +++ b/src/calendar/weeks.rs @@ -28,45 +28,47 @@ pub(crate) fn weeks(month: Date) -> Vec { mondays } -fn column(monday: Date, day: Date) -> usize { - let days = monday.until(day).expect("days apart").get_days(); +fn column(first: Date, day: Date) -> usize { + let days = first.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"); +/// Bars for the `count` days from `first`: earliest first and longer first, each in the first lane free across its days. +pub(crate) fn bars(events: &[Event], first: Date, count: usize, zone: &TimeZone) -> Vec { + let last = first + .checked_add((count as i64 - 1).days()) + .expect("the days end"); 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) + let (from, to) = event.days(zone); + (ix, from, to) }) - .filter(|&(_, first, last)| first <= sunday && last >= monday) + .filter(|&(_, from, to)| from <= last && to >= first) .collect(); - spans.sort_by_key(|&(ix, first, last)| (first, Reverse(last), ix)); + spans.sort_by_key(|&(ix, from, to)| (from, Reverse(to), ix)); let mut ends: Vec = Vec::new(); spans .into_iter() - .map(|(event, first, last)| { - let (from, to) = (first.max(monday), last.min(sunday)); + .map(|(event, from, to)| { + let (shown, until) = (from.max(first), to.min(last)); let lane = ends .iter() - .position(|end| *end < from) + .position(|end| *end < shown) .unwrap_or(ends.len()); match ends.get_mut(lane) { - Some(end) => *end = to, - None => ends.push(to), + Some(end) => *end = until, + None => ends.push(until), } Bar { event, lane, - from: column(monday, from), - to: column(monday, to), - before: first < monday, - after: last > sunday, + from: column(first, shown), + to: column(first, until), + before: from < first, + after: to > last, } }) .collect() @@ -162,7 +164,7 @@ mod tests { ]; let utc = &TimeZone::UTC; assert_eq!( - bars(&events, date(2026, 9, 21), utc), + bars(&events, date(2026, 9, 21), 7, utc), [ bar(3, 0, 0, 1), bar(0, 1, 1, 3), @@ -174,7 +176,7 @@ mod tests { ] ); assert_eq!( - bars(&events, date(2026, 9, 28), utc), + bars(&events, date(2026, 9, 28), 7, utc), [Bar { before: true, ..bar(1, 0, 0, 1) diff --git a/src/forms/mod.rs b/src/forms/mod.rs index b4da18c..5e1962f 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, month_grid, picker_field, week_start}; +pub(crate) use date::{Face, dropdown, month_grid, picker_field, show_span, 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/theme/calendar.rs b/src/theme/calendar.rs index edb5b22..b18f7fa 100644 --- a/src/theme/calendar.rs +++ b/src/theme/calendar.rs @@ -11,6 +11,10 @@ pub struct CalendarSizes { pub day_rows: usize, /// A day in a year's small months, square. pub mini_day: Rems, + /// An hour in a time grid, tall. + pub hour: Rems, + /// A time grid's column of hours, wide. + pub gutter: Rems, } impl Theme { @@ -19,6 +23,8 @@ impl Theme { chip: px_to_rems(20.0), day_rows: 3, mini_day: px_to_rems(28.0), + hour: px_to_rems(48.0), + gutter: px_to_rems(56.0), } } } diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 8ebc9ff..e6ac20e 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -394,12 +394,12 @@ ## T30b Calendar · Time grid -- [ ] CalendarWeekView -- [ ] CalendarDayView -- [ ] TimeGrid — drag to create, resize -- [ ] AllDayRow -- [ ] CurrentTimeIndicator -- [ ] TimezoneOverlay +- [x] CalendarWeekView +- [x] CalendarDayView +- [x] TimeGrid — drag to create, resize +- [x] AllDayRow +- [x] CurrentTimeIndicator +- [x] TimezoneOverlay ## T30c Calendar · Agenda and people