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

+2 -2
View File
@@ -51,7 +51,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
- gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn.
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags.
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. A field that reports its words as they change keeps what it sent, so the owner's echo leaves the caret alone (`mail::SignatureEditor`).
- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. gpui's mac window stops its display link while occluded, so a capture advances a moving picture only on the draws the harness forces.
- Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one.
- The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level.
@@ -67,7 +67,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui's `test-support` swaps its executor: `block` fails on a real dispatcher. It stays behind this crate's `test-support` feature, never on for the gallery.
- A tracked `FocusHandle` is a Tab stop only when built with `.tab_stop(true)`. `tab_index` on the div reaches only handles gpui makes itself. `primitives::tab_stop` keeps one per id.
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. An option list is at least its trigger's width and at most the window's less `window_margin` each side, so gpui can snap it inside and its rows end in an ellipsis; wider, gpui pins it left and the rest runs off.
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike.
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. A field that suggests as you type floats its rows through `forms::select::Picker` and `forms::options::Popup`, as Combobox and TagInput do.
- A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows.
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event.
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
+3 -1
View File
@@ -104,7 +104,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T28d Messaging · Calls
- [x] T28e Messaging · Stickers and GIFs
- [x] T29a Mail · Boxes and lists
- [ ] T29b Mail · Reading
- [x] T29b Mail · Reading
- [ ] T29c Mail · Writing
- [ ] T29d Mail · Sorting
- [ ] T30 Calendar
@@ -344,3 +344,5 @@ Per-component lines live in `tasks/`. Tags there:
| T28e | 2 | PASS | |
| T29a | 1 | FAIL | Space on a focused star also toggled the list's selection |
| T29a | 2 | PASS | |
| T29b | 1 | FAIL | a folded message lost focus as it opened; a long name beside a long date ran past a 280px fold |
| T29b | 2 | PASS | |
+12
View File
@@ -9,6 +9,7 @@ use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Wi
use jiff::{SignedDuration, Timestamp};
mod read;
mod write;
use super::Page;
use crate::{
@@ -43,6 +44,14 @@ const SCRIPT: &[Step] = &[
Step::UpAt("mail-thread", 200.0, 120.0),
Step::Wait(300),
Step::Shot("thread-opened"),
Step::DownAt("mail-composer", 270.0, 31.0),
Step::UpAt("mail-composer", 270.0, 31.0),
Step::Type("an"),
Step::Wait(200),
Step::Shot("recipient-suggested"),
Step::Key("enter"),
Step::Wait(200),
Step::Shot("recipient-added"),
];
fn boxes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
@@ -212,5 +221,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(zero(cx))
.child(read::reader(cx))
.child(read::thread(cx))
.child(write::composer(window, cx))
.child(write::signature(window, cx))
.child(write::schedule(window, cx))
.into_any_element()
}
+171
View File
@@ -0,0 +1,171 @@
use ely_gpui_component::{
chat::Attachment,
mail::{Contact, MailComposer, ScheduleSend, SignatureEditor},
theme::{ActiveTheme, Radius, TextSize},
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{change, keep, section, set},
};
const SIGNATURE: &str = "Chloé Martin\nLead designer · Atrium Studio";
fn contacts() -> [Contact; 4] {
[
Contact::new("Ana Lima", "ana@atrium.studio"),
Contact::new("Ben Ito", "ben@atrium.studio"),
Contact::new("Chloé Martin", "chloe@atrium.studio"),
Contact::new("Dev Rao", "dev@atrium.studio"),
]
}
fn file(key: &str, name: &str, bytes: u64) -> Attachment {
Attachment {
key: SharedString::from(key.to_string()),
name: SharedString::from(name.to_string()),
bytes,
preview: None,
progress: None,
}
}
/// The demo composer: which one it is, its files, and what became of the last draft.
#[derive(Clone)]
struct Writing {
round: usize,
files: Vec<Attachment>,
said: Option<SharedString>,
}
fn quiet(text: SharedString, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(text)
}
pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let writing = keep(
"mail-writing",
|| Writing {
round: 0,
files: vec![file("plan", "atrium-plan.pdf", 2_400_000)],
said: None,
},
window,
cx,
);
let now = writing.read(cx).clone();
let (sent, later, gone, attach, remove) = (
writing.clone(),
writing.clone(),
writing.clone(),
writing.clone(),
writing,
);
let theme = cx.theme();
let frame = div()
.w(px(560.))
.p_4()
.border_1()
.border_color(theme.colors.border)
.rounded(theme.radius(Radius::Lg))
.child(
MailComposer::new(("mail-composer", now.round))
.contacts(contacts())
.signature(SIGNATURE)
.files(now.files.clone())
.on_send(move |draft, _, cx| {
let said = format!("Sent to {}.", draft.to.join(", "));
change(&sent, cx, |writing| writing.said = Some(said.into()))
})
.on_schedule(move |draft, at, _, cx| {
let said = format!("{} will go at {at}.", draft.subject);
change(&later, cx, |writing| writing.said = Some(said.into()))
})
.on_discard(move |_, cx| {
change(&gone, cx, |writing| {
writing.round += 1;
writing.said = Some("Discarded.".into());
})
})
.on_attach(move |_, cx| {
change(&attach, cx, |writing| {
let ix = writing.files.len();
writing.files.push(file(
&format!("photo-{ix}"),
&format!("stair-hall-{ix}.jpg"),
3_100_000,
));
})
})
.on_remove(move |key, _, cx| {
change(&remove, cx, |writing| {
writing.files.retain(|each| each.key != *key)
})
}),
);
section(
"MailComposer / RecipientInput → TagInput",
"To, and Cc and Bcc once asked for, suggest people as you type and mark what is not an address. Then the subject, the words, the files and the signature. Send wakes once there is someone to send to, now or at a time; Discard lets the draft go.",
cx,
)
.child(probe(
"mail-composer",
div()
.flex()
.flex_col()
.gap_3()
.child(frame)
.children(now.said.map(|said| quiet(said, cx))),
))
}
pub fn signature(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let words = keep(
"mail-signature",
|| SharedString::from(SIGNATURE),
window,
cx,
);
let shown = words.read(cx).clone();
section(
"SignatureEditor",
"A signature's lines in a field, and under it how they will sit below a message.",
cx,
)
.child(probe(
"mail-signature",
div().w(px(420.)).child(
SignatureEditor::new("mail-signature", shown)
.on_change(move |next, _, cx| set(&words, next.clone(), cx)),
),
))
}
pub fn schedule(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let said = keep("mail-schedule-said", || None::<SharedString>, window, cx);
let shown = said.read(cx).clone();
let (sent, later) = (said.clone(), said);
section(
"ScheduleSend",
"Send now, or open the arrow for tomorrow morning, tomorrow afternoon, Monday morning, or a day and hour of your own.",
cx,
)
.child(probe(
"mail-schedule",
div()
.flex()
.items_center()
.gap_4()
.child(
ScheduleSend::new("mail-schedule")
.on_send(move |_, cx| set(&sent, Some("Sent now.".into()), cx))
.on_schedule(move |at, _, cx| set(&later, Some(format!("Goes at {at}.").into()), cx)),
)
.children(shown.map(|shown| quiet(shown, cx))),
))
}
+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"]
);
}
+4 -4
View File
@@ -375,10 +375,10 @@
## T29c Mail · Writing
- [ ] MailComposer
- [ ] RecipientInput
- [ ] SignatureEditor
- [ ] ScheduleSend
- [x] MailComposer
- [x] RecipientInput → `forms::TagInput`, its suggestions and an address check
- [x] SignatureEditor
- [x] ScheduleSend
## T29d Mail · Sorting