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

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