Skip to main content

gpui_wgpu/
cosmic_text_system.rs

1use anyhow::{Context as _, Ok, Result};
2use collections::HashMap;
3use cosmic_text::{
4    Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont,
5    FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, Stretch, Style, Weight,
6};
7use gpui::{
8    Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId,
9    IsZero as _, LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X,
10    SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point,
11    size,
12};
13
14use itertools::Itertools;
15use parking_lot::RwLock;
16use smallvec::SmallVec;
17use std::{borrow::Cow, ops::Range, sync::Arc};
18use swash::{
19    scale::{Render, ScaleContext, Source, StrikeWith},
20    zeno::{Format, Vector},
21};
22use unicode_segmentation::UnicodeSegmentation;
23
24pub struct CosmicTextSystem(RwLock<CosmicTextSystemState>);
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27struct FontKey {
28    family: SharedString,
29    features: FontFeatures,
30    fallbacks: Option<FontFallbacks>,
31}
32
33impl FontKey {
34    fn new(family: SharedString, features: FontFeatures, fallbacks: Option<FontFallbacks>) -> Self {
35        Self {
36            family,
37            features,
38            fallbacks,
39        }
40    }
41}
42
43struct CosmicTextSystemState {
44    font_system: FontSystem,
45    scratch: ShapeBuffer,
46    swash_scale_context: ScaleContext,
47    pending_glyph_images: HashMap<RenderGlyphParams, swash::scale::image::Image>,
48    /// Contains all already loaded fonts, including all faces. Indexed by `FontId`.
49    loaded_fonts: Vec<LoadedFont>,
50    /// Caches the `FontId`s associated with a specific family to avoid iterating the font database
51    /// for every font face in a family.
52    font_ids_by_family_cache: HashMap<FontKey, SmallVec<[FontId; 4]>>,
53    system_font_fallback: String,
54}
55
56struct LoadedFont {
57    font: Arc<CosmicTextFont>,
58    features: CosmicFontFeatures,
59    is_known_emoji_font: bool,
60    /// resolved at load time so `layout_line` shares one chain across faces.
61    /// `Arc` keeps clone cheap on the per-run hot path.
62    user_fallback_chain: Arc<[(FontId, SharedString)]>,
63}
64
65struct FontMatchProperties {
66    primary_family_name: SharedString,
67    stretch: Stretch,
68    style: Style,
69    weight: Weight,
70    features: CosmicFontFeatures,
71    fallback_chain: Arc<[(FontId, SharedString)]>,
72}
73
74impl FontMatchProperties {
75    fn attributes<'a>(&'a self, font_id: FontId, family_name: &'a str) -> Attrs<'a> {
76        Attrs::new()
77            .metadata(font_id.0)
78            .family(Family::Name(family_name))
79            .stretch(self.stretch)
80            .style(self.style)
81            .weight(self.weight)
82            .font_features(self.features.clone())
83    }
84}
85
86impl CosmicTextSystem {
87    pub fn new(system_font_fallback: &str) -> Self {
88        let font_system = FontSystem::new();
89
90        Self(RwLock::new(CosmicTextSystemState {
91            font_system,
92            scratch: ShapeBuffer::default(),
93            swash_scale_context: ScaleContext::new(),
94            pending_glyph_images: HashMap::default(),
95            loaded_fonts: Vec::new(),
96            font_ids_by_family_cache: HashMap::default(),
97            system_font_fallback: system_font_fallback.to_string(),
98        }))
99    }
100
101    pub fn new_without_system_fonts(system_font_fallback: &str) -> Self {
102        let font_system = FontSystem::new_with_locale_and_db(
103            "en-US".to_string(),
104            cosmic_text::fontdb::Database::new(),
105        );
106
107        Self(RwLock::new(CosmicTextSystemState {
108            font_system,
109            scratch: ShapeBuffer::default(),
110            swash_scale_context: ScaleContext::new(),
111            pending_glyph_images: HashMap::default(),
112            loaded_fonts: Vec::new(),
113            font_ids_by_family_cache: HashMap::default(),
114            system_font_fallback: system_font_fallback.to_string(),
115        }))
116    }
117}
118
119impl PlatformTextSystem for CosmicTextSystem {
120    fn add_fonts(&self, fonts: Vec<Cow<'static, [u8]>>) -> Result<()> {
121        self.0.write().add_fonts(fonts)
122    }
123
124    fn all_font_names(&self) -> Vec<String> {
125        let mut result = self
126            .0
127            .read()
128            .font_system
129            .db()
130            .faces()
131            .filter_map(|face| face.families.first().map(|family| family.0.clone()))
132            .collect_vec();
133        result.sort_unstable();
134        result.dedup();
135        result
136    }
137
138    fn font_id(&self, font: &Font) -> Result<FontId> {
139        let mut state = self.0.write();
140        let key = FontKey::new(
141            font.family.clone(),
142            font.features.clone(),
143            font.fallbacks.clone(),
144        );
145        let candidates = if let Some(font_ids) = state.font_ids_by_family_cache.get(&key) {
146            font_ids.as_slice()
147        } else {
148            let font_ids =
149                state.load_family(&font.family, &font.features, font.fallbacks.as_ref())?;
150            state.font_ids_by_family_cache.insert(key.clone(), font_ids);
151            state.font_ids_by_family_cache[&key].as_ref()
152        };
153
154        let ix = find_best_match(font, candidates, &state)?;
155
156        Ok(candidates[ix])
157    }
158
159    fn prewarm_fonts(&self, font_ids: &[FontId]) {
160        self.0.write().prewarm_fonts(font_ids);
161    }
162
163    fn font_metrics(&self, font_id: FontId) -> FontMetrics {
164        let metrics = self
165            .0
166            .read()
167            .loaded_font(font_id)
168            .font
169            .as_swash()
170            .metrics(&[]);
171
172        FontMetrics {
173            units_per_em: metrics.units_per_em as u32,
174            ascent: metrics.ascent,
175            descent: -metrics.descent,
176            line_gap: metrics.leading,
177            underline_position: metrics.underline_offset,
178            underline_thickness: metrics.stroke_size,
179            cap_height: metrics.cap_height,
180            x_height: metrics.x_height,
181            bounding_box: Bounds {
182                origin: point(0.0, 0.0),
183                size: size(metrics.max_width, metrics.ascent + metrics.descent),
184            },
185        }
186    }
187
188    fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Bounds<f32>> {
189        let lock = self.0.read();
190        let glyph_metrics = lock.loaded_font(font_id).font.as_swash().glyph_metrics(&[]);
191        let glyph_id = glyph_id.0 as u16;
192        Ok(Bounds {
193            origin: point(0.0, 0.0),
194            size: size(
195                glyph_metrics.advance_width(glyph_id),
196                glyph_metrics.advance_height(glyph_id),
197            ),
198        })
199    }
200
201    fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Size<f32>> {
202        self.0.read().advance(font_id, glyph_id)
203    }
204
205    fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option<GlyphId> {
206        self.0.read().glyph_for_char(font_id, ch)
207    }
208
209    fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {
210        self.0.write().raster_bounds(params)
211    }
212
213    fn rasterize_glyph(
214        &self,
215        params: &RenderGlyphParams,
216        raster_bounds: Bounds<DevicePixels>,
217    ) -> Result<(Size<DevicePixels>, Vec<u8>)> {
218        self.0.write().rasterize_glyph(params, raster_bounds)
219    }
220
221    fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout {
222        self.0.write().layout_line(text, font_size, runs)
223    }
224
225    fn recommended_rendering_mode(
226        &self,
227        _font_id: FontId,
228        _font_size: Pixels,
229    ) -> TextRenderingMode {
230        TextRenderingMode::Subpixel
231    }
232}
233
234impl CosmicTextSystemState {
235    fn loaded_font(&self, font_id: FontId) -> &LoadedFont {
236        &self.loaded_fonts[font_id.0]
237    }
238
239    fn font_match_properties(&self, font_id: FontId) -> Option<FontMatchProperties> {
240        let loaded_font = self.loaded_font(font_id);
241        let Some(face) = self.font_system.db().face(loaded_font.font.id()) else {
242            log::warn!("font face not found in database for font_id {:?}", font_id);
243            return None;
244        };
245        let Some(first_family) = face.families.first() else {
246            log::warn!("font face has no family names for font_id {:?}", font_id);
247            return None;
248        };
249
250        Some(FontMatchProperties {
251            primary_family_name: first_family.0.clone().into(),
252            stretch: face.stretch,
253            style: face.style,
254            weight: face.weight,
255            features: loaded_font.features.clone(),
256            fallback_chain: Arc::clone(&loaded_font.user_fallback_chain),
257        })
258    }
259
260    fn prewarm_fonts(&mut self, font_ids: &[FontId]) {
261        for &font_id in font_ids {
262            let Some(properties) = self.font_match_properties(font_id) else {
263                continue;
264            };
265            let primary_attributes =
266                properties.attributes(font_id, &properties.primary_family_name);
267            self.font_system.get_font_matches(&primary_attributes);
268
269            for (fallback_id, fallback_name) in &*properties.fallback_chain {
270                let fallback_attributes = properties.attributes(*fallback_id, fallback_name);
271                self.font_system.get_font_matches(&fallback_attributes);
272            }
273        }
274    }
275
276    #[profiling::function]
277    fn add_fonts(&mut self, fonts: Vec<Cow<'static, [u8]>>) -> Result<()> {
278        let db = self.font_system.db_mut();
279        for bytes in fonts {
280            db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes)));
281        }
282        Ok(())
283    }
284
285    #[profiling::function]
286    fn load_family(
287        &mut self,
288        name: &str,
289        features: &FontFeatures,
290        fallbacks: Option<&FontFallbacks>,
291    ) -> Result<SmallVec<[FontId; 4]>> {
292        // recurse with `fallbacks = None` so a fallback family cannot pull in
293        // another chain. missing fallback families are dropped so a typo in
294        // settings still lets the primary family load.
295        let user_fallback_chain: Arc<[(FontId, SharedString)]> = match fallbacks {
296            Some(fallbacks) if !fallbacks.fallback_list().is_empty() => {
297                let mut chain: Vec<(FontId, SharedString)> = Vec::new();
298                for fallback_name in fallbacks.fallback_list() {
299                    let fb_key = FontKey::new(
300                        SharedString::from(fallback_name.clone()),
301                        features.clone(),
302                        None,
303                    );
304                    let fb_ids = if let Some(cached) = self.font_ids_by_family_cache.get(&fb_key) {
305                        cached.clone()
306                    } else {
307                        let loaded = self.load_family(fallback_name, features, None)?;
308                        self.font_ids_by_family_cache
309                            .insert(fb_key.clone(), loaded.clone());
310                        loaded
311                    };
312                    let Some(&fb_id) = fb_ids.first() else {
313                        continue;
314                    };
315                    let db_id = self.loaded_fonts[fb_id.0].font.id();
316                    if let Some(face) = self.font_system.db().face(db_id)
317                        && let Some(family) = face.families.first()
318                    {
319                        chain.push((fb_id, SharedString::from(family.0.clone())));
320                    }
321                }
322                Arc::from(chain)
323            }
324            _ => Arc::from(Vec::new()),
325        };
326
327        let name = gpui::font_name_with_fallbacks(name, &self.system_font_fallback);
328
329        let families = self
330            .font_system
331            .db()
332            .faces()
333            .filter(|face| face.families.iter().any(|family| *name == family.0))
334            .map(|face| (face.id, face.post_script_name.clone()))
335            .collect::<SmallVec<[_; 4]>>();
336
337        let cosmic_features = cosmic_font_features(features)?;
338
339        let mut loaded_font_ids = SmallVec::new();
340        for (font_id, postscript_name) in families {
341            let font = self
342                .font_system
343                .get_font(font_id, cosmic_text::Weight::NORMAL)
344                .context("Could not load font")?;
345
346            // HACK: To let the storybook run and render Windows caption icons. We should actually do better font fallback.
347            let allowed_bad_font_names = [
348                "SegoeFluentIcons", // NOTE: Segoe fluent icons postscript name is inconsistent
349                "Segoe Fluent Icons",
350            ];
351
352            if font.as_swash().charmap().map('m') == 0
353                && !allowed_bad_font_names.contains(&postscript_name.as_str())
354            {
355                self.font_system.db_mut().remove_face(font.id());
356                continue;
357            };
358
359            let font_id = FontId(self.loaded_fonts.len());
360            loaded_font_ids.push(font_id);
361            self.loaded_fonts.push(LoadedFont {
362                font,
363                features: cosmic_features.clone(),
364                is_known_emoji_font: check_is_known_emoji_font(&postscript_name),
365                user_fallback_chain: Arc::clone(&user_fallback_chain),
366            });
367        }
368
369        Ok(loaded_font_ids)
370    }
371
372    fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Size<f32>> {
373        let glyph_metrics = self.loaded_font(font_id).font.as_swash().glyph_metrics(&[]);
374        Ok(Size {
375            width: glyph_metrics.advance_width(glyph_id.0 as u16),
376            height: glyph_metrics.advance_height(glyph_id.0 as u16),
377        })
378    }
379
380    fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option<GlyphId> {
381        let glyph_id = self.loaded_font(font_id).font.as_swash().charmap().map(ch);
382        if glyph_id == 0 {
383            None
384        } else {
385            Some(GlyphId(glyph_id.into()))
386        }
387    }
388
389    fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {
390        let image = self.render_glyph_image(params)?;
391        let bounds = Bounds {
392            origin: point(image.placement.left.into(), (-image.placement.top).into()),
393            size: size(image.placement.width.into(), image.placement.height.into()),
394        };
395        if !bounds.is_zero() {
396            self.pending_glyph_images.insert(params.clone(), image);
397        }
398        Ok(bounds)
399    }
400
401    #[profiling::function]
402    fn rasterize_glyph(
403        &mut self,
404        params: &RenderGlyphParams,
405        glyph_bounds: Bounds<DevicePixels>,
406    ) -> Result<(Size<DevicePixels>, Vec<u8>)> {
407        if glyph_bounds.size.width.0 == 0 || glyph_bounds.size.height.0 == 0 {
408            anyhow::bail!("glyph bounds are empty");
409        }
410
411        let mut image = match self.pending_glyph_images.remove(params) {
412            Some(image) => image,
413            None => self.render_glyph_image(params)?,
414        };
415        let bitmap_size = glyph_bounds.size;
416        match image.content {
417            swash::scale::image::Content::Color | swash::scale::image::Content::SubpixelMask => {
418                // Convert from RGBA to BGRA.
419                for pixel in image.data.chunks_exact_mut(4) {
420                    pixel.swap(0, 2);
421                }
422                Ok((bitmap_size, image.data))
423            }
424            swash::scale::image::Content::Mask => {
425                if params.subpixel_rendering {
426                    // We must always return RGBA data when subpixel rendering is requested.
427                    let expanded = image.data.iter().flat_map(|&a| [a, a, a, a]).collect();
428                    Ok((bitmap_size, expanded))
429                } else {
430                    Ok((bitmap_size, image.data))
431                }
432            }
433        }
434    }
435
436    fn render_glyph_image(
437        &mut self,
438        params: &RenderGlyphParams,
439    ) -> Result<swash::scale::image::Image> {
440        let loaded_font = &self.loaded_fonts[params.font_id.0];
441        let font_ref = loaded_font.font.as_swash();
442        let pixel_size = f32::from(params.font_size);
443
444        let subpixel_offset = Vector::new(
445            params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor,
446            params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor,
447        );
448
449        let mut scaler = self
450            .swash_scale_context
451            .builder(font_ref)
452            .size(pixel_size * params.scale_factor)
453            .hint(true)
454            .build();
455
456        let sources: &[Source] = if params.is_emoji {
457            &[
458                Source::ColorOutline(0),
459                Source::ColorBitmap(StrikeWith::BestFit),
460                Source::Outline,
461            ]
462        } else {
463            &[Source::Bitmap(StrikeWith::ExactSize), Source::Outline]
464        };
465
466        let mut renderer = Render::new(sources);
467        if params.subpixel_rendering {
468            // There seems to be a bug in Swash where the B and R values are swapped.
469            renderer
470                .format(Format::subpixel_bgra())
471                .offset(subpixel_offset);
472        } else {
473            renderer.format(Format::Alpha).offset(subpixel_offset);
474        }
475
476        let glyph_id: u16 = params.glyph_id.0.try_into()?;
477        renderer
478            .render(&mut scaler, glyph_id)
479            .with_context(|| format!("unable to render glyph via swash for {params:?}"))
480    }
481
482    /// This is used when cosmic_text has chosen a fallback font instead of using the requested
483    /// font, typically to handle some unicode characters. When this happens, `loaded_fonts` may not
484    /// yet have an entry for this fallback font, and so one is added.
485    ///
486    /// Note that callers shouldn't use this `FontId` somewhere that will retrieve the corresponding
487    /// `LoadedFont.features`, as it will have an arbitrarily chosen or empty value. The only
488    /// current use of this field is for the *input* of `layout_line`, and so it's fine to use
489    /// `font_id_for_cosmic_id` when computing the *output* of `layout_line`.
490    fn font_id_for_cosmic_id(&mut self, id: cosmic_text::fontdb::ID) -> Result<FontId> {
491        if let Some(ix) = self
492            .loaded_fonts
493            .iter()
494            .position(|loaded_font| loaded_font.font.id() == id)
495        {
496            Ok(FontId(ix))
497        } else {
498            let font = self
499                .font_system
500                .get_font(id, cosmic_text::Weight::NORMAL)
501                .context("failed to get fallback font from cosmic-text font system")?;
502            let face = self
503                .font_system
504                .db()
505                .face(id)
506                .context("fallback font face not found in cosmic-text database")?;
507
508            let font_id = FontId(self.loaded_fonts.len());
509            self.loaded_fonts.push(LoadedFont {
510                font,
511                features: CosmicFontFeatures::new(),
512                is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name),
513                user_fallback_chain: Arc::from(Vec::new()),
514            });
515
516            Ok(font_id)
517        }
518    }
519
520    #[profiling::function]
521    fn layout_line(&mut self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout {
522        if contains_paragraph_separator(text) {
523            self.layout_line_with_separators(text, font_size, font_runs)
524        } else {
525            self.layout_line_no_separators(text, font_size, font_runs)
526        }
527    }
528
529    fn layout_line_with_separators(
530        &mut self,
531        text: &str,
532        font_size: Pixels,
533        font_runs: &[FontRun],
534    ) -> LineLayout {
535        let mut layout = LineLayout {
536            font_size,
537            len: text.len(),
538            ..Default::default()
539        };
540        let mut paragraph_start = 0;
541
542        for (separator_start, separator) in text
543            .char_indices()
544            .filter(|(_, character)| is_paragraph_separator(*character))
545        {
546            let separator_end = separator_start + separator.len_utf8();
547            self.shape_segment(
548                text,
549                paragraph_start..separator_start,
550                font_size,
551                font_runs,
552                &mut layout,
553            );
554            self.shape_segment(
555                text,
556                separator_start..separator_end,
557                font_size,
558                font_runs,
559                &mut layout,
560            );
561            paragraph_start = separator_end;
562        }
563
564        self.shape_segment(
565            text,
566            paragraph_start..text.len(),
567            font_size,
568            font_runs,
569            &mut layout,
570        );
571
572        layout
573    }
574
575    fn shape_segment(
576        &mut self,
577        text: &str,
578        range: Range<usize>,
579        font_size: Pixels,
580        font_runs: &[FontRun],
581        layout: &mut LineLayout,
582    ) {
583        if range.is_empty() {
584            return;
585        }
586
587        let segment_font_runs = clip_font_runs(font_runs, range.clone());
588        let segment =
589            self.layout_line_no_separators(&text[range.clone()], font_size, &segment_font_runs);
590
591        let mut segment_runs = segment.runs;
592        for run in &mut segment_runs {
593            for glyph in &mut run.glyphs {
594                glyph.index += range.start;
595                glyph.position.x += layout.width;
596            }
597        }
598
599        for mut run in segment_runs {
600            if let Some(same_run) = layout
601                .runs
602                .last_mut()
603                .filter(|last| last.font_id == run.font_id)
604            {
605                same_run.glyphs.append(&mut run.glyphs);
606            } else {
607                layout.runs.push(run);
608            }
609        }
610
611        layout.width += segment.width;
612        layout.ascent = layout.ascent.max(segment.ascent);
613        layout.descent = layout.descent.max(segment.descent);
614    }
615
616    fn layout_line_no_separators(
617        &mut self,
618        text: &str,
619        font_size: Pixels,
620        font_runs: &[FontRun],
621    ) -> LineLayout {
622        let mut attrs_list = AttrsList::new(&Attrs::new());
623        let mut offs = 0;
624        for run in font_runs {
625            let run_end = offs + run.len;
626
627            let Some(properties) = self.font_match_properties(run.font_id) else {
628                offs = run_end;
629                continue;
630            };
631
632            let primary_attrs = properties.attributes(run.font_id, &properties.primary_family_name);
633            let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = properties
634                .fallback_chain
635                .iter()
636                .map(|(font_id, family_name)| properties.attributes(*font_id, family_name))
637                .collect();
638
639            let spans = if properties.fallback_chain.is_empty() {
640                let mut spans = SmallVec::<[RunSpan; 4]>::new();
641                spans.push(RunSpan {
642                    start: offs,
643                    end: run_end,
644                    slot: None,
645                    font_id: run.font_id,
646                });
647                spans
648            } else {
649                let loaded_fonts = &self.loaded_fonts;
650                let covers = |id: FontId, ch: char| charmap_covers(loaded_fonts, id, ch);
651                compute_run_spans(
652                    text,
653                    offs,
654                    run.len,
655                    run.font_id,
656                    &properties.fallback_chain,
657                    &covers,
658                )
659            };
660
661            for span in spans {
662                let attrs = match span.slot {
663                    None => &primary_attrs,
664                    Some(ix) => &fallback_attrs[ix],
665                };
666                attrs_list.add_span(span.start..span.end, attrs);
667            }
668            offs = run_end;
669        }
670
671        let line = ShapeLine::new(
672            &mut self.font_system,
673            text,
674            &attrs_list,
675            cosmic_text::Shaping::Advanced,
676            4,
677        );
678        let mut layout_lines = Vec::with_capacity(1);
679        line.layout_to_buffer(
680            &mut self.scratch,
681            f32::from(font_size),
682            None, // We do our own wrapping
683            cosmic_text::Wrap::None,
684            Ellipsize::None,
685            None,
686            &mut layout_lines,
687            None,
688            cosmic_text::Hinting::Disabled,
689        );
690
691        let Some(layout) = layout_lines.first() else {
692            return LineLayout {
693                font_size,
694                width: Pixels::ZERO,
695                ascent: Pixels::ZERO,
696                descent: Pixels::ZERO,
697                runs: Vec::new(),
698                len: text.len(),
699            };
700        };
701
702        let mut runs: Vec<ShapedRun> = Vec::new();
703        for glyph in &layout.glyphs {
704            let mut font_id = FontId(glyph.metadata);
705            let mut loaded_font = self.loaded_font(font_id);
706            if loaded_font.font.id() != glyph.font_id {
707                match self.font_id_for_cosmic_id(glyph.font_id) {
708                    std::result::Result::Ok(resolved_id) => {
709                        font_id = resolved_id;
710                        loaded_font = self.loaded_font(font_id);
711                    }
712                    Err(error) => {
713                        log::warn!(
714                            "failed to resolve cosmic font id {:?}: {error:#}",
715                            glyph.font_id
716                        );
717                        continue;
718                    }
719                }
720            }
721            let is_emoji = loaded_font.is_known_emoji_font;
722
723            // HACK: Prevent crash caused by variation selectors.
724            if glyph.glyph_id == 3 && is_emoji {
725                continue;
726            }
727
728            let shaped_glyph = ShapedGlyph {
729                id: GlyphId(glyph.glyph_id as u32),
730                position: point(glyph.x.into(), glyph.y.into()),
731                index: glyph.start,
732                is_emoji,
733            };
734
735            if let Some(last_run) = runs
736                .last_mut()
737                .filter(|last_run| last_run.font_id == font_id)
738            {
739                last_run.glyphs.push(shaped_glyph);
740            } else {
741                runs.push(ShapedRun {
742                    font_id,
743                    glyphs: vec![shaped_glyph],
744                });
745            }
746        }
747
748        LineLayout {
749            font_size,
750            width: layout.w.into(),
751            ascent: layout.max_ascent.into(),
752            descent: layout.max_descent.into(),
753            runs,
754            len: text.len(),
755        }
756    }
757}
758
759#[inline(always)]
760fn is_paragraph_separator(character: char) -> bool {
761    unicode_bidi::bidi_class(character) == unicode_bidi::BidiClass::B
762}
763
764fn contains_paragraph_separator(text: &str) -> bool {
765    if text
766        .bytes()
767        .any(|byte| matches!(byte, b'\n' | b'\r' | 0x1c | 0x1d | 0x1e))
768    {
769        return true;
770    }
771
772    !text.is_ascii() && text.chars().any(is_paragraph_separator)
773}
774
775fn clip_font_runs(font_runs: &[FontRun], range: Range<usize>) -> SmallVec<[FontRun; 4]> {
776    let mut clipped = SmallVec::new();
777    let mut offs = 0;
778    for run in font_runs {
779        let run_start = offs;
780        offs += run.len;
781        if offs <= range.start {
782            continue;
783        }
784        if run_start >= range.end {
785            break;
786        }
787        let start = run_start.max(range.start);
788        let end = offs.min(range.end);
789        if start < end {
790            clipped.push(FontRun {
791                len: end - start,
792                font_id: run.font_id,
793            });
794        }
795    }
796    clipped
797}
798
799#[cfg(feature = "font-kit")]
800fn find_best_match(
801    font: &Font,
802    candidates: &[FontId],
803    state: &CosmicTextSystemState,
804) -> Result<usize> {
805    let candidate_properties = candidates
806        .iter()
807        .map(|font_id| {
808            let database_id = state.loaded_font(*font_id).font.id();
809            let face_info = state
810                .font_system
811                .db()
812                .face(database_id)
813                .context("font face not found in database")?;
814            Ok(face_info_into_properties(face_info))
815        })
816        .collect::<Result<SmallVec<[_; 4]>>>()?;
817
818    let ix =
819        font_kit::matching::find_best_match(&candidate_properties, &font_into_properties(font))
820            .context("requested font family contains no font matching the other parameters")?;
821
822    Ok(ix)
823}
824
825#[cfg(not(feature = "font-kit"))]
826fn find_best_match(
827    font: &Font,
828    candidates: &[FontId],
829    state: &CosmicTextSystemState,
830) -> Result<usize> {
831    if candidates.is_empty() {
832        anyhow::bail!("requested font family contains no font matching the other parameters");
833    }
834    if candidates.len() == 1 {
835        return Ok(0);
836    }
837
838    let target_weight = font.weight.0;
839    let target_italic = matches!(
840        font.style,
841        gpui::FontStyle::Italic | gpui::FontStyle::Oblique
842    );
843
844    let mut best_index = 0;
845    let mut best_score = u32::MAX;
846
847    for (index, font_id) in candidates.iter().enumerate() {
848        let database_id = state.loaded_font(*font_id).font.id();
849        let face_info = state
850            .font_system
851            .db()
852            .face(database_id)
853            .context("font face not found in database")?;
854
855        let is_italic = matches!(
856            face_info.style,
857            cosmic_text::Style::Italic | cosmic_text::Style::Oblique
858        );
859        let style_penalty: u32 = if is_italic == target_italic { 0 } else { 1000 };
860        let weight_diff = (face_info.weight.0 as i32 - target_weight as i32).unsigned_abs();
861        let score = style_penalty + weight_diff;
862
863        if score < best_score {
864            best_score = score;
865            best_index = index;
866        }
867    }
868
869    Ok(best_index)
870}
871
872/// one contiguous slice of a `FontRun` that maps to a single slot. `slot` is
873/// `None` for the primary font and `Some(ix)` for `fallback_chain[ix]`.
874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
875struct RunSpan {
876    start: usize,
877    end: usize,
878    slot: Option<usize>,
879    font_id: FontId,
880}
881
882/// walks `text[run_offset..run_offset + run_len]` and groups codepoints into
883/// spans. inheriting codepoints stay in the current span so shaping clusters
884/// like emoji zwj sequences and combining marks are not torn apart.
885fn compute_run_spans(
886    text: &str,
887    run_offset: usize,
888    run_len: usize,
889    primary: FontId,
890    fallback_chain: &[(FontId, SharedString)],
891    covers: &impl Fn(FontId, char) -> bool,
892) -> SmallVec<[RunSpan; 4]> {
893    let mut spans = SmallVec::new();
894    let run_end = run_offset + run_len;
895    if run_end <= run_offset {
896        return spans;
897    }
898    if fallback_chain.is_empty() {
899        spans.push(RunSpan {
900            start: run_offset,
901            end: run_end,
902            slot: None,
903            font_id: primary,
904        });
905        return spans;
906    }
907    let run_text = &text[run_offset..run_end];
908    let mut span_start = run_offset;
909    let mut span_slot: Option<usize> = None;
910    let mut span_font_id = primary;
911    for (grapheme_idx, grapheme) in run_text.grapheme_indices(true) {
912        let abs = run_offset + grapheme_idx;
913        let ch = grapheme.chars().next().unwrap_or('\0');
914        let next_slot = pick_covering_slot(ch, span_slot, primary, fallback_chain, covers);
915        if next_slot == span_slot {
916            continue;
917        }
918        if abs > span_start {
919            spans.push(RunSpan {
920                start: span_start,
921                end: abs,
922                slot: span_slot,
923                font_id: span_font_id,
924            });
925        }
926        span_start = abs;
927        span_slot = next_slot;
928        span_font_id = slot_font_id(next_slot, primary, fallback_chain);
929    }
930    if span_start < run_end {
931        spans.push(RunSpan {
932            start: span_start,
933            end: run_end,
934            slot: span_slot,
935            font_id: span_font_id,
936        });
937    }
938    spans
939}
940
941fn slot_font_id(
942    slot: Option<usize>,
943    primary: FontId,
944    fallback_chain: &[(FontId, SharedString)],
945) -> FontId {
946    match slot {
947        None => primary,
948        Some(ix) => fallback_chain[ix].0,
949    }
950}
951
952fn pick_covering_slot(
953    ch: char,
954    current: Option<usize>,
955    primary: FontId,
956    fallback_chain: &[(FontId, SharedString)],
957    covers: &impl Fn(FontId, char) -> bool,
958) -> Option<usize> {
959    if (ch as u32) <= 0x7F {
960        return None;
961    }
962    if covers(primary, ch) {
963        return None;
964    }
965    let current_id = slot_font_id(current, primary, fallback_chain);
966    if covers(current_id, ch) {
967        return current;
968    }
969
970    fallback_chain
971        .iter()
972        .position(|(fb_id, _)| covers(*fb_id, ch))
973}
974
975fn charmap_covers(loaded_fonts: &[LoadedFont], id: FontId, ch: char) -> bool {
976    loaded_fonts
977        .get(id.0)
978        .is_some_and(|loaded| loaded.font.as_swash().charmap().map(ch) != 0)
979}
980
981fn cosmic_font_features(features: &FontFeatures) -> Result<CosmicFontFeatures> {
982    let mut result = CosmicFontFeatures::new();
983    for feature in features.0.iter() {
984        let name_bytes: [u8; 4] = feature
985            .0
986            .as_bytes()
987            .try_into()
988            .context("Incorrect feature flag format")?;
989
990        let tag = cosmic_text::FeatureTag::new(&name_bytes);
991
992        result.set(tag, feature.1);
993    }
994    Ok(result)
995}
996
997#[cfg(feature = "font-kit")]
998fn font_into_properties(font: &gpui::Font) -> font_kit::properties::Properties {
999    font_kit::properties::Properties {
1000        style: match font.style {
1001            gpui::FontStyle::Normal => font_kit::properties::Style::Normal,
1002            gpui::FontStyle::Italic => font_kit::properties::Style::Italic,
1003            gpui::FontStyle::Oblique => font_kit::properties::Style::Oblique,
1004        },
1005        weight: font_kit::properties::Weight(font.weight.0),
1006        stretch: Default::default(),
1007    }
1008}
1009
1010#[cfg(feature = "font-kit")]
1011fn face_info_into_properties(
1012    face_info: &cosmic_text::fontdb::FaceInfo,
1013) -> font_kit::properties::Properties {
1014    font_kit::properties::Properties {
1015        style: match face_info.style {
1016            cosmic_text::Style::Normal => font_kit::properties::Style::Normal,
1017            cosmic_text::Style::Italic => font_kit::properties::Style::Italic,
1018            cosmic_text::Style::Oblique => font_kit::properties::Style::Oblique,
1019        },
1020        weight: font_kit::properties::Weight(face_info.weight.0.into()),
1021        stretch: match face_info.stretch {
1022            cosmic_text::Stretch::Condensed => font_kit::properties::Stretch::CONDENSED,
1023            cosmic_text::Stretch::Expanded => font_kit::properties::Stretch::EXPANDED,
1024            cosmic_text::Stretch::ExtraCondensed => font_kit::properties::Stretch::EXTRA_CONDENSED,
1025            cosmic_text::Stretch::ExtraExpanded => font_kit::properties::Stretch::EXTRA_EXPANDED,
1026            cosmic_text::Stretch::Normal => font_kit::properties::Stretch::NORMAL,
1027            cosmic_text::Stretch::SemiCondensed => font_kit::properties::Stretch::SEMI_CONDENSED,
1028            cosmic_text::Stretch::SemiExpanded => font_kit::properties::Stretch::SEMI_EXPANDED,
1029            cosmic_text::Stretch::UltraCondensed => font_kit::properties::Stretch::ULTRA_CONDENSED,
1030            cosmic_text::Stretch::UltraExpanded => font_kit::properties::Stretch::ULTRA_EXPANDED,
1031        },
1032    }
1033}
1034
1035fn check_is_known_emoji_font(postscript_name: &str) -> bool {
1036    // TODO: Include other common emoji fonts
1037    postscript_name == "NotoColorEmoji"
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043
1044    fn fid(i: usize) -> FontId {
1045        FontId(i)
1046    }
1047
1048    fn chain(ids: &[usize]) -> SmallVec<[(FontId, SharedString); 4]> {
1049        ids.iter()
1050            .map(|&i| (fid(i), SharedString::from(format!("fb{i}"))))
1051            .collect()
1052    }
1053
1054    fn span(start: usize, end: usize, slot: Option<usize>, font_id: FontId) -> RunSpan {
1055        RunSpan {
1056            start,
1057            end,
1058            slot,
1059            font_id,
1060        }
1061    }
1062
1063    const IBM_PLEX: &[u8] =
1064        include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
1065
1066    /// Every code point of `Bidi_Class=B`, each of which starts a new bidi
1067    /// paragraph and so can split one line into mixed-direction paragraphs.
1068    const SEPARATORS: &[char] = &[
1069        '\u{000a}', '\u{000d}', '\u{001c}', '\u{001d}', '\u{001e}', '\u{0085}', '\u{2029}',
1070    ];
1071
1072    fn text_system() -> Result<CosmicTextSystem> {
1073        let text_system = CosmicTextSystem::new_without_system_fonts("IBM Plex Sans");
1074        text_system.add_fonts(vec![Cow::Borrowed(IBM_PLEX)])?;
1075        Ok(text_system)
1076    }
1077
1078    fn layout_text(text_system: &CosmicTextSystem, text: &str) -> Result<LineLayout> {
1079        let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?;
1080        let runs = [FontRun {
1081            len: text.len(),
1082            font_id,
1083        }];
1084        Ok(text_system.layout_line(text, gpui::px(14.0), &runs))
1085    }
1086
1087    /// Mirrors the original crash: mixed-direction text reaching the shaper
1088    /// through `shape_text`, which only splits lines on `\n`.
1089    #[test]
1090    fn shape_text_with_mixed_direction_paragraphs() -> Result<()> {
1091        let platform_text_system = Arc::new(text_system()?);
1092        let text_system = Arc::new(gpui::TextSystem::new(platform_text_system));
1093        let window_text_system = gpui::WindowTextSystem::new(text_system);
1094
1095        let text: SharedString = "first line\n\u{05d0}\u{001c}A".into();
1096        let runs = [gpui::TextRun {
1097            len: text.len(),
1098            font: gpui::font("IBM Plex Sans"),
1099            ..Default::default()
1100        }];
1101
1102        let lines = window_text_system.shape_text(text, gpui::px(14.0), &runs, None, None)?;
1103
1104        assert_eq!(lines.len(), 2);
1105        assert_eq!(lines[1].len(), "\u{05d0}\u{001c}A".len());
1106        assert!(lines[1].width() > Pixels::ZERO);
1107        Ok(())
1108    }
1109
1110    #[test]
1111    fn layout_line_with_mixed_direction_paragraphs() -> Result<()> {
1112        let text_system = text_system()?;
1113
1114        for separator in SEPARATORS {
1115            for text in [
1116                format!("\u{05d0}{separator}A"),
1117                format!("A{separator}\u{05d0}"),
1118            ] {
1119                let layout = layout_text(&text_system, &text)?;
1120
1121                assert_eq!(layout.len, text.len(), "{text:?}");
1122                assert!(layout.width > Pixels::ZERO, "{text:?}");
1123                assert!(
1124                    layout.runs.iter().any(|run| !run.glyphs.is_empty()),
1125                    "{text:?}"
1126                );
1127            }
1128        }
1129
1130        Ok(())
1131    }
1132
1133    #[test]
1134    fn layout_line_with_separators_at_line_edges() -> Result<()> {
1135        let text_system = text_system()?;
1136
1137        for text in [
1138            "\u{001c}",
1139            "\u{001c}\u{001c}",
1140            "\u{001c}\u{05d0}",
1141            "\u{05d0}\u{001c}",
1142            "\u{05d0}\u{001c}\u{001c}A",
1143            "\u{001c}\u{05d0}\u{001c}A\u{001c}",
1144        ] {
1145            let layout = layout_text(&text_system, text)?;
1146            assert_eq!(layout.len, text.len(), "{text:?}");
1147        }
1148
1149        Ok(())
1150    }
1151
1152    /// Glyph indices must stay absolute and positions ordered across segment
1153    /// boundaries, otherwise cursor placement and hit testing desync. Uses
1154    /// single-direction text so visual order matches logical order.
1155    #[test]
1156    fn layout_line_keeps_indices_and_positions_ordered_across_paragraphs() -> Result<()> {
1157        let text_system = text_system()?;
1158        let text = "ab\u{001c}cd\u{2029}ef";
1159        let layout = layout_text(&text_system, text)?;
1160
1161        let glyphs: Vec<_> = layout.runs.iter().flat_map(|run| &run.glyphs).collect();
1162        assert!(!glyphs.is_empty());
1163
1164        for glyph in &glyphs {
1165            assert!(glyph.index < text.len(), "{:?}", glyph.index);
1166            assert!(text.is_char_boundary(glyph.index), "{:?}", glyph.index);
1167        }
1168        for pair in glyphs.windows(2) {
1169            assert!(pair[0].index < pair[1].index);
1170            assert!(pair[0].position.x <= pair[1].position.x);
1171        }
1172
1173        // Every segment contributes width, so the whole line is wider than its
1174        // leading paragraph alone.
1175        assert!(layout.width > layout_text(&text_system, "ab")?.width);
1176        Ok(())
1177    }
1178
1179    /// A font run boundary that does not line up with a paragraph boundary must
1180    /// still be clipped to the right segments.
1181    #[test]
1182    fn layout_line_with_font_run_straddling_a_separator() -> Result<()> {
1183        let text_system = text_system()?;
1184        let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?;
1185        let text = "ab\u{001c}\u{05d0}\u{05d1}";
1186
1187        // The run boundary falls inside the trailing RTL paragraph.
1188        let runs = [
1189            FontRun {
1190                len: "ab\u{001c}\u{05d0}".len(),
1191                font_id,
1192            },
1193            FontRun {
1194                len: "\u{05d1}".len(),
1195                font_id,
1196            },
1197        ];
1198        let layout = text_system.layout_line(text, gpui::px(14.0), &runs);
1199
1200        assert_eq!(layout.len, text.len());
1201        assert!(layout.width > Pixels::ZERO);
1202        Ok(())
1203    }
1204
1205    /// Lines with no separator take the fast path and must be shaped exactly as
1206    /// they were before paragraph splitting existed.
1207    #[test]
1208    fn layout_line_without_separators_takes_fast_path() -> Result<()> {
1209        let text_system = text_system()?;
1210
1211        for text in [
1212            "hello world",
1213            "\u{05d0}\u{05d1}\u{05d2}",
1214            "mixed \u{05d0}\u{05d1}",
1215        ] {
1216            assert!(!contains_paragraph_separator(text), "{text:?}");
1217            let layout = layout_text(&text_system, text)?;
1218            assert_eq!(layout.len, text.len(), "{text:?}");
1219            assert!(layout.width > Pixels::ZERO, "{text:?}");
1220        }
1221
1222        Ok(())
1223    }
1224
1225    #[test]
1226    fn paragraph_separator_detection() {
1227        for separator in SEPARATORS {
1228            assert!(is_paragraph_separator(*separator), "{separator:?}");
1229            assert!(contains_paragraph_separator(&format!("a{separator}b")));
1230        }
1231
1232        for text in [
1233            "",
1234            "plain ascii",
1235            "\u{05d0}",
1236            "tab\there",
1237            "emoji \u{1f600}",
1238        ] {
1239            assert!(!contains_paragraph_separator(text), "{text:?}");
1240        }
1241    }
1242
1243    #[test]
1244    fn font_runs_are_clipped_to_segment() {
1245        let runs = [
1246            FontRun {
1247                len: 3,
1248                font_id: fid(1),
1249            },
1250            FontRun {
1251                len: 4,
1252                font_id: fid(2),
1253            },
1254        ];
1255
1256        assert_eq!(clip_font_runs(&runs, 0..7).as_slice(), &runs);
1257        assert_eq!(
1258            clip_font_runs(&runs, 2..5).as_slice(),
1259            &[
1260                FontRun {
1261                    len: 1,
1262                    font_id: fid(1)
1263                },
1264                FontRun {
1265                    len: 2,
1266                    font_id: fid(2)
1267                },
1268            ]
1269        );
1270        assert_eq!(
1271            clip_font_runs(&runs, 3..7).as_slice(),
1272            &[FontRun {
1273                len: 4,
1274                font_id: fid(2)
1275            }]
1276        );
1277        assert!(clip_font_runs(&runs, 5..5).is_empty());
1278    }
1279
1280    #[test]
1281    fn primary_wins_over_current_fallback_when_primary_covers() {
1282        let primary = fid(0);
1283        let fb = chain(&[1, 2]);
1284        let covers = |id: FontId, _: char| id == fid(0) || id == fid(1);
1285        assert_eq!(
1286            pick_covering_slot('a', Some(0), primary, &fb, &covers),
1287            None
1288        );
1289    }
1290
1291    #[test]
1292    fn primary_preferred_over_fallback_when_both_cover() {
1293        let primary = fid(0);
1294        let fb = chain(&[1]);
1295        let covers = |_: FontId, _: char| true;
1296        assert_eq!(pick_covering_slot('a', None, primary, &fb, &covers), None);
1297    }
1298
1299    #[test]
1300    fn falls_through_chain_in_order() {
1301        let primary = fid(0);
1302        let fb = chain(&[1, 2, 3]);
1303        // only fallback 2 at index 1 covers.
1304        let covers = |id: FontId, _: char| id == fid(2);
1305        assert_eq!(
1306            pick_covering_slot('字', None, primary, &fb, &covers),
1307            Some(1)
1308        );
1309    }
1310
1311    #[test]
1312    fn no_coverage_returns_primary() {
1313        let primary = fid(0);
1314        let fb = chain(&[1, 2]);
1315        let covers = |_: FontId, _: char| false;
1316        // nothing covers. return `None` so the `cosmic-text` built in script
1317        // fallback can take over during shaping.
1318        assert_eq!(
1319            pick_covering_slot('\u{1F600}', Some(1), primary, &fb, &covers),
1320            None
1321        );
1322    }
1323
1324    #[test]
1325    fn empty_chain_always_returns_primary() {
1326        let primary = fid(0);
1327        let fb: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new();
1328        let covers = |_: FontId, _: char| false;
1329        assert_eq!(pick_covering_slot('a', None, primary, &fb, &covers), None);
1330    }
1331
1332    #[test]
1333    fn slot_font_id_resolution() {
1334        let primary = fid(7);
1335        let fb = chain(&[10, 20]);
1336        assert_eq!(slot_font_id(None, primary, &fb), fid(7));
1337        assert_eq!(slot_font_id(Some(0), primary, &fb), fid(10));
1338        assert_eq!(slot_font_id(Some(1), primary, &fb), fid(20));
1339    }
1340
1341    #[test]
1342    fn run_spans_with_no_chain_emit_one_primary_span() {
1343        let primary = fid(0);
1344        let fb: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new();
1345        let covers = |_: FontId, _: char| false;
1346        let text = "hello";
1347        let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers);
1348        assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]);
1349    }
1350
1351    #[test]
1352    fn run_spans_use_byte_offsets_for_multibyte_chars() {
1353        let primary = fid(0);
1354        let fb = chain(&[1]);
1355        // primary covers ascii. fallback covers cjk.
1356        let covers = |id: FontId, ch: char| {
1357            if id == primary {
1358                ch.is_ascii()
1359            } else {
1360                !ch.is_ascii()
1361            }
1362        };
1363        let text = "a字b";
1364        let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers);
1365        // '字' is 3 bytes so split is at 1 then 4.
1366        assert_eq!(
1367            spans.as_slice(),
1368            &[
1369                span(0, 1, None, primary),
1370                span(1, 4, Some(0), fid(1)),
1371                span(4, 5, None, primary),
1372            ]
1373        );
1374    }
1375
1376    #[test]
1377    fn run_spans_respect_run_offset() {
1378        let primary = fid(0);
1379        let fb = chain(&[1]);
1380        let covers = |id: FontId, ch: char| {
1381            if id == primary {
1382                ch.is_ascii()
1383            } else {
1384                !ch.is_ascii()
1385            }
1386        };
1387        // outer text has a prefix that is not part of this run.
1388        let text = "xx字y";
1389        let run_offset = 2;
1390        let run_len = text.len() - run_offset;
1391        let spans = compute_run_spans(text, run_offset, run_len, primary, &fb, &covers);
1392        assert_eq!(
1393            spans.as_slice(),
1394            &[span(2, 5, Some(0), fid(1)), span(5, 6, None, primary)]
1395        );
1396    }
1397
1398    #[test]
1399    fn run_spans_keep_combining_marks_with_base_in_fallback() {
1400        let primary = fid(0);
1401        let fb = chain(&[1]);
1402        // primary covers ascii only. fallback covers the base char.
1403        // combining mark must stay in the fallback span even when fallback
1404        // does not advertise coverage of it.
1405        let covers = |id: FontId, ch: char| {
1406            if id == primary {
1407                ch.is_ascii()
1408            } else {
1409                ch == '\u{0905}'
1410            }
1411        };
1412        // \u{0905} devanagari short a + \u{0902} candrabindu mark.
1413        let text = "\u{0905}\u{0902}";
1414        let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers);
1415        assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]);
1416    }
1417
1418    #[test]
1419    fn run_spans_keep_zwj_inside_emoji_cluster() {
1420        let primary = fid(0);
1421        let fb = chain(&[1]);
1422        // only fallback covers the emoji codepoints. zwj must not split.
1423        let covers = |id: FontId, ch: char| id == fid(1) && ch != '\u{200D}';
1424        // family zwj sequence woman zwj girl.
1425        let text = "\u{1F469}\u{200D}\u{1F467}";
1426        let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers);
1427        assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]);
1428    }
1429
1430    #[test]
1431    fn run_spans_collapse_adjacent_same_slot() {
1432        let primary = fid(0);
1433        let fb = chain(&[1]);
1434        let covers = |id: FontId, ch: char| {
1435            if id == primary {
1436                ch.is_ascii()
1437            } else {
1438                !ch.is_ascii()
1439            }
1440        };
1441        let text = "字字字";
1442        let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers);
1443        assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]);
1444    }
1445
1446    #[test]
1447    fn run_spans_empty_run_returns_no_spans() {
1448        let primary = fid(0);
1449        let fb = chain(&[1]);
1450        let covers = |_: FontId, _: char| true;
1451        let spans = compute_run_spans("anything", 3, 0, primary, &fb, &covers);
1452        assert!(spans.is_empty());
1453    }
1454}