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