Calendar · Months: a month view, a year view, event chips and cards
CalendarMonthView lays a month in week rows. An event across days lies over them as a bar in the first free lane, cut at the week's edge; the rest sit by the hour as chips, and a crowded day keeps the rest behind "N more". Arrows move a day, Page keys turn the month, and Enter or a double press opens the day; the cursor holds through the owner's echo of a turn. Days sit at sevenths of their row, as the bars do, so text never widens a column. YearView shows twelve small months; a day with something on it wears a dot in its first event's hue. EventChip is an event in a line and EventCard a block with its hours and place. MiniCalendar points to forms::Calendar. Palette::hue is now the one home for a chart color by index, for mail labels and events alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1a5603184
commit
f316ab56ba
22 files changed
+2121
-20
No files matched your search
@@ -0,0 +1,353 @@
|
||||
use std::{collections::BTreeMap, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, RenderOnce, Styled, Window, div, prelude::*,
|
||||
};
|
||||
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
|
||||
|
||||
use super::{
|
||||
chip::tint,
|
||||
event::{Event, unique},
|
||||
head::{OnDay, Step, head},
|
||||
};
|
||||
use crate::{
|
||||
forms::month_grid,
|
||||
layout::seeded::use_seeded,
|
||||
primitives::tab_stop,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::format,
|
||||
};
|
||||
|
||||
const INITIALS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"];
|
||||
|
||||
type OnYear = Rc<dyn Fn(i16, &mut Window, &mut App)>;
|
||||
|
||||
/// The hue of the first event on each day of `year` in `zone`.
|
||||
fn marks(events: &[Event], year: i16, zone: &TimeZone) -> BTreeMap<Date, usize> {
|
||||
let mut marks = BTreeMap::new();
|
||||
for (ix, event) in events.iter().enumerate() {
|
||||
let (first, last) = event.days(zone);
|
||||
let mut day = first;
|
||||
while day <= last {
|
||||
if day.year() == year {
|
||||
marks.entry(day).or_insert(ix);
|
||||
}
|
||||
day = day.tomorrow().expect("a day follows");
|
||||
}
|
||||
}
|
||||
marks
|
||||
}
|
||||
|
||||
/// A year in twelve small months; a day with something on it wears a dot in its first event's hue. Arrows move a day, Page keys a month, and Enter or a double press opens the day.
|
||||
#[derive(IntoElement)]
|
||||
pub struct YearView {
|
||||
id: ElementId,
|
||||
year: i16,
|
||||
events: Vec<Event>,
|
||||
today: Option<Date>,
|
||||
zone: Option<TimeZone>,
|
||||
on_day: Option<OnDay>,
|
||||
on_year: Option<OnYear>,
|
||||
}
|
||||
|
||||
impl YearView {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
year: i16,
|
||||
events: impl IntoIterator<Item = Event>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
year,
|
||||
events: unique(events),
|
||||
today: None,
|
||||
zone: None,
|
||||
on_day: None,
|
||||
on_year: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The day marked as today; the zone's own otherwise.
|
||||
pub fn today(mut self, date: Date) -> Self {
|
||||
self.today = Some(date);
|
||||
self
|
||||
}
|
||||
|
||||
/// The zone its days read in; the system's otherwise.
|
||||
pub fn zone(mut self, zone: TimeZone) -> Self {
|
||||
self.zone = Some(zone);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets a day opened: Enter on it or a double press.
|
||||
pub fn on_day(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_day = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets each year turned to.
|
||||
pub fn on_year(mut self, handler: impl Fn(i16, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_year = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// One small month: its name, the weekdays, and six weeks with only its own days shown.
|
||||
struct Mini<'a> {
|
||||
id: &'a ElementId,
|
||||
today: Date,
|
||||
cursor: Option<Date>,
|
||||
marks: &'a BTreeMap<Date, Hsla>,
|
||||
pick: &'a OnDay,
|
||||
open: &'a OnDay,
|
||||
}
|
||||
|
||||
impl Mini<'_> {
|
||||
fn month(&self, first: Date, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let cell = theme.calendar().mini_day;
|
||||
let initials = INITIALS.map(|initial| {
|
||||
div()
|
||||
.size(cell)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(initial)
|
||||
});
|
||||
let days = month_grid(first).into_iter().map(|day| {
|
||||
if day.first_of_month() != first {
|
||||
return div().size(cell).into_any_element();
|
||||
}
|
||||
let (pick, open) = (self.pick.clone(), self.open.clone());
|
||||
let today = day == self.today;
|
||||
div()
|
||||
.id((self.id.clone(), format!("day-{day}")))
|
||||
.debug_selector(move || format!("year-day {day}"))
|
||||
.relative()
|
||||
.size(cell)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(match self.cursor == Some(day) {
|
||||
true => colors.focus,
|
||||
false => gpui::transparent_black(),
|
||||
})
|
||||
.when(today, |day| day.bg(colors.accent))
|
||||
.text_color(match today {
|
||||
true => colors.on_accent,
|
||||
false => colors.fg,
|
||||
})
|
||||
.when(today, |day| day.font_weight(FontWeight::SEMIBOLD))
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||
if event.click_count == 2 {
|
||||
open(day, window, cx);
|
||||
} else {
|
||||
pick(day, window, cx);
|
||||
}
|
||||
})
|
||||
.child(day.day().to_string())
|
||||
.children(self.marks.get(&day).map(|tint| {
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_0p5()
|
||||
.size(theme.status_dot())
|
||||
.rounded_full()
|
||||
.bg(*tint)
|
||||
}))
|
||||
.into_any_element()
|
||||
});
|
||||
div()
|
||||
.flex_none()
|
||||
.w(cell * 7.0)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.px_1()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg)
|
||||
.child(first.strftime("%B").to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.children(initials)
|
||||
.children(days.collect::<Vec<_>>()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for YearView {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let zone = self
|
||||
.zone
|
||||
.unwrap_or_else(|| format::system_zone("year view"));
|
||||
let today = self
|
||||
.today
|
||||
.unwrap_or_else(|| Timestamp::now().to_zoned(zone.clone()).date());
|
||||
let year = use_seeded((self.id.clone(), "year"), self.year, window, cx);
|
||||
let shown = year.read(cx).value;
|
||||
let start = match today.year() == shown {
|
||||
true => today,
|
||||
false => Date::new(shown, 1, 1).expect("a year has a first day"),
|
||||
};
|
||||
let cursor = window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| start);
|
||||
if cursor.read(cx).year() != shown {
|
||||
cursor.update(cx, |cursor, _| *cursor = start);
|
||||
}
|
||||
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
|
||||
let at = *cursor.read(cx);
|
||||
let pick: OnDay = {
|
||||
let (id, year, cursor, on_year) = (
|
||||
self.id.clone(),
|
||||
year.clone(),
|
||||
cursor.clone(),
|
||||
self.on_year.clone(),
|
||||
);
|
||||
Rc::new(move |day, window, cx| {
|
||||
cursor.update(cx, |cursor, cx| {
|
||||
*cursor = day;
|
||||
cx.notify();
|
||||
});
|
||||
if year.read(cx).value == day.year() {
|
||||
return;
|
||||
}
|
||||
log::info!("year view {id:?}: {}", day.year());
|
||||
year.update(cx, |year, cx| {
|
||||
year.value = day.year();
|
||||
cx.notify();
|
||||
});
|
||||
if let Some(on_year) = &on_year {
|
||||
on_year(day.year(), window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let open: OnDay = {
|
||||
let (id, on_day) = (self.id.clone(), self.on_day.clone());
|
||||
Rc::new(move |day, window, cx| {
|
||||
log::info!("year view {id:?}: open {day}");
|
||||
if let Some(on_day) = &on_day {
|
||||
on_day(day, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let step: Step = {
|
||||
let pick = pick.clone();
|
||||
Rc::new(move |by, window, cx| {
|
||||
let day = match by {
|
||||
0 => today,
|
||||
by => at
|
||||
.checked_add(by.years())
|
||||
.expect("a year lies on either side"),
|
||||
};
|
||||
pick(day, window, cx)
|
||||
})
|
||||
};
|
||||
let keys = {
|
||||
let (focus, pick, open) = (focus.clone(), pick.clone(), open.clone());
|
||||
move |event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App| {
|
||||
if !focus.is_focused(window) {
|
||||
return;
|
||||
}
|
||||
let by = match event.keystroke.key.as_str() {
|
||||
"left" => 1.day().negate(),
|
||||
"right" => 1.day(),
|
||||
"up" => 1.week().negate(),
|
||||
"down" => 1.week(),
|
||||
"pageup" => 1.month().negate(),
|
||||
"pagedown" => 1.month(),
|
||||
"enter" => {
|
||||
cx.stop_propagation();
|
||||
return open(at, window, cx);
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
cx.stop_propagation();
|
||||
pick(at.checked_add(by).expect("a day nearby"), window, cx)
|
||||
}
|
||||
};
|
||||
let tints = marks(&self.events, shown, &zone)
|
||||
.into_iter()
|
||||
.map(|(day, ix)| (day, tint(&self.events[ix], cx)))
|
||||
.collect();
|
||||
let mini = Mini {
|
||||
id: &self.id,
|
||||
today,
|
||||
cursor: focus.is_focused(window).then_some(at),
|
||||
marks: &tints,
|
||||
pick: &pick,
|
||||
open: &open,
|
||||
};
|
||||
let months: Vec<AnyElement> = (1..=12)
|
||||
.map(|month| {
|
||||
let first = Date::new(shown, month, 1).expect("a month has a first day");
|
||||
mini.month(first, cx)
|
||||
})
|
||||
.collect();
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(head(
|
||||
&self.id,
|
||||
shown.to_string(),
|
||||
"Previous year",
|
||||
"Next year",
|
||||
step,
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.id((self.id.clone(), "months"))
|
||||
.debug_selector(|| "year-months".into())
|
||||
.track_focus(&focus)
|
||||
.on_key_down(keys)
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_x_8()
|
||||
.gap_y_6()
|
||||
.children(months),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::{civil::date, tz::TimeZone};
|
||||
|
||||
use super::marks;
|
||||
use crate::calendar::Event;
|
||||
|
||||
#[test]
|
||||
fn a_day_takes_the_first_events_mark_within_the_year() {
|
||||
let events = [
|
||||
Event::all_day("trip", "Trip", date(2026, 12, 30), date(2027, 1, 2), 0),
|
||||
Event::all_day(
|
||||
"offsite",
|
||||
"Offsite",
|
||||
date(2026, 12, 31),
|
||||
date(2026, 12, 31),
|
||||
1,
|
||||
),
|
||||
];
|
||||
let marks = marks(&events, 2026, &TimeZone::UTC);
|
||||
assert_eq!(
|
||||
marks.into_iter().collect::<Vec<_>>(),
|
||||
[(date(2026, 12, 30), 0), (date(2026, 12, 31), 0)],
|
||||
"the trip marks its days in 2026 only, and first"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user