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:
1 parent
48118499ae
commit
84c3dc16de
14 files changed
+1425
-12
No files matched your search
+107
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod colors;
|
||||
mod dates;
|
||||
mod other;
|
||||
mod structure;
|
||||
mod tags;
|
||||
mod upload;
|
||||
|
||||
use gpui::{
|
||||
|
||||
@@ -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")]]);
|
||||
}
|
||||
Reference in new issue
Block a user