use crate::core::{Pixels, Rgba, Size};
use crate::gpu::font_families::FontFamilies;
pub use crate::gpu::font_families::FontFamily;
use crate::gpu::fonts::build_font_system;
use crate::gpu::text_paint::GlyphTile;
use crate::gpu::text_traits::TextTraits;
use cosmic_text::{Attrs, Buffer, CacheKey, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight};
use std::collections::HashMap;
pub use crate::gpu::text_paint::{GlyphTarget, TextPlacement};
pub(crate) const UNDERLINE_FLAG: usize = 1;
pub(crate) const CODE_FLAG: usize = 2;
pub(crate) const STRIKE_FLAG: usize = 4;
pub struct ShapedText {
pub(crate) buffer: Buffer,
pub size: Size,
pub line_height: f32,
}
impl ShapedText {
pub fn caret_x(&self, byte_index: usize) -> f32 {
let Some(run) = self.buffer.layout_runs().next() else { return 0.0 };
for glyph in run.glyphs {
if glyph.start >= byte_index {
return glyph.x;
}
}
run.line_w
}
pub fn byte_at_x(&self, x: f32) -> usize {
let Some(run) = self.buffer.layout_runs().next() else { return 0 };
for glyph in run.glyphs {
if x < glyph.x + glyph.w / 2.0 {
return glyph.start;
}
}
run.glyphs.last().map_or(0, |glyph| glyph.end)
}
}
#[derive(Clone, Debug, Default, PartialEq, Hash)]
pub struct TextRun {
pub length: usize,
pub color: Option<[u8; 4]>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub code: bool,
pub strikethrough: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextStyle {
pub font_size: f32,
pub line_height: f32,
pub monospace: bool,
pub family: Option<FontFamily>,
pub max_width: Option<f32>,
pub traits: TextTraits,
}
pub struct TextSystem {
pub(crate) font_system: FontSystem,
pub(crate) swash_cache: SwashCache,
pub(crate) tiles: HashMap<(CacheKey, bool), Option<GlyphTile>>,
families: FontFamilies,
}
pub fn rgba_bytes(color: Rgba) -> [u8; 4] {
let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
[channel(color.r), channel(color.g), channel(color.b), channel(color.a)]
}
impl TextSystem {
pub fn new() -> Self {
Self {
font_system: build_font_system(),
swash_cache: SwashCache::new(),
tiles: HashMap::new(),
families: FontFamilies::default(),
}
}
pub fn font_family(&mut self, name: &str) -> Option<FontFamily> {
self.families.resolve(&mut self.font_system, name)
}
fn base_attrs(&self, style: &TextStyle) -> Attrs<'static> {
let named = style.family.and_then(|family| self.families.name(family)).map(Family::Name);
let attrs =
Attrs::new().family(named.unwrap_or(if style.monospace { Family::Monospace } else { Family::SansSerif }));
style.traits.apply(attrs)
}
pub fn shape(&mut self, text: &str, style: &TextStyle) -> ShapedText {
self.shape_rich(text, &[], style)
}
pub fn shape_rich(&mut self, text: &str, runs: &[TextRun], style: &TextStyle) -> ShapedText {
let mut buffer = Buffer::new(&mut self.font_system, Metrics::new(style.font_size, style.line_height));
buffer.set_size(style.max_width, None);
buffer.set_ellipsize(style.traits.ellipsize());
let text = style.traits.transformed(text, !runs.is_empty());
let text = text.as_ref();
let base = self.base_attrs(style);
let mut spans: Vec<(&str, Attrs)> = Vec::with_capacity(runs.len() + 1);
let mut offset = 0;
for run in runs {
let end = (offset + run.length).min(text.len());
let Some(slice) = text.get(offset..end) else { break };
let mut attrs = base.clone();
if let Some([red, green, blue, alpha]) = run.color {
attrs = attrs.color(Color::rgba(red, green, blue, alpha));
}
if run.bold {
attrs = attrs.weight(Weight::BOLD);
}
if run.italic {
attrs = attrs.style(Style::Italic);
}
if run.code {
attrs = attrs.family(Family::Monospace);
}
let flags = if run.underline { UNDERLINE_FLAG } else { 0 }
| if run.code { CODE_FLAG } else { 0 }
| if run.strikethrough { STRIKE_FLAG } else { 0 };
if flags != 0 {
attrs = attrs.metadata(flags);
}
spans.push((slice, attrs));
offset = end;
}
if let Some(rest) = text.get(offset..).filter(|rest| !rest.is_empty()) {
spans.push((rest, base.clone()));
}
buffer.set_rich_text(spans, &base, Shaping::Advanced, style.traits.alignment());
buffer.shape_until_scroll(&mut self.font_system, false);
let mut width = 0.0f32;
let mut lines = 0usize;
for run in buffer.layout_runs() {
width = width.max(run.line_w);
lines += 1;
}
let height = lines.max(1) as f32 * style.line_height;
ShapedText { buffer, size: Size::new(Pixels(width), Pixels(height)), line_height: style.line_height }
}
pub fn shrink_caches(&mut self) {
self.tiles = HashMap::new();
self.swash_cache = SwashCache::new();
}
}
impl Default for TextSystem {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bold_runs_do_not_leak_into_following_text() {
let mut system = TextSystem::new();
let style = TextStyle {
font_size: 14.0,
line_height: 20.0,
monospace: true,
family: None,
max_width: None,
traits: TextTraits::default(),
};
let shaped = system.shape_rich("ab cd", &[TextRun { length: 2, bold: true, ..Default::default() }], &style);
let run = shaped.buffer.layout_runs().next().expect("una línea");
let fonts: Vec<_> = run.glyphs.iter().map(|glyph| glyph.font_id).collect();
assert_ne!(fonts[0], fonts[4], "la negrita debe terminar en el tramo: {fonts:?}");
let runs =
[TextRun { length: 3, ..Default::default() }, TextRun { length: 2, bold: true, ..Default::default() }];
let shaped = system.shape_rich("ab cd ef", &runs, &style);
let run = shaped.buffer.layout_runs().next().expect("una línea");
let fonts: Vec<_> = run.glyphs.iter().map(|glyph| (glyph.start, glyph.font_id)).collect();
assert_eq!(fonts[0].1, fonts[6].1, "después del tramo en negrita vuelve la fuente normal: {fonts:?}");
}
}