Skip to main content

lumen_text/
lib.rs

1//! Text shaping backend trait.
2//!
3//! Implementations (e.g. `lumen-text-cosmic`) take a UTF-8 string + size and
4//! return positioned glyphs plus the resolved font data. Backend-agnostic so
5//! the WGPU/vello renderer and any future renderer can both consume the
6//! output.
7//!
8//! ## Multi-segment output (W5.6)
9//!
10//! A real paragraph can mix scripts (Latin + Arabic), fonts (`SansSerif`
11//! falling back to a CJK face), and BiDi levels (LTR runs of even level,
12//! RTL runs of odd level). cosmic-text already emits the right itemisation
13//! internally; the W5.6 work surfaces it through [`ShapedRun::segments`].
14//! Each [`ShapedSegment`] is a maximal slice with one font + one BiDi
15//! level, so the renderer can call `draw_glyphs` once per font and the
16//! caret/selection math can split logical ranges at segment boundaries to
17//! reproduce the BiDi-correct visual rectangles.
18
19#![forbid(unsafe_code)]
20#![warn(missing_docs)]
21
22pub mod edit;
23pub mod geometry;
24pub mod hit_test;
25pub mod shaped;
26pub mod undo;
27
28pub use edit::*;
29pub use geometry::{CaretGeometry, SelectionBand, TextGeometry};
30pub use hit_test::{hit_test_text, select_line_at_byte, select_word_at_byte};
31pub use shaped::{ShapedText, TextViewport, build_shaped_text};
32pub use undo::{UndoEntry, UndoKind, UndoStack};
33
34use std::sync::Arc;
35
36/// One positioned glyph within a shaped run.
37#[derive(Debug, Clone, Copy)]
38pub struct GlyphPosition {
39    /// Glyph index into the resolved font.
40    pub id: u32,
41    /// X position of the glyph's leading edge, from the run origin, in
42    /// logical pixels. cosmic-text emits glyphs in **visual** order, so
43    /// in a mixed-script paragraph `glyphs[i].x` can be smaller than
44    /// `glyphs[i-1].x` when the run crosses a BiDi boundary.
45    pub x: f32,
46    /// Y offset from the run's baseline, in logical pixels.
47    pub y: f32,
48    /// X advance (the leading edge of the next glyph would land at
49    /// `x + advance`). Critical for caret placement: kerning makes
50    /// `next.x - this.x` slightly different from `this.advance`, and
51    /// trailing whitespace produces no glyph at all unless we record
52    /// the advance separately.
53    pub advance: f32,
54    /// Byte range in the source string that produced this glyph cluster
55    /// (`start..end`). Empty range = synthesised (e.g. ellipsis).
56    /// Stored so the renderer can map logical byte ranges to visual
57    /// glyph clusters for caret + selection in mixed-script text.
58    pub byte_start: u32,
59    /// Byte range end (exclusive). See [`Self::byte_start`].
60    pub byte_end: u32,
61}
62
63/// One maximal `(font_id, BiDi level)` slice within a shaped run.
64///
65/// Emitted in **logical source order** so callers can walk the segment
66/// list and snap selection ranges at boundaries. Each segment's
67/// `glyphs` are still in cosmic-text's visual order - the renderer paints
68/// them in that order, while caret/selection math uses
69/// [`GlyphPosition::byte_start`] / [`GlyphPosition::byte_end`] to map
70/// back to logical bytes.
71#[derive(Debug, Clone)]
72pub struct ShapedSegment {
73    /// Opaque font identifier from the shaper's font database. Two
74    /// segments with the same `font_id` resolve through the same
75    /// `font_data` so the renderer can cache by id.
76    pub font_id: u64,
77    /// TTF/OTF bytes of the resolved face for this segment.
78    pub font_data: Arc<Vec<u8>>,
79    /// Face index inside the font file.
80    pub font_index: u32,
81    /// Normalized variation coordinates of the instance this segment was
82    /// shaped at, in the face's axis order, as F2Dot14 raw bits. Empty for
83    /// a static face, which has one instance and needs none.
84    ///
85    /// A variable face shapes at the instance the authored `font-weight`
86    /// selects, and the advances in `glyphs` come from that instance. The
87    /// renderer hands these coordinates to the glyph rasterizer so the
88    /// outlines it paints belong to the same one; without them the outlines
89    /// come from the face's default instance and the text pairs one weight's
90    /// spacing with another weight's strokes.
91    pub normalized_coords: Vec<i16>,
92    /// BiDi embedding level (Unicode BiDi algorithm). Even = LTR, odd =
93    /// RTL. From cosmic-text's `LayoutGlyph.level` (unicode-bidi crate).
94    pub level: u8,
95    /// Glyphs belonging to this segment, in cosmic-text's visual
96    /// order. The renderer issues one `draw_glyphs(&font_data)` per
97    /// segment.
98    pub glyphs: Vec<GlyphPosition>,
99    /// Total visual advance of this segment in logical pixels.
100    pub width: f32,
101}
102
103impl ShapedSegment {
104    /// Convenience: even-level = LTR.
105    pub const fn is_ltr(&self) -> bool {
106        self.level.is_multiple_of(2)
107    }
108    /// Convenience: odd-level = RTL.
109    pub const fn is_rtl(&self) -> bool {
110        self.level % 2 == 1
111    }
112}
113
114/// A shaped paragraph of text.
115///
116/// `font_data` / `font_index` reflect the **first** segment's font and
117/// stay for renderer back-compat with the W3.6 single-font path. The
118/// authoritative per-(font, level) breakdown lives in [`Self::segments`].
119/// `font_data` is the raw TTF/OTF bytes. Renderers wrap these in their own
120/// font type (e.g. `peniko::Font`) and should cache by [`Arc::as_ptr`].
121#[derive(Debug, Clone)]
122pub struct ShapedRun {
123    /// TTF/OTF bytes of the resolved font face (first segment).
124    pub font_data: Arc<Vec<u8>>,
125    /// Face index inside the font file (0 for single-face TTF).
126    pub font_index: u32,
127    /// All glyphs across every segment, in cosmic-text's visual order.
128    /// Kept for back-compat with single-font renderer paths and tests;
129    /// new code should iterate [`Self::segments`] instead.
130    pub glyphs: Vec<GlyphPosition>,
131    /// Per-(font, BiDi level) maximal slices in logical order.
132    /// Non-empty whenever `glyphs` is non-empty.
133    pub segments: Vec<ShapedSegment>,
134    /// Total advance of the run (logical pixels). For an empty input,
135    /// `0.0`. Includes trailing whitespace that may not appear in
136    /// `glyphs`. Use this - not `glyphs.last().x` - for end-of-run
137    /// caret placement.
138    pub width: f32,
139}
140
141/// Wrap policy passed to [`TextShaper::shape_wrapped`].
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
143pub enum WrapMode {
144    /// No automatic wrapping (`white-space: nowrap`). Default.
145    #[default]
146    None,
147    /// Word-break wrapping at the available width.
148    Word,
149    /// Glyph-level wrapping (no word boundary respect - best for very
150    /// narrow CJK columns).
151    Glyph,
152}
153
154impl From<lumen_core::components::TextWrap> for WrapMode {
155    /// 1:1 variant map from the core `TextWrap` style enum to the shaper
156    /// wrap policy. Lets layout + render backends call `.into()` instead of
157    /// hand-matching the three variants.
158    fn from(w: lumen_core::components::TextWrap) -> Self {
159        use lumen_core::components::TextWrap;
160        match w {
161            TextWrap::None => WrapMode::None,
162            TextWrap::Word => WrapMode::Word,
163            TextWrap::Glyph => WrapMode::Glyph,
164        }
165    }
166}
167
168/// Extension shape parameters. Backends that don't override
169/// [`TextShaper::shape_wrapped`] fall through to the no-wrap path so old
170/// callers keep working.
171#[derive(Debug, Clone)]
172pub struct ShapeOptions {
173    /// Available width for wrapping, in logical pixels. `None` = unbounded.
174    pub width: Option<f32>,
175    /// Wrap policy.
176    pub wrap: WrapMode,
177    /// Hard cap on line count after shaping (callers cull). `None` = unbounded.
178    pub max_lines: Option<u32>,
179    /// CSS `font-family` fallback chain as authored (comma-separated,
180    /// possibly quoted names, generic keywords allowed). `None` = the
181    /// platform sans-serif. The backend resolves the first family
182    /// present in its font database.
183    pub family: Option<Arc<str>>,
184    /// CSS `font-weight` (1-1000; 400 = normal, 700 = bold).
185    pub weight: u16,
186    /// Resolved CSS `line-height`, in the same unit space as the `size_px`
187    /// argument passed alongside these options to [`TextShaper::shape`]
188    /// (a caller that scales `size_px` by DPR must scale this the same
189    /// way). `None` => the backend's own `line-height: normal` fallback
190    /// ([`DEFAULT_LINE_HEIGHT_MULTIPLIER`]). Callers with a `TextStyle`-ish
191    /// source resolve one via
192    /// `lumen_core::components::resolve_line_height` before constructing
193    /// these options; this field never re-derives the multiplier itself.
194    pub line_height: Option<f32>,
195}
196
197impl Default for ShapeOptions {
198    fn default() -> Self {
199        Self {
200            width: None,
201            wrap: WrapMode::None,
202            max_lines: None,
203            family: None,
204            weight: 400,
205            line_height: None,
206        }
207    }
208}
209
210impl ShapeOptions {
211    /// Line height in the same unit space as `size_px`: the authored CSS
212    /// `line-height` when the caller resolved one, else `size_px` times
213    /// [`DEFAULT_LINE_HEIGHT_MULTIPLIER`]. Shaping, measuring, and
214    /// painting all route through this so one paragraph cannot end up
215    /// with three different line heights.
216    pub fn resolved_line_height(&self, size_px: f32) -> f32 {
217        self.line_height
218            .unwrap_or(size_px * DEFAULT_LINE_HEIGHT_MULTIPLIER)
219    }
220}
221
222/// Re-export of [`lumen_core::components::DEFAULT_LINE_HEIGHT_MULTIPLIER`]
223/// for crates (e.g. `lumen-text-cosmic`) that depend on `lumen-text` but
224/// not directly on `lumen-core`.
225pub use lumen_core::components::DEFAULT_LINE_HEIGHT_MULTIPLIER;
226
227/// Backend that turns strings into [`ShapedRun`]s.
228pub trait TextShaper {
229    /// Shape `text` at `size_px` under the given [`ShapeOptions`].
230    /// Returns `None` when the input is empty, when no font can be
231    /// resolved, or when shaping fails (rare).
232    ///
233    /// Pass [`ShapeOptions::default()`] for the no-wrap, unbounded case
234    /// (caret / selection prefix measurement, label sizing without
235    /// a container width).
236    fn shape(&mut self, text: &str, size_px: f32, opts: ShapeOptions) -> Option<ShapedRun>;
237
238    /// Measure a text run for layout. Returns the tight `(width, height)`
239    /// in logical pixels of the shaped paragraph at `size_px` under the
240    /// `wrap` / `max_lines` policy, clamped to `max_width` when `Some`.
241    ///
242    /// The layout engine calls this from taffy's measure callback to size
243    /// text leaves, and the caret-scroll pass calls it to place the caret
244    /// on the shaped prefix.
245    fn measure(
246        &mut self,
247        text: &str,
248        size_px: f32,
249        max_width: Option<f32>,
250        wrap: WrapMode,
251        max_lines: Option<u32>,
252    ) -> (f32, f32) {
253        let opts = ShapeOptions {
254            width: max_width,
255            wrap,
256            max_lines,
257            ..ShapeOptions::default()
258        };
259        let (w, h, _baseline) = self.measure_with_baseline(text, size_px, &opts);
260        (w, h)
261    }
262
263    /// Same as [`Self::measure`], plus the first-line alphabetic baseline
264    /// offset from the top of the leaf box - the y at which Latin,
265    /// Cyrillic, and Greek glyphs sit. `FlexAlign::Baseline` and the
266    /// AccessKit text-position report consume it.
267    ///
268    /// The default walks the shaped glyphs: the width is the widest laid
269    /// out line (taffy's intrinsic content size), the height is the line
270    /// count times [`ShapeOptions::resolved_line_height`], and the
271    /// baseline is `0.8` of that line height, the ratio the common font
272    /// metrics tables produce. Empty input measures `(0, 0, 0)` so the
273    /// leaf collapses. Override it when the backend can answer more
274    /// cheaply or more precisely than by shaping.
275    fn measure_with_baseline(
276        &mut self,
277        text: &str,
278        size_px: f32,
279        opts: &ShapeOptions,
280    ) -> (f32, f32, f32) {
281        if text.is_empty() {
282            return (0.0, 0.0, 0.0);
283        }
284        let max_width = opts.width;
285        let line_height = opts.resolved_line_height(size_px).max(1.0);
286        match self.shape(text, size_px, opts.clone()) {
287            Some(run) => {
288                let mut rows = 1u32;
289                let mut last_y = run.glyphs.first().map(|g| g.y).unwrap_or(0.0);
290                for g in run.glyphs.iter().skip(1) {
291                    if (g.y - last_y).abs() > line_height * 0.5 {
292                        rows += 1;
293                        last_y = g.y;
294                    }
295                }
296                let mut width = run.width;
297                if let Some(cap) = max_width {
298                    width = width.min(cap);
299                }
300                (width, (rows as f32) * line_height, line_height * 0.8)
301            }
302            None => (0.0, 0.0, 0.0),
303        }
304    }
305
306    /// Number of entries the backend currently holds in its shape cache.
307    /// `0` for a backend that caches nothing.
308    fn cache_len(&self) -> usize {
309        0
310    }
311
312    /// Resize the shape cache to the given entry count. The per-tick
313    /// memory-budget sweep calls this when the cache outgrows
314    /// [`lumen_core::components::MemoryBudget`]. No-op for a backend that
315    /// caches nothing.
316    fn set_capacity(&mut self, _entries: usize) {}
317}
318
319/// The text shaper an app runs, held as a `NonSend` resource (font
320/// databases are rarely `Send`).
321///
322/// It derefs to the shaper, so [`TextShaper`] methods work directly on it.
323/// Build one from any backend with `ShaperService::from(backend)`, and
324/// replace the runtime's default by inserting your own in an app hook.
325/// The layout engine reads it for text measurement, so a swapped backend
326/// changes measuring and painting together.
327pub struct ShaperService(Box<dyn TextShaper>);
328
329impl ShaperService {
330    /// Wrap a shaper.
331    pub fn new<S: TextShaper + 'static>(shaper: S) -> Self {
332        Self(Box::new(shaper))
333    }
334}
335
336impl<S: TextShaper + 'static> From<S> for ShaperService {
337    fn from(shaper: S) -> Self {
338        Self::new(shaper)
339    }
340}
341
342impl From<Box<dyn TextShaper>> for ShaperService {
343    fn from(shaper: Box<dyn TextShaper>) -> Self {
344        Self(shaper)
345    }
346}
347
348impl std::ops::Deref for ShaperService {
349    type Target = dyn TextShaper;
350
351    fn deref(&self) -> &Self::Target {
352        self.0.as_ref()
353    }
354}
355
356impl std::ops::DerefMut for ShaperService {
357    fn deref_mut(&mut self) -> &mut Self::Target {
358        self.0.as_mut()
359    }
360}
361
362impl Default for ShaperService {
363    fn default() -> Self {
364        Self::new(NullShaper)
365    }
366}
367
368/// The shaper that shapes nothing.
369///
370/// Every run comes back empty, so text measures to a zero-sized box and
371/// no glyph is painted. A build with no text backend selected runs this,
372/// and a layout test that only cares about boxes can use it directly.
373#[derive(Debug, Default, Clone, Copy)]
374pub struct NullShaper;
375
376impl TextShaper for NullShaper {
377    fn shape(&mut self, _text: &str, _size_px: f32, _opts: ShapeOptions) -> Option<ShapedRun> {
378        None
379    }
380}
381
382/// Marker trait for a font database / system-font accessor. Currently empty.
383pub trait FontDB {}