Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): numeric + dimensioned input

Lewis: May this revision serve well! <lu5a@proton.me>

+675
+361
crates/bone-ui/src/widgets/dimensioned_input.rs
··· 1 + use uom::si::angle::{degree, radian}; 2 + use uom::si::f64::{Angle, Length}; 3 + use uom::si::length::{centimeter, foot, inch, meter, millimeter}; 4 + 5 + use super::parsed_input::{ParsedInput, ParsedInputResponse, ParsedValue}; 6 + 7 + pub type DimensionedInput<'state, Q> = ParsedInput<'state, Q>; 8 + pub type DimensionedInputResponse<Q> = ParsedInputResponse<Q>; 9 + 10 + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] 11 + pub enum DimensionedParseError { 12 + #[error("empty input")] 13 + Empty, 14 + #[error("unknown unit suffix: {0}")] 15 + UnknownUnit(String), 16 + #[error("invalid number: {0}")] 17 + InvalidNumber(String), 18 + } 19 + 20 + type UnitCtor<Q> = fn(f64) -> Q; 21 + type UnitEntry<Q> = (&'static str, UnitCtor<Q>); 22 + 23 + struct UnitTable<Q: 'static> { 24 + default: UnitCtor<Q>, 25 + suffixes: &'static [UnitEntry<Q>], 26 + } 27 + 28 + const LENGTH_MM: UnitCtor<Length> = |v| Length::new::<millimeter>(v); 29 + const LENGTH_CM: UnitCtor<Length> = |v| Length::new::<centimeter>(v); 30 + const LENGTH_M: UnitCtor<Length> = |v| Length::new::<meter>(v); 31 + const LENGTH_IN: UnitCtor<Length> = |v| Length::new::<inch>(v); 32 + const LENGTH_FT: UnitCtor<Length> = |v| Length::new::<foot>(v); 33 + 34 + const LENGTH_UNITS: UnitTable<Length> = UnitTable { 35 + default: LENGTH_MM, 36 + suffixes: &[ 37 + ("mm", LENGTH_MM), 38 + ("cm", LENGTH_CM), 39 + ("m", LENGTH_M), 40 + ("in", LENGTH_IN), 41 + ("\"", LENGTH_IN), 42 + ("ft", LENGTH_FT), 43 + ("'", LENGTH_FT), 44 + ], 45 + }; 46 + 47 + const ANGLE_DEG: UnitCtor<Angle> = |v| Angle::new::<degree>(v); 48 + const ANGLE_RAD: UnitCtor<Angle> = |v| Angle::new::<radian>(v); 49 + 50 + const ANGLE_UNITS: UnitTable<Angle> = UnitTable { 51 + default: ANGLE_DEG, 52 + suffixes: &[("deg", ANGLE_DEG), ("\u{00B0}", ANGLE_DEG), ("rad", ANGLE_RAD)], 53 + }; 54 + 55 + fn parse_dimensioned<Q>(text: &str, units: &UnitTable<Q>) -> Result<Q, DimensionedParseError> { 56 + let trimmed = text.trim(); 57 + if trimmed.is_empty() { 58 + return Err(DimensionedParseError::Empty); 59 + } 60 + let lowered = trimmed.to_ascii_lowercase(); 61 + let with_unit = units 62 + .suffixes 63 + .iter() 64 + .filter(|(suffix, _)| lowered.ends_with(suffix)) 65 + .max_by_key(|(suffix, _)| suffix.len()) 66 + .and_then(|(suffix, ctor)| { 67 + let cut = trimmed.len() - suffix.len(); 68 + trimmed[..cut].trim().parse::<f64>().ok().map(ctor) 69 + }); 70 + if let Some(value) = with_unit { 71 + return Ok(value); 72 + } 73 + if let Ok(value) = trimmed.parse::<f64>() { 74 + return Ok((units.default)(value)); 75 + } 76 + let suffix_start = trimmed 77 + .char_indices() 78 + .find(|(_, c)| !is_numeric_part(*c)) 79 + .map(|(i, _)| i); 80 + match suffix_start { 81 + Some(start) if start > 0 => Err(DimensionedParseError::UnknownUnit( 82 + trimmed[start..].trim().to_owned(), 83 + )), 84 + _ => Err(DimensionedParseError::InvalidNumber(trimmed.to_owned())), 85 + } 86 + } 87 + 88 + fn is_numeric_part(c: char) -> bool { 89 + c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E') 90 + } 91 + 92 + impl ParsedValue for Length { 93 + type Error = DimensionedParseError; 94 + 95 + fn parse(text: &str) -> Result<Self, Self::Error> { 96 + parse_dimensioned(text, &LENGTH_UNITS) 97 + } 98 + } 99 + 100 + impl ParsedValue for Angle { 101 + type Error = DimensionedParseError; 102 + 103 + fn parse(text: &str) -> Result<Self, Self::Error> { 104 + parse_dimensioned(text, &ANGLE_UNITS) 105 + } 106 + } 107 + 108 + #[cfg(test)] 109 + mod tests { 110 + use std::sync::Arc; 111 + 112 + use uom::si::angle::{degree, radian}; 113 + use uom::si::f64::{Angle, Length}; 114 + use uom::si::length::{centimeter, foot, inch, meter, millimeter}; 115 + 116 + use super::{DimensionedInput, DimensionedInputResponse, DimensionedParseError}; 117 + use crate::focus::FocusManager; 118 + use crate::frame::FrameCtx; 119 + use crate::hit_test::{HitFrame, HitState}; 120 + use crate::hotkey::HotkeyTable; 121 + use crate::input::{FrameInstant, InputSnapshot}; 122 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 123 + use crate::strings::StringKey; 124 + use crate::strings::StringTable; 125 + use crate::theme::Theme; 126 + use crate::widget_id::{WidgetId, WidgetKey}; 127 + use crate::widgets::parsed_input::show_parsed_input; 128 + use crate::widgets::{MemoryClipboard, TextInputState}; 129 + 130 + const PLACEHOLDER: StringKey = StringKey::new("dim.placeholder"); 131 + 132 + fn rect() -> LayoutRect { 133 + LayoutRect::new( 134 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 135 + LayoutSize::new(LayoutPx::new(120.0), LayoutPx::new(24.0)), 136 + ) 137 + } 138 + 139 + fn id_widget() -> WidgetId { 140 + WidgetId::ROOT.child(WidgetKey::new("dim")) 141 + } 142 + 143 + fn run_length(state_text: &str) -> DimensionedInputResponse<Length> { 144 + let mut state = TextInputState::from_text(state_text); 145 + let theme = Arc::new(Theme::light()); 146 + let mut focus = FocusManager::new(); 147 + focus.register_focusable(id_widget()); 148 + focus.request_focus(id_widget()); 149 + focus.end_frame(); 150 + let table = HotkeyTable::new(); 151 + let mut hits = HitFrame::new(); 152 + let prev = HitState::new(); 153 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 154 + let mut clipboard = MemoryClipboard::default(); 155 + let widget = DimensionedInput::<Length>::new(id_widget(), rect(), PLACEHOLDER, &mut state); 156 + let mut ctx = FrameCtx::new( 157 + theme, 158 + &mut input, 159 + &mut focus, 160 + &table, 161 + StringTable::empty(), 162 + &mut hits, 163 + &prev, 164 + ); 165 + show_parsed_input(&mut ctx, widget, &mut clipboard) 166 + } 167 + 168 + fn run_angle(state_text: &str) -> DimensionedInputResponse<Angle> { 169 + let mut state = TextInputState::from_text(state_text); 170 + let theme = Arc::new(Theme::light()); 171 + let mut focus = FocusManager::new(); 172 + focus.register_focusable(id_widget()); 173 + focus.request_focus(id_widget()); 174 + focus.end_frame(); 175 + let table = HotkeyTable::new(); 176 + let mut hits = HitFrame::new(); 177 + let prev = HitState::new(); 178 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 179 + let mut clipboard = MemoryClipboard::default(); 180 + let widget = DimensionedInput::<Angle>::new(id_widget(), rect(), PLACEHOLDER, &mut state); 181 + let mut ctx = FrameCtx::new( 182 + theme, 183 + &mut input, 184 + &mut focus, 185 + &table, 186 + StringTable::empty(), 187 + &mut hits, 188 + &prev, 189 + ); 190 + show_parsed_input(&mut ctx, widget, &mut clipboard) 191 + } 192 + 193 + #[test] 194 + fn length_default_unit_is_mm() { 195 + let response = run_length("12.5"); 196 + let Some(value) = response.value else { 197 + panic!("value parses") 198 + }; 199 + assert!((value.get::<millimeter>() - 12.5).abs() < 1e-12); 200 + } 201 + 202 + #[test] 203 + fn length_with_explicit_mm_suffix() { 204 + let response = run_length("3mm"); 205 + let Some(value) = response.value else { 206 + panic!("value parses") 207 + }; 208 + assert!((value.get::<millimeter>() - 3.0).abs() < 1e-12); 209 + } 210 + 211 + #[test] 212 + fn length_inch_suffix_converts_to_mm() { 213 + let response = run_length("1in"); 214 + let Some(value) = response.value else { 215 + panic!("value parses") 216 + }; 217 + assert!((value.get::<inch>() - 1.0).abs() < 1e-12); 218 + } 219 + 220 + #[test] 221 + fn length_double_quote_inch_suffix() { 222 + let response = run_length("0.5\""); 223 + let Some(value) = response.value else { 224 + panic!("value parses") 225 + }; 226 + assert!((value.get::<inch>() - 0.5).abs() < 1e-12); 227 + } 228 + 229 + #[test] 230 + fn length_centimeter_suffix() { 231 + let response = run_length("2cm"); 232 + let Some(value) = response.value else { 233 + panic!("value parses") 234 + }; 235 + assert!((value.get::<centimeter>() - 2.0).abs() < 1e-12); 236 + } 237 + 238 + #[test] 239 + fn length_meter_suffix() { 240 + let response = run_length("0.5m"); 241 + let Some(value) = response.value else { 242 + panic!("value parses") 243 + }; 244 + assert!((value.get::<meter>() - 0.5).abs() < 1e-12); 245 + } 246 + 247 + #[test] 248 + fn length_foot_suffix() { 249 + let response = run_length("2ft"); 250 + let Some(value) = response.value else { 251 + panic!("value parses") 252 + }; 253 + assert!((value.get::<foot>() - 2.0).abs() < 1e-12); 254 + } 255 + 256 + #[test] 257 + fn length_unknown_suffix_errors() { 258 + let response = run_length("3parsec"); 259 + match response.error { 260 + Some(DimensionedParseError::UnknownUnit(s)) => assert_eq!(s, "parsec"), 261 + other => panic!("expected unknown-unit, got {other:?}"), 262 + } 263 + } 264 + 265 + #[test] 266 + fn length_partial_suffix_match_falls_through_to_unknown_unit() { 267 + let response = run_length("3min"); 268 + match response.error { 269 + Some(DimensionedParseError::UnknownUnit(s)) => assert_eq!(s, "min"), 270 + other => panic!("expected unknown-unit, got {other:?}"), 271 + } 272 + } 273 + 274 + #[test] 275 + fn length_invalid_number_errors() { 276 + let response = run_length("abcmm"); 277 + assert!(matches!( 278 + response.error, 279 + Some(DimensionedParseError::InvalidNumber(_)) 280 + )); 281 + } 282 + 283 + #[test] 284 + fn angle_default_unit_is_deg() { 285 + let response = run_angle("90"); 286 + let Some(value) = response.value else { 287 + panic!("value parses") 288 + }; 289 + assert!((value.get::<degree>() - 90.0).abs() < 1e-12); 290 + } 291 + 292 + #[test] 293 + fn angle_radian_suffix() { 294 + let response = run_angle("1.0rad"); 295 + let Some(value) = response.value else { 296 + panic!("value parses") 297 + }; 298 + assert!((value.get::<radian>() - 1.0).abs() < 1e-12); 299 + } 300 + 301 + #[test] 302 + fn angle_degree_glyph_suffix() { 303 + let response = run_angle("45\u{00B0}"); 304 + let Some(value) = response.value else { 305 + panic!("value parses") 306 + }; 307 + assert!((value.get::<degree>() - 45.0).abs() < 1e-12); 308 + } 309 + 310 + #[test] 311 + fn empty_input_neither_value_nor_error_for_length() { 312 + let response = run_length(""); 313 + assert!(response.value.is_none()); 314 + assert!(response.error.is_none()); 315 + } 316 + 317 + #[test] 318 + fn pure_garbage_classifies_as_invalid_number_not_unknown_unit() { 319 + let response = run_length("abc"); 320 + assert!(matches!( 321 + response.error, 322 + Some(DimensionedParseError::InvalidNumber(_)) 323 + )); 324 + } 325 + 326 + #[test] 327 + fn scientific_notation_with_unit_parses() { 328 + let response = run_length("1.5e1mm"); 329 + let Some(value) = response.value else { 330 + panic!("scientific notation parses") 331 + }; 332 + assert!((value.get::<millimeter>() - 15.0).abs() < 1e-12); 333 + } 334 + 335 + #[test] 336 + fn length_uppercase_suffix_accepted() { 337 + let response = run_length("3MM"); 338 + let Some(value) = response.value else { 339 + panic!("uppercase mm parses") 340 + }; 341 + assert!((value.get::<millimeter>() - 3.0).abs() < 1e-12); 342 + } 343 + 344 + #[test] 345 + fn length_mixed_case_suffix_accepted() { 346 + let response = run_length("12In"); 347 + let Some(value) = response.value else { 348 + panic!("mixed-case in parses") 349 + }; 350 + assert!((value.get::<inch>() - 12.0).abs() < 1e-12); 351 + } 352 + 353 + #[test] 354 + fn angle_uppercase_suffix() { 355 + let response = run_angle("1.5RAD"); 356 + let Some(value) = response.value else { 357 + panic!("uppercase rad parses") 358 + }; 359 + assert!((value.get::<radian>() - 1.5).abs() < 1e-12); 360 + } 361 + }
+314
crates/bone-ui/src/widgets/numeric_input.rs
··· 1 + use core::str::FromStr; 2 + 3 + use super::parsed_input::{ParsedInput, ParsedInputResponse, ParsedValue}; 4 + 5 + pub type NumericInput<'state, T> = ParsedInput<'state, T>; 6 + pub type NumericInputResponse<T> = ParsedInputResponse<T>; 7 + 8 + macro_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 + 20 + impl_parsed_value_via_fromstr!(i8, i16, i32, i64, i128, isize); 21 + impl_parsed_value_via_fromstr!(u8, u16, u32, u64, u128, usize); 22 + 23 + #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] 24 + pub enum NumericFloatParseError { 25 + #[error("not a finite number")] 26 + NotFinite, 27 + #[error("invalid float: {0}")] 28 + Parse(String), 29 + } 30 + 31 + macro_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 + 51 + impl_parsed_value_finite_float!(f32, f64); 52 + 53 + #[cfg(test)] 54 + mod 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 ctx = FrameCtx::new( 98 + theme, 99 + &mut input, 100 + &mut focus, 101 + &table, 102 + StringTable::empty(), 103 + &mut hits, 104 + &prev, 105 + ); 106 + show_parsed_input(&mut ctx, widget, &mut clipboard) 107 + } 108 + 109 + #[test] 110 + fn parses_valid_integer() { 111 + let mut state = TextInputState::from_text("42"); 112 + let response: NumericInputResponse<i32> = run(&mut state); 113 + assert_eq!(response.value, Some(42)); 114 + assert!(response.error.is_none()); 115 + } 116 + 117 + #[test] 118 + fn parses_valid_float() { 119 + let mut state = TextInputState::from_text("2.5"); 120 + let response: NumericInputResponse<f64> = run(&mut state); 121 + assert_eq!(response.value, Some(2.5)); 122 + assert!(response.error.is_none()); 123 + } 124 + 125 + #[test] 126 + fn rejects_garbage_with_typed_error() { 127 + let mut state = TextInputState::from_text("not-a-number"); 128 + let response: NumericInputResponse<u32> = run(&mut state); 129 + assert!(response.value.is_none()); 130 + assert!(response.error.is_some()); 131 + } 132 + 133 + #[test] 134 + fn empty_text_is_neither_value_nor_error() { 135 + let mut state = TextInputState::default(); 136 + let response: NumericInputResponse<i64> = run(&mut state); 137 + assert!(response.value.is_none()); 138 + assert!(response.error.is_none()); 139 + } 140 + 141 + #[test] 142 + fn negative_integer_parses_when_signed() { 143 + let mut state = TextInputState::from_text("-7"); 144 + let response: NumericInputResponse<i32> = run(&mut state); 145 + assert_eq!(response.value, Some(-7)); 146 + } 147 + 148 + #[test] 149 + fn negative_integer_errors_when_unsigned() { 150 + let mut state = TextInputState::from_text("-7"); 151 + let response: NumericInputResponse<u32> = run(&mut state); 152 + assert!(response.error.is_some()); 153 + } 154 + 155 + #[test] 156 + fn float_rejects_nan() { 157 + use super::NumericFloatParseError; 158 + let mut state = TextInputState::from_text("NaN"); 159 + let response: NumericInputResponse<f64> = run(&mut state); 160 + assert!(response.value.is_none()); 161 + assert_eq!(response.error, Some(NumericFloatParseError::NotFinite)); 162 + } 163 + 164 + #[test] 165 + fn float_rejects_infinity() { 166 + use super::NumericFloatParseError; 167 + let mut state = TextInputState::from_text("inf"); 168 + let response: NumericInputResponse<f64> = run(&mut state); 169 + assert!(response.value.is_none()); 170 + assert_eq!(response.error, Some(NumericFloatParseError::NotFinite)); 171 + } 172 + 173 + #[test] 174 + fn float_accepts_finite_value() { 175 + let mut state = TextInputState::from_text("2.5"); 176 + let response: NumericInputResponse<f64> = run(&mut state); 177 + assert_eq!(response.value, Some(2.5)); 178 + assert!(response.error.is_none()); 179 + } 180 + 181 + #[test] 182 + fn whitespace_around_value_parses() { 183 + let mut state = TextInputState::from_text(" 12 "); 184 + let response: NumericInputResponse<i32> = run(&mut state); 185 + assert_eq!(response.value, Some(12)); 186 + } 187 + 188 + #[test] 189 + fn typing_alone_does_not_commit() { 190 + let mut state = TextInputState::from_text("42"); 191 + let response: NumericInputResponse<i32> = run(&mut state); 192 + assert_eq!(response.value, Some(42)); 193 + assert!(response.committed.is_none()); 194 + } 195 + 196 + #[test] 197 + fn enter_while_focused_commits_value() { 198 + use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 199 + let mut state = TextInputState::from_text("42"); 200 + let theme = Arc::new(Theme::light()); 201 + let mut focus = FocusManager::new(); 202 + focus.register_focusable(id_widget()); 203 + focus.request_focus(id_widget()); 204 + focus.end_frame(); 205 + let table = HotkeyTable::new(); 206 + let mut hits = HitFrame::new(); 207 + let prev = HitState::new(); 208 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 209 + input.keys_pressed.push(KeyEvent::new( 210 + KeyCode::Named(NamedKey::Enter), 211 + ModifierMask::NONE, 212 + )); 213 + let mut clipboard = MemoryClipboard::default(); 214 + let widget = NumericInput::<i32>::new(id_widget(), rect(), PLACEHOLDER, &mut state); 215 + let response = { 216 + let mut ctx = FrameCtx::new( 217 + theme, 218 + &mut input, 219 + &mut focus, 220 + &table, 221 + StringTable::empty(), 222 + &mut hits, 223 + &prev, 224 + ); 225 + show_parsed_input(&mut ctx, widget, &mut clipboard) 226 + }; 227 + assert_eq!(response.committed, Some(42)); 228 + assert!(input.keys_pressed.is_empty(), "Enter drained on commit"); 229 + } 230 + 231 + #[test] 232 + fn focus_loss_commits_value() { 233 + let mut state = TextInputState::from_text("42"); 234 + state.was_focused = true; 235 + let theme = Arc::new(Theme::light()); 236 + let mut focus = FocusManager::new(); 237 + let table = HotkeyTable::new(); 238 + let mut hits = HitFrame::new(); 239 + let prev = HitState::new(); 240 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 241 + let mut clipboard = MemoryClipboard::default(); 242 + let widget = NumericInput::<i32>::new(id_widget(), rect(), PLACEHOLDER, &mut state); 243 + let mut ctx = FrameCtx::new( 244 + theme, 245 + &mut input, 246 + &mut focus, 247 + &table, 248 + StringTable::empty(), 249 + &mut hits, 250 + &prev, 251 + ); 252 + let response = show_parsed_input(&mut ctx, widget, &mut clipboard); 253 + assert_eq!(response.committed, Some(42)); 254 + assert!( 255 + !state.was_focused, 256 + "post-call state reflects current focus, not prior", 257 + ); 258 + } 259 + 260 + #[test] 261 + fn two_frame_focus_loss_commits_value() { 262 + let mut state = TextInputState::from_text("42"); 263 + let theme = Arc::new(Theme::light()); 264 + let table = HotkeyTable::new(); 265 + let mut clipboard = MemoryClipboard::default(); 266 + 267 + let mut focus = FocusManager::new(); 268 + focus.register_focusable(id_widget()); 269 + focus.request_focus(id_widget()); 270 + focus.end_frame(); 271 + 272 + { 273 + let mut hits = HitFrame::new(); 274 + let prev = HitState::new(); 275 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 276 + let widget = NumericInput::<i32>::new(id_widget(), rect(), PLACEHOLDER, &mut state); 277 + let mut ctx = FrameCtx::new( 278 + theme.clone(), 279 + &mut input, 280 + &mut focus, 281 + &table, 282 + StringTable::empty(), 283 + &mut hits, 284 + &prev, 285 + ); 286 + let response = show_parsed_input(&mut ctx, widget, &mut clipboard); 287 + assert!( 288 + response.committed.is_none(), 289 + "frame 1 still focused, no commit yet", 290 + ); 291 + } 292 + 293 + let mut focus = FocusManager::new(); 294 + let mut hits = HitFrame::new(); 295 + let prev = HitState::new(); 296 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 297 + let widget = NumericInput::<i32>::new(id_widget(), rect(), PLACEHOLDER, &mut state); 298 + let mut ctx = FrameCtx::new( 299 + theme, 300 + &mut input, 301 + &mut focus, 302 + &table, 303 + StringTable::empty(), 304 + &mut hits, 305 + &prev, 306 + ); 307 + let response = show_parsed_input(&mut ctx, widget, &mut clipboard); 308 + assert_eq!( 309 + response.committed, 310 + Some(42), 311 + "frame 2 unfocused after being focused frame 1: commit fires", 312 + ); 313 + } 314 + }