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,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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user