diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 52614dd..392fbae 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -33,6 +33,7 @@ full = [ "scroll_area", "segmented", "spinner", + "field", "tooltip", "menu", "select", @@ -57,6 +58,7 @@ popover = [] scroll_area = [] segmented = [] spinner = [] +field = [] tooltip = [] menu = ["scroll_area"] select = ["menu"] diff --git a/crates/core/src/field.rs b/crates/core/src/field.rs new file mode 100644 index 0000000..e3ccc3a --- /dev/null +++ b/crates/core/src/field.rs @@ -0,0 +1,175 @@ +//! The field: a label, description, and error wrapped around a control. + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, + Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, +}; +use gpui_native_theme::ActiveTheme as _; + +use crate::common::{StyledExt, h_flex, v_flex}; + +/// The arrangement of a [`Field`]'s label and control. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FieldVariant { + /// The label block sits above the control, full width. + #[default] + Block, + /// The label block leads and the control trails — the settings row. + Row, +} + +/// A labeled control: the label, description, and error around any element. +#[derive(IntoElement)] +pub struct Field { + id: ElementId, + label: SharedString, + description: Option, + error: Option, + variant: FieldVariant, + control: Vec, + style: StyleRefinement, +} + +impl Field { + /// Creates a field naming its control. + pub fn new(id: I, label: L) -> Self + where + I: Into, + L: Into, + { + Self { + id: id.into(), + label: label.into(), + description: None, + error: None, + variant: FieldVariant::default(), + control: Vec::new(), + style: StyleRefinement::default(), + } + } + + /// The helper text under the label. + pub fn description(mut self, description: D) -> Self + where + D: Into, + { + self.description = Some(description.into()); + self + } + + /// The error under the control; `None` clears it. + pub fn error(mut self, error: Option) -> Self + where + E: Into, + { + self.error = error.map(Into::into); + self + } + + /// Sets the field's arrangement. + pub fn variant(mut self, variant: FieldVariant) -> Self { + self.variant = variant; + self + } +} + +impl ParentElement for Field { + fn extend(&mut self, children: impl IntoIterator) { + self.control.extend(children); + } +} + +impl Styled for Field { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Field { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let metrics = theme.sizes.medium; + let error_color = theme.ramps.destructive.base; + let id = self.id.clone(); + + let title = div() + .debug_selector({ + let name = format!("{id}-label"); + move || name + }) + .text_size(metrics.text_size) + .child(self.label.clone()); + + let control = h_flex() + .items_center() + .gap_2() + .debug_selector({ + let name = format!("{id}-control"); + move || name + }) + .children(self.control); + + let error = self.error.map(|error| { + div() + .debug_selector({ + let name = format!("{id}-error"); + move || name + }) + .text_size(metrics.caption_size) + .text_color(error_color) + .child(error) + }); + + let description_text = |cx: &App| { + div() + .text_size(metrics.caption_size) + .text_color(cx.theme().colors.muted_foreground) + }; + + let content = if self.variant == FieldVariant::Row { + div() + .flex() + .flex_col() + .gap_1() + .child( + h_flex() + .items_center() + .justify_between() + .gap_4() + .child(title.flex_1().min_w_0()) + .child(control.flex_none()), + ) + .when_some(self.description.clone(), |field, description| { + field.child(description_text(cx).child(description)) + }) + .when_some(error, |field, error| field.child(error)) + } else { + div() + .flex() + .flex_col() + .gap_2() + .child( + v_flex() + .gap_0p5() + .child(title) + .when_some(self.description.clone(), |slot, description| { + slot.child(description_text(cx).child(description)) + }), + ) + .child(control) + .when_some(error, |field, error| field.child(error)) + }; + + div() + .id(self.id) + .w_full() + .role(Role::Group) + .aria_label(self.label.clone()) + .when_some(self.description, |field, description| { + field.aria_description(description) + }) + .refine_style(&self.style) + .child(content) + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 60c7925..73715ac 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -69,6 +69,9 @@ pub mod segmented; #[cfg(feature = "spinner")] pub mod spinner; +#[cfg(feature = "field")] +pub mod field; + #[cfg(feature = "toast")] pub mod toast; diff --git a/crates/core/tests/field.rs b/crates/core/tests/field.rs new file mode 100644 index 0000000..757f751 --- /dev/null +++ b/crates/core/tests/field.rs @@ -0,0 +1,115 @@ +use gpui::{ + AppContext as _, IntoElement, ParentElement as _, Render, Styled as _, TestAppContext, + VisualTestContext, Window, div, +}; +use gpui_native_core::button::{Button, ButtonVariant}; +use gpui_native_core::field::{Field, FieldVariant}; +use gpui_native_theme::Theme; + +struct FieldHost { + variant: FieldVariant, + with_error: bool, +} + +impl Render for FieldHost { + fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context) -> impl IntoElement { + let mut field = Field::new("test-field", "Server URL") + .description("Where patches are sent.") + .variant(self.variant) + .child( + Button::new("test-field-button") + .label("Edit") + .variant(ButtonVariant::Ghost), + ); + if self.with_error { + field = field.error(Some("Required")); + } + + div().size_full().p_4().child(field) + } +} + +fn field_window( + variant: FieldVariant, + with_error: bool, +) -> (TestAppContext, gpui::AnyWindowHandle) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let window = cx.add_window(move |_, _| FieldHost { + variant, + with_error, + }); + let window: gpui::AnyWindowHandle = window.into(); + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + (cx, window) +} + +#[test] +fn block_stacks_the_label_above_the_control() { + let (cx, window) = field_window(FieldVariant::Block, false); + let mut visual = VisualTestContext::from_window(window, &cx); + let label = visual + .debug_bounds("test-field-label") + .expect("label missing"); + let control = visual + .debug_bounds("test-field-control") + .expect("control missing"); + + assert!( + label.bottom() <= control.top(), + "label must sit above the control: {label:?} vs {control:?}" + ); + assert!( + (label.left() - control.left()).abs() < gpui::px(1.), + "label and control must share the left edge" + ); +} + +#[test] +fn row_leads_with_the_label_and_trails_the_control() { + let (cx, window) = field_window(FieldVariant::Row, false); + let mut visual = VisualTestContext::from_window(window, &cx); + let label = visual + .debug_bounds("test-field-label") + .expect("label missing"); + let control = visual + .debug_bounds("test-field-control") + .expect("control missing"); + + assert!( + control.left() >= label.right(), + "the control must trail the label: {label:?} vs {control:?}" + ); + let label_center = (label.top() + label.bottom()) / 2.; + let control_center = (control.top() + control.bottom()) / 2.; + assert!( + (label_center - control_center).abs() < gpui::px(1.5), + "the control must center on the title line: {label:?} vs {control:?}" + ); +} + +#[test] +fn the_error_renders_under_the_control_only_when_set() { + let (cx, window) = field_window(FieldVariant::Block, false); + let mut visual = VisualTestContext::from_window(window, &cx); + assert!( + visual.debug_bounds("test-field-error").is_none(), + "no error must render when none is set" + ); + + let (cx, window) = field_window(FieldVariant::Block, true); + let mut visual = VisualTestContext::from_window(window, &cx); + let error = visual + .debug_bounds("test-field-error") + .expect("error missing"); + let control = visual + .debug_bounds("test-field-control") + .expect("control missing"); + assert!( + error.top() >= control.bottom(), + "the error must sit under the control: {control:?} vs {error:?}" + ); +} diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 75b7649..0552560 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -8,10 +8,8 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Spinner** (`core`) — the loading ring, indeterminate + determinate; - painted-ring and rotating-icon variants. - Replaces `Tab::busy`'s placeholder ring; land before signed wires repo - loading into dock tabs. +- [ ] **Field wrapper** (M9) — label / description / error around any control; + the settings dialog. - [x] API sketch - [x] Implementation (theme-baked, motion inline per AGENTS.md) - [x] Tests @@ -20,8 +18,6 @@ is the first downstream consumer; its screens drive the order. ## Queue — to follow -- [ ] **Field wrapper** (M9) — label / description / error around any control; - the settings dialog. - [ ] **Tag** (M10) — removable server and relay entries. - [ ] **Status dot** (M11) — sync-drift beads on rows. - [ ] **Combobox** (M12) — type-to-filter pickers; adopt gpuikit's @@ -59,6 +55,8 @@ is the first downstream consumer; its screens drive the order. - [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider, Progress, Avatar (image + seeded pixel-art fallback) +- [x] Spinner — painted ring + rotating-icon variant, indeterminate and + determinate; replaces `Tab::busy`'s placeholder - [x] Input — single-line, password with reveal, multiline, undo/redo, actions + key context, caret blink - [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast, diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 1e0a727..af4b5e0 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -15,7 +15,8 @@ use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex}; use gpui_native_core::context_menu::ContextMenu; use gpui_native_core::dialog::Dialog; use gpui_native_core::empty_state::EmptyState; -use gpui_native_core::input::{Input, InputState}; +use gpui_native_core::field::{Field, FieldVariant}; +use gpui_native_core::input::{Input, InputState, InputStateEvent}; use gpui_native_core::link::Link; use gpui_native_core::menu::{Menu, MenuItem}; use gpui_native_core::popover::Popover; @@ -51,6 +52,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ &[ ("button", "Button"), ("input", "Input"), + ("field", "Field"), ("checkbox", "Checkbox"), ("radio", "Radio"), ("switch", "Switch"), @@ -382,6 +384,10 @@ impl Gallery { PanelDescriptor::new("input", "Input", |_, cx| cx.new(InputDemo::new).into()), cx, ); + dock.register_panel( + PanelDescriptor::new("field", "Field", |_, cx| cx.new(FieldDemo::new).into()), + cx, + ); dock.register_panel( PanelDescriptor::new("checkbox", "Checkbox", |_, cx| { cx.new(|_| CheckboxDemo { checked: false }).into() @@ -844,6 +850,104 @@ impl Render for InputDemo { } } +struct FieldDemo { + url: Entity, + name: Entity, + notifications: bool, + sync_on_open: bool, + _subscription: gpui::Subscription, +} + +impl FieldDemo { + fn new(cx: &mut Context) -> Self { + let url = InputState::new(cx); + let name = InputState::new_with_text("reya", cx); + let subscription = cx.subscribe(&url, |_, _, event, cx| { + if matches!(event, InputStateEvent::TextChanged) { + cx.notify(); + } + }); + Self { + url, + name, + notifications: false, + sync_on_open: true, + _subscription: subscription, + } + } +} + +impl Render for FieldDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let colors = cx.theme().colors; + let url_error = self.url.read(cx).value().is_empty().then_some("Required"); + let url_error_render = url_error.map(SharedString::from); + + ScrollArea::new("field-demo") + .size_full() + .p_4() + .child(demo_section( + "block", + colors, + v_flex() + .gap_3() + .child( + Field::new("field-demo-url", "Server URL") + .description("Where your patches are sent.") + .error(url_error_render) + .child(Input::new("field-demo-url-input", self.url.clone())), + ) + .child( + Field::new("field-demo-name", "Display name") + .description("Shown next to your commits.") + .child(Input::new("field-demo-name-input", self.name.clone())), + ), + )) + .child(demo_section( + "row", + colors, + v_flex() + .gap_3() + .child( + Field::new("field-demo-notifications", "Desktop notifications") + .description("Tell me when a push lands.") + .variant(FieldVariant::Row) + .child( + Switch::new("field-demo-notifications-switch") + .checked(self.notifications) + .on_change({ + let view = view.clone(); + move |checked, _, cx| { + view.update(cx, |demo, cx| { + demo.notifications = checked; + cx.notify(); + }); + } + }), + ), + ) + .child( + Field::new("field-demo-sync", "Sync on open") + .variant(FieldVariant::Row) + .child( + Switch::new("field-demo-sync-switch") + .checked(self.sync_on_open) + .on_change({ + let view = view.clone(); + move |checked, _, cx| { + view.update(cx, |demo, cx| { + demo.sync_on_open = checked; + cx.notify(); + }); + } + }), + ), + ), + )) + } +} + struct CheckboxDemo { checked: bool, }