Another project
0

Configure Feed

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

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