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 {}