feat: add field component

This commit is contained in:
2026-11-03 19:07:15 +07:00
parent 7399d92f36
commit 0c8d7f05d4
6 changed files with 404 additions and 7 deletions
+2
View File
@@ -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"]
+175
View File
@@ -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<SharedString>,
error: Option<SharedString>,
variant: FieldVariant,
control: Vec<AnyElement>,
style: StyleRefinement,
}
impl Field {
/// Creates a field naming its control.
pub fn new<I, L>(id: I, label: L) -> Self
where
I: Into<ElementId>,
L: Into<SharedString>,
{
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<D>(mut self, description: D) -> Self
where
D: Into<SharedString>,
{
self.description = Some(description.into());
self
}
/// The error under the control; `None` clears it.
pub fn error<E>(mut self, error: Option<E>) -> Self
where
E: Into<SharedString>,
{
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<Item = AnyElement>) {
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)
}
}
+3
View File
@@ -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;
+115
View File
@@ -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<Self>) -> 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:?}"
);
}
+4 -6
View File
@@ -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,
+105 -1
View File
@@ -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<InputState>,
name: Entity<InputState>,
notifications: bool,
sync_on_open: bool,
_subscription: gpui::Subscription,
}
impl FieldDemo {
fn new(cx: &mut Context<Self>) -> 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<Self>) -> 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,
}