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            image = Render::new(&color_sources)
227                .format(render_format)
228                .render(&mut retry_scaler, glyph_id)?;
229        }
230
231        let (pixels, is_colored) = match image.content {
232            Content::Color => {
233                if force_monochrome {
234                    // Convert color emoji to monochrome using the original alpha channel.
235                    // This is more accurate than luminance-based conversion: colored
236                    // symbols (e.g., yellow ⭐, colored ✨) keep full opacity, while
237                    // luminance would make non-white colors appear faint.
238                    let pixels = convert_color_to_alpha_mask(&image);
239                    (pixels, false)
240                } else {
241                    (image.data.clone(), true)
242                }
243            }
244            Content::Mask => {
245                let mut pixels = Vec::with_capacity(image.data.len() * 4);
246                for &mask in &image.data {
247                    // If anti-aliasing is disabled, threshold the alpha to create crisp edges
248                    let alpha = if !self.font.font_antialias {
249                        if mask > 127 { 255 } else { 0 }
250                    } else {
251                        mask
252                    };
253                    pixels.extend_from_slice(&[255, 255, 255, alpha]);
254                }
255                (pixels, false)
256            }
257            Content::SubpixelMask => {
258                let pixels = convert_subpixel_mask_to_rgba(&image);
259                (pixels, false)
260            }
261        };
262
263        // Final check: reject glyphs that are still all-transparent after processing.
264        // This catches cases where even color bitmap conversion produced no visible pixels.
265        if !is_colored && pixels.iter().skip(3).step_by(4).all(|&a| a == 0) {
266            return None;
267        }
268
269        Some(RasterizedGlyph {
270            width: image.placement.width,
271            height: image.placement.height,
272            bearing_x: image.placement.left as f32,
273            bearing_y: image.placement.top as f32,
274            pixels,
275            is_colored,
276        })
277    }
278
279    pub(crate) fn upload_glyph(&mut self, _key: u64, raster: &RasterizedGlyph) -> GlyphInfo {
280        let padding = super::ATLAS_GLYPH_PADDING;
281        let atlas_size = self.atlas.atlas_size;
282        if self.atlas.atlas_next_x + raster.width + padding > atlas_size {
283            self.atlas.atlas_next_x = 0;
284            self.atlas.atlas_next_y += self.atlas.atlas_row_height + padding;
285            self.atlas.atlas_row_height = 0;
286        }
287
288        if self.atlas.atlas_next_y + raster.height + padding > atlas_size {
289            self.clear_glyph_cache();
290        }
291
292        let info = GlyphInfo {
293            key: _key,
294            x: self.atlas.atlas_next_x,
295            y: self.atlas.atlas_next_y,
296            width: raster.width,
297            height: raster.height,
298            bearing_x: raster.bearing_x,
299            bearing_y: raster.bearing_y,
300            is_colored: raster.is_colored,
301            prev: None,
302            next: None,
303        };
304
305        self.queue.write_texture(
306            wgpu::TexelCopyTextureInfo {
307                texture: &self.atlas.atlas_texture,
308                mip_level: 0,
309                origin: wgpu::Origin3d {
310                    x: info.x,
311                    y: info.y,
312                    z: 0,
313                },
314                aspect: wgpu::TextureAspect::All,
315            },
316            &raster.pixels,
317            wgpu::TexelCopyBufferLayout {
318                offset: 0,
319                bytes_per_row: Some(4 * raster.width),
320                rows_per_image: Some(raster.height),
321            },
322            wgpu::Extent3d {
323                width: raster.width,
324                height: raster.height,
325                depth_or_array_layers: 1,
326            },
327        );
328
329        // Clear the padding strips with transparent black so bilinear sampling at glyph
330        // edges never bleeds into stale data from previously evicted glyphs.
331        let pad_right_x = info.x + raster.width;
332        let pad_bottom_y = info.y + raster.height;
333
334        // Right border: `padding` columns × glyph height
335        if pad_right_x + padding <= atlas_size && raster.height > 0 {
336            let zero = vec![0u8; (padding * raster.height * 4) as usize];
337            self.queue.write_texture(
338                wgpu::TexelCopyTextureInfo {
339                    texture: &self.atlas.atlas_texture,
340                    mip_level: 0,
341                    origin: wgpu::Origin3d {
342                        x: pad_right_x,
343                        y: info.y,
344                        z: 0,
345                    },
346                    aspect: wgpu::TextureAspect::All,
347                },
348                &zero,
349                wgpu::TexelCopyBufferLayout {
350                    offset: 0,
351                    bytes_per_row: Some(padding * 4),
352                    rows_per_image: Some(raster.height),
353                },
354                wgpu::Extent3d {
355                    width: padding,
356                    height: raster.height,
357                    depth_or_array_layers: 1,
358                },
359            );
360        }
361
362        // Bottom border: glyph width × `padding` rows
363        if pad_bottom_y + padding <= atlas_size && raster.width > 0 {
364            let zero = vec![0u8; (raster.width * padding * 4) as usize];
365            self.queue.write_texture(
366                wgpu::TexelCopyTextureInfo {
367                    texture: &self.atlas.atlas_texture,
368                    mip_level: 0,
369                    origin: wgpu::Origin3d {
370                        x: info.x,
371                        y: pad_bottom_y,
372                        z: 0,
373                    },
374                    aspect: wgpu::TextureAspect::All,
375                },
376                &zero,
377                wgpu::TexelCopyBufferLayout {
378                    offset: 0,
379                    bytes_per_row: Some(raster.width * 4),
380                    rows_per_image: Some(padding),
381                },
382                wgpu::Extent3d {
383                    width: raster.width,
384                    height: padding,
385                    depth_or_array_layers: 1,
386                },
387            );
388        }
389
390        self.atlas.atlas_next_x += raster.width + padding;
391        self.atlas.atlas_row_height = self.atlas.atlas_row_height.max(raster.height);
392
393        info
394    }
395
396    /// Look up a glyph by `cache_key` in the atlas, rasterizing and uploading it on
397    /// a cache miss.  Returns `None` when rasterization produces an empty bitmap.
398    ///
399    /// `cache_key` must be computed by the caller as:
400    ///   `((font_idx as u64) << 32) | (glyph_id as u64)`
401    /// with bit 63 set when querying the colored-emoji variant of a symbol character.
402    ///
403    /// On a cache hit the LRU order is updated before returning.
404    pub(crate) fn get_or_rasterize_glyph(
405        &mut self,
406        font_idx: usize,
407        glyph_id: u16,
408        force_monochrome: bool,
409        cache_key: u64,
410    ) -> Option<GlyphInfo> {
411        if self.atlas.glyph_cache.contains_key(&cache_key) {
412            self.lru_remove(cache_key);
413            self.lru_push_front(cache_key);
414            return Some(
415                self.atlas
416                    .glyph_cache
417                    .get(&cache_key)
418                    .expect("Glyph cache entry must exist after contains_key check")
419                    .clone(),
420            );
421        }
422        let raster = self.rasterize_glyph(font_idx, glyph_id, force_monochrome)?;
423        let info = self.upload_glyph(cache_key, &raster);
424        self.atlas.glyph_cache.insert(cache_key, info.clone());
425        self.lru_push_front(cache_key);
426        Some(info)
427    }
428
429    /// Resolve a renderable glyph for a character, walking font fallbacks until one succeeds.
430    ///
431    /// This is the single canonical implementation of the font-fallback loop previously
432    /// duplicated in `text_instance_builder.rs` and `pane_render/` (see ARC-004 / QA-003).
433    ///
434    /// # Arguments
435    /// * `base_char`       — the base Unicode scalar to look up (after stripping VS16 etc.)
436    /// * `grapheme`        — the full grapheme cluster string (may be multi-char for ZWJ/flags)
437    /// * `bold`            — bold style flag
438    /// * `italic`          — italic style flag
439    /// * `force_monochrome` — when true use single-char lookup and suppress colored-emoji
440    ///   rasterization; falls back to colored-emoji as last resort
441    ///
442    /// # Returns
443    /// The first [`GlyphInfo`] that rasterizes successfully, or `None` if every font
444    /// (including the colored-emoji last-resort) fails.
445    ///
446    /// # Caching
447    /// Results are cached in the glyph atlas.  The cache key encodes `(font_idx, glyph_id)`
448    /// as `((font_idx as u64) << 32) | (glyph_id as u64)`, with bit 63 set for the
449    /// colored-emoji fallback variant.
450    pub(crate) fn resolve_glyph_with_fallback(
451        &mut self,
452        base_char: char,
453        grapheme: &str,
454        bold: bool,
455        italic: bool,
456        force_monochrome: bool,
457    ) -> Option<GlyphInfo> {
458        // Initial lookup: use grapheme-aware path for multi-char sequences (flags, ZWJ emoji,
459        // skin-tone modifiers), unless force_monochrome has already stripped VS16 to a single char.
460        let chars: Vec<char> = grapheme.chars().collect();
461        let mut glyph_result = if force_monochrome || chars.len() == 1 {
462            self.font_manager.find_glyph(base_char, bold, italic)
463        } else {
464            self.font_manager
465                .find_grapheme_glyph(grapheme, bold, italic)
466        };
467
468        // Walk font fallbacks until a glyph rasterizes successfully.
469        // Rasterization can fail even when a font has a charmap entry (e.g. Apple Color Emoji
470        // charmap entries exist for some symbols but produce empty outlines).
471        let mut excluded_fonts: Vec<usize> = Vec::new();
472        let resolved = loop {
473            match glyph_result {
474                Some((font_idx, glyph_id)) => {
475                    let cache_key = ((font_idx as u64) << 32) | (glyph_id as u64);
476                    if let Some(info) =
477                        self.get_or_rasterize_glyph(font_idx, glyph_id, force_monochrome, cache_key)
478                    {
479                        break Some(info);
480                    }
481                    // This font's outline was empty — exclude it and retry.
482                    excluded_fonts.push(font_idx);
483                    glyph_result = self.font_manager.find_glyph_excluding(
484                        base_char,
485                        bold,
486                        italic,
487                        &excluded_fonts,
488                    );
489                }
490                None => break None,
491            }
492        };
493
494        // Last resort: if monochrome rendering failed across all fonts (no font has vector
495        // outlines for this char), retry with colored-emoji rasterization.  Characters like ✨
496        // only exist in Apple Color Emoji — rendering them colored is better than nothing.
497        // Bit 63 of the cache key distinguishes the colored-fallback entry from the monochrome
498        // entry for the same (font_idx, glyph_id) pair.
499        if resolved.is_none() && force_monochrome {
500            let mut glyph_result2 = self.font_manager.find_glyph(base_char, bold, italic);
501            loop {
502                match glyph_result2 {
503                    Some((font_idx, glyph_id)) => {
504                        let cache_key =
505                            ((font_idx as u64) << 32) | (glyph_id as u64) | (1u64 << 63);
506                        if let Some(info) =
507                            self.get_or_rasterize_glyph(font_idx, glyph_id, false, cache_key)
508                        {
509                            break Some(info);
510                        }
511                        glyph_result2 = self.font_manager.find_glyph_excluding(
512                            base_char,
513                            bold,
514                            italic,
515                            &[font_idx],
516                        );
517                    }
518                    None => break None,
519                }
520            }
521        } else {
522            resolved
523        }
524    }
525}
526
527/// Convert a swash subpixel mask into an RGBA alpha mask.
528/// Some swash builds emit 3 bytes/pixel (RGB), others 4 bytes/pixel (RGBA).
529/// We derive alpha from luminance of RGB and ignore the packed alpha to avoid
530/// dropping coverage when alpha is zeroed by the rasterizer.
531fn convert_subpixel_mask_to_rgba(image: &swash::scale::image::Image) -> Vec<u8> {
532    let width = image.placement.width as usize;
533    let height = image.placement.height as usize;
534    let mut pixels = Vec::with_capacity(width * height * 4);
535
536    let stride = if width > 0 && height > 0 {
537        image.data.len() / (width * height)
538    } else {
539        0
540    };
541
542    match stride {
543        3 => {
544            for chunk in image.data.chunks_exact(3) {
545                let r = chunk[0];
546                let g = chunk[1];
547                let b = chunk[2];
548                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
549                pixels.extend_from_slice(&[255, 255, 255, alpha]);
550            }
551        }
552        4 => {
553            for chunk in image.data.chunks_exact(4) {
554                let r = chunk[0];
555                let g = chunk[1];
556                let b = chunk[2];
557                // Ignore chunk[3] because it can be zeroed in some builds.
558                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
559                pixels.extend_from_slice(&[255, 255, 255, alpha]);
560            }
561        }
562        _ => {
563            // Fallback: treat as opaque white to avoid invisibility if layout changes.
564            pixels.resize(width * height * 4, 255);
565        }
566    }
567
568    pixels
569}
570
571/// Convert a color RGBA image to a monochrome alpha mask.
572///
573/// This is used when a symbol character (like dingbats ✨ ⭐ ✔) is rendered
574/// from a color emoji font but should be displayed as a monochrome glyph
575/// using the terminal foreground color.
576///
577/// Uses the original alpha channel directly rather than luminance-derived alpha.
578/// This produces more accurate results: colored symbols (e.g., yellow ⭐,
579/// multi-colored ✨) retain full opacity, whereas luminance-based conversion
580/// makes non-white colors appear faint (a red symbol would only be ~30% visible).
581fn convert_color_to_alpha_mask(image: &swash::scale::image::Image) -> Vec<u8> {
582    let width = image.placement.width as usize;
583    let height = image.placement.height as usize;
584    let mut pixels = Vec::with_capacity(width * height * 4);
585
586    // Color emoji images are RGBA (4 bytes per pixel).
587    // Use the original alpha channel directly to preserve the symbol shape.
588    for chunk in image.data.chunks_exact(4) {
589        let a = chunk[3];
590        pixels.extend_from_slice(&[255, 255, 255, a]);
591    }
592
593    pixels
594}
595
596#[cfg(test)]
597mod tests {
598    use super::convert_subpixel_mask_to_rgba;
599    use swash::scale::{Render, ScaleContext, Source};
600    use swash::zeno::Format;
601
602    #[test]
603    fn subpixel_mask_uses_rgba_stride() {
604        let data = std::fs::read("../par-term-fonts/fonts/DejaVuSansMono.ttf").expect("font file");
605        let font = swash::FontRef::from_index(&data, 0).expect("font ref");
606        let mut context = ScaleContext::new();
607        let glyph_id = font.charmap().map('a');
608        let mut scaler = context.builder(font).size(18.0).hint(true).build();
609
610        let image = Render::new(&[
611            Source::ColorOutline(0),
612            Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
613            Source::Outline,
614            Source::Bitmap(swash::scale::StrikeWith::BestFit),
615        ])
616        .format(Format::Subpixel)
617        .render(&mut scaler, glyph_id)
618        .expect("render");
619
620        let converted = convert_subpixel_mask_to_rgba(&image);
621
622        let width = image.placement.width as usize;
623        let height = image.placement.height as usize;
624        let mut expected = Vec::with_capacity(width * height * 4);
625        let stride = if width > 0 && height > 0 {
626            image.data.len() / (width * height)
627        } else {
628            0
629        };
630
631        match stride {
632            3 => {
633                for chunk in image.data.chunks_exact(3) {
634                    let r = chunk[0];
635                    let g = chunk[1];
636                    let b = chunk[2];
637                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
638                    expected.extend_from_slice(&[255, 255, 255, alpha]);
639                }
640            }
641            4 => {
642                for chunk in image.data.chunks_exact(4) {
643                    let r = chunk[0];
644                    let g = chunk[1];
645                    let b = chunk[2];
646                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
647                    expected.extend_from_slice(&[255, 255, 255, alpha]);
648                }
649            }
650            _ => expected.resize(width * height * 4, 255),
651        }
652
653        assert_eq!(converted, expected);
654    }
655
656    use super::should_render_as_symbol;
657
658    #[test]
659    fn test_dingbats_are_symbols() {
660        // Dingbats block (U+2700-U+27BF)
661        assert!(
662            should_render_as_symbol('\u{2733}'),
663            "✳ EIGHT SPOKED ASTERISK"
664        );
665        assert!(
666            should_render_as_symbol('\u{2734}'),
667            "✴ EIGHT POINTED BLACK STAR"
668        );
669        assert!(should_render_as_symbol('\u{2747}'), "❇ SPARKLE");
670        assert!(should_render_as_symbol('\u{2744}'), "❄ SNOWFLAKE");
671        assert!(should_render_as_symbol('\u{2702}'), "✂ SCISSORS");
672        assert!(should_render_as_symbol('\u{2714}'), "✔ HEAVY CHECK MARK");
673        assert!(
674            should_render_as_symbol('\u{2716}'),
675            "✖ HEAVY MULTIPLICATION X"
676        );
677        assert!(should_render_as_symbol('\u{2728}'), "✨ SPARKLES");
678    }
679
680    #[test]
681    fn test_misc_symbols_are_symbols() {
682        // Miscellaneous Symbols block (U+2600-U+26FF)
683        assert!(should_render_as_symbol('\u{2600}'), "☀ SUN");
684        assert!(should_render_as_symbol('\u{2601}'), "☁ CLOUD");
685        assert!(should_render_as_symbol('\u{263A}'), "☺ SMILING FACE");
686        assert!(should_render_as_symbol('\u{2665}'), "♥ BLACK HEART SUIT");
687        assert!(should_render_as_symbol('\u{2660}'), "♠ BLACK SPADE SUIT");
688    }
689
690    #[test]
691    fn test_misc_symbols_arrows_are_symbols() {
692        // Miscellaneous Symbols and Arrows block (U+2B00-U+2BFF)
693        assert!(should_render_as_symbol('\u{2B50}'), "⭐ WHITE MEDIUM STAR");
694        assert!(should_render_as_symbol('\u{2B55}'), "⭕ HEAVY LARGE CIRCLE");
695    }
696
697    #[test]
698    fn test_regular_emoji_not_symbols() {
699        // Full emoji characters (outside symbol ranges) should NOT be treated as symbols
700        // They should render as colorful emoji
701        assert!(
702            !should_render_as_symbol('\u{1F600}'),
703            "😀 GRINNING FACE should not be a symbol"
704        );
705        assert!(
706            !should_render_as_symbol('\u{1F389}'),
707            "🎉 PARTY POPPER should not be a symbol"
708        );
709        assert!(
710            !should_render_as_symbol('\u{1F44D}'),
711            "👍 THUMBS UP should not be a symbol"
712        );
713    }
714
715    #[test]
716    fn test_regular_chars_not_symbols() {
717        // Regular text characters should NOT be treated as symbols
718        assert!(
719            !should_render_as_symbol('A'),
720            "Letter A should not be a symbol"
721        );
722        assert!(
723            !should_render_as_symbol('*'),
724            "Asterisk should not be a symbol (it's ASCII)"
725        );
726        assert!(
727            !should_render_as_symbol('1'),
728            "Digit 1 should not be a symbol"
729        );
730    }
731}