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:
1 parent
57dd55320e
commit
48ff348357
17 files changed
+940
-37
No files matched your search
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -196,6 +196,18 @@ impl RenderOnce for CountBadge {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dot in `color` centered in an icon's box, as a label or an event wears its hue in a row.
|
||||
pub(crate) fn color_mark(color: Hsla, cx: &App) -> gpui::Div {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex_none()
|
||||
.size(theme.icon_size(IconSize::Sm))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(div().size(theme.status_dot()).rounded_full().bg(color))
|
||||
}
|
||||
|
||||
/// A small dot that says something is new or live. Set `over` an icon, it sits on the top-right corner.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DotBadge {
|
||||
|
||||
@@ -19,6 +19,7 @@ mod watermark;
|
||||
|
||||
pub use avatar::{Avatar, AvatarGroup, Presence, UserChip};
|
||||
pub(crate) use avatar::{more, tucked};
|
||||
pub(crate) use badge::color_mark;
|
||||
pub use badge::{Badge, CountBadge, DotBadge, Tag, Tone};
|
||||
pub use before_after::BeforeAfter;
|
||||
pub use carousel::Carousel;
|
||||
|
||||
+3
-10
@@ -7,13 +7,14 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
data_display::color_mark,
|
||||
forms::{
|
||||
Choice, Down, Enter, OnValue, OnValues, Pick, SearchInput, TextInput, Up, marked_row,
|
||||
reveal, revealer,
|
||||
},
|
||||
layout::on_axis,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
/// A label: its key, its name, and its hue among the theme's chart colors.
|
||||
@@ -36,15 +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.hue(hue, format_args!("label {name}"));
|
||||
div()
|
||||
.flex_none()
|
||||
.size(theme.icon_size(IconSize::Sm))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(div().size(theme.status_dot()).rounded_full().bg(tint))
|
||||
color_mark(cx.theme().colors.hue(hue, format_args!("label {name}")), cx)
|
||||
}
|
||||
|
||||
/// The search field, the row under the keyboard, the query it was set for, and the list's scroll.
|
||||
|
||||
+65
-21
@@ -1,3 +1,5 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Pixels, RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*,
|
||||
@@ -5,12 +7,27 @@ use gpui::{
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{float_height, surface},
|
||||
forms::{Run, float_height, surface},
|
||||
primitives::{FocusScope, IconName, Takeover, give_back, take_focus},
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
type Content = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
|
||||
type Own = Box<dyn FnOnce(Run) -> AnyElement>;
|
||||
|
||||
/// A popover's own button: its label and look.
|
||||
struct Face {
|
||||
label: SharedString,
|
||||
icon: Option<IconName>,
|
||||
variant: ButtonVariant,
|
||||
size: ControlSize,
|
||||
}
|
||||
|
||||
/// What opens the panel: its own button, or an element of the owner's, built with the run that opens and closes it.
|
||||
enum Opener {
|
||||
Button(Face),
|
||||
Own(Own),
|
||||
}
|
||||
|
||||
/// Whether the panel is open, where the trigger sits, and the focus to hand back.
|
||||
#[derive(Default)]
|
||||
@@ -37,10 +54,7 @@ fn close(state: &Entity<Pop>, window: &mut Window, cx: &mut App) {
|
||||
#[derive(IntoElement)]
|
||||
pub struct Popover {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
icon: Option<IconName>,
|
||||
variant: ButtonVariant,
|
||||
size: ControlSize,
|
||||
opener: Opener,
|
||||
content: Content,
|
||||
}
|
||||
|
||||
@@ -53,27 +67,50 @@ impl Popover {
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
variant: ButtonVariant::Secondary,
|
||||
size: ControlSize::default(),
|
||||
opener: Opener::Button(Face {
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
variant: ButtonVariant::Secondary,
|
||||
size: ControlSize::default(),
|
||||
}),
|
||||
content: Box::new(move |window, cx| content(window, cx).into_any_element()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel that opens from an element of the owner's: `opener` builds it with the run that opens and closes the panel.
|
||||
pub fn with_opener<T: IntoElement, E: IntoElement>(
|
||||
id: impl Into<ElementId>,
|
||||
opener: impl FnOnce(Run) -> T + 'static,
|
||||
content: impl FnOnce(&mut Window, &mut App) -> E + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
opener: Opener::Own(Box::new(move |toggle| opener(toggle).into_any_element())),
|
||||
content: Box::new(move |window, cx| content(window, cx).into_any_element()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The popover's own button, which a popover with an opener of the owner's does not have.
|
||||
fn face(&mut self, what: &str) -> &mut Face {
|
||||
match &mut self.opener {
|
||||
Opener::Button(face) => face,
|
||||
Opener::Own(_) => panic!("popover {:?}: {what} is for its own button", self.id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self.face("an icon").icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
/// `Link` sets the trigger inline in running text.
|
||||
pub fn variant(mut self, variant: ButtonVariant) -> Self {
|
||||
self.variant = variant;
|
||||
self.face("a variant").variant = variant;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: ControlSize) -> Self {
|
||||
self.size = size;
|
||||
self.face("a size").size = size;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -82,22 +119,29 @@ impl RenderOnce for Popover {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state((self.id.clone(), "popover"), cx, |_, _| Pop::default());
|
||||
let open = state.read(cx).open;
|
||||
let toggle = state.clone();
|
||||
let trigger = Button::new((self.id.clone(), "trigger"), self.label)
|
||||
.variant(self.variant)
|
||||
.size(self.size)
|
||||
.when_some(self.icon, |button, icon| button.icon(icon))
|
||||
.on_click(move |_, window, cx| {
|
||||
let toggle: Run = {
|
||||
let state = state.clone();
|
||||
Rc::new(move |window, cx| {
|
||||
if open {
|
||||
close(&toggle, window, cx);
|
||||
close(&state, window, cx);
|
||||
} else {
|
||||
log::info!("popover: open");
|
||||
toggle.update(cx, |pop, cx| {
|
||||
state.update(cx, |pop, cx| {
|
||||
pop.open = true;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
let trigger = match self.opener {
|
||||
Opener::Button(face) => Button::new((self.id.clone(), "trigger"), face.label)
|
||||
.variant(face.variant)
|
||||
.size(face.size)
|
||||
.when_some(face.icon, |button, icon| button.icon(icon))
|
||||
.on_click(move |_, window, cx| toggle(window, cx))
|
||||
.into_any_element(),
|
||||
Opener::Own(build) => build(toggle),
|
||||
};
|
||||
let measure = state.clone();
|
||||
let host = div()
|
||||
.id(self.id.clone())
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog
|
||||
use crate::{
|
||||
buttons::Button,
|
||||
forms::TextInput,
|
||||
primitives::{FocusNext, FocusScope, Severity},
|
||||
primitives::{FocusNext, FocusScope, IconName, Severity},
|
||||
theme::{ActiveTheme, ControlSize, Theme},
|
||||
};
|
||||
|
||||
@@ -426,3 +426,9 @@ fn a_tall_dialog_body_keeps_its_height_and_scrolls(cx: &mut TestAppContext) {
|
||||
"a scroll moves the body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "an icon is for its own button")]
|
||||
fn a_popover_opened_by_its_owner_takes_no_icon() {
|
||||
let _ = Popover::with_opener("own", |_| div(), |_, _| div()).icon(IconName::Clock);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ pub struct CalendarSizes {
|
||||
pub hour: Rems,
|
||||
/// A time grid's column of hours, wide.
|
||||
pub gutter: Rems,
|
||||
/// An event's details in a panel, wide.
|
||||
pub card: Rems,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@@ -25,6 +27,7 @@ impl Theme {
|
||||
mini_day: px_to_rems(28.0),
|
||||
hour: px_to_rems(48.0),
|
||||
gutter: px_to_rems(56.0),
|
||||
card: px_to_rems(320.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user