Skip to main content

oxframe_text/
lib.rs

1//! Text for oxframe: layout with `parley`, glyph outlines with `skrifa`.
2//!
3//! > **Status:** oxframe is under very active development. The API is not stable yet and
4//! > may change from version to version. Pin the exact version in your Cargo.toml and check
5//! > the [changelog](https://github.com/oxygenframe/oxframe-sdk/blob/main/CHANGELOG.md)
6//! > before upgrading.
7//!
8//! Text becomes a [`BezPath`], so it renders identically on every backend
9//! and can be morphed and trimmed like any other path.
10//!
11//! Determinism: only embedded fonts are used — Inter and JetBrains Mono
12//! (SIL OFL 1.1, see `fonts/`) plus fonts registered with
13//! [`register_font`]. System fonts are never consulted.
14
15#![warn(missing_docs)]
16
17mod code;
18mod diff;
19
20pub use code::{Lang, Token, highlight};
21pub use diff::{
22    CodeDiff, EditError, LineEdit, Loc, Piece, PieceEdit, code_lines, diff_code, insert_at,
23    line_pieces, map_focus, remove_range, replace_range,
24};
25
26use std::cell::RefCell;
27use std::hash::{Hash, Hasher};
28use std::rc::Rc;
29
30use kurbo::{Affine, BezPath, Rect};
31use parley::fontique::{Blob, FontInfoOverride};
32use parley::{
33    Alignment, AlignmentOptions, FontContext, FontFamily, FontWeight, Layout, LayoutContext,
34    LineHeight, PositionedLayoutItem, StyleProperty,
35};
36use skrifa::instance::{LocationRef, Size};
37use skrifa::outline::{DrawSettings, OutlinePen};
38use skrifa::{FontRef, GlyphId, MetadataProvider};
39
40const INTER: &[u8] = include_bytes!("../fonts/Inter.ttf");
41const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono.ttf");
42
43/// Built-in sans-serif family (Inter).
44pub const SANS: &str = "Inter";
45/// Built-in monospace family (JetBrains Mono).
46pub const MONO: &str = "JetBrains Mono";
47
48/// Horizontal alignment of lines.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
50pub enum Align {
51    #[default]
52    /// Left.
53    Start,
54    /// Centered.
55    Center,
56    /// Right.
57    End,
58}
59
60/// How text is laid out.
61#[derive(Debug, Clone, PartialEq)]
62pub struct TextStyle {
63    /// Font family name: [`SANS`], [`MONO`] or a registered family.
64    pub family: String,
65    /// Font size in pixels.
66    pub size: f64,
67    /// 100..=900.
68    pub weight: f64,
69    /// Italic style.
70    pub italic: bool,
71    /// Multiple of the font size.
72    pub line_height: f64,
73    /// Extra space between letters, in pixels.
74    pub letter_spacing: f64,
75    /// Alignment of lines.
76    pub align: Align,
77    /// Wrap width; `None` means no wrapping.
78    pub max_width: Option<f64>,
79}
80
81impl Default for TextStyle {
82    fn default() -> Self {
83        Self {
84            family: SANS.to_owned(),
85            size: 48.0,
86            weight: 400.0,
87            italic: false,
88            line_height: 1.2,
89            letter_spacing: 0.0,
90            align: Align::Start,
91            max_width: None,
92        }
93    }
94}
95
96impl Hash for TextStyle {
97    fn hash<H: Hasher>(&self, h: &mut H) {
98        self.family.hash(h);
99        for v in [
100            self.size,
101            self.weight,
102            self.line_height,
103            self.letter_spacing,
104        ] {
105            v.to_bits().hash(h);
106        }
107        self.italic.hash(h);
108        self.align.hash(h);
109        self.max_width.map(f64::to_bits).hash(h);
110    }
111}
112
113/// A run of text with its own style; unset fields inherit the base style.
114#[derive(Debug, Clone, PartialEq, Default)]
115pub struct TextSpan {
116    /// The text.
117    pub text: String,
118    /// Font family.
119    pub family: Option<String>,
120    /// Font size in pixels.
121    pub size: Option<f64>,
122    /// Weight, 100..=900.
123    pub weight: Option<f64>,
124    /// Italic style.
125    pub italic: Option<bool>,
126}
127
128impl Hash for TextSpan {
129    fn hash<H: Hasher>(&self, h: &mut H) {
130        self.text.hash(h);
131        self.family.hash(h);
132        self.size.map(f64::to_bits).hash(h);
133        self.weight.map(f64::to_bits).hash(h);
134        self.italic.hash(h);
135    }
136}
137
138/// Where a glyph came from.
139#[derive(Debug, Clone, Copy, PartialEq)]
140pub struct GlyphInfo {
141    /// Index of the span (0 for plain text).
142    pub span: u16,
143    /// First character of the glyph's cluster.
144    pub ch: char,
145    /// Pen position of the glyph (baseline origin).
146    pub origin: kurbo::Point,
147}
148
149/// Laid-out text. Coordinates: origin at the top-left of the layout box,
150/// y pointing down.
151#[derive(Debug, Clone)]
152pub struct TextLayout {
153    /// All glyph outlines.
154    pub path: BezPath,
155    /// One path per glyph, in visual order — for per-letter animation.
156    pub glyphs: Vec<BezPath>,
157    /// Parallel to `glyphs`.
158    pub info: Vec<GlyphInfo>,
159    /// Width of the layout box.
160    pub width: f64,
161    /// Height of the layout box.
162    pub height: f64,
163}
164
165impl TextLayout {
166    /// The layout box as a rectangle.
167    pub fn bounds(&self) -> Rect {
168        Rect::new(0.0, 0.0, self.width, self.height)
169    }
170}
171
172struct Fonts {
173    font_cx: FontContext,
174    layout_cx: LayoutContext<u16>,
175    cache: Vec<(u64, Rc<TextLayout>)>,
176}
177
178const CACHE_SIZE: usize = 256;
179
180thread_local! {
181    static FONTS: RefCell<Fonts> = RefCell::new(Fonts::new());
182}
183
184impl Fonts {
185    fn new() -> Self {
186        let mut font_cx = FontContext::new();
187        for data in [INTER, JETBRAINS_MONO] {
188            font_cx
189                .collection
190                .register_fonts(Blob::new(std::sync::Arc::new(data)), None);
191        }
192        Self {
193            font_cx,
194            layout_cx: LayoutContext::new(),
195            cache: Vec::new(),
196        }
197    }
198}
199
200/// Registers a font (TTF/OTF/TTC bytes, e.g. from `include_bytes!`).
201/// Returns the family names it provides.
202pub fn register_font(data: &'static [u8]) -> Vec<String> {
203    FONTS.with(|f| {
204        let mut f = f.borrow_mut();
205        f.cache.clear();
206        let collection = &mut f.font_cx.collection;
207        let families = collection.register_fonts(
208            Blob::new(std::sync::Arc::new(data)),
209            None::<FontInfoOverride<'_>>,
210        );
211        families
212            .into_iter()
213            .filter_map(|(id, _)| collection.family_name(id).map(str::to_owned))
214            .collect()
215    })
216}
217
218/// Lays out `text` (cached by text and style).
219pub fn layout(text: &str, style: &TextStyle) -> Rc<TextLayout> {
220    layout_rich(
221        &[TextSpan {
222            text: text.to_owned(),
223            ..Default::default()
224        }],
225        style,
226    )
227}
228
229/// Lays out styled spans as one paragraph (cached).
230pub fn layout_rich(spans: &[TextSpan], style: &TextStyle) -> Rc<TextLayout> {
231    let mut h = std::hash::DefaultHasher::new();
232    spans.hash(&mut h);
233    style.hash(&mut h);
234    let key = h.finish();
235    FONTS.with(|f| {
236        let mut f = f.borrow_mut();
237        if let Some(pos) = f.cache.iter().position(|(k, _)| *k == key) {
238            let entry = f.cache.remove(pos);
239            let value = entry.1.clone();
240            f.cache.push(entry);
241            return value;
242        }
243        let value = Rc::new(build(&mut f, spans, style));
244        if f.cache.len() >= CACHE_SIZE {
245            f.cache.remove(0);
246        }
247        f.cache.push((key, value.clone()));
248        value
249    })
250}
251
252fn build(f: &mut Fonts, spans: &[TextSpan], style: &TextStyle) -> TextLayout {
253    let Fonts {
254        font_cx, layout_cx, ..
255    } = f;
256    let text: String = spans.iter().map(|s| s.text.as_str()).collect();
257    let text = text.as_str();
258    let mut b = layout_cx.ranged_builder(font_cx, text, 1.0, true);
259    b.push_default(StyleProperty::FontFamily(FontFamily::named(&style.family)));
260    b.push_default(StyleProperty::FontSize(style.size as f32));
261    b.push_default(StyleProperty::FontWeight(FontWeight::new(
262        style.weight as f32,
263    )));
264    if style.italic {
265        b.push_default(StyleProperty::FontStyle(parley::FontStyle::Italic));
266    }
267    b.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative(
268        style.line_height as f32,
269    )));
270    b.push_default(StyleProperty::LetterSpacing(style.letter_spacing as f32));
271    b.push_default(StyleProperty::Brush(0u16));
272    let mut at = 0;
273    for (i, span) in spans.iter().enumerate() {
274        let r = at..at + span.text.len();
275        at = r.end;
276        if r.is_empty() {
277            continue;
278        }
279        b.push(StyleProperty::Brush(i as u16), r.clone());
280        if let Some(f) = &span.family {
281            b.push(StyleProperty::FontFamily(FontFamily::named(f)), r.clone());
282        }
283        if let Some(sz) = span.size {
284            b.push(StyleProperty::FontSize(sz as f32), r.clone());
285        }
286        if let Some(w) = span.weight {
287            b.push(
288                StyleProperty::FontWeight(FontWeight::new(w as f32)),
289                r.clone(),
290            );
291        }
292        if let Some(it) = span.italic {
293            let st = if it {
294                parley::FontStyle::Italic
295            } else {
296                parley::FontStyle::Normal
297            };
298            b.push(StyleProperty::FontStyle(st), r.clone());
299        }
300    }
301    let mut layout: Layout<u16> = b.build(text);
302    layout.break_all_lines(style.max_width.map(|w| w as f32));
303    // parley aligns within the wrap width, but the block is as wide as its
304    // widest line: break again at that width (the lines stay the same), so
305    // centered and end-aligned lines sit inside the block's own box.
306    if style.align != Align::Start && style.max_width.is_some() {
307        let widest = layout.width();
308        layout.break_all_lines(Some(widest + 1e-3));
309    }
310    let align = match style.align {
311        Align::Start => Alignment::Start,
312        Align::Center => Alignment::Center,
313        Align::End => Alignment::End,
314    };
315    layout.align(align, AlignmentOptions::default());
316
317    let mut path = BezPath::new();
318    let mut glyphs = Vec::new();
319    let mut info = Vec::new();
320    for line in layout.lines() {
321        for item in line.items() {
322            let PositionedLayoutItem::GlyphRun(run) = item else {
323                continue;
324            };
325            let r = run.run();
326            let span = run.style().brush;
327            // The source character of every glyph, in visual order.
328            let chars: Vec<char> = r
329                .visual_clusters()
330                .flat_map(|c| {
331                    let ch = text[c.text_range()].chars().next().unwrap_or('\0');
332                    std::iter::repeat_n(ch, c.glyphs().count())
333                })
334                .collect();
335            let font = r.font();
336            let Ok(font_ref) = FontRef::from_index(font.data.as_ref(), font.index) else {
337                continue;
338            };
339            let outlines = font_ref.outline_glyphs();
340            let coords = r.normalized_coords();
341            let size = Size::new(r.font_size());
342            let coords: Vec<skrifa::instance::NormalizedCoord> = coords
343                .iter()
344                .map(|c| skrifa::raw::types::F2Dot14::from_bits(c.to_bits()))
345                .collect();
346            let location = LocationRef::new(&coords);
347            // Synthetic italic when the family has no italic face.
348            let skew = r
349                .synthesis()
350                .skew()
351                .map_or(0.0, |deg| f64::from(deg).to_radians().tan());
352            for (gi, g) in run.positioned_glyphs().enumerate() {
353                let Some(outline) = outlines.get(GlyphId::new(g.id)) else {
354                    continue;
355                };
356                let mut pen = Pen(BezPath::new());
357                if outline
358                    .draw(DrawSettings::unhinted(size, location), &mut pen)
359                    .is_err()
360                {
361                    continue;
362                }
363                // Font units are y-up; flip and move to the pen position.
364                let tf = Affine::translate((f64::from(g.x), f64::from(g.y)))
365                    * Affine::scale_non_uniform(1.0, -1.0)
366                    * Affine::skew(skew, 0.0);
367                let glyph = tf * pen.0;
368                if !glyph.elements().is_empty() {
369                    path.extend(glyph.elements().iter().copied());
370                    glyphs.push(glyph);
371                    info.push(GlyphInfo {
372                        span,
373                        ch: chars.get(gi).copied().unwrap_or('\0'),
374                        origin: kurbo::Point::new(f64::from(g.x), f64::from(g.y)),
375                    });
376                }
377            }
378        }
379    }
380    TextLayout {
381        path,
382        glyphs,
383        info,
384        width: f64::from(layout.width()),
385        height: f64::from(layout.height()),
386    }
387}
388
389struct Pen(BezPath);
390
391impl OutlinePen for Pen {
392    fn move_to(&mut self, x: f32, y: f32) {
393        self.0.move_to((f64::from(x), f64::from(y)));
394    }
395    fn line_to(&mut self, x: f32, y: f32) {
396        self.0.line_to((f64::from(x), f64::from(y)));
397    }
398    fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
399        self.0.quad_to(
400            (f64::from(cx0), f64::from(cy0)),
401            (f64::from(x), f64::from(y)),
402        );
403    }
404    fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
405        self.0.curve_to(
406            (f64::from(cx0), f64::from(cy0)),
407            (f64::from(cx1), f64::from(cy1)),
408            (f64::from(x), f64::from(y)),
409        );
410    }
411    fn close(&mut self) {
412        self.0.close_path();
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use kurbo::Shape;
419
420    use super::*;
421
422    #[test]
423    fn lays_out_text_with_embedded_font() {
424        let l = layout("Hello", &TextStyle::default());
425        assert_eq!(l.glyphs.len(), 5);
426        assert!(l.width > 100.0 && l.width < 160.0, "width {}", l.width);
427        let bb = l.path.bounding_box();
428        assert!(bb.y0 >= 0.0 && bb.y1 <= l.height, "{bb:?} h={}", l.height);
429    }
430
431    #[test]
432    fn mono_and_weight_change_shapes() {
433        let s = TextStyle::default();
434        let regular = layout("iii", &s);
435        let mono = layout(
436            "iii",
437            &TextStyle {
438                family: MONO.into(),
439                ..s.clone()
440            },
441        );
442        let bold = layout("iii", &TextStyle { weight: 800.0, ..s });
443        assert!(mono.width > regular.width);
444        assert_ne!(bold.path, regular.path);
445    }
446
447    #[test]
448    fn wraps_and_centers() {
449        let s = TextStyle {
450            max_width: Some(120.0),
451            align: Align::Center,
452            ..Default::default()
453        };
454        let l = layout("one two three four", &s);
455        assert!(l.height > 2.0 * 48.0);
456    }
457
458    #[test]
459    fn centered_lines_stay_inside_their_block() {
460        let s = TextStyle {
461            max_width: Some(1000.0),
462            align: Align::Center,
463            ..Default::default()
464        };
465        // One short line: the block is the line, not the wrap width.
466        let one = layout("Fixed", &s);
467        let b = one.path.bounding_box();
468        assert!(
469            b.x0 >= -1.0 && b.x1 <= one.width + 1.0,
470            "{b:?} in {}",
471            one.width
472        );
473        // Wrapped lines are centered in the widest one.
474        let s = TextStyle {
475            max_width: Some(300.0),
476            ..s
477        };
478        let two = layout("a long first line of text then short", &s);
479        let b = two.path.bounding_box();
480        assert!(
481            b.x0 >= -1.0 && b.x1 <= two.width + 1.0,
482            "{b:?} in {}",
483            two.width
484        );
485        assert!(two.height > 2.0 * 30.0);
486    }
487
488    #[test]
489    fn rich_spans_carry_index_and_chars() {
490        let spans = [
491            TextSpan {
492                text: "ab ".into(),
493                ..Default::default()
494            },
495            TextSpan {
496                text: "cd".into(),
497                weight: Some(800.0),
498                ..Default::default()
499            },
500        ];
501        let l = layout_rich(&spans, &TextStyle::default());
502        let got: Vec<(u16, char)> = l.info.iter().map(|i| (i.span, i.ch)).collect();
503        assert_eq!(got, vec![(0, 'a'), (0, 'b'), (1, 'c'), (1, 'd')]);
504        assert!(l.info[1].origin.x > l.info[0].origin.x);
505    }
506
507    #[test]
508    fn cyrillic_is_supported() {
509        let l = layout("Привет", &TextStyle::default());
510        assert_eq!(l.glyphs.len(), 6);
511    }
512}