Mail · Sorting: a label picker and a snooze menu

LabelPicker puts labels on a message: a field finds them by name,
arrows move and a press or Enter turns one on or off, each row with its
check and its hue. A name no label has can be made into one, and the
field starts over. Its rows are the option rows every list shares;
forms::marked_row leads one with a mark. The cursor shows only while
the field has focus.

SnoozePicker offers later today, tomorrow, this weekend and next week,
each with its day and hour, or a time picked in a dialog. It shares
mail::times with ScheduleSend: the names, the dialog, which now waits
for a time ahead of now, and a rule that a moment comes once, so on a
Sunday tomorrow morning no longer repeats as Monday morning.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 01:16:24 -04:00
1 parent 14b5faa80c
commit ccbc0970e5
16 files changed
+1037 -144

No files matched your search

+2
View File
@@ -162,6 +162,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::picks` and `lists::ListItem` do.
- A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture. A focusable one keeps its focus border on an outer box, so the picture keeps that shape exactly (`media::ImageCropper`).
- A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`).
- A menu of times goes through `mail::times`: each row names its day and hour, a moment comes once (tomorrow is next Monday on a Sunday), and "Pick a time…" opens one dialog that waits for a time ahead of now (`ScheduleSend`, `SnoozePicker`).
- A list whose cursor a field drives marks the cursor only while the field has focus; at rest its first row would look hovered (`mail::LabelPicker`). Its rows are option rows, and `forms::marked_row` leads one with a mark such as a label's hue.
## Decisions
+3 -1
View File
@@ -105,7 +105,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T28e Messaging · Stickers and GIFs
- [x] T29a Mail · Boxes and lists
- [x] T29b Mail · Reading
- [ ] T29c Mail · Writing
- [x] T29c Mail · Writing
- [ ] T29d Mail · Sorting
- [ ] T30 Calendar
- [ ] T31 Project — `tasks/ch31-43.md`
@@ -346,3 +346,5 @@ Per-component lines live in `tasks/`. Tags there:
| T29a | 2 | PASS | |
| T29b | 1 | FAIL | a folded message lost focus as it opened; a long name beside a long date ran past a 280px fold |
| T29b | 2 | PASS | |
| T29c | 1 | FAIL | a long chip ran past its tag field at 280px |
| T29c | 2 | PASS | |
+38 -2
View File
@@ -3,12 +3,14 @@ use ely_gpui_component::{
feedback::EmptyState,
mail::{Mail, MailList, Mailbox, MailboxList},
primitives::IconName,
theme::{ActiveTheme, Radius},
theme::{ActiveTheme, Radius, TextSize},
typography::format,
};
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use jiff::{SignedDuration, Timestamp};
use jiff::{SignedDuration, Timestamp, tz::TimeZone};
mod read;
mod sort;
mod write;
use super::Page;
@@ -52,8 +54,40 @@ const SCRIPT: &[Step] = &[
Step::Key("enter"),
Step::Wait(200),
Step::Shot("recipient-added"),
Step::DownAt("mail-labels", 150.0, 25.0),
Step::UpAt("mail-labels", 150.0, 25.0),
Step::Type("Suppliers"),
Step::Wait(200),
Step::Shot("label-new"),
Step::Key("enter"),
Step::Wait(200),
Step::Shot("label-made"),
Step::DownAt("mail-snooze", 50.0, 16.0),
Step::UpAt("mail-snooze", 50.0, 16.0),
Step::Wait(300),
Step::Shot("snooze-open"),
Step::Key("up"),
Step::Key("enter"),
Step::Wait(300),
Step::Shot("snooze-dialog"),
Step::Key("escape"),
];
/// What a demo says it did, under it.
fn quiet(text: SharedString, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(text)
}
/// A moment as the demos say it: its weekday and hour here.
fn day_and_hour(at: Timestamp) -> String {
let zone = TimeZone::try_system().expect("the gallery reads the system time zone");
format::datetime(at, &zone, "%a %H:%M").expect("a fixed pattern formats")
}
fn boxes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = keep("mail-open-box", || SharedString::from("inbox"), window, cx);
let shown = open.read(cx).clone();
@@ -223,6 +257,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(read::thread(cx))
.child(write::composer(window, cx))
.child(write::signature(window, cx))
.child(sort::labels(window, cx))
.child(sort::snooze(window, cx))
.child(write::schedule(window, cx))
.into_any_element()
}
+112
View File
@@ -0,0 +1,112 @@
use ely_gpui_component::{
mail::{Label, LabelPicker, SnoozePicker},
theme::{ActiveTheme, Radius},
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::{day_and_hour, quiet};
use crate::{
probe::probe,
ui::{change, keep, section, set},
};
/// The demo's labels and the keys on.
#[derive(Clone)]
struct Sorting {
labels: Vec<Label>,
on: Vec<SharedString>,
}
pub fn labels(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let sorting = keep(
"mail-labels",
|| Sorting {
labels: vec![
Label::new("clients", "Clients", 0),
Label::new("studio", "Studio", 5),
Label::new("press", "Press", 3),
Label::new("finance", "Finance", 1),
Label::new("site", "Site visits", 2),
Label::new("travel", "Travel", 7),
],
on: vec!["clients".into(), "press".into()],
},
window,
cx,
);
let now = sorting.read(cx).clone();
let names: Vec<&str> = now
.labels
.iter()
.filter(|label| now.on.contains(&label.key))
.map(|label| label.name.as_ref())
.collect();
let said = match names.len() {
0 => SharedString::from("No labels on."),
_ => format!("On: {}.", names.join(", ")).into(),
};
let (changing, making) = (sorting.clone(), sorting);
let theme = cx.theme();
section(
"LabelPicker",
"The labels a message wears: the field finds them by name, arrows move, and a press or Enter turns one on or off. A name no label has can be made into one, on at once.",
cx,
)
.child(probe(
"mail-labels",
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.w(px(300.))
.p_2()
.border_1()
.border_color(theme.colors.border)
.rounded(theme.radius(Radius::Lg))
.child(
LabelPicker::new("mail-labels", now.labels.clone())
.selected(now.on.clone())
.on_change(move |keys, _, cx| {
change(&changing, cx, |sorting| sorting.on = keys.to_vec())
})
.on_create(move |name, _, cx| {
let hues = cx.theme().colors.chart.len();
change(&making, cx, |sorting| {
let made = sorting.labels.len();
let key = SharedString::from(format!("made-{made}"));
let free = (0..hues).find(|hue| {
sorting.labels.iter().all(|label| label.hue != *hue)
});
let hue = free.unwrap_or(made % hues);
sorting.labels.push(Label::new(key.clone(), name.clone(), hue));
sorting.on.push(key);
})
}),
),
)
.child(quiet(said, cx)),
))
}
pub fn snooze(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let said = keep("mail-snoozed", || None::<SharedString>, window, cx);
let shown = said.read(cx).clone();
section(
"SnoozePicker",
"Put a message off until later today, tomorrow, this weekend or next week, each with its day and hour, or until a time of your own.",
cx,
)
.child(probe(
"mail-snooze",
div()
.flex()
.items_center()
.gap_4()
.child(SnoozePicker::new("mail-snooze").on_snooze(move |at, _, cx| {
set(&said, Some(format!("Back {}.", day_and_hour(at)).into()), cx)
}))
.children(shown.map(|shown| quiet(shown, cx))),
))
}
+6 -11
View File
@@ -1,10 +1,11 @@
use ely_gpui_component::{
chat::Attachment,
mail::{Contact, MailComposer, ScheduleSend, SignatureEditor},
theme::{ActiveTheme, Radius, TextSize},
theme::{ActiveTheme, Radius},
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::{day_and_hour, quiet};
use crate::{
probe::probe,
ui::{change, keep, section, set},
@@ -39,14 +40,6 @@ struct Writing {
said: Option<SharedString>,
}
fn quiet(text: SharedString, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(text)
}
pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let writing = keep(
"mail-writing",
@@ -83,7 +76,7 @@ pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
change(&sent, cx, |writing| writing.said = Some(said.into()))
})
.on_schedule(move |draft, at, _, cx| {
let said = format!("{} will go at {at}.", draft.subject);
let said = format!("{} goes {}.", draft.subject, day_and_hour(at));
change(&later, cx, |writing| writing.said = Some(said.into()))
})
.on_discard(move |_, cx| {
@@ -164,7 +157,9 @@ pub fn schedule(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.child(
ScheduleSend::new("mail-schedule")
.on_send(move |_, cx| set(&sent, Some("Sent now.".into()), cx))
.on_schedule(move |at, _, cx| set(&later, Some(format!("Goes at {at}.").into()), cx)),
.on_schedule(move |at, _, cx| {
set(&later, Some(format!("Goes {}.", day_and_hour(at)).into()), cx)
}),
)
.children(shown.map(|shown| quiet(shown, cx))),
))
+2 -2
View File
@@ -85,8 +85,8 @@ pub use multi::MultiSelect;
pub use number::{NumberInput, ScrubInput};
pub use options::Choice;
pub(crate) use options::{
OnFlag, OnNumber, OnValue, OnValues, Pick, Run, float, float_height, reveal, revealer, step,
surface,
OnFlag, OnNumber, OnValue, OnValues, Pick, Run, float, float_height, marked_row, reveal,
revealer, step, surface,
};
pub use pairs::{FieldArray, KeyValueInput, ListInput};
pub use path::PathInput;
+13
View File
@@ -87,6 +87,18 @@ pub(crate) fn option_row(
highlighted: bool,
checked: Option<bool>,
cx: &App,
) -> Stateful<Div> {
marked_row(id, choice, None, highlighted, checked, cx)
}
/// An option row with `mark`, such as a label's color, before the label.
pub(crate) fn marked_row(
id: impl Into<ElementId>,
choice: &Choice,
mark: Option<AnyElement>,
highlighted: bool,
checked: Option<bool>,
cx: &App,
) -> Stateful<Div> {
let theme = cx.theme();
let colors = &theme.colors;
@@ -128,6 +140,7 @@ pub(crate) fn option_row(
.when_some(choice.icon, |row, icon| {
row.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
})
.children(mark)
.child(
div()
.flex_1()
+2 -13
View File
@@ -5,6 +5,7 @@ use gpui::{
Styled, Window, div,
};
use super::labels::hue_dot;
use crate::{
data_display::CountBadge,
forms::OnValue,
@@ -66,19 +67,7 @@ impl Mailbox {
.size(IconSize::Sm)
.color(colors.fg_muted)
.into_any_element(),
Mark::Hue(hue) => {
let tint = *colors
.chart
.get(hue)
.unwrap_or_else(|| panic!("label {}: no chart hue {hue}", self.name));
div()
.size(theme.icon_size(IconSize::Sm))
.flex()
.items_center()
.justify_center()
.child(div().size(theme.status_dot()).rounded_full().bg(tint))
.into_any_element()
}
Mark::Hue(hue) => hue_dot(&self.name, hue, cx).into_any_element(),
};
let row = ListItem::new((id.clone(), format!("box-{}", self.key)), self.name.clone())
.leading(mark)
+290
View File
@@ -0,0 +1,290 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Context, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement,
Styled, Window, div, prelude::*,
};
use crate::{
forms::{
Choice, Down, Enter, OnValue, OnValues, Pick, SearchInput, TextInput, Up, marked_row,
reveal, revealer,
},
layout::on_axis,
primitives::IconName,
theme::{ActiveTheme, IconSize, TextSize},
};
/// A label: its key, its name, and its hue among the theme's chart colors.
#[derive(Clone, Debug, PartialEq)]
pub struct Label {
pub key: SharedString,
pub name: SharedString,
pub hue: usize,
}
impl Label {
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>, hue: usize) -> Self {
Self {
key: key.into(),
name: name.into(),
hue,
}
}
}
/// Label `name`'s dot in the chart color at `hue`, centered in an icon's box.
pub(super) fn hue_dot(name: &str, hue: usize, cx: &App) -> Div {
let theme = cx.theme();
let tint = *theme
.colors
.chart
.get(hue)
.unwrap_or_else(|| panic!("label {name}: no chart hue {hue}"));
div()
.flex_none()
.size(theme.icon_size(IconSize::Sm))
.flex()
.items_center()
.justify_center()
.child(div().size(theme.status_dot()).rounded_full().bg(tint))
}
/// The search field, the row under the keyboard, the query it was set for, and the list's scroll.
struct Finding {
search: Entity<TextInput>,
at: usize,
query: String,
revealed: Option<usize>,
scroll: ScrollHandle,
}
/// Moves the row under the keyboard by `by` among `count`, wrapping.
fn step(finding: &Entity<Finding>, count: usize, by: isize, cx: &mut App) {
if count == 0 {
return;
}
finding.update(cx, |finding, cx| {
let at = finding.at.min(count - 1) as isize;
finding.at = (at + by).rem_euclid(count as isize) as usize;
cx.notify();
})
}
/// Labels to put on a message: the field finds them by name, arrows move between them, and a press or Enter turns one on or off. With `on_create`, a name no label has can be made into one.
#[derive(IntoElement)]
pub struct LabelPicker {
id: ElementId,
labels: Vec<Label>,
selected: Vec<SharedString>,
on_change: Option<OnValues>,
on_create: Option<OnValue>,
}
impl LabelPicker {
pub fn new(id: impl Into<ElementId>, labels: impl IntoIterator<Item = Label>) -> Self {
let labels: Vec<Label> = labels.into_iter().collect();
for (ix, label) in labels.iter().enumerate() {
let before = &labels[..ix];
let key = before.iter().any(|other| other.key == label.key);
assert!(!key, "label {} twice", label.key);
let name = label.name.to_lowercase();
let named = before.iter().any(|other| other.name.to_lowercase() == name);
assert!(!named, "label name {} twice", label.name);
}
Self {
id: id.into(),
labels,
selected: Vec::new(),
on_change: None,
on_create: None,
}
}
/// The keys of the labels on.
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.selected = keys.into_iter().map(Into::into).collect();
self
}
/// Gets the keys on, in the labels' order, each time one turns on or off.
pub fn on_change(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
/// Offers to make a label of a name no label has, and gets the name. The owner adds the label, on if it should be.
pub fn on_create(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_create = Some(Rc::new(handler));
self
}
}
impl RenderOnce for LabelPicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
for key in &self.selected {
let known = self.labels.iter().any(|label| label.key == *key);
assert!(known, "label picker {:?}: no label {key}", self.id);
}
let finding = window.use_keyed_state(
(self.id.clone(), "finding"),
cx,
|window, cx: &mut Context<Finding>| Finding {
search: cx.new(|cx| TextInput::new(window, cx).placeholder("Find or make a label")),
at: 0,
query: String::new(),
revealed: None,
scroll: ScrollHandle::new(),
},
);
let search = finding.read(cx).search.clone();
let query = search.read(cx).text().trim().to_string();
if finding.read(cx).query != query {
finding.update(cx, |finding, _| {
finding.query = query.clone();
finding.at = 0;
finding.scroll.set_offset(Point::default());
});
}
let lower = query.to_lowercase();
let fits: Vec<&Label> = self
.labels
.iter()
.filter(|label| label.name.to_lowercase().contains(&lower))
.collect();
let fresh = self.on_create.is_some()
&& !query.is_empty()
&& !self
.labels
.iter()
.any(|label| label.name.to_lowercase() == lower);
let count = fits.len() + usize::from(fresh);
let at = finding.read(cx).at.min(count.saturating_sub(1));
let keyed = search.read(cx).focus().is_focused(window);
let shown = reveal(&finding, |finding| &mut finding.revealed, count > 0, at, cx);
let scroll = finding.read(cx).scroll.clone();
let selected = Rc::new(self.selected);
let pick: Pick = {
let keys: Vec<SharedString> = fits.iter().map(|label| label.key.clone()).collect();
let all: Vec<SharedString> =
self.labels.iter().map(|label| label.key.clone()).collect();
let (id, selected, search) = (self.id.clone(), selected.clone(), search.clone());
let (on_change, on_create) = (self.on_change, self.on_create.clone());
let name = SharedString::from(query.clone());
Rc::new(move |ix, window, cx| {
let Some(key) = keys.get(ix) else {
log::info!("label picker {id:?}: create {name}");
search.update(cx, |input, cx| input.set_text(String::new(), cx));
if let Some(on_create) = &on_create {
on_create(&name, window, cx);
}
return;
};
let on = selected.contains(key);
log::info!(
"label picker {id:?}: {key} {}",
if on { "off" } else { "on" }
);
let next: Vec<SharedString> = all
.iter()
.filter(|each| {
if *each == key {
!on
} else {
selected.contains(each)
}
})
.cloned()
.collect();
if let Some(on_change) = &on_change {
on_change(&next, window, cx);
}
})
};
let row = |ix: usize, choice: Choice, mark: Option<AnyElement>, on: bool| {
let pick = pick.clone();
let key = choice.value.clone();
marked_row(
(self.id.clone(), format!("label-{key}")),
&choice,
mark,
keyed && ix == at,
Some(on),
cx,
)
.debug_selector(move || format!("label {key}"))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| pick(ix, window, cx))
};
let mut rows: Vec<AnyElement> = fits
.iter()
.enumerate()
.map(|(ix, label)| {
let choice = Choice::new(label.key.clone(), label.name.clone());
let dot = hue_dot(&label.name, label.hue, cx).into_any_element();
row(ix, choice, Some(dot), selected.contains(&label.key)).into_any_element()
})
.collect();
if fresh {
let choice = Choice::new("+create", format!("Create “{query}”")).icon(IconName::Plus);
rows.push(row(fits.len(), choice, None, false).into_any_element());
}
let theme = cx.theme();
let none = rows.is_empty().then(|| {
div()
.px_2()
.py_2()
.text_color(theme.colors.fg_subtle)
.child(match (self.labels.is_empty(), self.on_create.is_some()) {
(true, true) => "No labels yet. Type a name to make one.",
(true, false) => "No labels yet.",
(false, _) => "No label fits.",
})
});
let (up, down, chosen) = (finding.clone(), finding.clone(), finding);
div()
.id(self.id.clone())
.w_full()
.flex()
.flex_col()
.gap_2()
.capture_action(move |_: &Up, _, cx| {
cx.stop_propagation();
step(&up, count, -1, cx);
})
.capture_action(move |_: &Down, _, cx| {
cx.stop_propagation();
step(&down, count, 1, cx);
})
.capture_action(move |_: &Enter, window, cx| {
if count > 0 {
cx.stop_propagation();
let at = chosen.read(cx).at.min(count - 1);
pick(at, window, cx);
}
})
.child(SearchInput::new((self.id.clone(), "search"), &search))
.child(
on_axis(
div()
.id((self.id.clone(), "labels"))
.track_scroll(&scroll)
.max_h(theme.list_max_height())
.overflow_y_scroll(),
)
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.children(rows)
.children(none)
.children(shown.map(|(ix, done)| revealer(&scroll, ix, done))),
)
}
}
+5
View File
@@ -1,20 +1,25 @@
mod boxes;
mod compose;
mod labels;
mod list;
mod quoted;
mod reader;
mod send;
mod signature;
mod snooze;
mod thread;
mod times;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use boxes::{Mailbox, MailboxList};
pub use compose::{Draft, MailComposer};
pub use labels::{Label, LabelPicker};
pub use list::{Mail, MailItem, MailList};
pub use quoted::QuotedText;
pub use reader::{Contact, MailReader, Message};
pub use send::ScheduleSend;
pub use signature::SignatureEditor;
pub use snooze::SnoozePicker;
pub use thread::MailThreadView;
+50 -113
View File
@@ -1,49 +1,27 @@
use std::rc::Rc;
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Window, div, prelude::*};
use jiff::{Timestamp, ToSpan, civil::DateTime, tz::TimeZone};
use jiff::{Timestamp, tz::TimeZone};
use super::times::{
Ask, OnTime, at_hour, distinct, named, next_monday, pick_item, picking, time_dialog,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{DateTimePicker, Run},
forms::Run,
menus::{Menu, MenuItem, SplitButton},
overlays::Dialog,
primitives::IconName,
typography::format,
};
pub(super) type OnTime = Rc<dyn Fn(Timestamp, &mut Window, &mut App)>;
/// Times to send later from `now` in `zone`: tomorrow at 8:00 and at 13:00, and the next Monday at 8:00, each named with its day and hour.
fn presets(now: Timestamp, zone: &TimeZone) -> [(String, Timestamp); 3] {
/// Times to send later from `now` in `zone`: tomorrow at 8:00 and at 13:00, and the next Monday at 8:00 unless tomorrow is one, each named with its day and hour.
fn presets(now: Timestamp, zone: &TimeZone) -> Vec<(String, Timestamp)> {
let today = now.to_zoned(zone.clone()).date();
let tomorrow = today.tomorrow().expect("a day follows today");
let ahead = 7 - i64::from(today.weekday().to_monday_zero_offset());
let monday = today
.checked_add(ahead.days())
.expect("a Monday lies ahead");
let at = |day: jiff::civil::Date, hour: i8| {
day.at(hour, 0, 0, 0)
.to_zoned(zone.clone())
.expect("the zone holds the hour")
.timestamp()
};
let named = |name: &str, when: Timestamp| {
let hour = format::datetime(when, zone, "%a %H:%M").expect("a fixed pattern formats");
(format!("{name} · {hour}"), when)
};
[
named("Tomorrow morning", at(tomorrow, 8)),
named("Tomorrow afternoon", at(tomorrow, 13)),
named("Monday morning", at(monday, 8)),
]
}
/// Whether the dialog for a time of one's own shows, and the time picked in it.
#[derive(Default)]
struct Picking {
open: bool,
chosen: Option<DateTime>,
distinct([
named("Tomorrow morning", at_hour(tomorrow, 8, zone), zone),
named("Tomorrow afternoon", at_hour(tomorrow, 13, zone), zone),
named("Monday morning", at_hour(next_monday(today), 8, zone), zone),
])
}
/// Send, and beside it the times to send later: tomorrow morning, tomorrow afternoon and Monday morning in the zone given, or a time picked in a dialog. Each only with its handler; disabled, only Send shows, dimmed.
@@ -99,8 +77,7 @@ impl RenderOnce for ScheduleSend {
let zone = self
.zone
.unwrap_or_else(|| format::system_zone("schedule send"));
let picking =
window.use_keyed_state((self.id.clone(), "picking"), cx, |_, _| Picking::default());
let picking = picking(&self.id, window, cx);
let menu = self
.on_schedule
.clone()
@@ -116,18 +93,9 @@ impl RenderOnce for ScheduleSend {
}))
},
);
let opening = picking.clone();
presets.separator().item(
MenuItem::new("Pick a time…")
.icon(IconName::Calendar)
.on_click(move |_, cx| {
log::info!("schedule send: pick a time");
opening.update(cx, |picking, cx| {
picking.open = true;
cx.notify();
})
}),
)
presets
.separator()
.item(pick_item("schedule send", &picking))
});
let send = self.on_send.clone();
let button = match menu {
@@ -151,56 +119,18 @@ impl RenderOnce for ScheduleSend {
})
.into_any_element(),
};
let now = picking.read(cx);
let dialog = (now.open).then(|| {
let chosen = now.chosen;
let (closing, choosing, id) = (picking.clone(), picking.clone(), self.id.clone());
let schedule = self.on_schedule.clone();
Dialog::new(
(self.id.clone(), "dialog"),
"Schedule send",
move |_, cx| {
closing.update(cx, |picking, cx| {
*picking = Picking::default();
cx.notify();
})
},
)
.detail("Pick the day and the hour it goes.")
.child(
DateTimePicker::new((id.clone(), "when"), chosen).on_change(move |at, _, cx| {
choosing.update(cx, |picking, cx| {
picking.chosen = Some(at);
cx.notify();
})
}),
)
.action(move |close| {
Button::new((id.clone(), "cancel"), "Cancel")
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| close(window, cx))
})
.action({
let (id, zone) = (self.id.clone(), zone.clone());
move |close| {
Button::new((id, "schedule"), "Schedule")
.variant(ButtonVariant::Primary)
.disabled(chosen.is_none())
.on_click(move |_, window, cx| {
let at = chosen
.expect("Schedule waits for a time")
.to_zoned(zone.clone())
.expect("the zone holds the time")
.timestamp();
log::info!("schedule send: at {at}");
close(window, cx);
if let Some(schedule) = &schedule {
schedule(at, window, cx);
}
})
}
})
});
let dialog = time_dialog(
&self.id,
&picking,
Ask {
title: "Schedule send",
detail: "Pick the day and the hour it goes.",
action: "Schedule",
},
&zone,
self.on_schedule.clone(),
cx,
);
div().flex_none().child(button).children(dialog)
}
}
@@ -212,37 +142,44 @@ mod tests {
use super::*;
fn at(day: i8, hour: i8) -> Timestamp {
date(2026, 9, day)
.at(hour, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp()
at_hour(date(2026, 9, day), hour, &TimeZone::UTC)
}
#[test]
fn presets_fall_tomorrow_and_on_the_next_monday() {
let [(morning, a), (afternoon, b), (monday, c)] = presets(at(26, 12), &TimeZone::UTC);
let [(morning, a), (afternoon, b), (monday, c)] = &presets(at(26, 12), &TimeZone::UTC)[..]
else {
panic!("three times from a Saturday");
};
assert_eq!(
(morning.as_str(), a),
(morning.as_str(), *a),
("Tomorrow morning · Sun 08:00", at(27, 8))
);
assert_eq!(
(afternoon.as_str(), b),
(afternoon.as_str(), *b),
("Tomorrow afternoon · Sun 13:00", at(27, 13))
);
assert_eq!(
(monday.as_str(), c),
(monday.as_str(), *c),
("Monday morning · Mon 08:00", at(28, 8))
);
let [_, _, (_, next)] = presets(at(28, 9), &TimeZone::UTC);
let next = presets(at(28, 9), &TimeZone::UTC).pop().map(|(_, at)| at);
assert_eq!(
next,
date(2026, 10, 5)
.at(8, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp(),
Some(at_hour(date(2026, 10, 5), 8, &TimeZone::UTC)),
"on a Monday, the next one"
);
let sunday: Vec<String> = presets(at(27, 12), &TimeZone::UTC)
.into_iter()
.map(|(name, _)| name)
.collect();
assert_eq!(
sunday,
[
"Tomorrow morning · Mon 08:00",
"Tomorrow afternoon · Mon 13:00"
],
"on a Sunday, tomorrow morning is Monday morning"
);
}
}
+186
View File
@@ -0,0 +1,186 @@
use std::rc::Rc;
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Window, div, prelude::*};
use jiff::{Timestamp, civil::Weekday, tz::TimeZone};
use super::times::{
Ask, OnTime, at_hour, distinct, named, next_monday, pick_item, picking, time_dialog,
};
use crate::{
menus::{DropdownMenu, Menu, MenuItem},
primitives::IconName,
typography::format,
};
/// Times to snooze until from `now` in `zone`, each named with its day and hour: later today at 18:00 while before 17:00, tomorrow at 8:00, Saturday at 8:00 on a weekday, and the next Monday at 8:00. A moment comes once.
pub(super) fn snoozes(now: Timestamp, zone: &TimeZone) -> Vec<(String, Timestamp)> {
let here = now.to_zoned(zone.clone());
let today = here.date();
let tomorrow = today.tomorrow().expect("a day follows today");
let workday = !matches!(today.weekday(), Weekday::Saturday | Weekday::Sunday);
let times = [
(here.hour() < 17).then(|| named("Later today", at_hour(today, 18, zone), zone)),
Some(named("Tomorrow", at_hour(tomorrow, 8, zone), zone)),
workday.then(|| {
let saturday = today
.nth_weekday(1, Weekday::Saturday)
.expect("a Saturday lies ahead");
named("This weekend", at_hour(saturday, 8, zone), zone)
}),
Some(named(
"Next week",
at_hour(next_monday(today), 8, zone),
zone,
)),
];
distinct(times.into_iter().flatten())
}
/// A menu of times to put a message off until: later today, tomorrow, this weekend, next week, or a day and hour picked in a dialog, in the zone given.
#[derive(IntoElement)]
pub struct SnoozePicker {
id: ElementId,
zone: Option<TimeZone>,
on_snooze: Option<OnTime>,
}
impl SnoozePicker {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
zone: None,
on_snooze: None,
}
}
/// The zone its times read in; the system's otherwise.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
/// Gets the moment the message comes back.
pub fn on_snooze(
mut self,
handler: impl Fn(Timestamp, &mut Window, &mut App) + 'static,
) -> Self {
self.on_snooze = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SnoozePicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let zone = self
.zone
.unwrap_or_else(|| format::system_zone("snooze picker"));
let picking = picking(&self.id, window, cx);
let menu = snoozes(Timestamp::now(), &zone)
.into_iter()
.fold(Menu::new(), |menu, (name, at)| {
let on_snooze = self.on_snooze.clone();
menu.item(MenuItem::new(name).on_click(move |window, cx| {
log::info!("snooze picker: until {at}");
if let Some(on_snooze) = &on_snooze {
on_snooze(at, window, cx);
}
}))
})
.separator()
.item(pick_item("snooze picker", &picking));
let dialog = time_dialog(
&self.id,
&picking,
Ask {
title: "Snooze until",
detail: "Pick the day and the hour it comes back.",
action: "Snooze",
},
&zone,
self.on_snooze.clone(),
cx,
);
div()
.flex_none()
.child(DropdownMenu::new(self.id.clone(), "Snooze", menu).icon(IconName::Clock))
.children(dialog)
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::*;
fn at(day: i8, hour: i8) -> Timestamp {
at_hour(date(2026, 10, day), hour, &TimeZone::UTC)
}
fn names(now: Timestamp) -> Vec<String> {
snoozes(now, &TimeZone::UTC)
.into_iter()
.map(|(name, _)| name)
.collect()
}
#[test]
fn a_weekday_morning_offers_later_today_tomorrow_the_weekend_and_next_week() {
let times: Vec<Timestamp> = snoozes(at(7, 9), &TimeZone::UTC)
.into_iter()
.map(|(_, at)| at)
.collect();
assert_eq!(
names(at(7, 9)),
[
"Later today · Wed 18:00",
"Tomorrow · Thu 08:00",
"This weekend · Sat 08:00",
"Next week · Mon 08:00",
]
);
assert_eq!(times, [at(7, 18), at(8, 8), at(10, 8), at(12, 8)]);
}
#[test]
fn late_in_the_day_and_at_the_weekend_the_near_times_step_aside() {
assert_eq!(
names(at(7, 17)),
[
"Tomorrow · Thu 08:00",
"This weekend · Sat 08:00",
"Next week · Mon 08:00",
],
"from 17:00, later today is too near"
);
assert_eq!(
names(at(10, 9)),
[
"Later today · Sat 18:00",
"Tomorrow · Sun 08:00",
"Next week · Mon 08:00",
],
"on a Saturday, the weekend is here"
);
assert_eq!(
names(at(9, 9)),
[
"Later today · Fri 18:00",
"Tomorrow · Sat 08:00",
"Next week · Mon 08:00",
],
"on a Friday, tomorrow is the weekend"
);
assert_eq!(
names(at(4, 9)),
["Later today · Sun 18:00", "Tomorrow · Mon 08:00"],
"on a Sunday, tomorrow is next week"
);
let last = snoozes(at(12, 9), &TimeZone::UTC).pop();
assert_eq!(
last.map(|(_, at)| at),
Some(at(19, 8)),
"on a Monday, the next one"
);
}
}
+1
View File
@@ -11,6 +11,7 @@ use crate::{
};
mod read;
mod sort;
mod write;
fn setup(cx: &mut TestAppContext) {
+168
View File
@@ -0,0 +1,168 @@
use gpui::{
Context, Entity, IntoElement, Modifiers, ParentElement, Render, SharedString, Styled,
TestAppContext, VisualTestContext, Window, div, px,
};
use jiff::{Timestamp, tz::TimeZone};
use super::{Mailing, heard, mailing, note, press, settle, setup, tab_to};
use crate::{
forms,
mail::{Label, LabelPicker, SnoozePicker, snooze::snoozes},
};
/// Labels the host keeps, the keys on, and what it heard.
struct Labeling {
labels: Vec<Label>,
on: Vec<SharedString>,
heard: Vec<String>,
}
impl Render for Labeling {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (changing, making) = (cx.entity(), cx.entity());
div().w(px(320.0)).child(
LabelPicker::new("labels", self.labels.clone())
.selected(self.on.clone())
.on_change(move |keys, _, cx| {
changing.update(cx, |host, cx| {
host.heard.push(format!("on [{}]", keys.join(" ")));
host.on = keys.to_vec();
cx.notify();
})
})
.on_create(move |name, _, cx| {
making.update(cx, |host, cx| {
host.heard.push(format!("make {name}"));
let key = SharedString::from(name.to_lowercase());
let hue = host.labels.len();
host.labels.push(Label::new(key.clone(), name.clone(), hue));
host.on.push(key);
cx.notify();
})
}),
)
}
}
fn labeling(cx: &mut TestAppContext) -> (Entity<Labeling>, &mut VisualTestContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Labeling {
labels: vec![
Label::new("clients", "Clients", 0),
Label::new("finance", "Finance", 1),
Label::new("press", "Press", 2),
Label::new("travel", "Travel", 3),
],
on: vec!["press".into()],
heard: Vec::new(),
});
cx.update(|window, cx| {
window.activate_window();
forms::bind_keys(cx);
});
settle(cx);
tab_to(1, cx);
(host, cx)
}
fn told(host: &Entity<Labeling>, cx: &mut VisualTestContext) -> Vec<String> {
host.read_with(cx, |host, _| host.heard.clone())
}
fn find(words: &str, cx: &mut VisualTestContext) {
cx.simulate_input(words);
settle(cx);
}
#[gpui::test]
fn typing_narrows_the_labels_and_enter_turns_the_first_on(cx: &mut TestAppContext) {
let (host, cx) = labeling(cx);
find("fin", cx);
press("enter", cx);
assert_eq!(told(&host, cx), ["on [finance press]"]);
}
#[gpui::test]
fn arrows_wrap_and_a_press_turns_a_label_off(cx: &mut TestAppContext) {
let (host, cx) = labeling(cx);
press("up", cx);
press("enter", cx);
let row = cx
.debug_bounds("label press")
.expect("the press label draws");
cx.simulate_click(row.center(), Modifiers::none());
settle(cx);
assert_eq!(told(&host, cx), ["on [press travel]", "on [travel]"]);
}
#[gpui::test]
fn a_name_no_label_has_makes_one_and_the_field_starts_over(cx: &mut TestAppContext) {
let (host, cx) = labeling(cx);
find("Suppliers", cx);
press("enter", cx);
press("enter", cx);
assert_eq!(
told(&host, cx),
["make Suppliers", "on [clients press suppliers]"],
"with the field empty again, Enter takes the first label"
);
}
#[gpui::test]
fn a_name_a_label_has_is_not_offered_again(cx: &mut TestAppContext) {
let (host, cx) = labeling(cx);
find("PRESS", cx);
press("down", cx);
press("enter", cx);
assert_eq!(told(&host, cx), ["on []"], "Press is the only row");
}
#[test]
#[should_panic(expected = "label name clients twice")]
fn a_label_name_is_listed_once() {
let _ = LabelPicker::new(
"labels",
[Label::new("a", "Clients", 0), Label::new("b", "clients", 1)],
);
}
#[gpui::test]
#[should_panic(expected = "no label ghost")]
fn what_is_on_is_a_label(cx: &mut TestAppContext) {
let _ = mailing(
|_| {
LabelPicker::new("labels", [Label::new("a", "A", 0)])
.selected(["ghost"])
.into_any_element()
},
cx,
);
}
fn snoozing(owner: Entity<Mailing>) -> gpui::AnyElement {
SnoozePicker::new("snooze")
.zone(TimeZone::UTC)
.on_snooze(move |at, _, cx| note(&owner, format!("until {at}"), cx))
.into_any_element()
}
#[gpui::test]
fn the_first_time_offered_puts_the_message_off(cx: &mut TestAppContext) {
let (host, cx) = mailing(snoozing, cx);
tab_to(1, cx);
press("enter", cx);
press("enter", cx);
let (_, first) = snoozes(Timestamp::now(), &TimeZone::UTC).remove(0);
assert_eq!(heard(&host, cx), [format!("until {first}")]);
}
#[gpui::test]
fn the_last_row_asks_for_a_time_of_ones_own(cx: &mut TestAppContext) {
let (host, cx) = mailing(snoozing, cx);
tab_to(1, cx);
press("enter", cx);
press("up", cx);
press("enter", cx);
assert!(cx.debug_bounds("time-dialog").is_some(), "the dialog shows");
assert!(heard(&host, cx).is_empty(), "no time yet");
}
+157
View File
@@ -0,0 +1,157 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, InteractiveElement, ParentElement, SharedString, Styled, Window, div,
};
use jiff::{
Timestamp, ToSpan,
civil::{Date, DateTime},
tz::TimeZone,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{DateTimePicker, FormError},
menus::MenuItem,
overlays::Dialog,
primitives::IconName,
typography::format,
};
pub(super) type OnTime = Rc<dyn Fn(Timestamp, &mut Window, &mut App)>;
/// `hour`:00 on `day` in `zone`.
pub(super) fn at_hour(day: Date, hour: i8, zone: &TimeZone) -> Timestamp {
day.at(hour, 0, 0, 0)
.to_zoned(zone.clone())
.expect("the zone holds the hour")
.timestamp()
}
/// The first Monday after `day`: a week on when `day` is a Monday.
pub(super) fn next_monday(day: Date) -> Date {
let ahead = 7 - i64::from(day.weekday().to_monday_zero_offset());
day.checked_add(ahead.days()).expect("a Monday lies ahead")
}
/// `name` beside the day and hour of `at` in `zone`, as a menu row shows a time.
pub(super) fn named(name: &str, at: Timestamp, zone: &TimeZone) -> (String, Timestamp) {
let hour = format::datetime(at, zone, "%a %H:%M").expect("a fixed pattern formats");
(format!("{name} · {hour}"), at)
}
/// `times` less each moment an earlier one names, as tomorrow names next Monday on a Sunday.
pub(super) fn distinct(
times: impl IntoIterator<Item = (String, Timestamp)>,
) -> Vec<(String, Timestamp)> {
let mut kept: Vec<(String, Timestamp)> = Vec::new();
for (name, at) in times {
if kept.iter().all(|(_, seen)| *seen != at) {
kept.push((name, at));
}
}
kept
}
/// Whether the dialog for a time of one's own shows, and the time picked in it.
#[derive(Default)]
pub(super) struct Picking {
open: bool,
chosen: Option<DateTime>,
}
pub(super) fn picking(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Picking> {
window.use_keyed_state((id.clone(), "picking"), cx, |_, _| Picking::default())
}
/// The menu row that opens the dialog.
pub(super) fn pick_item(owner: &'static str, picking: &Entity<Picking>) -> MenuItem {
let picking = picking.clone();
MenuItem::new("Pick a time…")
.icon(IconName::Calendar)
.on_click(move |_, cx| {
log::info!("{owner}: pick a time");
picking.update(cx, |picking, cx| {
picking.open = true;
cx.notify();
})
})
}
/// What the dialog asks, and the action that takes its time.
pub(super) struct Ask {
pub title: &'static str,
pub detail: &'static str,
pub action: &'static str,
}
/// While open, a dialog to pick a day and an hour in `zone`. Its action waits for a time ahead of now, then closes and hands the time on.
pub(super) fn time_dialog(
id: &ElementId,
picking: &Entity<Picking>,
ask: Ask,
zone: &TimeZone,
on_time: Option<OnTime>,
cx: &App,
) -> Option<Dialog> {
let now = picking.read(cx);
if !now.open {
return None;
}
let chosen = now.chosen;
let at = chosen.map(|chosen| {
chosen
.to_zoned(zone.clone())
.expect("the zone holds the time")
.timestamp()
});
let ahead = at.filter(|at| *at > Timestamp::now());
let (closing, choosing, id) = (picking.clone(), picking.clone(), id.clone());
let late = (at.is_some() && ahead.is_none())
.then(|| FormError::new((id.clone(), "late"), "That time has passed."));
let dialog = Dialog::new((id.clone(), "dialog"), ask.title, move |_, cx| {
closing.update(cx, |picking, cx| {
*picking = Picking::default();
cx.notify();
})
})
.detail(ask.detail)
.child(
div()
.debug_selector(|| "time-dialog".into())
.flex()
.flex_col()
.gap_2()
.child(
DateTimePicker::new((id.clone(), "when"), chosen).on_change(move |at, _, cx| {
choosing.update(cx, |picking, cx| {
picking.chosen = Some(at);
cx.notify();
})
}),
)
.children(late),
)
.action({
let id = id.clone();
move |close| {
Button::new((id, "cancel"), "Cancel")
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| close(window, cx))
}
})
.action(move |close| {
Button::new((id.clone(), "take"), SharedString::from(ask.action))
.variant(ButtonVariant::Primary)
.disabled(ahead.is_none())
.on_click(move |_, window, cx| {
let at = ahead.expect("the action waits for a time ahead");
log::info!("time dialog {id:?}: {at}");
close(window, cx);
if let Some(on_time) = &on_time {
on_time(at, window, cx);
}
})
});
Some(dialog)
}
+2 -2
View File
@@ -382,8 +382,8 @@
## T29d Mail · Sorting
- [ ] LabelPicker
- [ ] SnoozePicker
- [x] LabelPicker
- [x] SnoozePicker
## T30 Calendar — `src/calendar`