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:
1 parent
cd5ed088c7
commit
7e3f99f278
18 files changed
+1577
-36
No files matched your search
@@ -0,0 +1,269 @@
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, MouseButton,
|
||||
MouseDownEvent, MouseUpEvent, ParentElement, Render, Styled, TestAppContext, VisualTestContext,
|
||||
Window, div, px,
|
||||
};
|
||||
use jiff::{Timestamp, civil::date, tz::TimeZone};
|
||||
|
||||
use super::{CalendarMonthView, Event, YearView};
|
||||
|
||||
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.
|
||||
struct Planning {
|
||||
part: fn(&Planning, Entity<Planning>) -> gpui::AnyElement,
|
||||
month: jiff::civil::Date,
|
||||
heard: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Planning {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().w(px(900.0)).child((self.part)(self, cx.entity()))
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn planning(
|
||||
part: fn(&Planning, Entity<Planning>) -> gpui::AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Planning>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|_, _| Planning {
|
||||
part,
|
||||
month: date(2026, 9, 1),
|
||||
heard: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn note(owner: &Entity<Planning>, what: String, cx: &mut gpui::App) {
|
||||
owner.update(cx, |host, cx| {
|
||||
host.heard.push(what);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn heard(host: &Entity<Planning>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
host.read_with(cx, |host, _| host.heard.clone())
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Moves focus to the `nth` Tab stop from none.
|
||||
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
for _ in 0..nth {
|
||||
window.focus_next();
|
||||
}
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn at(day: i8, hour: i8) -> Timestamp {
|
||||
date(2026, 9, day)
|
||||
.at(hour, 0, 0, 0)
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.expect("a UTC time")
|
||||
.timestamp()
|
||||
}
|
||||
|
||||
/// Five meetings on the 16th, and a trip across the weekend after it.
|
||||
fn events() -> Vec<Event> {
|
||||
let mut events: Vec<Event> = (0..5)
|
||||
.map(|ix| {
|
||||
let hour = 9 + ix as i8;
|
||||
Event::timed(
|
||||
format!("m{ix}"),
|
||||
format!("Meeting {ix}"),
|
||||
at(16, hour),
|
||||
at(16, hour + 1),
|
||||
ix,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
events.push(Event::all_day(
|
||||
"trip",
|
||||
"Trip",
|
||||
date(2026, 9, 18),
|
||||
date(2026, 9, 22),
|
||||
5,
|
||||
));
|
||||
events
|
||||
}
|
||||
|
||||
fn month(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
let (days, keys, months) = (owner.clone(), owner.clone(), owner);
|
||||
CalendarMonthView::new("month", date(2026, 9, 1), events())
|
||||
.zone(TimeZone::UTC)
|
||||
.today(date(2026, 9, 16))
|
||||
.on_day(move |day, _, cx| note(&days, format!("open {day}"), cx))
|
||||
.on_event(move |key, _, cx| note(&keys, format!("event {key}"), cx))
|
||||
.on_month(move |first, _, cx| note(&months, format!("month {first}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn arrows_move_the_day_and_enter_opens_it(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(month, cx);
|
||||
tab_to(4, cx);
|
||||
press("right", cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
["open 2026-09-24"],
|
||||
"from today, the 16th"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn page_down_turns_the_month(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(month, cx);
|
||||
tab_to(4, cx);
|
||||
press("pagedown", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(heard(&host, cx), ["month 2026-10-01", "open 2026-10-16"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_crowded_day_opens_from_its_more(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(month, cx);
|
||||
let more = cx
|
||||
.debug_bounds("more 2026-09-16")
|
||||
.expect("the 16th holds more than fits");
|
||||
cx.simulate_click(more.center(), Modifiers::none());
|
||||
settle(cx);
|
||||
assert_eq!(heard(&host, cx), ["open 2026-09-16"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_chip_names_its_event_and_leaves_the_day(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(month, cx);
|
||||
let chip = cx
|
||||
.debug_bounds("event-chip m0")
|
||||
.expect("the first meeting shows");
|
||||
let origin = chip.origin + gpui::point(px(4.0), px(4.0));
|
||||
for click_count in [1, 2] {
|
||||
cx.simulate_event(MouseDownEvent {
|
||||
button: MouseButton::Left,
|
||||
position: origin,
|
||||
modifiers: Modifiers::none(),
|
||||
click_count,
|
||||
first_mouse: false,
|
||||
});
|
||||
cx.simulate_event(MouseUpEvent {
|
||||
button: MouseButton::Left,
|
||||
position: origin,
|
||||
modifiers: Modifiers::none(),
|
||||
click_count,
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
["event m0", "event m0"],
|
||||
"a double press on a chip does not open its day"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "event m0 twice")]
|
||||
fn an_event_is_listed_once() {
|
||||
let twin = Event::timed("m0", "Again", at(1, 9), at(1, 10), 0);
|
||||
let _ = CalendarMonthView::new("month", date(2026, 9, 1), [events()[0].clone(), twin]);
|
||||
}
|
||||
|
||||
fn year(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
let (days, years) = (owner.clone(), owner);
|
||||
YearView::new("year", 2026, events())
|
||||
.zone(TimeZone::UTC)
|
||||
.today(date(2026, 9, 16))
|
||||
.on_day(move |day, _, cx| note(&days, format!("open {day}"), cx))
|
||||
.on_year(move |year, _, cx| note(&years, format!("year {year}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_year_walks_by_day_and_month_into_the_next(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(year, cx);
|
||||
tab_to(4, cx);
|
||||
for key in [
|
||||
"pagedown", "pagedown", "pagedown", "pagedown", "right", "enter",
|
||||
] {
|
||||
press(key, cx);
|
||||
}
|
||||
assert_eq!(heard(&host, cx), ["year 2027", "open 2027-01-17"]);
|
||||
}
|
||||
|
||||
/// A month view whose owner keeps the month and hands each turn back.
|
||||
fn echoing(host: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
let (days, months) = (owner.clone(), owner);
|
||||
CalendarMonthView::new("month", host.month, events())
|
||||
.zone(TimeZone::UTC)
|
||||
.today(date(2026, 9, 16))
|
||||
.on_day(move |day, _, cx| note(&days, format!("open {day}"), cx))
|
||||
.on_month(move |first, _, cx| {
|
||||
months.update(cx, |host, cx| {
|
||||
host.month = first;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_owners_echo_of_a_turn_keeps_the_day(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(echoing, cx);
|
||||
tab_to(4, cx);
|
||||
press("pagedown", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(heard(&host, cx), ["open 2026-10-16"]);
|
||||
}
|
||||
|
||||
/// A year view whose owner keeps the year and hands each turn back.
|
||||
fn echoing_year(host: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
let (days, years) = (owner.clone(), owner);
|
||||
YearView::new("year", host.month.year(), events())
|
||||
.zone(TimeZone::UTC)
|
||||
.today(date(2026, 9, 16))
|
||||
.on_day(move |day, _, cx| note(&days, format!("open {day}"), cx))
|
||||
.on_year(move |year, _, cx| {
|
||||
years.update(cx, |host, cx| {
|
||||
host.month = date(year, 1, 1);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_owners_echo_of_a_year_keeps_the_day(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(echoing_year, cx);
|
||||
tab_to(4, cx);
|
||||
for key in ["pagedown", "pagedown", "pagedown", "pagedown", "enter"] {
|
||||
press(key, cx);
|
||||
}
|
||||
assert_eq!(heard(&host, cx), ["open 2027-01-16"]);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user