Forms: Cmd-Enter submits from any control in a form
Form sets the ElyForm key context and binds secondary-enter to Submit under it, so a focused tile, chip or button submits too. The prompt enhancer's wash trims anything but letters and digits from its runs, its spacing sits on its parts so nothing gaps at rest, and its tests check that the field takes focus back.
This commit is contained in:
6 files changed
+51
-12
No files matched your search
@@ -291,3 +291,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T24e | 2 | PASS | |
|
| T24e | 2 | PASS | |
|
||||||
| T24f | 1 | FAIL | Tab to a sortable header past a narrow table's edge left it out of view; a virtualized table still drew past its box at 280px |
|
| T24f | 1 | FAIL | Tab to a sortable header past a narrow table's edge left it out of view; a virtualized table still drew past its box at 280px |
|
||||||
| T24f | 2 | PASS | |
|
| T24f | 2 | PASS | |
|
||||||
|
| T25a | 1 | FAIL | Cmd-Enter generated only while a text field held focus; the form set no key context of its own |
|
||||||
@@ -15,7 +15,10 @@ use crate::{
|
|||||||
typography::{Caption, Heading, Label},
|
typography::{Caption, Heading, Label},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Sections stacked with room between them. Cmd-Enter in any field submits the form.
|
/// The key context a form sets, so Cmd-Enter reaches it from any control inside.
|
||||||
|
pub(crate) const FORM_CONTEXT: &str = "ElyForm";
|
||||||
|
|
||||||
|
/// Sections stacked with room between them. Cmd-Enter on any field or control inside submits the form.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Form {
|
pub struct Form {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
@@ -49,6 +52,7 @@ impl RenderOnce for Form {
|
|||||||
let id = self.id.clone();
|
let id = self.id.clone();
|
||||||
div()
|
div()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
|
.key_context(FORM_CONTEXT)
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_8()
|
.gap_8()
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::setup;
|
use super::setup;
|
||||||
use crate::forms::{FieldArray, Form, FormField, Input, InputEvent, TextInput};
|
use crate::{
|
||||||
|
buttons::Button,
|
||||||
|
forms::{FieldArray, Form, FormField, Input, InputEvent, TextInput},
|
||||||
|
};
|
||||||
|
|
||||||
struct Signup {
|
struct Signup {
|
||||||
name: Entity<TextInput>,
|
name: Entity<TextInput>,
|
||||||
@@ -27,13 +30,13 @@ impl Render for Signup {
|
|||||||
.required()
|
.required()
|
||||||
.child(Input::new(&self.name)),
|
.child(Input::new(&self.name)),
|
||||||
)
|
)
|
||||||
|
.child(Button::new("signup-save", "Save"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
fn signup(cx: &mut TestAppContext) -> (Entity<Signup>, &mut VisualTestContext) {
|
||||||
fn cmd_enter_in_a_field_submits_the_form_once(cx: &mut TestAppContext) {
|
|
||||||
setup(cx);
|
setup(cx);
|
||||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
cx.add_window_view(|window, cx| {
|
||||||
let name = cx.new(|cx| TextInput::new(window, cx));
|
let name = cx.new(|cx| TextInput::new(window, cx));
|
||||||
cx.subscribe(&name, |view: &mut Signup, _, event, _| {
|
cx.subscribe(&name, |view: &mut Signup, _, event, _| {
|
||||||
if *event == InputEvent::Submit {
|
if *event == InputEvent::Submit {
|
||||||
@@ -46,7 +49,12 @@ fn cmd_enter_in_a_field_submits_the_form_once(cx: &mut TestAppContext) {
|
|||||||
sent: 0,
|
sent: 0,
|
||||||
field_submits: 0,
|
field_submits: 0,
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn cmd_enter_in_a_field_submits_the_form_once(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = signup(cx);
|
||||||
cx.update(|window, _| window.focus_next());
|
cx.update(|window, _| window.focus_next());
|
||||||
cx.simulate_input("Ada");
|
cx.simulate_input("Ada");
|
||||||
cx.simulate_keystrokes("cmd-enter");
|
cx.simulate_keystrokes("cmd-enter");
|
||||||
@@ -54,6 +62,21 @@ fn cmd_enter_in_a_field_submits_the_form_once(cx: &mut TestAppContext) {
|
|||||||
assert_eq!(counts, (1, 0));
|
assert_eq!(counts, (1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn cmd_enter_on_any_control_in_a_form_submits_it(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = signup(cx);
|
||||||
|
cx.update(|window, _| {
|
||||||
|
window.focus_next();
|
||||||
|
window.focus_next();
|
||||||
|
});
|
||||||
|
cx.simulate_keystrokes("cmd-enter");
|
||||||
|
assert_eq!(
|
||||||
|
view.read_with(cx, |view, _| view.sent),
|
||||||
|
1,
|
||||||
|
"the button holds focus"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
struct Rows {
|
struct Rows {
|
||||||
rows: Vec<&'static str>,
|
rows: Vec<&'static str>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use super::{
|
|||||||
edit::{self, next_grapheme, prev_grapheme},
|
edit::{self, next_grapheme, prev_grapheme},
|
||||||
element::TextElement,
|
element::TextElement,
|
||||||
};
|
};
|
||||||
|
use crate::forms::structure::FORM_CONTEXT;
|
||||||
|
|
||||||
actions!(
|
actions!(
|
||||||
ely_input,
|
ely_input,
|
||||||
@@ -87,6 +88,7 @@ pub(crate) fn bind_keys(cx: &mut App) {
|
|||||||
KeyBinding::new("enter", Enter, Some(CONTEXT)),
|
KeyBinding::new("enter", Enter, Some(CONTEXT)),
|
||||||
KeyBinding::new("shift-enter", Newline, Some(CONTEXT)),
|
KeyBinding::new("shift-enter", Newline, Some(CONTEXT)),
|
||||||
KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)),
|
KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)),
|
||||||
|
KeyBinding::new("secondary-enter", Submit, Some(FORM_CONTEXT)),
|
||||||
];
|
];
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
bindings.extend([
|
bindings.extend([
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::{
|
|||||||
|
|
||||||
type OnText = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
type OnText = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||||
|
|
||||||
/// Byte ranges of `new` that `old` lacks, word by word: neighbors joined, spaces and punctuation trimmed from each end.
|
/// Byte ranges of `new` that `old` lacks, word by word: neighbors joined, and anything but letters and digits trimmed from each end.
|
||||||
pub(crate) fn added_words(old: &str, new: &str) -> Vec<Range<usize>> {
|
pub(crate) fn added_words(old: &str, new: &str) -> Vec<Range<usize>> {
|
||||||
let diff = TextDiff::from_unicode_words(old, new);
|
let diff = TextDiff::from_unicode_words(old, new);
|
||||||
let mut ranges: Vec<Range<usize>> = Vec::new();
|
let mut ranges: Vec<Range<usize>> = Vec::new();
|
||||||
@@ -37,7 +37,7 @@ pub(crate) fn added_words(old: &str, new: &str) -> Vec<Range<usize>> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|range| {
|
.filter_map(|range| {
|
||||||
let word = &new[range.clone()];
|
let word = &new[range.clone()];
|
||||||
let glue = |ch: char| ch.is_whitespace() || ch.is_ascii_punctuation();
|
let glue = |ch: char| !ch.is_alphanumeric();
|
||||||
let start = range.start + word.len() - word.trim_start_matches(glue).len();
|
let start = range.start + word.len() - word.trim_start_matches(glue).len();
|
||||||
let end = range.end - (word.len() - word.trim_end_matches(glue).len());
|
let end = range.end - (word.len() - word.trim_end_matches(glue).len());
|
||||||
(start < end).then_some(start..end)
|
(start < end).then_some(start..end)
|
||||||
@@ -145,7 +145,7 @@ impl RenderOnce for PromptEnhancer {
|
|||||||
..HighlightStyle::default()
|
..HighlightStyle::default()
|
||||||
};
|
};
|
||||||
let card = div()
|
let card = div()
|
||||||
.mt_1()
|
.mt_3()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
@@ -202,9 +202,8 @@ impl RenderOnce for PromptEnhancer {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_2()
|
|
||||||
.child(Input::new(&self.field))
|
.child(Input::new(&self.field))
|
||||||
.children(enhance.map(|button| div().flex().justify_end().child(button)))
|
.children(enhance.map(|button| div().mt_2().flex().justify_end().child(button)))
|
||||||
.child(
|
.child(
|
||||||
Transition::new((self.id.clone(), "offered"), shown)
|
Transition::new((self.id.clone(), "offered"), shown)
|
||||||
.entrance(Entrance::Rise)
|
.entrance(Entrance::Rise)
|
||||||
@@ -231,6 +230,10 @@ mod tests {
|
|||||||
["pale limestone"],
|
["pale limestone"],
|
||||||
"a comma added after a kept word stays plain"
|
"a comma added after a kept word stays plain"
|
||||||
);
|
);
|
||||||
|
let new = "白い中庭,朝の光";
|
||||||
|
let ranges = added_words("白い中庭", new);
|
||||||
|
let words: Vec<&str> = ranges.iter().map(|range| &new[range.clone()]).collect();
|
||||||
|
assert_eq!(words, ["朝の光"], "any script's punctuation stays plain");
|
||||||
assert!(added_words("same words", "same words").is_empty());
|
assert!(added_words("same words", "same words").is_empty());
|
||||||
let whole = 2..7;
|
let whole = 2..7;
|
||||||
assert_eq!(added_words("", " a cat "), [whole]);
|
assert_eq!(added_words("", " a cat "), [whole]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke,
|
AppContext as _, Context, Entity, Focusable, IntoElement, KeyBinding, KeyUpEvent, Keystroke,
|
||||||
ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div,
|
ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div,
|
||||||
px,
|
px,
|
||||||
};
|
};
|
||||||
@@ -101,6 +101,11 @@ fn enhance_asks_with_the_trimmed_prompt_and_use_this_takes_the_offer(cx: &mut Te
|
|||||||
assert_eq!(view.heard, ["asked a cat", "taken true"]);
|
assert_eq!(view.heard, ["asked a cat", "taken true"]);
|
||||||
assert_eq!(view.field.read(cx).text(), "a fluffy cat asleep");
|
assert_eq!(view.field.read(cx).text(), "a fluffy cat asleep");
|
||||||
});
|
});
|
||||||
|
assert!(field_focused(&view, cx), "the field takes focus back");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_focused(view: &Entity<Enhancing>, cx: &mut VisualTestContext) -> bool {
|
||||||
|
cx.update(|window, cx| view.read(cx).field.focus_handle(cx).is_focused(window))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
@@ -119,6 +124,7 @@ fn keep_mine_leaves_the_prompt_as_written(cx: &mut TestAppContext) {
|
|||||||
assert_eq!(view.heard, ["taken false"]);
|
assert_eq!(view.heard, ["taken false"]);
|
||||||
assert_eq!(view.field.read(cx).text(), "a cat");
|
assert_eq!(view.field.read(cx).text(), "a cat");
|
||||||
});
|
});
|
||||||
|
assert!(field_focused(&view, cx), "the field takes focus back");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Three presets, two ratios, a seed, and every pick heard.
|
/// Three presets, two ratios, a seed, and every pick heard.
|
||||||
|
|||||||
Reference in new issue
Block a user