Skip to main content

dear_implot/
numeric_format.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::str::FromStr;
4
5use dear_imgui_rs::{NumericFormat, NumericFormatError};
6
7/// A validated floating-point format for ImPlot values.
8///
9/// ImPlot promotes the corresponding values to `double` before formatting.
10/// Unlike core Dear ImGui numeric widgets, ImPlot forwards the complete format
11/// to native printf paths, so this type also requires ASCII text. Use an axis
12/// formatter closure when localized or arbitrary UTF-8 output is required.
13#[derive(Clone, Debug, PartialEq)]
14pub struct FloatFormat<'a>(NumericFormat<'a, f64>);
15
16impl<'a> FloatFormat<'a> {
17    /// Validates a floating-point format accepted by ImPlot's native formatters.
18    pub fn new(storage: impl Into<Cow<'a, str>>) -> Result<Self, FloatFormatError> {
19        let format = NumericFormat::new(storage)?;
20        if let Some(byte_offset) = format.as_str().bytes().position(|byte| !byte.is_ascii()) {
21            return Err(FloatFormatError::NonAscii { byte_offset });
22        }
23        Ok(Self(format))
24    }
25
26    /// Returns the validated format string.
27    pub fn as_str(&self) -> &str {
28        self.0.as_str()
29    }
30
31    /// Borrows this format without revalidating it.
32    pub fn borrowed(&self) -> FloatFormat<'_> {
33        FloatFormat(self.0.borrowed())
34    }
35
36    /// Converts this format into an owned value.
37    pub fn into_owned(self) -> FloatFormat<'static> {
38        FloatFormat(self.0.into_owned())
39    }
40
41    /// Returns the underlying typed Dear ImGui numeric format.
42    pub fn into_numeric_format(self) -> NumericFormat<'a, f64> {
43        self.0
44    }
45}
46
47impl AsRef<str> for FloatFormat<'_> {
48    fn as_ref(&self) -> &str {
49        self.as_str()
50    }
51}
52
53impl<'a> TryFrom<&'a str> for FloatFormat<'a> {
54    type Error = FloatFormatError;
55
56    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
57        Self::new(value)
58    }
59}
60
61impl TryFrom<String> for FloatFormat<'static> {
62    type Error = FloatFormatError;
63
64    fn try_from(value: String) -> Result<Self, Self::Error> {
65        Self::new(value)
66    }
67}
68
69impl FromStr for FloatFormat<'static> {
70    type Err = FloatFormatError;
71
72    fn from_str(value: &str) -> Result<Self, Self::Err> {
73        Self::new(value.to_owned())
74    }
75}
76
77/// Describes why an ImPlot floating-point format was rejected.
78#[derive(Clone, Debug, Eq, PartialEq)]
79#[non_exhaustive]
80pub enum FloatFormatError {
81    /// The C-style numeric directive is invalid for a promoted `double` value.
82    Numeric(NumericFormatError),
83    /// ImPlot forwards the complete string to a locale-sensitive native formatter.
84    NonAscii { byte_offset: usize },
85}
86
87impl fmt::Display for FloatFormatError {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::Numeric(error) => error.fmt(formatter),
91            Self::NonAscii { byte_offset } => write!(
92                formatter,
93                "ImPlot numeric formats must be ASCII; non-ASCII text starts at byte {byte_offset}"
94            ),
95        }
96    }
97}
98
99impl std::error::Error for FloatFormatError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        match self {
102            Self::Numeric(error) => Some(error),
103            Self::NonAscii { .. } => None,
104        }
105    }
106}
107
108impl From<NumericFormatError> for FloatFormatError {
109    fn from(error: NumericFormatError) -> Self {
110        Self::Numeric(error)
111    }
112}
113
114/// A validated floating-point format that fits ImPlot's axis storage.
115#[derive(Clone, Debug, PartialEq)]
116pub struct AxisFormat<'a>(FloatFormat<'a>);
117
118impl<'a> AxisFormat<'a> {
119    /// Maximum format length accepted by ImPlot's 16-byte axis buffer.
120    pub const MAX_LEN: usize = 15;
121
122    /// Validates a floating-point format and the axis storage limit.
123    pub fn new(storage: impl Into<Cow<'a, str>>) -> Result<Self, AxisFormatError> {
124        Self::try_from(FloatFormat::new(storage)?)
125    }
126
127    /// Returns the validated format string.
128    pub fn as_str(&self) -> &str {
129        self.0.as_str()
130    }
131
132    /// Borrows this axis format without revalidating it.
133    pub fn borrowed(&self) -> AxisFormat<'_> {
134        AxisFormat(self.0.borrowed())
135    }
136
137    /// Converts this format into an owned value.
138    pub fn into_owned(self) -> AxisFormat<'static> {
139        AxisFormat(self.0.into_owned())
140    }
141
142    /// Returns the underlying general-purpose floating-point format.
143    pub fn into_float_format(self) -> FloatFormat<'a> {
144        self.0
145    }
146}
147
148impl<'a> TryFrom<FloatFormat<'a>> for AxisFormat<'a> {
149    type Error = AxisFormatError;
150
151    fn try_from(format: FloatFormat<'a>) -> Result<Self, Self::Error> {
152        let length = format.as_str().len();
153        if length > Self::MAX_LEN {
154            return Err(AxisFormatError::TooLong {
155                length,
156                maximum: Self::MAX_LEN,
157            });
158        }
159        Ok(Self(format))
160    }
161}
162
163impl AsRef<str> for AxisFormat<'_> {
164    fn as_ref(&self) -> &str {
165        self.as_str()
166    }
167}
168
169impl<'a> TryFrom<&'a str> for AxisFormat<'a> {
170    type Error = AxisFormatError;
171
172    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
173        Self::new(value)
174    }
175}
176
177impl TryFrom<String> for AxisFormat<'static> {
178    type Error = AxisFormatError;
179
180    fn try_from(value: String) -> Result<Self, Self::Error> {
181        Self::new(value)
182    }
183}
184
185impl FromStr for AxisFormat<'static> {
186    type Err = AxisFormatError;
187
188    fn from_str(value: &str) -> Result<Self, Self::Err> {
189        Self::new(value.to_owned())
190    }
191}
192
193/// Describes why an ImPlot axis format was rejected.
194#[derive(Clone, Debug, Eq, PartialEq)]
195#[non_exhaustive]
196pub enum AxisFormatError {
197    /// The underlying numeric format is invalid.
198    Format(FloatFormatError),
199    /// The complete string does not fit ImPlot's fixed-size axis buffer.
200    TooLong { length: usize, maximum: usize },
201}
202
203impl fmt::Display for AxisFormatError {
204    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
205        match self {
206            Self::Format(error) => error.fmt(formatter),
207            Self::TooLong { length, maximum } => write!(
208                formatter,
209                "axis format is {length} bytes; ImPlot supports at most {maximum}"
210            ),
211        }
212    }
213}
214
215impl std::error::Error for AxisFormatError {
216    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
217        match self {
218            Self::Format(error) => Some(error),
219            Self::TooLong { .. } => None,
220        }
221    }
222}
223
224impl From<FloatFormatError> for AxisFormatError {
225    fn from(error: FloatFormatError) -> Self {
226        Self::Format(error)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn axis_formats_validate_numeric_type_and_storage_length() {
236        assert_eq!(AxisFormat::new("%.3f").unwrap().as_str(), "%.3f");
237        assert!(matches!(
238            AxisFormat::new("%s"),
239            Err(AxisFormatError::Format(_))
240        ));
241        assert!(matches!(
242            FloatFormat::new("%.2f °C"),
243            Err(FloatFormatError::NonAscii { .. })
244        ));
245
246        let maximum = format!("{}%.1f", "x".repeat(11));
247        assert_eq!(maximum.len(), AxisFormat::MAX_LEN);
248        assert!(AxisFormat::new(maximum).is_ok());
249
250        let too_long = format!("{}%.1f", "x".repeat(12));
251        assert!(matches!(
252            AxisFormat::new(too_long),
253            Err(AxisFormatError::TooLong { length: 16, .. })
254        ));
255    }
256}