Skip to main content

waterui_locale/format/
mod.rs

1//! Locale-aware formatting utilities.
2
3#[cfg(feature = "datetime")]
4pub mod date;
5#[cfg(feature = "number")]
6pub mod number;
7#[cfg(feature = "number")]
8pub mod unit;
9
10use core::fmt::{self, Display};
11
12use crate::locale::Locale;
13
14/// Trait for locale-aware display.
15///
16/// Types implementing this trait can format themselves differently
17/// based on the current locale.
18///
19/// # Examples
20///
21/// ```rust
22/// use waterui_locale::{Length, LocalizedDisplay, Meter, locales};
23///
24/// let distance = Length::<Meter>::new(18.0);
25///
26/// // Format with different locales. The unit symbol follows the number with
27/// // no separator; CLDR would put a space in before a Latin-script symbol,
28/// // which this hand-rolled unit table does not yet do.
29/// assert_eq!(distance.to_localized_string(&locales::EN), "18m");
30/// assert_eq!(distance.to_localized_string(&locales::ZH_CN), "18米");
31/// assert_eq!(distance.to_localized_string(&locales::JA), "18メートル");
32/// ```
33pub trait LocalizedDisplay {
34    /// Format the value for the given locale.
35    ///
36    /// # Errors
37    ///
38    /// Returns the formatter error propagated from `fmt::Formatter`.
39    fn fmt(&self, locale: &Locale, f: &mut fmt::Formatter<'_>) -> fmt::Result;
40
41    /// Convert to a localized string.
42    fn to_localized_string(&self, locale: &Locale) -> String {
43        struct Adapter<'a, T: LocalizedDisplay + ?Sized>(&'a T, &'a Locale);
44
45        impl<T: LocalizedDisplay + ?Sized> Display for Adapter<'_, T> {
46            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47                self.0.fmt(self.1, f)
48            }
49        }
50
51        Adapter(self, locale).to_string()
52    }
53
54    /// Get a Display adapter for use with format strings.
55    fn localized_fmt<'a>(&'a self, locale: &'a Locale) -> impl Display + 'a
56    where
57        Self: Sized,
58    {
59        struct LocalizedFmt<'a, T: LocalizedDisplay>(&'a T, &'a Locale);
60
61        impl<T: LocalizedDisplay> Display for LocalizedFmt<'_, T> {
62            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63                self.0.fmt(self.1, f)
64            }
65        }
66
67        LocalizedFmt(self, locale)
68    }
69}
70
71/// Blanket implementation for any type that implements Display.
72///
73/// This allows standard types to be used with `LocalizedDisplay`,
74/// though they won't have locale-specific formatting.
75impl<T: Display> LocalizedDisplay for T {
76    fn fmt(&self, _locale: &Locale, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        Display::fmt(self, f)
78    }
79}
80
81/// A directionally isolated, locale-aware interpolation argument used by
82/// WaterUI's `text!` macro.
83#[cfg(feature = "number")]
84#[doc(hidden)]
85pub struct LocalizedArgument<'a, T> {
86    value: &'a T,
87    locale: &'a Locale,
88}
89
90/// `{:?}` interpolations render the wrapped value, not this adapter.
91#[cfg(feature = "number")]
92impl<T: fmt::Debug> fmt::Debug for LocalizedArgument<'_, T> {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        fmt::Debug::fmt(self.value, f)
95    }
96}
97
98#[cfg(feature = "number")]
99impl<'a, T> LocalizedArgument<'a, T> {
100    /// Creates an interpolation adapter.
101    #[must_use]
102    pub const fn new(value: &'a T, locale: &'a Locale) -> Self {
103        Self { value, locale }
104    }
105}
106
107#[cfg(feature = "number")]
108struct RawArgument<'a, T: LocalizedDisplay + ?Sized>(&'a T, &'a Locale);
109
110#[cfg(feature = "number")]
111impl<T: LocalizedDisplay + ?Sized> Display for RawArgument<'_, T> {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        self.0.fmt(self.1, f)
114    }
115}
116
117#[cfg(feature = "number")]
118fn numeric_argument<T: LocalizedDisplay + ?Sized>(
119    value: &T,
120    locale: &Locale,
121    precision: Option<usize>,
122) -> Option<String> {
123    let numeric = matches!(
124        core::any::type_name::<T>(),
125        "u8" | "u16"
126            | "u32"
127            | "u64"
128            | "u128"
129            | "usize"
130            | "i8"
131            | "i16"
132            | "i32"
133            | "i64"
134            | "i128"
135            | "isize"
136            | "f32"
137            | "f64"
138    );
139    let argument = RawArgument(value, locale);
140    numeric.then(|| precision.map_or_else(|| argument.to_string(), |p| format!("{argument:.p$}")))
141}
142
143#[cfg(feature = "number")]
144impl<T: LocalizedDisplay> Display for LocalizedArgument<'_, T> {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        f.write_str("\u{2068}")?;
147        if let Some(number) = numeric_argument(self.value, self.locale, f.precision()) {
148            let formatted = number::format_number_text(self.locale, &number);
149            if f.width().is_none() && !f.sign_plus() && !f.sign_aware_zero_pad() {
150                // No width, sign or zero-padding flags: emit the localized
151                // digits verbatim so a locale-specific minus sign survives.
152                f.write_str(&formatted)?;
153            } else {
154                // `pad_integral` re-emits the sign and applies width, fill,
155                // alignment and sign-aware zero padding exactly as `core::fmt`
156                // does for numbers. `pad` cannot be used here: it interprets
157                // `precision` as a maximum string length and would truncate
158                // the already-rounded text.
159                let digits = formatted
160                    .strip_prefix(|minus| matches!(minus, '-' | '\u{2212}'))
161                    .unwrap_or(formatted.as_str());
162                f.pad_integral(!number.starts_with('-'), "", digits)?;
163            }
164        } else {
165            self.value.fmt(self.locale, f)?;
166        }
167        f.write_str("\u{2069}")
168    }
169}
170
171/// Wrapper for locale-aware list formatting.
172///
173/// Formats lists according to locale conventions:
174/// - English: "A, B, and C"
175/// - Chinese: "A、B、C"
176/// - Japanese: "A、B、C"
177///
178/// # Examples
179///
180/// ```rust
181/// use waterui_locale::{LocalizedDisplay, LocalizedList, locales};
182///
183/// let items = LocalizedList(&["Apple", "Banana", "Orange"]);
184///
185/// assert_eq!(items.to_localized_string(&locales::EN), "Apple, Banana, and Orange");
186/// assert_eq!(items.to_localized_string(&locales::ZH_CN), "Apple、Banana和Orange");
187/// ```
188#[cfg(feature = "list")]
189#[derive(Debug)]
190pub struct LocalizedList<'a>(pub &'a [&'a str]);
191
192#[cfg(feature = "list")]
193impl LocalizedDisplay for LocalizedList<'_> {
194    fn fmt(&self, locale: &Locale, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        use icu_list::{
196            ListFormatter,
197            options::{ListFormatterOptions, ListLength},
198        };
199
200        let options = ListFormatterOptions::default().with_length(ListLength::Wide);
201        let formatter = ListFormatter::try_new_and(locale.0.clone().into(), options)
202            .unwrap_or_else(|_| {
203                ListFormatter::try_new_and(icu_locale::locale!("en").into(), options)
204                    .expect("English list formatter should always be available")
205            });
206
207        let result = formatter.format(self.0.iter().copied());
208        write!(f, "{result}")
209    }
210}
211
212#[cfg(all(test, feature = "number"))]
213mod tests {
214    use super::*;
215    use crate::locale::locales;
216
217    #[test]
218    fn localized_argument_formats_numbers_and_adds_bidi_isolation() {
219        let value = 1234.5_f64;
220        let formatted = format!("{}", LocalizedArgument::new(&value, &locales::DE));
221
222        assert!(formatted.starts_with('\u{2068}'));
223        assert!(formatted.ends_with('\u{2069}'));
224        assert!(formatted.contains("1.234,5"));
225    }
226
227    #[test]
228    fn localized_argument_accepts_non_static_borrowed_text() {
229        let owned = String::from("مرحبا");
230        let borrowed = owned.as_str();
231
232        assert_eq!(
233            LocalizedArgument::new(&borrowed, &locales::AR).to_string(),
234            "\u{2068}مرحبا\u{2069}"
235        );
236    }
237
238    #[test]
239    fn localized_argument_applies_width_and_zero_fill_to_integers() {
240        assert_eq!(
241            format!("{:06}", LocalizedArgument::new(&0_u32, &locales::EN)),
242            "\u{2068}000000\u{2069}"
243        );
244        assert_eq!(
245            format!("{:06}", LocalizedArgument::new(&-42_i32, &locales::EN)),
246            "\u{2068}-00042\u{2069}"
247        );
248        assert_eq!(
249            format!("{:>8}", LocalizedArgument::new(&1234_i64, &locales::EN)),
250            "\u{2068}   1,234\u{2069}"
251        );
252    }
253
254    #[test]
255    fn localized_argument_applies_width_to_string_arguments() {
256        let value = "hi";
257        assert_eq!(
258            format!("{:>8}", LocalizedArgument::new(&value, &locales::EN)),
259            "\u{2068}      hi\u{2069}"
260        );
261    }
262
263    #[test]
264    fn localized_argument_applies_precision_and_width_to_floats() {
265        let value = 1234.567_f64;
266        assert_eq!(
267            format!("{:.2}", LocalizedArgument::new(&value, &locales::EN)),
268            "\u{2068}1,234.57\u{2069}"
269        );
270        assert_eq!(
271            format!(
272                "{:>10.2}",
273                LocalizedArgument::new(&1234.5_f64, &locales::EN)
274            ),
275            "\u{2068}  1,234.50\u{2069}"
276        );
277    }
278
279    #[test]
280    fn localized_argument_debug_forwards_to_the_wrapped_value() {
281        #[derive(Debug)]
282        enum Fruit {
283            Apple,
284        }
285
286        assert_eq!(
287            format!("{:?}", LocalizedArgument::new(&Fruit::Apple, &locales::EN)),
288            "Apple"
289        );
290        assert_eq!(
291            format!("{:#?}", LocalizedArgument::new(&Fruit::Apple, &locales::EN)),
292            "Apple"
293        );
294    }
295}