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