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
@@ -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).
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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()
|
||||
|
||||
@@ -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::<SharedString>, 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::<SharedString>, 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()))),
|
||||
))
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -403,9 +403,9 @@
|
||||
|
||||
## T30c Calendar · Agenda and people
|
||||
|
||||
- [ ] AgendaView
|
||||
- [ ] EventPopover
|
||||
- [ ] AttendeeList
|
||||
- [x] AgendaView
|
||||
- [x] EventPopover
|
||||
- [x] AttendeeList
|
||||
|
||||
## T30d Calendar · Editing
|
||||
|
||||
|
||||
Reference in new issue
Block a user