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 mut image = Render::new(&sources)
177            .format(render_format)
178            .render(&mut scaler, glyph_id)?;
179
180        // Detect degenerate outlines: some fonts (e.g., Apple Color Emoji) have charmap
181        // entries but produce empty outlines (all-zero alpha) when the font only has
182        // bitmap data (sbix).
183        if matches!(image.content, Content::Mask) && image.data.iter().all(|&b| b == 0) {
184            if force_monochrome {
185                // For monochrome symbol rendering, don't fall back to color bitmaps.
186                // Return None so the caller can try the next font in the fallback
187                // chain. If no text font has the character, the caller's last resort
188                // will retry with force_monochrome=false to get colored emoji.
189                return None;
190            }
191            // For normal (non-monochrome) rendering, try color bitmap sources.
192            let mut retry_ctx = ScaleContext::new();
193            let mut retry_scaler = retry_ctx
194                .builder(*font)
195                .size(self.font_size_pixels)
196                .hint(self.font_hinting)
197                .build();
198            let color_sources = [
199                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
200                swash::scale::Source::ColorOutline(0),
201            ];
202            if let Some(color_image) = Render::new(&color_sources)
203                .format(render_format)
204                .render(&mut retry_scaler, glyph_id)
205            {
206                image = color_image;
207            } else {
208                return None;
209            }
210        }
211
212        let (pixels, is_colored) = match image.content {
213            Content::Color => {
214                if force_monochrome {
215                    // Convert color emoji to monochrome using the original alpha channel.
216                    // This is more accurate than luminance-based conversion: colored
217                    // symbols (e.g., yellow ⭐, colored ✨) keep full opacity, while
218                    // luminance would make non-white colors appear faint.
219                    let pixels = convert_color_to_alpha_mask(&image);
220                    (pixels, false)
221                } else {
222                    (image.data.clone(), true)
223                }
224            }
225            Content::Mask => {
226                let mut pixels = Vec::with_capacity(image.data.len() * 4);
227                for &mask in &image.data {
228                    // If anti-aliasing is disabled, threshold the alpha to create crisp edges
229                    let alpha = if !self.font_antialias {
230                        if mask > 127 { 255 } else { 0 }
231                    } else {
232                        mask
233                    };
234                    pixels.extend_from_slice(&[255, 255, 255, alpha]);
235                }
236                (pixels, false)
237            }
238            Content::SubpixelMask => {
239                let pixels = convert_subpixel_mask_to_rgba(&image);
240                (pixels, false)
241            }
242        };
243
244        // Final check: reject glyphs that are still all-transparent after processing.
245        // This catches cases where even color bitmap conversion produced no visible pixels.
246        if !is_colored && pixels.iter().skip(3).step_by(4).all(|&a| a == 0) {
247            return None;
248        }
249
250        Some(RasterizedGlyph {
251            width: image.placement.width,
252            height: image.placement.height,
253            bearing_x: image.placement.left as f32,
254            bearing_y: image.placement.top as f32,
255            pixels,
256            is_colored,
257        })
258    }
259
260    pub(crate) fn upload_glyph(&mut self, _key: u64, raster: &RasterizedGlyph) -> GlyphInfo {
261        let padding = 2;
262        if self.atlas_next_x + raster.width + padding > 2048 {
263            self.atlas_next_x = 0;
264            self.atlas_next_y += self.atlas_row_height + padding;
265            self.atlas_row_height = 0;
266        }
267
268        if self.atlas_next_y + raster.height + padding > 2048 {
269            self.clear_glyph_cache();
270        }
271
272        let info = GlyphInfo {
273            key: _key,
274            x: self.atlas_next_x,
275            y: self.atlas_next_y,
276            width: raster.width,
277            height: raster.height,
278            bearing_x: raster.bearing_x,
279            bearing_y: raster.bearing_y,
280            is_colored: raster.is_colored,
281            prev: None,
282            next: None,
283        };
284
285        self.queue.write_texture(
286            wgpu::TexelCopyTextureInfo {
287                texture: &self.atlas_texture,
288                mip_level: 0,
289                origin: wgpu::Origin3d {
290                    x: info.x,
291                    y: info.y,
292                    z: 0,
293                },
294                aspect: wgpu::TextureAspect::All,
295            },
296            &raster.pixels,
297            wgpu::TexelCopyBufferLayout {
298                offset: 0,
299                bytes_per_row: Some(4 * raster.width),
300                rows_per_image: Some(raster.height),
301            },
302            wgpu::Extent3d {
303                width: raster.width,
304                height: raster.height,
305                depth_or_array_layers: 1,
306            },
307        );
308
309        // Clear the padding strips with transparent black so bilinear sampling at glyph
310        // edges never bleeds into stale data from previously evicted glyphs.
311        let pad_right_x = info.x + raster.width;
312        let pad_bottom_y = info.y + raster.height;
313
314        // Right border: `padding` columns × glyph height
315        if pad_right_x + padding <= 2048 && raster.height > 0 {
316            let zero = vec![0u8; (padding * raster.height * 4) as usize];
317            self.queue.write_texture(
318                wgpu::TexelCopyTextureInfo {
319                    texture: &self.atlas_texture,
320                    mip_level: 0,
321                    origin: wgpu::Origin3d {
322                        x: pad_right_x,
323                        y: info.y,
324                        z: 0,
325                    },
326                    aspect: wgpu::TextureAspect::All,
327                },
328                &zero,
329                wgpu::TexelCopyBufferLayout {
330                    offset: 0,
331                    bytes_per_row: Some(padding * 4),
332                    rows_per_image: Some(raster.height),
333                },
334                wgpu::Extent3d {
335                    width: padding,
336                    height: raster.height,
337                    depth_or_array_layers: 1,
338                },
339            );
340        }
341
342        // Bottom border: glyph width × `padding` rows
343        if pad_bottom_y + padding <= 2048 && raster.width > 0 {
344            let zero = vec![0u8; (raster.width * padding * 4) as usize];
345            self.queue.write_texture(
346                wgpu::TexelCopyTextureInfo {
347                    texture: &self.atlas_texture,
348                    mip_level: 0,
349                    origin: wgpu::Origin3d {
350                        x: info.x,
351                        y: pad_bottom_y,
352                        z: 0,
353                    },
354                    aspect: wgpu::TextureAspect::All,
355                },
356                &zero,
357                wgpu::TexelCopyBufferLayout {
358                    offset: 0,
359                    bytes_per_row: Some(raster.width * 4),
360                    rows_per_image: Some(padding),
361                },
362                wgpu::Extent3d {
363                    width: raster.width,
364                    height: padding,
365                    depth_or_array_layers: 1,
366                },
367            );
368        }
369
370        self.atlas_next_x += raster.width + padding;
371        self.atlas_row_height = self.atlas_row_height.max(raster.height);
372
373        info
374    }
375}
376
377/// Convert a swash subpixel mask into an RGBA alpha mask.
378/// Some swash builds emit 3 bytes/pixel (RGB), others 4 bytes/pixel (RGBA).
379/// We derive alpha from luminance of RGB and ignore the packed alpha to avoid
380/// dropping coverage when alpha is zeroed by the rasterizer.
381fn convert_subpixel_mask_to_rgba(image: &swash::scale::image::Image) -> Vec<u8> {
382    let width = image.placement.width as usize;
383    let height = image.placement.height as usize;
384    let mut pixels = Vec::with_capacity(width * height * 4);
385
386    let stride = if width > 0 && height > 0 {
387        image.data.len() / (width * height)
388    } else {
389        0
390    };
391
392    match stride {
393        3 => {
394            for chunk in image.data.chunks_exact(3) {
395                let r = chunk[0];
396                let g = chunk[1];
397                let b = chunk[2];
398                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
399                pixels.extend_from_slice(&[255, 255, 255, alpha]);
400            }
401        }
402        4 => {
403            for chunk in image.data.chunks_exact(4) {
404                let r = chunk[0];
405                let g = chunk[1];
406                let b = chunk[2];
407                // Ignore chunk[3] because it can be zeroed in some builds.
408                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
409                pixels.extend_from_slice(&[255, 255, 255, alpha]);
410            }
411        }
412        _ => {
413            // Fallback: treat as opaque white to avoid invisibility if layout changes.
414            pixels.resize(width * height * 4, 255);
415        }
416    }
417
418    pixels
419}
420
421/// Convert a color RGBA image to a monochrome alpha mask.
422///
423/// This is used when a symbol character (like dingbats ✨ ⭐ ✔) is rendered
424/// from a color emoji font but should be displayed as a monochrome glyph
425/// using the terminal foreground color.
426///
427/// Uses the original alpha channel directly rather than luminance-derived alpha.
428/// This produces more accurate results: colored symbols (e.g., yellow ⭐,
429/// multi-colored ✨) retain full opacity, whereas luminance-based conversion
430/// makes non-white colors appear faint (a red symbol would only be ~30% visible).
431fn convert_color_to_alpha_mask(image: &swash::scale::image::Image) -> Vec<u8> {
432    let width = image.placement.width as usize;
433    let height = image.placement.height as usize;
434    let mut pixels = Vec::with_capacity(width * height * 4);
435
436    // Color emoji images are RGBA (4 bytes per pixel).
437    // Use the original alpha channel directly to preserve the symbol shape.
438    for chunk in image.data.chunks_exact(4) {
439        let a = chunk[3];
440        pixels.extend_from_slice(&[255, 255, 255, a]);
441    }
442
443    pixels
444}
445
446#[cfg(test)]
447mod tests {
448    use super::convert_subpixel_mask_to_rgba;
449    use swash::scale::{Render, ScaleContext, Source};
450    use swash::zeno::Format;
451
452    #[test]
453    fn subpixel_mask_uses_rgba_stride() {
454        let data = std::fs::read("../par-term-fonts/fonts/DejaVuSansMono.ttf").expect("font file");
455        let font = swash::FontRef::from_index(&data, 0).expect("font ref");
456        let mut context = ScaleContext::new();
457        let glyph_id = font.charmap().map('a');
458        let mut scaler = context.builder(font).size(18.0).hint(true).build();
459
460        let image = Render::new(&[
461            Source::ColorOutline(0),
462            Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
463            Source::Outline,
464            Source::Bitmap(swash::scale::StrikeWith::BestFit),
465        ])
466        .format(Format::Subpixel)
467        .render(&mut scaler, glyph_id)
468        .expect("render");
469
470        let converted = convert_subpixel_mask_to_rgba(&image);
471
472        let width = image.placement.width as usize;
473        let height = image.placement.height as usize;
474        let mut expected = Vec::with_capacity(width * height * 4);
475        let stride = if width > 0 && height > 0 {
476            image.data.len() / (width * height)
477        } else {
478            0
479        };
480
481        match stride {
482            3 => {
483                for chunk in image.data.chunks_exact(3) {
484                    let r = chunk[0];
485                    let g = chunk[1];
486                    let b = chunk[2];
487                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
488                    expected.extend_from_slice(&[255, 255, 255, alpha]);
489                }
490            }
491            4 => {
492                for chunk in image.data.chunks_exact(4) {
493                    let r = chunk[0];
494                    let g = chunk[1];
495                    let b = chunk[2];
496                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
497                    expected.extend_from_slice(&[255, 255, 255, alpha]);
498                }
499            }
500            _ => expected.resize(width * height * 4, 255),
501        }
502
503        assert_eq!(converted, expected);
504    }
505
506    use super::should_render_as_symbol;
507
508    #[test]
509    fn test_dingbats_are_symbols() {
510        // Dingbats block (U+2700-U+27BF)
511        assert!(
512            should_render_as_symbol('\u{2733}'),
513            "✳ EIGHT SPOKED ASTERISK"
514        );
515        assert!(
516            should_render_as_symbol('\u{2734}'),
517            "✴ EIGHT POINTED BLACK STAR"
518        );
519        assert!(should_render_as_symbol('\u{2747}'), "❇ SPARKLE");
520        assert!(should_render_as_symbol('\u{2744}'), "❄ SNOWFLAKE");
521        assert!(should_render_as_symbol('\u{2702}'), "✂ SCISSORS");
522        assert!(should_render_as_symbol('\u{2714}'), "✔ HEAVY CHECK MARK");
523        assert!(
524            should_render_as_symbol('\u{2716}'),
525            "✖ HEAVY MULTIPLICATION X"
526        );
527        assert!(should_render_as_symbol('\u{2728}'), "✨ SPARKLES");
528    }
529
530    #[test]
531    fn test_misc_symbols_are_symbols() {
532        // Miscellaneous Symbols block (U+2600-U+26FF)
533        assert!(should_render_as_symbol('\u{2600}'), "☀ SUN");
534        assert!(should_render_as_symbol('\u{2601}'), "☁ CLOUD");
535        assert!(should_render_as_symbol('\u{263A}'), "☺ SMILING FACE");
536        assert!(should_render_as_symbol('\u{2665}'), "♥ BLACK HEART SUIT");
537        assert!(should_render_as_symbol('\u{2660}'), "♠ BLACK SPADE SUIT");
538    }
539
540    #[test]
541    fn test_misc_symbols_arrows_are_symbols() {
542        // Miscellaneous Symbols and Arrows block (U+2B00-U+2BFF)
543        assert!(should_render_as_symbol('\u{2B50}'), "⭐ WHITE MEDIUM STAR");
544        assert!(should_render_as_symbol('\u{2B55}'), "⭕ HEAVY LARGE CIRCLE");
545    }
546
547    #[test]
548    fn test_regular_emoji_not_symbols() {
549        // Full emoji characters (outside symbol ranges) should NOT be treated as symbols
550        // They should render as colorful emoji
551        assert!(
552            !should_render_as_symbol('\u{1F600}'),
553            "😀 GRINNING FACE should not be a symbol"
554        );
555        assert!(
556            !should_render_as_symbol('\u{1F389}'),
557            "🎉 PARTY POPPER should not be a symbol"
558        );
559        assert!(
560            !should_render_as_symbol('\u{1F44D}'),
561            "👍 THUMBS UP should not be a symbol"
562        );
563    }
564
565    #[test]
566    fn test_regular_chars_not_symbols() {
567        // Regular text characters should NOT be treated as symbols
568        assert!(
569            !should_render_as_symbol('A'),
570            "Letter A should not be a symbol"
571        );
572        assert!(
573            !should_render_as_symbol('*'),
574            "Asterisk should not be a symbol (it's ASCII)"
575        );
576        assert!(
577            !should_render_as_symbol('1'),
578            "Digit 1 should not be a symbol"
579        );
580    }
581}