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