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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 02:49:37 -04:00
1 parent c1a5603184
commit f316ab56ba
22 files changed
+2121 -20

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::*, relative,
};
use jiff::tz::TimeZone;
use super::{
chip::tint,
event::{Event, hours},
};
use crate::{
forms::Run,
primitives::FocusRing,
theme::{ActiveTheme, Radius, TextSize},
typography::{Ellipsis, format},
};
/// An event as a block: a rule in its hue, its title, and under it its hours and place. It fills the box it is given, as a time grid sizes it by the hour. Pressable with `on_click`.
#[derive(IntoElement)]
pub struct EventCard {
id: ElementId,
event: Event,
zone: Option<TimeZone>,
on_click: Option<Run>,
}
impl EventCard {
pub fn new(id: impl Into<ElementId>, event: Event) -> Self {
Self {
id: id.into(),
event,
zone: None,
on_click: None,
}
}
/// The zone its hours read in; the system's otherwise.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for EventCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let zone = self
.zone
.unwrap_or_else(|| format::system_zone("event card"));
let theme = cx.theme();
let colors = &theme.colors;
let tint = tint(&self.event, cx);
let detail = match &self.event.place {
Some(place) => format!("{} · {place}", hours(&self.event, &zone)),
None => hours(&self.event, &zone),
};
let key = self.event.key.clone();
div()
.id(self.id)
.debug_selector(move || format!("event-card {key}"))
.relative()
.size_full()
.overflow_hidden()
.flex()
.flex_col()
.gap_0p5()
.pl_2p5()
.pr_1p5()
.py_1()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(gpui::transparent_black())
.bg(tint.alpha(0.14))
.line_height(relative(1.25))
.when_some(self.on_click, |card, on_click| {
let key = self.event.key.clone();
card.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
log::info!("event card: {key}");
on_click(window, cx)
})
})
.child(
div()
.absolute()
.top_1()
.bottom_1()
.left_1()
.w_0p5()
.rounded_full()
.bg(tint),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(Ellipsis::new(self.event.title.clone())),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(Ellipsis::new(detail)),
)
}
}
+146
View File
@@ -0,0 +1,146 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::*, relative,
};
use jiff::tz::TimeZone;
use super::event::{Event, When};
use crate::{
forms::Run,
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, Radius, TextSize},
typography::{Ellipsis, format},
};
/// Event `event`'s color: the chart color at its hue.
pub(crate) fn tint(event: &Event, cx: &App) -> Hsla {
cx.theme()
.colors
.hue(event.hue, format_args!("event {}", event.key))
}
/// An event in a line: a dot, the hour it starts and its title; or, for whole days, its title on a wash of its color. Pressable with `on_click`.
#[derive(IntoElement)]
pub struct EventChip {
id: ElementId,
event: Event,
zone: Option<TimeZone>,
before: bool,
after: bool,
on_click: Option<Run>,
}
impl EventChip {
pub fn new(id: impl Into<ElementId>, event: Event) -> Self {
Self {
id: id.into(),
event,
zone: None,
before: false,
after: false,
on_click: None,
}
}
/// The zone its hour reads in; the system's otherwise.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
/// Squares the ends where the event runs on past the chip, as a bar cut at a week's edge.
pub(crate) fn open(mut self, before: bool, after: bool) -> Self {
self.before = before;
self.after = after;
self
}
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for EventChip {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let zone = self
.zone
.unwrap_or_else(|| format::system_zone("event chip"));
let focus = tab_stop(
(self.id.clone(), "focus").into(),
self.on_click.is_some(),
window,
cx,
);
let theme = cx.theme();
let colors = &theme.colors;
let tint = tint(&self.event, cx);
let round = theme.radius(Radius::Sm);
let spans = self.event.spans(&zone);
let hour = match self.event.when {
When::Timed { start, .. } if !spans => {
Some(format::datetime(start, &zone, "%H:%M").expect("a fixed pattern formats"))
}
_ => None,
};
let key = self.event.key.clone();
div()
.id(self.id.clone())
.debug_selector(move || format!("event-chip {key}"))
.track_focus(&focus)
.flex()
.items_center()
.gap_1()
.w_full()
.h(theme.calendar().chip)
.px_1p5()
.rounded(round)
.when(self.before, |chip| chip.rounded_l_none())
.when(self.after, |chip| chip.rounded_r_none())
.border_1()
.border_color(gpui::transparent_black())
.focus_ring(cx)
.text_size(theme.text_size(TextSize::Xs))
.line_height(relative(1.0))
.text_color(colors.fg)
.map(|chip| match &hour {
Some(_) => chip.hover(|style| style.bg(colors.hover)),
None => chip.bg(tint.alpha(0.18)).font_weight(FontWeight::MEDIUM),
})
.when_some(self.on_click, |chip, on_click| {
let key = self.event.key.clone();
chip.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
log::info!("event chip: {key}");
on_click(window, cx)
})
})
.when(hour.is_some(), |chip| {
chip.child(
div()
.flex_none()
.size(theme.status_dot())
.rounded_full()
.bg(tint),
)
})
.children(hour.map(|hour| {
div()
.min_w_0()
.text_color(colors.fg_muted)
.child(Ellipsis::new(hour))
}))
.child(
div()
.flex_1()
.min_w_0()
.child(Ellipsis::new(self.event.title.clone())),
)
}
}
+165
View File
@@ -0,0 +1,165 @@
use gpui::SharedString;
use jiff::{SignedDuration, Timestamp, civil::Date, tz::TimeZone};
use crate::typography::format;
/// When an event happens: from one moment to a later one, or whole days from the first to the last.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum When {
Timed { start: Timestamp, end: Timestamp },
AllDay { first: Date, last: Date },
}
/// Something on a calendar: its key, its title, when it happens, its hue among the theme's chart colors, and where.
#[derive(Clone, Debug, PartialEq)]
pub struct Event {
pub key: SharedString,
pub title: SharedString,
pub when: When,
pub hue: usize,
pub place: Option<SharedString>,
}
impl Event {
/// From `start` to `end`, which comes later.
pub fn timed(
key: impl Into<SharedString>,
title: impl Into<SharedString>,
start: Timestamp,
end: Timestamp,
hue: usize,
) -> Self {
let key = key.into();
assert!(end > start, "event {key}: it ends before it starts");
Self {
key,
title: title.into(),
when: When::Timed { start, end },
hue,
place: None,
}
}
/// Whole days from `first` to `last`.
pub fn all_day(
key: impl Into<SharedString>,
title: impl Into<SharedString>,
first: Date,
last: Date,
hue: usize,
) -> Self {
let key = key.into();
assert!(
last >= first,
"event {key}: its last day comes before its first"
);
Self {
key,
title: title.into(),
when: When::AllDay { first, last },
hue,
place: None,
}
}
pub fn place(mut self, place: impl Into<SharedString>) -> Self {
self.place = Some(place.into());
self
}
/// The first and last days it touches in `zone`. An end at midnight touches no part of that day.
pub(crate) fn days(&self, zone: &TimeZone) -> (Date, Date) {
match self.when {
When::AllDay { first, last } => (first, last),
When::Timed { start, end } => {
let first = start.to_zoned(zone.clone()).date();
let before_end = end - SignedDuration::from_nanos(1);
(first, before_end.to_zoned(zone.clone()).date().max(first))
}
}
}
/// Whether it shows as a bar across days: all day, or timed past midnight.
pub(crate) fn spans(&self, zone: &TimeZone) -> bool {
let (first, last) = self.days(zone);
matches!(self.when, When::AllDay { .. }) || first != last
}
}
/// Events with each key asserted once.
pub(crate) fn unique(events: impl IntoIterator<Item = Event>) -> Vec<Event> {
let events: Vec<Event> = events.into_iter().collect();
for (ix, event) in events.iter().enumerate() {
let twin = events[..ix].iter().any(|other| other.key == event.key);
assert!(!twin, "event {} twice", event.key);
}
events
}
/// When `event` happens, read in `zone`: its hours, its hours with their days across midnight, all day, or its span of days.
pub(crate) fn hours(event: &Event, zone: &TimeZone) -> String {
let dash = " \u{2013} ";
match event.when {
When::AllDay { first, last } if first == last => "All day".into(),
When::AllDay { first, last } if first.month() == last.month() => {
format!("{}{dash}{}", first.strftime("%b %-d"), last.strftime("%-d"))
}
When::AllDay { first, last } => {
format!(
"{}{dash}{}",
first.strftime("%b %-d"),
last.strftime("%b %-d")
)
}
When::Timed { start, end } => {
let pattern = match event.spans(zone) {
true => "%a %H:%M",
false => "%H:%M",
};
let at = |moment| format::datetime(moment, zone, pattern).expect("a fixed pattern");
format!("{}{dash}{}", at(start), at(end))
}
}
}
#[cfg(test)]
mod tests {
use jiff::{Timestamp, civil::date, tz::TimeZone};
use super::{Event, hours};
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()
}
#[test]
fn hours_read_by_the_clock_across_midnight_and_by_days() {
let utc = &TimeZone::UTC;
let said = |event: Event| hours(&event, utc);
assert_eq!(
said(Event::timed("a", "A", at(22, 9, 30), at(22, 10, 15), 0)),
"09:30 \u{2013} 10:15"
);
assert_eq!(
said(Event::timed("b", "B", at(21, 22, 0), at(22, 2, 0), 0)),
"Mon 22:00 \u{2013} Tue 02:00"
);
let day = |first: i8, last| (date(2026, 9, first), last);
let (first, last) = day(22, date(2026, 9, 22));
assert_eq!(said(Event::all_day("c", "C", first, last, 0)), "All day");
let (first, last) = day(22, date(2026, 9, 24));
assert_eq!(
said(Event::all_day("d", "D", first, last, 0)),
"Sep 22 \u{2013} 24"
);
let (first, last) = day(29, date(2026, 10, 2));
assert_eq!(
said(Event::all_day("e", "E", first, last, 0)),
"Sep 29 \u{2013} Oct 2"
);
}
}
+66
View File
@@ -0,0 +1,66 @@
use std::rc::Rc;
use gpui::{App, Div, ElementId, FontWeight, ParentElement, Styled, Window, div};
use jiff::civil::Date;
use crate::{
buttons::{Button, ButtonVariant, IconButton},
primitives::IconName,
theme::{ActiveTheme, ControlSize, TextSize},
};
pub(crate) type OnDay = Rc<dyn Fn(Date, &mut Window, &mut App)>;
/// Steps a view back (-1), to today (0) or on (1).
pub(crate) type Step = Rc<dyn Fn(i32, &mut Window, &mut App)>;
/// A view's title, and beside it Today and the arrows back and on; the arrows drop below a narrow title.
pub(crate) fn head(
id: &ElementId,
title: String,
back: &str,
on: &str,
step: Step,
cx: &App,
) -> Div {
let theme = cx.theme();
let button = |name: &str, icon: IconName, tip: &str, by: i32| {
let step = step.clone();
IconButton::new((id.clone(), name.to_string()), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip.to_string())
.on_click(move |_, window, cx| step(by, window, cx))
};
let today = {
let step = step.clone();
Button::new((id.clone(), "today"), "Today")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| step(0, window, cx))
};
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::SEMIBOLD)
.text_color(theme.colors.fg)
.child(title),
)
.child(
div()
.flex_none()
.flex()
.items_center()
.gap_1()
.child(today)
.child(button("back", IconName::ChevronLeft, back, -1))
.child(button("on", IconName::ChevronRight, on, 1)),
)
}
+16
View File
@@ -0,0 +1,16 @@
mod card;
mod chip;
mod event;
mod head;
mod month;
mod weeks;
mod year;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use card::EventCard;
pub use chip::EventChip;
pub use event::{Event, When};
pub use month::CalendarMonthView;
pub use year::YearView;
+446
View File
@@ -0,0 +1,446 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::*,
relative, rems,
};
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
use super::{
chip::EventChip,
event::{Event, unique},
head::{OnDay, Step, head},
weeks::{Bar, bars, day_rows, singles, weeks},
};
use crate::{
forms::OnValue,
layout::seeded::use_seeded,
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, Radius, TextSize},
typography::{Ellipsis, format},
};
const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
/// A month of days in week rows, each with its events: what spans days lies across them as bars, the rest by the hour as chips, and what does not fit waits behind "N more". Arrows move a day, Page keys turn the month, and Enter or a double press opens the day.
#[derive(IntoElement)]
pub struct CalendarMonthView {
id: ElementId,
month: Date,
events: Vec<Event>,
today: Option<Date>,
zone: Option<TimeZone>,
on_day: Option<OnDay>,
on_event: Option<OnValue>,
on_month: Option<OnDay>,
}
impl CalendarMonthView {
pub fn new(
id: impl Into<ElementId>,
month: Date,
events: impl IntoIterator<Item = Event>,
) -> Self {
Self {
id: id.into(),
month,
events: unique(events),
today: None,
zone: None,
on_day: None,
on_event: None,
on_month: 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 and hours 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, a double press, or its "N more".
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 the key of an event pressed.
pub fn on_event(
mut self,
handler: impl Fn(&gpui::SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_event = Some(Rc::new(handler));
self
}
/// Gets the first day of each month turned to.
pub fn on_month(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self {
self.on_month = Some(Rc::new(handler));
self
}
}
/// What a week row draws with: the view's id and zone, its events, today, the cursor, and what presses do.
struct Row<'a> {
id: &'a ElementId,
events: &'a [Event],
zone: &'a TimeZone,
shown: Date,
today: Date,
cursor: Option<Date>,
pick: OnDay,
open: OnDay,
on_event: Option<OnValue>,
}
/// Sizes of a month's rows in pixels: a chip, the gap between rows, and the cell's inset.
struct Metrics {
chip: Pixels,
gap: Pixels,
pad: Pixels,
rows: usize,
}
impl Metrics {
fn pitch(&self) -> Pixels {
self.chip + self.gap
}
/// A day's height: its number, then its rows of events.
fn height(&self) -> Pixels {
self.pad * 2.0 + self.chip + self.pitch() * self.rows as f32
}
}
impl Row<'_> {
fn chip(&self, id: String, event: &Event) -> EventChip {
let chip = EventChip::new((self.id.clone(), id), event.clone()).zone(self.zone.clone());
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,
}
}
fn bar(&self, bar: &Bar, monday: Date, sizes: &Metrics) -> AnyElement {
let event = &self.events[bar.event];
let span = (bar.to - bar.from + 1) as f32;
div()
.absolute()
.top(sizes.pad + sizes.pitch() * (bar.lane + 1) as f32)
.left(relative(bar.from as f32 / 7.0))
.w(relative(span / 7.0))
.when(!bar.before, |slot| slot.pl_1())
.when(!bar.after, |slot| slot.pr_1())
.child(
self.chip(format!("bar-{}-{monday}", event.key), event)
.open(bar.before, bar.after),
)
.into_any_element()
}
fn day(
&self,
day: Date,
column: usize,
lanes: usize,
hidden: usize,
sizes: &Metrics,
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let on = singles(self.events, day, self.zone);
let (shown, more) = day_rows(on.len(), hidden, sizes.rows - lanes);
let label = match day.day() {
1 => day.strftime("%b %-d").to_string(),
number => number.to_string(),
};
let fg = if day.first_of_month() == self.shown {
colors.fg
} else {
colors.fg_subtle
};
let (pick, open) = (self.pick.clone(), self.open.clone());
let more = (more > 0).then(|| {
let open = self.open.clone();
div()
.id((self.id.clone(), format!("more-{day}")))
.debug_selector(move || format!("more {day}"))
.h(sizes.chip)
.flex()
.items_center()
.px_1p5()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(gpui::transparent_black())
.focus_ring(cx)
.tab_index(0)
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.cursor_pointer()
.hover(|style| style.bg(colors.hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| open(day, window, cx))
.child(div().min_w_0().child(Ellipsis::new(format!("{more} more"))))
});
div()
.id((self.id.clone(), format!("day-{day}")))
.debug_selector(move || format!("day {day}"))
.absolute()
.top_0()
.left(relative(column as f32 / 7.0))
.w(relative(1.0 / 7.0))
.h_full()
.overflow_hidden()
.p_1()
.flex()
.flex_col()
.gap_0p5()
.border_r_1()
.border_b_1()
.border_color(colors.border)
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
if event.click_count == 2 {
open(day, window, cx);
} else {
pick(day, window, cx);
}
})
.when(self.cursor == Some(day), |cell| {
cell.child(
div()
.absolute()
.top_0()
.left_0()
.size_full()
.border_1()
.border_color(colors.focus),
)
})
.child(
div().h(sizes.chip).flex().items_center().child(
div()
.px_1p5()
.rounded_full()
.text_size(theme.text_size(TextSize::Xs))
.text_color(fg)
.map(|number| match day == self.today {
true => number
.bg(colors.accent)
.text_color(colors.on_accent)
.font_weight(FontWeight::SEMIBOLD),
false => number,
})
.child(label),
),
)
.children((0..lanes).map(|_| div().flex_none().h(sizes.chip)))
.children(on.iter().take(shown).map(|&ix| {
let event = &self.events[ix];
self.chip(format!("chip-{}", event.key), event)
}))
.children(more)
.into_any_element()
}
fn week(&self, monday: Date, sizes: &Metrics, cx: &App) -> AnyElement {
let bars = bars(self.events, monday, self.zone);
let lanes = bars
.iter()
.map(|bar| bar.lane + 1)
.max()
.unwrap_or(0)
.min(sizes.rows - 1);
let days = (0..7).map(|column| {
let day = monday
.checked_add((column as i64).days())
.expect("a week has seven days");
let hidden = bars
.iter()
.filter(|bar| bar.lane >= lanes && bar.from <= column && column <= bar.to)
.count();
self.day(day, column, lanes, hidden, sizes, cx)
});
div()
.relative()
.h(sizes.height())
.children(days.collect::<Vec<_>>())
.children(
bars.iter()
.filter(|bar| bar.lane < lanes)
.map(|bar| self.bar(bar, monday, sizes)),
)
.into_any_element()
}
}
impl RenderOnce for CalendarMonthView {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let zone = self
.zone
.unwrap_or_else(|| format::system_zone("calendar month view"));
let today = self
.today
.unwrap_or_else(|| Timestamp::now().to_zoned(zone.clone()).date());
let month = use_seeded(
(self.id.clone(), "month"),
self.month.first_of_month(),
window,
cx,
);
let shown = month.read(cx).value;
let start = match today.first_of_month() == shown {
true => today,
false => shown,
};
let cursor = window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| start);
if cursor.read(cx).first_of_month() != 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 turn: OnDay = {
let (id, month, on_month) = (self.id.clone(), month.clone(), self.on_month.clone());
Rc::new(move |day, window, cx| {
let first = day.first_of_month();
if month.read(cx).value == first {
return;
}
log::info!("calendar month view {id:?}: {}", first.strftime("%B %Y"));
month.update(cx, |month, cx| {
month.value = first;
cx.notify();
});
if let Some(on_month) = &on_month {
on_month(first, window, cx);
}
})
};
let pick: OnDay = {
let cursor = cursor.clone();
Rc::new(move |day, window, cx| {
cursor.update(cx, |cursor, cx| {
*cursor = day;
cx.notify();
});
turn(day, window, cx);
})
};
let open: OnDay = {
let (id, on_day) = (self.id.clone(), self.on_day.clone());
Rc::new(move |day, window, cx| {
log::info!("calendar month 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.months())
.expect("a month 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 theme = cx.theme();
let colors = &theme.colors;
let rem = window.rem_size();
let sizes = Metrics {
chip: theme.calendar().chip.to_pixels(rem),
gap: rems(0.125).to_pixels(rem),
pad: rems(0.25).to_pixels(rem),
rows: theme.calendar().day_rows,
};
let row = Row {
id: &self.id,
events: &self.events,
zone: &zone,
shown,
today,
cursor: focus.is_focused(window).then_some(at),
pick,
open,
on_event: self.on_event.clone(),
};
let weekdays = div().flex().children(WEEKDAYS.map(|name| {
div()
.flex_none()
.w(relative(1.0 / 7.0))
.px_1p5()
.pb_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(name)
}));
let rows: Vec<AnyElement> = weeks(shown)
.into_iter()
.map(|monday| row.week(monday, &sizes, cx))
.collect();
let title = shown.strftime("%B %Y").to_string();
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(head(
&self.id,
title,
"Previous month",
"Next month",
step,
cx,
))
.child(
div().flex().flex_col().child(weekdays).child(
div()
.id((self.id.clone(), "grid"))
.debug_selector(|| "month-grid".into())
.track_focus(&focus)
.on_key_down(keys)
.border_t_1()
.border_l_1()
.border_color(colors.border)
.children(rows),
),
)
}
}
+267
View File
@@ -0,0 +1,267 @@
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};
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"]);
}
+213
View File
@@ -0,0 +1,213 @@
use std::cmp::Reverse;
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
use super::event::{Event, When};
use crate::forms::week_start;
/// An event that spans days, laid across a week row: its lane from the top, its first and last column, and whether it runs on past either end.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Bar {
pub event: usize,
pub lane: usize,
pub from: usize,
pub to: usize,
pub before: bool,
pub after: bool,
}
/// The Mondays of the week rows that hold `month`'s days: four to six.
pub(crate) fn weeks(month: Date) -> Vec<Date> {
let last = month.last_of_month();
let mut monday = week_start(month.first_of_month());
let mut mondays = Vec::with_capacity(6);
while monday <= last {
mondays.push(monday);
monday = monday.checked_add(1.week()).expect("a week follows");
}
mondays
}
fn column(monday: Date, day: Date) -> usize {
let days = monday.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");
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)
})
.filter(|&(_, first, last)| first <= sunday && last >= monday)
.collect();
spans.sort_by_key(|&(ix, first, last)| (first, Reverse(last), ix));
let mut ends: Vec<Date> = Vec::new();
spans
.into_iter()
.map(|(event, first, last)| {
let (from, to) = (first.max(monday), last.min(sunday));
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),
}
Bar {
event,
lane,
from: column(monday, from),
to: column(monday, to),
before: first < monday,
after: last > sunday,
}
})
.collect()
}
fn start(event: &Event) -> Timestamp {
match event.when {
When::Timed { start, .. } => start,
When::AllDay { .. } => panic!("event {}: an all-day event spans", event.key),
}
}
/// The events on `day` alone, by start.
pub(crate) fn singles(events: &[Event], day: Date, zone: &TimeZone) -> Vec<usize> {
let mut on: Vec<usize> = events
.iter()
.enumerate()
.filter(|(_, event)| !event.spans(zone) && event.days(zone).0 == day)
.map(|(ix, _)| ix)
.collect();
on.sort_by_key(|&ix| (start(&events[ix]), ix));
on
}
/// What a day shows under its week's bars: how many of its `singles` fit in `room` rows, and how many more wait, counting `hidden` bars past the lanes shown.
pub(crate) fn day_rows(singles: usize, hidden: usize, room: usize) -> (usize, usize) {
if hidden == 0 && singles <= room {
return (singles, 0);
}
let shown = room.saturating_sub(1).min(singles);
(shown, singles - shown + hidden)
}
#[cfg(test)]
mod tests {
use jiff::{Timestamp, civil::date, tz::TimeZone};
use super::{Bar, bars, day_rows, singles, weeks};
use crate::calendar::Event;
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()
}
fn bar(event: usize, lane: usize, from: usize, to: usize) -> Bar {
Bar {
event,
lane,
from,
to,
before: false,
after: false,
}
}
#[test]
fn a_month_takes_the_weeks_that_hold_its_days() {
assert_eq!(
weeks(date(2026, 9, 15)),
[
date(2026, 8, 31),
date(2026, 9, 7),
date(2026, 9, 14),
date(2026, 9, 21),
date(2026, 9, 28)
]
);
assert_eq!(weeks(date(2027, 2, 1)).len(), 4, "a February from a Monday");
assert_eq!(
weeks(date(2026, 8, 1)).len(),
6,
"an August from a Saturday"
);
}
#[test]
fn bars_take_the_first_free_lane_and_break_at_the_week() {
let events = [
Event::all_day(
"offsite",
"Offsite",
date(2026, 9, 22),
date(2026, 9, 24),
0,
),
Event::all_day("trip", "Trip", date(2026, 9, 24), date(2026, 9, 29), 1),
Event::all_day("fair", "Fair", date(2026, 9, 25), date(2026, 9, 26), 2),
Event::timed("night", "Night shoot", at(21, 22), at(22, 2), 3),
];
let utc = &TimeZone::UTC;
assert_eq!(
bars(&events, date(2026, 9, 21), utc),
[
bar(3, 0, 0, 1),
bar(0, 1, 1, 3),
Bar {
after: true,
..bar(1, 0, 3, 6)
},
bar(2, 1, 4, 5),
]
);
assert_eq!(
bars(&events, date(2026, 9, 28), utc),
[Bar {
before: true,
..bar(1, 0, 0, 1)
}]
);
}
#[test]
fn a_day_shows_what_fits_and_counts_the_rest() {
assert_eq!(day_rows(3, 0, 3), (3, 0));
assert_eq!(day_rows(5, 0, 3), (2, 3), "two chips, then 3 more");
assert_eq!(day_rows(0, 1, 1), (0, 1), "a hidden bar still counts");
assert_eq!(day_rows(2, 1, 2), (1, 2));
}
#[test]
fn a_days_own_events_come_by_the_hour() {
let events = [
Event::timed("late", "Late", at(22, 14), at(22, 15), 0),
Event::timed("early", "Early", at(22, 9), at(22, 10), 0),
Event::all_day("all", "All", date(2026, 9, 22), date(2026, 9, 22), 0),
Event::timed("next", "Next", at(23, 9), at(23, 10), 0),
];
assert_eq!(singles(&events, date(2026, 9, 22), &TimeZone::UTC), [1, 0]);
}
#[test]
fn an_end_at_midnight_leaves_the_next_day_alone() {
let late = Event::timed("late", "Late", at(22, 20), at(23, 0), 0);
assert_eq!(
late.days(&TimeZone::UTC),
(date(2026, 9, 22), date(2026, 9, 22))
);
assert!(!late.spans(&TimeZone::UTC));
}
}
+353
View File
@@ -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"
);
}
}
+1
View File
@@ -16,6 +16,7 @@ use jiff::{
use crate::typography::format::system_zone;
pub use calendar::Calendar;
pub(crate) use calendar::{month_grid, week_start};
pub use cron::CronEditor;
pub use period::{MonthPicker, QuarterPicker, YearPicker};
pub use picker::{DatePicker, DateRangePicker};
+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, picker_field};
pub(crate) use date::{Face, dropdown, month_grid, picker_field, week_start};
pub use expression::{ExpressionInput, evaluate, expression_highlights};
pub use files::{DropZone, FileInput, PICTURES};
pub(crate) use files::{browse, dropped};
+1
View File
@@ -2,6 +2,7 @@ pub mod agent;
mod assets;
pub mod buttons;
pub mod calendar;
pub mod charts;
pub mod chat;
pub mod collab;
+1 -5
View File
@@ -37,11 +37,7 @@ impl Label {
/// Label `name`'s dot in the chart color at `hue`, centered in an icon's box.
pub(super) fn hue_dot(name: &str, hue: usize, cx: &App) -> Div {
let theme = cx.theme();
let tint = *theme
.colors
.chart
.get(hue)
.unwrap_or_else(|| panic!("label {name}: no chart hue {hue}"));
let tint = theme.colors.hue(hue, format_args!("label {name}"));
div()
.flex_none()
.size(theme.icon_size(IconSize::Sm))
+24
View File
@@ -0,0 +1,24 @@
use gpui::Rems;
use super::{Theme, tokens::px_to_rems};
/// Calendar's measures.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CalendarSizes {
/// An event's chip in a day, tall.
pub chip: Rems,
/// Rows of events a month's day holds under its number.
pub day_rows: usize,
/// A day in a year's small months, square.
pub mini_day: Rems,
}
impl Theme {
pub fn calendar(&self) -> CalendarSizes {
CalendarSizes {
chip: px_to_rems(20.0),
day_rows: 3,
mini_day: px_to_rems(28.0),
}
}
}
+2
View File
@@ -1,3 +1,4 @@
mod calendar;
mod chart;
mod files;
mod generative;
@@ -11,6 +12,7 @@ use std::time::{Duration, Instant};
use gpui::{App, Global, SharedString, WindowAppearance};
use crate::motion;
pub use calendar::CalendarSizes;
pub use chart::ChartSizes;
pub use files::FileSizes;
pub use generative::GenerativeSizes;
+8
View File
@@ -118,6 +118,14 @@ fn c8<const N: usize>(hex: [u32; N]) -> [Hsla; N] {
}
impl Palette {
/// The chart color at `hue`; a hue past the chart fails, naming `owner`.
pub fn hue(&self, hue: usize, owner: impl std::fmt::Display) -> Hsla {
*self
.chart
.get(hue)
.unwrap_or_else(|| panic!("{owner}: no chart hue {hue}"))
}
pub fn light(high_contrast: bool) -> Self {
let mut palette = Self {
bg: c(0xfcfaf7),