use teksilo_tokens::TextStyle;
use crate::geometry::Point;
use crate::render_frame::GlyphQuad;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextOverflow {
Ellipsis(EllipsisMode),
#[default]
Wrap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EllipsisMode {
Trailing,
Middle,
Leading,
}
#[derive(Debug, Clone)]
pub struct TextLayout {
pub width: f32,
pub height: f32,
pub ascent: f32,
pub descent: f32,
pub underline_offset: f32,
pub underline_thickness: f32,
pub layout_key: u64,
pub line_count: usize,
pub spans: Vec<TextLayoutSpan>,
pub raster_scale: f32,
}
#[derive(Debug, Clone)]
pub struct TextLayoutSpan {
pub kind: TextSpanKind,
pub line_index: usize,
pub rect: [f32; 4],
pub byte_range: std::ops::Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextSpanKind {
Text,
Link { url: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HitTarget {
Text,
Link { url: String },
}
impl TextLayout {
pub fn hit_test(&self, point: Point) -> Option<HitTarget> {
for sp in self.spans.iter().rev() {
let [x, y, w, h] = sp.rect;
if point.x >= x && point.x < x + w && point.y >= y && point.y < y + h {
return Some(match &sp.kind {
TextSpanKind::Link { url } => HitTarget::Link { url: url.clone() },
TextSpanKind::Text => HitTarget::Text,
});
}
}
None
}
}
pub fn quantize_raster_scale(scale: f32) -> f32 {
if !scale.is_finite() || scale <= 1.0 {
return 1.0;
}
const STEP: f32 = 1.25;
const MAX_BUCKET: i32 = 6;
let bucket = ((scale.ln() / STEP.ln()).round() as i32).clamp(0, MAX_BUCKET);
STEP.powi(bucket)
}
pub trait TextBackend {
fn set_scale_factor(&mut self, _scale_factor: f32) {}
fn set_raster_scale(&mut self, _raster_scale: f32) {}
fn raster_scale(&self) -> f32 {
1.0
}
fn layout_single_line(
&mut self,
text: &str,
style: &TextStyle,
max_width: Option<f32>,
) -> TextLayout;
fn layout_paragraph(
&mut self,
text: &str,
style: &TextStyle,
max_width: f32,
_max_lines: Option<usize>,
) -> TextLayout {
self.layout_single_line(text, style, Some(max_width))
}
fn layout_single_line_markup(
&mut self,
source: &str,
style: &TextStyle,
max_width: Option<f32>,
) -> TextLayout {
self.layout_single_line(source, style, max_width)
}
fn layout_paragraph_markup(
&mut self,
source: &str,
style: &TextStyle,
max_width: f32,
max_lines: Option<usize>,
) -> TextLayout {
self.layout_paragraph(source, style, max_width, max_lines)
}
fn ensure_glyphs(&mut self, layout: &TextLayout) -> Vec<GlyphQuad>;
fn touch_layout(&mut self, _layout_key: u64) {}
fn glyph_epoch(&self) -> u64 {
0
}
fn debug_validate_layout(&self, _layout_key: u64) -> GlyphValidation {
GlyphValidation::Valid
}
fn layout_cache_generation(&self) -> u64 {
0
}
fn debug_layout_text(&self, _layout_key: u64) -> Option<String> {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlyphValidation {
Valid,
StaleKey,
RectMismatch,
}
#[derive(Debug, Clone)]
pub struct AtlasInfo {
pub dirty: bool,
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
pub version: u64,
pub glyphs_evicted: bool,
}
pub struct MockTextBackend {
char_width: f32,
line_height: f32,
}
impl MockTextBackend {
pub fn new() -> Self {
Self {
char_width: 8.0,
line_height: 16.0,
}
}
}
impl Default for MockTextBackend {
fn default() -> Self {
Self::new()
}
}
impl TextBackend for MockTextBackend {
fn layout_single_line(
&mut self,
text: &str,
_style: &TextStyle,
max_width: Option<f32>,
) -> TextLayout {
let width = text.len() as f32 * self.char_width;
let clamped_width = match max_width {
Some(max) => width.min(max),
None => width,
};
TextLayout {
width: clamped_width,
height: self.line_height,
ascent: self.line_height * 0.75,
descent: self.line_height * 0.25,
underline_offset: 2.0,
underline_thickness: 1.0,
layout_key: 0,
line_count: 1,
spans: Vec::new(),
raster_scale: 1.0,
}
}
fn layout_paragraph(
&mut self,
text: &str,
_style: &TextStyle,
max_width: f32,
max_lines: Option<usize>,
) -> TextLayout {
let max_chars_per_line = (max_width / self.char_width).floor() as usize;
if max_chars_per_line == 0 {
return TextLayout {
width: 0.0,
height: self.line_height,
ascent: self.line_height * 0.75,
descent: self.line_height * 0.25,
underline_offset: 2.0,
underline_thickness: 1.0,
layout_key: 0,
line_count: 1,
spans: Vec::new(),
raster_scale: 1.0,
};
}
let words: Vec<&str> = text.split_whitespace().collect();
let mut lines: Vec<f32> = Vec::new(); let mut current_line_chars: usize = 0;
for word in &words {
let word_len = word.len();
let needed = if current_line_chars == 0 {
word_len
} else {
current_line_chars + 1 + word_len };
if needed > max_chars_per_line && current_line_chars > 0 {
lines.push(current_line_chars as f32 * self.char_width);
current_line_chars = word_len;
} else {
current_line_chars = needed;
}
}
if current_line_chars > 0 || lines.is_empty() {
lines.push(current_line_chars as f32 * self.char_width);
}
if let Some(max) = max_lines {
lines.truncate(max);
}
let line_count = lines.len();
let max_line_width = lines.iter().cloned().fold(0.0_f32, f32::max);
TextLayout {
width: max_line_width,
height: line_count as f32 * self.line_height,
ascent: self.line_height * 0.75,
descent: self.line_height * 0.25,
underline_offset: 2.0,
underline_thickness: 1.0,
layout_key: 0,
line_count,
spans: Vec::new(),
raster_scale: 1.0,
}
}
fn ensure_glyphs(&mut self, layout: &TextLayout) -> Vec<GlyphQuad> {
let char_count = (layout.width / 8.0).ceil() as usize;
if char_count == 0 {
return Vec::new();
}
(0..char_count)
.map(|i| GlyphQuad {
screen: [i as f32 * 8.0, 0.0, 8.0, layout.height],
atlas: [0.0, 0.0, 8.0, layout.height],
color: [0.0, 0.0, 0.0, 1.0],
is_color: false,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quantize_raster_scale_ladder_properties() {
assert_eq!(quantize_raster_scale(1.0), 1.0);
assert_eq!(quantize_raster_scale(0.5), 1.0);
assert_eq!(quantize_raster_scale(0.0), 1.0);
assert_eq!(quantize_raster_scale(f32::NAN), 1.0);
assert_eq!(quantize_raster_scale(2.0), 1.25_f32.powi(3));
assert_eq!(quantize_raster_scale(3.0), 1.25_f32.powi(5));
assert_eq!(quantize_raster_scale(10.0), 1.25_f32.powi(6));
for raw in [1.1, 1.5, 2.0, 2.7, 3.3, 5.0] {
let q = quantize_raster_scale(raw);
assert_eq!(quantize_raster_scale(q), q, "not idempotent at {raw}");
}
}
#[test]
fn mock_backend_measures_text() {
let mut backend = MockTextBackend::new();
let layout = backend.layout_single_line("Hello", &TextStyle::default(), None);
assert_eq!(layout.width, 40.0); assert_eq!(layout.height, 16.0);
}
#[test]
fn mock_backend_respects_max_width() {
let mut backend = MockTextBackend::new();
let layout = backend.layout_single_line("Hello World", &TextStyle::default(), Some(50.0));
assert!(layout.width <= 50.0);
}
#[test]
fn mock_backend_empty_text() {
let mut backend = MockTextBackend::new();
let layout = backend.layout_single_line("", &TextStyle::default(), None);
assert_eq!(layout.width, 0.0);
assert!(layout.height > 0.0); }
#[test]
fn mock_backend_ensure_glyphs_returns_fake_quads() {
let mut backend = MockTextBackend::new();
let layout = backend.layout_single_line("Hi", &TextStyle::default(), None);
let glyphs = backend.ensure_glyphs(&layout);
assert_eq!(glyphs.len(), 2);
}
#[test]
fn mock_backend_single_line_count() {
let mut backend = MockTextBackend::new();
let layout = backend.layout_single_line("Hello", &TextStyle::default(), None);
assert_eq!(layout.line_count, 1);
}
#[test]
fn mock_backend_paragraph_wraps() {
let mut backend = MockTextBackend::new();
let layout = backend.layout_paragraph("Hello World", &TextStyle::default(), 50.0, None);
assert_eq!(layout.line_count, 2);
assert_eq!(layout.height, 32.0); }
#[test]
fn mock_backend_paragraph_max_lines() {
let mut backend = MockTextBackend::new();
let layout = backend.layout_paragraph(
"one two three four five",
&TextStyle::default(),
40.0, Some(2),
);
assert_eq!(layout.line_count, 2);
}
#[test]
fn mock_backend_paragraph_single_line_fits() {
let mut backend = MockTextBackend::new();
let layout = backend.layout_paragraph("Hi", &TextStyle::default(), 100.0, None);
assert_eq!(layout.line_count, 1);
assert_eq!(layout.width, 16.0); }
}