Another project
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}