Skip to main content

crabshell_ui/
text.rs

1//! Text: finding fonts through fontconfig, and the cosmic-text buffer each
2//! [`Text`](crate::Text) widget is measured with and drawn from.
3//!
4//! Scanning every installed font would cost tens of milliseconds at startup,
5//! so the font database starts empty. Each family a style names is resolved
6//! through fontconfig and only the file it matched is loaded. When a text
7//! uses a character none of the loaded fonts has, fontconfig names a font
8//! that has it, that file is loaded too, and the text is shaped again.
9
10use std::collections::{HashMap, HashSet};
11use std::ffi::CString;
12use std::path::PathBuf;
13use std::rc::Rc;
14
15use cosmic_text::fontdb::{Database, Source};
16use cosmic_text::{
17  Attrs, Buffer, Ellipsize, EllipsizeHeightLimit, Family, FontSystem, LayoutGlyph, LayoutRun,
18  Metrics, Shaping, Weight, Wrap,
19};
20use fontconfig::{CharSet, FC_FAMILY, FC_WEIGHT, Fontconfig, Pattern};
21use taffy::AvailableSpace;
22
23use crate::geometry::{Point, Rect};
24use crate::style::Font;
25
26/// The fonts every window measures and draws text with.
27///
28/// There is one per process. The platform passes it to
29/// [`Window::layout`](crate::Window::layout), and the renderer rasterizes
30/// glyphs with its [`font_system`](Self::font_system).
31pub struct TextSystem {
32  fonts: FontSystem,
33  fontconfig: Lookup,
34  /// The family name each requested family and weight resolved to, as the
35  /// font database knows it. `None` if nothing was found.
36  families: HashMap<(&'static str, u16), Option<Rc<str>>>,
37  /// The family name of each face asked for, by file and face number.
38  loaded: HashMap<(PathBuf, u32), Option<Rc<str>>>,
39  /// Files read into the database. Reading one loads all its faces.
40  files: HashSet<PathBuf>,
41  /// Characters a fallback font was already looked up for, by the family
42  /// and weight asked for: fontconfig's answer depends on both.
43  fallback_tried: HashSet<(char, &'static str, u16)>,
44}
45
46/// fontconfig, started on first use because that costs about 15 ms.
47enum Lookup {
48  NotStarted,
49  Ready(Fontconfig),
50  Failed,
51}
52
53impl TextSystem {
54  /// An empty font database. Nothing is loaded until text is laid out.
55  pub fn new() -> TextSystem {
56    // cosmic-text needs a locale. Reading the system's would take another
57    // crate, and it only affects which script-specific fallbacks it tries.
58    let fonts = FontSystem::new_with_locale_and_db("en-US".to_owned(), Database::new());
59    TextSystem {
60      fonts,
61      fontconfig: Lookup::NotStarted,
62      families: HashMap::new(),
63      loaded: HashMap::new(),
64      files: HashSet::new(),
65      fallback_tried: HashSet::new(),
66    }
67  }
68
69  /// cosmic-text's font system, for rasterizing glyphs.
70  pub fn font_system(&mut self) -> &mut FontSystem {
71    &mut self.fonts
72  }
73
74  fn fontconfig(&mut self) -> Option<&Fontconfig> {
75    if let Lookup::NotStarted = self.fontconfig {
76      self.fontconfig = match Fontconfig::new() {
77        Some(fontconfig) => Lookup::Ready(fontconfig),
78        None => {
79          tracing::error!("fontconfig failed to start, so no fonts can be loaded");
80          Lookup::Failed
81        }
82      };
83    }
84    match &self.fontconfig {
85      Lookup::Ready(fontconfig) => Some(fontconfig),
86      _ => None,
87    }
88  }
89
90  /// The name to ask cosmic-text for to get `font`'s family and weight,
91  /// loading the file fontconfig matches the first time. `None`, logged
92  /// once, if no font could be loaded.
93  pub(crate) fn family(&mut self, font: &Font) -> Option<Rc<str>> {
94    let key = (font.family, font.weight);
95    if let Some(name) = self.families.get(&key) {
96      return name.clone();
97    }
98    let matched = self
99      .fontconfig()
100      .and_then(|fontconfig| match_font(fontconfig, font.family, font.weight, None));
101    let name = matched.and_then(|file| self.load(file));
102    match &name {
103      Some(name) => tracing::debug!(requested = font.family, family = %name, "font loaded"),
104      None => tracing::warn!(
105        requested = font.family,
106        "no font found for this family, so its text shows nothing"
107      ),
108    }
109    self.families.insert(key, name.clone());
110    name
111  }
112
113  /// Loads fontconfig's best font for each character in `missing` that
114  /// no loaded font has, preferring `font`'s family. Returns whether the
115  /// database gained a face, so the text should be shaped again.
116  fn load_fallbacks(&mut self, font: &Font, missing: &[char]) -> bool {
117    let faces = self.fonts.db().len();
118    for &ch in missing {
119      if !self.fallback_tried.insert((ch, font.family, font.weight)) {
120        continue;
121      }
122      let Some(file) = self
123        .fontconfig()
124        .and_then(|fontconfig| match_font(fontconfig, font.family, font.weight, Some(ch)))
125      else {
126        continue;
127      };
128      self.load(file);
129    }
130    self.fonts.db().len() > faces
131  }
132
133  /// Reads a font file into the database once, and returns the family name
134  /// of its face `index`.
135  pub(crate) fn load(&mut self, (path, index): (PathBuf, u32)) -> Option<Rc<str>> {
136    let key = (path, index);
137    if let Some(name) = self.loaded.get(&key) {
138      return name.clone();
139    }
140    let (path, index) = &key;
141    if !self.files.contains(path) {
142      self.files.insert(path.clone());
143      if let Err(error) = self.fonts.db_mut().load_font_file(path) {
144        tracing::warn!(path = %path.display(), %error, "couldn't read a font file");
145      }
146    }
147    let name = self
148      .fonts
149      .db()
150      .faces()
151      .find(|face| face.index == *index && source_path(&face.source) == Some(path))
152      .and_then(|face| face.families.first())
153      .map(|(name, _language)| Rc::from(name.as_str()));
154    self.loaded.insert(key, name.clone());
155    name
156  }
157}
158
159impl Default for TextSystem {
160  fn default() -> Self {
161    Self::new()
162  }
163}
164
165fn source_path(source: &Source) -> Option<&PathBuf> {
166  match source {
167    Source::File(path) | Source::SharedFile(path, _) => Some(path),
168    Source::Binary(_) => None,
169  }
170}
171
172/// Asks fontconfig for the font file and face index that best match
173/// `family` and `weight`, and that have `covering` if given.
174fn match_font(
175  fontconfig: &Fontconfig,
176  family: &str,
177  weight: u16,
178  covering: Option<char>,
179) -> Option<(PathBuf, u32)> {
180  let mut pattern = Pattern::new(fontconfig).ok()?;
181  pattern
182    .add_string(FC_FAMILY, &CString::new(family).ok()?)
183    .ok()?;
184  pattern.add_integer(FC_WEIGHT, fc_weight(weight)).ok()?;
185  if let Some(ch) = covering {
186    let mut chars = CharSet::new(fontconfig).ok()?;
187    chars.add_char(ch).ok()?;
188    pattern.add_charset(chars).ok()?;
189  }
190  let matched = pattern.font_match().ok()?;
191  let path = PathBuf::from(matched.filename().ok()?);
192  let index = matched.face_index().ok().map_or(0, face_number);
193  Some((path, index))
194}
195
196/// The face number fontdb uses for a fontconfig face index. fontconfig puts
197/// a variable font's named instance, such as Bold, in the upper 16 bits;
198/// fontdb numbers only the faces in the file, and cosmic-text sets the
199/// weight axis itself.
200pub(crate) fn face_number(index: i32) -> u32 {
201  // The mask leaves 0 to 65535, so the conversion can't fail.
202  u32::try_from(index & 0xFFFF).unwrap_or(0)
203}
204
205/// Converts a CSS weight (100 to 1000) to fontconfig's scale, which puts
206/// regular at 80 and bold at 200. The points are fontconfig's own
207/// OpenType table; weights between them are interpolated.
208pub(crate) fn fc_weight(weight: u16) -> i32 {
209  const POINTS: [(f32, f32); 12] = [
210    (100.0, 0.0),
211    (200.0, 40.0),
212    (300.0, 50.0),
213    (350.0, 55.0),
214    (380.0, 75.0),
215    (400.0, 80.0),
216    (500.0, 100.0),
217    (600.0, 180.0),
218    (700.0, 200.0),
219    (800.0, 205.0),
220    (900.0, 210.0),
221    (1000.0, 215.0),
222  ];
223  let weight = f32::from(weight).clamp(100.0, 1000.0);
224  let upper = POINTS
225    .iter()
226    .position(|&(css, _)| css >= weight)
227    .unwrap_or(POINTS.len() - 1)
228    .max(1);
229  let (css_low, fc_low) = POINTS[upper - 1];
230  let (css_high, fc_high) = POINTS[upper];
231  let fc = fc_low + (weight - css_low) / (css_high - css_low) * (fc_high - fc_low);
232  fc.round() as i32
233}
234
235/// The font size, falling back to the default for a size cosmic-text can't
236/// use, and a line height of 1.2 times it in whole pixels.
237fn metrics(font: &Font) -> Metrics {
238  let size = if font.size.is_finite() && font.size >= 1.0 {
239    font.size
240  } else {
241    Font::DEFAULT.size
242  };
243  Metrics::new(size, (size * 1.2).round())
244}
245
246/// The text of one [`Text`](crate::Text) widget, shaped and laid out by
247/// cosmic-text. Layout measures with this buffer and the renderer draws from
248/// it, so the measured and drawn sizes are the same.
249pub(crate) struct TextNode {
250  pub(crate) content: String,
251  pub(crate) ellipsis: bool,
252  /// At least 1.
253  pub(crate) max_lines: u32,
254  pub(crate) buffer: Buffer,
255  /// The font the buffer was shaped with; `None` until the first layout.
256  font: Option<Font>,
257  /// The content, `ellipsis` or `max_lines` changed since the buffer was
258  /// shaped.
259  stale: bool,
260  /// The top-left corner of the first line, set by layout.
261  pub(crate) origin: Point,
262}
263
264impl TextNode {
265  pub(crate) fn new() -> TextNode {
266    TextNode {
267      content: String::new(),
268      ellipsis: false,
269      max_lines: 1,
270      buffer: Buffer::new_empty(metrics(&Font::DEFAULT)),
271      font: None,
272      stale: true,
273      origin: Point::default(),
274    }
275  }
276
277  // Each setter returns whether the value changed, which needs a layout.
278
279  pub(crate) fn set_content(&mut self, content: String) -> bool {
280    self.set(|text| &mut text.content, content)
281  }
282
283  pub(crate) fn set_ellipsis(&mut self, ellipsis: bool) -> bool {
284    self.set(|text| &mut text.ellipsis, ellipsis)
285  }
286
287  pub(crate) fn set_max_lines(&mut self, max_lines: u32) -> bool {
288    self.set(|text| &mut text.max_lines, max_lines.max(1))
289  }
290
291  fn set<T: PartialEq>(&mut self, field: impl FnOnce(&mut TextNode) -> &mut T, value: T) -> bool {
292    let slot = field(self);
293    if *slot == value {
294      return false;
295    }
296    *slot = value;
297    self.stale = true;
298    true
299  }
300
301  /// Whether the text's width depends on the space it gets: it wraps or
302  /// ends in an ellipsis. Plain one-line text is always as wide as it is.
303  fn fits_width(&self) -> bool {
304    self.ellipsis || self.max_lines > 1
305  }
306
307  /// Shapes the text again if its content, settings or `font` changed,
308  /// loading fallback fonts for characters the font lacks. Returns whether
309  /// it did, so layout must measure it again.
310  pub(crate) fn prepare(&mut self, font: Font, text: &mut TextSystem) -> bool {
311    if !self.stale && self.font == Some(font) {
312      return false;
313    }
314    let family = text.family(&font);
315    self.buffer.set_metrics(metrics(&font));
316    // Words wrap; one too long for a line breaks inside it.
317    self.buffer.set_wrap(if self.max_lines > 1 {
318      Wrap::WordOrGlyph
319    } else {
320      Wrap::None
321    });
322    self.buffer.set_ellipsize(if self.ellipsis {
323      Ellipsize::End(EllipsizeHeightLimit::Lines(self.max_lines as usize))
324    } else {
325      Ellipsize::None
326    });
327    self.buffer.set_size(None, None);
328    self.shape(family.as_deref(), &font, text);
329    let missing = self.fallback_chars(family.as_deref(), text.font_system().db());
330    if !missing.is_empty() && text.load_fallbacks(&font, &missing) {
331      self.shape(family.as_deref(), &font, text);
332    }
333    self.font = Some(font);
334    self.stale = false;
335    true
336  }
337
338  fn shape(&mut self, family: Option<&str>, font: &Font, text: &mut TextSystem) {
339    let attrs = Attrs::new().weight(Weight(font.weight));
340    let attrs = match family {
341      Some(family) => attrs.family(Family::Name(family)),
342      None => attrs,
343    };
344    self
345      .buffer
346      .set_text(&self.content, &attrs, Shaping::Advanced, None);
347    self.buffer.shape_until_scroll(text.font_system(), false);
348  }
349
350  /// Characters that need a fallback font: those that came out as the
351  /// missing-glyph box, and those drawn from a face outside `family`.
352  /// The second kind may have come from a fallback loaded for another
353  /// weight or family, so fontconfig is asked again for this one.
354  fn fallback_chars(&self, family: Option<&str>, db: &Database) -> Vec<char> {
355    let in_family = |id| {
356      db.face(id).is_some_and(|face| {
357        face
358          .families
359          .iter()
360          .any(|(name, _)| Some(name.as_str()) == family)
361      })
362    };
363    let mut missing = Vec::new();
364    for run in self.buffer.layout_runs() {
365      let fallback = |glyph: &&LayoutGlyph| glyph.glyph_id == 0 || !in_family(glyph.font_id);
366      for glyph in run.glyphs.iter().filter(fallback) {
367        for ch in run.text.get(glyph.start..glyph.end).unwrap_or("").chars() {
368          if !missing.contains(&ch) {
369            missing.push(ch);
370          }
371        }
372      }
373    }
374    missing
375  }
376
377  /// The size taffy asks for: as wide as the text, or as `available`
378  /// allows for text that wraps or ends in an ellipsis, and one line
379  /// height per line shown. Sizes are rounded up to whole pixels so that
380  /// rounding the layout never cuts the text short.
381  pub(crate) fn measure(
382    &mut self,
383    fonts: &mut FontSystem,
384    known: taffy::Size<Option<f32>>,
385    available: taffy::Size<AvailableSpace>,
386  ) -> taffy::Size<f32> {
387    let width = if self.fits_width() {
388      known.width.or(match available.width {
389        AvailableSpace::Definite(width) => Some(width),
390        AvailableSpace::MinContent => Some(0.0),
391        AvailableSpace::MaxContent => None,
392      })
393    } else {
394      None
395    };
396    self.buffer.set_size(width, None);
397    self.buffer.shape_until_scroll(fonts, false);
398    taffy::Size {
399      width: known.width.unwrap_or_else(|| self.width().ceil()),
400      height: known.height.unwrap_or_else(|| self.height().ceil()),
401    }
402  }
403
404  /// Lays the text out in its final `content` box and centers it
405  /// vertically there.
406  pub(crate) fn finish(&mut self, content: Rect, fonts: &mut FontSystem) {
407    let width = self.fits_width().then_some(content.width);
408    self.buffer.set_size(width, None);
409    self.buffer.shape_until_scroll(fonts, false);
410    let top = ((content.height - self.height()) / 2.0).round();
411    self.origin = Point::new(content.x, content.y + top);
412  }
413
414  /// The area the glyphs may cover: the lines' box, which can stick out
415  /// of a widget too small for it, grown by an em left and right and half
416  /// an em above and below. Ink reaches past the line box: stacked accents
417  /// rise above it, and a combining mark at the start hangs left of the
418  /// text by nearly the width of the base it lacks.
419  pub(crate) fn ink_box(&self) -> Rect {
420    let em = self.buffer.metrics().font_size;
421    Rect::new(
422      self.origin.x - em,
423      self.origin.y - em / 2.0,
424      self.width().ceil() + 2.0 * em,
425      self.height() + em,
426    )
427  }
428
429  /// The lines shown, at most `max_lines`.
430  pub(crate) fn runs(&self) -> impl Iterator<Item = LayoutRun<'_>> {
431    self.buffer.layout_runs().take(self.max_lines as usize)
432  }
433
434  fn width(&self) -> f32 {
435    self.runs().map(|run| run.line_w).fold(0.0, f32::max)
436  }
437
438  fn height(&self) -> f32 {
439    self.runs().map(|run| run.line_height).sum()
440  }
441}