Files
Ely-GPUI-Components/src/calendar/year.rs
T
ZacharyZhang-NYandClaude Opus 5.5 b68f2bf1d5 Calendar · views on the clock keep moving
The week and day views read the clock once and handed that moment down
as given, which turned off the line's ticker: the line at now and the
lit day stood still. The owner's moment now goes down unchanged, and a
time grid, month view or year view on the clock keeps its own ticker,
so the line moves and today turns over at midnight, even on days that
are not today. A given moment holds them still.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:23:18 -04:00

357 lines
11 KiB
Rust

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, fresh},
};
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 {
if self.today.is_none() {
fresh((self.id.clone(), "clock"), window, cx);
}
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"
);
}
}