Skip to main content

par_term_render/cell_renderer/
atlas.rs

1use super::{CellRenderer, GlyphInfo};
2
3pub(crate) struct RasterizedGlyph {
4    pub width: u32,
5    pub height: u32,
6    #[allow(dead_code)]
7    pub bearing_x: f32,
8    #[allow(dead_code)]
9    pub bearing_y: f32,
10    pub pixels: Vec<u8>,
11    pub is_colored: bool,
12}
13
14/// Unicode ranges for symbols that should render monochromatically.
15/// These characters have emoji default presentation but are commonly used
16/// as symbols in terminal contexts (spinners, decorations, etc.) and should
17/// use the terminal foreground color rather than colorful emoji rendering.
18pub mod symbol_ranges {
19    /// Dingbats block (U+2700–U+27BF)
20    /// Contains asterisks, stars, sparkles, arrows, etc.
21    pub const DINGBATS_START: u32 = 0x2700;
22    pub const DINGBATS_END: u32 = 0x27BF;
23
24    /// Miscellaneous Symbols (U+2600–U+26FF)
25    /// Contains weather, zodiac, chess, etc.
26    pub const MISC_SYMBOLS_START: u32 = 0x2600;
27    pub const MISC_SYMBOLS_END: u32 = 0x26FF;
28
29    /// Miscellaneous Symbols and Arrows (U+2B00–U+2BFF)
30    /// Contains various arrows and stars like ⭐
31    pub const MISC_SYMBOLS_ARROWS_START: u32 = 0x2B00;
32    pub const MISC_SYMBOLS_ARROWS_END: u32 = 0x2BFF;
33}
34
35/// Check if a character should be rendered as a monochrome symbol.
36///
37/// Returns true for characters that:
38/// 1. Are in symbol/dingbat Unicode ranges
39/// 2. Have emoji default presentation but are commonly used as symbols
40///
41/// These characters should use the terminal foreground color rather than
42/// colorful emoji bitmaps, even if the emoji font provides a colored glyph.
43pub fn should_render_as_symbol(ch: char) -> bool {
44    let code = ch as u32;
45
46    // Dingbats (U+2700–U+27BF)
47    if (symbol_ranges::DINGBATS_START..=symbol_ranges::DINGBATS_END).contains(&code) {
48        return true;
49    }
50
51    // Miscellaneous Symbols (U+2600–U+26FF)
52    if (symbol_ranges::MISC_SYMBOLS_START..=symbol_ranges::MISC_SYMBOLS_END).contains(&code) {
53        return true;
54    }
55
56    // Miscellaneous Symbols and Arrows (U+2B00–U+2BFF)
57    if (symbol_ranges::MISC_SYMBOLS_ARROWS_START..=symbol_ranges::MISC_SYMBOLS_ARROWS_END)
58        .contains(&code)
59    {
60        return true;
61    }
62
63    false
64}
65
66impl CellRenderer {
67    pub fn clear_glyph_cache(&mut self) {
68        self.glyph_cache.clear();
69        self.lru_head = None;
70        self.lru_tail = None;
71        self.atlas_next_x = 0;
72        self.atlas_next_y = 0;
73        self.atlas_row_height = 0;
74        self.dirty_rows.fill(true);
75        // Re-upload the solid white pixel for geometric block rendering
76        self.upload_solid_pixel();
77    }
78
79    pub(crate) fn lru_remove(&mut self, key: u64) {
80        let info = self.glyph_cache.get(&key).unwrap();
81        let prev = info.prev;
82        let next = info.next;
83
84        if let Some(p) = prev {
85            self.glyph_cache.get_mut(&p).unwrap().next = next;
86        } else {
87            self.lru_head = next;
88        }
89
90        if let Some(n) = next {
91            self.glyph_cache.get_mut(&n).unwrap().prev = prev;
92        } else {
93            self.lru_tail = prev;
94        }
95    }
96
97    pub(crate) fn lru_push_front(&mut self, key: u64) {
98        let next = self.lru_head;
99        if let Some(n) = next {
100            self.glyph_cache.get_mut(&n).unwrap().prev = Some(key);
101        } else {
102            self.lru_tail = Some(key);
103        }
104
105        let info = self.glyph_cache.get_mut(&key).unwrap();
106        info.prev = None;
107        info.next = next;
108        self.lru_head = Some(key);
109    }
110
111    pub(crate) fn rasterize_glyph(
112        &self,
113        font_idx: usize,
114        glyph_id: u16,
115        force_monochrome: bool,
116    ) -> Option<RasterizedGlyph> {
117        let font = self.font_manager.get_font(font_idx)?;
118        // Use swash to rasterize
119        use swash::scale::image::Content;
120        use swash::scale::{Render, ScaleContext};
121        use swash::zeno::Format;
122
123        let mut context = ScaleContext::new();
124
125        // Apply hinting based on config setting
126        let mut scaler = context
127            .builder(*font)
128            .size(self.font_size_pixels)
129            .hint(self.font_hinting)
130            .build();
131
132        // Determine render format based on anti-aliasing and thin strokes settings
133        let use_thin_strokes = self.should_use_thin_strokes();
134        let render_format = if !self.font_antialias {
135            // No anti-aliasing: render as alpha mask (will be thresholded)
136            Format::Alpha
137        } else if use_thin_strokes {
138            // Thin strokes: use subpixel rendering for lighter appearance
139            Format::Subpixel
140        } else {
141            // Standard anti-aliased rendering
142            Format::Alpha
143        };
144
145        // For symbol characters (dingbats, etc.), prefer outline rendering to get
146        // monochrome glyphs that use the terminal foreground color. For emoji,
147        // prefer color bitmaps for proper colorful rendering.
148        let sources = if force_monochrome {
149            // Symbol character: try outline first, fall back to color if unavailable
150            // This ensures dingbats like ✳ ✴ ❇ render as monochrome symbols
151            // with the terminal foreground color, not as colorful emoji.
152            [
153                swash::scale::Source::Outline,
154                swash::scale::Source::ColorOutline(0),
155                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
156            ]
157        } else {
158            // Regular emoji: prefer color sources for colorful rendering
159            [
160                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
161                swash::scale::Source::ColorOutline(0),
162                swash::scale::Source::Outline,
163            ]
164        };
165
166        let image = Render::new(&sources)
167            .format(render_format)
168            .render(&mut scaler, glyph_id)?;
169
170        let (pixels, is_colored) = match image.content {
171            Content::Color => {
172                // If this is a symbol character that should be monochrome,
173                // convert the color image to a monochrome alpha mask using
174                // luminance as the alpha channel.
175                if force_monochrome {
176                    let pixels = convert_color_to_alpha_mask(&image);
177                    (pixels, false)
178                } else {
179                    (image.data.clone(), true)
180                }
181            }
182            Content::Mask => {
183                let mut pixels = Vec::with_capacity(image.data.len() * 4);
184                for &mask in &image.data {
185                    // If anti-aliasing is disabled, threshold the alpha to create crisp edges
186                    let alpha = if !self.font_antialias {
187                        if mask > 127 { 255 } else { 0 }
188                    } else {
189                        mask
190                    };
191                    pixels.extend_from_slice(&[255, 255, 255, alpha]);
192                }
193                (pixels, false)
194            }
195            Content::SubpixelMask => {
196                let pixels = convert_subpixel_mask_to_rgba(&image);
197                (pixels, false)
198            }
199        };
200
201        Some(RasterizedGlyph {
202            width: image.placement.width,
203            height: image.placement.height,
204            bearing_x: image.placement.left as f32,
205            bearing_y: image.placement.top as f32,
206            pixels,
207            is_colored,
208        })
209    }
210
211    pub(crate) fn upload_glyph(&mut self, _key: u64, raster: &RasterizedGlyph) -> GlyphInfo {
212        let padding = 2;
213        if self.atlas_next_x + raster.width + padding > 2048 {
214            self.atlas_next_x = 0;
215            self.atlas_next_y += self.atlas_row_height + padding;
216            self.atlas_row_height = 0;
217        }
218
219        if self.atlas_next_y + raster.height + padding > 2048 {
220            self.clear_glyph_cache();
221        }
222
223        let info = GlyphInfo {
224            key: _key,
225            x: self.atlas_next_x,
226            y: self.atlas_next_y,
227            width: raster.width,
228            height: raster.height,
229            bearing_x: raster.bearing_x,
230            bearing_y: raster.bearing_y,
231            is_colored: raster.is_colored,
232            prev: None,
233            next: None,
234        };
235
236        self.queue.write_texture(
237            wgpu::TexelCopyTextureInfo {
238                texture: &self.atlas_texture,
239                mip_level: 0,
240                origin: wgpu::Origin3d {
241                    x: info.x,
242                    y: info.y,
243                    z: 0,
244                },
245                aspect: wgpu::TextureAspect::All,
246            },
247            &raster.pixels,
248            wgpu::TexelCopyBufferLayout {
249                offset: 0,
250                bytes_per_row: Some(4 * raster.width),
251                rows_per_image: Some(raster.height),
252            },
253            wgpu::Extent3d {
254                width: raster.width,
255                height: raster.height,
256                depth_or_array_layers: 1,
257            },
258        );
259
260        self.atlas_next_x += raster.width + padding;
261        self.atlas_row_height = self.atlas_row_height.max(raster.height);
262
263        info
264    }
265}
266
267/// Convert a swash subpixel mask into an RGBA alpha mask.
268/// Some swash builds emit 3 bytes/pixel (RGB), others 4 bytes/pixel (RGBA).
269/// We derive alpha from luminance of RGB and ignore the packed alpha to avoid
270/// dropping coverage when alpha is zeroed by the rasterizer.
271fn convert_subpixel_mask_to_rgba(image: &swash::scale::image::Image) -> Vec<u8> {
272    let width = image.placement.width as usize;
273    let height = image.placement.height as usize;
274    let mut pixels = Vec::with_capacity(width * height * 4);
275
276    let stride = if width > 0 && height > 0 {
277        image.data.len() / (width * height)
278    } else {
279        0
280    };
281
282    match stride {
283        3 => {
284            for chunk in image.data.chunks_exact(3) {
285                let r = chunk[0];
286                let g = chunk[1];
287                let b = chunk[2];
288                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
289                pixels.extend_from_slice(&[255, 255, 255, alpha]);
290            }
291        }
292        4 => {
293            for chunk in image.data.chunks_exact(4) {
294                let r = chunk[0];
295                let g = chunk[1];
296                let b = chunk[2];
297                // Ignore chunk[3] because it can be zeroed in some builds.
298                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
299                pixels.extend_from_slice(&[255, 255, 255, alpha]);
300            }
301        }
302        _ => {
303            // Fallback: treat as opaque white to avoid invisibility if layout changes.
304            pixels.resize(width * height * 4, 255);
305        }
306    }
307
308    pixels
309}
310
311/// Convert a color RGBA image to a monochrome alpha mask.
312///
313/// This is used when a symbol character (like dingbats ✳ ✴ ❇) is rendered
314/// from a color emoji font but should be displayed as a monochrome glyph
315/// using the terminal foreground color.
316///
317/// The alpha channel is derived from the luminance of the original color,
318/// preserving the shape while discarding the color information.
319fn convert_color_to_alpha_mask(image: &swash::scale::image::Image) -> Vec<u8> {
320    let width = image.placement.width as usize;
321    let height = image.placement.height as usize;
322    let mut pixels = Vec::with_capacity(width * height * 4);
323
324    // Color emoji images are typically RGBA (4 bytes per pixel)
325    for chunk in image.data.chunks_exact(4) {
326        let r = chunk[0];
327        let g = chunk[1];
328        let b = chunk[2];
329        let a = chunk[3];
330
331        // Use luminance as alpha, multiplied by original alpha
332        // This preserves the shape of colored emoji while making them monochrome
333        let luminance = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
334        // Blend luminance with original alpha
335        let alpha = ((luminance as u32 * a as u32) / 255) as u8;
336
337        pixels.extend_from_slice(&[255, 255, 255, alpha]);
338    }
339
340    pixels
341}
342
343#[cfg(test)]
344mod tests {
345    use super::convert_subpixel_mask_to_rgba;
346    use swash::scale::{Render, ScaleContext, Source};
347    use swash::zeno::Format;
348
349    #[test]
350    fn subpixel_mask_uses_rgba_stride() {
351        let data = std::fs::read("../par-term-fonts/fonts/DejaVuSansMono.ttf").expect("font file");
352        let font = swash::FontRef::from_index(&data, 0).expect("font ref");
353        let mut context = ScaleContext::new();
354        let glyph_id = font.charmap().map('a');
355        let mut scaler = context.builder(font).size(18.0).hint(true).build();
356
357        let image = Render::new(&[
358            Source::ColorOutline(0),
359            Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
360            Source::Outline,
361            Source::Bitmap(swash::scale::StrikeWith::BestFit),
362        ])
363        .format(Format::Subpixel)
364        .render(&mut scaler, glyph_id)
365        .expect("render");
366
367        let converted = convert_subpixel_mask_to_rgba(&image);
368
369        let width = image.placement.width as usize;
370        let height = image.placement.height as usize;
371        let mut expected = Vec::with_capacity(width * height * 4);
372        let stride = if width > 0 && height > 0 {
373            image.data.len() / (width * height)
374        } else {
375            0
376        };
377
378        match stride {
379            3 => {
380                for chunk in image.data.chunks_exact(3) {
381                    let r = chunk[0];
382                    let g = chunk[1];
383                    let b = chunk[2];
384                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
385                    expected.extend_from_slice(&[255, 255, 255, alpha]);
386                }
387            }
388            4 => {
389                for chunk in image.data.chunks_exact(4) {
390                    let r = chunk[0];
391                    let g = chunk[1];
392                    let b = chunk[2];
393                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
394                    expected.extend_from_slice(&[255, 255, 255, alpha]);
395                }
396            }
397            _ => expected.resize(width * height * 4, 255),
398        }
399
400        assert_eq!(converted, expected);
401    }
402
403    use super::should_render_as_symbol;
404
405    #[test]
406    fn test_dingbats_are_symbols() {
407        // Dingbats block (U+2700-U+27BF)
408        assert!(
409            should_render_as_symbol('\u{2733}'),
410            "✳ EIGHT SPOKED ASTERISK"
411        );
412        assert!(
413            should_render_as_symbol('\u{2734}'),
414            "✴ EIGHT POINTED BLACK STAR"
415        );
416        assert!(should_render_as_symbol('\u{2747}'), "❇ SPARKLE");
417        assert!(should_render_as_symbol('\u{2744}'), "❄ SNOWFLAKE");
418        assert!(should_render_as_symbol('\u{2702}'), "✂ SCISSORS");
419        assert!(should_render_as_symbol('\u{2714}'), "✔ HEAVY CHECK MARK");
420        assert!(
421            should_render_as_symbol('\u{2716}'),
422            "✖ HEAVY MULTIPLICATION X"
423        );
424        assert!(should_render_as_symbol('\u{2728}'), "✨ SPARKLES");
425    }
426
427    #[test]
428    fn test_misc_symbols_are_symbols() {
429        // Miscellaneous Symbols block (U+2600-U+26FF)
430        assert!(should_render_as_symbol('\u{2600}'), "☀ SUN");
431        assert!(should_render_as_symbol('\u{2601}'), "☁ CLOUD");
432        assert!(should_render_as_symbol('\u{263A}'), "☺ SMILING FACE");
433        assert!(should_render_as_symbol('\u{2665}'), "♥ BLACK HEART SUIT");
434        assert!(should_render_as_symbol('\u{2660}'), "♠ BLACK SPADE SUIT");
435    }
436
437    #[test]
438    fn test_misc_symbols_arrows_are_symbols() {
439        // Miscellaneous Symbols and Arrows block (U+2B00-U+2BFF)
440        assert!(should_render_as_symbol('\u{2B50}'), "⭐ WHITE MEDIUM STAR");
441        assert!(should_render_as_symbol('\u{2B55}'), "⭕ HEAVY LARGE CIRCLE");
442    }
443
444    #[test]
445    fn test_regular_emoji_not_symbols() {
446        // Full emoji characters (outside symbol ranges) should NOT be treated as symbols
447        // They should render as colorful emoji
448        assert!(
449            !should_render_as_symbol('\u{1F600}'),
450            "😀 GRINNING FACE should not be a symbol"
451        );
452        assert!(
453            !should_render_as_symbol('\u{1F389}'),
454            "🎉 PARTY POPPER should not be a symbol"
455        );
456        assert!(
457            !should_render_as_symbol('\u{1F44D}'),
458            "👍 THUMBS UP should not be a symbol"
459        );
460    }
461
462    #[test]
463    fn test_regular_chars_not_symbols() {
464        // Regular text characters should NOT be treated as symbols
465        assert!(
466            !should_render_as_symbol('A'),
467            "Letter A should not be a symbol"
468        );
469        assert!(
470            !should_render_as_symbol('*'),
471            "Asterisk should not be a symbol (it's ASCII)"
472        );
473        assert!(
474            !should_render_as_symbol('1'),
475            "Digit 1 should not be a symbol"
476        );
477    }
478}