Forms · NumberInput: blank shows a placeholder and commits only what is typed
This commit is contained in:
4 files changed
+101
-40
No files matched your search
@@ -266,3 +266,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- 2026-10-05: `NumberInput::label` names the field for assistive technology without drawing it, through `TextInput::set_label` on the field's inner input, which lives across frames; a number field named only by a description list beside it was anonymous in the AX tree.
|
||||
- 2026-10-05: `TemplatePicker`'s list is one Tab stop: up, down, Home and End move the highlight, Enter uses it; rows are list items with name, selection, place and count; its search placeholder, All chip, Use button and empty line read from the builtin catalog in English and Chinese. Before, the rows were reachable by mouse alone and anonymous in the AX tree.
|
||||
- 2026-10-05: `SelectableList` is a list in the AX tree, named by `label`; each `ListItem` is a list item with its title as name, its description, its selection, and, in a selectable list, its place and count (`ListItem::place`). Before, the rows had no role and were missing from the AX tree.
|
||||
- 2026-10-05: `NumberInput::blank` shows a placeholder, not a number, as for a mixed selection; Enter or blur on the empty field commits nothing, a typed number commits as usual. Before, a mixed field held a real number the owner had to invent, and Enter sent it. `ScrubInput` moved to `number/scrub.rs`.
|
||||
+25
-40
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user