use crate::harness;
use crate::source_scan;
use gpui::{
point, prelude::*, px, Bounds, Focusable, Modifiers, MouseButton, Pixels, TestAppContext,
VisualTestContext,
};
use herogpui_components::{
Button, ColorField, Date, DateField, FieldGroup, Fieldset, FieldsetLegend, Input, InputAddon,
InputGroup, InputOTP, InputState, InputType, NumberField, NumberState, OtpPattern, OtpState,
PickerColor, SearchField, TextArea, TextField, Time, TimeField, TimeState,
};
use harness::{click, events, open_host, press, probe as inset_probe};
fn flush_frame(cx: &mut VisualTestContext) {
cx.update(|window, _| window.refresh());
}
const SEARCH_CLEAR_BOX: f32 = 20.;
const SEARCH_CLEAR_PRESS_SCALE: f32 = 0.93;
fn clear_probe(entity_id: u64) -> &'static str {
Box::leak(format!("input-clear-{entity_id}").into_boxed_str())
}
fn clear_bounds(cx: &mut VisualTestContext, entity_id: u64) -> Bounds<Pixels> {
cx.debug_bounds(clear_probe(entity_id))
.unwrap_or_else(|| panic!("the SearchField clear button {entity_id} must paint"))
}
fn bounds_centre(bounds: Bounds<Pixels>) -> gpui::Point<Pixels> {
point(
bounds.origin.x + bounds.size.width / 2.,
bounds.origin.y + bounds.size.height / 2.,
)
}
fn near(value: Pixels, expected: f32) -> bool {
(f32::from(value) - expected).abs() < 0.5
}
#[gpui::test]
fn text_field_accepts_platform_text_without_a_printable_key(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::with_value(cx, "replace me"));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
TextField::new(state_for_view.clone())
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_keystrokes("ctrl-a");
cx.update(|window, app| {
window.dispatch_keystroke(
gpui::Keystroke {
key: "unidentified".into(),
key_char: Some("東京😀".into()),
modifiers: Modifiers::default(),
},
app,
);
});
assert_eq!(
state.read_with(cx, |state, _| state.value().to_owned()),
"東京😀"
);
assert_eq!(recorded.borrow().as_slice(), ["東京😀"]);
}
#[gpui::test]
fn text_field_typing_reports_and_holds(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
TextField::new(state_for_view.clone())
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("hello");
assert_eq!(
recorded.borrow().as_slice(),
["h", "he", "hel", "hell", "hello"],
"typing must report the growing value on every keystroke"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(value, "hello", "the InputState must hold what was typed");
press(cx, "left");
press(cx, "left");
press(cx, "x");
assert_eq!(
recorded.borrow().as_slice(),
["h", "he", "hel", "hell", "hello", "helxlo"],
"a keystroke after Left Left must insert at cursor 3, not append"
);
let moved = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
moved, "helxlo",
"the InputState must hold the mid-string insert"
);
}
#[gpui::test]
fn text_field_backspace_and_select_all(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
TextField::new(state_for_view.clone())
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("hello");
press(cx, "backspace");
assert_eq!(
recorded.borrow().as_slice(),
["h", "he", "hel", "hell", "hello", "hell"],
"Backspace must delete the char before the caret and report the loss"
);
press(cx, "ctrl-a");
let selection = cx.update(|_, cx| state.read(cx).selection());
assert_eq!(
selection,
Some((0, 4)),
"Ctrl+A must select the whole value: anchor 0 to cursor 4"
);
press(cx, "x");
assert_eq!(
recorded.borrow().as_slice(),
["h", "he", "hel", "hell", "hello", "hell", "x"],
"typing over a selection must replace it outright"
);
let replaced = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(replaced, "x", "the InputState must hold the replaced value");
}
#[gpui::test]
fn input_max_length_blocks_rejected_edits_without_change(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
Input::new(state_for_view.clone())
.max_length(3)
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("abcd");
press(cx, "space");
cx.write_to_clipboard(gpui::ClipboardItem::new_string("z".to_owned()));
press(cx, "ctrl-v");
press(cx, "delete");
press(cx, "home");
press(cx, "backspace");
assert_eq!(
recorded.borrow().as_slice(),
["a", "ab", "abc"],
"rejected insertions and boundary deletions must not emit duplicate changes"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
value, "abc",
"the owned InputState must stop at the configured maximum length"
);
}
#[gpui::test]
fn password_input_copy_and_cut_never_reach_the_clipboard(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
Input::new(state_for_view.clone())
.input_type(InputType::Password)
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("hunter2");
cx.write_to_clipboard(gpui::ClipboardItem::new_string("sentinel".to_owned()));
press(cx, "ctrl-a");
press(cx, "ctrl-c");
press(cx, "cmd-c");
press(cx, "ctrl-x");
press(cx, "cmd-x");
let clipboard = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(
clipboard.as_deref(),
Some("sentinel"),
"copy and cut on a masked field must leave the clipboard untouched"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
value, "hunter2",
"cut on a masked field must not delete the value"
);
assert_eq!(
recorded.borrow().last().map(String::as_str),
Some("hunter2"),
"cut on a masked field must not emit a change"
);
}
#[gpui::test]
fn plain_input_copy_and_cut_still_use_the_clipboard(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
Input::new(state_for_view.clone()).into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("hello");
press(cx, "ctrl-a");
press(cx, "ctrl-c");
let copied = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(
copied.as_deref(),
Some("hello"),
"copy must write the selection"
);
cx.write_to_clipboard(gpui::ClipboardItem::new_string(String::new()));
press(cx, "ctrl-a");
press(cx, "ctrl-x");
let cut = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(
cut.as_deref(),
Some("hello"),
"cut must write the selection"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(value, "", "cut must delete the selection");
}
#[gpui::test]
fn text_area_typing_and_newline(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
TextArea::new(state_for_view.clone())
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 10., 40.);
cx.simulate_input("ab");
press(cx, "enter");
cx.simulate_input("cd");
assert_eq!(
recorded.borrow().as_slice(),
["a", "ab", "ab\n", "ab\nc", "ab\ncd"],
"Enter must insert a newline into the value, and typing must continue \
after it"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
value, "ab\ncd",
"the InputState must hold the multi-line value"
);
}
#[gpui::test]
fn text_area_max_length_rejects_a_newline_without_change(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
TextArea::new(state_for_view.clone())
.max_length(2)
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 10., 40.);
cx.simulate_input("ab");
press(cx, "enter");
assert_eq!(
recorded.borrow().as_slice(),
["a", "ab"],
"a rejected newline must not report an unchanged TextArea value"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(value, "ab");
}
#[gpui::test]
fn text_area_value_builder_seeds_the_bound_state(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
TextArea::new(&state_for_view)
.value("seeded")
.into_any_element()
});
let seeded = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
seeded, "seeded",
"TextArea::value must forward to the inner field's seed, without &mut App"
);
}
#[gpui::test]
fn input_value_builder_seeds_the_bound_state_at_first_render(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
Input::new(&state_for_view)
.value("Ada Lovelace")
.into_any_element()
});
let seeded = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
seeded, "Ada Lovelace",
"value must seed the bound InputState when the field first renders"
);
}
#[gpui::test]
fn input_value_builder_keeps_the_last_call_and_outranks_default_value(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
Input::new(&state_for_view)
.default_value("uncontrolled")
.value("first")
.value("controlled")
.into_any_element()
});
let seeded = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
seeded, "controlled",
"a pure builder keeps the last call, and the controlled spelling outranks defaultValue"
);
}
#[gpui::test]
fn input_value_builder_seeds_once_and_never_clobbers_edits(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
Input::new(&state_for_view)
.value("seed")
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
let seeded = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(seeded, "seed");
press(cx, "tab");
cx.simulate_input("!");
flush_frame(cx);
let typed = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
typed, "seed!",
"a re-render must not re-apply the value seed over the user's edit"
);
assert_eq!(
recorded.borrow().as_slice(),
["seed!"],
"the seeded-then-edited value reports exactly once, from the keystroke"
);
}
#[gpui::test]
fn text_field_value_builder_seeds_the_bound_state(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
TextField::new(&state_for_view)
.value("labelled seed")
.into_any_element()
});
let seeded = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
seeded, "labelled seed",
"TextField::value must forward to the inner Input's seed"
);
}
#[gpui::test]
fn search_field_clear_button_press_scales_centered_to_pinned_value(cx: &mut TestAppContext) {
let clears = events();
let state = cx.new(|cx| InputState::with_value(cx, "rust"));
let state_for_view = state.clone();
let entity_id = state.entity_id().as_u64();
let recorded_for_view = clears.clone();
let cx = open_host(cx, move || {
let recorded = recorded_for_view.clone();
SearchField::new(state_for_view.clone())
.on_clear(move |_, _| recorded.borrow_mut().push("clear".to_owned()))
.into_any_element()
});
flush_frame(cx);
let at_rest = clear_bounds(cx, entity_id);
assert!(
near(at_rest.size.width, SEARCH_CLEAR_BOX) && near(at_rest.size.height, SEARCH_CLEAR_BOX),
"the resting SearchField clear slot must be a 20px square, got {at_rest:?}"
);
let at = bounds_centre(at_rest);
cx.simulate_mouse_move(at, None, Modifiers::none());
flush_frame(cx);
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
let pressed = clear_bounds(cx, entity_id);
let scaled = SEARCH_CLEAR_BOX * SEARCH_CLEAR_PRESS_SCALE;
let inset = (SEARCH_CLEAR_BOX - scaled) / 2.;
assert!(
near(pressed.size.width, scaled) && near(pressed.size.height, scaled),
"a pressed SearchField clear button must scale to 0.93 ({scaled}px), got {pressed:?}"
);
assert!(
near(pressed.origin.x, f32::from(at_rest.origin.x) + inset)
&& near(pressed.origin.y, f32::from(at_rest.origin.y) + inset),
"the clear scale must stay centered in its original slot, got {pressed:?}"
);
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
clears.borrow().as_slice(),
["clear"],
"the completed pressed clear must still activate"
);
}
#[gpui::test]
fn search_field_clear_button_press_scale_is_instant_with_reduced_motion(cx: &mut TestAppContext) {
harness::still();
let state = cx.new(|cx| InputState::with_value(cx, "rust"));
let state_for_view = state.clone();
let entity_id = state.entity_id().as_u64();
let cx = open_host(cx, move || {
SearchField::new(state_for_view.clone()).into_any_element()
});
flush_frame(cx);
let at_rest = clear_bounds(cx, entity_id);
let at = bounds_centre(at_rest);
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
let pressed = clear_bounds(cx, entity_id);
let scaled = SEARCH_CLEAR_BOX * SEARCH_CLEAR_PRESS_SCALE;
let inset = (SEARCH_CLEAR_BOX - scaled) / 2.;
assert!(
near(pressed.size.width, scaled) && near(pressed.size.height, scaled),
"reduced motion must apply the clear scale immediately ({scaled}px), got {pressed:?}"
);
assert!(
near(pressed.origin.x, f32::from(at_rest.origin.x) + inset)
&& near(pressed.origin.y, f32::from(at_rest.origin.y) + inset),
"reduced-motion clear scale must stay centered, got {pressed:?}"
);
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
}
#[gpui::test]
fn search_field_clear_button_is_inert_when_disabled_read_only_or_empty(cx: &mut TestAppContext) {
let clears = events();
let disabled = cx.new(|cx| InputState::with_value(cx, "disabled"));
let read_only = cx.new(|cx| InputState::with_value(cx, "read-only"));
let empty = cx.new(|cx| InputState::new(cx));
let disabled_id = disabled.entity_id().as_u64();
let read_only_id = read_only.entity_id().as_u64();
let empty_id = empty.entity_id().as_u64();
let clears_for_view = clears.clone();
let cx = open_host(cx, move || {
let disabled_clears = clears_for_view.clone();
let read_only_clears = clears_for_view.clone();
let empty_clears = clears_for_view.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(4.))
.child(
SearchField::new(disabled.clone())
.is_disabled(true)
.on_clear(move |_, _| disabled_clears.borrow_mut().push("disabled".to_owned())),
)
.child(
SearchField::new(read_only.clone())
.is_read_only(true)
.on_clear(move |_, _| {
read_only_clears.borrow_mut().push("read-only".to_owned());
}),
)
.child(
SearchField::new(empty.clone())
.on_clear(move |_, _| empty_clears.borrow_mut().push("empty".to_owned())),
)
.into_any_element()
});
flush_frame(cx);
for entity_id in [disabled_id, read_only_id, empty_id] {
assert!(
cx.debug_bounds(clear_probe(entity_id)).is_none(),
"disabled, read-only, and empty SearchFields must not paint a clear button"
);
}
for y in [18., 58., 98.] {
let at = point(px(298.), px(y));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
}
assert!(
clears.borrow().is_empty(),
"disabled, read-only, and invisible clear controls must never activate"
);
}
#[gpui::test]
fn search_field_clear_and_submit(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let clears = events();
let cleared = clears.clone();
let submits = events();
let submitted = submits.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let clears = clears.clone();
let submits = submits.clone();
SearchField::new(state_for_view.clone())
.on_change(move |text, _, _| changes.borrow_mut().push(format!("change:{text}")))
.on_clear(move |_, _| clears.borrow_mut().push("clear".to_owned()))
.on_submit(move |text, _, _| submits.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("rust");
assert_eq!(
recorded.borrow().as_slice(),
["change:r", "change:ru", "change:rus", "change:rust"],
"typing must report the growing value"
);
click(cx, 298., 18.);
assert_eq!(
recorded.borrow().last().map(String::as_str),
Some("change:"),
"the clear button must report an empty value through on_change"
);
assert_eq!(
cleared.borrow().as_slice(),
["clear"],
"clearing must notify on_clear"
);
let empty = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(empty, "", "clearing must empty the InputState");
let field_focus = cx.update(|_, cx| state.read(cx).focus_handle(cx));
assert!(
cx.update(|window, cx| {
window
.focused(cx)
.is_some_and(|focused| focused == field_focus)
}),
"pinned `preventFocusOnPress: true`: a pointer clear must restore \
field focus"
);
cx.simulate_input("abc");
press(cx, "enter");
assert_eq!(
submitted.borrow().as_slice(),
["abc"],
"Enter on the single-line search field must report on_submit with the \
value"
);
let after = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(after, "abc", "the InputState must hold the typed value");
}
#[gpui::test]
fn search_field_clear_button_is_excluded_from_tab_order(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let clears = events();
let cleared = clears.clone();
let state = cx.new(|cx| InputState::with_value(cx, "rust"));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let clears = clears.clone();
SearchField::new(state_for_view.clone())
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.on_clear(move |_, _| clears.borrow_mut().push("clear".to_owned()))
.into_any_element()
});
let field_focus = cx.update(|_, cx| state.read(cx).focus_handle(cx));
let clear_handle = cx.update(|_, cx| state.read(cx).clear_focus_handle());
press(cx, "tab");
assert!(
cx.update(|window, cx| {
window
.focused(cx)
.is_some_and(|focused| focused == field_focus)
}),
"Tab must seat the input"
);
for _ in 0..2 {
press(cx, "tab");
assert!(
cx.update(|window, cx| {
window
.focused(cx)
.is_some_and(|focused| focused == field_focus)
}),
"the clear button must stay out of the tab order (pinned \
`excludeFromTabOrder: true`); Tab must wrap back to the field"
);
}
cx.update(|window, cx| window.focus(&clear_handle, cx));
flush_frame(cx);
assert!(
cx.update(|window, _| clear_handle.is_focused(window)),
"window.focus on the InputState handle must seat the painted clear \
button"
);
press(cx, "enter");
assert!(
cx.update(|window, cx| {
window
.focused(cx)
.is_some_and(|focused| focused == field_focus)
}),
"clear activation must return focus to the input"
);
state.update(cx, |state, cx| {
state.set_value("rust");
cx.notify();
});
flush_frame(cx);
cx.update(|window, cx| window.focus(&clear_handle, cx));
flush_frame(cx);
assert!(
cx.update(|window, _| clear_handle.is_focused(window)),
"window.focus on the InputState handle must seat the painted clear \
button"
);
press(cx, "space");
assert!(
cx.update(|window, cx| {
window
.focused(cx)
.is_some_and(|focused| focused == field_focus)
}),
"clear activation must return focus to the input"
);
assert_eq!(
recorded.borrow().as_slice(),
["", ""],
"keyboard activation must report the clear through on_change"
);
assert_eq!(
cleared.borrow().as_slice(),
["clear", "clear"],
"Enter and Space on the focused clear button must report on_clear"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(value, "", "keyboard activation must empty the InputState");
}
#[gpui::test]
fn search_field_custom_clear_icon_preserves_clear_behavior(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let clears = events();
let cleared = clears.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let clears = clears.clone();
SearchField::new(state_for_view.clone())
.clear_icon(gpui::div().size(px(12.)).child("!"))
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.on_clear(move |_, _| clears.borrow_mut().push("clear".to_owned()))
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("rust");
click(cx, 298., 18.);
assert_eq!(recorded.borrow().last().map(String::as_str), Some(""));
assert_eq!(cleared.borrow().as_slice(), ["clear"]);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(value, "");
}
#[gpui::test]
fn input_otp_fills_and_advances(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let completes = events();
let completed = completes.clone();
let state = cx.new(|cx| OtpState::with_length(cx, 4));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let completes = completes.clone();
InputOTP::new(state_for_view.clone())
.on_change(move |code, _, _| changes.borrow_mut().push(code.to_owned()))
.on_complete(move |code, _, _| completes.borrow_mut().push(code.to_owned()))
.into_any_element()
});
click(cx, 19., 20.);
press(cx, "1");
press(cx, "2");
press(cx, "3");
press(cx, "4");
assert_eq!(
recorded.borrow().as_slice(),
["1", "12", "123", "1234"],
"typing digits must fill successive slots and report the growing code"
);
assert_eq!(
completed.borrow().as_slice(),
["1234"],
"filling the last slot must fire on_complete with the full code"
);
press(cx, "backspace");
press(cx, "backspace");
assert_eq!(
recorded.borrow().as_slice(),
["1", "12", "123", "1234", "123", "12"],
"Backspace must walk back through the filled slots"
);
let code = cx.update(|_, cx| state.read(cx).code());
assert_eq!(
code, "12",
"the OtpState must hold the remaining digits only"
);
}
#[gpui::test]
fn input_otp_value_builder_seeds_the_bound_state_at_first_render(cx: &mut TestAppContext) {
let state = cx.new(|cx| OtpState::with_length(cx, 4));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
InputOTP::new(state_for_view.clone())
.value("12")
.into_any_element()
});
let seeded = cx.update(|_, cx| state.read(cx).code());
assert_eq!(
seeded, "12",
"value must seed the bound OtpState when the field first renders"
);
}
#[gpui::test]
fn input_otp_value_builder_keeps_the_last_call(cx: &mut TestAppContext) {
let state = cx.new(|cx| OtpState::with_length(cx, 4));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
InputOTP::new(state_for_view.clone())
.value("1")
.value("23")
.into_any_element()
});
let seeded = cx.update(|_, cx| state.read(cx).code());
assert_eq!(
seeded, "23",
"a pure builder keeps the last call, without &mut App"
);
}
#[gpui::test]
fn input_otp_value_builder_seeds_once_and_never_clobbers_edits(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| OtpState::with_length(cx, 4));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
InputOTP::new(state_for_view.clone())
.value("12")
.on_change(move |code, _, _| changes.borrow_mut().push(code.to_owned()))
.into_any_element()
});
let seeded = cx.update(|_, cx| state.read(cx).code());
assert_eq!(seeded, "12");
click(cx, 111., 20.);
press(cx, "9");
flush_frame(cx);
let typed = cx.update(|_, cx| state.read(cx).code());
assert_eq!(
typed, "129",
"a re-render must not re-apply the value seed over the user's edit"
);
assert_eq!(
recorded.borrow().as_slice(),
["129"],
"the seeded-then-edited code reports exactly once, from the keystroke"
);
}
#[gpui::test]
fn input_otp_rejects_wrong_length_and_non_digits(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let completes = events();
let completed = completes.clone();
let state = cx.new(|cx| OtpState::with_length(cx, 4));
let state_for_view = state.clone();
let alpha_changes = events();
let alpha_recorded = alpha_changes.clone();
let alpha_state = cx.new(|cx| OtpState::with_length(cx, 4));
let alpha_for_view = alpha_state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let completes = completes.clone();
let alpha_changes = alpha_changes.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(16.))
.child(
InputOTP::new(state_for_view.clone())
.on_change(move |code, _, _| changes.borrow_mut().push(code.to_owned()))
.on_complete(move |code, _, _| completes.borrow_mut().push(code.to_owned())),
)
.child(
InputOTP::new(alpha_for_view.clone())
.pattern(OtpPattern::Alphanumeric)
.on_change(move |code, _, _| alpha_changes.borrow_mut().push(code.to_owned())),
)
.into_any_element()
});
click(cx, 19., 20.);
press(cx, "1");
press(cx, "2");
press(cx, "3");
press(cx, "4");
press(cx, "5");
let code = cx.update(|_, cx| state.read(cx).code());
assert_eq!(
code, "1235",
"a fifth digit must overwrite the last slot: the code cannot grow past \
the slot count"
);
assert_eq!(
recorded.borrow().as_slice(),
["1", "12", "123", "1234", "1235"],
"every accepted keystroke must be reported"
);
assert_eq!(
completed.borrow().as_slice(),
["1234", "1235"],
"an overwrite that leaves the code full must fire on_complete again"
);
let before = recorded.borrow().len();
press(cx, "a");
assert_eq!(
recorded.borrow().len(),
before,
"a letter in the default digit mode must be refused silently"
);
let after_letter = cx.update(|_, cx| state.read(cx).code());
assert_eq!(after_letter, "1235", "a refused letter must change nothing");
press(cx, "6");
let after_digit = cx.update(|_, cx| state.read(cx).code());
assert_eq!(
after_digit, "1236",
"a digit typed after a refused letter must still land"
);
click(cx, 19., 76.);
press(cx, "a");
press(cx, "1");
assert_eq!(
alpha_recorded.borrow().as_slice(),
["a", "a1"],
"the Alphanumeric pattern must accept letters and then digits"
);
let alpha_code = cx.update(|_, cx| alpha_state.read(cx).code());
assert_eq!(
alpha_code, "a1",
"the OtpState must hold the alphanumeric code"
);
}
#[gpui::test]
fn input_group_prefix_and_suffix_do_not_steal_the_caret(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
gpui::div()
.w(px(400.))
.child(
InputGroup::new()
.prefix(InputAddon::new("$"))
.input(
Input::new(state_for_view.clone()).on_change(move |text, _, _| {
changes.borrow_mut().push(text.to_owned());
}),
)
.suffix(InputAddon::new(".com")),
)
.into_any_element()
});
click(cx, 200., 18.);
cx.simulate_input("ab");
assert_eq!(
recorded.borrow().as_slice(),
["a", "ab"],
"typing into the group's field must work"
);
press(cx, "left");
click(cx, 6., 18.);
cx.simulate_input("cd");
click(cx, 394., 18.);
cx.simulate_input("x");
assert_eq!(
recorded.borrow().as_slice(),
["a", "ab", "acb", "acdb", "acdxb"],
"an addon click must focus the group's field and leave the caret \
exactly where it was: typing continues mid-string"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
value, "acdxb",
"the InputState must hold the text typed through the addon clicks"
);
}
#[gpui::test]
fn input_group_disabled_propagates_to_the_field(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
gpui::div()
.w(px(400.))
.child(
InputGroup::new()
.is_disabled(true)
.prefix(InputAddon::new("$"))
.input(
Input::new(state_for_view.clone()).on_change(move |text, _, _| {
changes.borrow_mut().push(text.to_owned());
}),
)
.suffix(InputAddon::new(".com")),
)
.into_any_element()
});
flush_frame(cx);
click(cx, 200., 18.);
cx.simulate_input("x");
press(cx, "a");
click(cx, 6., 18.);
cx.simulate_input("y");
assert!(
recorded.borrow().is_empty(),
"a group-disabled field must record no typing at all, got {:?}",
recorded.borrow()
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
value, "",
"a group-disabled field must hold no text: is_disabled propagates to \
the inner Input"
);
}
#[gpui::test]
fn input_group_textarea_click_exception_and_direct_focus(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
gpui::div()
.w(px(400.))
.child(
InputGroup::new().prefix(InputAddon::new("Note")).text_area(
TextArea::new(state_for_view.clone())
.rows(3)
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned())),
),
)
.into_any_element()
});
flush_frame(cx);
click(cx, 6., 13.);
cx.simulate_input("x");
assert!(
recorded.borrow().is_empty(),
"a click on a textarea-only group's addon must not focus the textarea \
(querySelector(\"input\") finds nothing), got {:?}",
recorded.borrow()
);
click(cx, 200., 38.);
cx.simulate_input("ab");
assert_eq!(
recorded.borrow().as_slice(),
["a", "ab"],
"a click inside the textarea must focus it: the exception only covers \
clicks outside the field"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(value, "ab", "the InputState must hold the typed text");
}
#[gpui::test]
fn input_group_button_suffix_presses_then_hands_focus_to_the_field(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let presses = events();
let pressed = presses.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let presses = presses.clone();
gpui::div()
.w(px(400.))
.child(
InputGroup::new()
.prefix(InputAddon::new("$"))
.input(
Input::new(state_for_view.clone()).on_change(move |text, _, _| {
changes.borrow_mut().push(text.to_owned());
}),
)
.suffix(
Button::new("ig-go")
.label("Go")
.on_press(move |_, _, _| presses.borrow_mut().push("press".into())),
),
)
.into_any_element()
});
flush_frame(cx);
click(cx, 394., 18.);
assert_eq!(
pressed.borrow().as_slice(),
["press"],
"the focusable suffix must keep its own action"
);
let field_handle = cx.update(|_, cx| state.read(cx).focus_handle(cx));
let focused_on_field =
cx.update(|window, cx| window.focused(cx).is_some_and(|held| held == field_handle));
assert!(
focused_on_field,
"the click-to-focus must take the focus back from the pressed \
suffix button and give it to the field"
);
cx.simulate_input("x");
assert_eq!(
recorded.borrow().as_slice(),
["x"],
"typing after the suffix click must reach the field"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
value, "x",
"the InputState must hold the text typed through the suffix button"
);
}
#[gpui::test]
fn input_group_field_disabled_refuses_addon_focus_from_the_first_frame(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let presses = events();
let pressed = presses.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let presses = presses.clone();
gpui::div()
.w(px(400.))
.child(
InputGroup::new()
.prefix(InputAddon::new("$"))
.input(
Input::new(state_for_view.clone())
.is_disabled(true)
.on_change(move |text, _, _| {
changes.borrow_mut().push(text.to_owned());
}),
)
.suffix(
Button::new("ig-disabled-go")
.label("Go")
.on_press(move |_, _, _| presses.borrow_mut().push("press".into())),
),
)
.into_any_element()
});
let field_handle = cx.update(|_, cx| state.read(cx).focus_handle(cx));
click(cx, 394., 18.);
assert_eq!(
pressed.borrow().as_slice(),
["press"],
"the suffix button must keep its own press beside a disabled field"
);
let focused_on_field = |cx: &mut VisualTestContext| {
cx.update(|window, cx| window.focused(cx).is_some_and(|held| held == field_handle))
};
assert!(
!focused_on_field(cx),
"a field disabled inside an enabled group must refuse the addon \
click-to-focus on the first frame"
);
flush_frame(cx);
press(cx, "enter");
assert_eq!(
pressed.borrow().as_slice(),
["press", "press"],
"the button must still hold the focus after the click: a disabled \
field must not have taken it from the first frame on"
);
click(cx, 6., 18.);
assert!(
!focused_on_field(cx),
"the addon click must never focus a disabled field"
);
cx.simulate_input("y");
cx.simulate_input("z");
assert!(
recorded.borrow().is_empty(),
"no keystroke may reach the disabled field, got {:?}",
recorded.borrow()
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
value, "",
"the disabled field must hold no text: the group's click-to-focus \
reads the field's own flag, not a rendered mirror"
);
}
#[gpui::test]
fn input_group_disabled_group_propagates_to_the_textarea(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
gpui::div()
.w(px(400.))
.child(
InputGroup::new().is_disabled(true).text_area(
TextArea::new(state_for_view.clone())
.rows(3)
.on_change(move |text, _, _| {
changes.borrow_mut().push(text.to_owned());
}),
),
)
.into_any_element()
});
flush_frame(cx);
click(cx, 200., 38.);
cx.simulate_input("x");
let field_handle = cx.update(|_, cx| state.read(cx).focus_handle(cx));
let focused_on_field =
cx.update(|window, cx| window.focused(cx).is_some_and(|held| held == field_handle));
assert!(
!focused_on_field,
"a group-disabled textarea must not take the focus"
);
assert!(
recorded.borrow().is_empty(),
"a group-disabled textarea must record no typing, got {:?}",
recorded.borrow()
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
value, "",
"the propagation must reach the converted multi-line field"
);
}
#[gpui::test]
#[allow(clippy::float_cmp)]
fn input_group_full_width_stretches_the_outer_wrapper(cx: &mut TestAppContext) {
let changes = events();
let state = cx.new(|cx| InputState::new(cx));
let entity = state.entity_id().as_u64();
let plain = cx.new(|cx| InputState::new(cx));
let plain_entity = plain.entity_id().as_u64();
let cx = open_host(cx, move || {
let changes = changes.clone();
gpui::div()
.w(px(400.))
.flex()
.flex_col()
.items_start()
.gap(px(16.))
.child(
InputGroup::new()
.full_width(true)
.prefix(InputAddon::new("$"))
.input(Input::new(state.clone()).on_change(move |text, _, _| {
changes.borrow_mut().push(text.to_owned());
}))
.suffix(InputAddon::new("USD")),
)
.child(
InputGroup::new()
.prefix(InputAddon::new("$"))
.input(Input::new(plain.clone()))
.suffix(InputAddon::new("USD")),
)
.into_any_element()
});
flush_frame(cx);
let full = cx
.debug_bounds(input_group_probe(entity, "group"))
.expect("the full-width group box must be measurable");
let plain_bounds = cx
.debug_bounds(input_group_probe(plain_entity, "group"))
.expect("the plain group box must be measurable");
assert_eq!(
f32::from(full.size.width),
400.0,
"full_width must stretch the group to its container's width, got {}",
f32::from(full.size.width)
);
assert!(
f32::from(plain_bounds.size.width) < 400.0,
"without full_width the group must hug its content, got {}",
f32::from(plain_bounds.size.width)
);
}
#[gpui::test]
#[allow(clippy::float_cmp)]
fn input_group_textarea_auto_height_and_top_aligned_addons(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let entity = state.entity_id().as_u64();
let cx = open_host(cx, move || {
gpui::div()
.w(px(400.))
.child(
InputGroup::new()
.prefix(
gpui::div().w(px(16.)).h(px(20.)),
)
.suffix(gpui::div().w(px(16.)).h(px(20.)))
.text_area(TextArea::new(state.clone()).rows(3)),
)
.into_any_element()
});
flush_frame(cx);
let group = cx
.debug_bounds(input_group_probe(entity, "group"))
.expect("the textarea group box must be measurable");
assert_eq!(
f32::from(group.size.height),
76.0,
"rows(3) is 20*3 + 16 = 76px and `height: auto` must let the group \
grow to it, got {}",
f32::from(group.size.height)
);
let prefix = cx
.debug_bounds(input_group_probe(entity, "prefix"))
.expect("the textarea group's addon slot must be measurable");
assert_eq!(
f32::from(prefix.origin.y),
f32::from(group.origin.y),
"the addon slot must be top-aligned with the textarea (items-start), \
not centred in the taller group"
);
assert_eq!(
f32::from(prefix.size.height),
28.0,
"the addon slot carries the pinned 8px top padding over its 20px \
probe, got {}",
f32::from(prefix.size.height)
);
let suffix = cx
.debug_bounds(input_group_probe(entity, "suffix"))
.expect("the textarea group's suffix slot must be measurable");
assert_eq!(
f32::from(suffix.origin.y),
f32::from(group.origin.y),
"both addon slots must top-align"
);
}
#[gpui::test]
fn color_field_typing_reports_a_colour(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
ColorField::new("cf-hex", PickerColor::from_hex("#336699").unwrap())
.state(state_for_view.clone())
.on_change(move |colour, _, _| {
changes.borrow_mut().push(match colour {
Some(c) => c.to_hex(),
None => "none".to_owned(),
});
})
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("ff0000");
assert_eq!(
recorded.borrow().len(),
6,
"every keystroke must be parsed and reported"
);
assert_eq!(
recorded.borrow().last().map(String::as_str),
Some("#FF0000"),
"the final report must be the colour the hex text parses to"
);
let text = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(text, "ff0000", "the InputState must hold the typed hex");
cx.simulate_input("0");
assert_eq!(
recorded.borrow().last().map(String::as_str),
Some("none"),
"a 7-char hex must not parse; on_change must report None for it"
);
}
#[gpui::test]
fn color_field_accepts_an_uncontrolled_null_default(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
ColorField::new("cf-null-default", None::<PickerColor>)
.default_value(None::<PickerColor>)
.state(state_for_view.clone())
.placeholder("#000000")
.on_change(move |colour, _, _| {
changes.borrow_mut().push(match colour {
Some(c) => c.to_hex(),
None => "none".to_owned(),
});
})
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("00ff00");
assert_eq!(
recorded.borrow().last().map(String::as_str),
Some("#00FF00"),
"an uncontrolled null field must accept its first complete color"
);
assert_eq!(
cx.update(|_, cx| state.read(cx).value().to_owned()),
"00ff00",
"the empty field's text input remains editable"
);
}
#[gpui::test]
fn date_field_segments_answer_arrows_and_digits(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::with_value(cx, "2025-01-15"));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
DateField::new(state_for_view.clone())
.min_value(Date::new(2025, 1, 10))
.max_value(Date::new(2025, 1, 20))
.on_change(move |date, _, _| {
changes.borrow_mut().push(match date {
Some(d) => d.format_iso(),
None => "none".to_owned(),
});
})
.into_any_element()
});
press(cx, "tab");
press(cx, "up");
press(cx, "down");
assert_eq!(
recorded.borrow().as_slice(),
["none", "2025-01-15"],
"stepping the month past the bounds must report None, and stepping \
back must report the in-range date again"
);
press(cx, "right");
press(cx, "1");
press(cx, "5");
assert_eq!(
recorded.borrow().as_slice(),
["none", "2025-01-15", "none", "2025-01-15"],
"digits must type into the focused day segment and report each step's \
date, filtering any out-of-range value"
);
press(cx, "left");
press(cx, "2");
press(cx, "5");
assert_eq!(
recorded.borrow().as_slice(),
["none", "2025-01-15", "none", "2025-01-15", "none", "none"],
"an over-`maxValue` day must report None, never the out-of-range date"
);
let text = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(
text, "2025-01-25",
"the bound InputState must hold the ISO text the segments produced"
);
}
#[gpui::test]
fn fieldset_disabled_disables_its_children(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
Fieldset::new()
.child(FieldsetLegend::new("Billing address"))
.child(
FieldGroup::new().child(
Input::new(state_for_view.clone())
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned())),
),
)
.into_any_element()
});
click(cx, 60., 66.);
cx.simulate_input("abc");
assert_eq!(
recorded.borrow().as_slice(),
["a", "ab", "abc"],
"a field inside a Fieldset must keep accepting typing: the container \
imposes no disabling"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(value, "abc", "the InputState must hold the typed value");
}
#[gpui::test]
fn text_area_click_places_the_caret_inside_a_wrapped_line(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::with_value(cx, "aaaa bbbb cccc dddd eeee ffff gggg hhhh"));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
gpui::div()
.w(px(200.))
.child(TextArea::new(state_for_view.clone()))
.into_any_element()
});
let end = cx.update(|_, cx| state.read(cx).value().chars().count());
click(cx, 16., 14.);
cx.simulate_input("X");
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert!(
!value.ends_with('X'),
"typing after a click in a wrapped paragraph must insert at the caret, \
not append; got {value:?}"
);
assert_eq!(
value.chars().count(),
end + 1,
"the click must move the caret, not select and replace"
);
}
#[gpui::test]
fn field_addons_and_custom_slots_keep_twenty_pixel_lines(cx: &mut TestAppContext) {
for kind in 0..7 {
for leading in [None, Some(12.), Some(48.)] {
let state = cx.new(|cx| InputState::new(cx));
let otp = cx.new(|cx| OtpState::with_length(cx, 1));
let time = cx.new(|cx| TimeState::new(cx));
let number = cx.new(|cx| NumberState::new(cx, 4.));
let cx = open_host(cx, move || {
let probe = || {
gpui::div()
.debug_selector(|| "input-leading-text".into())
.child("$")
};
let control = match kind {
0 => Input::new(state.clone())
.start_content(probe())
.into_any_element(),
1 => InputGroup::new()
.prefix(
gpui::div()
.debug_selector(|| "input-leading-text".into())
.child(InputAddon::new("$")),
)
.input(Input::new(state.clone()))
.into_any_element(),
2 => InputOTP::new(otp.clone())
.slot(move |_, _| probe().into_any_element())
.into_any_element(),
3 => DateField::new(state.clone())
.prefix(probe())
.into_any_element(),
4 => TimeField::new(time.clone())
.prefix(probe())
.into_any_element(),
_ => NumberField::new(number.clone())
.increment_icon(probe())
.is_disabled(kind == 6)
.into_any_element(),
};
gpui::div()
.w(px(300.))
.when_some(leading, |el, leading| el.line_height(px(leading)))
.child(control)
.into_any_element()
});
assert_eq!(
cx.debug_bounds("input-leading-text")
.expect("slot text paints")
.size
.height,
px(20.),
"kind={kind}, host={leading:?}"
);
if kind == 5 {
let at = bounds_centre(cx.debug_bounds("input-leading-text").unwrap());
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
let pressed = cx
.debug_bounds("input-leading-text")
.expect("pressed slot paints");
assert_eq!(
pressed.size.height,
px(20.),
"stepper press leaves the inner 20px line alone: {pressed:?}"
);
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
cx.debug_bounds("input-leading-text").unwrap().size.height,
px(20.)
);
}
}
}
}
#[gpui::test]
fn color_and_time_full_width_reaches_the_parent_edge(cx: &mut TestAppContext) {
for kind in 0..3 {
for width in [200., 400., 640.] {
for labeled in [false, true] {
let measured = std::rc::Rc::new(std::cell::Cell::new(None));
let recorded = measured.clone();
let input = cx.new(|cx| InputState::new(cx));
let time = cx.new(|cx| TimeState::new(cx));
let time_for_view = time.clone();
let input_for_view = input.clone();
let cx = open_host(cx, move || {
let control = if kind == 2 {
TimeField::new(time_for_view.clone())
.default_value(Time::new(12, 30))
.full_width(true)
.when(labeled, |field| field.label("Time"))
.into_any_element()
} else {
ColorField::new("width-color", PickerColor::hsb(0., 1., 1.))
.full_width(true)
.when(kind == 1, |field| field.state(input_for_view.clone()))
.when(labeled, |field| field.label("Color"))
.into_any_element()
};
let recorded = recorded.clone();
gpui::div()
.w(px(width))
.flex()
.flex_col()
.items_start()
.on_children_prepainted(move |bounds, _, _| recorded.set(Some(bounds[0])))
.child(control)
.into_any_element()
});
flush_frame(cx);
assert_eq!(
measured.get().expect("field paints").size.width,
px(width),
"kind={kind}, labeled={labeled}"
);
if kind != 0 {
click(cx, width - 8., if labeled { 42. } else { 18. });
if kind == 1 {
cx.update(|window, cx| {
assert!(
input.focus_handle(cx).is_focused(window),
"expanded input edge accepts focus"
);
});
} else {
press(cx, "up");
cx.update(|_, cx| {
assert_ne!(
time.read(cx).value,
Some(Time::new(12, 30)),
"expanded time field edge accepts keyboard editing"
);
});
}
}
}
}
}
}
#[gpui::test]
fn input_label_description_and_error_keep_pinned_line_heights(cx: &mut TestAppContext) {
for leading in [None, Some(12.), Some(48.)] {
let desc_state = cx.new(|cx| InputState::new(cx));
let err_state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
let mut root = gpui::div().flex().flex_col().gap(px(16.)).items_start();
if let Some(leading) = leading {
root = root.text_size(px(32.)).line_height(px(leading));
}
root.child(
gpui::div()
.debug_selector(|| "input-desc".to_owned())
.child(
Input::new(desc_state.clone())
.label("Name")
.description("Help"),
),
)
.child(
gpui::div()
.debug_selector(|| "input-error".to_owned())
.child(
Input::new(err_state.clone())
.label("Name")
.is_required(true)
.description("Help")
.validation_errors(["Oops"]),
),
)
.into_any_element()
});
cx.run_until_parked();
assert_eq!(
cx.debug_bounds("input-desc").unwrap().size.height,
px(80.),
"label + description must stay 20 + 4 + 36 + 4 + 16, host={leading:?}"
);
assert_eq!(
cx.debug_bounds("input-error").unwrap().size.height,
px(80.),
"required label + error must stay 80px and the error must replace \
the description rather than stack under it, host={leading:?}"
);
}
}
fn box_bounds(cx: &mut VisualTestContext, name: &'static str) -> Bounds<Pixels> {
cx.debug_bounds(name)
.unwrap_or_else(|| panic!("the `{name}` probe must paint"))
}
fn input_group_probe(entity: u64, suffix: &str) -> &'static str {
Box::leak(format!("input-group-{entity}-{suffix}").into_boxed_str())
}
#[gpui::test]
fn input_height_replaces_the_field_height_and_defaults_to_it(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
let default_state = cx.new(|cx| InputState::new(cx));
let short_state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.child(
gpui::div()
.debug_selector(|| "h-default".to_owned())
.child(Input::new(default_state.clone())),
)
.child(
gpui::div()
.debug_selector(|| "h-short".to_owned())
.child(Input::new(short_state.clone()).height(px(SHORT))),
)
.into_any_element()
});
flush_frame(cx);
let default_box = box_bounds(cx, "h-default");
let short_box = box_bounds(cx, "h-short");
assert!(
near(
default_box.size.height,
f32::from(herogpui_components::extend::FIELD_HEIGHT)
),
"an untouched field must stay `util::FIELD_HEIGHT` tall, got {default_box:?}"
);
assert!(
near(short_box.size.height, SHORT),
"`height` must replace the box height, got {short_box:?}"
);
assert!(
default_box.size.height - short_box.size.height
== herogpui_components::extend::FIELD_HEIGHT - px(SHORT),
"the only difference between the two boxes must be the requested \
height delta: default={default_box:?} short={short_box:?}"
);
}
#[gpui::test]
fn input_padding_x_moves_the_content_origin_by_the_requested_delta(cx: &mut TestAppContext) {
const WIDE: f32 = 24.;
let default_state = cx.new(|cx| InputState::new(cx));
let wide_state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.child(Input::new(default_state.clone()).start_content(inset_probe("pad-default")))
.child(
Input::new(wide_state.clone())
.padding_x(px(WIDE))
.start_content(inset_probe("pad-wide")),
)
.into_any_element()
});
flush_frame(cx);
let default_probe = box_bounds(cx, "pad-default");
let wide_probe = box_bounds(cx, "pad-wide");
let delta = f32::from(wide_probe.origin.x - default_probe.origin.x);
assert!(
(delta - (WIDE - 12.)).abs() < 0.5,
"`padding_x` must move the content origin by the delta over the \
default 12px inset, moved {delta}: default={default_probe:?} \
wide={wide_probe:?}"
);
}
#[gpui::test]
fn is_bare_drops_the_chrome_the_variant_would_paint(cx: &mut TestAppContext) {
let bare_state = cx.new(|cx| InputState::new(cx));
let chromed_state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.child(
Input::new(bare_state.clone())
.is_bare(true)
.is_invalid(true)
.start_content(inset_probe("bare-probe")),
)
.child(
Input::new(chromed_state.clone())
.is_bare(false)
.is_invalid(true)
.start_content(inset_probe("chromed-probe")),
)
.into_any_element()
});
flush_frame(cx);
let bare = box_bounds(cx, "bare-probe");
let chromed = box_bounds(cx, "chromed-probe");
assert!(
chromed.origin.x > bare.origin.x,
"the chromed invalid field must paint the danger border that insets \
its content; the bare one must paint no chrome at all: \
bare={bare:?} chromed={chromed:?}"
);
assert!(
near(bare.origin.x, 12.),
"a bare field must keep only its own padding, got {bare:?}"
);
}
#[gpui::test]
fn is_bare_keeps_the_field_focusable_and_editable(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
Input::new(state_for_view.clone())
.is_bare(true)
.into_any_element()
});
click(cx, 40., 18.);
press(cx, "b a r e");
cx.update(|_, cx| {
assert_eq!(
state.read(cx).value(),
"bare",
"a bare field must still take focus and typing"
);
});
}
#[gpui::test]
fn focus_ring_opt_out_keeps_the_field_focusable_and_editable(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
Input::new(state_for_view.clone())
.focus_ring(false)
.into_any_element()
});
click(cx, 40., 18.);
press(cx, "r i n g");
cx.update(|_, cx| {
assert_eq!(
state.read(cx).value(),
"ring",
"disabling the visual ring must not disable focus or editing"
);
});
}
fn chrome_boxes(cx: &mut VisualTestContext, min_h: f32) -> usize {
let scene = harness::painted(cx);
let field = cx.update(|_, cx| {
use herogpui_theme::ActiveTheme;
cx.colors().field.background
});
scene
.filled(field)
.into_iter()
.filter(|q| f32::from(scene.bounds(q).size.height) >= min_h)
.count()
}
#[gpui::test]
fn input_chrome_is_one_call_site_gated_on_bare_and_group(cx: &mut TestAppContext) {
type Build = fn(gpui::Entity<InputState>) -> gpui::AnyElement;
let cases: [(&str, Build, usize); 4] = [
("stock", |s| Input::new(s).into_any_element(), 1),
(
"focus_ring(false)",
|s| Input::new(s).focus_ring(false).into_any_element(),
1,
),
(
"is_bare",
|s| Input::new(s).is_bare(true).into_any_element(),
0,
),
(
"grouped",
|s| InputGroup::new().input(Input::new(s)).into_any_element(),
1,
),
];
for (name, build, want) in cases {
let state = cx.new(|cx| InputState::new(cx));
harness::still();
let cx = open_host(cx, move || {
gpui::div()
.w(px(320.))
.child(build(state.clone()))
.into_any_element()
});
assert_eq!(chrome_boxes(cx, 30.), want, "{name}: chrome boxes painted");
}
}
#[gpui::test]
fn text_field_start_content_renders_inside_the_box(cx: &mut TestAppContext) {
let state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
TextField::new(state.clone())
.placeholder("Search")
.start_content(inset_probe("tf-start"))
.into_any_element()
});
flush_frame(cx);
let probe = box_bounds(cx, "tf-start");
assert!(
near(probe.origin.x, 12.) && near(probe.size.width, 10.),
"the leading element must render at the box's 12px inset, got {probe:?}"
);
}
#[gpui::test]
fn text_field_forwards_the_box_builders(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
let state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.debug_selector(|| "tf-box".to_owned())
.child(
TextField::new(state.clone())
.height(px(SHORT))
.padding_x(px(4.))
.is_bare(true)
.font_family("monospace")
.start_content(inset_probe("tf-box-start")),
)
.into_any_element()
});
flush_frame(cx);
let wrapper = box_bounds(cx, "tf-box");
let probe = box_bounds(cx, "tf-box-start");
assert!(
near(wrapper.size.height, SHORT),
"the forwarded height must be the whole unlabelled column's height, \
got {wrapper:?}"
);
assert!(
near(probe.origin.x, 4.),
"the forwarded padding must inset the leading element, got {probe:?}"
);
}
#[gpui::test]
fn time_field_box_builders_match_the_input_contract(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
const WIDE: f32 = 24.;
let default_state = cx.new(|cx| TimeState::new(cx));
let short_state = cx.new(|cx| TimeState::new(cx));
let wide_state = cx.new(|cx| TimeState::new(cx));
let bare_state = cx.new(|cx| TimeState::new(cx));
let chromed_state = cx.new(|cx| TimeState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.child(
gpui::div()
.debug_selector(|| "tf-default".to_owned())
.child(
TimeField::new(default_state.clone()).prefix(inset_probe("tf-pad-default")),
),
)
.child(
gpui::div()
.debug_selector(|| "tf-short".to_owned())
.child(TimeField::new(short_state.clone()).height(px(SHORT))),
)
.child(
TimeField::new(wide_state.clone())
.padding_x(px(WIDE))
.prefix(inset_probe("tf-pad-wide")),
)
.child(
TimeField::new(bare_state.clone())
.is_bare(true)
.is_invalid(true)
.prefix(inset_probe("tf-bare")),
)
.child(
TimeField::new(chromed_state.clone())
.is_invalid(true)
.prefix(inset_probe("tf-chromed")),
)
.into_any_element()
});
flush_frame(cx);
let default_box = box_bounds(cx, "tf-default");
let short_box = box_bounds(cx, "tf-short");
assert!(
near(
default_box.size.height,
f32::from(herogpui_components::extend::FIELD_HEIGHT)
),
"an untouched TimeField must stay `util::FIELD_HEIGHT` tall, got {default_box:?}"
);
assert!(
near(short_box.size.height, SHORT),
"`height` must replace the box height, got {short_box:?}"
);
let pad_default = box_bounds(cx, "tf-pad-default");
let pad_wide = box_bounds(cx, "tf-pad-wide");
let delta = f32::from(pad_wide.origin.x - pad_default.origin.x);
assert!(
(delta - (WIDE - 12.)).abs() < 0.5,
"`padding_x` must move the prefix by the delta over the default 12px \
inset, moved {delta}: default={pad_default:?} wide={pad_wide:?}"
);
let bare = box_bounds(cx, "tf-bare");
let chromed = box_bounds(cx, "tf-chromed");
assert!(
chromed.origin.x > bare.origin.x,
"the chromed invalid field must paint the danger border that insets \
its prefix; the bare one must paint no chrome: bare={bare:?} \
chromed={chromed:?}"
);
assert!(
near(bare.origin.x, 12.),
"a bare TimeField must keep only its own padding, got {bare:?}"
);
}
#[gpui::test]
fn date_field_box_builders_match_the_input_contract(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
const WIDE: f32 = 24.;
let default_state = cx.new(|cx| InputState::new(cx));
let short_state = cx.new(|cx| InputState::new(cx));
let wide_state = cx.new(|cx| InputState::new(cx));
let bare_state = cx.new(|cx| InputState::new(cx));
let chromed_state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.child(
gpui::div()
.debug_selector(|| "df-default".to_owned())
.child(
DateField::new(default_state.clone()).prefix(inset_probe("df-box-default")),
),
)
.child(
gpui::div().debug_selector(|| "df-short".to_owned()).child(
DateField::new(short_state.clone())
.height(px(SHORT))
.prefix(inset_probe("df-box-short")),
),
)
.child(
DateField::new(wide_state.clone())
.padding_x(px(WIDE))
.prefix(inset_probe("df-pad-wide")),
)
.child(
DateField::new(bare_state.clone())
.is_bare(true)
.is_invalid(true)
.prefix(inset_probe("df-bare")),
)
.child(
DateField::new(chromed_state.clone())
.is_invalid(true)
.prefix(inset_probe("df-chromed")),
)
.into_any_element()
});
flush_frame(cx);
let default_probe = box_bounds(cx, "df-box-default");
let short_probe = box_bounds(cx, "df-box-short");
let default_offset = f32::from(default_probe.origin.y - box_bounds(cx, "df-default").origin.y);
let short_offset = f32::from(short_probe.origin.y - box_bounds(cx, "df-short").origin.y);
assert!(
near(
px(default_offset),
(f32::from(herogpui_components::extend::FIELD_HEIGHT) - 10.) / 2.
),
"an untouched DateField must stay `util::FIELD_HEIGHT` tall, got {default_probe:?}"
);
assert!(
near(px(short_offset), (SHORT - 10.) / 2.),
"`height` must replace the box height, got {short_probe:?}"
);
let pad_default = box_bounds(cx, "df-box-default");
let pad_wide = box_bounds(cx, "df-pad-wide");
let delta = f32::from(pad_wide.origin.x - pad_default.origin.x);
assert!(
(delta - (WIDE - 12.)).abs() < 0.5,
"`padding_x` must move the prefix by the delta over the default 12px \
inset, moved {delta}: default={pad_default:?} wide={pad_wide:?}"
);
let bare = box_bounds(cx, "df-bare");
let chromed = box_bounds(cx, "df-chromed");
assert!(
chromed.origin.x > bare.origin.x,
"the chromed invalid field must paint the danger border that insets \
its prefix; the bare one must paint no chrome: bare={bare:?} \
chromed={chromed:?}"
);
assert!(
near(bare.origin.x, 12.),
"a bare DateField must keep only its own padding, got {bare:?}"
);
}
#[gpui::test]
fn number_field_height_replaces_the_group_and_inner_field(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
let default_state = cx.new(|cx| NumberState::new(cx, 0.));
let short_state = cx.new(|cx| NumberState::new(cx, 0.));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.child(
gpui::div()
.debug_selector(|| "nf-default".to_owned())
.child(NumberField::new(default_state.clone())),
)
.child(
gpui::div()
.debug_selector(|| "nf-short".to_owned())
.child(NumberField::new(short_state.clone()).height(px(SHORT))),
)
.into_any_element()
});
flush_frame(cx);
let default_box = box_bounds(cx, "nf-default");
let short_box = box_bounds(cx, "nf-short");
assert!(
near(
default_box.size.height,
f32::from(herogpui_components::extend::FIELD_HEIGHT)
),
"an untouched NumberField must stay `util::FIELD_HEIGHT` tall, got {default_box:?}"
);
assert!(
near(short_box.size.height, SHORT),
"`height` must replace the group height, got {short_box:?}"
);
assert!(
default_box.size.height - short_box.size.height
== herogpui_components::extend::FIELD_HEIGHT - px(SHORT),
"the only difference between the two groups must be the requested \
height delta: default={default_box:?} short={short_box:?}"
);
}
#[gpui::test]
fn color_field_box_builders_cover_both_render_paths(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
const WIDE: f32 = 24.;
let state = cx.new(|cx| InputState::new(cx));
let default_state = cx.new(|cx| InputState::new(cx));
let short_state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.child(
gpui::div().debug_selector(|| "cf-static".to_owned()).child(
ColorField::new("cf-static", PickerColor::hsb(180., 1., 1.))
.suffix(inset_probe("cf-suffix-default")),
),
)
.child(
ColorField::new("cf-static-wide", PickerColor::hsb(180., 1., 1.))
.padding_x(px(WIDE))
.suffix(inset_probe("cf-suffix-wide")),
)
.child(
gpui::div()
.debug_selector(|| "cf-edit-default".to_owned())
.child(
ColorField::new("cf-edit", PickerColor::hsb(180., 1., 1.))
.state(default_state.clone()),
),
)
.child(
gpui::div()
.debug_selector(|| "cf-edit-short".to_owned())
.child(
ColorField::new("cf-edit-short", PickerColor::hsb(180., 1., 1.))
.state(short_state.clone())
.height(px(SHORT)),
),
)
.child(
ColorField::new("cf-edit-h", PickerColor::hsb(180., 1., 1.))
.is_bare(true)
.state(state.clone()),
)
.into_any_element()
});
flush_frame(cx);
let static_box = box_bounds(cx, "cf-static");
assert!(
near(
static_box.size.height,
f32::from(herogpui_components::extend::FIELD_HEIGHT)
),
"an untouched static ColorField must stay `util::FIELD_HEIGHT` tall, \
got {static_box:?}"
);
let suffix_default = box_bounds(cx, "cf-suffix-default");
let suffix_wide = box_bounds(cx, "cf-suffix-wide");
let delta = f32::from(
(suffix_default.origin.x + suffix_default.size.width)
- (suffix_wide.origin.x + suffix_wide.size.width),
);
assert!(
(delta - (WIDE - 12.)).abs() < 0.5,
"`padding_x` must move the trailing suffix inward by the delta over \
the default 12px inset, moved {delta}: default={suffix_default:?} \
wide={suffix_wide:?}"
);
let edit_default = box_bounds(cx, "cf-edit-default");
let edit_short = box_bounds(cx, "cf-edit-short");
assert!(
near(
edit_default.size.height,
f32::from(herogpui_components::extend::FIELD_HEIGHT)
),
"the editable ColorField must inherit the stock Input height, got \
{edit_default:?}"
);
assert!(
near(edit_short.size.height, SHORT),
"the editable ColorField must forward `height` to the inner Input, \
got {edit_short:?}"
);
}
#[gpui::test]
fn search_field_box_builders_reach_the_inner_field(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
const WIDE: f32 = 24.;
let default_state = cx.new(|cx| InputState::new(cx));
let short_state = cx.new(|cx| InputState::new(cx));
let wide_state = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.child(
gpui::div()
.w(px(320.))
.debug_selector(|| "sf-default".to_owned())
.child(
SearchField::new(default_state.clone())
.full_width()
.end_content(inset_probe("sf-end-default")),
),
)
.child(
gpui::div()
.w(px(320.))
.debug_selector(|| "sf-short".to_owned())
.child(
SearchField::new(short_state.clone())
.full_width()
.height(px(SHORT)),
),
)
.child(
gpui::div().w(px(320.)).child(
SearchField::new(wide_state.clone())
.full_width()
.padding_x(px(WIDE))
.end_content(inset_probe("sf-end-wide")),
),
)
.into_any_element()
});
flush_frame(cx);
let default_box = box_bounds(cx, "sf-default");
let short_box = box_bounds(cx, "sf-short");
assert!(
near(
default_box.size.height,
f32::from(herogpui_components::extend::FIELD_HEIGHT)
),
"an untouched SearchField must stay `util::FIELD_HEIGHT` tall, got {default_box:?}"
);
assert!(
near(short_box.size.height, SHORT),
"`height` must reach the inner field, got {short_box:?}"
);
let end_default = box_bounds(cx, "sf-end-default");
let end_wide = box_bounds(cx, "sf-end-wide");
let delta = f32::from(
(end_default.origin.x + end_default.size.width) - (end_wide.origin.x + end_wide.size.width),
);
assert!(
(delta - (WIDE - 12.)).abs() < 0.5,
"`padding_x` must move the trailing content inward by the delta over \
the default 12px inset, moved {delta}: default={end_default:?} \
wide={end_wide:?}"
);
}
#[gpui::test]
fn the_field_family_gates_its_chrome_on_one_bare_flag(cx: &mut TestAppContext) {
for bare in [false, true] {
let time = cx.new(|cx| TimeState::new(cx));
let date = cx.new(|cx| InputState::new(cx));
let number = cx.new(|cx| NumberState::new(cx, 0.));
type Build = Box<dyn Fn() -> gpui::AnyElement>;
let fields: [(&str, Build); 4] = [
(
"TimeField",
Box::new(move || {
TimeField::new(time.clone())
.is_bare(bare)
.into_any_element()
}),
),
(
"DateField",
Box::new(move || {
DateField::new(date.clone())
.is_bare(bare)
.into_any_element()
}),
),
(
"ColorField",
Box::new(move || {
ColorField::new("cf-bare", PickerColor::from_hex("#FF0000"))
.is_bare(bare)
.into_any_element()
}),
),
(
"NumberField",
Box::new(move || {
NumberField::new(number.clone())
.is_bare(bare)
.into_any_element()
}),
),
];
for (name, build) in fields {
harness::still();
let cx = open_host(cx, move || {
gpui::div().w(px(320.)).child(build()).into_any_element()
});
assert_eq!(
chrome_boxes(cx, 30.),
usize::from(!bare),
"{name} bare={bare}: chrome boxes painted"
);
}
}
}
fn fills_over(scene: &harness::Painted, target: Bounds<Pixels>) -> Vec<gpui::Hsla> {
let mut fills: Vec<gpui::Hsla> = Vec::new();
for q in &scene.quads {
let b = scene.bounds(q);
if harness::contains(b, target) && harness::contains(target, b) {
if let Some(c) = q.background.as_solid().filter(|c| c.a > 0.) {
if !harness::has_color(&fills, c) {
fills.push(c);
}
}
}
}
fills
}
#[gpui::test]
fn color_field_static_group_uses_the_pinned_hover_transition(cx: &mut TestAppContext) {
use gpui::Modifiers;
use herogpui_components::FieldVariant;
use herogpui_theme::{set_theme, ActiveTheme, Theme};
fn red() -> PickerColor {
PickerColor::from_hex("#FF0000").expect("red")
}
type Build = fn() -> gpui::AnyElement;
let cases: [(&str, Build, bool); 4] = [
(
"Primary",
|| ColorField::new("cf-h", red()).into_any_element(),
true,
),
(
"Secondary",
|| {
ColorField::new("cf-h", red())
.variant(FieldVariant::Secondary)
.into_any_element()
},
true,
),
(
"disabled",
|| {
ColorField::new("cf-h", red())
.is_disabled(true)
.into_any_element()
},
false,
),
(
"invalid",
|| {
ColorField::new("cf-h", red())
.is_invalid(true)
.into_any_element()
},
false,
),
];
for (name, build, hovers) in cases {
let cx = open_host(cx, move || {
gpui::div()
.p(px(24.))
.w(px(320.))
.child(build())
.into_any_element()
});
cx.update(|window, cx| {
set_theme(
Theme::builder("slow", Theme::light())
.hover_fade_ms(60_000)
.build(),
cx,
);
window.refresh();
});
let resting = harness::painted(cx);
let group = resting
.quads
.iter()
.filter(|q| q.background.as_solid().is_some_and(|c| c.a > 0.))
.filter(|q| f32::from(resting.bounds(q).size.height) > 30.)
.map(|q| resting.bounds(q))
.next()
.unwrap_or_else(|| panic!("{name}: the group must paint\n{}", resting.describe()));
let before = fills_over(&resting, group);
cx.simulate_mouse_move(group.center(), None, Modifiers::none());
harness::wait_real(cx, 400);
let scene = harness::painted(cx);
let after = fills_over(&scene, group);
let endpoint = cx.update(|_, cx| {
let colors = cx.colors();
if name == "Secondary" {
colors.default.hover()
} else {
colors.field.hover()
}
});
if hovers {
assert!(
after.len() == 1 && harness::same_color(after[0], endpoint),
"{name}: after 400ms the group's own 150ms fade must have settled \
on {endpoint:?}, painted {after:?}"
);
} else {
assert_eq!(
after, before,
"{name}: the group keeps its chrome under the pointer"
);
}
}
}
#[gpui::test]
fn input_group_height_replaces_the_group_and_inner_field(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
let default_state = cx.new(|cx| InputState::new(cx));
let short_state = cx.new(|cx| InputState::new(cx));
let default_entity = default_state.entity_id().as_u64();
let short_entity = short_state.entity_id().as_u64();
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.gap(px(16.))
.child(InputGroup::new().input(Input::new(default_state.clone())))
.child(
InputGroup::new()
.height(px(SHORT))
.input(Input::new(short_state.clone())),
)
.into_any_element()
});
flush_frame(cx);
let default = cx
.debug_bounds(input_group_probe(default_entity, "group"))
.expect("the default group must be laid out");
let short = cx
.debug_bounds(input_group_probe(short_entity, "group"))
.expect("the short group must be laid out");
assert!(
near(
default.size.height,
f32::from(herogpui_components::extend::FIELD_HEIGHT)
),
"an untouched group must keep the 36px floor, got {default:?}"
);
assert!(
near(short.size.height, SHORT),
"`height` must replace the group's floor, got {short:?}"
);
}
#[gpui::test]
fn input_group_padding_x_reaches_the_inner_field(cx: &mut TestAppContext) {
const WIDE: f32 = 24.;
let default_state = cx.new(|cx| InputState::new(cx));
let wide_state = cx.new(|cx| InputState::new(cx));
let default_entity = default_state.entity_id().as_u64();
let wide_entity = wide_state.entity_id().as_u64();
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.gap(px(16.))
.child(InputGroup::new().input(Input::new(default_state.clone())))
.child(
InputGroup::new()
.padding_x(px(WIDE))
.input(Input::new(wide_state.clone())),
)
.into_any_element()
});
flush_frame(cx);
let default = cx
.debug_bounds(input_group_probe(default_entity, "group"))
.expect("the default group must be laid out");
let wide = cx
.debug_bounds(input_group_probe(wide_entity, "group"))
.expect("the wide group must be laid out");
let delta = f32::from(wide.size.width) - f32::from(default.size.width);
assert!(
(delta - (WIDE - 12.) * 2.).abs() < 0.5,
"`padding_x` must inset both exposed sides by the delta over px-3, \
moved {delta}: default={default:?} wide={wide:?}"
);
}
#[gpui::test]
fn input_group_gates_chrome_and_forwards_the_seam(cx: &mut TestAppContext) {
for bare in [false, true] {
let state = cx.new(|cx| InputState::new(cx));
harness::still();
let cx = open_host(cx, move || {
gpui::div()
.w(px(320.))
.child(
InputGroup::new()
.is_bare(bare)
.input(Input::new(state.clone())),
)
.into_any_element()
});
assert_eq!(
chrome_boxes(cx, 30.),
usize::from(!bare),
"bare={bare}: the group paints its chrome unless bare"
);
}
assert!(
source_scan::component_src("input_group.rs")
.contains("Some(height) => input.height(height),"),
"the group must forward the explicit height to the held field"
);
}
#[gpui::test]
fn number_field_padding_override_keeps_the_wide_editable_area(cx: &mut TestAppContext) {
let state = cx.new(|cx| NumberState::new(cx, 0.));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
gpui::div()
.w(px(600.))
.child(
NumberField::new(state_for_view.clone())
.full_width(true)
.hide_steppers(true)
.padding_x(px(12.)),
)
.into_any_element()
});
flush_frame(cx);
click(cx, 520., 18.);
press(cx, "5");
cx.update(|_, cx| {
assert!(
(state.read(cx).value() - 5.).abs() < f64::EPSILON,
"a click past the standalone Input's 320px cap must still focus \
and type, got {}",
state.read(cx).value()
);
});
}
#[gpui::test]
fn number_field_short_height_keeps_vertical_steppers_reachable(cx: &mut TestAppContext) {
const SHORT: f32 = 28.;
let state = cx.new(|cx| NumberState::new(cx, 5.));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
gpui::div()
.w(px(220.))
.child(
NumberField::new(state_for_view.clone())
.vertical_steppers(true)
.height(px(SHORT)),
)
.into_any_element()
});
flush_frame(cx);
click(cx, 208., 16.);
cx.update(|_, cx| {
assert!(
(state.read(cx).value() - 4.).abs() < f64::EPSILON,
"the lower stepper must stay reachable at an explicit height, got {}",
state.read(cx).value()
);
});
}
#[gpui::test]
fn input_group_padding_override_respects_addon_sides(cx: &mut TestAppContext) {
const WIDE: f32 = 24.;
let plain = cx.new(|cx| InputState::new(cx));
let plain_wide = cx.new(|cx| InputState::new(cx));
let prefixed = cx.new(|cx| InputState::new(cx));
let prefixed_wide = cx.new(|cx| InputState::new(cx));
let direct = cx.new(|cx| InputState::new(cx));
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.gap(px(16.))
.child(
InputGroup::new()
.padding_x(px(4.))
.input(Input::new(plain.clone()).start_content(inset_probe("ig-pad-4"))),
)
.child(
InputGroup::new()
.padding_x(px(WIDE))
.input(Input::new(plain_wide.clone()).start_content(inset_probe("ig-pad-24"))),
)
.child(
InputGroup::new()
.padding_x(px(4.))
.prefix(InputAddon::new("$"))
.input(Input::new(prefixed.clone()).start_content(inset_probe("ig-prefix-4"))),
)
.child(
InputGroup::new()
.padding_x(px(WIDE))
.prefix(InputAddon::new("$"))
.input(
Input::new(prefixed_wide.clone())
.start_content(inset_probe("ig-prefix-24")),
),
)
.child(
InputGroup::new().input(
Input::new(direct.clone())
.padding_x(px(30.))
.start_content(inset_probe("ig-direct")),
),
)
.into_any_element()
});
flush_frame(cx);
let pad4 = box_bounds(cx, "ig-pad-4");
let pad24 = box_bounds(cx, "ig-pad-24");
assert!(
near(pad4.origin.x, 4.),
"the group override must set the exposed edge, got {pad4:?}"
);
assert!(
near(pad24.origin.x, WIDE),
"the wide group override must set the exposed edge, got {pad24:?}"
);
let prefix4 = box_bounds(cx, "ig-prefix-4");
let prefix24 = box_bounds(cx, "ig-prefix-24");
assert!(
near(prefix4.origin.x, f32::from(prefix24.origin.x)),
"a side with an addon keeps the addon's inset regardless of the \
group override: 4={prefix4:?} 24={prefix24:?}"
);
let direct = box_bounds(cx, "ig-direct");
assert!(
near(direct.origin.x, 12.),
"Input::padding_x must stay ignored inside a group, got {direct:?}"
);
}
#[gpui::test]
fn color_field_static_bare_drops_the_invalid_border(cx: &mut TestAppContext) {
let cx = open_host(cx, move || {
gpui::div()
.flex()
.flex_col()
.items_start()
.gap(px(16.))
.child(
ColorField::new("cf-bare-static", PickerColor::hsb(180., 1., 1.))
.is_bare(true)
.is_invalid(true)
.suffix(inset_probe("cf-bare-suffix")),
)
.child(
ColorField::new("cf-chromed-static", PickerColor::hsb(180., 1., 1.))
.is_invalid(true)
.suffix(inset_probe("cf-chromed-suffix")),
)
.child(
ColorField::new("cf-valid-static", PickerColor::hsb(180., 1., 1.))
.suffix(inset_probe("cf-valid-suffix")),
)
.into_any_element()
});
flush_frame(cx);
let bare_invalid = box_bounds(cx, "cf-bare-suffix");
let chromed_invalid = box_bounds(cx, "cf-chromed-suffix");
let valid = box_bounds(cx, "cf-valid-suffix");
assert!(
near(bare_invalid.origin.x, f32::from(valid.origin.x)),
"a bare static ColorField must paint no border even when invalid: \
bare={bare_invalid:?} valid={valid:?}"
);
assert!(
chromed_invalid.origin.x < valid.origin.x,
"the chromed invalid field must paint the danger border that insets \
its suffix: chromed={chromed_invalid:?} valid={valid:?}"
);
}