Mail · Writing: the composer, recipients that suggest, a signature, send later

MailComposer writes a message: To, and Cc and Bcc once asked for, as
tag fields that suggest contacts and mark what is not an address; the
subject and the words; the files, each with a way off; the signature
under the words. Send wakes once there is someone to send to and every
address is one; it hands over the draft now or at a time, and Discard
lets it go. A new start from the owner begins the draft over.

forms::TagInput now suggests: the rows that hold the typed words float
under it through the list Combobox uses; Up, Down and Enter pick, and
Escape sets them aside. RecipientInput points to it. ScheduleSend
offers tomorrow morning, tomorrow afternoon, Monday morning, or a day
and hour picked in a dialog. SignatureEditor writes the lines and shows
how they sit under a message; it tells the owner's echo from a new
signature, so the caret stays.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 00:27:23 -04:00
1 parent 1ad72bc7aa
commit ccd48f9ba2
14 files changed
+1425 -12

No files matched your search

+107 -5
View File
@@ -5,7 +5,13 @@ use gpui::{
RenderOnce, SharedString, Styled, Subscription, Window, div, prelude::*,
};
use super::{InputEvent, TextInput, text::Backspace};
use super::{
Choice, InputEvent, TextInput,
combobox::matching,
options::{Pick, Popup, step},
select::{Picker, measure_anchor},
text::{Backspace, Down, Enter, Up},
};
use crate::{
data_display::{Tag, Tone},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
@@ -17,6 +23,7 @@ struct Tags {
input: Entity<TextInput>,
tags: Vec<SharedString>,
on_change: Option<OnChange>,
dismissed: bool,
_events: Subscription,
}
@@ -31,12 +38,17 @@ impl Tags {
.trim_end_matches(',')
.trim()
.to_string();
self.add(typed, window, cx);
}
/// Adds `value` as a tag and clears the field, unless blank or already there.
fn add(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |input, cx| input.set_text("", cx));
if typed.is_empty() || self.tags.iter().any(|tag| tag.as_ref() == typed) {
if value.is_empty() || self.tags.iter().any(|tag| tag.as_ref() == value) {
return;
}
let mut next = self.tags.clone();
next.push(typed.into());
next.push(value.into());
log::info!("tag input: {} tags", next.len());
if let Some(on_change) = self.on_change.clone() {
on_change(next, window, cx);
@@ -44,13 +56,14 @@ impl Tags {
}
}
/// A chip per tag, then a field. Enter or a comma adds; Backspace on empty drops the last.
/// A chip per tag, then a field. Enter or a comma adds; Backspace on empty drops the last. With suggestions, the rows that hold the typed words float under it: Up and Down move, Enter or a press adds one, Escape sets them aside.
#[derive(IntoElement)]
pub struct TagInput {
id: ElementId,
tags: Vec<SharedString>,
placeholder: SharedString,
check: Option<fn(&str) -> bool>,
suggestions: Vec<Choice>,
on_change: Option<OnChange>,
}
@@ -64,6 +77,7 @@ impl TagInput {
tags: tags.into_iter().map(Into::into).collect(),
placeholder: "Add a tag".into(),
check: None,
suggestions: Vec::new(),
on_change: None,
}
}
@@ -79,6 +93,12 @@ impl TagInput {
self
}
/// Rows to offer as you type, each adding its value; a chip shows the label of the row whose value it holds.
pub fn suggestions(mut self, choices: impl IntoIterator<Item = Choice>) -> Self {
self.suggestions = choices.into_iter().collect();
self
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<SharedString>, &mut Window, &mut App) + 'static,
@@ -95,6 +115,9 @@ impl RenderOnce for TagInput {
window.use_keyed_state(self.id.clone(), cx, |window, cx: &mut Context<Tags>| {
let input = cx.new(|cx| TextInput::new(window, cx).placeholder(placeholder));
let events = cx.subscribe_in(&input, window, |tags, input, event, window, cx| {
if *event == InputEvent::Changed {
tags.dismissed = false;
}
let comma =
*event == InputEvent::Changed && input.read(cx).text().ends_with(',');
if comma || *event == InputEvent::Submit {
@@ -105,6 +128,7 @@ impl RenderOnce for TagInput {
input,
tags: Vec::new(),
on_change: None,
dismissed: false,
_events: events,
}
});
@@ -115,13 +139,47 @@ impl RenderOnce for TagInput {
let input = state.read(cx).input.clone();
let focused = input.read(cx).focus().is_focused(window);
let focus = input.read(cx).focus().clone();
let picker =
window.use_keyed_state((self.id.clone(), "picker"), cx, |_, _| Picker::default());
let typed = input.read(cx).text().trim().to_string();
let rows: Rc<Vec<Choice>> = Rc::new(match typed.is_empty() {
true => Vec::new(),
false => matching(&self.suggestions, &typed)
.into_iter()
.filter(|row| !self.tags.contains(&row.value))
.collect(),
});
let open = focused && !state.read(cx).dismissed && !rows.is_empty();
if open != picker.read(cx).open {
Picker::show(&picker, open, 0, cx);
}
let kept = picker.read(cx).highlighted;
let highlighted = if kept < rows.len() { kept } else { 0 };
let (anchor, scroll) = (picker.read(cx).anchor, picker.read(cx).scroll.clone());
let reveal = Picker::reveal(&picker, open, highlighted, cx);
let pick: Pick = {
let (rows, state) = (rows.clone(), state.clone());
Rc::new(move |ix, window, cx| {
let value = rows[ix].value.to_string();
log::info!("tag input: suggestion {value}");
state.update(cx, |tags, cx| tags.add(value, window, cx));
})
};
let (up, down, enter, escape) =
(picker.clone(), picker.clone(), pick.clone(), state.clone());
let (up_rows, down_rows) = (rows.clone(), rows.clone());
let theme = cx.theme();
let colors = &theme.colors;
let owner = self.id.clone();
let chips = self.tags.iter().enumerate().map(|(ix, tag)| {
let (tags, on_change) = (self.tags.clone(), self.on_change.clone());
let failed = self.check.is_some_and(|check| !check(tag));
Tag::new((owner.clone(), format!("tag-{ix}")), tag.clone())
let shown = self
.suggestions
.iter()
.find(|row| row.value == *tag)
.map_or(tag.clone(), |row| row.label.clone());
Tag::new((owner.clone(), format!("tag-{ix}")), shown)
.when(failed, |tag| tag.tone(Tone::Danger))
.on_remove(move |window, cx| {
let mut next = tags.clone();
@@ -134,8 +192,23 @@ impl RenderOnce for TagInput {
});
let (tags, on_change, empty_check) =
(self.tags.clone(), self.on_change.clone(), input.clone());
let popup = open.then(|| {
Popup {
id: (self.id.clone(), "suggestions").into(),
anchor,
rows: &rows,
highlighted: Some(highlighted),
checked: None,
pick,
dismiss: None,
scroll: Some(&scroll),
reveal,
}
.render(window, cx)
});
div()
.id(self.id)
.relative()
.flex()
.flex_wrap()
.items_center()
@@ -155,6 +228,33 @@ impl RenderOnce for TagInput {
.text_size(theme.text_size(TextSize::Base))
.text_color(colors.fg)
.on_mouse_down(MouseButton::Left, move |_, window, _| window.focus(&focus))
.capture_action(move |_: &Up, _, cx| {
if open {
cx.stop_propagation();
Picker::show(&up, true, step(&up_rows, highlighted, -1), cx);
}
})
.capture_action(move |_: &Down, _, cx| {
if open {
cx.stop_propagation();
Picker::show(&down, true, step(&down_rows, highlighted, 1), cx);
}
})
.capture_action(move |_: &Enter, window, cx| {
if open {
cx.stop_propagation();
enter(highlighted, window, cx);
}
})
.on_key_down(move |event, _, cx| {
if open && event.keystroke.key == "escape" {
cx.stop_propagation();
escape.update(cx, |tags, cx| {
tags.dismissed = true;
cx.notify();
});
}
})
.capture_action(move |_: &Backspace, window, cx| {
if !empty_check.read(cx).is_empty() || tags.is_empty() {
return;
@@ -175,5 +275,7 @@ impl RenderOnce for TagInput {
.px_1()
.child(input),
)
.child(measure_anchor(picker))
.children(popup)
}
}
+1
View File
@@ -3,6 +3,7 @@ mod colors;
mod dates;
mod other;
mod structure;
mod tags;
mod upload;
use gpui::{
+111
View File
@@ -0,0 +1,111 @@
use gpui::{
Context, Entity, IntoElement, KeyUpEvent, Keystroke, Render, SharedString, TestAppContext,
VisualTestContext, Window,
};
use super::setup;
use crate::forms::{Choice, TagInput};
/// A tag field offering two people, and the tags it asked for.
struct Recipients {
tags: Vec<SharedString>,
asked: Vec<Vec<SharedString>>,
}
impl Render for Recipients {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
TagInput::new("to", self.tags.clone())
.suggestions([
Choice::new("ana@atrium.studio", "Ana Lima"),
Choice::new("ben@atrium.studio", "Ben Ito"),
])
.on_change(move |next, _, cx| {
owner.update(cx, |host, cx| {
host.asked.push(next.clone());
host.tags = next;
cx.notify();
})
})
}
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
fn typing<'a>(
words: &str,
cx: &'a mut TestAppContext,
) -> (Entity<Recipients>, &'a mut VisualTestContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Recipients {
tags: Vec::new(),
asked: Vec::new(),
});
cx.update(|window, _| {
window.activate_window();
window.focus_next();
});
settle(cx);
cx.simulate_input(words);
settle(cx);
(host, cx)
}
fn asked(host: &Entity<Recipients>, cx: &mut VisualTestContext) -> Vec<Vec<SharedString>> {
host.read_with(cx, |host, _| host.asked.clone())
}
#[gpui::test]
fn enter_adds_the_suggestion_the_words_find(cx: &mut TestAppContext) {
let (host, cx) = typing("be", cx);
press("enter", cx);
assert_eq!(
asked(&host, cx),
[vec![SharedString::from("ben@atrium.studio")]]
);
}
#[gpui::test]
fn down_moves_to_the_next_suggestion(cx: &mut TestAppContext) {
let (host, cx) = typing("i", cx);
press("down", cx);
press("enter", cx);
assert_eq!(
asked(&host, cx),
[vec![SharedString::from("ben@atrium.studio")]],
"Ana Lima and Ben Ito both hold an i; Down reaches Ben"
);
}
#[gpui::test]
fn words_with_no_suggestion_are_added_as_typed(cx: &mut TestAppContext) {
let (host, cx) = typing("dev@atrium.studio", cx);
press("enter", cx);
assert_eq!(
asked(&host, cx),
[vec![SharedString::from("dev@atrium.studio")]]
);
}
#[gpui::test]
fn escape_sets_the_suggestions_aside(cx: &mut TestAppContext) {
let (host, cx) = typing("be", cx);
press("escape", cx);
press("enter", cx);
assert_eq!(asked(&host, cx), [vec![SharedString::from("be")]]);
}
+387
View File
@@ -0,0 +1,387 @@
use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*,
};
use jiff::{Timestamp, tz::TimeZone};
use super::{reader::Contact, send::ScheduleSend, signature::signature_block};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
chat::{Attachment, AttachmentChip},
forms::{Choice, Input, OnValue, Run, TagInput, TextInput, is_email},
primitives::IconName,
theme::{ActiveTheme, ControlSize, TextSize},
};
/// What a composer hands over: the addresses, the subject and the words.
#[derive(Clone, Debug, PartialEq)]
pub struct Draft {
pub to: Vec<SharedString>,
pub cc: Vec<SharedString>,
pub bcc: Vec<SharedString>,
pub subject: SharedString,
pub body: SharedString,
}
type OnDraft = Rc<dyn Fn(&Draft, &mut Window, &mut App)>;
type OnDraftAt = Rc<dyn Fn(&Draft, Timestamp, &mut Window, &mut App)>;
/// Whether a message can go: someone to send to, and every address an address.
fn ready(to: &[SharedString], cc: &[SharedString], bcc: &[SharedString]) -> bool {
let mut all = to.iter().chain(cc).chain(bcc).peekable();
all.peek().is_some() && all.all(|address| is_email(address))
}
/// Which field of addresses.
#[derive(Clone, Copy)]
enum Field {
To,
Cc,
Bcc,
}
/// The message being written: the owner's start it came from, the addresses, whether Cc and Bcc show, and the subject and words.
struct Composing {
seed: (Vec<SharedString>, SharedString),
to: Vec<SharedString>,
cc: Vec<SharedString>,
bcc: Vec<SharedString>,
copies: bool,
subject: Entity<TextInput>,
body: Entity<TextInput>,
}
impl Composing {
/// Starts over from the owner's addresses and subject, with no words.
fn fill(&mut self, (to, subject): (Vec<SharedString>, SharedString), cx: &mut Context<Self>) {
self.to = to;
self.cc.clear();
self.bcc.clear();
self.copies = false;
self.subject
.update(cx, |input, cx| input.set_text(subject.to_string(), cx));
self.body.update(cx, |input, cx| input.set_text("", cx));
}
fn draft(&self, cx: &App) -> Draft {
Draft {
to: self.to.clone(),
cc: self.cc.clone(),
bcc: self.bcc.clone(),
subject: self.subject.read(cx).text().trim().to_string().into(),
body: self.body.read(cx).text().to_string().into(),
}
}
}
/// A message to write: To, and Cc and Bcc once asked for, as fields that suggest contacts and mark what is not an address; the subject and the words; the files, each with a way off; and the signature under the words. Send goes once there is someone to send to, now or at a time; Discard lets it go. A new start from the owner begins it over.
#[derive(IntoElement)]
pub struct MailComposer {
id: ElementId,
contacts: Vec<Contact>,
to: Vec<SharedString>,
subject: SharedString,
signature: Option<SharedString>,
files: Vec<Attachment>,
zone: Option<TimeZone>,
on_send: Option<OnDraft>,
on_schedule: Option<OnDraftAt>,
on_discard: Option<Run>,
on_attach: Option<Run>,
on_remove: Option<OnValue>,
}
impl MailComposer {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
contacts: Vec::new(),
to: Vec::new(),
subject: SharedString::default(),
signature: None,
files: Vec::new(),
zone: None,
on_send: None,
on_schedule: None,
on_discard: None,
on_attach: None,
on_remove: None,
}
}
/// People to suggest as addresses are typed.
pub fn contacts(mut self, contacts: impl IntoIterator<Item = Contact>) -> Self {
self.contacts = contacts.into_iter().collect();
self
}
/// The addresses it starts with, as a reply has them.
pub fn to(mut self, addresses: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.to = addresses.into_iter().map(Into::into).collect();
self
}
/// The subject it starts with.
pub fn subject(mut self, subject: impl Into<SharedString>) -> Self {
self.subject = subject.into();
self
}
pub fn signature(mut self, text: impl Into<SharedString>) -> Self {
self.signature = Some(text.into());
self
}
/// Files the owner attached.
pub fn files(mut self, files: impl IntoIterator<Item = Attachment>) -> Self {
self.files = files.into_iter().collect();
self
}
/// The zone its times to send read in; the system's otherwise.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
pub fn on_send(mut self, handler: impl Fn(&Draft, &mut Window, &mut App) + 'static) -> Self {
self.on_send = Some(Rc::new(handler));
self
}
/// Gets the draft and the moment to send it.
pub fn on_schedule(
mut self,
handler: impl Fn(&Draft, Timestamp, &mut Window, &mut App) + 'static,
) -> Self {
self.on_schedule = Some(Rc::new(handler));
self
}
pub fn on_discard(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_discard = Some(Rc::new(handler));
self
}
/// Asks the owner for files to attach.
pub fn on_attach(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_attach = Some(Rc::new(handler));
self
}
/// Gets the key of the file to take off.
pub fn on_remove(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for MailComposer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let seed = (self.to.clone(), self.subject.clone());
let state = window.use_keyed_state(
(self.id.clone(), "draft"),
cx,
|window, cx: &mut Context<Composing>| {
let subject = cx.new(|cx| TextInput::new(window, cx).placeholder("Subject"));
let body = cx.new(|cx| {
TextInput::new(window, cx)
.multi_line(6, 16)
.placeholder("Write your message")
});
let mut composing = Composing {
seed: seed.clone(),
to: Vec::new(),
cc: Vec::new(),
bcc: Vec::new(),
copies: false,
subject,
body,
};
composing.fill(seed.clone(), cx);
composing
},
);
if state.read(cx).seed != seed {
log::info!("mail composer {}: started over", self.id);
state.update(cx, |composing, cx| {
composing.seed = seed.clone();
composing.fill(seed, cx);
});
}
let now = state.read(cx);
let (to, cc, bcc, copies) = (now.to.clone(), now.cc.clone(), now.bcc.clone(), now.copies);
let (subject, body) = (now.subject.clone(), now.body.clone());
let can_send = ready(&to, &cc, &bcc);
let choices: Vec<Choice> = self
.contacts
.iter()
.map(|contact| Choice::new(contact.address.clone(), contact.name.clone()))
.collect();
let theme = cx.theme();
let colors = &theme.colors;
let row = |label: &'static str, field: Field, tags: Vec<SharedString>| {
let owner = state.clone();
div()
.debug_selector(move || format!("composer-{label}"))
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_none()
.w_10()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(label),
)
.child(
div().flex_1().min_w_0().child(
TagInput::new((self.id.clone(), label), tags)
.placeholder("Add people")
.check(is_email)
.suggestions(choices.clone())
.on_change(move |next, _, cx| {
log::info!("mail composer: {label} holds {}", next.len());
owner.update(cx, |composing, cx| {
match field {
Field::To => composing.to = next,
Field::Cc => composing.cc = next,
Field::Bcc => composing.bcc = next,
}
cx.notify();
})
}),
),
)
};
let showing = state.clone();
let copies_button = (!copies).then(|| {
Button::new((self.id.clone(), "copies"), "Cc Bcc")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
log::info!("mail composer: shows Cc and Bcc");
showing.update(cx, |composing, cx| {
composing.copies = true;
cx.notify();
})
})
});
let files = self.files.iter().map(|file| {
let chip = AttachmentChip::new(
(self.id.clone(), format!("file-{}", file.key)),
file.clone(),
);
match self.on_remove.clone() {
Some(remove) => {
let key = file.key.clone();
chip.on_remove(move |window, cx| {
log::info!("mail composer: takes off {key}");
remove(&key, window, cx)
})
.into_any_element()
}
None => chip.into_any_element(),
}
});
let sending = state.clone();
let send = ScheduleSend::new((self.id.clone(), "send"))
.disabled(!can_send)
.when_some(self.zone.clone(), |send, zone| send.zone(zone))
.when_some(self.on_send.clone(), |send, on_send| {
send.on_send(move |window, cx| {
let draft = sending.read(cx).draft(cx);
log::info!("mail composer: sends to {}", draft.to.len());
on_send(&draft, window, cx)
})
});
let scheduling = state.clone();
let send = match self.on_schedule.clone() {
Some(on_schedule) => send.on_schedule(move |at, window, cx| {
let draft = scheduling.read(cx).draft(cx);
log::info!("mail composer: sends at {at}");
on_schedule(&draft, at, window, cx)
}),
None => send,
};
let attach = self.on_attach.map(|attach| {
IconButton::new((self.id.clone(), "attach"), IconName::Paperclip)
.variant(ButtonVariant::Ghost)
.tooltip("Attach files")
.on_click(move |_, window, cx| {
log::info!("mail composer: attach");
attach(window, cx)
})
});
let discard = self.on_discard.map(|discard| {
IconButton::new((self.id.clone(), "discard"), IconName::Trash2)
.variant(ButtonVariant::Ghost)
.tooltip("Discard")
.on_click(move |_, window, cx| {
log::info!("mail composer: discard");
discard(window, cx)
})
});
div()
.debug_selector(|| "mail-composer".into())
.w_full()
.flex()
.flex_col()
.gap_3()
.child(
row("To", Field::To, to)
.children(copies_button.map(|button| div().flex_none().child(button))),
)
.when(copies, |form| {
form.child(row("Cc", Field::Cc, cc))
.child(row("Bcc", Field::Bcc, bcc))
})
.child(Input::new(&subject))
.child(Input::new(&body))
.when(!self.files.is_empty(), |form| {
form.child(div().flex().flex_wrap().gap_2().children(files))
})
.children(self.signature.map(|text| signature_block(&text, cx)))
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_1()
.child(send)
.children(attach),
)
.children(discard),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_message_goes_once_someone_holds_an_address() {
let ben: SharedString = "ben@atrium.studio".into();
let bad: SharedString = "ben@".into();
assert!(!ready(&[], &[], &[]), "nobody to send to");
assert!(ready(std::slice::from_ref(&ben), &[], &[]));
assert!(
ready(&[], &[], std::slice::from_ref(&ben)),
"a blind copy alone"
);
assert!(!ready(&[ben], &[bad], &[]), "an address that is not one");
}
}
+6
View File
@@ -1,14 +1,20 @@
mod boxes;
mod compose;
mod list;
mod quoted;
mod reader;
mod send;
mod signature;
mod thread;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use boxes::{Mailbox, MailboxList};
pub use compose::{Draft, MailComposer};
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 thread::MailThreadView;
+248
View File
@@ -0,0 +1,248 @@
use std::rc::Rc;
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Window, div, prelude::*};
use jiff::{Timestamp, ToSpan, civil::DateTime, tz::TimeZone};
use crate::{
buttons::{Button, ButtonVariant},
forms::{DateTimePicker, 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] {
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>,
}
/// 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.
#[derive(IntoElement)]
pub struct ScheduleSend {
id: ElementId,
zone: Option<TimeZone>,
disabled: bool,
on_send: Option<Run>,
on_schedule: Option<OnTime>,
}
impl ScheduleSend {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
zone: None,
disabled: false,
on_send: None,
on_schedule: None,
}
}
/// The zone its times read in; the system's otherwise.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
/// Nothing to send yet.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_send(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_send = Some(Rc::new(handler));
self
}
/// Gets the moment to send at.
pub fn on_schedule(
mut self,
handler: impl Fn(Timestamp, &mut Window, &mut App) + 'static,
) -> Self {
self.on_schedule = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ScheduleSend {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
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 menu = self
.on_schedule
.clone()
.filter(|_| !self.disabled)
.map(|schedule| {
let presets = presets(Timestamp::now(), &zone).into_iter().fold(
Menu::new(),
|menu, (name, at)| {
let schedule = schedule.clone();
menu.item(MenuItem::new(name).on_click(move |window, cx| {
log::info!("schedule send: at {at}");
schedule(at, window, cx)
}))
},
);
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();
})
}),
)
});
let send = self.on_send.clone();
let button = match menu {
Some(menu) => SplitButton::new((self.id.clone(), "send"), "Send", menu)
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
log::info!("schedule send: send");
if let Some(send) = &send {
send(window, cx);
}
})
.into_any_element(),
None => Button::new((self.id.clone(), "send"), "Send")
.variant(ButtonVariant::Primary)
.disabled(self.disabled)
.on_click(move |_, window, cx| {
log::info!("schedule send: send");
if let Some(send) = &send {
send(window, cx);
}
})
.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);
}
})
}
})
});
div().flex_none().child(button).children(dialog)
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
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()
}
#[test]
fn presets_fall_tomorrow_and_on_the_next_monday() {
let [(morning, a), (afternoon, b), (monday, c)] = presets(at(26, 12), &TimeZone::UTC);
assert_eq!(
(morning.as_str(), a),
("Tomorrow morning · Sun 08:00", at(27, 8))
);
assert_eq!(
(afternoon.as_str(), b),
("Tomorrow afternoon · Sun 13:00", at(27, 13))
);
assert_eq!(
(monday.as_str(), c),
("Monday morning · Mon 08:00", at(28, 8))
);
let [_, _, (_, next)] = presets(at(28, 9), &TimeZone::UTC);
assert_eq!(
next,
date(2026, 10, 5)
.at(8, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp(),
"on a Monday, the next one"
);
}
}
+140
View File
@@ -0,0 +1,140 @@
use std::rc::Rc;
use gpui::{
App, Context, Div, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Subscription, Window, div, prelude::*,
};
use crate::{
forms::{Input, InputEvent, OnValue, TextInput},
theme::{ActiveTheme, TextSize},
};
/// A signature as it sits under a message: a short rule, then its lines in the muted tone.
pub(super) fn signature_block(text: &str, cx: &App) -> Div {
let theme = cx.theme();
let colors = &theme.colors;
div()
.flex()
.flex_col()
.gap_1()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(
div()
.w_6()
.mb_1()
.border_t_1()
.border_color(colors.border_strong),
)
.children(text.lines().map(|line| div().child(line.to_string())))
}
/// The field and what it last told the owner, so an echo is known from a new signature.
struct Signed {
input: Entity<TextInput>,
seed: SharedString,
sent: Option<SharedString>,
on_change: Option<OnValue>,
_changes: Subscription,
}
/// Writes a signature: its lines in a field, and under it how it will sit below a message. The owner gets the words as they change; a new signature from the owner starts the field over.
#[derive(IntoElement)]
pub struct SignatureEditor {
id: ElementId,
text: SharedString,
on_change: Option<OnValue>,
}
impl SignatureEditor {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
on_change: None,
}
}
/// Gets the words as they change.
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SignatureEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let seed = self.text.clone();
let signed = window.use_keyed_state(
(self.id.clone(), "signed"),
cx,
|window, cx: &mut Context<Signed>| {
let input = cx.new(|cx| {
let mut input = TextInput::new(window, cx)
.multi_line(3, 6)
.placeholder("Your name, your role, a way to reach you");
input.set_text(seed.to_string(), cx);
input
});
let changes =
cx.subscribe_in(&input, window, |signed, input, event, window, cx| {
if *event != InputEvent::Changed {
return;
}
let words = SharedString::from(input.read(cx).text().to_string());
if words == signed.seed {
return;
}
log::info!("signature editor: {} characters", words.len());
signed.sent = Some(words.clone());
if let Some(on_change) = signed.on_change.clone() {
on_change(&words, window, cx);
}
cx.notify();
});
Signed {
input,
seed: seed.clone(),
sent: None,
on_change: None,
_changes: changes,
}
},
);
let text = self.text.clone();
signed.update(cx, |signed, cx| {
signed.on_change = self.on_change.clone();
if signed.seed == text {
return;
}
signed.seed = text.clone();
if signed.sent.as_ref() == Some(&text) {
return;
}
log::info!("signature editor: started over");
signed
.input
.update(cx, |input, cx| input.set_text(text.to_string(), cx));
});
let input = signed.read(cx).input.clone();
let words = input.read(cx).text().to_string();
let theme = cx.theme();
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(Input::new(&input))
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child("Under each message"),
)
.child(signature_block(&words, cx))
}
}
+1
View File
@@ -11,6 +11,7 @@ use crate::{
};
mod read;
mod write;
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
+232
View File
@@ -0,0 +1,232 @@
use gpui::{
Context, Entity, IntoElement, Render, SharedString, TestAppContext, VisualTestContext, Window,
};
use jiff::{Timestamp, tz::TimeZone};
use super::{Mailing, heard, mailing, note, press, settle, tab_to};
use crate::{
forms,
mail::{Contact, MailComposer, ScheduleSend, SignatureEditor},
};
fn contacts() -> [Contact; 2] {
[
Contact::new("Ana Lima", "ana@atrium.studio"),
Contact::new("Ben Ito", "ben@atrium.studio"),
]
}
fn composer(owner: Entity<Mailing>) -> gpui::AnyElement {
let (send, discard) = (owner.clone(), owner);
MailComposer::new("composer")
.contacts(contacts())
.on_send(move |draft, _, cx| {
note(
&send,
format!(
"send {} | {} | {}",
draft.to.join(","),
draft.subject,
draft.body
),
cx,
)
})
.on_discard(move |_, cx| note(&discard, "discard".into(), cx))
.into_any_element()
}
fn writing(
part: fn(Entity<Mailing>) -> gpui::AnyElement,
cx: &mut TestAppContext,
) -> (Entity<Mailing>, &mut VisualTestContext) {
let (host, cx) = mailing(part, cx);
cx.update(|_, cx| forms::bind_keys(cx));
(host, cx)
}
fn next(cx: &mut VisualTestContext) {
cx.update(|window, _| window.focus_next());
settle(cx);
}
#[gpui::test]
fn a_suggested_person_a_subject_and_words_go_out_as_one_draft(cx: &mut TestAppContext) {
let (host, cx) = writing(composer, cx);
tab_to(1, cx);
cx.simulate_input("be");
settle(cx);
press("enter", cx);
next(cx);
next(cx);
cx.simulate_input("Samples");
next(cx);
cx.simulate_input("They are dry.");
next(cx);
press("space", cx);
assert_eq!(
heard(&host, cx),
["send ben@atrium.studio | Samples | They are dry."]
);
}
#[gpui::test]
fn cc_and_bcc_show_once_asked(cx: &mut TestAppContext) {
let (_, cx) = writing(composer, cx);
assert!(cx.debug_bounds("composer-Cc").is_none());
tab_to(2, cx);
press("space", cx);
assert!(cx.debug_bounds("composer-Cc").is_some());
assert!(cx.debug_bounds("composer-Bcc").is_some());
}
#[gpui::test]
fn with_nobody_to_send_to_send_steps_aside_for_discard(cx: &mut TestAppContext) {
let (host, cx) = writing(composer, cx);
tab_to(5, cx);
press("space", cx);
assert_eq!(
heard(&host, cx),
["discard"],
"Send is off, so the stop after the words is Discard"
);
}
fn scheduling(owner: Entity<Mailing>) -> gpui::AnyElement {
let (send, later) = (owner.clone(), owner);
ScheduleSend::new("send")
.zone(TimeZone::UTC)
.on_send(move |_, cx| note(&send, "send".into(), cx))
.on_schedule(move |at, _, cx| note(&later, format!("at {at}"), cx))
.into_any_element()
}
#[gpui::test]
fn the_first_time_to_send_later_is_tomorrow_morning(cx: &mut TestAppContext) {
let (host, cx) = writing(scheduling, cx);
tab_to(2, cx);
press("enter", cx);
press("enter", cx);
let morning = Timestamp::now()
.to_zoned(TimeZone::UTC)
.date()
.tomorrow()
.expect("a day follows")
.at(8, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp();
assert_eq!(heard(&host, cx), [format!("at {morning}")]);
}
/// A signature the host keeps, and every change it heard.
struct Signing {
words: SharedString,
heard: Vec<String>,
}
impl Render for Signing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
SignatureEditor::new("signature", self.words.clone()).on_change(move |words, _, cx| {
owner.update(cx, |host, cx| {
host.heard.push(words.to_string());
host.words = words.clone();
cx.notify();
})
})
}
}
fn signing<'a>(
words: &str,
cx: &'a mut TestAppContext,
) -> (Entity<Signing>, &'a mut VisualTestContext) {
super::setup(cx);
cx.update(forms::bind_keys);
let (host, cx) = cx.add_window_view(|_, _| Signing {
words: SharedString::from(words.to_string()),
heard: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(host, cx)
}
#[gpui::test]
fn an_echo_keeps_the_caret_where_it_is(cx: &mut TestAppContext) {
let (host, cx) = signing("", cx);
tab_to(1, cx);
cx.simulate_input("ab");
settle(cx);
press("left", cx);
cx.simulate_input("X");
settle(cx);
cx.simulate_input("Y");
settle(cx);
let heard = host.read_with(cx, |host, _| host.heard.clone());
assert_eq!(
heard.last().map(String::as_str),
Some("aXYb"),
"the caret stayed after X"
);
}
#[gpui::test]
fn a_new_signature_from_the_owner_starts_the_field_over(cx: &mut TestAppContext) {
let (host, cx) = signing("Ana", cx);
host.update(cx, |host, cx| {
host.words = "Ben Ito".into();
cx.notify();
});
settle(cx);
tab_to(1, cx);
cx.simulate_input("!");
settle(cx);
let heard = host.read_with(cx, |host, _| host.heard.clone());
assert_eq!(heard, ["Ben Ito!"]);
}
/// A composer the host starts from an address, and what it sent.
struct Replying {
to: SharedString,
sent: Vec<String>,
}
impl Render for Replying {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
MailComposer::new("composer")
.to([self.to.clone()])
.subject("Re: Samples")
.on_send(move |draft, _, cx| {
owner.update(cx, |host, _| {
host.sent
.push(format!("{} | {}", draft.to.join(","), draft.subject))
})
})
}
}
#[gpui::test]
fn a_new_start_from_the_owner_begins_the_draft_over(cx: &mut TestAppContext) {
super::setup(cx);
cx.update(forms::bind_keys);
let (host, cx) = cx.add_window_view(|_, _| Replying {
to: "ana@atrium.studio".into(),
sent: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
host.update(cx, |host, cx| {
host.to = "ben@atrium.studio".into();
cx.notify();
});
settle(cx);
tab_to(5, cx);
press("space", cx);
assert_eq!(
host.read_with(cx, |host, _| host.sent.clone()),
["ben@atrium.studio | Re: Samples"]
);
}