From 05ff67ad9fd9bb3d8b3a93dc31dbb7be97e2d95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 10:46:27 -0400 Subject: [PATCH] T06g forms structure: form with Cmd-Enter submit, sections, fields, labels, errors, field arrays, inline forms, dirty indicator --- AGENTS.md | 3 +- TASKS.md | 5 +- examples/gallery/pages/forms/mod.rs | 19 ++ examples/gallery/pages/forms/structure.rs | 193 ++++++++++++ src/forms/checked.rs | 6 +- src/forms/mod.rs | 8 +- src/forms/pairs.rs | 181 +++++++---- src/forms/structure.rs | 368 ++++++++++++++++++++++ src/forms/tests/mod.rs | 1 + src/forms/tests/structure.rs | 113 +++++++ src/forms/text/mod.rs | 2 +- src/theme/tokens.rs | 5 + tasks/ch01-10.md | 24 +- 13 files changed, 850 insertions(+), 78 deletions(-) create mode 100644 examples/gallery/pages/forms/structure.rs create mode 100644 src/forms/structure.rs create mode 100644 src/forms/tests/structure.rs diff --git a/AGENTS.md b/AGENTS.md index bc7c5b7..c8ce407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,9 +62,10 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui's `Hsla::opacity` scales alpha; `alpha` sets it. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. - Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. +- gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. - Long grids scroll in a `uniform_list`. gpui has no nearest scroll, so a key move up scrolls with `Top` and down with `Bottom` (`forms::glyphs`). - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. -- Ids inside a reusable component carry its owner's id or `EntityId`. Twin ids share focus and click state. +- Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. - `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. diff --git a/TASKS.md b/TASKS.md index b0912e9..db9b761 100644 --- a/TASKS.md +++ b/TASKS.md @@ -28,7 +28,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T06d Forms · Color - [x] T06e Forms · Files - [x] T06f Forms · Other -- [ ] T06g Forms · Structure +- [x] T06g Forms · Structure - [ ] T07 Navigation - [ ] T08 Menus - [ ] T09 Overlays @@ -133,3 +133,6 @@ Per-component lines live in `tasks/`. Tags there: | T06e | 1 | PASS | | | T06f | 1 | FAIL | a stroke lost the move that started the drag and its release point; language codes were not searchable though the gallery said so; emoji search compared a lowercase query with mixed-case names | | T06f | 2 | PASS | | +| T06g | 1 | FAIL | form errors shared one animation id, so a later error skipped its entrance; the demo left links out of its saved snapshot; the demo let a lone @ pass as an email | +| T06g | 2 | FAIL | a field's error still keyed its entrance by the label, so twin labels in one form shared it | +| T06g | 3 | PASS | | diff --git a/examples/gallery/pages/forms/mod.rs b/examples/gallery/pages/forms/mod.rs index bfb2821..e2076ab 100644 --- a/examples/gallery/pages/forms/mod.rs +++ b/examples/gallery/pages/forms/mod.rs @@ -7,6 +7,7 @@ mod numbers; mod other; mod pickers; mod rich; +mod structure; mod text; mod values; @@ -299,6 +300,22 @@ const SCRIPT: &[Step] = &[ Step::Shot("country"), Step::Key("enter"), Step::Wait(300), + Step::Click("form-name"), + Step::Key("secondary-a"), + Step::Type("Ada Lovelace"), + Step::Click("form-email"), + Step::Key("secondary-a"), + Step::Type("ada.example.com"), + Step::Wait(400), + Step::Shot("form"), + Step::Hover("form-actions"), + Step::Wait(300), + Step::Shot("form-dirty"), + Step::Key("secondary-a"), + Step::Type("ada@example.com"), + Step::Key("cmd-enter"), + Step::Wait(400), + Step::Shot("form-saved"), Step::Rest, ]; @@ -351,5 +368,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(other::font(window, cx)) .child(other::glyphs(window, cx)) .child(other::regional(window, cx)) + .child(structure::form(window, cx)) + .child(structure::inline(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/forms/structure.rs b/examples/gallery/pages/forms/structure.rs new file mode 100644 index 0000000..3c0c715 --- /dev/null +++ b/examples/gallery/pages/forms/structure.rs @@ -0,0 +1,193 @@ +use ely_gpui_component::{ + buttons::{ActionBar, Button, ButtonVariant}, + forms::{ + DirtyIndicator, FieldArray, Form, FormField, FormSection, InlineForm, Input, TextInput, + is_email, + }, +}; +use gpui::{ + App, AppContext, Entity, IntoElement, ParentElement, Styled, Window, div, prelude::*, px, +}; + +use super::{ + choices::{keep, set}, + text::field, +}; +use crate::{ + probe::probe, + ui::{section, specimen}, +}; + +type Saved = (String, String, String, Vec); + +fn ready(now: &Saved) -> bool { + !now.0.trim().is_empty() && is_email(&now.1) +} + +fn snapshot(fields: &[Entity; 3], links: &[Entity], cx: &App) -> Saved { + let text = |field: &Entity| field.read(cx).text().to_string(); + let [a, b, c] = fields.each_ref().map(text); + (a, b, c, links.iter().map(text).collect()) +} + +fn link(text: String, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| { + let mut input = TextInput::new(window, cx).placeholder("https://"); + input.set_text(text, cx); + input + }) +} + +pub fn form(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let name = field("form-name", window, cx, |input| { + input.placeholder("Ada Lovelace") + }); + let email = field("form-email", window, cx, |input| { + input.placeholder("ada@example.com") + }); + let bio = field("form-bio", window, cx, |input| { + input.multi_line(3, 5).placeholder("A line about you") + }); + let links = keep("form-links", Vec::>::new, window, cx); + let saved = keep("form-saved", Saved::default, window, cx); + let sent = keep("form-sent", || 0usize, window, cx); + let fields = [name.clone(), email.clone(), bio.clone()]; + let now = snapshot(&fields, links.read(cx), cx); + let dirty = now != *saved.read(cx); + let problem = (!now.1.is_empty() && !is_email(&now.1)) + .then_some("Enter an address like name@example.com"); + let save = { + let (fields, links, saved, sent) = + (fields.clone(), links.clone(), saved.clone(), sent.clone()); + move |_: &mut Window, cx: &mut App| { + let now = snapshot(&fields, links.read(cx), cx); + if !ready(&now) { + log::info!("gallery form: kept unsaved until name and email are filled"); + return; + } + set(&saved, now, cx); + let count = *sent.read(cx) + 1; + set(&sent, count, cx); + } + }; + let discard = { + let (fields, links, saved) = (fields.clone(), links.clone(), saved.clone()); + move |window: &mut Window, cx: &mut App| { + let (a, b, c, kept) = saved.read(cx).clone(); + for (field, text) in fields.iter().zip([a, b, c]) { + field.update(cx, |input, cx| input.set_text(text, cx)); + } + let rows = kept + .into_iter() + .map(|text| link(text, window, cx)) + .collect(); + set(&links, rows, cx); + } + }; + let rows = links.read(cx).clone(); + let (grow, shrink) = (links.clone(), links); + let array = rows.iter().fold( + FieldArray::new("form-links") + .add_label("Add link") + .on_add(move |window, cx| { + let row = link(String::new(), window, cx); + grow.update(cx, |links, cx| { + links.push(row); + cx.notify(); + }); + }) + .on_remove(move |ix, _, cx| { + shrink.update(cx, |links, cx| { + links.remove(ix); + cx.notify(); + }) + }), + |array, link| array.row(Input::new(link)), + ); + let (save_click, count) = (save.clone(), *sent.read(cx)); + section( + "Form / FormSection / FormField / FieldArray / DirtyIndicator", + "Sections with room between them. Errors take the description's place; Cmd-Enter saves from any field.", + cx, + ) + .child(specimen( + match count { + 0 => "not saved yet".to_string(), + 1 => "saved once".to_string(), + count => format!("saved {count} times"), + }, + div().w(px(480.0)).child( + Form::new("profile-form") + .on_submit(save) + .child( + FormSection::new("Profile") + .description("How others see you.") + .child( + FormField::new("profile-name", "Name") + .required() + .child(probe("form-name", div().w(px(480.0)).child(Input::new(&name)))), + ) + .child( + FormField::new("profile-email", "Email") + .required() + .help("Only for sign-in and receipts.") + .description("We never share it.") + .when_some(problem, |field, problem| field.error(problem)) + .child(probe("form-email", div().w(px(480.0)).child(Input::new(&email)))), + ) + .child( + FormField::new("profile-bio", "Bio") + .description("Plain text, a few lines.") + .child(Input::new(&bio)), + ), + ) + .child( + FormSection::new("Links") + .description("Sites to show on your profile.") + .child(array), + ) + .child(probe( + "form-actions", + div().w(px(480.0)).child( + ActionBar::new() + .child(DirtyIndicator::new("form-dirty", dirty)) + .child(div().flex_1()) + .child( + Button::new("form-discard", "Discard") + .variant(ButtonVariant::Ghost) + .disabled(!dirty) + .on_click(move |_, window, cx| discard(window, cx)), + ) + .child( + Button::new("form-save", "Save") + .primary() + .disabled(!dirty || !ready(&now)) + .on_click(move |_, window, cx| save_click(window, cx)), + ), + ), + ) + ), + ), + cx, + )) +} + +pub fn inline(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let email = field("inline-email", window, cx, |input| { + input.placeholder("you@example.com") + }); + section( + "InlineForm", + "Fields and actions in one row, their bottoms aligned.", + cx, + ) + .child( + div().w(px(480.0)).child( + InlineForm::new() + .child(div().flex_1().child( + FormField::new("inline-newsletter", "Newsletter").child(Input::new(&email)), + )) + .child(Button::new("inline-join", "Subscribe").primary()), + ), + ) +} diff --git a/src/forms/checked.rs b/src/forms/checked.rs index 9679cd5..7854617 100644 --- a/src/forms/checked.rs +++ b/src/forms/checked.rs @@ -18,11 +18,13 @@ static URL: LazyLock = LazyLock::new(|| { Regex::new(r"^https?://[^\s/?#]+\.[^\s/?#]+\S*$").expect("url pattern compiles") }); -pub(crate) fn is_email(text: &str) -> bool { +/// Whether `text` reads as one email address. +pub fn is_email(text: &str) -> bool { EMAIL.is_match(text) } -pub(crate) fn is_url(text: &str) -> bool { +/// Whether `text` reads as a web link. +pub fn is_url(text: &str) -> bool { URL.is_match(text) } diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 2c316f6..85d0bbe 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -33,6 +33,7 @@ mod select; mod signature; mod slider; mod stepper; +mod structure; mod switch; mod tags; #[cfg(all(test, feature = "test-support"))] @@ -45,7 +46,7 @@ pub use card::{CheckboxCard, RadioCard}; pub use cascade::{Cascade, Cascader}; pub use catalogs::FontPicker; pub use check::{CheckState, Checkbox, CheckboxGroup}; -pub use checked::{EmailInput, UrlInput}; +pub use checked::{EmailInput, UrlInput, is_email, is_url}; pub use chips::ChoiceChips; pub use code::{CodeInput, JsonInput, code_highlights, json_highlights}; pub use color::{ColorPalette, ColorPicker, ColorSwatch, EyeDropper, GradientEditor, GradientStop}; @@ -69,7 +70,7 @@ pub use mention::{MentionInput, mention_highlights}; pub use multi::MultiSelect; pub use number::{NumberInput, ScrubInput}; pub use options::Choice; -pub use pairs::{KeyValueInput, ListInput}; +pub use pairs::{FieldArray, KeyValueInput, ListInput}; pub use path::PathInput; pub use pattern::{RegexInput, regex_highlights}; pub use pin::PinInput; @@ -80,6 +81,9 @@ pub use select::Select; pub use signature::{SignaturePad, Stroke}; pub use slider::{RangeSlider, Slider}; pub use stepper::Stepper; +pub use structure::{ + DirtyIndicator, Form, FormError, FormField, FormLabel, FormSection, InlineForm, +}; pub use switch::Switch; pub use tags::TagInput; pub(crate) use text::bind_keys; diff --git a/src/forms/pairs.rs b/src/forms/pairs.rs index 21af7e0..3173f9a 100644 --- a/src/forms/pairs.rs +++ b/src/forms/pairs.rs @@ -1,11 +1,11 @@ use std::rc::Rc; use gpui::{ - App, Context, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, - Subscription, Window, div, prelude::*, + AnyElement, App, Context, ElementId, Entity, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Subscription, Window, div, prelude::*, }; -use super::{Input, InputEvent, TextInput}; +use super::{Input, InputEvent, TextInput, options::Run}; use crate::{ buttons::{Button, ButtonVariant, IconButton}, primitives::IconName, @@ -85,68 +85,131 @@ fn rows_state( }) } -fn render_rows( - id: &ElementId, - state: Entity, - add: &'static str, - cx: &App, -) -> impl IntoElement + use<> { - let rows = state.read(cx).rows.len(); - let lines = (0..rows).map(|ix| { - let fields: Vec> = state.read(cx).rows[ix] - .iter() - .map(|(field, _)| field.clone()) - .collect(); - let remove = state.clone(); +type OnRemove = Rc; + +/// Rows the owner builds, each with a remove button, and a button that adds one. +#[derive(IntoElement)] +pub struct FieldArray { + id: ElementId, + rows: Vec, + add_label: SharedString, + on_add: Option, + on_remove: Option, +} + +impl FieldArray { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + rows: Vec::new(), + add_label: SharedString::from("Add"), + on_add: None, + on_remove: None, + } + } + + pub fn row(mut self, row: impl IntoElement) -> Self { + self.rows.push(row.into_any_element()); + self + } + + pub fn add_label(mut self, text: impl Into) -> Self { + self.add_label = text.into(); + self + } + + pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_add = Some(Rc::new(handler)); + self + } + + /// Runs with the index of the row to remove. + pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FieldArray { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let id = self.id.clone(); + let lines = self.rows.into_iter().enumerate().map(|(ix, row)| { + let (id, remove) = (id.clone(), self.on_remove.clone()); + div() + .flex() + .items_center() + .gap_2() + .child(div().flex_1().min_w_0().child(row)) + .child( + IconButton::new(("remove-row", ix), IconName::Minus) + .size(ControlSize::Sm) + .tooltip("Remove") + .on_click(move |_, window, cx| { + log::info!("field array {id:?}: removed row {ix}"); + if let Some(remove) = &remove { + remove(ix, window, cx); + } + }), + ) + }); + let add = self.on_add; div() + .id(self.id) .flex() - .items_center() + .flex_col() .gap_2() - .children(fields.iter().map(|field| { - div() - .flex_1() - .child(Input::new(field).size(ControlSize::Sm)) - })) + .w_full() + .children(lines) .child( - IconButton::new(("remove-row", ix), IconName::Minus) - .size(ControlSize::Sm) - .tooltip("Remove") - .on_click(move |_, window, cx| { - log::info!("rows: removed {ix}"); - remove.update(cx, |rows, cx| { - rows.rows.remove(ix); - rows.report(window, cx); - cx.notify(); - }) - }), + div().flex().child( + Button::new(("add-row", 0usize), self.add_label) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .icon(IconName::Plus) + .on_click(move |_, window, cx| { + log::info!("field array {id:?}: added a row"); + if let Some(add) = &add { + add(window, cx); + } + }), + ), ) - }); - let grow = state.clone(); - div() - .id(id.clone()) - .flex() - .flex_col() - .gap_2() - .w_full() - .children(lines) - .child( - div().child( - Button::new(("add-row", 0usize), add) - .size(ControlSize::Sm) - .variant(ButtonVariant::Ghost) - .icon(IconName::Plus) - .on_click(move |_, window, cx| { - log::info!("rows: added one"); - grow.update(cx, |rows, cx| { - rows.push(&[], window, cx); - let last = rows.rows.last().expect("just pushed"); - window.focus(&last[0].0.read(cx).focus().clone()); - rows.report(window, cx); - cx.notify(); - }) - }), - ), + } +} + +fn render_rows(id: &ElementId, state: Entity, add: &'static str, cx: &App) -> FieldArray { + let (grow, remove) = (state.clone(), state.clone()); + let array = FieldArray::new(id.clone()) + .add_label(add) + .on_add(move |window, cx| { + grow.update(cx, |rows, cx| { + rows.push(&[], window, cx); + let last = rows.rows.last().expect("just pushed"); + window.focus(&last[0].0.read(cx).focus().clone()); + rows.report(window, cx); + cx.notify(); + }) + }) + .on_remove(move |ix, window, cx| { + remove.update(cx, |rows, cx| { + rows.rows.remove(ix); + rows.report(window, cx); + cx.notify(); + }) + }); + state.read(cx).rows.iter().fold(array, |array, row| { + array.row( + div() + .flex() + .items_center() + .gap_2() + .children(row.iter().map(|(field, _)| { + div() + .flex_1() + .child(Input::new(field).size(ControlSize::Sm)) + })), ) + }) } /// Rows of key and value fields; add and remove rows. diff --git a/src/forms/structure.rs b/src/forms/structure.rs new file mode 100644 index 0000000..8f682cb --- /dev/null +++ b/src/forms/structure.rs @@ -0,0 +1,368 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, +}; +use smallvec::SmallVec; + +use super::{options::Run, text::Submit}; +use crate::{ + motion, + primitives::{Icon, IconName, Tooltip}, + theme::{ActiveTheme, IconSize, TextSize}, + typography::{Caption, Heading, Label}, +}; + +/// Sections stacked with room between them. Cmd-Enter in any field submits the form. +#[derive(IntoElement)] +pub struct Form { + id: ElementId, + children: SmallVec<[AnyElement; 4]>, + on_submit: Option, +} + +impl Form { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + children: SmallVec::new(), + on_submit: None, + } + } + + pub fn on_submit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_submit = Some(Rc::new(handler)); + self + } +} + +impl ParentElement for Form { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for Form { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let id = self.id.clone(); + div() + .id(self.id) + .flex() + .flex_col() + .gap_8() + .w_full() + .when_some(self.on_submit, |form, submit| { + form.capture_action(move |_: &Submit, window, cx| { + cx.stop_propagation(); + log::info!("form {id:?}: submitted"); + submit(window, cx); + }) + }) + .children(self.children) + } +} + +/// A titled group of fields, with a line of context under the title. +#[derive(IntoElement)] +pub struct FormSection { + title: SharedString, + description: Option, + children: SmallVec<[AnyElement; 4]>, +} + +impl FormSection { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + description: None, + children: SmallVec::new(), + } + } + + pub fn description(mut self, text: impl Into) -> Self { + self.description = Some(text.into()); + self + } +} + +impl ParentElement for FormSection { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for FormSection { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_4() + .w_full() + .child( + div() + .flex() + .flex_col() + .gap_1() + .child(Heading::h4(self.title)) + .children(self.description.map(Caption::new)), + ) + .children(self.children) + } +} + +/// A field's name, with a required mark and a help tip when asked. +#[derive(IntoElement)] +pub struct FormLabel { + id: ElementId, + text: SharedString, + required: bool, + help: Option, +} + +impl FormLabel { + pub fn new(id: impl Into, text: impl Into) -> Self { + Self { + id: id.into(), + text: text.into(), + required: false, + help: None, + } + } + + pub fn required(mut self) -> Self { + self.required = true; + self + } + + /// A tip behind a help mark beside the name. + pub fn help(mut self, text: impl Into) -> Self { + self.help = Some(text.into()); + self + } +} + +impl RenderOnce for FormLabel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = &cx.theme().colors; + div() + .flex() + .items_center() + .gap_1() + .child(Label::new(self.text)) + .when(self.required, |label| { + label.child(div().text_color(colors.danger).child("*")) + }) + .when_some(self.help, |label, help| { + label.child( + div() + .id((self.id, "help")) + .child( + Icon::new(IconName::CircleHelp) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .tooltip(Tooltip::text(help)), + ) + }) + } +} + +/// Why a field failed, in the danger color. Each new message eases in. +#[derive(IntoElement)] +pub struct FormError { + id: ElementId, + text: SharedString, +} + +impl FormError { + pub fn new(id: impl Into, text: impl Into) -> Self { + Self { + id: id.into(), + text: text.into(), + } + } +} + +impl RenderOnce for FormError { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let danger = theme.colors.danger; + div() + .flex() + .items_center() + .gap_1() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(danger) + .child( + Icon::new(IconName::CircleAlert) + .size(IconSize::Xs) + .color(danger), + ) + .child(self.text.clone()) + .with_animation( + ElementId::from((self.id, self.text)), + Animation::new(motion::duration(motion::FAST, cx)) + .with_easing(motion::ease_out_cubic), + |error, t| error.opacity(t).mt(motion::NUDGE * (t - 1.0)), + ) + } +} + +/// A labeled control, with a description beneath, or the error once it fails. +#[derive(IntoElement)] +pub struct FormField { + id: ElementId, + label: FormLabel, + description: Option, + error: Option, + children: SmallVec<[AnyElement; 2]>, +} + +impl FormField { + pub fn new(id: impl Into, label: impl Into) -> Self { + let id = id.into(); + Self { + label: FormLabel::new((id.clone(), "label"), label), + id, + description: None, + error: None, + children: SmallVec::new(), + } + } + + pub fn required(mut self) -> Self { + self.label = self.label.required(); + self + } + + pub fn help(mut self, text: impl Into) -> Self { + self.label = self.label.help(text); + self + } + + pub fn description(mut self, text: impl Into) -> Self { + self.description = Some(text.into()); + self + } + + /// Shown in place of the description. + pub fn error(mut self, text: impl Into) -> Self { + self.error = Some(text.into()); + self + } +} + +impl ParentElement for FormField { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for FormField { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let note = match (self.error, self.description) { + (Some(error), _) => Some(FormError::new((self.id, "error"), error).into_any_element()), + (None, Some(description)) => Some(Caption::new(description).into_any_element()), + (None, None) => None, + }; + div() + .flex() + .flex_col() + .gap_1p5() + .w_full() + .child(self.label) + .children(self.children) + .children(note) + } +} + +/// Fields and actions in one row, their bottoms aligned; the row wraps when narrow. +#[derive(IntoElement)] +pub struct InlineForm { + children: SmallVec<[AnyElement; 4]>, +} + +impl InlineForm { + pub fn new() -> Self { + Self { + children: SmallVec::new(), + } + } +} + +impl Default for InlineForm { + fn default() -> Self { + Self::new() + } +} + +impl ParentElement for InlineForm { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for InlineForm { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .flex() + .flex_wrap() + .items_end() + .gap_3() + .w_full() + .children(self.children) + } +} + +/// A dot and a word while there are unsaved changes; nothing once saved. +#[derive(IntoElement)] +pub struct DirtyIndicator { + id: ElementId, + dirty: bool, + label: SharedString, +} + +impl DirtyIndicator { + pub fn new(id: impl Into, dirty: bool) -> Self { + Self { + id: id.into(), + dirty, + label: SharedString::from("Unsaved changes"), + } + } + + pub fn label(mut self, text: impl Into) -> Self { + self.label = text.into(); + self + } +} + +impl RenderOnce for DirtyIndicator { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let shown = self.dirty.then(|| { + div() + .flex() + .items_center() + .gap_1p5() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child( + div() + .size(theme.status_dot()) + .rounded_full() + .bg(theme.colors.warning), + ) + .child(self.label) + .with_animation( + self.id, + Animation::new(motion::duration(motion::BASE, cx)) + .with_easing(motion::ease_out_cubic), + |shown, t| shown.opacity(t), + ) + }); + div().children(shown) + } +} diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs index 5a1a947..065e0eb 100644 --- a/src/forms/tests/mod.rs +++ b/src/forms/tests/mod.rs @@ -2,6 +2,7 @@ mod choices; mod colors; mod dates; mod other; +mod structure; use gpui::{ AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke, diff --git a/src/forms/tests/structure.rs b/src/forms/tests/structure.rs new file mode 100644 index 0000000..a099d9e --- /dev/null +++ b/src/forms/tests/structure.rs @@ -0,0 +1,113 @@ +use gpui::{ + AppContext as _, Context, Entity, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, + TestAppContext, VisualTestContext, Window, div, +}; + +use super::setup; +use crate::forms::{FieldArray, Form, FormField, Input, InputEvent, TextInput}; + +struct Signup { + name: Entity, + sent: usize, + field_submits: usize, +} + +impl Render for Signup { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + Form::new("signup") + .on_submit(move |_, cx| { + view.update(cx, |view, cx| { + view.sent += 1; + cx.notify(); + }) + }) + .child( + FormField::new("signup-name", "Name") + .required() + .child(Input::new(&self.name)), + ) + } +} + +#[gpui::test] +fn cmd_enter_in_a_field_submits_the_form_once(cx: &mut TestAppContext) { + setup(cx); + let (view, 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 { + view.field_submits += 1; + } + }) + .detach(); + Signup { + name, + sent: 0, + field_submits: 0, + } + }); + cx.update(|window, _| window.focus_next()); + cx.simulate_input("Ada"); + cx.simulate_keystrokes("cmd-enter"); + let counts = view.read_with(cx, |view, _| (view.sent, view.field_submits)); + assert_eq!(counts, (1, 0)); +} + +struct Rows { + rows: Vec<&'static str>, +} + +impl Render for Rows { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (grow, shrink) = (cx.entity(), cx.entity()); + self.rows.iter().fold( + FieldArray::new("rows") + .on_add(move |_, cx| { + grow.update(cx, |view, cx| { + view.rows.push("new"); + cx.notify(); + }) + }) + .on_remove(move |ix, _, cx| { + shrink.update(cx, |view, cx| { + view.rows.remove(ix); + cx.notify(); + }) + }), + |array, row| array.row(div().child(*row)), + ) + } +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).unwrap(), + }); +} + +#[gpui::test] +fn field_array_buttons_remove_their_own_row_and_add_one(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Rows { + rows: vec!["a", "b", "c"], + }); + cx.update(|window, _| { + window.focus_next(); + window.focus_next(); + }); + press("space", cx); + assert_eq!(view.read_with(cx, |view, _| view.rows.clone()), ["a", "c"]); + cx.update(|window, _| { + window.blur(); + for _ in 0..3 { + window.focus_next(); + } + }); + press("space", cx); + assert_eq!( + view.read_with(cx, |view, _| view.rows.clone()), + ["a", "c", "new"] + ); +} diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index ba59dfe..e54fc61 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -10,7 +10,7 @@ use gpui::{ Subscription, Task, Window, WrappedLine, }; -pub(crate) use actions::{Backspace, Down, Enter, Up, bind_keys}; +pub(crate) use actions::{Backspace, Down, Enter, Submit, Up, bind_keys}; use edit::{History, Snapshot}; use crate::theme::ActiveTheme; diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index fd00d8b..7356894 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -243,6 +243,11 @@ impl Theme { px_to_rems(6.0) } + /// A dot that marks a state, such as unsaved changes. + pub fn status_dot(&self) -> Rems { + px_to_rems(6.0) + } + /// A switch's track; its thumb fills the height inside a small inset. pub fn switch_track(&self) -> Size { size(px_to_rems(32.0), px_to_rems(18.0)) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 9cda409..09aca41 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -266,17 +266,17 @@ ## T06g Forms · Structure -- [ ] Form -- [ ] FormField -- [ ] FormLabel -- [ ] FormDescription / HelperText -- [ ] FormError / ValidationMessage -- [ ] FormSection -- [ ] FormActions -- [ ] FieldArray -- [ ] InlineForm -- [ ] WizardForm / MultiStepForm -- [ ] DirtyIndicator +- [x] Form → `forms::Form`, Cmd-Enter submits +- [x] FormField → `forms::FormField` +- [x] FormLabel — required mark, help tip → `forms::FormLabel` +- [x] FormDescription / HelperText → `typography::Caption`, placed by `FormField::description` +- [x] FormError / ValidationMessage → `forms::FormError` +- [x] FormSection → `forms::FormSection` +- [x] FormActions → `buttons::ActionBar` +- [x] FieldArray → `forms::FieldArray`; ListInput and KeyValueInput use it +- [x] InlineForm → `forms::InlineForm` +- [x] WizardForm / MultiStepForm → lands with Navigation (T07): its frame is the Wizard +- [x] DirtyIndicator → `forms::DirtyIndicator` ## T07 Navigation — `src/navigation` @@ -286,7 +286,7 @@ - [ ] Breadcrumb - [ ] Pagination - [ ] LoadMore -- [ ] Stepper / Steps / Wizard — as `Steps` +- [ ] Stepper / Steps / Wizard — as `Steps`; also T06g WizardForm - [ ] Sidebar Navigation / NavItem / NavGroup - [ ] NavigationMenu - [ ] ActivityBar Item → `shell::ActivityBar`