Calendar · Time grid: week and day views, drag to make and to resize

TimeGrid sets days side by side under a column of hours. Each event is
a card by its hours, clipped to each day it touches, and events that
overlap share the width in lanes. A drag on empty time makes an event
by quarter hours through forms::pen; a drag on an event's lower edge,
on the day it ends, moves its end, each drag checked against its
owner. The grid opens at half past seven, or an hour and a half
before now when today is shown and that is later.

CurrentTimeIndicator marks the minute now on today and moves with the
clock. TimezoneOverlay adds another zone's hours beside the grid's.
AllDayRow lays whole days across their columns in lanes.
CalendarWeekView and CalendarDayView are one view of seven days or
one: a header that steps and comes back to today, the days' names
stacked so they fit narrow columns, the whole days, then the hours.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 03:13:38 -04:00
1 parent cd5ed088c7
commit 7e3f99f278
18 files changed
+1577 -36

No files matched your search

+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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 | |
+20 -2
View File
@@ -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<Vec<Event>> {
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<Event> {
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()
+6 -4
View File
@@ -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<Viewing> {
}
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::<SharedString>, 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"));
+103
View File
@@ -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<Vec<Event>>,
said: &Entity<Option<SharedString>>,
) -> 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<Vec<Event>>,
) -> 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::<SharedString>, 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::<SharedString>, 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),
),
))
}
+151
View File
@@ -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<Date>,
events: Vec<Event>,
zone: Option<TimeZone>,
gutters: usize,
on_event: Option<OnValue>,
}
impl AllDayRow {
pub fn new(
id: impl Into<ElementId>,
days: impl IntoIterator<Item = Date>,
events: impl IntoIterator<Item = Event>,
) -> Self {
let id = id.into();
let days: Vec<Date> = 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<Event> = 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::<Vec<_>>()),
)
}
}
+289
View File
@@ -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<const DAYS: usize> {
id: ElementId,
day: Date,
events: Vec<Event>,
zone: Option<TimeZone>,
now: Option<Timestamp>,
overlay: Option<TimezoneOverlay>,
on_create: Option<OnSpan>,
on_resize: Option<OnResize>,
on_event: Option<OnValue>,
on_step: Option<OnDay>,
}
impl<const DAYS: usize> CalendarDays<DAYS> {
/// The days holding `day`: its week, or the day itself.
pub fn new(
id: impl Into<ElementId>,
day: Date,
events: impl IntoIterator<Item = Event>,
) -> 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<const DAYS: usize> RenderOnce for CalendarDays<DAYS> {
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<Date> = (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)),
)
}
}
+448
View File
@@ -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<dyn Fn(Timestamp, Timestamp, &mut Window, &mut App)>;
/// Gets an event's key and its new end.
pub(crate) type OnResize = Rc<dyn Fn(&SharedString, Timestamp, &mut Window, &mut App)>;
/// 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<Date>,
events: Vec<Event>,
zone: Option<TimeZone>,
now: Option<Timestamp>,
pub(crate) overlay: Option<TimezoneOverlay>,
pub(crate) on_create: Option<OnSpan>,
pub(crate) on_resize: Option<OnResize>,
pub(crate) on_event: Option<OnValue>,
}
impl TimeGrid {
pub fn new(
id: impl Into<ElementId>,
days: impl IntoIterator<Item = Date>,
events: impl IntoIterator<Item = Event>,
) -> Self {
let id = id.into();
let days: Vec<Date> = 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<Drawing>,
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<Resize>, 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<AnyElement> = 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<AnyElement> = 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),
),
)
}
}
+204
View File
@@ -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<Block> {
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<Block> = Vec::with_capacity(spans.len());
let mut cluster = 0;
let mut reach = i64::MIN;
let mut ends: Vec<i64> = 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"
);
}
}
+9
View File
@@ -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;
+1 -1
View File
@@ -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)
+78
View File
@@ -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<TimeZone>,
now: Option<Timestamp>,
}
impl CurrentTimeIndicator {
pub fn new(id: impl Into<ElementId>) -> 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),
)
}
}
@@ -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.
+228
View File
@@ -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<Planning>) -> 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<Pixels> {
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<Pixels>, minutes: f32) -> Pixels {
column.top() + px(48.0 * minutes / 60.0)
}
fn drag(from: gpui::Point<Pixels>, to: gpui::Point<Pixels>, 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<Planning>) -> 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<Planning>) -> 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<Planning>) -> gpui::AnyElement {
let one = |id: &'static str, owner: Entity<Planning>| {
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<Planning>) -> 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");
}
+22 -20
View File
@@ -28,45 +28,47 @@ pub(crate) fn weeks(month: Date) -> Vec<Date> {
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<Bar> {
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<Bar> {
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<Date> = 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)
+1 -1
View File
@@ -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};
+6
View File
@@ -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),
}
}
}
+6 -6
View File
@@ -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