diff --git a/TASKS.md b/TASKS.md index 1534fc2..06ce4cd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -291,3 +291,4 @@ Per-component lines live in `tasks/`. Tags there: | 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 | 2 | PASS | | +| T25a | 1 | FAIL | Cmd-Enter generated only while a text field held focus; the form set no key context of its own | diff --git a/src/forms/structure.rs b/src/forms/structure.rs index 8f682cb..abc21ff 100644 --- a/src/forms/structure.rs +++ b/src/forms/structure.rs @@ -15,7 +15,10 @@ use crate::{ 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)] pub struct Form { id: ElementId, @@ -49,6 +52,7 @@ impl RenderOnce for Form { let id = self.id.clone(); div() .id(self.id) + .key_context(FORM_CONTEXT) .flex() .flex_col() .gap_8() diff --git a/src/forms/tests/structure.rs b/src/forms/tests/structure.rs index a099d9e..9f992fa 100644 --- a/src/forms/tests/structure.rs +++ b/src/forms/tests/structure.rs @@ -4,7 +4,10 @@ use gpui::{ }; 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 { name: Entity, @@ -27,13 +30,13 @@ impl Render for Signup { .required() .child(Input::new(&self.name)), ) + .child(Button::new("signup-save", "Save")) } } -#[gpui::test] -fn cmd_enter_in_a_field_submits_the_form_once(cx: &mut TestAppContext) { +fn signup(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { 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)); cx.subscribe(&name, |view: &mut Signup, _, event, _| { if *event == InputEvent::Submit { @@ -46,7 +49,12 @@ fn cmd_enter_in_a_field_submits_the_form_once(cx: &mut TestAppContext) { sent: 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.simulate_input("Ada"); 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)); } +#[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 { rows: Vec<&'static str>, } diff --git a/src/forms/text/actions.rs b/src/forms/text/actions.rs index 6133a9c..fcde0e1 100644 --- a/src/forms/text/actions.rs +++ b/src/forms/text/actions.rs @@ -9,6 +9,7 @@ use super::{ edit::{self, next_grapheme, prev_grapheme}, element::TextElement, }; +use crate::forms::structure::FORM_CONTEXT; actions!( ely_input, @@ -87,6 +88,7 @@ pub(crate) fn bind_keys(cx: &mut App) { KeyBinding::new("enter", Enter, Some(CONTEXT)), KeyBinding::new("shift-enter", Newline, Some(CONTEXT)), KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)), + KeyBinding::new("secondary-enter", Submit, Some(FORM_CONTEXT)), ]; if cfg!(target_os = "macos") { bindings.extend([ diff --git a/src/generative/prompt.rs b/src/generative/prompt.rs index 1f0ff73..a648280 100644 --- a/src/generative/prompt.rs +++ b/src/generative/prompt.rs @@ -16,7 +16,7 @@ use crate::{ type OnText = Rc; -/// 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> { let diff = TextDiff::from_unicode_words(old, new); let mut ranges: Vec> = Vec::new(); @@ -37,7 +37,7 @@ pub(crate) fn added_words(old: &str, new: &str) -> Vec> { .into_iter() .filter_map(|range| { 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 end = range.end - (word.len() - word.trim_end_matches(glue).len()); (start < end).then_some(start..end) @@ -145,7 +145,7 @@ impl RenderOnce for PromptEnhancer { ..HighlightStyle::default() }; let card = div() - .mt_1() + .mt_3() .flex() .flex_col() .gap_3() @@ -202,9 +202,8 @@ impl RenderOnce for PromptEnhancer { .w_full() .flex() .flex_col() - .gap_2() .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( Transition::new((self.id.clone(), "offered"), shown) .entrance(Entrance::Rise) @@ -231,6 +230,10 @@ mod tests { ["pale limestone"], "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()); let whole = 2..7; assert_eq!(added_words("", " a cat "), [whole]); diff --git a/src/generative/tests/mod.rs b/src/generative/tests/mod.rs index eb5cfea..3e494f6 100644 --- a/src/generative/tests/mod.rs +++ b/src/generative/tests/mod.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, rc::Rc}; 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, 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.field.read(cx).text(), "a fluffy cat asleep"); }); + assert!(field_focused(&view, cx), "the field takes focus back"); +} + +fn field_focused(view: &Entity, cx: &mut VisualTestContext) -> bool { + cx.update(|window, cx| view.read(cx).field.focus_handle(cx).is_focused(window)) } #[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.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.