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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 00:27:23 -04:00
1 parent 48118499ae
commit 84c3dc16de
14 files changed
+1425 -12

No files matched your search

+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"]
);
}