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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 01:16:24 -04:00
1 parent 154f7f2b46
commit 5b0a8443a7
16 files changed
+1037 -144

No files matched your search

+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)
}