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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f316ab56ba
commit
a41cb2f9c2
18 files changed
+1577
-36
No files matched your search
@@ -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<_>>()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user