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.
This commit is contained in:
1 parent
48ff348357
commit
0bcfd82bf3
18 files changed
+1856
-12
No files matched your search
@@ -141,7 +141,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does.
|
- A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does.
|
||||||
- A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up.
|
- A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up.
|
||||||
- A callback field holds a named `type` alias; clippy's type_complexity rejects `Option<Rc<dyn Fn(..)>>`. A chapter shares one alias per shape.
|
- A callback field holds a named `type` alias; clippy's type_complexity rejects `Option<Rc<dyn Fn(..)>>`. A chapter shares one alias per shape.
|
||||||
- A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width.
|
- A component that fills its container says `min_w_full` on its root. As a window's root, or in a flex row, an auto width would shrink to its text's narrowest width. A percent width does not stretch in a flex box, and taffy 0.9 lays a flex box out once at unknown width while a block parent measures its content, then keeps that layout when the width comes out the same: its `w_full` children stay at content width (`forms::FormField`). Roots made before T30d still say `w_full`; T44 moves them.
|
||||||
- A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match.
|
- A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match.
|
||||||
- A block editor keeps one undo for the document: text and structure together, as snapshots through `forms::History`; it takes Undo and Redo before its fields do.
|
- A block editor keeps one undo for the document: text and structure together, as snapshots through `forms::History`; it takes Undo and Redo before its fields do.
|
||||||
- gpui calls focus listeners only while the window is active. A test that needs focus events activates its window with `window.activate_window()`.
|
- gpui calls focus listeners only while the window is active. A test that needs focus events activates its window with `window.activate_window()`.
|
||||||
@@ -156,6 +156,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- The system time zone comes from `typography::format::system_zone`, which fails loud; jiff's `TimeZone::system()` falls back to UTC in silence.
|
- The system time zone comes from `typography::format::system_zone`, which fails loud; jiff's `TimeZone::system()` falls back to UTC in silence.
|
||||||
- A Select with nothing to pick stays shut: disabled, it shows its placeholder; open, it fails (`media::DeviceSelector` says no device was found).
|
- A Select with nothing to pick stays shut: disabled, it shows its placeholder; open, it fails (`media::DeviceSelector` says no device was found).
|
||||||
- A list opens with its cursor on the current choice: Select on the chosen row, Combobox on its value, MultiSelect on the first one ticked, SelectableList on its first selected row. A selection the owner makes, not the echo of the list's own pick, moves SelectableList's cursor there.
|
- A list opens with its cursor on the current choice: Select on the chosen row, Combobox on its value, MultiSelect on the first one ticked, SelectableList on its first selected row. A selection the owner makes, not the echo of the list's own pick, moves SelectableList's cursor there.
|
||||||
|
- An event repeats as RFC 5545 has it: weeks count from the start's own week, and a month or a year without the start's date is passed over (`calendar::Recurrence`).
|
||||||
- A search step, a tool call or an agent's step says where it stands with `chat::StepState` and draws it with `chat::step_mark`.
|
- A search step, a tool call or an agent's step says where it stands with `chat::StepState` and draws it with `chat::step_mark`.
|
||||||
- A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space.
|
- A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space.
|
||||||
- A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below.
|
- A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below.
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [x] T29d Mail · Sorting
|
- [x] T29d Mail · Sorting
|
||||||
- [x] T30a Calendar · Months
|
- [x] T30a Calendar · Months
|
||||||
- [x] T30b Calendar · Time grid
|
- [x] T30b Calendar · Time grid
|
||||||
- [ ] T30c Calendar · Agenda and people
|
- [x] T30c Calendar · Agenda and people
|
||||||
- [ ] T30d Calendar · Editing
|
- [ ] T30d Calendar · Editing
|
||||||
- [ ] T31 Project — `tasks/ch31-43.md`
|
- [ ] T31 Project — `tasks/ch31-43.md`
|
||||||
- [ ] T32 Canvas & Design
|
- [ ] T32 Canvas & Design
|
||||||
@@ -124,7 +124,7 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [ ] T41 Maps
|
- [ ] T41 Maps
|
||||||
- [ ] T42 Misc
|
- [ ] T42 Misc
|
||||||
- [ ] T43 Library Tooling
|
- [ ] T43 Library Tooling
|
||||||
- [ ] T44 Capture: every component, light and dark, plus motion clips
|
- [ ] T44 Capture: every component, light and dark, plus motion clips; roots still on `w_full` move to `min_w_full`, checked at 280px
|
||||||
- [ ] T45 Website: `frontend/`, Vite 8, pnpm, light and dark, motion
|
- [ ] T45 Website: `frontend/`, Vite 8, pnpm, light and dark, motion
|
||||||
- [ ] T46 E2E: Playwright against the built site
|
- [ ] T46 E2E: Playwright against the built site
|
||||||
- [ ] T47 Ship: GitHub repo (public), CI, Pages deploy
|
- [ ] T47 Ship: GitHub repo (public), CI, Pages deploy
|
||||||
@@ -357,3 +357,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T30a | 1 | 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 | 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 | |
|
| T30b | 2 | PASS | |
|
||||||
|
| T30c | 1 | PASS | |
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
use ely_gpui_component::{
|
||||||
|
calendar::{AvailabilityPicker, EventDraft, EventEditor, Frequency, Recurrence, When},
|
||||||
|
theme::{ActiveTheme, Radius},
|
||||||
|
};
|
||||||
|
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||||
|
use jiff::{ToSpan, civil::Weekday};
|
||||||
|
|
||||||
|
use super::{here, plan, quiet};
|
||||||
|
use crate::{
|
||||||
|
probe::probe,
|
||||||
|
ui::{change, keep, section, set},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn editor(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();
|
||||||
|
let said = keep("calendar-editor-said", || None::<SharedString>, window, cx);
|
||||||
|
let shown = said.read(cx).clone();
|
||||||
|
let theme = cx.theme();
|
||||||
|
let body = match review {
|
||||||
|
Some(review) => {
|
||||||
|
let (saving, cancelling, deleting) = (said.clone(), said.clone(), said);
|
||||||
|
let (kept, dropped) = (events.clone(), events);
|
||||||
|
let draft = EventDraft {
|
||||||
|
repeat: Some(Recurrence {
|
||||||
|
weekdays: vec![Weekday::Monday, Weekday::Thursday],
|
||||||
|
..Recurrence::new(Frequency::Weekly)
|
||||||
|
}),
|
||||||
|
reminders: vec![10, 1440],
|
||||||
|
..EventDraft::of(&review)
|
||||||
|
};
|
||||||
|
EventEditor::new("calendar-editor", draft)
|
||||||
|
.zone(zone)
|
||||||
|
.on_save(move |draft, _, cx| {
|
||||||
|
let draft = draft.clone();
|
||||||
|
change(&kept, cx, |events| {
|
||||||
|
let event = events
|
||||||
|
.iter_mut()
|
||||||
|
.find(|event| event.key.as_ref() == "review")
|
||||||
|
.expect("the review is listed");
|
||||||
|
event.title = draft.title.clone();
|
||||||
|
event.when = draft.when;
|
||||||
|
event.hue = draft.hue;
|
||||||
|
event.place = (!draft.place.is_empty()).then(|| draft.place.clone());
|
||||||
|
});
|
||||||
|
set(&saving, Some(format!("Saved {}.", draft.title).into()), cx)
|
||||||
|
})
|
||||||
|
.on_cancel(move |_, cx| set(&cancelling, Some("Left as it was.".into()), cx))
|
||||||
|
.on_delete(move |_, cx| {
|
||||||
|
change(&dropped, cx, |events| {
|
||||||
|
events.retain(|event| event.key.as_ref() != "review")
|
||||||
|
});
|
||||||
|
set(&deleting, Some("Deleted the review.".into()), cx)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
None => quiet("The review was deleted.".into(), cx).into_any_element(),
|
||||||
|
};
|
||||||
|
section(
|
||||||
|
"EventEditor / RecurrenceEditor / ReminderPicker",
|
||||||
|
"An event's parts in a form: its title, whole days or hours, where, its color, how it repeats with the next days it falls on, its reminders and notes. Save waits for a title and an end after the start; saved, every view on the page shows the change.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(probe(
|
||||||
|
"calendar-editor",
|
||||||
|
div()
|
||||||
|
.w(px(480.))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.p_5()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.colors.border)
|
||||||
|
.rounded(theme.radius(Radius::Lg))
|
||||||
|
.child(body),
|
||||||
|
)
|
||||||
|
.children(shown.map(|shown| quiet(shown, cx))),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn availability(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
|
let (zone, today) = here();
|
||||||
|
let busy: Vec<_> = plan(window, cx)
|
||||||
|
.read(cx)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|event| match event.when {
|
||||||
|
When::Timed { start, end } => Some((start, end)),
|
||||||
|
When::AllDay { .. } => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let days: Vec<_> = (1..=9)
|
||||||
|
.map(|offset| {
|
||||||
|
today
|
||||||
|
.checked_add((offset as i64).days())
|
||||||
|
.expect("a day ahead")
|
||||||
|
})
|
||||||
|
.filter(|day| !matches!(day.weekday(), Weekday::Saturday | Weekday::Sunday))
|
||||||
|
.take(5)
|
||||||
|
.collect();
|
||||||
|
let picked = keep("calendar-slot", || None::<jiff::Timestamp>, window, cx);
|
||||||
|
let chosen = *picked.read(cx);
|
||||||
|
let said = chosen.map(|start| {
|
||||||
|
let when = start
|
||||||
|
.to_zoned(zone.clone())
|
||||||
|
.strftime("%A at %H:%M")
|
||||||
|
.to_string();
|
||||||
|
SharedString::from(format!("Meet {when}."))
|
||||||
|
});
|
||||||
|
let picker = AvailabilityPicker::new("calendar-slots", days)
|
||||||
|
.zone(zone)
|
||||||
|
.busy(busy)
|
||||||
|
.length(45)
|
||||||
|
.on_pick(move |start, _, cx| set(&picked, Some(start), cx));
|
||||||
|
let picker = match chosen {
|
||||||
|
Some(start) => picker.selected(start),
|
||||||
|
None => picker,
|
||||||
|
};
|
||||||
|
section(
|
||||||
|
"AvailabilityPicker",
|
||||||
|
"Times to meet over the working days ahead: each day with its count of open slots, then the slots of the one chosen. What the page's events take is closed.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(probe(
|
||||||
|
"calendar-slots",
|
||||||
|
div()
|
||||||
|
.w(px(560.))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.child(picker)
|
||||||
|
.children(said.map(|said| quiet(said, cx))),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
|
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
|
||||||
|
|
||||||
|
mod editing;
|
||||||
mod months;
|
mod months;
|
||||||
mod people;
|
mod people;
|
||||||
mod times;
|
mod times;
|
||||||
@@ -109,6 +110,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
|||||||
.child(times::day(window, cx))
|
.child(times::day(window, cx))
|
||||||
.child(people::agenda(window, cx))
|
.child(people::agenda(window, cx))
|
||||||
.child(people::details(window, cx))
|
.child(people::details(window, cx))
|
||||||
|
.child(editing::editor(window, cx))
|
||||||
|
.child(editing::availability(window, cx))
|
||||||
.child(months::year(window, cx))
|
.child(months::year(window, cx))
|
||||||
.child(months::marks(cx))
|
.child(months::marks(cx))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
|||||||
@@ -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 agenda;
|
||||||
mod allday;
|
mod allday;
|
||||||
|
mod availability;
|
||||||
mod card;
|
mod card;
|
||||||
mod chip;
|
mod chip;
|
||||||
mod days;
|
mod days;
|
||||||
|
mod editor;
|
||||||
mod event;
|
mod event;
|
||||||
mod grid;
|
mod grid;
|
||||||
mod head;
|
mod head;
|
||||||
@@ -11,6 +13,9 @@ mod month;
|
|||||||
mod now;
|
mod now;
|
||||||
mod people;
|
mod people;
|
||||||
mod popover;
|
mod popover;
|
||||||
|
mod recurrence;
|
||||||
|
mod reminders;
|
||||||
|
mod repeat;
|
||||||
mod weeks;
|
mod weeks;
|
||||||
mod year;
|
mod year;
|
||||||
|
|
||||||
@@ -19,13 +24,18 @@ mod tests;
|
|||||||
|
|
||||||
pub use agenda::AgendaView;
|
pub use agenda::AgendaView;
|
||||||
pub use allday::AllDayRow;
|
pub use allday::AllDayRow;
|
||||||
|
pub use availability::AvailabilityPicker;
|
||||||
pub use card::EventCard;
|
pub use card::EventCard;
|
||||||
pub use chip::EventChip;
|
pub use chip::EventChip;
|
||||||
pub use days::{CalendarDayView, CalendarDays, CalendarWeekView};
|
pub use days::{CalendarDayView, CalendarDays, CalendarWeekView};
|
||||||
|
pub use editor::{EventDraft, EventEditor};
|
||||||
pub use event::{Event, When};
|
pub use event::{Event, When};
|
||||||
pub use grid::{TimeGrid, TimezoneOverlay};
|
pub use grid::{TimeGrid, TimezoneOverlay};
|
||||||
pub use month::CalendarMonthView;
|
pub use month::CalendarMonthView;
|
||||||
pub use now::CurrentTimeIndicator;
|
pub use now::CurrentTimeIndicator;
|
||||||
pub use people::{Answer, Attendee, AttendeeList};
|
pub use people::{Answer, Attendee, AttendeeList};
|
||||||
pub use popover::EventPopover;
|
pub use popover::EventPopover;
|
||||||
|
pub use recurrence::RecurrenceEditor;
|
||||||
|
pub use reminders::ReminderPicker;
|
||||||
|
pub use repeat::{Ends, Frequency, Recurrence};
|
||||||
pub use year::YearView;
|
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};
|
use super::{CalendarMonthView, Event, YearView};
|
||||||
|
|
||||||
|
mod editing;
|
||||||
mod people;
|
mod people;
|
||||||
mod times;
|
mod times;
|
||||||
use crate::{primitives::FocusNext, theme::Theme};
|
use crate::{primitives::FocusNext, theme::Theme};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub use picker::{DatePicker, DateRangePicker};
|
|||||||
pub(crate) use picker::{Face, dropdown, picker_field};
|
pub(crate) use picker::{Face, dropdown, picker_field};
|
||||||
pub use relative::{RelativeDatePicker, RelativeRange};
|
pub use relative::{RelativeDatePicker, RelativeRange};
|
||||||
pub use rule::CronRule;
|
pub use rule::CronRule;
|
||||||
|
pub(crate) use rule::weekday_words;
|
||||||
pub use time::{DateTimePicker, DurationPicker, TimePicker};
|
pub use time::{DateTimePicker, DurationPicker, TimePicker};
|
||||||
pub use week::WeekPicker;
|
pub use week::WeekPicker;
|
||||||
pub use zone::TimezoneSelect;
|
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.
|
/// 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();
|
let mut monday_first: Vec<u8> = values.iter().map(|day| (day + 6) % 7).collect();
|
||||||
monday_first.sort_unstable();
|
monday_first.sort_unstable();
|
||||||
let name = |monday_zero: u8| DAY_NAMES[((monday_zero + 1) % 7) as usize].to_string();
|
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,
|
MonthPicker, QuarterPicker, RelativeDatePicker, RelativeRange, TimePicker, TimezoneSelect,
|
||||||
WeekPicker, YearPicker,
|
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 expression::{ExpressionInput, evaluate, expression_highlights};
|
||||||
pub use files::{DropZone, FileInput, PICTURES};
|
pub use files::{DropZone, FileInput, PICTURES};
|
||||||
pub(crate) use files::{browse, dropped};
|
pub(crate) use files::{browse, dropped};
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ impl RenderOnce for FormField {
|
|||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.w_full()
|
.min_w_full()
|
||||||
.child(self.label)
|
.child(self.label)
|
||||||
.children(self.children)
|
.children(self.children)
|
||||||
.children(note)
|
.children(note)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render,
|
AppContext as _, Context, Entity, InteractiveElement, IntoElement, KeyUpEvent, Keystroke,
|
||||||
TestAppContext, VisualTestContext, Window, div,
|
ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::setup;
|
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", "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"
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-4
@@ -409,7 +409,7 @@
|
|||||||
|
|
||||||
## T30d Calendar · Editing
|
## T30d Calendar · Editing
|
||||||
|
|
||||||
- [ ] EventEditor
|
- [x] EventEditor
|
||||||
- [ ] RecurrenceEditor
|
- [x] RecurrenceEditor
|
||||||
- [ ] ReminderPicker
|
- [x] ReminderPicker
|
||||||
- [ ] AvailabilityPicker
|
- [x] AvailabilityPicker
|
||||||
Reference in new issue
Block a user