Calendar · Agenda and people: an agenda, an event's details, its guests

AgendaView lists the days ahead under their names, whole days first,
then by the hour, and leaves out days with nothing; it rides on
lists::Sections, so a press or an arrow selects and Enter opens. An
empty stretch says nothing is planned.

EventPopover is an event's chip that opens its details: its title in
its hue, when and where, who is asked, and Edit and Delete when the
owner handles them. It opens through Popover::with_opener, which now
hands any element the toggle and keeps focus, placement and every way
out in the popover. AttendeeList counts the answers and lists each
guest with their answer, the organizer first. A hue worn in a row is
now data_display::color_mark, for mail labels and events alike.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 03:35:59 -04:00
1 parent 57dd55320e
commit 48ff348357
17 files changed
+940 -37

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
use std::{collections::HashMap, rc::Rc};
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
use super::{
chip::tint,
event::{Event, When, hours, unique},
hours::minutes,
};
use crate::{
data_display::color_mark,
forms::OnValue,
lists::{ListItem, Sections},
theme::{ActiveTheme, TextSize},
typography::{format, fresh},
};
/// A day's heading: today and tomorrow say so before the date.
pub(crate) fn heading(day: Date, today: Date) -> SharedString {
let long = day.strftime("%A, %B %-d").to_string();
let tomorrow = today.tomorrow().expect("a day follows today");
match day {
day if day == today => format!("Today · {long}").into(),
day if day == tomorrow => format!("Tomorrow · {long}").into(),
_ => long.into(),
}
}
/// The events on `day` in order: whole days first, then by their start that day.
pub(crate) fn on_day<'a>(events: &'a [Event], day: Date, zone: &TimeZone) -> Vec<&'a Event> {
let mut on: Vec<(i64, usize, &Event)> = events
.iter()
.enumerate()
.filter_map(|(ix, event)| match event.when {
When::AllDay { first, last } => {
(first <= day && day <= last).then_some((-1, ix, event))
}
When::Timed { .. } => minutes(event, day, zone).map(|(from, _)| (from, ix, event)),
})
.collect();
on.sort_by_key(|&(from, ix, _)| (from, ix));
on.into_iter().map(|(_, _, event)| event).collect()
}
/// The days from `from` with their events under each day's name, whole days first, then by the hour; days without events are left out. A press or an arrow selects an event, Enter or a double press opens it.
#[derive(IntoElement)]
pub struct AgendaView {
id: ElementId,
from: Date,
days: usize,
events: Vec<Event>,
zone: Option<TimeZone>,
today: Option<Date>,
on_event: Option<OnValue>,
}
impl AgendaView {
pub fn new(
id: impl Into<ElementId>,
from: Date,
days: usize,
events: impl IntoIterator<Item = Event>,
) -> Self {
Self {
id: id.into(),
from,
days,
events: unique(events),
zone: None,
today: None,
on_event: None,
}
}
/// The zone its days and hours read in; the system's otherwise.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
/// The day named today; the zone's own otherwise.
pub fn today(mut self, date: Date) -> Self {
self.today = Some(date);
self
}
/// Gets the key of an event opened.
pub fn on_event(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_event = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AgendaView {
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("agenda view"));
let today = self
.today
.unwrap_or_else(|| Timestamp::now().to_zoned(zone.clone()).date());
let picked =
window.use_keyed_state((self.id.clone(), "picked"), cx, |_, _| None::<SharedString>);
let theme = cx.theme();
let mut owners: HashMap<SharedString, SharedString> = HashMap::new();
let mut sections = Sections::new(self.id.clone());
for offset in 0..self.days as i64 {
let day = self.from.checked_add(offset.days()).expect("a day follows");
let on = on_day(&self.events, day, &zone);
if on.is_empty() {
continue;
}
let rows = on
.into_iter()
.map(|event| {
let key = SharedString::from(format!("{day}/{}", event.key));
owners.insert(key.clone(), event.key.clone());
let detail = match &event.place {
Some(place) => format!("{} · {place}", hours(event, &zone)),
None => hours(event, &zone),
};
let mark = color_mark(tint(event, cx), cx);
let row =
ListItem::new((self.id.clone(), key.to_string()), event.title.clone())
.leading(mark)
.description(detail);
(key, row)
})
.collect();
sections = sections.section(heading(day, today), rows);
}
if owners.is_empty() {
return div()
.debug_selector(|| "agenda-none".into())
.w_full()
.py_6()
.flex()
.justify_center()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_subtle)
.child("Nothing planned.")
.into_any_element();
}
let owners = Rc::new(owners);
let pick: OnValue = {
let picked = picked.clone();
Rc::new(move |key, _, cx| {
picked.update(cx, |picked, cx| {
*picked = Some(key.clone());
cx.notify();
})
})
};
let open: OnValue = {
let (id, on_event) = (self.id.clone(), self.on_event);
Rc::new(move |key, window, cx| {
let event = owners.get(key).expect("a row names its event");
log::info!("agenda view {id:?}: open {event}");
if let Some(on_event) = &on_event {
on_event(event, window, cx);
}
})
};
div()
.w_full()
.child(
sections
.selected(picked.read(cx).clone())
.on_select(pick)
.on_activate(open),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use jiff::{civil::date, tz::TimeZone};
use super::{heading, on_day};
use crate::calendar::Event;
#[test]
fn today_and_tomorrow_say_so_before_the_date() {
let today = date(2026, 9, 27);
assert_eq!(heading(today, today), "Today · Sunday, September 27");
assert_eq!(
heading(date(2026, 9, 28), today),
"Tomorrow · Monday, September 28"
);
assert_eq!(heading(date(2026, 9, 30), today), "Wednesday, September 30");
}
#[test]
fn a_day_lists_whole_days_first_then_by_the_hour() {
let at = |day: i8, hour: i8| {
date(2026, 9, day)
.at(hour, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp()
};
let events = [
Event::timed("late", "Late", at(22, 15), at(22, 16), 0),
Event::timed("night", "Night", at(21, 22), at(22, 2), 0),
Event::all_day("fair", "Fair", date(2026, 9, 21), date(2026, 9, 23), 0),
Event::timed("early", "Early", at(22, 9), at(22, 10), 0),
];
let keys: Vec<&str> = on_day(&events, date(2026, 9, 22), &TimeZone::UTC)
.iter()
.map(|event| event.key.as_ref())
.collect();
assert_eq!(keys, ["fair", "night", "early", "late"]);
}
}
+6
View File
@@ -1,3 +1,4 @@
mod agenda;
mod allday;
mod card;
mod chip;
@@ -8,12 +9,15 @@ mod head;
mod hours;
mod month;
mod now;
mod people;
mod popover;
mod weeks;
mod year;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use agenda::AgendaView;
pub use allday::AllDayRow;
pub use card::EventCard;
pub use chip::EventChip;
@@ -22,4 +26,6 @@ pub use event::{Event, When};
pub use grid::{TimeGrid, TimezoneOverlay};
pub use month::CalendarMonthView;
pub use now::CurrentTimeIndicator;
pub use people::{Answer, Attendee, AttendeeList};
pub use popover::EventPopover;
pub use year::YearView;
+218
View File
@@ -0,0 +1,218 @@
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
data_display::Avatar,
lists::ListItem,
primitives::{Icon, IconName},
theme::{ActiveTheme, AvatarSize, IconSize, TextSize},
};
/// What someone said to an invitation.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Answer {
Going,
Maybe,
Declined,
Waiting,
}
impl Answer {
fn label(self) -> &'static str {
match self {
Self::Going => "Going",
Self::Maybe => "Maybe",
Self::Declined => "Declined",
Self::Waiting => "Waiting",
}
}
fn icon(self) -> IconName {
match self {
Self::Going => IconName::CircleCheck,
Self::Maybe => IconName::CircleHelp,
Self::Declined => IconName::CircleX,
Self::Waiting => IconName::Clock,
}
}
}
/// Someone asked to an event: their name, their address, their answer, and whether they asked the rest.
#[derive(Clone, Debug, PartialEq)]
pub struct Attendee {
pub name: SharedString,
pub email: SharedString,
pub answer: Answer,
pub organizer: bool,
}
impl Attendee {
pub fn new(
name: impl Into<SharedString>,
email: impl Into<SharedString>,
answer: Answer,
) -> Self {
Self {
name: name.into(),
email: email.into(),
answer,
organizer: false,
}
}
/// Marks them as the one who asked.
pub fn organizer(mut self) -> Self {
self.organizer = true;
self
}
}
/// "5 guests · 2 going · 1 maybe": each answer given, in order, with none left out but the zeros.
pub(crate) fn tally(attendees: &[Attendee]) -> String {
let guests = match attendees.len() {
1 => "1 guest".to_string(),
count => format!("{count} guests"),
};
[
Answer::Going,
Answer::Maybe,
Answer::Declined,
Answer::Waiting,
]
.into_iter()
.filter_map(|answer| {
let count = attendees
.iter()
.filter(|each| each.answer == answer)
.count();
(count > 0).then(|| format!("{count} {}", answer.label().to_lowercase()))
})
.fold(guests, |line, part| format!("{line} · {part}"))
}
/// Who is asked to an event: a line that counts the answers, then each person with their avatar, name, address and answer, the one who asked first.
#[derive(IntoElement)]
pub struct AttendeeList {
id: ElementId,
attendees: Vec<Attendee>,
}
impl AttendeeList {
pub fn new(id: impl Into<ElementId>, attendees: impl IntoIterator<Item = Attendee>) -> Self {
let attendees: Vec<Attendee> = attendees.into_iter().collect();
for (ix, attendee) in attendees.iter().enumerate() {
let twin = attendees[..ix]
.iter()
.any(|other| other.email == attendee.email);
assert!(!twin, "attendee {} twice", attendee.email);
}
let organizers = attendees
.iter()
.filter(|attendee| attendee.organizer)
.count();
assert!(
organizers <= 1,
"an event has one organizer, not {organizers}"
);
Self {
id: id.into(),
attendees,
}
}
}
impl RenderOnce for AttendeeList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let mut shown = self.attendees;
shown.sort_by_key(|attendee| !attendee.organizer);
let theme = cx.theme();
let colors = &theme.colors;
let rows: Vec<_> = shown
.iter()
.map(|attendee| {
let tone = match attendee.answer {
Answer::Going => colors.success,
Answer::Maybe => colors.warning,
Answer::Declined | Answer::Waiting => colors.fg_subtle,
};
let about = match attendee.organizer {
true => format!("Organizer · {}", attendee.email),
false => attendee.email.to_string(),
};
let email = attendee.email.clone();
let row = ListItem::new(
(self.id.clone(), format!("guest-{}", attendee.email)),
attendee.name.clone(),
)
.leading(
Avatar::new(
(self.id.clone(), format!("avatar-{}", attendee.email)),
attendee.name.clone(),
)
.size(AvatarSize::Sm),
)
.description(about)
.trailing(
div()
.flex()
.items_center()
.gap_1()
.text_color(tone)
.child(
Icon::new(attendee.answer.icon())
.size(IconSize::Sm)
.color(tone),
)
.child(attendee.answer.label()),
);
div()
.debug_selector(move || format!("guest {email}"))
.child(row)
})
.collect();
div()
.w_full()
.flex()
.flex_col()
.gap_1()
.child(
div()
.px_3()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(tally(&shown)),
)
.children(rows)
}
}
#[cfg(test)]
mod tests {
use super::{Answer, Attendee, AttendeeList, tally};
#[test]
fn a_tally_counts_each_answer_given() {
let guests = [
Attendee::new("Ana", "ana@atrium.studio", Answer::Going).organizer(),
Attendee::new("Ben", "ben@atrium.studio", Answer::Going),
Attendee::new("Dev", "dev@atrium.studio", Answer::Waiting),
Attendee::new("Eli", "eli@atrium.studio", Answer::Maybe),
];
assert_eq!(tally(&guests), "4 guests · 2 going · 1 maybe · 1 waiting");
assert_eq!(tally(&guests[..1]), "1 guest · 1 going");
}
#[test]
#[should_panic(expected = "attendee ana@atrium.studio twice")]
fn an_address_is_asked_once() {
let _ = AttendeeList::new(
"guests",
[
Attendee::new("Ana", "ana@atrium.studio", Answer::Going),
Attendee::new("Ana Lima", "ana@atrium.studio", Answer::Maybe),
],
);
}
}
+185
View File
@@ -0,0 +1,185 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use jiff::tz::TimeZone;
use super::{
chip::{EventChip, tint},
event::{Event, When, hours},
people::{Attendee, AttendeeList},
};
use crate::{
buttons::{Button, ButtonVariant},
data_display::color_mark,
forms::OnValue,
overlays::Popover,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::format,
};
/// When an event happens, in full: the day's name and its hours, or its span of whole days.
pub(crate) fn when(event: &Event, zone: &TimeZone) -> String {
let (first, _) = event.days(zone);
match event.when {
When::AllDay { .. } if event.spans(zone) => hours(event, zone),
_ => format!("{} · {}", first.strftime("%A, %B %-d"), hours(event, zone)),
}
}
/// An event's chip that opens its details: its title in its hue, when and where, who comes, and what can be done with it. A press outside or Escape closes them.
#[derive(IntoElement)]
pub struct EventPopover {
id: ElementId,
event: Event,
zone: Option<TimeZone>,
attendees: Vec<Attendee>,
on_edit: Option<OnValue>,
on_delete: Option<OnValue>,
}
impl EventPopover {
pub fn new(id: impl Into<ElementId>, event: Event) -> Self {
Self {
id: id.into(),
event,
zone: None,
attendees: Vec::new(),
on_edit: None,
on_delete: None,
}
}
/// The zone its hours read in; the system's otherwise.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
/// Who is asked, shown under where.
pub fn attendees(mut self, attendees: impl IntoIterator<Item = Attendee>) -> Self {
self.attendees = attendees.into_iter().collect();
self
}
/// Offers Edit; gets the event's key.
pub fn on_edit(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_edit = Some(Rc::new(handler));
self
}
/// Offers Delete; gets the event's key.
pub fn on_delete(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_delete = Some(Rc::new(handler));
self
}
}
/// A line of the details: an icon, then its words, wrapping.
fn line(icon: IconName, words: String, cx: &App) -> AnyElement {
let theme = cx.theme();
div()
.flex()
.items_start()
.gap_2()
.child(
Icon::new(icon)
.size(IconSize::Sm)
.color(theme.colors.fg_subtle),
)
.child(div().flex_1().min_w_0().child(words))
.into_any_element()
}
impl RenderOnce for EventPopover {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let zone = self
.zone
.unwrap_or_else(|| format::system_zone("event popover"));
let (event, chip_zone) = (self.event.clone(), zone.clone());
let (id, chip_id) = (self.id.clone(), (self.id.clone(), "chip"));
let details = move |_: &mut Window, cx: &mut App| {
let theme = cx.theme();
let key = self.event.key.clone();
let action = |label: &'static str, variant, handler: Option<OnValue>| {
handler.map(|handler| {
let key = key.clone();
Button::new((self.id.clone(), label), label)
.variant(variant)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("event popover: {label} {key}");
handler(&key, window, cx)
})
})
};
let delete = action("Delete", ButtonVariant::Ghost, self.on_delete.clone());
let edit = action("Edit", ButtonVariant::Secondary, self.on_edit.clone());
let key = self.event.key.clone();
div()
.debug_selector(move || format!("event-popover {key}"))
.w(theme.calendar().card)
.flex()
.flex_col()
.gap_3()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg)
.child(
div()
.flex()
.items_start()
.gap_2()
.child(color_mark(tint(&self.event, cx), cx))
.child(
div()
.flex_1()
.min_w_0()
.text_size(theme.text_size(TextSize::Base))
.font_weight(FontWeight::SEMIBOLD)
.child(self.event.title.clone()),
),
)
.child(line(IconName::Clock, when(&self.event, &zone), cx))
.children(
self.event
.place
.clone()
.map(|place| line(IconName::MapPin, place.to_string(), cx)),
)
.when(!self.attendees.is_empty(), |details| {
details.child(AttendeeList::new(
(self.id.clone(), "guests"),
self.attendees.clone(),
))
})
.when(delete.is_some() || edit.is_some(), |details| {
details.child(
div()
.flex()
.justify_end()
.gap_2()
.children(delete)
.children(edit),
)
})
};
Popover::with_opener(
id,
move |toggle| {
EventChip::new(chip_id, event)
.zone(chip_zone)
.on_click(move |window, cx| toggle(window, cx))
},
details,
)
}
}
+1
View File
@@ -7,6 +7,7 @@ use jiff::{Timestamp, civil::date, tz::TimeZone};
use super::{CalendarMonthView, Event, YearView};
mod people;
mod times;
use crate::{primitives::FocusNext, theme::Theme};
+108
View File
@@ -0,0 +1,108 @@
use gpui::{Entity, IntoElement, TestAppContext};
use jiff::{civil::date, tz::TimeZone};
use super::{Planning, at, heard, note, planning, press, tab_to};
use crate::calendar::{AgendaView, Answer, Attendee, AttendeeList, Event, EventPopover};
fn guests(_: &Planning, _: Entity<Planning>) -> gpui::AnyElement {
AttendeeList::new(
"guests",
[
Attendee::new("Ben Ito", "ben@atrium.studio", Answer::Going),
Attendee::new("Dev Rao", "dev@atrium.studio", Answer::Maybe),
Attendee::new("Ana Lima", "ana@atrium.studio", Answer::Going).organizer(),
],
)
.into_any_element()
}
#[gpui::test]
fn the_one_who_asked_comes_first(cx: &mut TestAppContext) {
let (_, cx) = planning(guests, cx);
let ana = cx
.debug_bounds("guest ana@atrium.studio")
.expect("Ana draws");
let ben = cx
.debug_bounds("guest ben@atrium.studio")
.expect("Ben draws");
assert!(ana.top() < ben.top(), "{ana:?} {ben:?}");
}
fn agenda(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
AgendaView::new(
"agenda",
date(2026, 9, 21),
7,
[
Event::timed("crit", "Crit", at(24, 14), at(24, 15), 3),
Event::timed("review", "Review", at(22, 11), at(22, 12), 0),
Event::timed("standup", "Stand-up", at(22, 9), at(22, 10), 1),
],
)
.zone(TimeZone::UTC)
.today(date(2026, 9, 22))
.on_event(move |key, _, cx| note(&owner, format!("open {key}"), cx))
.into_any_element()
}
#[gpui::test]
fn enter_opens_the_event_under_the_cursor(cx: &mut TestAppContext) {
let (host, cx) = planning(agenda, cx);
tab_to(1, cx);
press("down", cx);
press("enter", cx);
tab_to(2, cx);
press("enter", cx);
assert_eq!(
heard(&host, cx),
["open review", "open crit"],
"today's stand-up then review; Thursday's crit in the next list"
);
}
fn empty(_: &Planning, _: Entity<Planning>) -> gpui::AnyElement {
AgendaView::new("agenda", date(2026, 9, 21), 7, [])
.zone(TimeZone::UTC)
.today(date(2026, 9, 22))
.into_any_element()
}
#[gpui::test]
fn an_empty_stretch_says_nothing_is_planned(cx: &mut TestAppContext) {
let (_, cx) = planning(empty, cx);
assert!(cx.debug_bounds("agenda-none").is_some());
}
fn popover(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
let (edits, deletes) = (owner.clone(), owner);
EventPopover::new(
"popover",
Event::timed("review", "Review", at(22, 11), at(22, 12), 0).place("Model room"),
)
.zone(TimeZone::UTC)
.attendees([Attendee::new("Ana Lima", "ana@atrium.studio", Answer::Going).organizer()])
.on_edit(move |key, _, cx| note(&edits, format!("edit {key}"), cx))
.on_delete(move |key, _, cx| note(&deletes, format!("delete {key}"), cx))
.into_any_element()
}
#[gpui::test]
fn a_chip_opens_its_details_and_edit_names_it(cx: &mut TestAppContext) {
let (host, cx) = planning(popover, cx);
tab_to(1, cx);
press("space", cx);
assert!(
cx.debug_bounds("event-popover review").is_some(),
"the details open"
);
cx.update(|window, _| {
window.focus_next();
window.focus_next();
});
press("space", cx);
assert_eq!(
heard(&host, cx),
["edit review"],
"Tab reaches Delete, then Edit"
);
}