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    pub bearing_x: f32,
7    pub bearing_y: f32,
8    pub pixels: Vec<u8>,
9    pub is_colored: bool,
10}
11
12/// Unicode ranges for symbols that should render monochromatically.
13/// These characters have emoji default presentation but are commonly used
14/// as symbols in terminal contexts (spinners, decorations, etc.) and should
15/// use the terminal foreground color rather than colorful emoji rendering.
16pub mod symbol_ranges {
17    /// Dingbats block (U+2700–U+27BF)
18    /// Contains asterisks, stars, sparkles, arrows, etc.
19    pub const DINGBATS_START: u32 = 0x2700;
20    pub const DINGBATS_END: u32 = 0x27BF;
21
22    /// Miscellaneous Symbols (U+2600–U+26FF)
23    /// Contains weather, zodiac, chess, etc.
24    pub const MISC_SYMBOLS_START: u32 = 0x2600;
25    pub const MISC_SYMBOLS_END: u32 = 0x26FF;
26
27    /// Miscellaneous Technical (U+2300–U+23FF)
28    /// Contains media controls (⏩⏪⏸⏹⏺), hourglass (⌛), power symbols, etc.
29    pub const MISC_TECHNICAL_START: u32 = 0x2300;
30    pub const MISC_TECHNICAL_END: u32 = 0x23FF;
31
32    /// Miscellaneous Symbols and Arrows (U+2B00–U+2BFF)
33    /// Contains various arrows and stars like ⭐
34    pub const MISC_SYMBOLS_ARROWS_START: u32 = 0x2B00;
35    pub const MISC_SYMBOLS_ARROWS_END: u32 = 0x2BFF;
36}
37
38/// Check if a character should be rendered as a monochrome symbol.
39///
40/// Returns true for characters that:
41/// 1. Are in symbol/dingbat Unicode ranges
42/// 2. Have emoji default presentation but are commonly used as symbols
43///
44/// These characters should use the terminal foreground color rather than
45/// colorful emoji bitmaps, even if the emoji font provides a colored glyph.
46pub fn should_render_as_symbol(ch: char) -> bool {
47    let code = ch as u32;
48
49    // Miscellaneous Technical (U+2300–U+23FF)
50    if (symbol_ranges::MISC_TECHNICAL_START..=symbol_ranges::MISC_TECHNICAL_END).contains(&code) {
51        return true;
52    }
53
54    // Miscellaneous Symbols (U+2600–U+26FF)
55    if (symbol_ranges::MISC_SYMBOLS_START..=symbol_ranges::MISC_SYMBOLS_END).contains(&code) {
56        return true;
57    }
58
59    // Dingbats (U+2700–U+27BF)
60    if (symbol_ranges::DINGBATS_START..=symbol_ranges::DINGBATS_END).contains(&code) {
61        return true;
62    }
63
64    // Miscellaneous Symbols and Arrows (U+2B00–U+2BFF)
65    if (symbol_ranges::MISC_SYMBOLS_ARROWS_START..=symbol_ranges::MISC_SYMBOLS_ARROWS_END)
66        .contains(&code)
67    {
68        return true;
69    }
70
71    false
72}
73
74impl CellRenderer {
75    pub fn clear_glyph_cache(&mut self) {
76        self.atlas.glyph_cache.clear();
77        self.atlas.lru_head = None;
78        self.atlas.lru_tail = None;
79        self.atlas.atlas_next_x = 0;
80        self.atlas.atlas_next_y = 0;
81        self.atlas.atlas_row_height = 0;
82        self.dirty_rows.fill(true);
83        // Re-upload the solid white pixel for geometric block rendering
84        self.upload_solid_pixel();
85    }
86
87    pub(crate) fn lru_remove(&mut self, key: u64) {
88        let info = self
89            .atlas
90            .glyph_cache
91            .get(&key)
92            .expect("Glyph cache entry must exist before calling lru_remove");
93        let prev = info.prev;
94        let next = info.next;
95
96        if let Some(p) = prev {
97            self.atlas
98                .glyph_cache
99                .get_mut(&p)
100                .expect("Glyph cache LRU prev entry must exist")
101                .next = next;
102        } else {
103            self.atlas.lru_head = next;
104        }
105
106        if let Some(n) = next {
107            self.atlas
108                .glyph_cache
109                .get_mut(&n)
110                .expect("Glyph cache LRU next entry must exist")
111                .prev = prev;
112        } else {
113            self.atlas.lru_tail = prev;
114        }
115    }
116
117    pub(crate) fn lru_push_front(&mut self, key: u64) {
118        let next = self.atlas.lru_head;
119        if let Some(n) = next {
120            self.atlas
121                .glyph_cache
122                .get_mut(&n)
123                .expect("Glyph cache LRU head entry must exist")
124                .prev = Some(key);
125        } else {
126            self.atlas.lru_tail = Some(key);
127        }
128
129        let info = self
130            .atlas
131            .glyph_cache
132            .get_mut(&key)
133            .expect("Glyph cache entry must exist before calling lru_push_front");
134        info.prev = None;
135        info.next = next;
136        self.atlas.lru_head = Some(key);
137    }
138
139    pub(crate) fn rasterize_glyph(
140        &mut self,
141        font_idx: usize,
142        glyph_id: u16,
143        force_monochrome: bool,
144    ) -> Option<RasterizedGlyph> {
145        let font = self.font_manager.get_font(font_idx)?;
146        // Use swash to rasterize
147        use swash::scale::Render;
148        use swash::scale::image::Content;
149        use swash::zeno::Format;
150
151        // Determine render format before creating the scaler so there is no live
152        // mutable borrow of `self.scale_context` when we call `should_use_thin_strokes`.
153        let use_thin_strokes = self.should_use_thin_strokes();
154        let render_format = if !self.font.font_antialias {
155            // No anti-aliasing: render as alpha mask (will be thresholded)
156            Format::Alpha
157        } else if use_thin_strokes {
158            // Thin strokes: use subpixel rendering for lighter appearance
159            Format::Subpixel
160        } else {
161            // Standard anti-aliased rendering
162            Format::Alpha
163        };
164
165        // For symbol characters (dingbats, etc.), prefer outline rendering to get
166        // monochrome glyphs that use the terminal foreground color. For emoji,
167        // prefer color bitmaps for proper colorful rendering.
168        let sources = if force_monochrome {
169            // Symbol character: try outline first, fall back to color if unavailable
170            // This ensures dingbats like ✳ ✴ ❇ render as monochrome symbols
171            // with the terminal foreground color, not as colorful emoji.
172            [
173                swash::scale::Source::Outline,
174                swash::scale::Source::ColorOutline(0),
175                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
176            ]
177        } else {
178            // Regular emoji: prefer color sources for colorful rendering
179            [
180                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
181                swash::scale::Source::ColorOutline(0),
182                swash::scale::Source::Outline,
183            ]
184        };
185
186        // Build the scaler after computing `render_format` to avoid a
187        // mutable+immutable borrow overlap on `self`.
188        let mut scaler = self
189            .scale_context
190            .builder(*font)
191            .size(self.font.font_size_pixels)
192            .hint(self.font.font_hinting)
193            .build();
194
195        let mut image = Render::new(&sources)
196            .format(render_format)
197            .render(&mut scaler, glyph_id)?;
198
199        // Detect degenerate outlines: some fonts (e.g., Apple Color Emoji) have charmap
200        // entries but produce empty outlines (all-zero alpha) when the font only has
201        // bitmap data (sbix).
202        if matches!(image.content, Content::Mask) && image.data.iter().all(|&b| b == 0) {
203            if force_monochrome {
204                // For monochrome symbol rendering, don't fall back to color bitmaps.
205                // Return None so the caller can try the next font in the fallback
206                // chain. If no text font has the character, the caller's last resort
207                // will retry with force_monochrome=false to get colored emoji.
208                return None;
209            }
210            // For normal (non-monochrome) rendering, try color bitmap sources.
211            // Drop `scaler` so the exclusive borrow on `self.scale_context` is
212            // released, allowing us to rebuild a new scaler for the retry pass.
213            #[allow(clippy::drop_non_drop)]
214            // Intentional: ends borrow lifetime on self.scale_context
215            drop(scaler);
216            let mut retry_scaler = self
217                .scale_context
218                .builder(*font)
219                .size(self.font.font_size_pixels)
220                .hint(self.font.font_hinting)
221                .build();
222            let color_sources = [
223                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
224                swash::scale::Source::ColorOutline(0),
225            ];
226            if let Some(color_image) = Render::new(&color_sources)
227                .format(render_format)
228                .render(&mut retry_scaler, glyph_id)
229            {
230                image = color_image;
231            } else {
232                return None;
233            }
234        }
235
236        let (pixels, is_colored) = match image.content {
237            Content::Color => {
238                if force_monochrome {
239                    // Convert color emoji to monochrome using the original alpha channel.
240                    // This is more accurate than luminance-based conversion: colored
241                    // symbols (e.g., yellow ⭐, colored ✨) keep full opacity, while
242                    // luminance would make non-white colors appear faint.
243                    let pixels = convert_color_to_alpha_mask(&image);
244                    (pixels, false)
245                } else {
246                    (image.data.clone(), true)
247                }
248            }
249            Content::Mask => {
250                let mut pixels = Vec::with_capacity(image.data.len() * 4);
251                for &mask in &image.data {
252                    // If anti-aliasing is disabled, threshold the alpha to create crisp edges
253                    let alpha = if !self.font.font_antialias {
254                        if mask > 127 { 255 } else { 0 }
255                    } else {
256                        mask
257                    };
258                    pixels.extend_from_slice(&[255, 255, 255, alpha]);
259                }
260                (pixels, false)
261            }
262            Content::SubpixelMask => {
263                let pixels = convert_subpixel_mask_to_rgba(&image);
264                (pixels, false)
265            }
266        };
267
268        // Final check: reject glyphs that are still all-transparent after processing.
269        // This catches cases where even color bitmap conversion produced no visible pixels.
270        if !is_colored && pixels.iter().skip(3).step_by(4).all(|&a| a == 0) {
271            return None;
272        }
273
274        Some(RasterizedGlyph {
275            width: image.placement.width,
276            height: image.placement.height,
277            bearing_x: image.placement.left as f32,
278            bearing_y: image.placement.top as f32,
279            pixels,
280            is_colored,
281        })
282    }
283
284    pub(crate) fn upload_glyph(&mut self, _key: u64, raster: &RasterizedGlyph) -> GlyphInfo {
285        let padding = super::ATLAS_GLYPH_PADDING;
286        let atlas_size = self.atlas.atlas_size;
287        if self.atlas.atlas_next_x + raster.width + padding > atlas_size {
288            self.atlas.atlas_next_x = 0;
289            self.atlas.atlas_next_y += self.atlas.atlas_row_height + padding;
290            self.atlas.atlas_row_height = 0;
291        }
292
293        if self.atlas.atlas_next_y + raster.height + padding > atlas_size {
294            self.clear_glyph_cache();
295        }
296
297        let info = GlyphInfo {
298            key: _key,
299            x: self.atlas.atlas_next_x,
300            y: self.atlas.atlas_next_y,
301            width: raster.width,
302            height: raster.height,
303            bearing_x: raster.bearing_x,
304            bearing_y: raster.bearing_y,
305            is_colored: raster.is_colored,
306            prev: None,
307            next: None,
308        };
309
310        self.queue.write_texture(
311            wgpu::TexelCopyTextureInfo {
312                texture: &self.atlas.atlas_texture,
313                mip_level: 0,
314                origin: wgpu::Origin3d {
315                    x: info.x,
316                    y: info.y,
317                    z: 0,
318                },
319                aspect: wgpu::TextureAspect::All,
320            },
321            &raster.pixels,
322            wgpu::TexelCopyBufferLayout {
323                offset: 0,
324                bytes_per_row: Some(4 * raster.width),
325                rows_per_image: Some(raster.height),
326            },
327            wgpu::Extent3d {
328                width: raster.width,
329                height: raster.height,
330                depth_or_array_layers: 1,
331            },
332        );
333
334        // Clear the padding strips with transparent black so bilinear sampling at glyph
335        // edges never bleeds into stale data from previously evicted glyphs.
336        let pad_right_x = info.x + raster.width;
337        let pad_bottom_y = info.y + raster.height;
338
339        // Right border: `padding` columns × glyph height
340        if pad_right_x + padding <= atlas_size && raster.height > 0 {
341            let zero = vec![0u8; (padding * raster.height * 4) as usize];
342            self.queue.write_texture(
343                wgpu::TexelCopyTextureInfo {
344                    texture: &self.atlas.atlas_texture,
345                    mip_level: 0,
346                    origin: wgpu::Origin3d {
347                        x: pad_right_x,
348                        y: info.y,
349                        z: 0,
350                    },
351                    aspect: wgpu::TextureAspect::All,
352                },
353                &zero,
354                wgpu::TexelCopyBufferLayout {
355                    offset: 0,
356                    bytes_per_row: Some(padding * 4),
357                    rows_per_image: Some(raster.height),
358                },
359                wgpu::Extent3d {
360                    width: padding,
361                    height: raster.height,
362                    depth_or_array_layers: 1,
363                },
364            );
365        }
366
367        // Bottom border: glyph width × `padding` rows
368        if pad_bottom_y + padding <= atlas_size && raster.width > 0 {
369            let zero = vec![0u8; (raster.width * padding * 4) as usize];
370            self.queue.write_texture(
371                wgpu::TexelCopyTextureInfo {
372                    texture: &self.atlas.atlas_texture,
373                    mip_level: 0,
374                    origin: wgpu::Origin3d {
375                        x: info.x,
376                        y: pad_bottom_y,
377                        z: 0,
378                    },
379                    aspect: wgpu::TextureAspect::All,
380                },
381                &zero,
382                wgpu::TexelCopyBufferLayout {
383                    offset: 0,
384                    bytes_per_row: Some(raster.width * 4),
385                    rows_per_image: Some(padding),
386                },
387                wgpu::Extent3d {
388                    width: raster.width,
389                    height: padding,
390                    depth_or_array_layers: 1,
391                },
392            );
393        }
394
395        self.atlas.atlas_next_x += raster.width + padding;
396        self.atlas.atlas_row_height = self.atlas.atlas_row_height.max(raster.height);
397
398        info
399    }
400
401    /// Look up a glyph by `cache_key` in the atlas, rasterizing and uploading it on
402    /// a cache miss.  Returns `None` when rasterization produces an empty bitmap.
403    ///
404    /// `cache_key` must be computed by the caller as:
405    ///   `((font_idx as u64) << 32) | (glyph_id as u64)`
406    /// with bit 63 set when querying the colored-emoji variant of a symbol character.
407    ///
408    /// On a cache hit the LRU order is updated before returning.
409    pub(crate) fn get_or_rasterize_glyph(
410        &mut self,
411        font_idx: usize,
412        glyph_id: u16,
413        force_monochrome: bool,
414        cache_key: u64,
415    ) -> Option<GlyphInfo> {
416        if self.atlas.glyph_cache.contains_key(&cache_key) {
417            self.lru_remove(cache_key);
418            self.lru_push_front(cache_key);
419            return Some(
420                self.atlas
421                    .glyph_cache
422                    .get(&cache_key)
423                    .expect("Glyph cache entry must exist after contains_key check")
424                    .clone(),
425            );
426        }
427        let raster = self.rasterize_glyph(font_idx, glyph_id, force_monochrome)?;
428        let info = self.upload_glyph(cache_key, &raster);
429        self.atlas.glyph_cache.insert(cache_key, info.clone());
430        self.lru_push_front(cache_key);
431        Some(info)
432    }
433}
434
435/// Convert a swash subpixel mask into an RGBA alpha mask.
436/// Some swash builds emit 3 bytes/pixel (RGB), others 4 bytes/pixel (RGBA).
437/// We derive alpha from luminance of RGB and ignore the packed alpha to avoid
438/// dropping coverage when alpha is zeroed by the rasterizer.
439fn convert_subpixel_mask_to_rgba(image: &swash::scale::image::Image) -> Vec<u8> {
440    let width = image.placement.width as usize;
441    let height = image.placement.height as usize;
442    let mut pixels = Vec::with_capacity(width * height * 4);
443
444    let stride = if width > 0 && height > 0 {
445        image.data.len() / (width * height)
446    } else {
447        0
448    };
449
450    match stride {
451        3 => {
452            for chunk in image.data.chunks_exact(3) {
453                let r = chunk[0];
454                let g = chunk[1];
455                let b = chunk[2];
456                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
457                pixels.extend_from_slice(&[255, 255, 255, alpha]);
458            }
459        }
460        4 => {
461            for chunk in image.data.chunks_exact(4) {
462                let r = chunk[0];
463                let g = chunk[1];
464                let b = chunk[2];
465                // Ignore chunk[3] because it can be zeroed in some builds.
466                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
467                pixels.extend_from_slice(&[255, 255, 255, alpha]);
468            }
469        }
470        _ => {
471            // Fallback: treat as opaque white to avoid invisibility if layout changes.
472            pixels.resize(width * height * 4, 255);
473        }
474    }
475
476    pixels
477}
478
479/// Convert a color RGBA image to a monochrome alpha mask.
480///
481/// This is used when a symbol character (like dingbats ✨ ⭐ ✔) is rendered
482/// from a color emoji font but should be displayed as a monochrome glyph
483/// using the terminal foreground color.
484///
485/// Uses the original alpha channel directly rather than luminance-derived alpha.
486/// This produces more accurate results: colored symbols (e.g., yellow ⭐,
487/// multi-colored ✨) retain full opacity, whereas luminance-based conversion
488/// makes non-white colors appear faint (a red symbol would only be ~30% visible).
489fn convert_color_to_alpha_mask(image: &swash::scale::image::Image) -> Vec<u8> {
490    let width = image.placement.width as usize;
491    let height = image.placement.height as usize;
492    let mut pixels = Vec::with_capacity(width * height * 4);
493
494    // Color emoji images are RGBA (4 bytes per pixel).
495    // Use the original alpha channel directly to preserve the symbol shape.
496    for chunk in image.data.chunks_exact(4) {
497        let a = chunk[3];
498        pixels.extend_from_slice(&[255, 255, 255, a]);
499    }
500
501    pixels
502}
503
504#[cfg(test)]
505mod tests {
506    use super::convert_subpixel_mask_to_rgba;
507    use swash::scale::{Render, ScaleContext, Source};
508    use swash::zeno::Format;
509
510    #[test]
511    fn subpixel_mask_uses_rgba_stride() {
512        let data = std::fs::read("../par-term-fonts/fonts/DejaVuSansMono.ttf").expect("font file");
513        let font = swash::FontRef::from_index(&data, 0).expect("font ref");
514        let mut context = ScaleContext::new();
515        let glyph_id = font.charmap().map('a');
516        let mut scaler = context.builder(font).size(18.0).hint(true).build();
517
518        let image = Render::new(&[
519            Source::ColorOutline(0),
520            Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
521            Source::Outline,
522            Source::Bitmap(swash::scale::StrikeWith::BestFit),
523        ])
524        .format(Format::Subpixel)
525        .render(&mut scaler, glyph_id)
526        .expect("render");
527
528        let converted = convert_subpixel_mask_to_rgba(&image);
529
530        let width = image.placement.width as usize;
531        let height = image.placement.height as usize;
532        let mut expected = Vec::with_capacity(width * height * 4);
533        let stride = if width > 0 && height > 0 {
534            image.data.len() / (width * height)
535        } else {
536            0
537        };
538
539        match stride {
540            3 => {
541                for chunk in image.data.chunks_exact(3) {
542                    let r = chunk[0];
543                    let g = chunk[1];
544                    let b = chunk[2];
545                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
546                    expected.extend_from_slice(&[255, 255, 255, alpha]);
547                }
548            }
549            4 => {
550                for chunk in image.data.chunks_exact(4) {
551                    let r = chunk[0];
552                    let g = chunk[1];
553                    let b = chunk[2];
554                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
555                    expected.extend_from_slice(&[255, 255, 255, alpha]);
556                }
557            }
558            _ => expected.resize(width * height * 4, 255),
559        }
560
561        assert_eq!(converted, expected);
562    }
563
564    use super::should_render_as_symbol;
565
566    #[test]
567    fn test_dingbats_are_symbols() {
568        // Dingbats block (U+2700-U+27BF)
569        assert!(
570            should_render_as_symbol('\u{2733}'),
571            "✳ EIGHT SPOKED ASTERISK"
572        );
573        assert!(
574            should_render_as_symbol('\u{2734}'),
575            "✴ EIGHT POINTED BLACK STAR"
576        );
577        assert!(should_render_as_symbol('\u{2747}'), "❇ SPARKLE");
578        assert!(should_render_as_symbol('\u{2744}'), "❄ SNOWFLAKE");
579        assert!(should_render_as_symbol('\u{2702}'), "✂ SCISSORS");
580        assert!(should_render_as_symbol('\u{2714}'), "✔ HEAVY CHECK MARK");
581        assert!(
582            should_render_as_symbol('\u{2716}'),
583            "✖ HEAVY MULTIPLICATION X"
584        );
585        assert!(should_render_as_symbol('\u{2728}'), "✨ SPARKLES");
586    }
587
588    #[test]
589    fn test_misc_symbols_are_symbols() {
590        // Miscellaneous Symbols block (U+2600-U+26FF)
591        assert!(should_render_as_symbol('\u{2600}'), "☀ SUN");
592        assert!(should_render_as_symbol('\u{2601}'), "☁ CLOUD");
593        assert!(should_render_as_symbol('\u{263A}'), "☺ SMILING FACE");
594        assert!(should_render_as_symbol('\u{2665}'), "♥ BLACK HEART SUIT");
595        assert!(should_render_as_symbol('\u{2660}'), "♠ BLACK SPADE SUIT");
596    }
597
598    #[test]
599    fn test_misc_symbols_arrows_are_symbols() {
600        // Miscellaneous Symbols and Arrows block (U+2B00-U+2BFF)
601        assert!(should_render_as_symbol('\u{2B50}'), "⭐ WHITE MEDIUM STAR");
602        assert!(should_render_as_symbol('\u{2B55}'), "⭕ HEAVY LARGE CIRCLE");
603    }
604
605    #[test]
606    fn test_regular_emoji_not_symbols() {
607        // Full emoji characters (outside symbol ranges) should NOT be treated as symbols
608        // They should render as colorful emoji
609        assert!(
610            !should_render_as_symbol('\u{1F600}'),
611            "😀 GRINNING FACE should not be a symbol"
612        );
613        assert!(
614            !should_render_as_symbol('\u{1F389}'),
615            "🎉 PARTY POPPER should not be a symbol"
616        );
617        assert!(
618            !should_render_as_symbol('\u{1F44D}'),
619            "👍 THUMBS UP should not be a symbol"
620        );
621    }
622
623    #[test]
624    fn test_regular_chars_not_symbols() {
625        // Regular text characters should NOT be treated as symbols
626        assert!(
627            !should_render_as_symbol('A'),
628            "Letter A should not be a symbol"
629        );
630        assert!(
631            !should_render_as_symbol('*'),
632            "Asterisk should not be a symbol (it's ASCII)"
633        );
634        assert!(
635            !should_render_as_symbol('1'),
636            "Digit 1 should not be a symbol"
637        );
638    }
639}