Another project
0

Configure Feed

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

dimensioned input widget

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

author
Lewis
date (Jul 20, 2026, 12:28 PM +0300) commit 46535eff parent 5c5d99f4 change-id umwlqpoo
+212 -205
+172 -191
crates/bone-ui/src/widgets/dimensioned_input.rs
··· 1 - use uom::si::angle::{degree, radian}; 1 + use uom::si::angle::{degree, radian, revolution}; 2 2 use uom::si::f64::{Angle, Length}; 3 3 use uom::si::length::{centimeter, foot, inch, meter, millimeter}; 4 4 ··· 46 46 47 47 const ANGLE_DEG: UnitCtor<Angle> = |v| Angle::new::<degree>(v); 48 48 const ANGLE_RAD: UnitCtor<Angle> = |v| Angle::new::<radian>(v); 49 + const ANGLE_REV: UnitCtor<Angle> = |v| Angle::new::<revolution>(v); 49 50 50 51 const ANGLE_UNITS: UnitTable<Angle> = UnitTable { 51 52 default: ANGLE_DEG, ··· 53 54 ("deg", ANGLE_DEG), 54 55 ("\u{00B0}", ANGLE_DEG), 55 56 ("rad", ANGLE_RAD), 57 + ("rev", ANGLE_REV), 56 58 ], 57 59 }; 58 60 61 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 62 + pub enum AngleUnit { 63 + #[default] 64 + Degrees, 65 + Radians, 66 + Revolutions, 67 + } 68 + 69 + impl AngleUnit { 70 + #[must_use] 71 + pub fn make(self, value: f64) -> Angle { 72 + match self { 73 + Self::Degrees => Angle::new::<degree>(value), 74 + Self::Radians => Angle::new::<radian>(value), 75 + Self::Revolutions => Angle::new::<revolution>(value), 76 + } 77 + } 78 + 79 + #[must_use] 80 + pub fn measure(self, angle: Angle) -> f64 { 81 + match self { 82 + Self::Degrees => angle.get::<degree>(), 83 + Self::Radians => angle.get::<radian>(), 84 + Self::Revolutions => angle.get::<revolution>(), 85 + } 86 + } 87 + 88 + #[must_use] 89 + pub fn suffix(self) -> &'static str { 90 + match self { 91 + Self::Degrees => "deg", 92 + Self::Radians => "rad", 93 + Self::Revolutions => "rev", 94 + } 95 + } 96 + } 97 + 98 + #[must_use] 99 + pub fn angle_text_has_unit(text: &str) -> bool { 100 + let lowered = text.trim().to_ascii_lowercase(); 101 + ANGLE_UNITS 102 + .suffixes 103 + .iter() 104 + .any(|(suffix, _)| lowered.ends_with(suffix)) 105 + } 106 + 59 107 fn parse_dimensioned<Q>(text: &str, units: &UnitTable<Q>) -> Result<Q, DimensionedParseError> { 60 108 let trimmed = text.trim(); 61 109 if trimmed.is_empty() { ··· 69 117 .max_by_key(|(suffix, _)| suffix.len()) 70 118 .and_then(|(suffix, ctor)| { 71 119 let cut = trimmed.len() - suffix.len(); 72 - trimmed[..cut].trim().parse::<f64>().ok().map(ctor) 120 + parse_finite(trimmed[..cut].trim()).map(ctor) 73 121 }); 74 122 if let Some(value) = with_unit { 75 123 return Ok(value); 76 124 } 77 - if let Ok(value) = trimmed.parse::<f64>() { 125 + if let Some(value) = parse_finite(trimmed) { 78 126 return Ok((units.default)(value)); 79 127 } 80 128 let suffix_start = trimmed ··· 91 139 92 140 fn is_numeric_part(c: char) -> bool { 93 141 c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E') 142 + } 143 + 144 + fn parse_finite(text: &str) -> Option<f64> { 145 + text.parse::<f64>().ok().filter(|value| value.is_finite()) 94 146 } 95 147 96 148 impl ParsedValue for Length { ··· 113 165 mod tests { 114 166 use std::sync::Arc; 115 167 116 - use uom::si::angle::{degree, radian}; 168 + use uom::si::angle::degree; 117 169 use uom::si::f64::{Angle, Length}; 118 - use uom::si::length::{centimeter, foot, inch, meter, millimeter}; 170 + use uom::si::length::millimeter; 119 171 120 172 use super::{DimensionedInput, DimensionedInputResponse, DimensionedParseError}; 121 173 use crate::focus::FocusManager; ··· 128 180 use crate::strings::StringTable; 129 181 use crate::theme::Theme; 130 182 use crate::widget_id::{WidgetId, WidgetKey}; 131 - use crate::widgets::parsed_input::show_parsed_input; 183 + use crate::widgets::parsed_input::{ParsedValue, show_parsed_input}; 132 184 use crate::widgets::{MemoryClipboard, TextInputState}; 133 185 134 186 const PLACEHOLDER: StringKey = StringKey::new("dim.placeholder"); ··· 144 196 WidgetId::ROOT.child(WidgetKey::new("dim")) 145 197 } 146 198 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> { 199 + fn run<T: ParsedValue>(state_text: &str) -> DimensionedInputResponse<T> { 177 200 let mut state = TextInputState::from_text(state_text); 178 201 let theme = Arc::new(Theme::light()); 179 202 let mut focus = FocusManager::new(); ··· 185 208 let prev = HitState::new(); 186 209 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 187 210 let mut clipboard = MemoryClipboard::default(); 188 - let widget = DimensionedInput::<Angle>::new(id_widget(), rect(), PLACEHOLDER, &mut state); 211 + let widget = DimensionedInput::<T>::new(id_widget(), rect(), PLACEHOLDER, &mut state); 189 212 let mut shaper = bone_text::Shaper::new(); 190 213 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 191 214 let mut ctx = FrameCtx::new( ··· 202 225 show_parsed_input(&mut ctx, widget, &mut clipboard) 203 226 } 204 227 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); 228 + fn assert_parses<T: ParsedValue>( 229 + cases: &[(&str, f64)], 230 + measure: impl Fn(T) -> f64, 231 + ) -> Result<(), Box<dyn std::error::Error>> { 232 + cases.iter().try_for_each( 233 + |&(text, expected)| -> Result<(), Box<dyn std::error::Error>> { 234 + let value = run::<T>(text) 235 + .value 236 + .ok_or_else(|| format!("{text} parses"))?; 237 + let got = measure(value); 238 + assert!( 239 + (got - expected).abs() < 1e-9, 240 + "{text}: want {expected}, got {got}" 241 + ); 242 + Ok(()) 243 + }, 244 + )?; 245 + Ok(()) 212 246 } 213 247 214 248 #[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); 249 + fn parses_units_and_defaults_for_length_and_angle() -> Result<(), Box<dyn std::error::Error>> { 250 + assert_parses::<Length>( 251 + &[ 252 + ("12.5", 12.5), 253 + ("3mm", 3.0), 254 + ("1in", 25.4), 255 + ("0.5\"", 12.7), 256 + ("2cm", 20.0), 257 + ("0.5m", 500.0), 258 + ("2ft", 609.6), 259 + ("3MM", 3.0), 260 + ("12In", 304.8), 261 + ("1.5e1mm", 15.0), 262 + ], 263 + |value| value.get::<millimeter>(), 264 + )?; 265 + assert_parses::<Angle>( 266 + &[ 267 + ("90", 90.0), 268 + ("1.0rad", 1.0_f64.to_degrees()), 269 + ("45\u{00B0}", 45.0), 270 + ("0.25rev", 90.0), 271 + ("1.5RAD", 1.5_f64.to_degrees()), 272 + ], 273 + |value| value.get::<degree>(), 274 + )?; 275 + Ok(()) 221 276 } 222 277 223 278 #[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:?}"), 279 + fn parse_reports_unknown_units_invalid_numbers_and_empties() { 280 + type Probe = fn(&str) -> (bool, Option<DimensionedParseError>); 281 + enum Want { 282 + Unknown(&'static str), 283 + Invalid, 284 + Silent, 274 285 } 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") 286 + let length: Probe = |text| { 287 + let response = run::<Length>(text); 288 + (response.value.is_none(), response.error) 300 289 }; 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") 290 + let angle: Probe = |text| { 291 + let response = run::<Angle>(text); 292 + (response.value.is_none(), response.error) 309 293 }; 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); 294 + [ 295 + (length, "3parsec", Want::Unknown("parsec")), 296 + (length, "3min", Want::Unknown("min")), 297 + (length, "abcmm", Want::Invalid), 298 + (length, "abc", Want::Invalid), 299 + (length, "NaN", Want::Invalid), 300 + (length, "1e999", Want::Invalid), 301 + (angle, "inf", Want::Invalid), 302 + (length, "", Want::Silent), 303 + ] 304 + .into_iter() 305 + .for_each(|(probe, text, want)| { 306 + let (value_absent, error) = probe(text); 307 + match want { 308 + Want::Unknown(unit) => assert!( 309 + matches!(&error, Some(DimensionedParseError::UnknownUnit(found)) if found == unit), 310 + "{text} reports unknown unit {unit}, got {error:?}" 311 + ), 312 + Want::Invalid => { 313 + assert!(value_absent, "{text} must not commit a value"); 314 + assert!( 315 + matches!(&error, Some(DimensionedParseError::InvalidNumber(_))), 316 + "{text} reports an invalid number, got {error:?}" 317 + ); 318 + } 319 + Want::Silent => { 320 + assert!(value_absent, "{text} yields no value"); 321 + assert!(error.is_none(), "{text} yields no error, got {error:?}"); 322 + } 323 + } 324 + }); 354 325 } 355 326 356 327 #[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 - } 328 + fn angle_helpers_detect_suffixes_and_round_trip_their_measure() { 329 + use super::{AngleUnit, angle_text_has_unit}; 330 + [ 331 + ("1.5 rad", true), 332 + ("90deg", true), 333 + ("0.25 REV", true), 334 + ("45\u{00B0}", true), 335 + ("90", false), 336 + (" 0.5 ", false), 337 + ] 338 + .into_iter() 339 + .for_each(|(text, has_unit)| { 340 + assert_eq!(angle_text_has_unit(text), has_unit, "{text}"); 341 + }); 364 342 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); 343 + [ 344 + AngleUnit::Degrees, 345 + AngleUnit::Radians, 346 + AngleUnit::Revolutions, 347 + ] 348 + .into_iter() 349 + .for_each(|unit| { 350 + assert!((unit.measure(unit.make(0.75)) - 0.75).abs() < 1e-12); 351 + }); 352 + assert!((AngleUnit::Revolutions.make(0.25).get::<degree>() - 90.0).abs() < 1e-9); 372 353 } 373 354 }
+4 -1
crates/bone-ui/src/widgets/mod.rs
··· 36 36 ConfirmationDialog, ConfirmationOutcome, ConfirmationResponse, Dialog, DialogButton, 37 37 DialogResponse, Modal, ModalResponse, show_confirmation, show_dialog, show_modal, 38 38 }; 39 - pub use dimensioned_input::{DimensionedInput, DimensionedInputResponse, DimensionedParseError}; 39 + pub use dimensioned_input::{ 40 + AngleUnit, DimensionedInput, DimensionedInputResponse, DimensionedParseError, 41 + angle_text_has_unit, 42 + }; 40 43 pub use dropdown::{Dropdown, DropdownItem, DropdownResponse, DropdownState, show_dropdown}; 41 44 pub use file_picker::{ 42 45 FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, FilePickerOutcome,
+13 -6
crates/bone-ui/src/widgets/paint.rs
··· 158 158 role: TypographyRole, 159 159 align: HorizontalAlign, 160 160 }, 161 + Paragraph { 162 + rect: LayoutRect, 163 + text: LabelText, 164 + color: Color, 165 + role: TypographyRole, 166 + align: HorizontalAlign, 167 + }, 161 168 Mark { 162 169 rect: LayoutRect, 163 170 kind: GlyphMark, ··· 241 248 border: *border, 242 249 radius: *radius, 243 250 }, 244 - WidgetPaint::Label { rect, color, .. } | WidgetPaint::AlignedLabel { rect, color, .. } => { 245 - PaintPrim::solid( 246 - label_placeholder_bar(*rect), 247 - color.with_alpha(LABEL_PLACEHOLDER_ALPHA * color.alpha()), 248 - ) 249 - } 251 + WidgetPaint::Label { rect, color, .. } 252 + | WidgetPaint::AlignedLabel { rect, color, .. } 253 + | WidgetPaint::Paragraph { rect, color, .. } => PaintPrim::solid( 254 + label_placeholder_bar(*rect), 255 + color.with_alpha(LABEL_PLACEHOLDER_ALPHA * color.alpha()), 256 + ), 250 257 WidgetPaint::Mark { rect, color, .. } => { 251 258 PaintPrim::solid(centered_square(*rect, MARK_PLACEHOLDER_FACTOR), *color) 252 259 }
+23 -7
crates/bone-ui/src/widgets/property_grid.rs
··· 10 10 use crate::widget_id::WidgetId; 11 11 12 12 use super::checkbox::{Checkbox, CheckboxState, show_checkbox}; 13 - use super::dimensioned_input::DimensionedInput; 13 + use super::dimensioned_input::{AngleUnit, DimensionedInput, angle_text_has_unit}; 14 14 use super::dropdown::{Dropdown, DropdownItem, DropdownState, show_dropdown}; 15 15 use super::keys::take_activation; 16 16 use super::paint::{HorizontalAlign, IconTint, LabelText, WidgetPaint}; ··· 475 475 #[derive(Clone, Debug, PartialEq)] 476 476 pub struct AngleEditor { 477 477 pub value: Angle, 478 + pub unit: AngleUnit, 478 479 pub buffer: TextInputState, 479 480 } 480 481 481 482 impl AngleEditor { 482 483 #[must_use] 483 484 pub fn new(value: Angle) -> Self { 485 + Self::new_in(value, AngleUnit::Degrees) 486 + } 487 + 488 + #[must_use] 489 + pub fn new_in(value: Angle, unit: AngleUnit) -> Self { 484 490 Self { 485 - buffer: TextInputState::from_text(format_angle(value)), 491 + buffer: TextInputState::from_text(format_angle(value, unit)), 486 492 value, 493 + unit, 487 494 } 488 495 } 489 496 } ··· 497 504 paint: &mut Vec<WidgetPaint>, 498 505 ) -> bool { 499 506 let editing = ctx.is_focused(cell.row_id); 500 - let formatted = format_angle(self.value); 507 + let formatted = format_angle(self.value, self.unit); 501 508 if !editing && self.buffer.text != formatted { 502 509 self.buffer = TextInputState::from_text(formatted); 503 510 } ··· 510 517 paint.extend(response.paint); 511 518 match response.committed { 512 519 Some(v) => { 513 - self.value = v; 520 + self.value = reinterpret_in_unit(&self.buffer.text, v, self.unit); 514 521 true 515 522 } 516 523 None => false, ··· 586 593 format!("{} mm", value.get::<millimeter>()) 587 594 } 588 595 589 - fn format_angle(value: Angle) -> String { 590 - use uom::si::angle::degree; 591 - format!("{} deg", value.get::<degree>()) 596 + fn format_angle(value: Angle, unit: AngleUnit) -> String { 597 + format!("{} {}", unit.measure(value), unit.suffix()) 598 + } 599 + 600 + fn reinterpret_in_unit(text: &str, parsed: Angle, unit: AngleUnit) -> Angle { 601 + if angle_text_has_unit(text) { 602 + return parsed; 603 + } 604 + match text.trim().parse::<f64>() { 605 + Ok(value) if value.is_finite() => unit.make(value), 606 + _ => parsed, 607 + } 592 608 } 593 609 594 610 #[cfg(test)]