feat: add field component
This commit is contained in:
@@ -33,6 +33,7 @@ full = [
|
|||||||
"scroll_area",
|
"scroll_area",
|
||||||
"segmented",
|
"segmented",
|
||||||
"spinner",
|
"spinner",
|
||||||
|
"field",
|
||||||
"tooltip",
|
"tooltip",
|
||||||
"menu",
|
"menu",
|
||||||
"select",
|
"select",
|
||||||
@@ -57,6 +58,7 @@ popover = []
|
|||||||
scroll_area = []
|
scroll_area = []
|
||||||
segmented = []
|
segmented = []
|
||||||
spinner = []
|
spinner = []
|
||||||
|
field = []
|
||||||
tooltip = []
|
tooltip = []
|
||||||
menu = ["scroll_area"]
|
menu = ["scroll_area"]
|
||||||
select = ["menu"]
|
select = ["menu"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ pub mod segmented;
|
|||||||
#[cfg(feature = "spinner")]
|
#[cfg(feature = "spinner")]
|
||||||
pub mod spinner;
|
pub mod spinner;
|
||||||
|
|
||||||
|
#[cfg(feature = "field")]
|
||||||
|
pub mod field;
|
||||||
|
|
||||||
#[cfg(feature = "toast")]
|
#[cfg(feature = "toast")]
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -8,10 +8,8 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [ ] **Spinner** (`core`) — the loading ring, indeterminate + determinate;
|
- [ ] **Field wrapper** (M9) — label / description / error around any control;
|
||||||
painted-ring and rotating-icon variants.
|
the settings dialog.
|
||||||
Replaces `Tab::busy`'s placeholder ring; land before signed wires repo
|
|
||||||
loading into dock tabs.
|
|
||||||
- [x] API sketch
|
- [x] API sketch
|
||||||
- [x] Implementation (theme-baked, motion inline per AGENTS.md)
|
- [x] Implementation (theme-baked, motion inline per AGENTS.md)
|
||||||
- [x] Tests
|
- [x] Tests
|
||||||
@@ -20,8 +18,6 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Queue — to follow
|
## Queue — to follow
|
||||||
|
|
||||||
- [ ] **Field wrapper** (M9) — label / description / error around any control;
|
|
||||||
the settings dialog.
|
|
||||||
- [ ] **Tag** (M10) — removable server and relay entries.
|
- [ ] **Tag** (M10) — removable server and relay entries.
|
||||||
- [ ] **Status dot** (M11) — sync-drift beads on rows.
|
- [ ] **Status dot** (M11) — sync-drift beads on rows.
|
||||||
- [ ] **Combobox** (M12) — type-to-filter pickers; adopt gpuikit's
|
- [ ] **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,
|
- [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider,
|
||||||
Progress, Avatar (image + seeded pixel-art fallback)
|
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
|
- [x] Input — single-line, password with reveal, multiline, undo/redo, actions
|
||||||
+ key context, caret blink
|
+ key context, caret blink
|
||||||
- [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast,
|
- [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast,
|
||||||
|
|||||||
@@ -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::context_menu::ContextMenu;
|
||||||
use gpui_native_core::dialog::Dialog;
|
use gpui_native_core::dialog::Dialog;
|
||||||
use gpui_native_core::empty_state::EmptyState;
|
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::link::Link;
|
||||||
use gpui_native_core::menu::{Menu, MenuItem};
|
use gpui_native_core::menu::{Menu, MenuItem};
|
||||||
use gpui_native_core::popover::Popover;
|
use gpui_native_core::popover::Popover;
|
||||||
@@ -51,6 +52,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
|||||||
&[
|
&[
|
||||||
("button", "Button"),
|
("button", "Button"),
|
||||||
("input", "Input"),
|
("input", "Input"),
|
||||||
|
("field", "Field"),
|
||||||
("checkbox", "Checkbox"),
|
("checkbox", "Checkbox"),
|
||||||
("radio", "Radio"),
|
("radio", "Radio"),
|
||||||
("switch", "Switch"),
|
("switch", "Switch"),
|
||||||
@@ -382,6 +384,10 @@ impl Gallery {
|
|||||||
PanelDescriptor::new("input", "Input", |_, cx| cx.new(InputDemo::new).into()),
|
PanelDescriptor::new("input", "Input", |_, cx| cx.new(InputDemo::new).into()),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
dock.register_panel(
|
||||||
|
PanelDescriptor::new("field", "Field", |_, cx| cx.new(FieldDemo::new).into()),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("checkbox", "Checkbox", |_, cx| {
|
PanelDescriptor::new("checkbox", "Checkbox", |_, cx| {
|
||||||
cx.new(|_| CheckboxDemo { checked: false }).into()
|
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 {
|
struct CheckboxDemo {
|
||||||
checked: bool,
|
checked: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user