diff --git a/crates/bone-ui/src/widgets/dimensioned_input.rs b/crates/bone-ui/src/widgets/dimensioned_input.rs index 74a262b..9c2fc43 100644 --- a/crates/bone-ui/src/widgets/dimensioned_input.rs +++ b/crates/bone-ui/src/widgets/dimensioned_input.rs @@ -1,4 +1,4 @@ -use uom::si::angle::{degree, radian}; +use uom::si::angle::{degree, radian, revolution}; use uom::si::f64::{Angle, Length}; use uom::si::length::{centimeter, foot, inch, meter, millimeter}; @@ -46,6 +46,7 @@ const LENGTH_UNITS: UnitTable = UnitTable { const ANGLE_DEG: UnitCtor = |v| Angle::new::(v); const ANGLE_RAD: UnitCtor = |v| Angle::new::(v); +const ANGLE_REV: UnitCtor = |v| Angle::new::(v); const ANGLE_UNITS: UnitTable = UnitTable { default: ANGLE_DEG, @@ -53,9 +54,56 @@ const ANGLE_UNITS: UnitTable = UnitTable { ("deg", ANGLE_DEG), ("\u{00B0}", ANGLE_DEG), ("rad", ANGLE_RAD), + ("rev", ANGLE_REV), ], }; +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum AngleUnit { + #[default] + Degrees, + Radians, + Revolutions, +} + +impl AngleUnit { + #[must_use] + pub fn make(self, value: f64) -> Angle { + match self { + Self::Degrees => Angle::new::(value), + Self::Radians => Angle::new::(value), + Self::Revolutions => Angle::new::(value), + } + } + + #[must_use] + pub fn measure(self, angle: Angle) -> f64 { + match self { + Self::Degrees => angle.get::(), + Self::Radians => angle.get::(), + Self::Revolutions => angle.get::(), + } + } + + #[must_use] + pub fn suffix(self) -> &'static str { + match self { + Self::Degrees => "deg", + Self::Radians => "rad", + Self::Revolutions => "rev", + } + } +} + +#[must_use] +pub fn angle_text_has_unit(text: &str) -> bool { + let lowered = text.trim().to_ascii_lowercase(); + ANGLE_UNITS + .suffixes + .iter() + .any(|(suffix, _)| lowered.ends_with(suffix)) +} + fn parse_dimensioned(text: &str, units: &UnitTable) -> Result { let trimmed = text.trim(); if trimmed.is_empty() { @@ -69,12 +117,12 @@ fn parse_dimensioned(text: &str, units: &UnitTable) -> Result().ok().map(ctor) + parse_finite(trimmed[..cut].trim()).map(ctor) }); if let Some(value) = with_unit { return Ok(value); } - if let Ok(value) = trimmed.parse::() { + if let Some(value) = parse_finite(trimmed) { return Ok((units.default)(value)); } let suffix_start = trimmed @@ -93,6 +141,10 @@ fn is_numeric_part(c: char) -> bool { c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E') } +fn parse_finite(text: &str) -> Option { + text.parse::().ok().filter(|value| value.is_finite()) +} + impl ParsedValue for Length { type Error = DimensionedParseError; @@ -113,9 +165,9 @@ impl ParsedValue for Angle { mod tests { use std::sync::Arc; - use uom::si::angle::{degree, radian}; + use uom::si::angle::degree; use uom::si::f64::{Angle, Length}; - use uom::si::length::{centimeter, foot, inch, meter, millimeter}; + use uom::si::length::millimeter; use super::{DimensionedInput, DimensionedInputResponse, DimensionedParseError}; use crate::focus::FocusManager; @@ -128,7 +180,7 @@ mod tests { use crate::strings::StringTable; use crate::theme::Theme; use crate::widget_id::{WidgetId, WidgetKey}; - use crate::widgets::parsed_input::show_parsed_input; + use crate::widgets::parsed_input::{ParsedValue, show_parsed_input}; use crate::widgets::{MemoryClipboard, TextInputState}; const PLACEHOLDER: StringKey = StringKey::new("dim.placeholder"); @@ -144,7 +196,7 @@ mod tests { WidgetId::ROOT.child(WidgetKey::new("dim")) } - fn run_length(state_text: &str) -> DimensionedInputResponse { + fn run(state_text: &str) -> DimensionedInputResponse { let mut state = TextInputState::from_text(state_text); let theme = Arc::new(Theme::light()); let mut focus = FocusManager::new(); @@ -156,7 +208,7 @@ mod tests { let prev = HitState::new(); let mut input = InputSnapshot::idle(FrameInstant::ZERO); let mut clipboard = MemoryClipboard::default(); - let widget = DimensionedInput::::new(id_widget(), rect(), PLACEHOLDER, &mut state); + let widget = DimensionedInput::::new(id_widget(), rect(), PLACEHOLDER, &mut state); let mut shaper = bone_text::Shaper::new(); let mut a11y = crate::a11y::AccessTreeBuilder::new(); let mut ctx = FrameCtx::new( @@ -173,201 +225,130 @@ mod tests { show_parsed_input(&mut ctx, widget, &mut clipboard) } - fn run_angle(state_text: &str) -> DimensionedInputResponse { - let mut state = TextInputState::from_text(state_text); - let theme = Arc::new(Theme::light()); - let mut focus = FocusManager::new(); - focus.register_focusable(id_widget()); - focus.request_focus(id_widget()); - focus.end_frame(); - let table = HotkeyTable::new(); - let mut hits = HitFrame::new(); - let prev = HitState::new(); - let mut input = InputSnapshot::idle(FrameInstant::ZERO); - let mut clipboard = MemoryClipboard::default(); - let widget = DimensionedInput::::new(id_widget(), rect(), PLACEHOLDER, &mut state); - let mut shaper = bone_text::Shaper::new(); - let mut a11y = crate::a11y::AccessTreeBuilder::new(); - let mut ctx = FrameCtx::new( - theme, - &mut input, - &mut focus, - &table, - StringTable::empty(), - &mut hits, - &prev, - &mut a11y, - &mut shaper, - ); - show_parsed_input(&mut ctx, widget, &mut clipboard) - } - - #[test] - fn length_default_unit_is_mm() { - let response = run_length("12.5"); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 12.5).abs() < 1e-12); - } - - #[test] - fn length_with_explicit_mm_suffix() { - let response = run_length("3mm"); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 3.0).abs() < 1e-12); - } - - #[test] - fn length_inch_suffix_converts_to_mm() { - let response = run_length("1in"); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 1.0).abs() < 1e-12); - } - - #[test] - fn length_double_quote_inch_suffix() { - let response = run_length("0.5\""); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 0.5).abs() < 1e-12); - } - - #[test] - fn length_centimeter_suffix() { - let response = run_length("2cm"); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 2.0).abs() < 1e-12); + fn assert_parses( + cases: &[(&str, f64)], + measure: impl Fn(T) -> f64, + ) -> Result<(), Box> { + cases.iter().try_for_each( + |&(text, expected)| -> Result<(), Box> { + let value = run::(text) + .value + .ok_or_else(|| format!("{text} parses"))?; + let got = measure(value); + assert!( + (got - expected).abs() < 1e-9, + "{text}: want {expected}, got {got}" + ); + Ok(()) + }, + )?; + Ok(()) } #[test] - fn length_meter_suffix() { - let response = run_length("0.5m"); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 0.5).abs() < 1e-12); + fn parses_units_and_defaults_for_length_and_angle() -> Result<(), Box> { + assert_parses::( + &[ + ("12.5", 12.5), + ("3mm", 3.0), + ("1in", 25.4), + ("0.5\"", 12.7), + ("2cm", 20.0), + ("0.5m", 500.0), + ("2ft", 609.6), + ("3MM", 3.0), + ("12In", 304.8), + ("1.5e1mm", 15.0), + ], + |value| value.get::(), + )?; + assert_parses::( + &[ + ("90", 90.0), + ("1.0rad", 1.0_f64.to_degrees()), + ("45\u{00B0}", 45.0), + ("0.25rev", 90.0), + ("1.5RAD", 1.5_f64.to_degrees()), + ], + |value| value.get::(), + )?; + Ok(()) } #[test] - fn length_foot_suffix() { - let response = run_length("2ft"); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 2.0).abs() < 1e-12); - } - - #[test] - fn length_unknown_suffix_errors() { - let response = run_length("3parsec"); - match response.error { - Some(DimensionedParseError::UnknownUnit(s)) => assert_eq!(s, "parsec"), - other => panic!("expected unknown-unit, got {other:?}"), + fn parse_reports_unknown_units_invalid_numbers_and_empties() { + type Probe = fn(&str) -> (bool, Option); + enum Want { + Unknown(&'static str), + Invalid, + Silent, } - } - - #[test] - fn length_partial_suffix_match_falls_through_to_unknown_unit() { - let response = run_length("3min"); - match response.error { - Some(DimensionedParseError::UnknownUnit(s)) => assert_eq!(s, "min"), - other => panic!("expected unknown-unit, got {other:?}"), - } - } - - #[test] - fn length_invalid_number_errors() { - let response = run_length("abcmm"); - assert!(matches!( - response.error, - Some(DimensionedParseError::InvalidNumber(_)) - )); - } - - #[test] - fn angle_default_unit_is_deg() { - let response = run_angle("90"); - let Some(value) = response.value else { - panic!("value parses") + let length: Probe = |text| { + let response = run::(text); + (response.value.is_none(), response.error) }; - assert!((value.get::() - 90.0).abs() < 1e-12); - } - - #[test] - fn angle_radian_suffix() { - let response = run_angle("1.0rad"); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 1.0).abs() < 1e-12); - } - - #[test] - fn angle_degree_glyph_suffix() { - let response = run_angle("45\u{00B0}"); - let Some(value) = response.value else { - panic!("value parses") - }; - assert!((value.get::() - 45.0).abs() < 1e-12); - } - - #[test] - fn empty_input_neither_value_nor_error_for_length() { - let response = run_length(""); - assert!(response.value.is_none()); - assert!(response.error.is_none()); - } - - #[test] - fn pure_garbage_classifies_as_invalid_number_not_unknown_unit() { - let response = run_length("abc"); - assert!(matches!( - response.error, - Some(DimensionedParseError::InvalidNumber(_)) - )); - } - - #[test] - fn scientific_notation_with_unit_parses() { - let response = run_length("1.5e1mm"); - let Some(value) = response.value else { - panic!("scientific notation parses") + let angle: Probe = |text| { + let response = run::(text); + (response.value.is_none(), response.error) }; - assert!((value.get::() - 15.0).abs() < 1e-12); - } - - #[test] - fn length_uppercase_suffix_accepted() { - let response = run_length("3MM"); - let Some(value) = response.value else { - panic!("uppercase mm parses") - }; - assert!((value.get::() - 3.0).abs() < 1e-12); + [ + (length, "3parsec", Want::Unknown("parsec")), + (length, "3min", Want::Unknown("min")), + (length, "abcmm", Want::Invalid), + (length, "abc", Want::Invalid), + (length, "NaN", Want::Invalid), + (length, "1e999", Want::Invalid), + (angle, "inf", Want::Invalid), + (length, "", Want::Silent), + ] + .into_iter() + .for_each(|(probe, text, want)| { + let (value_absent, error) = probe(text); + match want { + Want::Unknown(unit) => assert!( + matches!(&error, Some(DimensionedParseError::UnknownUnit(found)) if found == unit), + "{text} reports unknown unit {unit}, got {error:?}" + ), + Want::Invalid => { + assert!(value_absent, "{text} must not commit a value"); + assert!( + matches!(&error, Some(DimensionedParseError::InvalidNumber(_))), + "{text} reports an invalid number, got {error:?}" + ); + } + Want::Silent => { + assert!(value_absent, "{text} yields no value"); + assert!(error.is_none(), "{text} yields no error, got {error:?}"); + } + } + }); } #[test] - fn length_mixed_case_suffix_accepted() { - let response = run_length("12In"); - let Some(value) = response.value else { - panic!("mixed-case in parses") - }; - assert!((value.get::() - 12.0).abs() < 1e-12); - } + fn angle_helpers_detect_suffixes_and_round_trip_their_measure() { + use super::{AngleUnit, angle_text_has_unit}; + [ + ("1.5 rad", true), + ("90deg", true), + ("0.25 REV", true), + ("45\u{00B0}", true), + ("90", false), + (" 0.5 ", false), + ] + .into_iter() + .for_each(|(text, has_unit)| { + assert_eq!(angle_text_has_unit(text), has_unit, "{text}"); + }); - #[test] - fn angle_uppercase_suffix() { - let response = run_angle("1.5RAD"); - let Some(value) = response.value else { - panic!("uppercase rad parses") - }; - assert!((value.get::() - 1.5).abs() < 1e-12); + [ + AngleUnit::Degrees, + AngleUnit::Radians, + AngleUnit::Revolutions, + ] + .into_iter() + .for_each(|unit| { + assert!((unit.measure(unit.make(0.75)) - 0.75).abs() < 1e-12); + }); + assert!((AngleUnit::Revolutions.make(0.25).get::() - 90.0).abs() < 1e-9); } } diff --git a/crates/bone-ui/src/widgets/mod.rs b/crates/bone-ui/src/widgets/mod.rs index b436251..e02943a 100644 --- a/crates/bone-ui/src/widgets/mod.rs +++ b/crates/bone-ui/src/widgets/mod.rs @@ -36,7 +36,10 @@ pub use dialog::{ ConfirmationDialog, ConfirmationOutcome, ConfirmationResponse, Dialog, DialogButton, DialogResponse, Modal, ModalResponse, show_confirmation, show_dialog, show_modal, }; -pub use dimensioned_input::{DimensionedInput, DimensionedInputResponse, DimensionedParseError}; +pub use dimensioned_input::{ + AngleUnit, DimensionedInput, DimensionedInputResponse, DimensionedParseError, + angle_text_has_unit, +}; pub use dropdown::{Dropdown, DropdownItem, DropdownResponse, DropdownState, show_dropdown}; pub use file_picker::{ FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, FilePickerOutcome, diff --git a/crates/bone-ui/src/widgets/paint.rs b/crates/bone-ui/src/widgets/paint.rs index dda8e0a..07e29a7 100644 --- a/crates/bone-ui/src/widgets/paint.rs +++ b/crates/bone-ui/src/widgets/paint.rs @@ -158,6 +158,13 @@ pub enum WidgetPaint { role: TypographyRole, align: HorizontalAlign, }, + Paragraph { + rect: LayoutRect, + text: LabelText, + color: Color, + role: TypographyRole, + align: HorizontalAlign, + }, Mark { rect: LayoutRect, kind: GlyphMark, @@ -241,12 +248,12 @@ pub fn lower_paint(theme: &Theme, paint: &WidgetPaint) -> PaintPrim { border: *border, radius: *radius, }, - WidgetPaint::Label { rect, color, .. } | WidgetPaint::AlignedLabel { rect, color, .. } => { - PaintPrim::solid( - label_placeholder_bar(*rect), - color.with_alpha(LABEL_PLACEHOLDER_ALPHA * color.alpha()), - ) - } + WidgetPaint::Label { rect, color, .. } + | WidgetPaint::AlignedLabel { rect, color, .. } + | WidgetPaint::Paragraph { rect, color, .. } => PaintPrim::solid( + label_placeholder_bar(*rect), + color.with_alpha(LABEL_PLACEHOLDER_ALPHA * color.alpha()), + ), WidgetPaint::Mark { rect, color, .. } => { PaintPrim::solid(centered_square(*rect, MARK_PLACEHOLDER_FACTOR), *color) } diff --git a/crates/bone-ui/src/widgets/property_grid.rs b/crates/bone-ui/src/widgets/property_grid.rs index a4b3747..6f27d97 100644 --- a/crates/bone-ui/src/widgets/property_grid.rs +++ b/crates/bone-ui/src/widgets/property_grid.rs @@ -10,7 +10,7 @@ use crate::theme::{Border, Color, Step12, StrokeWidth}; use crate::widget_id::WidgetId; use super::checkbox::{Checkbox, CheckboxState, show_checkbox}; -use super::dimensioned_input::DimensionedInput; +use super::dimensioned_input::{AngleUnit, DimensionedInput, angle_text_has_unit}; use super::dropdown::{Dropdown, DropdownItem, DropdownState, show_dropdown}; use super::keys::take_activation; use super::paint::{HorizontalAlign, IconTint, LabelText, WidgetPaint}; @@ -475,15 +475,22 @@ impl PropertyEditor for LengthEditor { #[derive(Clone, Debug, PartialEq)] pub struct AngleEditor { pub value: Angle, + pub unit: AngleUnit, pub buffer: TextInputState, } impl AngleEditor { #[must_use] pub fn new(value: Angle) -> Self { + Self::new_in(value, AngleUnit::Degrees) + } + + #[must_use] + pub fn new_in(value: Angle, unit: AngleUnit) -> Self { Self { - buffer: TextInputState::from_text(format_angle(value)), + buffer: TextInputState::from_text(format_angle(value, unit)), value, + unit, } } } @@ -497,7 +504,7 @@ impl PropertyEditor for AngleEditor { paint: &mut Vec, ) -> bool { let editing = ctx.is_focused(cell.row_id); - let formatted = format_angle(self.value); + let formatted = format_angle(self.value, self.unit); if !editing && self.buffer.text != formatted { self.buffer = TextInputState::from_text(formatted); } @@ -510,7 +517,7 @@ impl PropertyEditor for AngleEditor { paint.extend(response.paint); match response.committed { Some(v) => { - self.value = v; + self.value = reinterpret_in_unit(&self.buffer.text, v, self.unit); true } None => false, @@ -586,9 +593,18 @@ fn format_length(value: Length) -> String { format!("{} mm", value.get::()) } -fn format_angle(value: Angle) -> String { - use uom::si::angle::degree; - format!("{} deg", value.get::()) +fn format_angle(value: Angle, unit: AngleUnit) -> String { + format!("{} {}", unit.measure(value), unit.suffix()) +} + +fn reinterpret_in_unit(text: &str, parsed: Angle, unit: AngleUnit) -> Angle { + if angle_text_has_unit(text) { + return parsed; + } + match text.trim().parse::() { + Ok(value) if value.is_finite() => unit.make(value), + _ => parsed, + } } #[cfg(test)]