Skip to content

Commit aa08592

Browse files
madcodelifeclaude
andauthored
number_input: Built-in stepping, input restriction, and normalization (#2460)
Enhances `NumberInput` to work out of the box and match web `<input type=number>` behavior: - **Built-in stepping**: `↑`/`↓` keys and `+`/`-` buttons now step the value internally by `step` (default `1`) and emit `InputEvent::Change`. Add `step`/`step_by`/`min`/`max` (plus `set_*` runtime setters) on `InputState`. `min`/`max` clamp on stepping and on blur; stepping follows web semantics (a step that can't move in the pressed direction is a no-op). `step_by` receives the current value and `StepAction` so the step can differ by direction (e.g. a price tick size at a range boundary). - **Input restriction**: a `NumberInput`-bound state now only accepts a valid number (optional leading sign, digits, single decimal point) by default; opt out via `set_mask_pattern(MaskPattern::None, ...)`. - **Full-width normalization**: full-width digits/signs and the ideographic full stop (`12。5`) are normalized to ASCII (`12.5`) for CJK IME users; a bare leading dot (`.5`) is completed to `0.5`. Undo/redo now records a whole-document change when the mask reshapes the text, so masked values undo correctly. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3229ac9 commit aa08592

7 files changed

Lines changed: 1027 additions & 265 deletions

File tree

crates/story/src/stories/number_input_story.rs

Lines changed: 42 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,8 @@ pub struct NumberInputStory {
1818
number_input1_value: i64,
1919
number_input1: Entity<InputState>,
2020
number_input2: Entity<InputState>,
21-
number_input2_value: u64,
2221
number_input3: Entity<InputState>,
23-
number_input3_value: f64,
2422
number_input4: Entity<InputState>,
25-
number_input4_value: f64,
2623
disabled_input: Entity<InputState>,
2724

2825
_subscriptions: Vec<Subscription>,
@@ -52,17 +49,24 @@ impl NumberInputStory {
5249
}
5350

5451
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
52+
// Opt out of internal stepping via `set_step(None)`, so the
53+
// NumberInput emits NumberInputEvent::Step and the subscriber is
54+
// responsible for updating the value (see `on_number_input_event`).
5555
let number_input1_value = 1;
5656
let number_input1 = cx.new(|cx| {
5757
InputState::new(window, cx)
5858
.placeholder("Normal Integer")
5959
.default_value(number_input1_value.to_string())
6060
});
61+
number_input1.update(cx, |state, cx| state.set_step(None, window, cx));
6162

63+
// With min, the NumberInput steps the value internally (step
64+
// default: 1) and clamps it to the range, no event handling needed.
6265
let number_input2 = cx.new(|cx| {
6366
InputState::new(window, cx)
6467
.placeholder("Unsized Integer")
6568
.pattern(Regex::new(r"^\d+$").unwrap())
69+
.min(0.)
6670
});
6771

6872
let number_input3 = cx.new(|cx| {
@@ -72,15 +76,34 @@ impl NumberInputStory {
7276
separator: Some(','),
7377
fraction: Some(2),
7478
})
79+
.default_value("1234.56")
80+
.step(100.)
81+
.min(0.)
7582
});
7683

84+
// The step varies by direction at the boundary 1.0: 0.1 going down,
85+
// 0.5 going up.
7786
let number_input4 = cx.new(|cx| {
7887
InputState::new(window, cx)
7988
.placeholder("Styling")
80-
.mask_pattern(MaskPattern::Number {
81-
separator: Some(','),
82-
fraction: Some(2),
89+
.default_value("0.9")
90+
.step_by(|value, action, _cx| match action {
91+
StepAction::Increment => {
92+
if value < 1.0 {
93+
0.1
94+
} else {
95+
0.5
96+
}
97+
}
98+
StepAction::Decrement => {
99+
if value <= 1.0 {
100+
0.1
101+
} else {
102+
0.5
103+
}
104+
}
83105
})
106+
.min(0.)
84107
});
85108

86109
let disabled_input = cx.new(|cx| {
@@ -93,11 +116,8 @@ impl NumberInputStory {
93116
cx.subscribe_in(&number_input1, window, Self::on_input_event),
94117
cx.subscribe_in(&number_input1, window, Self::on_number_input_event),
95118
cx.subscribe_in(&number_input2, window, Self::on_input_event),
96-
cx.subscribe_in(&number_input2, window, Self::on_number_input_event),
97119
cx.subscribe_in(&number_input3, window, Self::on_input_event),
98-
cx.subscribe_in(&number_input3, window, Self::on_number_input_event),
99120
cx.subscribe_in(&number_input4, window, Self::on_input_event),
100-
cx.subscribe_in(&number_input4, window, Self::on_number_input_event),
101121
cx.subscribe_in(&disabled_input, window, Self::on_input_event),
102122
cx.subscribe_in(&disabled_input, window, Self::on_number_input_event),
103123
];
@@ -106,11 +126,8 @@ impl NumberInputStory {
106126
number_input1,
107127
number_input1_value,
108128
number_input2,
109-
number_input2_value: 0,
110129
number_input3,
111-
number_input3_value: 0.0,
112130
number_input4,
113-
number_input4_value: 0.0,
114131
disabled_input,
115132
_subscriptions,
116133
}
@@ -130,14 +147,6 @@ impl NumberInputStory {
130147
if let Ok(value) = text.parse::<i64>() {
131148
self.number_input1_value = value;
132149
}
133-
} else if state == &self.number_input2 {
134-
if let Ok(value) = text.parse::<u64>() {
135-
self.number_input2_value = value;
136-
}
137-
} else if state == &self.number_input3 {
138-
if let Ok(value) = text.parse::<f64>() {
139-
self.number_input3_value = value;
140-
}
141150
}
142151
println!("Change: {}", text);
143152
}
@@ -157,54 +166,21 @@ impl NumberInputStory {
157166
cx: &mut Context<Self>,
158167
) {
159168
match event {
160-
NumberInputEvent::Step(step_action) => match step_action {
161-
StepAction::Decrement => {
162-
if this == &self.number_input1 {
163-
self.number_input1_value = self.number_input1_value - 1;
164-
this.update(cx, |input, cx| {
165-
input.set_value(self.number_input1_value.to_string(), window, cx);
166-
});
167-
} else if this == &self.number_input2 {
168-
self.number_input2_value = self.number_input2_value.saturating_sub(1);
169-
this.update(cx, |input, cx| {
170-
input.set_value(self.number_input2_value.to_string(), window, cx);
171-
});
172-
} else if this == &self.number_input3 {
173-
self.number_input3_value = self.number_input3_value - 1.0;
174-
this.update(cx, |input, cx| {
175-
input.set_value(self.number_input3_value.to_string(), window, cx);
176-
});
177-
} else if this == &self.number_input4 {
178-
self.number_input4_value = self.number_input4_value - 1.0;
179-
this.update(cx, |input, cx| {
180-
input.set_value(self.number_input4_value.to_string(), window, cx);
181-
});
169+
NumberInputEvent::Step(step_action) => {
170+
if this == &self.number_input1 {
171+
match step_action {
172+
StepAction::Decrement => {
173+
self.number_input1_value -= 1;
174+
}
175+
StepAction::Increment => {
176+
self.number_input1_value += 1;
177+
}
182178
}
179+
this.update(cx, |input, cx| {
180+
input.set_value(self.number_input1_value.to_string(), window, cx);
181+
});
183182
}
184-
StepAction::Increment => {
185-
if this == &self.number_input1 {
186-
self.number_input1_value = self.number_input1_value + 1;
187-
this.update(cx, |input, cx| {
188-
input.set_value(self.number_input1_value.to_string(), window, cx);
189-
});
190-
} else if this == &self.number_input2 {
191-
self.number_input2_value = self.number_input2_value + 1;
192-
this.update(cx, |input, cx| {
193-
input.set_value(self.number_input2_value.to_string(), window, cx);
194-
});
195-
} else if this == &self.number_input3 {
196-
self.number_input3_value = self.number_input3_value + 1.0;
197-
this.update(cx, |input, cx| {
198-
input.set_value(self.number_input3_value.to_string(), window, cx);
199-
});
200-
} else if this == &self.number_input4 {
201-
self.number_input4_value = self.number_input4_value + 1.0;
202-
this.update(cx, |input, cx| {
203-
input.set_value(self.number_input4_value.to_string(), window, cx);
204-
});
205-
}
206-
}
207-
},
183+
}
208184
}
209185
}
210186
}

crates/ui/src/input/mask_pattern.rs

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::borrow::Cow;
2+
13
use gpui::SharedString;
24

35
#[derive(Clone, PartialEq, Debug)]
@@ -198,7 +200,8 @@ impl MaskPattern {
198200
let int_part = parts.next().unwrap_or("");
199201
let frac_part = parts.next();
200202

201-
if int_part.is_empty() {
203+
// only one dot is valid
204+
if parts.next().is_some() {
202205
return false;
203206
}
204207

@@ -413,6 +416,43 @@ fn is_sign(ch: &char) -> bool {
413416
matches!(ch, '+' | '-')
414417
}
415418

419+
/// Normalize full-width and CJK number characters into their ASCII equivalents.
420+
///
421+
/// E.g. `123。5` -> `123.5`
422+
///
423+
/// Every mapping is 1 char to 1 char with the same UTF-16 length, so the IME
424+
/// marked-range offsets (in UTF-16) stay valid. The UTF-8 byte length may
425+
/// shrink (3 bytes to 1), the caller must use the normalized string for all
426+
/// byte-offset calculations.
427+
pub(crate) fn normalize_number_input(text: &str) -> Cow<'_, str> {
428+
#[inline]
429+
fn normalize_char(ch: char) -> Option<char> {
430+
match ch {
431+
// Full-width digits 0-9
432+
'\u{FF10}'..='\u{FF19}' => char::from_u32(ch as u32 - 0xFF10 + '0' as u32),
433+
// Full-width plus +
434+
'\u{FF0B}' => Some('+'),
435+
// Full-width hyphen - and minus sign −
436+
'\u{FF0D}' | '\u{2212}' => Some('-'),
437+
// Full-width dot . and ideographic full stop 。
438+
'\u{FF0E}' | '\u{3002}' => Some('.'),
439+
// Full-width comma ,
440+
'\u{FF0C}' => Some(','),
441+
_ => None,
442+
}
443+
}
444+
445+
if !text.chars().any(|ch| normalize_char(ch).is_some()) {
446+
return Cow::Borrowed(text);
447+
}
448+
449+
Cow::Owned(
450+
text.chars()
451+
.map(|ch| normalize_char(ch).unwrap_or(ch))
452+
.collect(),
453+
)
454+
}
455+
416456
#[cfg(test)]
417457
mod tests {
418458
use crate::input::mask_pattern::{MaskPattern, MaskToken};
@@ -634,4 +674,51 @@ mod tests {
634674
assert_eq!(mask.mask("-1234567."), "-1,234,567.");
635675
assert_eq!(mask.mask("-1234567.89"), "-1,234,567.89");
636676
}
677+
678+
#[test]
679+
fn test_number_leading_dot() {
680+
let mask = MaskPattern::number(None);
681+
assert_eq!(mask.is_valid("."), true);
682+
assert_eq!(mask.is_valid(".5"), true);
683+
assert_eq!(mask.is_valid("-."), true);
684+
assert_eq!(mask.is_valid("-.5"), true);
685+
assert_eq!(mask.is_valid("1.2.3"), false);
686+
assert_eq!(mask.is_valid("1.."), false);
687+
688+
// A bare leading dot is kept as-is (not completed to "0."), so that
689+
// deleting the integer part of "1.2" keeps ".2" and stays editable.
690+
assert_eq!(mask.mask("."), ".");
691+
assert_eq!(mask.mask(".5"), ".5");
692+
assert_eq!(mask.mask("-.5"), "-.5");
693+
assert_eq!(mask.mask("+.5"), "+.5");
694+
695+
let mask = MaskPattern::number(Some(','));
696+
assert_eq!(mask.mask(".5"), ".5");
697+
assert_eq!(mask.mask("-.5"), "-.5");
698+
}
699+
700+
#[test]
701+
fn test_normalize_number_input() {
702+
use std::borrow::Cow;
703+
704+
use crate::input::mask_pattern::normalize_number_input;
705+
706+
// Fast path: no allocation when nothing to normalize
707+
assert!(matches!(
708+
normalize_number_input("-1,234.5"),
709+
Cow::Borrowed(_)
710+
));
711+
712+
// Full-width digits
713+
assert_eq!(normalize_number_input("0123456789"), "0123456789");
714+
// Full-width signs, dot and comma
715+
assert_eq!(normalize_number_input("+1.5"), "+1.5");
716+
assert_eq!(normalize_number_input("-1,234"), "-1,234");
717+
// Minus sign (U+2212)
718+
assert_eq!(normalize_number_input("−1.5"), "-1.5");
719+
// Ideographic full stop
720+
assert_eq!(normalize_number_input("12。5"), "12.5");
721+
// Other characters are kept as-is
722+
assert_eq!(normalize_number_input("ab 中 1"), "ab 中 1");
723+
}
637724
}

crates/ui/src/input/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pub use input::*;
3131
pub use lsp::*;
3232
pub use lsp_types::Position;
3333
pub use mask_pattern::MaskPattern;
34-
pub use number_input::{NumberInput, NumberInputEvent, StepAction};
34+
pub use number_input::{NumberInput, NumberInputEvent, NumberStep, StepAction};
3535
pub use otp_input::*;
3636
pub use rope_ext::{InputEdit, Point, RopeExt, RopeLines};
3737
pub use ropey::Rope;

0 commit comments

Comments
 (0)