NumberInput
A specialized input component for numeric values with built-in increment/decrement buttons and support for min/max values, step values, and number formatting with thousands separators.
#Import
use gpui_kit::component::input::{InputState, NumberInput, NumberInputEvent, StepAction};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { NumberInput } from "number-input.slint";use iced::widget::{Id, column, container, mouse_area, operation, row, scrollable, svg, text};
use iced::{font, Alignment, Element, Font, Length, Subscription, Task, Theme};#Usage
// The Slint example's gallery in one panel: the bounds and the step, the
// prefix and suffix slots, the four sizes, the `− value +` shape, the disabled
// state and a value that is not a number. Every control is `value` — the number
// a step works from — plus `text`, the caller's editing buffer.
let usage = column![
row![
NumberInput::new("quantity")
.value(app.quantity.value)
.text(app.quantity.text.as_str())
.minimum(0.0)
.maximum(99.0)
.on_input(|text| Message::Edited(Slot::Quantity, text))
.on_change(|value| Message::Changed(Slot::Quantity, value)),
NumberInput::new("price")
.value(app.price.value)
.text(app.price.text.as_str())
.step(0.5)
.prefix(slot_text("$", ControlSize::Medium))
.on_input(|text| Message::Edited(Slot::Price, text))
.on_change(|value| Message::Changed(Slot::Price, value)),
]
.spacing(12.0),
row![
row_label("Seats, in twos", false),
NumberInput::new("seats")
.value(app.seats.value)
.text(app.seats.text.as_str())
.minimum(0.0)
.maximum(20.0)
.step(2.0)
.width(150.0)
.on_input(|text| Message::Edited(Slot::Seats, text))
.on_change(|value| Message::Changed(Slot::Seats, value)),
]
.spacing(10.0)
.align_y(Alignment::Center),
row![
row_label("At the minimum", false),
NumberInput::new("at-minimum")
.value(app.at_minimum.value)
.text(app.at_minimum.text.as_str())
.minimum(0.0)
.maximum(99.0)
.width(150.0)
.on_input(|text| Message::Edited(Slot::AtMinimum, text))
.on_change(|value| Message::Changed(Slot::AtMinimum, value)),
]
.spacing(10.0)
.align_y(Alignment::Center),
row![
row_label("At the maximum", false),
NumberInput::new("at-maximum")
.value(app.at_maximum.value)
.text(app.at_maximum.text.as_str())
.minimum(0.0)
.maximum(100.0)
.step(5.0)
.suffix(slot_text("%", ControlSize::Medium))
.width(170.0)
.on_input(|text| Message::Edited(Slot::AtMaximum, text))
.on_change(|value| Message::Changed(Slot::AtMaximum, value)),
]
.spacing(10.0)
.align_y(Alignment::Center),
NumberInput::new("salary")
.value(app.salary.value)
.text(app.salary.text.as_str())
.step(50.0)
.prefix(slot_text("$", ControlSize::Medium))
.suffix(slot_text("/mo", ControlSize::Medium))
.on_input(|text| Message::Edited(Slot::Salary, text))
.on_change(|value| Message::Changed(Slot::Salary, value)),
NumberInput::new("rejected")
.value(app.rejected.value)
.text(app.rejected.text.as_str())
.minimum(0.0)
.maximum(99.0)
.invalid(true)
.on_input(|text| Message::Edited(Slot::Rejected, text))
.on_change(|value| Message::Changed(Slot::Rejected, value)),
row![
NumberInput::new("size-small")
.value(app.size_small.value)
.text(app.size_small.text.as_str())
.size(ControlSize::Small)
.on_input(|text| Message::Edited(Slot::SizeSmall, text))
.on_change(|value| Message::Changed(Slot::SizeSmall, value)),
NumberInput::new("size-medium")
.value(app.size_medium.value)
.text(app.size_medium.text.as_str())
.size(ControlSize::Medium)
.on_input(|text| Message::Edited(Slot::SizeMedium, text))
.on_change(|value| Message::Changed(Slot::SizeMedium, value)),
NumberInput::new("size-large")
.value(app.size_large.value)
.text(app.size_large.text.as_str())
.size(ControlSize::Large)
.on_input(|text| Message::Edited(Slot::SizeLarge, text))
.on_change(|value| Message::Changed(Slot::SizeLarge, value)),
]
.spacing(12.0),
row![
NumberInput::new("centered")
.value(app.centered.value)
.text(app.centered.text.as_str())
.minimum(0.0)
.maximum(99.0)
.prefix(slot_text("$", ControlSize::Medium))
.centered(true)
.width(220.0)
.on_input(|text| Message::Edited(Slot::Centered, text))
.on_change(|value| Message::Changed(Slot::Centered, value)),
NumberInput::new("disabled")
.value(8.0)
.minimum(0.0)
.maximum(20.0)
.disabled(true)
.width(220.0),
]
.spacing(12.0),
muted_line(&format!(
"Quantity {} — a step clamps to 0..=99, and the stepper that would cross a bound is disabled.",
number_input::shown(app.quantity.value)
)),
]
.spacing(14.0);#Quantity
struct QuantitySelector {
quantity_input: Entity<InputState>,
}
impl QuantitySelector {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
// Step by 1 and clamp to 1..=99, no event handling needed.
let quantity_input = cx.new(|cx|
InputState::new(window, cx)
.default_value("1")
.min(1.)
.max(99.)
);
Self { quantity_input }
}
}
// Usage
NumberInput::new(&self.quantity_input).small()NumberInput {
value: 3;
minimum: 0;
maximum: 99;
step: 1;
}
NumberInput {
value: 19.99;
prefix: "$";
step: 0.5;
}#Basic Number Input
let number_input = cx.new(|cx|
InputState::new(window, cx)
.placeholder("Enter number")
.default_value("1")
);
NumberInput::new(&number_input)// The basic control in the centred-stepper shape: `−` at the leading edge, the
// value in the middle and `+` at the trailing edge, so the row reads
// `− 12 +` rather than `12 − +`. It is the same field — the frame, the 32px
// medium height and the two extra-small ghost steppers — rearranged by one
// property, not a second control.
let basic = NumberInput::new("basic")
.value(app.basic.value)
.text(app.basic.text.as_str())
.minimum(0.0)
.maximum(99.0)
.centered(true)
.on_input(|text| Message::Edited(Slot::Basic, text))
.on_change(|value| Message::Changed(Slot::Basic, value));#Input Restriction and Normalization
By default, the NumberInput only accepts a valid number: an optional leading
+/- sign, digits and a single decimal point (e.g. -1.5), other characters
are rejected on typing and pasting.
Full-width number characters are normalized into their ASCII equivalents automatically, for CJK IME users:
- Full-width digits:
123→123 - Full-width signs:
+→+,-→- - Full-width dot and ideographic full stop:
.,。→.
A bare leading decimal point is kept as-is (e.g. .5, parsed as 0.5), matching the web behavior, so deleting the integer part of 1.2 keeps .2 and stays editable.
To opt out of the default restriction, set an explicit mask:
state.set_mask_pattern(MaskPattern::None, window, cx).
To further restrict the input (e.g. positive integers only), use pattern:
// Integer input with validation
let integer_input = cx.new(|cx|
InputState::new(window, cx)
.placeholder("Integer value")
.pattern(Regex::new(r"^\d+$").unwrap()) // Only positive integers
);
NumberInput::new(&integer_input)#With Min/Max/Step
By default, the NumberInput updates the value internally with step(1.):
the ↑/↓ keys and the +/- buttons step the value by 1 and emit
InputEvent::Change. Set min/max to clamp the range, or set a custom step.
To fall back to emitting NumberInputEvent::Step only (the subscriber is
responsible for updating the value), call
state.set_step(None, window, cx).
A typed out-of-range value is kept while typing, and clamped on blur.
Stepping follows the web behavior: a step that cannot move the value in the
pressed direction (e.g. ↓ on a value at or below the min) does nothing.
let stepper_input = cx.new(|cx|
InputState::new(window, cx)
.default_value("50")
.step(5.)
.min(0.)
.max(100.)
);
NumberInput::new(&stepper_input)NumberInput {
value: 1;
minimum: 1;
maximum: 12;
step: 1;
}
NumberInput {
value: 100;
minimum: 0;
maximum: 100;
step: 5;
suffix: "%";
}#Dynamic Step
Use step_by to calculate the step value from the current value and the step
direction, e.g. a step size that varies by range. Because the step can differ
by direction at a boundary, the closure receives the StepAction; here 1.0
steps by 0.1 going down and 0.5 going up. The closure also receives a
Context for reading or updating other entities:
let price_input = cx.new(|cx|
InputState::new(window, cx)
.step_by(|value, action, _cx| match action {
StepAction::Increment => if value < 1.0 { 0.1 } else { 0.5 },
StepAction::Decrement => if value <= 1.0 { 0.1 } else { 0.5 },
})
.min(0.)
);
NumberInput::new(&price_input)The step strategy can also be updated at runtime via set_step:
use gpui_kit::component::input::NumberStep;
state.set_step(NumberStep::Fixed(0.01), window, cx);
state.set_step(NumberStep::by_value(|v, _, _cx| if v < 1. { 0.01 } else { 0.1 }), window, cx);
state.set_step(None, window, cx); // Fall back to NumberInputEvent::Step#With Number Formatting
use gpui_kit::component::input::MaskPattern;
// Currency input with thousands separator
let currency_input = cx.new(|cx|
InputState::new(window, cx)
.placeholder("Amount")
.mask_pattern(MaskPattern::Number {
separator: Some(','),
fraction: Some(2), // 2 decimal places
})
);
NumberInput::new(¤cy_input)NumberInput {
value: 1250;
step: 50;
prefix: "$";
suffix: "/mo";
}
NumberInput {
value: 7.5;
step: 0.5;
suffix: "kg";
}#Different Sizes
// Large size
NumberInput::new(&input).large()
// Medium size (default)
NumberInput::new(&input)
// Small size
NumberInput::new(&input).small()NumberInput {
value: 4;
size: ControlSize.small;
}
NumberInput {
value: 4;
}
NumberInput {
value: 4;
size: ControlSize.large;
}#With Prefix and Suffix
use gpui_kit::component::{button::{Button, ButtonVariants}, IconName};
// With currency prefix
NumberInput::new(&input)
.prefix(div().child("$"))
// With info button suffix
NumberInput::new(&input)
.suffix(
Button::new("info")
.ghost()
.icon(IconName::Info)
.xsmall()
)#Disabled State
NumberInput::new(&input).disabled(true)NumberInput {
value: 8;
enabled: false;
}#Without Default Styling
// For custom container styling
div()
.w_full()
.bg(cx.theme().secondary)
.rounded(cx.theme().radius)
.child(NumberInput::new(&input).appearance(false))description: "The value is centred, with a stepper at each end.";
VerticalLayout {
spacing: 12px;
NumberInput {
centered: true;
value: 3;
minimum: 0;
maximum: 99;
}
NumberInput {
centered: true;
value: 42;
minimum: 0;
maximum: 99;
prefix: "$";
}
NumberInput {
centered: true;
value: 8;
size: ControlSize.large;
}
HorizontalLayout {
spacing: 10px;
Text {
horizontal-stretch: 1;
horizontal-alignment: left;
text: "Seats, in twos";
color: Theme.foreground;
font-size: Theme.font-size-sm;
vertical-alignment: center;
}
NumberInput {
centered: true;
width: 140px;
value: 6;
minimum: 0;
maximum: 20;
step: 2;
}
}
}#Handle Number Input Events
By default, the NumberInput updates the value internally. To fall back to
NumberInputEvent::Step (the subscriber is responsible for updating the
value), call state.set_step(None, window, cx):
let number_input = cx.new(|cx| InputState::new(window, cx));
let mut value: i64 = 0;
// Subscribe to input changes
cx.subscribe_in(&number_input, window, |view, state, event, window, cx| {
match event {
InputEvent::Change => {
let text = state.read(cx).value();
if let Ok(new_value) = text.parse::<i64>() {
view.value = new_value;
}
}
_ => {}
}
});
// Subscribe to increment/decrement actions
cx.subscribe_in(&number_input, window, |view, state, event, window, cx| {
match event {
NumberInputEvent::Step(step_action) => {
match step_action {
StepAction::Increment => {
view.value += 1;
state.update(cx, |input, cx| {
input.set_value(view.value.to_string(), window, cx);
});
}
StepAction::Decrement => {
view.value -= 1;
state.update(cx, |input, cx| {
input.set_value(view.value.to_string(), window, cx);
});
}
}
}
}
});#Programmatic Control
// Increment programmatically
NumberInput::increment(&number_input, window, cx);
// Decrement programmatically
NumberInput::decrement(&number_input, window, cx);// The programmatic step the page documents: one control built with every
// method the reference lists, and the two helpers a programmatic step needs.
// `step_value` is the arithmetic the steppers perform; `shown` is the text they
// write back. It is the last card because it is the last usage example, not
// because it lists the API — `## API Reference` ends the page's example region,
// so a card named for it has no heading to scroll to and reads as broken.
let api = column![
NumberInput::new("api")
.value(app.api.value)
.text(app.api.text.as_str())
.minimum(0.0)
.maximum(100.0)
.step(5.0)
.placeholder("0 to 100")
.size(ControlSize::Medium)
.prefix(slot_text("$", ControlSize::Medium))
.suffix(slot_text("%", ControlSize::Medium))
.invalid(false)
.centered(false)
.width(Length::Fill)
.on_input(|text| Message::Edited(Slot::Api, text))
.on_change(|value| Message::Changed(Slot::Api, value))
.on_submit(Message::Commit(Slot::Api, app.api.text.clone())),
row![
Button::new("Decrease")
.size(ControlSize::Small)
.on_press(Message::ApiStep(StepAction::Decrement)),
Button::new("Increase")
.size(ControlSize::Small)
.on_press(Message::ApiStep(StepAction::Increment)),
muted_line(&format!(
"step_value({}, Decrement, 5.0, 0.0, 100.0) = {:?}",
number_input::shown(app.api.value),
number_input::step_value(app.api.value, StepAction::Decrement, 5.0, 0.0, 100.0)
.map(number_input::shown),
)),
]
.spacing(8.0)
.align_y(Alignment::Center),
]
.spacing(12.0);#Integer Counter
struct CounterView {
counter_input: Entity<InputState>,
counter_value: i32,
}
impl CounterView {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let counter_input = cx.new(|cx|
InputState::new(window, cx)
.placeholder("Count")
.default_value("0")
.pattern(Regex::new(r"^-?\d+$").unwrap()) // Allow negative integers
);
let _subscription = cx.subscribe_in(&counter_input, window, Self::on_number_event);
Self {
counter_input,
counter_value: 0,
}
}
fn on_number_event(
&mut self,
state: &Entity<InputState>,
event: &NumberInputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
NumberInputEvent::Step(StepAction::Increment) => {
self.counter_value += 1;
state.update(cx, |input, cx| {
input.set_value(self.counter_value.to_string(), window, cx);
});
}
NumberInputEvent::Step(StepAction::Decrement) => {
self.counter_value -= 1;
state.update(cx, |input, cx| {
input.set_value(self.counter_value.to_string(), window, cx);
});
}
}
}
}
// Usage
NumberInput::new(&self.counter_input)VerticalLayout {
spacing: 14px;
HorizontalLayout {
spacing: 10px;
Text {
horizontal-stretch: 1;
horizontal-alignment: left;
text: "Quantity";
color: Theme.foreground;
font-size: Theme.font-size-sm;
vertical-alignment: center;
}
NumberInput {
width: 96px;
value: 3;
minimum: 0;
maximum: 99;
}
}
HorizontalLayout {
spacing: 10px;
Text {
horizontal-stretch: 1;
horizontal-alignment: left;
text: "Seats, in twos";
color: Theme.foreground;
font-size: Theme.font-size-sm;
vertical-alignment: center;
}
NumberInput {
width: 96px;
value: 12;
minimum: 0;
maximum: 20;
step: 2;
}
}
// At the bound, the stepper that would cross it is disabled.
HorizontalLayout {
spacing: 10px;
Text {
horizontal-stretch: 1;
horizontal-alignment: left;
text: "At the minimum";
color: Theme.foreground;
font-size: Theme.font-size-sm;
vertical-alignment: center;
}
NumberInput {
width: 96px;
value: 0;
minimum: 0;
maximum: 99;
}
}
HorizontalLayout {
spacing: 10px;
Text {
horizontal-stretch: 1;
horizontal-alignment: left;
text: "Disabled";
color: Theme.muted-foreground;
font-size: Theme.font-size-sm;
vertical-alignment: center;
}
NumberInput {
width: 96px;
value: 8;
enabled: false;
}
}
}#Currency Input
struct PriceInput {
price_input: Entity<InputState>,
price_value: f64,
}
impl PriceInput {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let price_input = cx.new(|cx|
InputState::new(window, cx)
.placeholder("0.00")
.mask_pattern(MaskPattern::Number {
separator: Some(','),
fraction: Some(2),
})
);
Self {
price_input,
price_value: 0.0,
}
}
}
// Usage with currency prefix
h_flex()
.gap_2()
.child(div().child("$"))
.child(NumberInput::new(&self.price_input))#Floating Point Input
// Step by 0.1, the fraction digits of the value are kept on stepping,
// e.g. 0.2 -> 0.3 (not 0.30000000000000004).
let float_input = cx.new(|cx|
InputState::new(window, cx)
.placeholder("0.0")
.step(0.1)
);
NumberInput::new(&float_input)#Keyboard Navigation
// The table's keys are the application's, because iced's field reports text
// and submit only: `↑` and `↓` step through the subscription above, `Escape`
// clears through it, `Enter` arrives as `on_submit`, and `Tab`/`Shift+Tab` are
// iced's own focus traversal. iced gives the application no focus report, so
// the subscription steps this one field whatever is focused. The field
// captures these keys itself, so the subscription reads them with
// `event::listen_with` rather than `keyboard::listen`.
let keyboard = column![
NumberInput::new("keyboard")
.value(app.keyboard.value)
.text(app.keyboard.text.as_str())
.minimum(0.0)
.maximum(10.0)
.on_input(|text| Message::Edited(Slot::Keyboard, text))
.on_change(|value| Message::Changed(Slot::Keyboard, value))
.on_submit(Message::Commit(Slot::Keyboard, app.keyboard.text.clone())),
muted_line(&format!(
"↑ / ↓ step it, Escape clears it, Enter clamps what is typed{}. Tab and Shift+Tab are iced's.",
match app.committed.as_deref() {
Some(value) => format!(" — last committed {value}"),
None => String::new(),
}
)),
]
.spacing(8.0);| Key | Action |
|---|---|
↑ | Increment value |
↓ | Decrement value |
Tab | Navigate to next field |
Shift+Tab | Navigate to previous field |
Enter | Submit/confirm value |
Escape | Clear input (if enabled) |
#Best Practices
// The seven rules are prose, and prose is not a widget. The card builds the
// rules the control itself enforces — a step, a range, and the two bounds that
// clamp and disable — and leaves validation, formatting and error copy to the
// application, which the danger edge and the readout report.
let practices = column![
row![
row_label("Seats, in twos", false),
NumberInput::new("practice-seats")
.value(app.practice_seats.value)
.text(app.practice_seats.text.as_str())
.minimum(0.0)
.maximum(20.0)
.step(2.0)
.width(120.0)
.on_input(|text| Message::Edited(Slot::PracticeSeats, text))
.on_change(|value| Message::Changed(Slot::PracticeSeats, value)),
]
.spacing(10.0)
.align_y(Alignment::Center),
row![
row_label("Amount, two decimals", false),
NumberInput::new("practice-price")
.value(app.practice_price.value)
.text(app.practice_price.text.as_str())
.minimum(0.0)
.maximum(1000.0)
.step(0.5)
.prefix(slot_text("$", ControlSize::Medium))
.width(120.0)
.on_input(|text| Message::Edited(Slot::PracticePrice, text))
.on_change(|value| Message::Changed(Slot::PracticePrice, value)),
]
.spacing(10.0)
.align_y(Alignment::Center),
muted_line("The step and the range are the control's: a step clamps, and a bound disables its stepper."),
muted_line("Validation, formatting and the message beside a field are the application's — Slint has no property for them either."),
]
.spacing(10.0);- Validation: Always validate numeric input on both client and server side
- Range Limits: Use
min/maxto clamp values for user safety - Step Size: Choose appropriate
stepvalues for your use case - Error Handling: Provide clear feedback for invalid input
- Formatting: Use consistent number formatting across your application
- Performance: Debounce rapid increment/decrement actions if needed
- Accessibility: Always provide proper labels and descriptions
#API Reference
#NumberInput
| Method | Description |
|---|---|
new(state) | Create number input with InputState entity |
placeholder(str) | Set placeholder text |
size(size) | Set input size (small, medium, large) |
prefix(el) | Add prefix element |
suffix(el) | Add suffix element |
appearance(bool) | Enable/disable default styling |
disabled(bool) | Set disabled state |
increment(state, window, cx) | Increment value programmatically |
decrement(state, window, cx) | Decrement value programmatically |
#NumberInputEvent
| Event | Description |
|---|---|
Step(StepAction) | Increment/decrement pressed. Only emitted when step is None (opt out via set_step(None, ...)). |
#StepAction
| Action | Description |
|---|---|
Increment | Value should be increased |
Decrement | Value should be decreased |
#InputState (Number-specific methods)
| Method | Description |
|---|---|
step(impl Into<NumberStep>) | Set step value for built-in increment/decrement (default: 1) |
step_by(fn(f64, StepAction, &mut Context) -> f64) | Calculate step value based on the current value and direction |
min(f64) | Set minimum value, clamped on stepping and blur |
max(f64) | Set maximum value, clamped on stepping and blur |
set_step(Option<NumberStep>, ...) | Update step strategy after construction |
set_min(Option<f64>, ...) | Update minimum value after construction |
set_max(Option<f64>, ...) | Update maximum value after construction |
pattern(regex) | Set regex pattern for validation (e.g., digits only) |
mask_pattern(MaskPattern::Number) | Set number formatting with separator and decimal places |
value() | Get current display value (formatted) |
unmask_value() | Get actual numeric value (unformatted) |
#MaskPattern::Number
| Field | Type | Description |
|---|---|---|
separator | Option<char> | Thousands separator (e.g., ’,’ or ’ ’) |
fraction | Option<usize> | Number of decimal places |
#NumberInput
A numeric field with steppers. Mirrors GPUI Component's `NumberInput`.
| Name | Kind | Type | Default | Description |
|---|---|---|---|---|
value | in-out | float | ||
minimum | in | float | -1000000000 | |
maximum | in | float | 1000000000 | |
step | in | float | 1 | |
placeholder | in | string | ||
prefix | in | string | ||
suffix | in | string | ||
enabled | in | bool | true | |
size | in | ControlSize | ControlSize.medium | |
centered | in | bool | Centres the value and moves the steppers to either end, so the control reads as `− value +` instead of `value − +`. The frame is then this component's own and the field inside it drops its own with `appearance: false`, which is what that flag exists for. | |
changed | callback | (value: float) |