Calendar · Editing: an event's editor, its repeats, reminders and open times
EventEditor puts an event's parts in a form: its title, whole days or hours, where, its color, how it repeats, its reminders and notes. Save waits for a title and an end after the start and hands on an EventDraft; Cancel and Delete show when the owner handles them. A new draft from the owner starts the editor over. RecurrenceEditor sets a rule after RFC 5545: daily, weekly on its days, monthly or yearly, every so many, until a day or a count. A line reads the rule back with the next days it falls on. ReminderPicker keeps reminders from a fixed list, soonest first, and Add brings the nearest one not yet set. AvailabilityPicker counts the open slots of each day and lists the chosen day's, around what is busy, inside working hours and after now. FormField filled its column with a percent width, which taffy 0.9 leaves at content width when a block measures the column first and the width comes out the same: at 280px the editor's fields shrank to their labels. It now fills with min_w_full, as new roots do; older roots move over in T44. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a506ddc98c
commit
07a577b574
18 files changed
+1856
-12
No files matched your search
@@ -0,0 +1,269 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div,
|
||||
};
|
||||
use jiff::{Timestamp, civil::Date, tz::TimeZone};
|
||||
|
||||
use super::hours::moment;
|
||||
use crate::{
|
||||
forms::{Choice, ChoiceChips},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::format,
|
||||
};
|
||||
|
||||
type OnTime = Rc<dyn Fn(Timestamp, &mut Window, &mut App)>;
|
||||
|
||||
/// Slots start on the half hour.
|
||||
const STEP: i64 = 30;
|
||||
|
||||
/// The starts, in minutes of `day`, of slots `length` long within `hours` that meet no busy stretch and begin after `now`.
|
||||
pub(crate) fn open_slots(
|
||||
day: Date,
|
||||
hours: (i64, i64),
|
||||
length: i64,
|
||||
busy: &[(Timestamp, Timestamp)],
|
||||
now: Timestamp,
|
||||
zone: &TimeZone,
|
||||
) -> Vec<i64> {
|
||||
let mut starts = Vec::new();
|
||||
let mut start = hours.0;
|
||||
while start + length <= hours.1 {
|
||||
let (from, to) = (moment(day, start, zone), moment(day, start + length, zone));
|
||||
let free = busy
|
||||
.iter()
|
||||
.all(|(taken, until)| *until <= from || *taken >= to);
|
||||
if free && from > now {
|
||||
starts.push(start);
|
||||
}
|
||||
start += STEP;
|
||||
}
|
||||
starts
|
||||
}
|
||||
|
||||
/// Open times to meet over some days: each day with its count of open slots, then the slots of the one chosen. Busy stretches, the working hours and the past close the rest. A pick hands on the slot's start.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AvailabilityPicker {
|
||||
id: ElementId,
|
||||
days: Vec<Date>,
|
||||
busy: Vec<(Timestamp, Timestamp)>,
|
||||
hours: (i64, i64),
|
||||
length: i64,
|
||||
zone: Option<TimeZone>,
|
||||
now: Option<Timestamp>,
|
||||
selected: Option<Timestamp>,
|
||||
on_pick: Option<OnTime>,
|
||||
}
|
||||
|
||||
impl AvailabilityPicker {
|
||||
pub fn new(id: impl Into<ElementId>, days: impl IntoIterator<Item = Date>) -> Self {
|
||||
let id = id.into();
|
||||
let days: Vec<Date> = days.into_iter().collect();
|
||||
assert!(!days.is_empty(), "availability picker {id:?} has no days");
|
||||
Self {
|
||||
id,
|
||||
days,
|
||||
busy: Vec::new(),
|
||||
hours: (9 * 60, 17 * 60),
|
||||
length: 30,
|
||||
zone: None,
|
||||
now: None,
|
||||
selected: None,
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stretches already taken, each from a moment to a later one.
|
||||
pub fn busy(mut self, busy: impl IntoIterator<Item = (Timestamp, Timestamp)>) -> Self {
|
||||
self.busy = busy.into_iter().collect();
|
||||
for (from, to) in &self.busy {
|
||||
assert!(
|
||||
to > from,
|
||||
"a busy stretch from {from} ends before it starts"
|
||||
);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The hours a slot may fall in, from the first to the last; nine to five otherwise.
|
||||
pub fn hours(mut self, first: i8, last: i8) -> Self {
|
||||
assert!(
|
||||
first < last && last <= 24,
|
||||
"working hours from {first} to {last}"
|
||||
);
|
||||
self.hours = (i64::from(first) * 60, i64::from(last) * 60);
|
||||
self
|
||||
}
|
||||
|
||||
/// How long a slot is, in minutes; half an hour otherwise.
|
||||
pub fn length(mut self, minutes: u16) -> Self {
|
||||
assert!(minutes > 0, "a slot takes some time");
|
||||
self.length = i64::from(minutes);
|
||||
self
|
||||
}
|
||||
|
||||
/// 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 moment before which nothing is open; the clock's otherwise.
|
||||
pub fn now(mut self, now: Timestamp) -> Self {
|
||||
self.now = Some(now);
|
||||
self
|
||||
}
|
||||
|
||||
/// The slot picked, by its start.
|
||||
pub fn selected(mut self, start: Timestamp) -> Self {
|
||||
self.selected = Some(start);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the start of each slot picked.
|
||||
pub fn on_pick(mut self, handler: impl Fn(Timestamp, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_pick = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AvailabilityPicker {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let zone = self
|
||||
.zone
|
||||
.unwrap_or_else(|| format::system_zone("availability picker"));
|
||||
let now = self.now.unwrap_or_else(Timestamp::now);
|
||||
let open: Vec<(Date, Vec<i64>)> = self
|
||||
.days
|
||||
.iter()
|
||||
.map(|day| {
|
||||
let slots = open_slots(*day, self.hours, self.length, &self.busy, now, &zone);
|
||||
(*day, slots)
|
||||
})
|
||||
.collect();
|
||||
let picked_day = self
|
||||
.selected
|
||||
.map(|start| start.to_zoned(zone.clone()).date())
|
||||
.filter(|day| self.days.contains(day));
|
||||
let first_open = open
|
||||
.iter()
|
||||
.find(|(_, slots)| !slots.is_empty())
|
||||
.map(|(day, _)| *day);
|
||||
let chosen = window.use_keyed_state((self.id.clone(), "day"), cx, |_, _| None::<Date>);
|
||||
let shown = chosen.read(cx).or(picked_day).or(first_open);
|
||||
let theme = cx.theme();
|
||||
let quiet = |text: &'static str| {
|
||||
div()
|
||||
.py_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(text)
|
||||
};
|
||||
let Some(shown) = shown else {
|
||||
return div()
|
||||
.debug_selector(|| "availability-none".into())
|
||||
.child(quiet("No open time in these days."));
|
||||
};
|
||||
let days = ChoiceChips::new(
|
||||
(self.id.clone(), "days"),
|
||||
open.iter().map(|(day, slots)| {
|
||||
let label = format!("{} · {}", day.strftime("%a %-d"), slots.len());
|
||||
let choice = Choice::new(day.to_string(), label);
|
||||
match slots.is_empty() {
|
||||
true => choice.disabled(),
|
||||
false => choice,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.selected([shown.to_string()])
|
||||
.on_change(move |values, _, cx| {
|
||||
let day: Date = values
|
||||
.first()
|
||||
.expect("a day is chosen")
|
||||
.parse()
|
||||
.expect("a day's value is its date");
|
||||
log::info!("availability picker: {day}");
|
||||
chosen.update(cx, |chosen, cx| {
|
||||
*chosen = Some(day);
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
let slots = open
|
||||
.iter()
|
||||
.find(|(day, _)| *day == shown)
|
||||
.map(|(_, slots)| slots.clone())
|
||||
.expect("the day shown is one of the days");
|
||||
let picked = self
|
||||
.selected
|
||||
.filter(|_| picked_day == Some(shown))
|
||||
.map(|start| {
|
||||
let zoned = start.to_zoned(zone.clone());
|
||||
(i64::from(zoned.hour()) * 60 + i64::from(zoned.minute())).to_string()
|
||||
});
|
||||
let times = (!slots.is_empty()).then(|| {
|
||||
let (on_pick, zone) = (self.on_pick.clone(), zone.clone());
|
||||
ChoiceChips::new(
|
||||
(self.id.clone(), format!("slots-{shown}")),
|
||||
slots.iter().map(|minute| {
|
||||
let label = format!("{:02}:{:02}", minute / 60, minute % 60);
|
||||
Choice::new(minute.to_string(), label)
|
||||
}),
|
||||
)
|
||||
.selected(picked)
|
||||
.on_change(move |values, window, cx| {
|
||||
let minute: i64 = values
|
||||
.first()
|
||||
.expect("a slot is chosen")
|
||||
.parse()
|
||||
.expect("a slot's value is its minute");
|
||||
let start = moment(shown, minute, &zone);
|
||||
log::info!("availability picker: picked {start}");
|
||||
if let Some(on_pick) = &on_pick {
|
||||
on_pick(start, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
div()
|
||||
.debug_selector(|| "availability".into())
|
||||
.min_w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(days)
|
||||
.children(times)
|
||||
.children(slots.is_empty().then(|| quiet("No open time this day.")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::{Timestamp, civil::date, tz::TimeZone};
|
||||
|
||||
use super::open_slots;
|
||||
|
||||
fn at(day: i8, hour: i8, minute: i8) -> Timestamp {
|
||||
date(2026, 9, day)
|
||||
.at(hour, minute, 0, 0)
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.expect("a UTC time")
|
||||
.timestamp()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_fill_the_hours_around_what_is_busy_and_after_now() {
|
||||
let busy = [(at(28, 10, 0), at(28, 11, 15))];
|
||||
let starts = open_slots(
|
||||
date(2026, 9, 28),
|
||||
(9 * 60, 12 * 60),
|
||||
30,
|
||||
&busy,
|
||||
at(28, 9, 10),
|
||||
&TimeZone::UTC,
|
||||
);
|
||||
assert_eq!(
|
||||
starts,
|
||||
[9 * 60 + 30, 11 * 60 + 30],
|
||||
"09:00 has passed, 10:00 to 11:15 is taken, and the last slot ends at noon"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Subscription, Window, div, prelude::*,
|
||||
};
|
||||
use jiff::{SignedDuration, Timestamp, civil::DateTime, tz::TimeZone};
|
||||
|
||||
use super::{
|
||||
event::{Event, When},
|
||||
recurrence::RecurrenceEditor,
|
||||
reminders::ReminderPicker,
|
||||
repeat::Recurrence,
|
||||
};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{
|
||||
ColorPalette, DatePicker, DateTimePicker, FormError, FormField, Input, InputEvent, Run,
|
||||
Switch, TextInput,
|
||||
},
|
||||
theme::ActiveTheme,
|
||||
typography::format,
|
||||
};
|
||||
|
||||
type OnDraft = Rc<dyn Fn(&EventDraft, &mut Window, &mut App)>;
|
||||
|
||||
/// The chart's hues by name, for the color an event wears.
|
||||
const HUES: [&str; 8] = [
|
||||
"Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan",
|
||||
];
|
||||
|
||||
/// An event's parts as an editor hands them back.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EventDraft {
|
||||
pub title: SharedString,
|
||||
pub when: When,
|
||||
pub place: SharedString,
|
||||
pub hue: usize,
|
||||
pub repeat: Option<Recurrence>,
|
||||
pub reminders: Vec<u32>,
|
||||
pub notes: SharedString,
|
||||
}
|
||||
|
||||
impl EventDraft {
|
||||
/// `event`'s parts, with no repeat, reminders or notes.
|
||||
pub fn of(event: &Event) -> Self {
|
||||
Self {
|
||||
title: event.title.clone(),
|
||||
when: event.when,
|
||||
place: event.place.clone().unwrap_or_default(),
|
||||
hue: event.hue,
|
||||
repeat: None,
|
||||
reminders: Vec::new(),
|
||||
notes: SharedString::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why it cannot be saved: no title, or an end before its start.
|
||||
pub(crate) fn fault(&self) -> Option<&'static str> {
|
||||
if self.title.trim().is_empty() {
|
||||
return Some("Give it a title.");
|
||||
}
|
||||
let backward = match self.when {
|
||||
When::Timed { start, end } => end <= start,
|
||||
When::AllDay { first, last } => last < first,
|
||||
};
|
||||
backward.then_some("It ends before it starts.")
|
||||
}
|
||||
}
|
||||
|
||||
/// `when` as whole days, or as hours from nine to ten on its first day.
|
||||
fn toggled(when: When, zone: &TimeZone) -> When {
|
||||
match when {
|
||||
When::Timed { start, end } => {
|
||||
let first = start.to_zoned(zone.clone()).date();
|
||||
let last = end.to_zoned(zone.clone()).date().max(first);
|
||||
When::AllDay { first, last }
|
||||
}
|
||||
When::AllDay { first, .. } => {
|
||||
let at = |hour| {
|
||||
first
|
||||
.at(hour, 0, 0, 0)
|
||||
.to_zoned(zone.clone())
|
||||
.expect("the zone holds the hour")
|
||||
.timestamp()
|
||||
};
|
||||
When::Timed {
|
||||
start: at(9),
|
||||
end: at(10),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `when` with its start moved to `start`, its length kept.
|
||||
fn moved(when: When, start: Timestamp) -> When {
|
||||
let When::Timed { start: was, end } = when else {
|
||||
panic!("only timed events move by the hour");
|
||||
};
|
||||
let length: SignedDuration = end.duration_since(was);
|
||||
When::Timed {
|
||||
start,
|
||||
end: start + length,
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor's working copy: what the owner gave, the parts changed so far, and the fields that hold its words.
|
||||
struct Editing {
|
||||
seed: EventDraft,
|
||||
draft: EventDraft,
|
||||
title: Entity<TextInput>,
|
||||
place: Entity<TextInput>,
|
||||
notes: Entity<TextInput>,
|
||||
_title: Subscription,
|
||||
}
|
||||
|
||||
impl Editing {
|
||||
fn fill(&mut self, seed: EventDraft, cx: &mut Context<Self>) {
|
||||
for (input, text) in [
|
||||
(&self.title, &seed.title),
|
||||
(&self.place, &seed.place),
|
||||
(&self.notes, &seed.notes),
|
||||
] {
|
||||
input.update(cx, |input, cx| input.set_text(text.to_string(), cx));
|
||||
}
|
||||
self.draft = seed.clone();
|
||||
self.seed = seed;
|
||||
}
|
||||
|
||||
/// The draft as it stands, its words read from the fields.
|
||||
fn current(&self, cx: &App) -> EventDraft {
|
||||
let words =
|
||||
|input: &Entity<TextInput>| SharedString::from(input.read(cx).text().to_string());
|
||||
EventDraft {
|
||||
title: words(&self.title).trim().to_string().into(),
|
||||
place: words(&self.place).trim().to_string().into(),
|
||||
notes: words(&self.notes),
|
||||
..self.draft.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An event's parts in a form: its title, whole days or hours from and until, where, its color, how it repeats, its reminders and notes. Save waits for a title and an end after the start; Delete shows with its handler. A new draft from the owner starts it over.
|
||||
#[derive(IntoElement)]
|
||||
pub struct EventEditor {
|
||||
id: ElementId,
|
||||
draft: EventDraft,
|
||||
zone: Option<TimeZone>,
|
||||
on_save: Option<OnDraft>,
|
||||
on_cancel: Option<Run>,
|
||||
on_delete: Option<Run>,
|
||||
}
|
||||
|
||||
impl EventEditor {
|
||||
pub fn new(id: impl Into<ElementId>, draft: EventDraft) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
draft,
|
||||
zone: None,
|
||||
on_save: None,
|
||||
on_cancel: 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
|
||||
}
|
||||
|
||||
/// Gets the draft to keep.
|
||||
pub fn on_save(
|
||||
mut self,
|
||||
handler: impl Fn(&EventDraft, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_save = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_cancel = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Offers Delete, for an event that already exists.
|
||||
pub fn on_delete(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_delete = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EventEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let zone = self
|
||||
.zone
|
||||
.unwrap_or_else(|| format::system_zone("event editor"));
|
||||
let seed = self.draft.clone();
|
||||
let editing = window.use_keyed_state(
|
||||
(self.id.clone(), "editing"),
|
||||
cx,
|
||||
|window, cx: &mut Context<Editing>| {
|
||||
let mut field = |placeholder: &'static str, text: &SharedString, lines: bool| {
|
||||
cx.new(|cx| {
|
||||
let input = TextInput::new(window, cx).placeholder(placeholder);
|
||||
let mut input = match lines {
|
||||
true => input.multi_line(3, 6),
|
||||
false => input,
|
||||
};
|
||||
input.set_text(text.to_string(), cx);
|
||||
input
|
||||
})
|
||||
};
|
||||
let title = field("Title", &seed.title, false);
|
||||
let place = field("Where", &seed.place, false);
|
||||
let notes = field("Notes", &seed.notes, true);
|
||||
let watch = cx.subscribe(&title, |_, _, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Changed {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
Editing {
|
||||
seed: seed.clone(),
|
||||
draft: seed.clone(),
|
||||
title,
|
||||
place,
|
||||
notes,
|
||||
_title: watch,
|
||||
}
|
||||
},
|
||||
);
|
||||
if editing.read(cx).seed != self.draft {
|
||||
log::info!("event editor {:?}: started over", self.id);
|
||||
editing.update(cx, |editing, cx| editing.fill(self.draft.clone(), cx));
|
||||
}
|
||||
let draft = editing.read(cx).current(cx);
|
||||
let edit = {
|
||||
let editing = editing.clone();
|
||||
move |change: Rc<dyn Fn(&mut EventDraft)>| {
|
||||
let editing = editing.clone();
|
||||
move |cx: &mut App| {
|
||||
editing.update(cx, |editing, cx| {
|
||||
change(&mut editing.draft);
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
let civil = |at: Timestamp| at.to_zoned(zone.clone()).datetime();
|
||||
let exact = {
|
||||
let zone = zone.clone();
|
||||
move |at: DateTime| {
|
||||
at.to_zoned(zone.clone())
|
||||
.expect("the zone holds the time")
|
||||
.timestamp()
|
||||
}
|
||||
};
|
||||
let whole = matches!(draft.when, When::AllDay { .. });
|
||||
let toggle = {
|
||||
let zone = zone.clone();
|
||||
let when = draft.when;
|
||||
edit(Rc::new(move |draft| draft.when = toggled(when, &zone)))
|
||||
};
|
||||
let span: Vec<gpui::AnyElement> = match draft.when {
|
||||
When::Timed { start, end } => {
|
||||
let (at, until) = (exact.clone(), exact.clone());
|
||||
vec![
|
||||
DateTimePicker::new((self.id.clone(), "starts"), Some(civil(start)))
|
||||
.on_change({
|
||||
let edit = edit.clone();
|
||||
move |picked, _, cx| {
|
||||
let start = at(picked);
|
||||
edit(Rc::new(move |draft| draft.when = moved(draft.when, start)))(
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
DateTimePicker::new((self.id.clone(), "ends"), Some(civil(end)))
|
||||
.on_change({
|
||||
let edit = edit.clone();
|
||||
move |picked, _, cx| {
|
||||
let end = until(picked);
|
||||
edit(Rc::new(move |draft| {
|
||||
if let When::Timed { start, .. } = draft.when {
|
||||
draft.when = When::Timed { start, end };
|
||||
}
|
||||
}))(cx)
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
]
|
||||
}
|
||||
When::AllDay { first, last } => vec![
|
||||
DatePicker::new((self.id.clone(), "first"), Some(first))
|
||||
.on_change({
|
||||
let edit = edit.clone();
|
||||
move |day, _, cx| {
|
||||
let length = last - first;
|
||||
edit(Rc::new(move |draft| {
|
||||
let last = day.checked_add(length).expect("a day ahead");
|
||||
draft.when = When::AllDay { first: day, last };
|
||||
}))(cx)
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
DatePicker::new((self.id.clone(), "last"), Some(last))
|
||||
.on_change({
|
||||
let edit = edit.clone();
|
||||
move |day, _, cx| {
|
||||
edit(Rc::new(move |draft| {
|
||||
if let When::AllDay { first, .. } = draft.when {
|
||||
draft.when = When::AllDay { first, last: day };
|
||||
}
|
||||
}))(cx)
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
],
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let chart = theme.colors.chart;
|
||||
let (first, _) = Event {
|
||||
key: self.id.to_string().into(),
|
||||
title: draft.title.clone(),
|
||||
when: draft.when,
|
||||
hue: draft.hue,
|
||||
place: None,
|
||||
}
|
||||
.days(&zone);
|
||||
let fault = draft.fault();
|
||||
let save = {
|
||||
let (editing, on_save, id) = (editing.clone(), self.on_save.clone(), self.id.clone());
|
||||
Button::new((self.id.clone(), "save"), "Save")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.disabled(fault.is_some())
|
||||
.on_click(move |_, window, cx| {
|
||||
let draft = editing.read(cx).current(cx);
|
||||
log::info!("event editor {id:?}: save {}", draft.title);
|
||||
if let Some(on_save) = &on_save {
|
||||
on_save(&draft, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let cancel = self.on_cancel.clone().map(|on_cancel| {
|
||||
Button::new((self.id.clone(), "cancel"), "Cancel")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| on_cancel(window, cx))
|
||||
});
|
||||
let delete = self.on_delete.clone().map(|on_delete| {
|
||||
Button::new((self.id.clone(), "delete"), "Delete")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| on_delete(window, cx))
|
||||
});
|
||||
let (title, place, notes) = {
|
||||
let editing = editing.read(cx);
|
||||
(
|
||||
editing.title.clone(),
|
||||
editing.place.clone(),
|
||||
editing.notes.clone(),
|
||||
)
|
||||
};
|
||||
div()
|
||||
.debug_selector(|| "event-editor".into())
|
||||
.min_w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(
|
||||
FormField::new((self.id.clone(), "title-field"), "Title")
|
||||
.required()
|
||||
.child(Input::new(&title)),
|
||||
)
|
||||
.child(
|
||||
Switch::new((self.id.clone(), "all-day"), whole)
|
||||
.label("All day")
|
||||
.on_change(move |_, _, cx| toggle(cx)),
|
||||
)
|
||||
.child(
|
||||
div().flex().flex_wrap().gap_3().children(
|
||||
span.into_iter()
|
||||
.zip(["Starts", "Ends"])
|
||||
.map(|(picker, label)| {
|
||||
div().flex_1().min_w(theme.label_width()).child(
|
||||
FormField::new((self.id.clone(), label), label).child(picker),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.children((fault == Some("It ends before it starts.")).then(|| {
|
||||
div()
|
||||
.debug_selector(|| "editor-fault".into())
|
||||
.child(FormError::new(
|
||||
(self.id.clone(), "fault"),
|
||||
"It ends before it starts.",
|
||||
))
|
||||
}))
|
||||
.child(
|
||||
FormField::new((self.id.clone(), "place-field"), "Where").child(Input::new(&place)),
|
||||
)
|
||||
.child(
|
||||
FormField::new((self.id.clone(), "color-field"), "Color").child(
|
||||
ColorPalette::new(
|
||||
(self.id.clone(), "color"),
|
||||
HUES.iter().zip(chart).map(|(name, color)| (*name, color)),
|
||||
)
|
||||
.selected(chart[draft.hue])
|
||||
.on_change({
|
||||
let edit = edit.clone();
|
||||
move |color, _, cx| {
|
||||
let hue = chart
|
||||
.iter()
|
||||
.position(|each| *each == color)
|
||||
.expect("a hue of the chart");
|
||||
edit(Rc::new(move |draft| draft.hue = hue))(cx)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
FormField::new((self.id.clone(), "repeat-field"), "Repeat").child(
|
||||
RecurrenceEditor::new((self.id.clone(), "repeat"), first, draft.repeat.clone())
|
||||
.on_change({
|
||||
let edit = edit.clone();
|
||||
move |rule, _, cx| {
|
||||
edit(Rc::new(move |draft| draft.repeat = rule.clone()))(cx)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
FormField::new((self.id.clone(), "reminders-field"), "Reminders").child(
|
||||
ReminderPicker::new((self.id.clone(), "reminders"), draft.reminders.clone())
|
||||
.on_change({
|
||||
let edit = edit.clone();
|
||||
move |minutes, _, cx| {
|
||||
let minutes = minutes.to_vec();
|
||||
edit(Rc::new(move |draft| draft.reminders = minutes.clone()))(cx)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
FormField::new((self.id.clone(), "notes-field"), "Notes").child(Input::new(¬es)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "editor-actions".into())
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(div().children(delete))
|
||||
.child(div().flex().gap_2().children(cancel).child(save)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
mod agenda;
|
||||
mod allday;
|
||||
mod availability;
|
||||
mod card;
|
||||
mod chip;
|
||||
mod days;
|
||||
mod editor;
|
||||
mod event;
|
||||
mod grid;
|
||||
mod head;
|
||||
@@ -11,6 +13,9 @@ mod month;
|
||||
mod now;
|
||||
mod people;
|
||||
mod popover;
|
||||
mod recurrence;
|
||||
mod reminders;
|
||||
mod repeat;
|
||||
mod weeks;
|
||||
mod year;
|
||||
|
||||
@@ -19,13 +24,18 @@ mod tests;
|
||||
|
||||
pub use agenda::AgendaView;
|
||||
pub use allday::AllDayRow;
|
||||
pub use availability::AvailabilityPicker;
|
||||
pub use card::EventCard;
|
||||
pub use chip::EventChip;
|
||||
pub use days::{CalendarDayView, CalendarDays, CalendarWeekView};
|
||||
pub use editor::{EventDraft, EventEditor};
|
||||
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 recurrence::RecurrenceEditor;
|
||||
pub use reminders::ReminderPicker;
|
||||
pub use repeat::{Ends, Frequency, Recurrence};
|
||||
pub use year::YearView;
|
||||
@@ -0,0 +1,284 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div, prelude::*,
|
||||
};
|
||||
use jiff::{
|
||||
ToSpan,
|
||||
civil::{Date, Weekday},
|
||||
};
|
||||
|
||||
use super::repeat::{Ends, Frequency, Recurrence};
|
||||
use crate::{
|
||||
forms::{Choice, ChoiceChips, DatePicker, NumberInput, RadioGroup, Select},
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
|
||||
type OnRecurrence = Rc<dyn Fn(Option<Recurrence>, &mut Window, &mut App)>;
|
||||
|
||||
const FREQUENCIES: [(&str, &str, Option<Frequency>); 5] = [
|
||||
("never", "Does not repeat", None),
|
||||
("daily", "Daily", Some(Frequency::Daily)),
|
||||
("weekly", "Weekly", Some(Frequency::Weekly)),
|
||||
("monthly", "Monthly", Some(Frequency::Monthly)),
|
||||
("yearly", "Yearly", Some(Frequency::Yearly)),
|
||||
];
|
||||
|
||||
const WEEKDAYS: [(&str, &str, Weekday); 7] = [
|
||||
("mon", "Mon", Weekday::Monday),
|
||||
("tue", "Tue", Weekday::Tuesday),
|
||||
("wed", "Wed", Weekday::Wednesday),
|
||||
("thu", "Thu", Weekday::Thursday),
|
||||
("fri", "Fri", Weekday::Friday),
|
||||
("sat", "Sat", Weekday::Saturday),
|
||||
("sun", "Sun", Weekday::Sunday),
|
||||
];
|
||||
|
||||
fn frequency_key(frequency: Option<Frequency>) -> &'static str {
|
||||
FREQUENCIES
|
||||
.iter()
|
||||
.find(|(_, _, each)| *each == frequency)
|
||||
.map(|(key, _, _)| *key)
|
||||
.expect("every frequency has a key")
|
||||
}
|
||||
|
||||
fn weekday_key(day: Weekday) -> &'static str {
|
||||
WEEKDAYS
|
||||
.iter()
|
||||
.find(|(_, _, each)| *each == day)
|
||||
.map(|(key, _, _)| *key)
|
||||
.expect("every weekday has a key")
|
||||
}
|
||||
|
||||
/// Sets how an event repeats: not at all, or daily, weekly on its days, monthly or yearly, every so many, until a day or a count. A line under it reads the rule back with the next days it falls on.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RecurrenceEditor {
|
||||
id: ElementId,
|
||||
first: Date,
|
||||
value: Option<Recurrence>,
|
||||
on_change: Option<OnRecurrence>,
|
||||
}
|
||||
|
||||
impl RecurrenceEditor {
|
||||
/// For an event that starts on `first`.
|
||||
pub fn new(id: impl Into<ElementId>, first: Date, value: Option<Recurrence>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
first,
|
||||
value,
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the rule after each change, or none when it stops repeating.
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(Option<Recurrence>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RecurrenceEditor {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let set: OnRecurrence = {
|
||||
let (id, on_change) = (self.id.clone(), self.on_change);
|
||||
Rc::new(move |next, window, cx| {
|
||||
log::info!("recurrence editor {id:?}: {next:?}");
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(next, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let rule = self.value.clone();
|
||||
let choices = FREQUENCIES.map(|(key, label, _)| Choice::new(key, label));
|
||||
let pick = {
|
||||
let (set, rule) = (set.clone(), rule.clone());
|
||||
move |key: &SharedString, window: &mut Window, cx: &mut App| {
|
||||
let frequency = FREQUENCIES
|
||||
.iter()
|
||||
.find(|(each, _, _)| *each == key.as_ref())
|
||||
.and_then(|(_, _, frequency)| *frequency);
|
||||
let next = frequency.map(|frequency| match &rule {
|
||||
Some(rule) => Recurrence {
|
||||
frequency,
|
||||
..rule.clone()
|
||||
},
|
||||
None => Recurrence::new(frequency),
|
||||
});
|
||||
set(next, window, cx)
|
||||
}
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let frequency = Select::new((self.id.clone(), "frequency"), choices)
|
||||
.selected(frequency_key(rule.as_ref().map(|rule| rule.frequency)))
|
||||
.on_change(pick);
|
||||
let Some(rule) = rule else {
|
||||
return div().min_w_full().child(frequency);
|
||||
};
|
||||
let unit = match (rule.frequency, rule.interval) {
|
||||
(Frequency::Daily, 1) => "day",
|
||||
(Frequency::Daily, _) => "days",
|
||||
(Frequency::Weekly, 1) => "week",
|
||||
(Frequency::Weekly, _) => "weeks",
|
||||
(Frequency::Monthly, 1) => "month",
|
||||
(Frequency::Monthly, _) => "months",
|
||||
(Frequency::Yearly, 1) => "year",
|
||||
(Frequency::Yearly, _) => "years",
|
||||
};
|
||||
let every = {
|
||||
let (set, rule) = (set.clone(), rule.clone());
|
||||
NumberInput::new((self.id.clone(), "interval"), f64::from(rule.interval))
|
||||
.range(1.0, 99.0)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(move |value, window, cx| {
|
||||
let interval = value as u16;
|
||||
set(
|
||||
Some(Recurrence {
|
||||
interval,
|
||||
..rule.clone()
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
};
|
||||
let days = (rule.frequency == Frequency::Weekly).then(|| {
|
||||
let (set, rule, first) = (set.clone(), rule.clone(), self.first);
|
||||
let on: Vec<&str> = match rule.weekdays.is_empty() {
|
||||
true => vec![weekday_key(first.weekday())],
|
||||
false => rule.weekdays.iter().map(|day| weekday_key(*day)).collect(),
|
||||
};
|
||||
ChoiceChips::new(
|
||||
(self.id.clone(), "weekdays"),
|
||||
WEEKDAYS.map(|(key, label, _)| Choice::new(key, label)),
|
||||
)
|
||||
.multiple()
|
||||
.selected(on)
|
||||
.on_change(move |keys, window, cx| {
|
||||
let weekdays = WEEKDAYS
|
||||
.iter()
|
||||
.filter(|(key, _, _)| keys.iter().any(|each| each.as_ref() == *key))
|
||||
.map(|(_, _, day)| *day)
|
||||
.collect();
|
||||
set(
|
||||
Some(Recurrence {
|
||||
weekdays,
|
||||
..rule.clone()
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
let ends = {
|
||||
let (set, rule, first) = (set.clone(), rule.clone(), self.first);
|
||||
let key = match rule.ends {
|
||||
Ends::Never => "never",
|
||||
Ends::On(_) => "on",
|
||||
Ends::After(_) => "after",
|
||||
};
|
||||
RadioGroup::new(
|
||||
(self.id.clone(), "ends"),
|
||||
[
|
||||
Choice::new("never", "Never"),
|
||||
Choice::new("on", "On a day"),
|
||||
Choice::new("after", "After a number of times"),
|
||||
],
|
||||
)
|
||||
.selected(key)
|
||||
.on_change(move |key, window, cx| {
|
||||
let ends = match key.as_ref() {
|
||||
"on" => Ends::On(first.checked_add(1.month()).expect("a month ahead")),
|
||||
"after" => Ends::After(10),
|
||||
_ => Ends::Never,
|
||||
};
|
||||
set(
|
||||
Some(Recurrence {
|
||||
ends,
|
||||
..rule.clone()
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
};
|
||||
let until = match rule.ends {
|
||||
Ends::On(last) => {
|
||||
let (set, rule) = (set.clone(), rule.clone());
|
||||
Some(
|
||||
DatePicker::new((self.id.clone(), "until"), Some(last))
|
||||
.on_change(move |last, window, cx| {
|
||||
let ends = Ends::On(last);
|
||||
set(
|
||||
Some(Recurrence {
|
||||
ends,
|
||||
..rule.clone()
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
Ends::After(count) => {
|
||||
let (set, rule) = (set.clone(), rule.clone());
|
||||
Some(
|
||||
NumberInput::new((self.id.clone(), "count"), f64::from(count))
|
||||
.range(1.0, 999.0)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(move |value, window, cx| {
|
||||
let ends = Ends::After(value as u16);
|
||||
set(
|
||||
Some(Recurrence {
|
||||
ends,
|
||||
..rule.clone()
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
Ends::Never => None,
|
||||
};
|
||||
let next: Vec<String> = rule
|
||||
.days(self.first, 4)
|
||||
.iter()
|
||||
.map(|day| day.strftime("%a %b %-d").to_string())
|
||||
.collect();
|
||||
let quiet = |text: String| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(text)
|
||||
};
|
||||
div()
|
||||
.debug_selector(|| "recurrence".into())
|
||||
.min_w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(frequency)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(quiet("Every".into()))
|
||||
.child(div().w_24().child(every))
|
||||
.child(quiet(unit.into())),
|
||||
)
|
||||
.children(days)
|
||||
.child(ends)
|
||||
.children(until)
|
||||
.child(quiet(rule.describe(self.first)))
|
||||
.when(!next.is_empty(), |editor| {
|
||||
editor.child(quiet(format!("Next: {}", next.join(", "))))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::{Choice, Select},
|
||||
primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
type OnMinutes = Rc<dyn Fn(&[u32], &mut Window, &mut App)>;
|
||||
|
||||
/// The reminders on offer, in minutes before an event, with their words.
|
||||
pub(crate) const OFFSETS: [(u32, &str); 9] = [
|
||||
(0, "At the time"),
|
||||
(5, "5 minutes before"),
|
||||
(10, "10 minutes before"),
|
||||
(15, "15 minutes before"),
|
||||
(30, "30 minutes before"),
|
||||
(60, "1 hour before"),
|
||||
(120, "2 hours before"),
|
||||
(1440, "1 day before"),
|
||||
(10080, "1 week before"),
|
||||
];
|
||||
|
||||
/// `minutes` sorted, each asserted to be on offer and to come once.
|
||||
fn checked(minutes: &[u32]) -> Vec<u32> {
|
||||
let mut sorted = minutes.to_vec();
|
||||
sorted.sort_unstable();
|
||||
for (ix, minute) in sorted.iter().enumerate() {
|
||||
let offered = OFFSETS.iter().any(|(each, _)| each == minute);
|
||||
assert!(offered, "no reminder {minute} minutes before is on offer");
|
||||
assert!(
|
||||
ix == 0 || sorted[ix - 1] != *minute,
|
||||
"reminder {minute} twice"
|
||||
);
|
||||
}
|
||||
sorted
|
||||
}
|
||||
|
||||
/// Reminders before an event: each picked from a list, with a way off. Add brings the nearest one not yet set.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReminderPicker {
|
||||
id: ElementId,
|
||||
minutes: Vec<u32>,
|
||||
on_change: Option<OnMinutes>,
|
||||
}
|
||||
|
||||
impl ReminderPicker {
|
||||
/// `minutes` before the event, each one of those on offer.
|
||||
pub fn new(id: impl Into<ElementId>, minutes: impl IntoIterator<Item = u32>) -> Self {
|
||||
let minutes: Vec<u32> = minutes.into_iter().collect();
|
||||
Self {
|
||||
id: id.into(),
|
||||
minutes: checked(&minutes),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the minutes before after each change, soonest first.
|
||||
pub fn on_change(mut self, handler: impl Fn(&[u32], &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReminderPicker {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let set: OnMinutes = {
|
||||
let (id, on_change) = (self.id.clone(), self.on_change);
|
||||
Rc::new(move |next, window, cx| {
|
||||
let next = checked(next);
|
||||
log::info!("reminder picker {id:?}: {next:?}");
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(&next, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let minutes = Rc::new(self.minutes);
|
||||
let rows = minutes.iter().enumerate().map(|(ix, minute)| {
|
||||
let choices = OFFSETS
|
||||
.iter()
|
||||
.filter(|(each, _)| each == minute || !minutes.contains(each))
|
||||
.map(|(each, words)| Choice::new(each.to_string(), *words));
|
||||
let (change, remove) = (set.clone(), set.clone());
|
||||
let (changing, removing) = (minutes.clone(), minutes.clone());
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w_0().child(
|
||||
Select::new((self.id.clone(), format!("offset-{minute}")), choices)
|
||||
.size(ControlSize::Sm)
|
||||
.selected(minute.to_string())
|
||||
.on_change(move |value: &SharedString, window, cx| {
|
||||
let picked: u32 = value.parse().expect("an offset is a count");
|
||||
let mut next = changing.to_vec();
|
||||
next[ix] = picked;
|
||||
change(&next, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
IconButton::new((self.id.clone(), format!("remove-{minute}")), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Remove reminder")
|
||||
.on_click(move |_, window, cx| {
|
||||
let next: Vec<u32> = removing
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|each| each != &removing[ix])
|
||||
.collect();
|
||||
remove(&next, window, cx)
|
||||
}),
|
||||
)
|
||||
});
|
||||
let spare = OFFSETS
|
||||
.iter()
|
||||
.map(|(minute, _)| *minute)
|
||||
.find(|minute| !minutes.contains(minute));
|
||||
let add = {
|
||||
let (set, minutes) = (set.clone(), minutes.clone());
|
||||
Button::new((self.id.clone(), "add"), "Add reminder")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.icon(IconName::Plus)
|
||||
.disabled(spare.is_none())
|
||||
.on_click(move |_, window, cx| {
|
||||
let mut next = minutes.to_vec();
|
||||
next.push(spare.expect("Add waits for a reminder not yet set"));
|
||||
set(&next, window, cx)
|
||||
})
|
||||
};
|
||||
div()
|
||||
.debug_selector(|| "reminders".into())
|
||||
.min_w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.children(rows.collect::<Vec<_>>())
|
||||
.child(div().flex().child(add))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::checked;
|
||||
|
||||
#[test]
|
||||
fn reminders_sort_soonest_first() {
|
||||
assert_eq!(checked(&[1440, 10, 0]), [0, 10, 1440]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "no reminder 7 minutes before is on offer")]
|
||||
fn a_reminder_is_one_on_offer() {
|
||||
checked(&[7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "reminder 10 twice")]
|
||||
fn a_reminder_comes_once() {
|
||||
checked(&[10, 10]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use jiff::{
|
||||
ToSpan,
|
||||
civil::{Date, Weekday},
|
||||
};
|
||||
|
||||
use crate::forms::{show_date, week_start, weekday_words};
|
||||
|
||||
/// How often an event comes back.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Frequency {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
/// When a run of repeats stops: never, after a day, or after a count of them, the first among them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Ends {
|
||||
Never,
|
||||
On(Date),
|
||||
After(u16),
|
||||
}
|
||||
|
||||
/// An event's repeats: how often, every how many, on which weekdays for a weekly one, and when they stop.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Recurrence {
|
||||
pub frequency: Frequency,
|
||||
pub interval: u16,
|
||||
pub weekdays: Vec<Weekday>,
|
||||
pub ends: Ends,
|
||||
}
|
||||
|
||||
/// "1st", "2nd", "3rd", "11th", "22nd".
|
||||
fn ordinal(day: i8) -> String {
|
||||
let suffix = match (day % 10, day % 100) {
|
||||
(_, 11..=13) => "th",
|
||||
(1, _) => "st",
|
||||
(2, _) => "nd",
|
||||
(3, _) => "rd",
|
||||
_ => "th",
|
||||
};
|
||||
format!("{day}{suffix}")
|
||||
}
|
||||
|
||||
impl Recurrence {
|
||||
/// Every one, on the first day's weekday for a weekly one, without end.
|
||||
pub fn new(frequency: Frequency) -> Self {
|
||||
Self {
|
||||
frequency,
|
||||
interval: 1,
|
||||
weekdays: Vec::new(),
|
||||
ends: Ends::Never,
|
||||
}
|
||||
}
|
||||
|
||||
/// The weekdays a weekly one falls on, Monday first: its own, or the first day's.
|
||||
fn on(&self, first: Date) -> Vec<Weekday> {
|
||||
let mut days = match self.weekdays.is_empty() {
|
||||
true => vec![first.weekday()],
|
||||
false => self.weekdays.clone(),
|
||||
};
|
||||
days.sort_by_key(|day| day.to_monday_zero_offset());
|
||||
days.dedup();
|
||||
days
|
||||
}
|
||||
|
||||
/// In words, for an event that starts on `first`: "Every 2 weeks on Monday and Thursday, until Dec 31, 2026".
|
||||
pub fn describe(&self, first: Date) -> String {
|
||||
assert!(
|
||||
self.interval > 0,
|
||||
"a recurrence comes back every one or more"
|
||||
);
|
||||
let every = |one: &str, many: &str| match self.interval {
|
||||
1 => format!("Every {one}"),
|
||||
n => format!("Every {n} {many}"),
|
||||
};
|
||||
let what = match self.frequency {
|
||||
Frequency::Daily => every("day", "days"),
|
||||
Frequency::Weekly => {
|
||||
let days: Vec<u8> = self
|
||||
.on(first)
|
||||
.iter()
|
||||
.map(|day| day.to_sunday_zero_offset() as u8)
|
||||
.collect();
|
||||
format!("{} on {}", every("week", "weeks"), weekday_words(&days))
|
||||
}
|
||||
Frequency::Monthly => format!(
|
||||
"{} on the {}",
|
||||
every("month", "months"),
|
||||
ordinal(first.day())
|
||||
),
|
||||
Frequency::Yearly => {
|
||||
format!("{} on {}", every("year", "years"), first.strftime("%B %-d"))
|
||||
}
|
||||
};
|
||||
match self.ends {
|
||||
Ends::Never => what,
|
||||
Ends::On(last) => format!("{what}, until {}", show_date(last)),
|
||||
Ends::After(1) => format!("{what}, once"),
|
||||
Ends::After(count) => format!("{what}, {count} times"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `limit` days it falls on, `first` among them, before it ends. A month or a year without the first day's date is passed over.
|
||||
pub fn days(&self, first: Date, limit: usize) -> Vec<Date> {
|
||||
assert!(
|
||||
self.interval > 0,
|
||||
"a recurrence comes back every one or more"
|
||||
);
|
||||
let cap = match self.ends {
|
||||
Ends::After(count) => limit.min(usize::from(count)),
|
||||
_ => limit,
|
||||
};
|
||||
let within = |day: Date| match self.ends {
|
||||
Ends::On(last) => day <= last,
|
||||
_ => true,
|
||||
};
|
||||
let step = i64::from(self.interval);
|
||||
let mut days = Vec::with_capacity(cap);
|
||||
let mut round: i64 = 0;
|
||||
while days.len() < cap {
|
||||
let candidates: Vec<Date> = match self.frequency {
|
||||
Frequency::Daily => vec![
|
||||
first
|
||||
.checked_add((round * step).days())
|
||||
.expect("a day ahead"),
|
||||
],
|
||||
Frequency::Weekly => {
|
||||
let monday = week_start(first)
|
||||
.checked_add((round * step).weeks())
|
||||
.expect("a week ahead");
|
||||
self.on(first)
|
||||
.into_iter()
|
||||
.map(|day| {
|
||||
monday
|
||||
.checked_add(i64::from(day.to_monday_zero_offset()).days())
|
||||
.expect("a day of the week")
|
||||
})
|
||||
.filter(|day| *day >= first)
|
||||
.collect()
|
||||
}
|
||||
Frequency::Monthly => {
|
||||
let month = first
|
||||
.first_of_month()
|
||||
.checked_add((round * step).months())
|
||||
.expect("a month ahead");
|
||||
Date::new(month.year(), month.month(), first.day())
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
Frequency::Yearly => {
|
||||
let year = first.year() + (round * step) as i16;
|
||||
Date::new(year, first.month(), first.day())
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
for day in candidates {
|
||||
if !within(day) || days.len() == cap {
|
||||
return days;
|
||||
}
|
||||
days.push(day);
|
||||
}
|
||||
round += 1;
|
||||
}
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::civil::{Weekday, date};
|
||||
|
||||
use super::{Ends, Frequency, Recurrence, ordinal};
|
||||
|
||||
fn every(frequency: Frequency, interval: u16) -> Recurrence {
|
||||
Recurrence {
|
||||
interval,
|
||||
..Recurrence::new(frequency)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinals_read_as_spoken() {
|
||||
let said: Vec<String> = [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31]
|
||||
.map(ordinal)
|
||||
.to_vec();
|
||||
assert_eq!(
|
||||
said,
|
||||
[
|
||||
"1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "23rd", "31st"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recurrence_reads_in_words() {
|
||||
let sunday = date(2026, 9, 27);
|
||||
assert_eq!(every(Frequency::Daily, 1).describe(sunday), "Every day");
|
||||
assert_eq!(every(Frequency::Daily, 3).describe(sunday), "Every 3 days");
|
||||
assert_eq!(
|
||||
every(Frequency::Weekly, 1).describe(sunday),
|
||||
"Every week on Sunday"
|
||||
);
|
||||
let twice = Recurrence {
|
||||
weekdays: vec![Weekday::Thursday, Weekday::Monday],
|
||||
..every(Frequency::Weekly, 2)
|
||||
};
|
||||
assert_eq!(
|
||||
twice.describe(sunday),
|
||||
"Every 2 weeks on Monday and Thursday"
|
||||
);
|
||||
let workdays = Recurrence {
|
||||
weekdays: vec![
|
||||
Weekday::Monday,
|
||||
Weekday::Tuesday,
|
||||
Weekday::Wednesday,
|
||||
Weekday::Thursday,
|
||||
Weekday::Friday,
|
||||
],
|
||||
..every(Frequency::Weekly, 1)
|
||||
};
|
||||
assert_eq!(
|
||||
workdays.describe(sunday),
|
||||
"Every week on Monday through Friday"
|
||||
);
|
||||
assert_eq!(
|
||||
every(Frequency::Monthly, 1).describe(sunday),
|
||||
"Every month on the 27th"
|
||||
);
|
||||
assert_eq!(
|
||||
every(Frequency::Yearly, 1).describe(sunday),
|
||||
"Every year on September 27"
|
||||
);
|
||||
let until = Recurrence {
|
||||
ends: Ends::On(date(2026, 12, 31)),
|
||||
..every(Frequency::Daily, 1)
|
||||
};
|
||||
assert_eq!(until.describe(sunday), "Every day, until Dec 31, 2026");
|
||||
let counted = Recurrence {
|
||||
ends: Ends::After(10),
|
||||
..every(Frequency::Monthly, 3)
|
||||
};
|
||||
assert_eq!(
|
||||
counted.describe(sunday),
|
||||
"Every 3 months on the 27th, 10 times"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_weekly_one_falls_on_its_days_from_the_first() {
|
||||
let twice = Recurrence {
|
||||
weekdays: vec![Weekday::Monday, Weekday::Thursday],
|
||||
..every(Frequency::Weekly, 2)
|
||||
};
|
||||
assert_eq!(
|
||||
twice.days(date(2026, 9, 27), 4),
|
||||
[
|
||||
date(2026, 10, 5),
|
||||
date(2026, 10, 8),
|
||||
date(2026, 10, 19),
|
||||
date(2026, 10, 22)
|
||||
],
|
||||
"weeks count from the start's own, whose Monday and Thursday have passed"
|
||||
);
|
||||
let weekly = Recurrence {
|
||||
interval: 1,
|
||||
..twice
|
||||
};
|
||||
assert_eq!(
|
||||
weekly.days(date(2026, 9, 27), 3),
|
||||
[date(2026, 9, 28), date(2026, 10, 1), date(2026, 10, 5)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_month_or_year_without_the_date_is_passed_over() {
|
||||
assert_eq!(
|
||||
every(Frequency::Monthly, 1).days(date(2027, 1, 31), 3),
|
||||
[date(2027, 1, 31), date(2027, 3, 31), date(2027, 5, 31)]
|
||||
);
|
||||
assert_eq!(
|
||||
every(Frequency::Yearly, 1).days(date(2028, 2, 29), 2),
|
||||
[date(2028, 2, 29), date(2032, 2, 29)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeats_stop_after_a_count_or_a_day() {
|
||||
let counted = Recurrence {
|
||||
ends: Ends::After(3),
|
||||
..every(Frequency::Daily, 2)
|
||||
};
|
||||
assert_eq!(
|
||||
counted.days(date(2026, 9, 27), 10),
|
||||
[date(2026, 9, 27), date(2026, 9, 29), date(2026, 10, 1)]
|
||||
);
|
||||
let until = Recurrence {
|
||||
ends: Ends::On(date(2026, 10, 10)),
|
||||
..every(Frequency::Weekly, 1)
|
||||
};
|
||||
assert_eq!(
|
||||
until.days(date(2026, 9, 27), 10),
|
||||
[date(2026, 9, 27), date(2026, 10, 4)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use gpui::{Entity, IntoElement, Modifiers, TestAppContext, VisualTestContext, point, px};
|
||||
use jiff::{civil::date, tz::TimeZone};
|
||||
|
||||
use super::{Planning, at, heard, note, planning, press, settle, tab_to};
|
||||
use crate::calendar::{
|
||||
AvailabilityPicker, EventDraft, EventEditor, RecurrenceEditor, ReminderPicker, When,
|
||||
};
|
||||
|
||||
fn draft(title: &str, from: i8, to: i8) -> EventDraft {
|
||||
EventDraft {
|
||||
title: title.to_string().into(),
|
||||
when: When::Timed {
|
||||
start: at(22, from),
|
||||
end: at(22, to),
|
||||
},
|
||||
place: "".into(),
|
||||
hue: 0,
|
||||
repeat: None,
|
||||
reminders: Vec::new(),
|
||||
notes: "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn said(draft: &EventDraft) -> String {
|
||||
match draft.when {
|
||||
When::Timed { start, end } => format!("save {} {start} {end}", draft.title),
|
||||
When::AllDay { first, last } => format!("save {} {first} to {last}", draft.title),
|
||||
}
|
||||
}
|
||||
|
||||
fn editor(host: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
let title = match host.month.day() {
|
||||
1 => "",
|
||||
_ => "Crit",
|
||||
};
|
||||
EventEditor::new("editor", draft(title, 9, 10))
|
||||
.zone(TimeZone::UTC)
|
||||
.on_save(move |draft, _, cx| note(&owner, said(draft), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Presses the editor's last action, Save, at the right end of its row.
|
||||
fn save(cx: &mut VisualTestContext) {
|
||||
let row = cx.debug_bounds("editor-actions").expect("the actions draw");
|
||||
cx.simulate_click(
|
||||
point(row.right() - px(8.0), row.center().y),
|
||||
Modifiers::none(),
|
||||
);
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn save_waits_for_a_title_then_hands_on_the_draft(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(editor, cx);
|
||||
save(cx);
|
||||
assert!(heard(&host, cx).is_empty(), "no title, no save");
|
||||
tab_to(1, cx);
|
||||
cx.simulate_input(" Model review ");
|
||||
settle(cx);
|
||||
save(cx);
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
[format!("save Model review {} {}", at(22, 9), at(22, 10))]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn all_day_turns_the_hours_into_their_day(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(editor, cx);
|
||||
tab_to(1, cx);
|
||||
cx.simulate_input("Fair");
|
||||
settle(cx);
|
||||
tab_to(2, cx);
|
||||
press("space", cx);
|
||||
save(cx);
|
||||
assert_eq!(heard(&host, cx), ["save Fair 2026-09-22 to 2026-09-22"]);
|
||||
}
|
||||
|
||||
fn backward(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
EventEditor::new("editor", draft("Crit", 10, 9))
|
||||
.zone(TimeZone::UTC)
|
||||
.on_save(move |draft, _, cx| note(&owner, said(draft), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_end_before_the_start_says_so_and_holds_save(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(backward, cx);
|
||||
assert!(cx.debug_bounds("editor-fault").is_some());
|
||||
save(cx);
|
||||
assert!(heard(&host, cx).is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_new_draft_from_the_owner_starts_the_editor_over(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(editor, cx);
|
||||
host.update(cx, |host, cx| {
|
||||
host.month = date(2026, 10, 2);
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
save(cx);
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
[format!("save Crit {} {}", at(22, 9), at(22, 10))]
|
||||
);
|
||||
}
|
||||
|
||||
fn reminders(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
ReminderPicker::new("reminders", [10])
|
||||
.on_change(move |minutes, _, cx| note(&owner, format!("{minutes:?}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn add_brings_the_nearest_reminder_not_yet_set(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(reminders, cx);
|
||||
tab_to(3, cx);
|
||||
press("space", cx);
|
||||
tab_to(2, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(heard(&host, cx), ["[0, 10]", "[]"], "Add, then the way off");
|
||||
}
|
||||
|
||||
fn repeat(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
RecurrenceEditor::new("repeat", date(2026, 9, 27), None)
|
||||
.on_change(move |rule, _, cx| {
|
||||
let words = rule.map(|rule| rule.describe(date(2026, 9, 27)));
|
||||
note(&owner, format!("{words:?}"), cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn picking_a_frequency_starts_a_rule(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(repeat, cx);
|
||||
tab_to(1, cx);
|
||||
cx.simulate_keystrokes("down down down enter");
|
||||
settle(cx);
|
||||
assert_eq!(heard(&host, cx), ["Some(\"Every week on Sunday\")"]);
|
||||
}
|
||||
|
||||
fn slots(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
|
||||
AvailabilityPicker::new("slots", [date(2026, 9, 28), date(2026, 9, 29)])
|
||||
.zone(TimeZone::UTC)
|
||||
.hours(9, 12)
|
||||
.busy([(at(28, 9), at(28, 10))])
|
||||
.now(at(27, 12))
|
||||
.on_pick(move |start, _, cx| note(&owner, format!("pick {start}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_slot_pick_hands_on_its_start(cx: &mut TestAppContext) {
|
||||
let (host, cx) = planning(slots, cx);
|
||||
tab_to(3, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
[format!("pick {}", at(28, 10))],
|
||||
"09:00 to 10:00 is taken"
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use jiff::{Timestamp, civil::date, tz::TimeZone};
|
||||
|
||||
use super::{CalendarMonthView, Event, YearView};
|
||||
|
||||
mod editing;
|
||||
mod people;
|
||||
mod times;
|
||||
use crate::{primitives::FocusNext, theme::Theme};
|
||||
|
||||
@@ -23,6 +23,7 @@ pub use picker::{DatePicker, DateRangePicker};
|
||||
pub(crate) use picker::{Face, dropdown, picker_field};
|
||||
pub use relative::{RelativeDatePicker, RelativeRange};
|
||||
pub use rule::CronRule;
|
||||
pub(crate) use rule::weekday_words;
|
||||
pub use time::{DateTimePicker, DurationPicker, TimePicker};
|
||||
pub use week::WeekPicker;
|
||||
pub use zone::TimezoneSelect;
|
||||
|
||||
@@ -273,7 +273,7 @@ fn join(words: &[String]) -> String {
|
||||
}
|
||||
|
||||
/// Weekdays in words, Monday first, with a run of three or more as a span.
|
||||
fn weekday_words(values: &[u8]) -> String {
|
||||
pub(crate) fn weekday_words(values: &[u8]) -> String {
|
||||
let mut monday_first: Vec<u8> = values.iter().map(|day| (day + 6) % 7).collect();
|
||||
monday_first.sort_unstable();
|
||||
let name = |monday_zero: u8| DAY_NAMES[((monday_zero + 1) % 7) as usize].to_string();
|
||||
|
||||
+3
-1
@@ -63,7 +63,9 @@ pub use date::{
|
||||
MonthPicker, QuarterPicker, RelativeDatePicker, RelativeRange, TimePicker, TimezoneSelect,
|
||||
WeekPicker, YearPicker,
|
||||
};
|
||||
pub(crate) use date::{Face, dropdown, month_grid, picker_field, show_span, week_start};
|
||||
pub(crate) use date::{
|
||||
Face, dropdown, month_grid, picker_field, show_date, show_span, week_start, weekday_words,
|
||||
};
|
||||
pub use expression::{ExpressionInput, evaluate, expression_highlights};
|
||||
pub use files::{DropZone, FileInput, PICTURES};
|
||||
pub(crate) use files::{browse, dropped};
|
||||
|
||||
@@ -275,7 +275,7 @@ impl RenderOnce for FormField {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1p5()
|
||||
.w_full()
|
||||
.min_w_full()
|
||||
.child(self.label)
|
||||
.children(self.children)
|
||||
.children(note)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render,
|
||||
TestAppContext, VisualTestContext, Window, div,
|
||||
AppContext as _, Context, Entity, InteractiveElement, IntoElement, KeyUpEvent, Keystroke,
|
||||
ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px,
|
||||
};
|
||||
|
||||
use super::setup;
|
||||
@@ -134,3 +134,36 @@ fn field_array_buttons_remove_their_own_row_and_add_one(cx: &mut TestAppContext)
|
||||
["a", "c", "new"]
|
||||
);
|
||||
}
|
||||
|
||||
/// A field over a long line in a column that a padded block measures by content.
|
||||
struct Narrow;
|
||||
|
||||
impl Render for Narrow {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
let control = div().debug_selector(|| "narrow-control".into()).h_2();
|
||||
div().w(px(280.)).flex().flex_col().child(
|
||||
div().p_5().child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(FormField::new("narrow", "Name").child(control))
|
||||
.child("A line long enough to wrap in a narrow card, so the column fills it."),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_field_fills_a_column_its_block_measures_by_content(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Narrow);
|
||||
cx.run_until_parked();
|
||||
let control = cx
|
||||
.debug_bounds("narrow-control")
|
||||
.expect("the control draws");
|
||||
assert_eq!(
|
||||
control.size.width,
|
||||
px(240.),
|
||||
"the field spans the card inside its padding"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user