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

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