Skip to main content

oxiui_text/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3//! `oxiui-text` — rich text layer bridging OxiUI to OxiText/OxiFont.
4//!
5//! Provides text measurement, layout, hit-testing, selection, rich text spans,
6//! LRU shaping cache, font fallback, decorations, truncation, and hyperlink
7//! detection — all built on pure-Rust `oxitext` + `oxifont`.
8
9pub mod atlas;
10pub mod cache;
11pub mod decoration;
12pub mod editor;
13pub mod fallback;
14pub mod highlight;
15pub mod hyperlink;
16pub mod ime;
17pub mod input;
18pub mod label;
19pub mod layout;
20pub mod rich;
21pub mod selection;
22pub mod truncation;
23
24pub use atlas::{GlyphAtlas, GlyphEntry, GlyphKey};
25pub use editor::{TextArea, WrapMode};
26pub use highlight::{Highlighter, KeywordHighlighter};
27pub use ime::Preedit;
28pub use input::TextInput;
29pub use label::Label;
30
31use oxiui_core::UiError;
32
33// ── Re-exports from upstream ─────────────────────────────────────────────────
34
35pub use oxitext::{ParagraphMetrics, PositionedGlyph, RenderResult};
36
37// ── Error type ───────────────────────────────────────────────────────────────
38
39/// All errors that can originate from the `oxiui-text` layer.
40#[derive(Debug)]
41pub enum TextError {
42    /// An error from the underlying OxiText pipeline.
43    Pipeline(oxitext::OxiTextError),
44    /// A miscellaneous text error.
45    Other(String),
46}
47
48impl std::fmt::Display for TextError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            TextError::Pipeline(e) => write!(f, "text pipeline error: {e}"),
52            TextError::Other(s) => write!(f, "text error: {s}"),
53        }
54    }
55}
56
57impl std::error::Error for TextError {}
58
59impl From<oxitext::OxiTextError> for TextError {
60    fn from(e: oxitext::OxiTextError) -> Self {
61        TextError::Pipeline(e)
62    }
63}
64
65impl From<TextError> for UiError {
66    fn from(e: TextError) -> Self {
67        UiError::Render(e.to_string())
68    }
69}
70
71// ── TextStyle builder ─────────────────────────────────────────────────────────
72
73/// Builder-style text style for OxiUI widgets.
74///
75/// Wraps the upstream [`oxitext::TextStyle`] with additional per-glyph
76/// attributes (bold, italic, color, letter spacing) that OxiUI layers on
77/// top.
78#[derive(Clone, Debug)]
79pub struct TextStyle {
80    /// Font family name, if any.
81    pub font_family: Option<String>,
82    /// Font size in pixels-per-em.
83    pub font_size: f32,
84    /// Bold weight.
85    pub bold: bool,
86    /// Italic slant.
87    pub italic: bool,
88    /// Foreground RGBA color.
89    pub color: [u8; 4],
90    /// Additional horizontal spacing between glyphs, in pixels.
91    pub letter_spacing: f32,
92    /// Line height multiplier (1.0 = natural).
93    pub line_height: f32,
94    /// Maximum line width for wrapping (0 = no wrap).
95    pub max_width: f32,
96}
97
98impl Default for TextStyle {
99    fn default() -> Self {
100        Self::new(16.0)
101    }
102}
103
104impl TextStyle {
105    /// Create a new style with the given font size and sensible defaults.
106    pub fn new(size: f32) -> Self {
107        Self {
108            font_family: None,
109            font_size: size,
110            bold: false,
111            italic: false,
112            color: [0, 0, 0, 255],
113            letter_spacing: 0.0,
114            line_height: 1.0,
115            max_width: 0.0,
116        }
117    }
118
119    /// Set the font family name.
120    pub fn family(mut self, name: impl Into<String>) -> Self {
121        self.font_family = Some(name.into());
122        self
123    }
124
125    /// Enable bold weight.
126    pub fn bold(mut self) -> Self {
127        self.bold = true;
128        self
129    }
130
131    /// Enable italic slant.
132    pub fn italic(mut self) -> Self {
133        self.italic = true;
134        self
135    }
136
137    /// Set the foreground RGBA color.
138    pub fn color(mut self, rgba: [u8; 4]) -> Self {
139        self.color = rgba;
140        self
141    }
142
143    /// Set the additional letter spacing in pixels.
144    pub fn letter_spacing(mut self, spacing: f32) -> Self {
145        self.letter_spacing = spacing;
146        self
147    }
148
149    /// Set the line height multiplier.
150    pub fn line_height(mut self, height: f32) -> Self {
151        self.line_height = height;
152        self
153    }
154
155    /// Set the maximum line width for wrapping.
156    pub fn max_width(mut self, width: f32) -> Self {
157        self.max_width = width;
158        self
159    }
160
161    /// Convert to the upstream [`oxitext::TextStyle`].
162    pub(crate) fn to_upstream(&self) -> oxitext::TextStyle {
163        oxitext::TextStyle {
164            font_size: self.font_size,
165            max_width: self.max_width,
166            flow_direction: oxitext::FlowDirection::Horizontal,
167            alignment: oxitext::TextAlignment::Left,
168            line_spacing: oxitext::LineSpacing::default(),
169        }
170    }
171}
172
173// ── GlyphPosition ─────────────────────────────────────────────────────────────
174
175/// The position of a single glyph cluster in the laid-out text.
176#[derive(Debug, Clone, PartialEq)]
177pub struct GlyphPosition {
178    /// UTF-8 byte offset of this glyph's cluster in the source text.
179    pub byte_offset: usize,
180    /// Left edge in canvas pixels.
181    pub x: f32,
182    /// Top edge in canvas pixels (baseline − ascent).
183    pub y: f32,
184    /// Advance width of the glyph in pixels.
185    pub width: f32,
186    /// Line height in pixels (ascent + descent).
187    pub height: f32,
188}
189
190// ── ShapedText ────────────────────────────────────────────────────────────────
191
192/// The result of shaping and laying out a string of text.
193#[derive(Debug, Clone)]
194pub struct ShapedText {
195    /// Glyph positions grouped by line.
196    pub lines: Vec<Vec<GlyphPosition>>,
197    /// Total width of the widest line in pixels.
198    pub total_width: f32,
199    /// Total height of all lines stacked in pixels.
200    pub total_height: f32,
201}
202
203// ── TextPipeline ─────────────────────────────────────────────────────────────
204
205/// End-to-end text shaping + rasterization pipeline for OxiUI.
206///
207/// Wraps [`oxitext::Pipeline`] and maps errors to [`TextError`] / [`UiError`].
208pub struct TextPipeline {
209    inner: oxitext::Pipeline,
210}
211
212impl TextPipeline {
213    /// Create a new pipeline from raw font bytes (TTF or OTF).
214    ///
215    /// # Errors
216    /// Returns [`TextError::Pipeline`] if the font bytes are invalid or
217    /// unparseable.
218    pub fn from_bytes(font_bytes: &[u8]) -> Result<Self, TextError> {
219        Ok(Self {
220            inner: oxitext::Pipeline::from_bytes(font_bytes)?,
221        })
222    }
223
224    /// Create a pipeline using a named system font family.
225    ///
226    /// # Errors
227    /// Returns [`TextError::Pipeline`] if no matching system font is found.
228    pub fn from_system_font(family: &str) -> Result<Self, TextError> {
229        Ok(Self {
230            inner: oxitext::Pipeline::new_with_system_font(family)?,
231        })
232    }
233
234    /// Configure a font fallback chain.
235    ///
236    /// When a glyph is `.notdef` in the primary font the pipeline walks
237    /// this list to find a substitute.
238    pub fn set_fallback_fonts(&mut self, fonts: Vec<Vec<u8>>) {
239        self.inner.set_fallback_fonts(fonts);
240    }
241
242    /// Shape and lay out `text` under `style`, returning per-line glyph
243    /// positions without rasterizing.
244    ///
245    /// # Errors
246    /// Propagates shaping/layout errors.
247    pub fn shape(&mut self, text: &str, style: &TextStyle) -> Result<ShapedText, TextError> {
248        let upstream_style = style.to_upstream();
249        let layout = self.inner.shape_and_layout(text, &upstream_style)?;
250        let line_height = layout.metrics.total_height / layout.metrics.line_count.max(1) as f32;
251
252        let mut shaped_lines: Vec<Vec<GlyphPosition>> = Vec::with_capacity(layout.lines.len());
253        for line in &layout.lines {
254            let ascent = line.metrics.ascent;
255            let descent = line.metrics.descent;
256            let glyph_height = ascent + descent;
257            let top_y = line.metrics.baseline_y - ascent;
258
259            let glyphs: Vec<GlyphPosition> = layout.glyphs[line.glyph_start..line.glyph_end]
260                .iter()
261                .map(|g| GlyphPosition {
262                    byte_offset: g.cluster as usize,
263                    x: g.pos.0,
264                    y: top_y,
265                    width: g.advance_x,
266                    height: glyph_height,
267                })
268                .collect();
269            shaped_lines.push(glyphs);
270        }
271
272        // If no lines were produced (empty text), supply one empty line.
273        let _ = line_height; // consumed above
274
275        Ok(ShapedText {
276            lines: shaped_lines,
277            total_width: layout.metrics.total_width,
278            total_height: layout.metrics.total_height,
279        })
280    }
281
282    /// Measure total bounding box without rasterizing.
283    ///
284    /// Returns `(width, height)` in pixels.
285    ///
286    /// # Errors
287    /// Propagates shaping/layout errors.
288    pub fn measure(&mut self, text: &str, style: &TextStyle) -> Result<(f32, f32), TextError> {
289        let upstream_style = style.to_upstream();
290        let metrics = self.inner.measure(text, &upstream_style)?;
291        Ok((metrics.total_width, metrics.total_height))
292    }
293
294    /// Return per-glyph positions for hit-testing.
295    ///
296    /// # Errors
297    /// Propagates shaping/layout errors.
298    pub fn glyph_positions(
299        &mut self,
300        text: &str,
301        style: &TextStyle,
302    ) -> Result<Vec<GlyphPosition>, TextError> {
303        let shaped = self.shape(text, style)?;
304        Ok(shaped.lines.into_iter().flatten().collect())
305    }
306
307    /// Shape and rasterize `text` with the given style.
308    ///
309    /// Returns a [`RenderResult`] containing per-glyph bitmaps.
310    ///
311    /// # Errors
312    /// Propagates pipeline errors as [`UiError::Render`].
313    pub fn render(&mut self, text: &str, style: &TextStyle) -> Result<RenderResult, UiError> {
314        let upstream_style = style.to_upstream();
315        self.inner
316            .render(text, &upstream_style)
317            .map_err(|e| UiError::Render(e.to_string()))
318    }
319}
320
321// ── LazyTextPipeline ─────────────────────────────────────────────────────────
322
323/// A [`TextPipeline`] that defers parsing its font bytes until the first use.
324///
325/// Fonts can be large; this wrapper avoids upfront parsing cost by storing the
326/// raw bytes and initialising the inner [`TextPipeline`] on the first call to
327/// [`LazyTextPipeline::get`].
328pub struct LazyTextPipeline {
329    /// Raw TTF/OTF bytes.
330    font_bytes: Vec<u8>,
331    /// The lazily-initialised pipeline.
332    inner: std::cell::OnceCell<TextPipeline>,
333}
334
335impl LazyTextPipeline {
336    /// Create a new `LazyTextPipeline` that will parse `font_bytes` on demand.
337    pub fn new(font_bytes: Vec<u8>) -> Self {
338        Self {
339            font_bytes,
340            inner: std::cell::OnceCell::new(),
341        }
342    }
343
344    /// Return a reference to the inner [`TextPipeline`], initialising it on
345    /// the first call.
346    ///
347    /// # Errors
348    /// Returns [`TextError::Pipeline`] if the font bytes are invalid.
349    pub fn get(&self) -> Result<&TextPipeline, TextError> {
350        if let Some(p) = self.inner.get() {
351            return Ok(p);
352        }
353        let pipeline = TextPipeline::from_bytes(&self.font_bytes)?;
354        // `set` fails only on a race (impossible with `&self`-only access here);
355        // ignore the error and re-read the just-stored value.
356        let _ = self.inner.set(pipeline);
357        self.inner
358            .get()
359            .ok_or_else(|| TextError::Other("lazy pipeline initialisation failed".into()))
360    }
361}
362
363// ── Tests ─────────────────────────────────────────────────────────────────────
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    /// `from_bytes` with empty bytes must return `Err`, not panic.
370    #[test]
371    fn from_bytes_result_type() {
372        let result = TextPipeline::from_bytes(&[]);
373        assert!(result.is_err(), "empty bytes must yield Err");
374    }
375
376    /// `TextStyle::new` produces expected defaults.
377    #[test]
378    fn text_style_defaults() {
379        let s = TextStyle::new(24.0);
380        assert!((s.font_size - 24.0).abs() < f32::EPSILON);
381        assert!(!s.bold);
382        assert!(!s.italic);
383        assert_eq!(s.color, [0, 0, 0, 255]);
384    }
385
386    /// Builder chain should be additive / non-destructive.
387    #[test]
388    fn text_style_builder_chain() {
389        let s = TextStyle::new(16.0)
390            .bold()
391            .italic()
392            .color([255, 0, 0, 255])
393            .letter_spacing(2.0)
394            .family("Arial");
395        assert!(s.bold);
396        assert!(s.italic);
397        assert_eq!(s.color, [255, 0, 0, 255]);
398        assert_eq!(s.font_family.as_deref(), Some("Arial"));
399    }
400
401    /// `LazyTextPipeline::get` with empty bytes must return `Err`, not panic.
402    #[test]
403    fn lazy_pipeline_empty_bytes_is_err() {
404        let lazy = LazyTextPipeline::new(vec![]);
405        assert!(lazy.get().is_err(), "empty bytes must yield Err");
406    }
407
408    /// Second call to `LazyTextPipeline::get` (after error) must also return `Err`.
409    #[test]
410    fn lazy_pipeline_second_call_still_err() {
411        let lazy = LazyTextPipeline::new(vec![]);
412        let _ = lazy.get();
413        assert!(
414            lazy.get().is_err(),
415            "repeated call with empty bytes must remain Err"
416        );
417    }
418}