diff --git a/AGENTS.md b/AGENTS.md index c544d24..9e14a22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens. - gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. An edit in place that hands focus back keeps its text on Enter's release (`forms::InlineEdit`). The press itself bubbles, so a container that takes Space leaves a focused button its own: `overlays::media_button` stops it, and a button in a `SelectableList` row stops Space and Enter at its box (`mail::MailItem`'s star, `messaging::PinnedMessages`' Unpin). - A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere. A thread's message keeps one handle folded and open, a Tab stop only while folded (`mail::MailThreadView`). +- A panel that opens from something other than a button goes through `overlays::Popover::with_opener`, which hands the opener its toggle; focus, placement and every way out stay in the popover (`calendar::EventPopover`'s chip). - A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. Its close runs on every way out, so a dialog with two answers marks the chosen one before it closes (`messaging::IncomingCallDialog`). AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay. - Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike. - A component's letter keys step aside while Command or Control is held, so the app's shortcuts reach the app (`media::ImageAnnotator`, `VideoPlayer`, `VideoThumbnailStrip`). @@ -120,7 +121,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Paths with `/` between folders become a tree through `lists::Folder::of`, for `FileTree` and `files::ArchiveViewer` alike. - A folder's path over its entries is `lists::path_bar`, and its first order is `lists::listed`, folders first by name: DirectoryListing and FileExplorer share both, so every view shows one path and one order. - A file's icon comes from `primitives::file_icon`, one map from name to icon for every chapter; `files::FileIcon` draws it. What fits across a width comes from `layout::columns_for`, and `layout::fit` adds the inset that centers it. -- Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures. A tag is never wider than its container; a long label ends in an ellipsis beside its remove mark. +- Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. A hue worn in a row, a label's or an event's, is `data_display::color_mark`, and `Palette::hue` gives the chart color by index. Avatars take square pictures. A tag is never wider than its container; a long label ends in an ellipsis beside its remove mark. - A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model). - `img()` keeps loading state, and moves an animated picture's frames, only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. - gpui reads a `&str` picture source as a web address. A file goes through `Path` (`documents::source` in the crate, `Path::new` in the gallery). diff --git a/TASKS.md b/TASKS.md index fc02275..fb1d72e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -108,7 +108,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T29c Mail · Writing - [x] T29d Mail · Sorting - [x] T30a Calendar · Months -- [ ] T30b Calendar · Time grid +- [x] T30b Calendar · Time grid - [ ] T30c Calendar · Agenda and people - [ ] T30d Calendar · Editing - [ ] T31 Project — `tasks/ch31-43.md` @@ -356,3 +356,4 @@ Per-component lines live in `tasks/`. Tags there: | T29d | 3 | PASS | | | T30a | 1 | PASS | | | T30b | 1 | FAIL | the week and day views froze the line at now and today: the moment read once went down as given and turned the ticker off | +| T30b | 2 | PASS | | diff --git a/examples/gallery/pages/calendar/mod.rs b/examples/gallery/pages/calendar/mod.rs index b0efc6b..cb5f5b5 100644 --- a/examples/gallery/pages/calendar/mod.rs +++ b/examples/gallery/pages/calendar/mod.rs @@ -8,6 +8,7 @@ use gpui::{ use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; mod months; +mod people; mod times; use super::Page; @@ -45,6 +46,11 @@ const SCRIPT: &[Step] = &[ Step::UpAt("calendar-week", 560.0, 380.0), Step::Wait(200), Step::Shot("week-made"), + Step::DownAt("calendar-popover", 60.0, 10.0), + Step::UpAt("calendar-popover", 60.0, 10.0), + Step::Wait(300), + Step::Shot("popover-open"), + Step::Key("escape"), ]; /// What a demo says it did, under it. @@ -101,6 +107,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(months::mini(window, cx)) .child(times::week(window, cx)) .child(times::day(window, cx)) + .child(people::agenda(window, cx)) + .child(people::details(window, cx)) .child(months::year(window, cx)) .child(months::marks(cx)) .into_any_element() diff --git a/examples/gallery/pages/calendar/people.rs b/examples/gallery/pages/calendar/people.rs new file mode 100644 index 0000000..08661b9 --- /dev/null +++ b/examples/gallery/pages/calendar/people.rs @@ -0,0 +1,91 @@ +use ely_gpui_component::calendar::{AgendaView, Answer, Attendee, AttendeeList, EventPopover}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use super::{here, plan, quiet}; +use crate::{ + probe::probe, + ui::{change, keep, section, set}, +}; + +fn guests() -> [Attendee; 5] { + [ + Attendee::new("Chloé Martin", "chloe@atrium.studio", Answer::Going).organizer(), + Attendee::new("Ana Lima", "ana@atrium.studio", Answer::Going), + Attendee::new("Ben Ito", "ben@atrium.studio", Answer::Maybe), + Attendee::new("Dev Rao", "dev@atrium.studio", Answer::Waiting), + Attendee::new("Eli Park", "eli@atrium.studio", Answer::Declined), + ] +} + +pub fn agenda(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (zone, today) = here(); + let listed = plan(window, cx).read(cx).clone(); + let said = keep("calendar-agenda-said", || None::, window, cx); + let shown = said.read(cx).clone(); + section( + "AgendaView", + "The days ahead with their events under each day's name, whole days first, then by the hour; days with nothing are left out. A press or an arrow selects, Enter or a double press opens.", + cx, + ) + .child(probe( + "calendar-agenda", + div() + .w(px(420.)) + .flex() + .flex_col() + .gap_3() + .child( + AgendaView::new("calendar-agenda", today, 7, listed) + .zone(zone) + .on_event(move |key, _, cx| set(&said, Some(format!("Opened {key}.").into()), cx)), + ) + .children(shown.map(|shown| quiet(shown, cx))), + )) +} + +pub fn details(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (zone, _) = here(); + let events = plan(window, cx); + let review = events + .read(cx) + .iter() + .find(|event| event.key.as_ref() == "review") + .cloned() + .expect("the demo holds the review"); + let said = keep("calendar-popover-said", || None::, window, cx); + let shown = said.read(cx).clone(); + let (edited, dropped) = (said.clone(), said); + section( + "EventPopover / AttendeeList", + "An event's chip opens its details: when and where, who is asked and what each said, and what can be done with it. The list of guests stands on its own too.", + cx, + ) + .child(probe( + "calendar-popover", + div() + .flex() + .items_start() + .gap_8() + .child( + div() + .w(px(200.)) + .flex() + .flex_col() + .gap_3() + .child( + EventPopover::new("calendar-popover", review) + .zone(zone) + .attendees(guests()) + .on_edit(move |key, _, cx| { + set(&edited, Some(format!("Edit {key}.").into()), cx) + }) + .on_delete(move |key, _, cx| { + change(&events, cx, |events| events.retain(|event| event.key != *key)); + set(&dropped, Some(format!("Deleted {key}.").into()), cx) + }), + ) + .children(shown.map(|shown| quiet(shown, cx))), + ) + .child(div().w(px(360.)).child(AttendeeList::new("calendar-guests", guests()))), + )) +} diff --git a/src/calendar/agenda.rs b/src/calendar/agenda.rs new file mode 100644 index 0000000..9f37370 --- /dev/null +++ b/src/calendar/agenda.rs @@ -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, + zone: Option, + today: Option, + on_event: Option, +} + +impl AgendaView { + pub fn new( + id: impl Into, + from: Date, + days: usize, + events: impl IntoIterator, + ) -> 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::); + let theme = cx.theme(); + let mut owners: HashMap = 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"]); + } +} diff --git a/src/calendar/mod.rs b/src/calendar/mod.rs index e67f607..01a6853 100644 --- a/src/calendar/mod.rs +++ b/src/calendar/mod.rs @@ -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; diff --git a/src/calendar/people.rs b/src/calendar/people.rs new file mode 100644 index 0000000..e0e32cd --- /dev/null +++ b/src/calendar/people.rs @@ -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, + email: impl Into, + 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, +} + +impl AttendeeList { + pub fn new(id: impl Into, attendees: impl IntoIterator) -> Self { + let attendees: Vec = 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), + ], + ); + } +} diff --git a/src/calendar/popover.rs b/src/calendar/popover.rs new file mode 100644 index 0000000..698aecf --- /dev/null +++ b/src/calendar/popover.rs @@ -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, + attendees: Vec, + on_edit: Option, + on_delete: Option, +} + +impl EventPopover { + pub fn new(id: impl Into, 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) -> 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| { + 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, + ) + } +} diff --git a/src/calendar/tests/mod.rs b/src/calendar/tests/mod.rs index 5634cc0..0405e88 100644 --- a/src/calendar/tests/mod.rs +++ b/src/calendar/tests/mod.rs @@ -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}; diff --git a/src/calendar/tests/people.rs b/src/calendar/tests/people.rs new file mode 100644 index 0000000..19620ab --- /dev/null +++ b/src/calendar/tests/people.rs @@ -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) -> 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) -> 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) -> 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) -> 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" + ); +} diff --git a/src/data_display/badge.rs b/src/data_display/badge.rs index 1ed27cb..3d4fcec 100644 --- a/src/data_display/badge.rs +++ b/src/data_display/badge.rs @@ -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 { diff --git a/src/data_display/mod.rs b/src/data_display/mod.rs index 4268c87..04c2115 100644 --- a/src/data_display/mod.rs +++ b/src/data_display/mod.rs @@ -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; diff --git a/src/mail/labels.rs b/src/mail/labels.rs index a52ca8b..f982645 100644 --- a/src/mail/labels.rs +++ b/src/mail/labels.rs @@ -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. diff --git a/src/overlays/popover.rs b/src/overlays/popover.rs index 8f66e08..2b78e98 100644 --- a/src/overlays/popover.rs +++ b/src/overlays/popover.rs @@ -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 AnyElement>; +type Own = Box AnyElement>; + +/// A popover's own button: its label and look. +struct Face { + label: SharedString, + icon: Option, + 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, window: &mut Window, cx: &mut App) { #[derive(IntoElement)] pub struct Popover { id: ElementId, - label: SharedString, - icon: Option, - 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( + id: impl Into, + 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()) diff --git a/src/overlays/tests/mod.rs b/src/overlays/tests/mod.rs index ce9b0a1..5e33e14 100644 --- a/src/overlays/tests/mod.rs +++ b/src/overlays/tests/mod.rs @@ -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); +} diff --git a/src/theme/calendar.rs b/src/theme/calendar.rs index b18f7fa..8b7fd5c 100644 --- a/src/theme/calendar.rs +++ b/src/theme/calendar.rs @@ -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), } } } diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index e6ac20e..a75ff18 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -403,9 +403,9 @@ ## T30c Calendar · Agenda and people -- [ ] AgendaView -- [ ] EventPopover -- [ ] AttendeeList +- [x] AgendaView +- [x] EventPopover +- [x] AttendeeList ## T30d Calendar · Editing