Another project
1use core::str::FromStr;
2
3use super::parsed_input::{ParsedInput, ParsedInputResponse, ParsedValue};
4
5pub type NumericInput<'state, T> = ParsedInput<'state, T>;
6pub type NumericInputResponse<T> = ParsedInputResponse<T>;
7
8macro_rules! impl_parsed_value_via_fromstr {
9 ($($ty:ty),+ $(,)?) => {$(
10 impl ParsedValue for $ty {
11 type Error = <$ty as FromStr>::Err;
12
13 fn parse(text: &str) -> Result<Self, Self::Error> {
14 text.trim().parse::<$ty>()
15 }
16 }
17 )+};
18}
19
20impl_parsed_value_via_fromstr!(i8, i16, i32, i64, i128, isize);
21impl_parsed_value_via_fromstr!(u8, u16, u32, u64, u128, usize);
22
23#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
24pub enum NumericFloatParseError {
25 #[error("not a finite number")]
26 NotFinite,
27 #[error("invalid float: {0}")]
28 Parse(String),
29}
30
31macro_rules! impl_parsed_value_finite_float {
32 ($($ty:ty),+ $(,)?) => {$(
33 impl ParsedValue for $ty {
34 type Error = NumericFloatParseError;
35
36 fn parse(text: &str) -> Result<Self, Self::Error> {
37 let trimmed = text.trim();
38 let value: $ty = trimmed
39 .parse()
40 .map_err(|_| NumericFloatParseError::Parse(trimmed.to_owned()))?;
41 if value.is_finite() {
42 Ok(value)
43 } else {
44 Err(NumericFloatParseError::NotFinite)
45 }
46 }
47 }
48 )+};
49}
50
51impl_parsed_value_finite_float!(f32, f64);
52
53#[cfg(test)]
54mod tests {
55 use std::sync::Arc;
56
57 use super::{NumericInput, NumericInputResponse};
58 use crate::focus::FocusManager;
59 use crate::frame::FrameCtx;
60 use crate::hit_test::{HitFrame, HitState};
61 use crate::hotkey::HotkeyTable;
62 use crate::input::{FrameInstant, InputSnapshot};
63 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
64 use crate::strings::StringKey;
65 use crate::strings::StringTable;
66 use crate::theme::Theme;
67 use crate::widget_id::{WidgetId, WidgetKey};
68 use crate::widgets::parsed_input::{ParsedValue, show_parsed_input};
69 use crate::widgets::{MemoryClipboard, TextInputState};
70
71 const PLACEHOLDER: StringKey = StringKey::new("numeric.placeholder");
72
73 fn rect() -> LayoutRect {
74 LayoutRect::new(
75 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
76 LayoutSize::new(LayoutPx::new(120.0), LayoutPx::new(24.0)),
77 )
78 }
79
80 fn id_widget() -> WidgetId {
81 WidgetId::ROOT.child(WidgetKey::new("numeric"))
82 }
83
84 fn run<T: ParsedValue + 'static>(state: &mut TextInputState) -> NumericInputResponse<T> {
85 let theme = Arc::new(Theme::light());
86 let mut focus = FocusManager::new();
87 focus.register_focusable(id_widget());
88 focus.request_focus(id_widget());
89 focus.end_frame();
90 let table = HotkeyTable::new();
91 let mut hits = HitFrame::new();
92 let prev = HitState::new();
93 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
94 input.text_committed = String::new();
95 let mut clipboard = MemoryClipboard::default();
96 let widget = NumericInput::<T>::new(id_widget(), rect(), PLACEHOLDER, state);
97 let mut shaper = bone_text::Shaper::new();
98 let mut a11y = crate::a11y::AccessTreeBuilder::new();
99 let mut ctx = FrameCtx::new(
100 theme,
101 &mut input,
102 &mut focus,
103 &table,
104 StringTable::empty(),
105 &mut hits,
106 &prev,
107 &mut a11y,
108 &mut shaper,
109 );
110 show_parsed_input(&mut ctx, widget, &mut clipboard)
111 }
112
113 #[test]
114 fn parses_valid_integer() {
115 let mut state = TextInputState::from_text("42");
116 let response: NumericInputResponse<i32> = run(&mut state);
117 assert_eq!(response.value, Some(42));
118 assert!(response.error.is_none());
119 }
120
121 #[test]
122 fn parses_valid_float() {
123 let mut state = TextInputState::from_text("2.5");
124 let response: NumericInputResponse<f64> = run(&mut state);
125 assert_eq!(response.value, Some(2.5));
126 assert!(response.error.is_none());
127 }
128
129 #[test]
130 fn rejects_garbage_with_typed_error() {
131 let mut state = TextInputState::from_text("not-a-number");
132 let response: NumericInputResponse<u32> = run(&mut state);
133 assert!(response.value.is_none());
134 assert!(response.error.is_some());
135 }
136
137 #[test]
138 fn empty_text_is_neither_value_nor_error() {
139 let mut state = TextInputState::default();
140 let response: NumericInputResponse<i64> = run(&mut state);
141 assert!(response.value.is_none());
142 assert!(response.error.is_none());
143 }
144
145 #[test]
146 fn negative_integer_parses_when_signed() {
147 let mut state = TextInputState::from_text("-7");
148 let response: NumericInputResponse<i32> = run(&mut state);
149 assert_eq!(response.value, Some(-7));
150 }
151
152 #[test]
153 fn negative_integer_errors_when_unsigned() {
154 let mut state = TextInputState::from_text("-7");
155 let response: NumericInputResponse<u32> = run(&mut state);
156 assert!(response.error.is_some());
157 }
158
159 #[test]
160 fn float_rejects_nan() {
161 use super::NumericFloatParseError;
162 let mut state = TextInputState::from_text("NaN");
163 let response: NumericInputResponse<f64> = run(&mut state);
164 assert!(response.value.is_none());
165 assert_eq!(response.error, Some(NumericFloatParseError::NotFinite));
166 }
167
168 #[test]
169 fn float_rejects_infinity() {
170 use super::NumericFloatParseError;
171 let mut state = TextInputState::from_text("inf");
172 let response: NumericInputResponse<f64> = run(&mut state);
173 assert!(response.value.is_none());
174 assert_eq!(response.error, Some(NumericFloatParseError::NotFinite));
175 }
176
177 #[test]
178 fn float_accepts_finite_value() {
179 let mut state = TextInputState::from_text("2.5");
180 let response: NumericInputResponse<f64> = run(&mut state);
181 assert_eq!(response.value, Some(2.5));
182 assert!(response.error.is_none());
183 }
184
185 #[test]
186 fn whitespace_around_value_parses() {
187 let mut state = TextInputState::from_text(" 12 ");
188 let response: NumericInputResponse<i32> = run(&mut state);
189 assert_eq!(response.value, Some(12));
190 }
191
192 #[test]
193 fn typing_alone_does_not_commit() {
194 let mut state = TextInputState::from_text("42");
195 let response: NumericInputResponse<i32> = run(&mut state);
196 assert_eq!(response.value, Some(42));
197 assert!(response.committed.is_none());
198 }
199
200 #[test]
201 fn enter_while_focused_commits_value() {
202 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey};
203 let mut state = TextInputState::from_text("42");
204 let theme = Arc::new(Theme::light());
205 let mut focus = FocusManager::new();
206 focus.register_focusable(id_widget());
207 focus.request_focus(id_widget());
208 focus.end_frame();
209 let table = HotkeyTable::new();
210 let mut hits = HitFrame::new();
211 let prev = HitState::new();
212 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
213 input.keys_pressed.push(KeyEvent::new(
214 KeyCode::Named(NamedKey::Enter),
215 ModifierMask::NONE,
216 ));
217 let mut clipboard = MemoryClipboard::default();
218 let widget = NumericInput::<i32>::new(id_widget(), rect(), PLACEHOLDER, &mut state);
219 let response = {
220 let mut shaper = bone_text::Shaper::new();
221 let mut a11y = crate::a11y::AccessTreeBuilder::new();
222 let mut ctx = FrameCtx::new(
223 theme,
224 &mut input,
225 &mut focus,
226 &table,
227 StringTable::empty(),
228 &mut hits,
229 &prev,
230 &mut a11y,
231 &mut shaper,
232 );
233 show_parsed_input(&mut ctx, widget, &mut clipboard)
234 };
235 assert_eq!(response.committed, Some(42));
236 assert!(input.keys_pressed.is_empty(), "Enter drained on commit");
237 }
238
239 #[test]
240 fn focus_loss_commits_value() {
241 let mut state = TextInputState::from_text("42");
242 state.was_focused = true;
243 let theme = Arc::new(Theme::light());
244 let mut focus = FocusManager::new();
245 let table = HotkeyTable::new();
246 let mut hits = HitFrame::new();
247 let prev = HitState::new();
248 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
249 let mut clipboard = MemoryClipboard::default();
250 let widget = NumericInput::<i32>::new(id_widget(), rect(), PLACEHOLDER, &mut state);
251 let mut shaper = bone_text::Shaper::new();
252 let mut a11y = crate::a11y::AccessTreeBuilder::new();
253 let mut ctx = FrameCtx::new(
254 theme,
255 &mut input,
256 &mut focus,
257 &table,
258 StringTable::empty(),
259 &mut hits,
260 &prev,
261 &mut a11y,
262 &mut shaper,
263 );
264 let response = show_parsed_input(&mut ctx, widget, &mut clipboard);
265 assert_eq!(response.committed, Some(42));
266 assert!(
267 !state.was_focused,
268 "post-call state reflects current focus, not prior",
269 );
270 }
271
272 #[test]
273 fn two_frame_focus_loss_commits_value() {
274 let mut state = TextInputState::from_text("42");
275 let theme = Arc::new(Theme::light());
276 let table = HotkeyTable::new();
277 let mut clipboard = MemoryClipboard::default();
278
279 let mut focus = FocusManager::new();
280 focus.register_focusable(id_widget());
281 focus.request_focus(id_widget());
282 focus.end_frame();
283
284 {
285 let mut hits = HitFrame::new();
286 let prev = HitState::new();
287 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
288 let widget = NumericInput::<i32>::new(id_widget(), rect(), PLACEHOLDER, &mut state);
289 let mut shaper = bone_text::Shaper::new();
290 let mut a11y = crate::a11y::AccessTreeBuilder::new();
291 let mut ctx = FrameCtx::new(
292 theme.clone(),
293 &mut input,
294 &mut focus,
295 &table,
296 StringTable::empty(),
297 &mut hits,
298 &prev,
299 &mut a11y,
300 &mut shaper,
301 );
302 let response = show_parsed_input(&mut ctx, widget, &mut clipboard);
303 assert!(
304 response.committed.is_none(),
305 "frame 1 still focused, no commit yet",
306 );
307 }
308
309 let mut focus = FocusManager::new();
310 let mut hits = HitFrame::new();
311 let prev = HitState::new();
312 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
313 let widget = NumericInput::<i32>::new(id_widget(), rect(), PLACEHOLDER, &mut state);
314 let mut shaper = bone_text::Shaper::new();
315 let mut a11y = crate::a11y::AccessTreeBuilder::new();
316 let mut ctx = FrameCtx::new(
317 theme,
318 &mut input,
319 &mut focus,
320 &table,
321 StringTable::empty(),
322 &mut hits,
323 &prev,
324 &mut a11y,
325 &mut shaper,
326 );
327 let response = show_parsed_input(&mut ctx, widget, &mut clipboard);
328 assert_eq!(
329 response.committed,
330 Some(42),
331 "frame 2 unfocused after being focused frame 1: commit fires",
332 );
333 }
334}