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