Skip to main content

gpui_ce_motion/text/
format.rs

1//! Exact fixed-point number formatting and immutable rich display text.
2use crate::Color;
3use std::{fmt, sync::Arc};
4
5/// An invalid fixed-point scale or number-format configuration.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub struct NumberFormatError;
8
9impl fmt::Display for NumberFormatError {
10    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
11        f.write_str("invalid fixed-point number format")
12    }
13}
14
15impl std::error::Error for NumberFormatError {}
16
17/// Exact signed decimal coefficient with zero through eighteen fractional places.
18/// For example `FixedNumber::new(12345, 2)` represents 123.45 without float rounding.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct FixedNumber {
21    coefficient: i64,
22    scale: u8,
23}
24
25impl FixedNumber {
26    /// Validates a decimal scale. All i64 coefficients, including MIN, are supported.
27    pub fn new(coefficient: i64, scale: u8) -> Result<Self, NumberFormatError> {
28        if scale > 18 {
29            return Err(NumberFormatError);
30        }
31
32        Ok(Self { coefficient, scale })
33    }
34
35    /// Exact signed coefficient.
36    pub fn coefficient(self) -> i64 {
37        self.coefficient
38    }
39
40    /// Number of fractional decimal places.
41    pub fn scale(self) -> u8 {
42        self.scale
43    }
44}
45
46impl From<i64> for FixedNumber {
47    fn from(coefficient: i64) -> Self {
48        Self {
49            coefficient,
50            scale: 0,
51        }
52    }
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56struct Format {
57    prefix: Arc<str>,
58    suffix: Arc<str>,
59    decimal: Arc<str>,
60    separator: Arc<str>,
61    primary: usize,
62    secondary: usize,
63    digits: [char; 10],
64}
65
66/// Reusable locale-configurable formatting, with explicit separators, affixes,
67/// digit alphabet and primary/secondary groups. No implicit OS locale or CLDR
68/// dependency is installed. Currency/percent conversion and rounding belong to
69/// the caller; this formatter preserves the provided fixed-point value exactly.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct NumberFormat(Arc<Format>);
72
73impl Default for NumberFormat {
74    fn default() -> Self {
75        Self(Arc::new(Format {
76            prefix: "".into(),
77            suffix: "".into(),
78            decimal: ".".into(),
79            separator: "".into(),
80            primary: 3,
81            secondary: 3,
82            digits: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
83        }))
84    }
85}
86
87impl NumberFormat {
88    /// Sets a fixed prefix, such as a currency symbol.
89    pub fn prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
90        Arc::make_mut(&mut self.0).prefix = prefix.into();
91
92        self
93    }
94
95    /// Sets a fixed suffix, such as a unit or percentage symbol.
96    pub fn suffix(mut self, suffix: impl Into<Arc<str>>) -> Self {
97        Arc::make_mut(&mut self.0).suffix = suffix.into();
98
99        self
100    }
101
102    /// Sets a nonempty decimal separator. Panics for an empty string.
103    pub fn decimal(mut self, separator: impl Into<Arc<str>>) -> Self {
104        let separator = separator.into();
105
106        assert!(!separator.is_empty(), "decimal separator must not be empty");
107        Arc::make_mut(&mut self.0).decimal = separator;
108
109        self
110    }
111
112    /// Sets integer grouping: (3,3) is 1,234,567; (3,2) is 12,34,567.
113    /// Panics unless group sizes are between one and eighteen. An empty separator disables grouping.
114    pub fn grouping(
115        mut self,
116        separator: impl Into<Arc<str>>,
117        primary: usize,
118        secondary: usize,
119    ) -> Self {
120        assert!(
121            (1..=18).contains(&primary) && (1..=18).contains(&secondary),
122            "invalid digit groups"
123        );
124        let format = Arc::make_mut(&mut self.0);
125
126        format.separator = separator.into();
127        format.primary = primary;
128        format.secondary = secondary;
129
130        self
131    }
132
133    /// Supplies ten distinct Unicode digit characters. Panics for duplicates or
134    /// characters that are not numeric. Formatting records UTF-8 byte identities.
135    pub fn digits(mut self, digits: [char; 10]) -> Self {
136        assert!(
137            digits
138                .iter()
139                .enumerate()
140                .all(|(i, c)| c.is_numeric() && !digits[..i].contains(c)),
141            "invalid digit alphabet"
142        );
143        Arc::make_mut(&mut self.0).digits = digits;
144
145        self
146    }
147
148    /// Formats an exact value with semantic digit slots independent of separators,
149    /// affix length and integer growth. At most 256 leading integer digits are permitted.
150    /// Preparation allocates; keep the result when manually animating formatted text.
151    pub fn format(&self, value: FixedNumber, min_integer_digits: usize) -> FormattedNumber {
152        assert!(min_integer_digits <= 256, "minimum digit count exceeds 256");
153        let scale = usize::from(value.scale);
154        let magnitude = value.coefficient.unsigned_abs().to_string();
155
156        let width = magnitude
157            .len()
158            .max(scale + 1)
159            .max(scale + min_integer_digits);
160
161        let padded = format!("{magnitude:0>width$}");
162        let integer = padded.len() - scale;
163        let mut text = String::new();
164        let mut slots = Vec::new();
165
166        let push =
167            |text: &mut String, slots: &mut Vec<(usize, usize)>, string: &str, slot: usize| {
168                for (index, c) in string.char_indices() {
169                    slots.push((text.len(), slot + index * 8));
170                    text.push(c);
171                }
172            };
173
174        if value.coefficient < 0 {
175            push(&mut text, &mut slots, "-", 4);
176        }
177
178        push(&mut text, &mut slots, &self.0.prefix, 2);
179
180        for (index, digit) in padded.bytes().enumerate() {
181            if index == integer && scale > 0 {
182                push(&mut text, &mut slots, &self.0.decimal, 5);
183            }
184
185            if index > 0 && index < integer && !self.0.separator.is_empty() {
186                let remaining = integer - index;
187
188                if remaining == self.0.primary
189                    || (remaining > self.0.primary
190                        && (remaining - self.0.primary).is_multiple_of(self.0.secondary))
191                {
192                    push(
193                        &mut text,
194                        &mut slots,
195                        &self.0.separator,
196                        6 + remaining * self.0.separator.len() * 8,
197                    );
198                }
199            }
200
201            let slot = if index < integer {
202                (integer - index - 1) * 8
203            } else {
204                (index - integer) * 8 + 1
205            };
206
207            slots.push((text.len(), slot));
208            text.push(self.0.digits[usize::from(digit - b'0')]);
209        }
210
211        push(&mut text, &mut slots, &self.0.suffix, 3);
212
213        FormattedNumber {
214            text: text.into(),
215            slots: slots.into(),
216        }
217    }
218}
219
220/// Prepared text plus semantic place identities. Slot identities encode decimal place and token kind; affixes and
221/// separators remain distinct even with long UTF-8 prefixes.
222#[derive(Clone, Debug, PartialEq, Eq)]
223pub struct FormattedNumber {
224    text: Arc<str>,
225    slots: Arc<[(usize, usize)]>,
226}
227
228impl FormattedNumber {
229    /// Full display/accessibility text.
230    pub fn text(&self) -> &str {
231        &self.text
232    }
233
234    /// Stable slot containing a UTF-8 byte. Invalid offsets return `None`.
235    pub fn slot_at(&self, byte: usize) -> Option<usize> {
236        if byte >= self.text.len() {
237            return None;
238        }
239
240        let index = self.slots.partition_point(|(start, _)| *start <= byte);
241
242        index.checked_sub(1).map(|index| self.slots[index].1)
243    }
244}
245
246/// One immutable display-text span. Font size and line height remain label-wide,
247/// matching the pinned GPUI shaping API. Selection and editing are not provided.
248#[derive(Clone, Debug, PartialEq)]
249pub struct TextSpan {
250    pub(crate) text: Arc<str>,
251    pub(crate) color: Option<Color>,
252    pub(crate) family: Option<Arc<str>>,
253    pub(crate) weight: Option<f32>,
254}
255
256impl TextSpan {
257    /// Creates a span inheriting the label's text style.
258    pub fn new(text: impl Into<Arc<str>>) -> Self {
259        Self {
260            text: text.into(),
261            color: None,
262            family: None,
263            weight: None,
264        }
265    }
266
267    /// Overrides this span's color.
268    pub fn color(mut self, color: Color) -> Self {
269        self.color = Some(color);
270
271        self
272    }
273
274    /// Overrides this span's native font family.
275    pub fn font_family(mut self, family: impl Into<Arc<str>>) -> Self {
276        self.family = Some(family.into());
277
278        self
279    }
280
281    /// Overrides font weight. Panics outside finite 1..=1000; actual face selection
282    /// depends on the font and backend, rather than promising variable-font axes.
283    pub fn font_weight(mut self, weight: f32) -> Self {
284        assert!(
285            weight.is_finite() && (1.0..=1000.0).contains(&weight),
286            "invalid font weight"
287        );
288        self.weight = Some(weight);
289
290        self
291    }
292
293    /// Optional span color override.
294    pub fn text_color(&self) -> Option<Color> {
295        self.color
296    }
297
298    /// Optional font family override.
299    pub fn family(&self) -> Option<&str> {
300        self.family.as_deref()
301    }
302
303    /// Optional native font weight override.
304    pub fn weight(&self) -> Option<f32> {
305        self.weight
306    }
307
308    /// Logical span text.
309    pub fn text(&self) -> &str {
310        &self.text
311    }
312}
313
314#[derive(Clone, Debug, PartialEq)]
315struct RichData {
316    text: Arc<str>,
317    spans: Vec<TextSpan>,
318}
319
320/// Immutable, reusable styled display text. The complete string is shaped before
321/// animation units are split, preserving GPUI's font fallback and cluster boundaries.
322#[derive(Clone, Debug, PartialEq)]
323pub struct RichText(Arc<RichData>);
324
325impl RichText {
326    /// Concatenates spans once; empty spans are discarded.
327    pub fn new(spans: impl IntoIterator<Item = TextSpan>) -> Self {
328        let spans: Vec<_> = spans
329            .into_iter()
330            .filter(|span| !span.text.is_empty())
331            .collect();
332
333        let text = spans
334            .iter()
335            .map(|span| span.text.as_ref())
336            .collect::<String>();
337
338        Self(Arc::new(RichData {
339            text: text.into(),
340            spans,
341        }))
342    }
343
344    /// Complete logical and accessibility label.
345    pub fn text(&self) -> &str {
346        &self.0.text
347    }
348
349    /// Prepared spans, in logical text order.
350    pub fn spans(&self) -> &[TextSpan] {
351        &self.0.spans
352    }
353
354    /// Span containing a UTF-8 byte offset, or `None` beyond the text.
355    pub fn span_at(&self, byte: usize) -> Option<&TextSpan> {
356        let mut end = 0;
357
358        self.0.spans.iter().find(|span| {
359            end += span.text.len();
360
361            byte < end
362        })
363    }
364}