use oxiui_core::{
geometry::Rect,
paint::{DrawCommand, DrawList, ImageData, ImageFilter},
Color, UiError,
};
use oxiui_text::{GlyphAtlas, GlyphKey, TextPipeline, TextStyle};
pub struct TextBridge {
pipeline: TextPipeline,
atlas: GlyphAtlas,
}
impl TextBridge {
pub fn new(pipeline: TextPipeline, atlas_capacity: usize) -> Self {
Self {
pipeline,
atlas: GlyphAtlas::new(atlas_capacity),
}
}
pub fn expand_draw_text(
&mut self,
out: &mut DrawList,
rect: Rect,
text: &str,
style: &TextStyle,
color: Color,
) -> Result<(), UiError> {
let shaped = self
.pipeline
.shape(text, style)
.map_err(|e| UiError::Render(e.to_string()))?;
let pen_x0 = rect.left();
let pen_y0 = rect.top();
for line in &shaped.lines {
for glyph_pos in line {
let glyph_id = glyph_id_from_byte_offset(text, glyph_pos.byte_offset);
let key = GlyphKey::new(glyph_id, style.font_size, glyph_pos.x.fract(), 0.0);
let entry_result =
self.atlas
.get_or_rasterize(&mut self.pipeline, key, text, style);
let entry = match entry_result {
Ok(e) => e,
Err(_) => continue, };
let bm = &entry.bitmap;
if bm.width == 0 || bm.height == 0 || bm.pixels.is_empty() {
continue;
}
let rgba = greyscale_to_rgba(&bm.pixels, color);
let dest = Rect::new(
pen_x0 + glyph_pos.x + entry.bearing.0 as f32,
pen_y0 + glyph_pos.y + entry.bearing.1 as f32,
bm.width as f32,
bm.height as f32,
);
if dest.width() > 0.0 && dest.height() > 0.0 {
let image = ImageData::new(rgba, bm.width, bm.height);
out.push_image(image, dest, ImageFilter::Bilinear);
}
}
}
Ok(())
}
pub fn expand_draw_text_commands(&mut self, list: &DrawList) -> DrawList {
let mut out = DrawList::new();
for cmd in list.iter() {
match cmd {
DrawCommand::DrawText {
rect,
text,
font,
color,
} => {
let style =
TextStyle::new(font.size).color([color.0, color.1, color.2, color.3]);
let _ = self.expand_draw_text(&mut out, *rect, text, &style, *color);
}
other => {
out.push(other.clone());
}
}
}
out
}
pub fn atlas(&self) -> &GlyphAtlas {
&self.atlas
}
pub fn atlas_utilization(&self) -> f32 {
self.atlas.utilization()
}
}
fn glyph_id_from_byte_offset(text: &str, byte_offset: usize) -> u16 {
if byte_offset >= text.len() {
return 0;
}
let ch = text[byte_offset..].chars().next().unwrap_or('\0');
(ch as u32 & 0xFFFF) as u16
}
fn greyscale_to_rgba(grey: &[u8], color: Color) -> Vec<u8> {
let mut out = Vec::with_capacity(grey.len() * 4);
let (r, g, b, a) = (color.0, color.1, color.2, color.3);
for &v in grey {
let alpha = ((a as u32 * v as u32) / 255) as u8;
out.push(r);
out.push(g);
out.push(b);
out.push(alpha);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greyscale_to_rgba_full_coverage() {
let rgba = greyscale_to_rgba(&[255, 128], Color(255, 0, 0, 255));
assert_eq!(rgba.len(), 8);
assert_eq!(rgba[0], 255);
assert_eq!(rgba[1], 0);
assert_eq!(rgba[2], 0);
assert_eq!(rgba[3], 255);
assert_eq!(rgba[7], 128);
}
#[test]
fn greyscale_to_rgba_zero_coverage_is_transparent() {
let rgba = greyscale_to_rgba(&[0], Color(255, 255, 255, 255));
assert_eq!(rgba[3], 0);
}
#[test]
fn greyscale_to_rgba_zero_tint_alpha_is_transparent() {
let rgba = greyscale_to_rgba(&[255], Color(255, 255, 255, 0));
assert_eq!(rgba[3], 0);
}
#[test]
fn greyscale_to_rgba_output_length_is_4x_input() {
let input: Vec<u8> = (0..10).collect();
let rgba = greyscale_to_rgba(&input, Color(0, 0, 0, 255));
assert_eq!(rgba.len(), 40);
}
#[test]
fn glyph_id_ascii_a_is_97() {
let id = glyph_id_from_byte_offset("A", 0);
assert_eq!(id, 'A' as u16);
}
#[test]
fn glyph_id_out_of_range_returns_zero() {
let id = glyph_id_from_byte_offset("A", 100);
assert_eq!(id, 0);
}
#[test]
fn glyph_id_empty_text_returns_zero() {
let id = glyph_id_from_byte_offset("", 0);
assert_eq!(id, 0);
}
#[test]
fn text_bridge_from_invalid_font_fails() {
let result = TextPipeline::from_bytes(&[]);
assert!(result.is_err());
}
#[test]
fn text_bridge_atlas_utilization_starts_at_zero() {
let atlas = GlyphAtlas::new(100);
let u = atlas.utilization();
assert!((u - 0.0).abs() < f32::EPSILON);
}
#[test]
fn expand_commands_passthrough_non_text() {
use oxiui_core::geometry::Rect;
use oxiui_core::paint::{DrawCommand, DrawList};
use oxiui_core::Color;
let mut list = DrawList::new();
list.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Color(255, 0, 0, 255));
list.push_rect(Rect::new(10.0, 0.0, 10.0, 10.0), Color(0, 255, 0, 255));
let pipeline_result = TextPipeline::from_system_font("Helvetica")
.or_else(|_| TextPipeline::from_system_font("Arial"))
.or_else(|_| TextPipeline::from_system_font("sans-serif"));
let Ok(pipeline) = pipeline_result else {
return;
};
let mut bridge = TextBridge::new(pipeline, 256);
let expanded = bridge.expand_draw_text_commands(&list);
let rects: Vec<_> = expanded
.iter()
.filter(|c| matches!(c, DrawCommand::FillRect { .. }))
.collect();
assert_eq!(rects.len(), 2, "two FillRect commands must pass through");
}
#[test]
fn expand_commands_draw_text_produces_image_blits() {
use oxiui_core::geometry::Rect;
use oxiui_core::paint::{DrawCommand, DrawList};
use oxiui_core::{Color, FontSpec};
let pipeline_result = TextPipeline::from_system_font("Helvetica")
.or_else(|_| TextPipeline::from_system_font("Arial"))
.or_else(|_| TextPipeline::from_system_font("sans-serif"));
let Ok(pipeline) = pipeline_result else {
return; };
let mut bridge = TextBridge::new(pipeline, 512);
let mut list = DrawList::new();
let font = FontSpec::new("Helvetica", 16.0, 400);
list.push_text(
Rect::new(0.0, 0.0, 200.0, 24.0),
"Hi",
font,
Color(255, 255, 255, 255),
);
let expanded = bridge.expand_draw_text_commands(&list);
let text_cmds: usize = expanded
.iter()
.filter(|c| matches!(c, DrawCommand::DrawText { .. }))
.count();
assert_eq!(text_cmds, 0, "no DrawText commands should remain");
}
}