sctk_adwaita/title/
ab_glyph_renderer.rs

1//! Title renderer using ab_glyph.
2//!
3//! Requires no dynamically linked dependencies.
4//!
5//! Can fallback to a embedded Cantarell-Regular.ttf font (SIL Open Font Licence v1.1)
6//! if the system font doesn't work.
7use crate::title::{config, font_preference::FontPreference};
8use ab_glyph::{point, Font, FontRef, Glyph, PxScale, PxScaleFont, ScaleFont, VariableFont};
9use std::{fs::File, process::Command};
10use tiny_skia::{Color, Pixmap, PremultipliedColorU8};
11
12const CANTARELL: &[u8] = include_bytes!("Cantarell-Regular.ttf");
13
14#[derive(Debug)]
15pub struct AbGlyphTitleText {
16    title: String,
17    font: Option<(memmap2::Mmap, FontPreference)>,
18    original_px_size: f32,
19    size: PxScale,
20    color: Color,
21    pixmap: Option<Pixmap>,
22}
23
24impl AbGlyphTitleText {
25    pub fn new(color: Color) -> Self {
26        let font_pref = config::titlebar_font().unwrap_or_default();
27        let font_pref_pt_size = font_pref.pt_size;
28        let font = font_file_matching(&font_pref)
29            .and_then(|f| mmap(&f))
30            .map(|mmap| (mmap, font_pref));
31
32        let size = parse_font(&font)
33            .pt_to_px_scale(font_pref_pt_size)
34            .unwrap_or_else(|| {
35                log::error!("invalid font units_per_em");
36                PxScale { x: 17.6, y: 17.6 }
37            });
38
39        Self {
40            title: <_>::default(),
41            font,
42            original_px_size: size.x,
43            size,
44            color,
45            pixmap: None,
46        }
47    }
48
49    pub fn update_scale(&mut self, scale: u32) {
50        let new_scale = PxScale::from(self.original_px_size * scale as f32);
51        if (self.size.x - new_scale.x).abs() > f32::EPSILON {
52            self.size = new_scale;
53            self.pixmap = self.render();
54        }
55    }
56
57    pub fn update_title(&mut self, title: impl Into<String>) {
58        let new_title = title.into();
59        if new_title != self.title {
60            self.title = new_title;
61            self.pixmap = self.render();
62        }
63    }
64
65    pub fn update_color(&mut self, color: Color) {
66        if color != self.color {
67            self.color = color;
68            self.pixmap = self.render();
69        }
70    }
71
72    pub fn pixmap(&self) -> Option<&Pixmap> {
73        self.pixmap.as_ref()
74    }
75
76    /// Render returning the new `Pixmap`.
77    fn render(&self) -> Option<Pixmap> {
78        let font = parse_font(&self.font);
79        let font = font.as_scaled(self.size);
80
81        let glyphs = self.layout(&font);
82        let last_glyph = glyphs.last()?;
83        // + 2 because ab_glyph likes to draw outside of its area,
84        // so we add 1px border around the pixmap
85        let width = (last_glyph.position.x + font.h_advance(last_glyph.id)).ceil() as u32 + 2;
86        let height = font.height().ceil() as u32 + 2;
87
88        let mut pixmap = Pixmap::new(width, height)?;
89
90        let pixels = pixmap.pixels_mut();
91
92        for glyph in glyphs {
93            if let Some(outline) = font.outline_glyph(glyph) {
94                let bounds = outline.px_bounds();
95                let left = bounds.min.x as u32;
96                let top = bounds.min.y as u32;
97                outline.draw(|x, y, c| {
98                    // `ab_glyph` may return values greater than 1.0, but they are defined to be
99                    // same as 1.0. For our purposes, we need to constrain this value.
100                    let c = c.min(1.0);
101
102                    // offset the index by 1, so it is in the center of the pixmap
103                    let p_idx = (top + y + 1) * width + (left + x + 1);
104                    let Some(pixel) = pixels.get_mut(p_idx as usize) else {
105                        log::warn!("Ignoring out of range pixel (pixel id: {p_idx}");
106                        return;
107                    };
108
109                    let old_alpha_u8 = pixel.alpha();
110
111                    let new_alpha = c + (old_alpha_u8 as f32 / 255.0);
112                    if let Some(px) = PremultipliedColorU8::from_rgba(
113                        (self.color.red() * new_alpha * 255.0) as _,
114                        (self.color.green() * new_alpha * 255.0) as _,
115                        (self.color.blue() * new_alpha * 255.0) as _,
116                        (new_alpha * 255.0) as _,
117                    ) {
118                        *pixel = px;
119                    }
120                })
121            }
122        }
123
124        Some(pixmap)
125    }
126
127    /// Simple single-line glyph layout.
128    fn layout(&self, font: &PxScaleFont<impl Font>) -> Vec<Glyph> {
129        let mut caret = point(0.0, font.ascent());
130        let mut last_glyph: Option<Glyph> = None;
131        let mut target = Vec::new();
132        for c in self.title.chars() {
133            if c.is_control() {
134                continue;
135            }
136            let mut glyph = font.scaled_glyph(c);
137            if let Some(previous) = last_glyph.take() {
138                caret.x += font.kern(previous.id, glyph.id);
139            }
140            glyph.position = caret;
141
142            last_glyph = Some(glyph.clone());
143            caret.x += font.h_advance(glyph.id);
144
145            target.push(glyph);
146        }
147        target
148    }
149}
150
151/// Parse the memmapped system font or fallback to built-in cantarell.
152fn parse_font(sys_font: &Option<(memmap2::Mmap, FontPreference)>) -> FontRef<'_> {
153    match sys_font {
154        Some((mmap, font_pref)) => {
155            FontRef::try_from_slice(mmap)
156                .map(|mut f| {
157                    // basic "bold" handling for variable fonts
158                    if font_pref
159                        .style
160                        .as_deref()
161                        .map_or(false, |s| s.eq_ignore_ascii_case("bold"))
162                    {
163                        f.set_variation(b"wght", 700.0);
164                    }
165                    f
166                })
167                .unwrap_or_else(|_| {
168                    // We control the default font, so I guess it's fine to unwrap it
169                    #[allow(clippy::unwrap_used)]
170                    FontRef::try_from_slice(CANTARELL).unwrap()
171                })
172        }
173        // We control the default font, so I guess it's fine to unwrap it
174        #[allow(clippy::unwrap_used)]
175        _ => FontRef::try_from_slice(CANTARELL).unwrap(),
176    }
177}
178
179/// Font-config without dynamically linked dependencies
180fn font_file_matching(pref: &FontPreference) -> Option<File> {
181    let mut pattern = pref.name.clone();
182    if let Some(style) = &pref.style {
183        pattern.push(':');
184        pattern.push_str(style);
185    }
186    Command::new("fc-match")
187        .arg("-f")
188        .arg("%{file}")
189        .arg(&pattern)
190        .output()
191        .ok()
192        .and_then(|out| String::from_utf8(out.stdout).ok())
193        .and_then(|path| File::open(path.trim()).ok())
194}
195
196fn mmap(file: &File) -> Option<memmap2::Mmap> {
197    // Safety: System font files are not expected to be mutated during use
198    unsafe { memmap2::Mmap::map(file).ok() }
199}