Forms · NumberInput: blank shows a placeholder and commits only what is typed

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 17:48:31 -04:00
1 parent 7df3045a82
commit af54877693
4 files changed
+101 -40

No files matched your search

+25 -40
View File
@@ -65,6 +65,8 @@ struct Numeric {
on_commit: Option<OnChange>,
value: f64,
limits: (f64, f64, f64, usize),
/// Shows no number; an empty field commits nothing.
blank: bool,
_events: Subscription,
}
@@ -122,6 +124,9 @@ fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Numeric>
let (min, max, _, precision) = numeric.limits;
match event {
InputEvent::Blur | InputEvent::Submit => {
if numeric.blank && input.read(cx).text().trim().is_empty() {
return;
}
let value = settle(numeric.current(cx), min, max, precision);
numeric.value = value;
show(
@@ -156,6 +161,7 @@ fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Numeric>
on_commit: None,
value: 0.0,
limits: (f64::MIN, f64::MAX, 1.0, 0),
blank: false,
_events: events,
}
})
@@ -225,6 +231,7 @@ pub struct NumberInput {
on_commit: Option<OnChange>,
scrub_label: Option<SharedString>,
label: SharedString,
blank: Option<SharedString>,
}
impl NumberInput {
@@ -243,6 +250,7 @@ impl NumberInput {
on_commit: None,
scrub_label: None,
label: SharedString::default(),
blank: None,
}
}
@@ -252,6 +260,12 @@ impl NumberInput {
self
}
/// Shows `placeholder`, not a number, as for a mixed selection; commits once typed in.
pub fn blank(mut self, placeholder: impl Into<SharedString>) -> Self {
self.blank = Some(placeholder.into());
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
assert!(min <= max, "number range {min}..{max} is empty");
self.min = min;
@@ -322,13 +336,21 @@ impl RenderOnce for NumberInput {
numeric.on_commit = self.on_commit.clone();
numeric.value = value;
numeric.limits = (min, max, step, self.precision);
numeric.blank = self.blank.is_some();
});
let input = state.read(cx).input.clone();
if input.read(cx).label_text() != &self.label {
let label = self.label.clone();
input.update(cx, |input, cx| input.set_label(label, cx));
}
let shown = format::number(value, self.precision, Separators::EN);
let placeholder = self.blank.clone().unwrap_or_default();
if input.read(cx).placeholder_text() != &placeholder {
input.update(cx, |input, cx| input.set_placeholder(placeholder, cx));
}
let shown = match self.blank {
Some(_) => String::new(),
None => format::number(value, self.precision, Separators::EN),
};
let editing = input.read(cx).focus().is_focused(window);
if !editing && input.read(cx).text() != shown {
input.update(cx, |input, cx| input.set_text(shown, cx));
@@ -452,45 +474,8 @@ impl RenderOnce for NumberInput {
}
}
/// A label you drag sideways to change a number, beside its field.
#[derive(IntoElement)]
pub struct ScrubInput {
field: NumberInput,
}
impl ScrubInput {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, value: f64) -> Self {
let mut field = NumberInput::new(id, value);
field.scrub_label = Some(label.into());
Self { field }
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.field = self.field.range(min, max);
self
}
pub fn step(mut self, step: f64) -> Self {
self.field = self.field.step(step);
self
}
pub fn precision(mut self, places: usize) -> Self {
self.field = self.field.precision(places);
self
}
pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
self.field = self.field.on_change(handler);
self
}
}
impl RenderOnce for ScrubInput {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.field
}
}
mod scrub;
pub use scrub::ScrubInput;
#[cfg(test)]
mod tests;
+45
View File
@@ -0,0 +1,45 @@
//! A number field with a label you drag.
use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window};
use super::NumberInput;
/// A label you drag sideways to change a number, beside its field.
#[derive(IntoElement)]
pub struct ScrubInput {
field: NumberInput,
}
impl ScrubInput {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, value: f64) -> Self {
let mut field = NumberInput::new(id, value);
field.scrub_label = Some(label.into());
Self { field }
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.field = self.field.range(min, max);
self
}
pub fn step(mut self, step: f64) -> Self {
self.field = self.field.step(step);
self
}
pub fn precision(mut self, places: usize) -> Self {
self.field = self.field.precision(places);
self
}
pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
self.field = self.field.on_change(handler);
self
}
}
impl RenderOnce for ScrubInput {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.field
}
}
+30
View File
@@ -251,6 +251,36 @@ fn typing_commits_only_on_enter_and_steps(cx: &mut TestAppContext) {
);
}
struct Blank {
values: Vec<f64>,
}
impl Render for Blank {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
NumberInput::new("blank", 0.0)
.blank("Mixed")
.on_commit(move |value, _, cx| view.update(cx, |view, _| view.values.push(value)))
}
}
#[gpui::test]
fn a_blank_field_commits_only_what_is_typed(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Blank { values: Vec::new() });
cx.update(|window, cx| window.focus_next(cx));
cx.simulate_keystrokes("enter");
cx.update(|window, cx| window.focus_next(cx));
assert!(
view.read_with(cx, |view, _| view.values.is_empty()),
"Enter and blur on an empty field commit nothing"
);
cx.update(|window, cx| window.focus_prev(cx));
cx.simulate_input("0");
cx.simulate_keystrokes("enter");
assert_eq!(view.read_with(cx, |view, _| view.values.clone()), [0.0]);
}
struct Pin {
code: String,
}