Files
Ely-GPUI-Components/src/mail/compose.rs
T
ZacharyZhang-NY 81e73b79c1 Capture · Roots: roots drop w_full; hosts give the width
145 component roots drop w_full, the early returns included. Three
field rows keep it, Input, InputGroup and TagInput: stretched without
a width, taffy 0.9 laid the field's flex_1 share at no width in the
template picker at 280px, and the placeholder drew nothing. Container
keeps it, since mx_auto needs a width to fill.

Hosts that relied on a root's width now give it: MessageList boxes
each row at the list's width, the splash gives its bar the column's,
and test stages that mount a component as the window's root sit in a
size_full box. Tests hold a narrow row across the list and the splash
bar across its column; each goes red without its fix.

The gallery gains --narrow <px>, which lays each page in a padded
card at that width and shoots it without its script. The page
header wraps: the title takes the room and the theme switcher drops
below, where a long summary used to push it past the edge. A file
panel step waits for the panel to become key instead of a fixed
pause, which lost a race in a capture.
2026-09-28 13:07:22 -04:00

387 lines
14 KiB
Rust

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())
.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");
}
}