#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod atlas;
pub mod cache;
pub mod decoration;
pub mod editor;
pub mod fallback;
pub mod highlight;
pub mod hyperlink;
pub mod ime;
pub mod input;
pub mod label;
pub mod layout;
pub mod rich;
pub mod selection;
pub mod truncation;
pub use atlas::{GlyphAtlas, GlyphEntry, GlyphKey};
pub use editor::{TextArea, WrapMode};
pub use highlight::{Highlighter, KeywordHighlighter};
pub use ime::Preedit;
pub use input::TextInput;
pub use label::Label;
use oxiui_core::UiError;
pub use oxitext::{ParagraphMetrics, PositionedGlyph, RenderResult};
#[derive(Debug)]
pub enum TextError {
Pipeline(oxitext::OxiTextError),
Other(String),
}
impl std::fmt::Display for TextError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TextError::Pipeline(e) => write!(f, "text pipeline error: {e}"),
TextError::Other(s) => write!(f, "text error: {s}"),
}
}
}
impl std::error::Error for TextError {}
impl From<oxitext::OxiTextError> for TextError {
fn from(e: oxitext::OxiTextError) -> Self {
TextError::Pipeline(e)
}
}
impl From<TextError> for UiError {
fn from(e: TextError) -> Self {
UiError::Render(e.to_string())
}
}
#[derive(Clone, Debug)]
pub struct TextStyle {
pub font_family: Option<String>,
pub font_size: f32,
pub bold: bool,
pub italic: bool,
pub color: [u8; 4],
pub letter_spacing: f32,
pub line_height: f32,
pub max_width: f32,
}
impl Default for TextStyle {
fn default() -> Self {
Self::new(16.0)
}
}
impl TextStyle {
pub fn new(size: f32) -> Self {
Self {
font_family: None,
font_size: size,
bold: false,
italic: false,
color: [0, 0, 0, 255],
letter_spacing: 0.0,
line_height: 1.0,
max_width: 0.0,
}
}
pub fn family(mut self, name: impl Into<String>) -> Self {
self.font_family = Some(name.into());
self
}
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
pub fn italic(mut self) -> Self {
self.italic = true;
self
}
pub fn color(mut self, rgba: [u8; 4]) -> Self {
self.color = rgba;
self
}
pub fn letter_spacing(mut self, spacing: f32) -> Self {
self.letter_spacing = spacing;
self
}
pub fn line_height(mut self, height: f32) -> Self {
self.line_height = height;
self
}
pub fn max_width(mut self, width: f32) -> Self {
self.max_width = width;
self
}
pub(crate) fn to_upstream(&self) -> oxitext::TextStyle {
oxitext::TextStyle {
font_size: self.font_size,
max_width: self.max_width,
flow_direction: oxitext::FlowDirection::Horizontal,
alignment: oxitext::TextAlignment::Left,
line_spacing: oxitext::LineSpacing::default(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlyphPosition {
pub byte_offset: usize,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone)]
pub struct ShapedText {
pub lines: Vec<Vec<GlyphPosition>>,
pub total_width: f32,
pub total_height: f32,
}
pub struct TextPipeline {
inner: oxitext::Pipeline,
}
impl TextPipeline {
pub fn from_bytes(font_bytes: &[u8]) -> Result<Self, TextError> {
Ok(Self {
inner: oxitext::Pipeline::from_bytes(font_bytes)?,
})
}
pub fn from_system_font(family: &str) -> Result<Self, TextError> {
Ok(Self {
inner: oxitext::Pipeline::new_with_system_font(family)?,
})
}
pub fn set_fallback_fonts(&mut self, fonts: Vec<Vec<u8>>) {
self.inner.set_fallback_fonts(fonts);
}
pub fn shape(&mut self, text: &str, style: &TextStyle) -> Result<ShapedText, TextError> {
let upstream_style = style.to_upstream();
let layout = self.inner.shape_and_layout(text, &upstream_style)?;
let line_height = layout.metrics.total_height / layout.metrics.line_count.max(1) as f32;
let mut shaped_lines: Vec<Vec<GlyphPosition>> = Vec::with_capacity(layout.lines.len());
for line in &layout.lines {
let ascent = line.metrics.ascent;
let descent = line.metrics.descent;
let glyph_height = ascent + descent;
let top_y = line.metrics.baseline_y - ascent;
let glyphs: Vec<GlyphPosition> = layout.glyphs[line.glyph_start..line.glyph_end]
.iter()
.map(|g| GlyphPosition {
byte_offset: g.cluster as usize,
x: g.pos.0,
y: top_y,
width: g.advance_x,
height: glyph_height,
})
.collect();
shaped_lines.push(glyphs);
}
let _ = line_height;
Ok(ShapedText {
lines: shaped_lines,
total_width: layout.metrics.total_width,
total_height: layout.metrics.total_height,
})
}
pub fn measure(&mut self, text: &str, style: &TextStyle) -> Result<(f32, f32), TextError> {
let upstream_style = style.to_upstream();
let metrics = self.inner.measure(text, &upstream_style)?;
Ok((metrics.total_width, metrics.total_height))
}
pub fn glyph_positions(
&mut self,
text: &str,
style: &TextStyle,
) -> Result<Vec<GlyphPosition>, TextError> {
let shaped = self.shape(text, style)?;
Ok(shaped.lines.into_iter().flatten().collect())
}
pub fn render(&mut self, text: &str, style: &TextStyle) -> Result<RenderResult, UiError> {
let upstream_style = style.to_upstream();
self.inner
.render(text, &upstream_style)
.map_err(|e| UiError::Render(e.to_string()))
}
}
pub struct LazyTextPipeline {
font_bytes: Vec<u8>,
inner: std::cell::OnceCell<TextPipeline>,
}
impl LazyTextPipeline {
pub fn new(font_bytes: Vec<u8>) -> Self {
Self {
font_bytes,
inner: std::cell::OnceCell::new(),
}
}
pub fn get(&self) -> Result<&TextPipeline, TextError> {
if let Some(p) = self.inner.get() {
return Ok(p);
}
let pipeline = TextPipeline::from_bytes(&self.font_bytes)?;
let _ = self.inner.set(pipeline);
self.inner
.get()
.ok_or_else(|| TextError::Other("lazy pipeline initialisation failed".into()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_bytes_result_type() {
let result = TextPipeline::from_bytes(&[]);
assert!(result.is_err(), "empty bytes must yield Err");
}
#[test]
fn text_style_defaults() {
let s = TextStyle::new(24.0);
assert!((s.font_size - 24.0).abs() < f32::EPSILON);
assert!(!s.bold);
assert!(!s.italic);
assert_eq!(s.color, [0, 0, 0, 255]);
}
#[test]
fn text_style_builder_chain() {
let s = TextStyle::new(16.0)
.bold()
.italic()
.color([255, 0, 0, 255])
.letter_spacing(2.0)
.family("Arial");
assert!(s.bold);
assert!(s.italic);
assert_eq!(s.color, [255, 0, 0, 255]);
assert_eq!(s.font_family.as_deref(), Some("Arial"));
}
#[test]
fn lazy_pipeline_empty_bytes_is_err() {
let lazy = LazyTextPipeline::new(vec![]);
assert!(lazy.get().is_err(), "empty bytes must yield Err");
}
#[test]
fn lazy_pipeline_second_call_still_err() {
let lazy = LazyTextPipeline::new(vec![]);
let _ = lazy.get();
assert!(
lazy.get().is_err(),
"repeated call with empty bytes must remain Err"
);
}
}