Files
Ely-GPUI-Components/src/calendar/people.rs
T
ZacharyZhang-NY 81e73b79c1 Capture · Roots: roots drop w_full; hosts give the width
145 component roots drop w_full, the early returns included. Three
field rows keep it, Input, InputGroup and TagInput: stretched without
a width, taffy 0.9 laid the field's flex_1 share at no width in the
template picker at 280px, and the placeholder drew nothing. Container
keeps it, since mx_auto needs a width to fill.

Hosts that relied on a root's width now give it: MessageList boxes
each row at the list's width, the splash gives its bar the column's,
and test stages that mount a component as the window's root sit in a
size_full box. Tests hold a narrow row across the list and the splash
bar across its column; each goes red without its fix.

The gallery gains --narrow <px>, which lays each page in a padded
card at that width and shoots it without its script. The page
header wraps: the title takes the room and the theme switcher drops
below, where a long summary used to push it past the edge. A file
panel step waits for the panel to become key instead of a fixed
pause, which lost a race in a capture.
2026-09-28 13:07:22 -04:00

218 lines
6.6 KiB
Rust

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()
.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),
],
);
}
}