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

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