Skip to main content

xgpui_shell/
typography.rs

1use gpui::{Font, FontStyle, FontWeight, font};
2
3/// Client-frame typography in logical pixels, before the shell text scale.
4/// Application content retains its own typography. Font availability and glyph
5/// fallback are handled by GPUI's text system; a requested family is not proof
6/// that the family is installed.
7#[derive(Clone, Debug, PartialEq)]
8pub struct ShellTypography {
9    pub font: Font,
10    /// Logical pixel size; normalized to 8–48 before text scaling.
11    pub size: f32,
12}
13
14impl Default for ShellTypography {
15    fn default() -> Self {
16        Self {
17            font: font(".SystemUIFont"),
18            size: 14.0,
19        }
20    }
21}
22
23impl ShellTypography {
24    pub(crate) fn normalized(mut self) -> Self {
25        if self.font.family.trim().is_empty() {
26            self.font.family = ".SystemUIFont".into();
27        }
28        self.size = if self.size.is_finite() {
29            self.size.clamp(8.0, 48.0)
30        } else {
31            14.0
32        };
33        self.font.weight = if self.font.weight.0.is_finite() {
34            FontWeight(self.font.weight.0.clamp(100.0, 900.0))
35        } else {
36            FontWeight::NORMAL
37        };
38        self
39    }
40
41    /// Parse the common desktop family/size/weight/style subset of GNOME Pango
42    /// descriptions or KDE QFont serialization. Points use 96/72 logical pixels;
43    /// explicit `px` is already logical pixels. This never applies device DPI.
44    /// Advanced Pango variations/features/stretch are not supported. QFont's
45    /// additional attributes (underline, stretch, features, etc.) are not applied.
46    /// Invalid or unsupported descriptions return None, allowing a safe fallback.
47    pub fn from_desktop_description(description: &str) -> Option<Self> {
48        if description.chars().any(char::is_control) {
49            return None;
50        }
51        let description = description.trim();
52        if description.is_empty() {
53            return None;
54        }
55        let fields: Vec<_> = description.split(',').map(str::trim).collect();
56        let parsed = if fields.len() >= 10 && fields[1].parse::<f32>().is_ok() {
57            qfont(&fields)
58        } else {
59            pango(description)
60        }?;
61        Some(parsed.normalized())
62    }
63}
64
65fn positive(value: &str) -> Option<f32> {
66    value
67        .parse::<f32>()
68        .ok()
69        .filter(|v| v.is_finite() && *v > 0.0)
70}
71
72fn qfont(fields: &[&str]) -> Option<ShellTypography> {
73    let family = fields.first()?.trim();
74    if family.is_empty() {
75        return None;
76    }
77    let size = positive(fields.get(1)?)
78        .map(|pt| pt * 96.0 / 72.0)
79        .or_else(|| positive(fields.get(2)?))?;
80    let weight = fields.get(4)?.parse::<u16>().ok()?;
81    let weight = if weight < 100 {
82        // Qt 5 legacy weights are nonlinear; common serialized Plasma fonts
83        // use 50 (normal), 63 (demibold), or 75 (bold).
84        match weight {
85            0..=11 => 100.0,
86            12..=24 => 200.0,
87            25..=49 => 300.0,
88            50..=56 => 400.0,
89            57..=62 => 500.0,
90            63..=74 => 600.0,
91            75..=80 => 700.0,
92            81..=86 => 800.0,
93            _ => 900.0,
94        }
95    } else if weight <= 900 {
96        weight as f32
97    } else {
98        return None;
99    };
100    let style = match *fields.get(5)? {
101        "0" => FontStyle::Normal,
102        "1" => FontStyle::Italic,
103        "2" => FontStyle::Oblique,
104        _ => return None,
105    };
106    let mut font = font(family.to_owned());
107    font.weight = FontWeight(weight);
108    font.style = style;
109    Some(ShellTypography { font, size })
110}
111
112fn pango(description: &str) -> Option<ShellTypography> {
113    if description.contains(['@', '#', '=']) {
114        return None;
115    }
116    let mut words: Vec<_> = description.split_whitespace().collect();
117    let last = words.last().copied()?;
118    let (size, has_size) = if let Some(value) = last.strip_suffix("px") {
119        (positive(value)?, true)
120    } else if let Some(points) = positive(last) {
121        (points * 96.0 / 72.0, true)
122    } else {
123        // Numeric-looking invalid sizes must not become part of a family.
124        if last.parse::<f32>().is_ok() {
125            return None;
126        }
127        (14.0, false)
128    };
129    if has_size {
130        words.pop();
131    }
132    let mut weight = FontWeight::NORMAL;
133    let mut style = FontStyle::Normal;
134    while let Some(word) = words.last() {
135        match word.to_ascii_lowercase().as_str() {
136            "normal" | "regular" => (),
137            "thin" => weight = FontWeight::THIN,
138            "ultralight" | "extra-light" => weight = FontWeight::EXTRA_LIGHT,
139            "light" => weight = FontWeight::LIGHT,
140            "medium" => weight = FontWeight::MEDIUM,
141            "semibold" | "demibold" | "semi-bold" => weight = FontWeight::SEMIBOLD,
142            "bold" => weight = FontWeight::BOLD,
143            "ultrabold" | "extra-bold" => weight = FontWeight::EXTRA_BOLD,
144            "heavy" | "black" => weight = FontWeight::BLACK,
145            "italic" => style = FontStyle::Italic,
146            "oblique" => style = FontStyle::Oblique,
147            "condensed" | "expanded" | "small-caps" | "ultracondensed" | "semicondensed"
148            | "ultraexpanded" | "semiexpanded" => return None,
149            _ => break,
150        }
151        words.pop();
152    }
153    let families = words.join(" ");
154    let family = families.trim_end_matches(',').trim();
155    // GPUI supports a family plus explicit fallback list, not a Pango family list.
156    // Avoid silently treating multiple families as one installed family.
157    if family.is_empty() || family.contains(',') {
158        return None;
159    }
160    let mut font = font(family.to_owned());
161    font.weight = weight;
162    font.style = style;
163    Some(ShellTypography { font, size })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    #[test]
170    fn gnome_fonts_preserve_families_units_and_common_styles() {
171        let f = ShellTypography::from_desktop_description("Adwaita Sans 11").unwrap();
172        assert_eq!(f.font.family.as_ref(), "Adwaita Sans");
173        assert!((f.size - 14.666667).abs() < 0.001);
174        let f = ShellTypography::from_desktop_description("Noto Sans Bold Italic 16px").unwrap();
175        assert_eq!(f.font.family.as_ref(), "Noto Sans");
176        assert_eq!(f.size, 16.0);
177        assert_eq!(f.font.weight, FontWeight::BOLD);
178        assert_eq!(f.font.style, FontStyle::Italic);
179        let f = ShellTypography::from_desktop_description("Bookman Light, 12").unwrap();
180        assert_eq!(f.font.family.as_ref(), "Bookman Light");
181    }
182    #[test]
183    fn kde_fonts_support_legacy_and_css_weights_and_pixel_sizes() {
184        let f =
185            ShellTypography::from_desktop_description("Noto Sans,10,-1,5,50,0,0,0,0,0").unwrap();
186        assert_eq!(f.font.weight, FontWeight::NORMAL);
187        assert!((f.size - 13.333333).abs() < 0.001);
188        let f =
189            ShellTypography::from_desktop_description("Noto Sans,-1,18,5,700,1,0,0,0,0").unwrap();
190        assert_eq!(f.size, 18.0);
191        assert_eq!(f.font.weight, FontWeight::BOLD);
192        assert_eq!(f.font.style, FontStyle::Italic);
193    }
194    #[test]
195    fn malformed_and_unsupported_descriptions_are_not_used_as_families() {
196        for value in [
197            "",
198            "Sans NaN",
199            "Sans -1",
200            "Sans 0",
201            "Sans Condensed 11",
202            "Sans 11 @wght=500",
203            "Sans,Serif 11",
204            "\nSans 11",
205            "Noto,NaN,-1,5,50,0,0,0,0,0",
206        ] {
207            assert!(
208                ShellTypography::from_desktop_description(value).is_none(),
209                "{value}"
210            );
211        }
212        let normalized = ShellTypography {
213            font: font(""),
214            size: f32::NAN,
215        }
216        .normalized();
217        assert_eq!(normalized.size, 14.0);
218        assert_eq!(normalized.font.family.as_ref(), ".SystemUIFont");
219    }
220}