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 loaded_fonts: Vec<LoadedFont>,
50 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 user_fallback_chain: Arc<[(FontId, SharedString)]>,
63}
64
65struct FontMatchProperties {
66 primary_family_name: SharedString,
67 stretch: Stretch,
68 style: Style,
69 weight: Weight,
70 features: CosmicFontFeatures,
71 fallback_chain: Arc<[(FontId, SharedString)]>,
72}
73
74impl FontMatchProperties {
75 fn attributes<'a>(&'a self, font_id: FontId, family_name: &'a str) -> Attrs<'a> {
76 Attrs::new()
77 .metadata(font_id.0)
78 .family(Family::Name(family_name))
79 .stretch(self.stretch)
80 .style(self.style)
81 .weight(self.weight)
82 .font_features(self.features.clone())
83 }
84}
85
86impl CosmicTextSystem {
87 pub fn 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 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 let allowed_bad_font_names = [
371 "SegoeFluentIcons", "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 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 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 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 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, 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
898struct RunSpan {
899 start: usize,
900 end: usize,
901 slot: Option<usize>,
902 font_id: FontId,
903}
904
905fn 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 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 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(®ular)?;
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 #[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 #[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 assert!(layout.width > layout_text(&text_system, "ab")?.width);
1244 Ok(())
1245 }
1246
1247 #[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 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 #[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 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 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 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 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 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 let covers = |id: FontId, ch: char| {
1474 if id == primary {
1475 ch.is_ascii()
1476 } else {
1477 ch == '\u{0905}'
1478 }
1479 };
1480 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 let covers = |id: FontId, ch: char| id == fid(1) && ch != '\u{200D}';
1492 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}