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, Copy, PartialEq, Eq)]
pub enum TextDirection {
LeftToRight,
RightToLeft,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CharGeom {
pub position: f32,
pub width: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineEnd {
SoftWrap,
HardBreak { chars: u8, bytes: u8 },
EndOfText,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LineTruncation {
pub ellipsis_x: f32,
pub ellipsis_width: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextLineSegment {
pub byte_range: std::ops::Range<usize>,
pub char_range: std::ops::Range<usize>,
pub direction: TextDirection,
pub rect: [f32; 4],
pub characters: Vec<CharGeom>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextLine {
pub index: usize,
pub byte_range: std::ops::Range<usize>,
pub char_range: std::ops::Range<usize>,
pub rect: [f32; 4],
pub baseline: f32,
pub caret_x: f32,
pub segments: Vec<TextLineSegment>,
pub end: LineEnd,
pub truncation: Option<LineTruncation>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextLink {
pub rendered_byte_range: std::ops::Range<usize>,
pub url: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TextGeometry {
pub lines: Vec<TextLine>,
pub dropped_lines: usize,
pub source_len: usize,
pub rendered_text: Option<String>,
pub links: Vec<TextLink>,
}
#[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,
pub geometry: Option<std::rc::Rc<TextGeometry>>,
}
#[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,
next_key: u64,
texts: std::collections::HashMap<u64, String>,
cache_generation: u64,
}
struct MockLine {
bytes: std::ops::Range<usize>,
chars: std::ops::Range<usize>,
end: LineEnd,
}
impl MockTextBackend {
pub fn new() -> Self {
Self {
char_width: 8.0,
line_height: 16.0,
next_key: 1,
texts: std::collections::HashMap::new(),
cache_generation: 0,
}
}
pub fn invalidate(&mut self) {
self.texts.clear();
self.cache_generation += 1;
}
fn take_key(&mut self, text: &str) -> u64 {
let key = self.next_key;
self.next_key += 1;
self.texts.insert(key, text.to_string());
key
}
fn advance_of(&self, ch: char) -> f32 {
if ch == '\n' || ch == '\r' {
0.0
} else {
self.char_width
}
}
fn split_hard_lines(text: &str) -> Vec<(std::ops::Range<usize>, LineEnd)> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut start = 0usize;
for i in 0..bytes.len() {
if bytes[i] == b'\n' {
let crlf = i > start && bytes[i - 1] == b'\r';
let n = if crlf { 2 } else { 1 };
out.push((start..i + 1, LineEnd::HardBreak { chars: n, bytes: n }));
start = i + 1;
}
}
out.push((start..text.len(), LineEnd::EndOfText));
out
}
fn wrap_range(
text: &str,
range: std::ops::Range<usize>,
max_chars: usize,
) -> Vec<std::ops::Range<usize>> {
if max_chars == 0 || range.is_empty() {
return vec![range];
}
let mut pieces = Vec::new();
let mut piece_start = range.start;
let mut chars_in_piece = 0usize;
let mut last_break: Option<usize> = None;
for (offset, ch) in text[range.clone()].char_indices() {
let at = range.start + offset;
if chars_in_piece >= max_chars {
let cut = match last_break {
Some(b) if b > piece_start && b <= at => b,
_ => at,
};
if cut > piece_start {
pieces.push(piece_start..cut);
piece_start = cut;
chars_in_piece = text[piece_start..at].chars().count();
last_break = None;
}
}
chars_in_piece += 1;
if ch == ' ' {
last_break = Some(at + ch.len_utf8());
}
}
pieces.push(piece_start..range.end);
pieces
}
fn break_lines(text: &str, max_chars: Option<usize>) -> Vec<MockLine> {
let mut out: Vec<MockLine> = Vec::new();
let mut char_cursor = 0usize;
for (range, end) in Self::split_hard_lines(text) {
let term = match end {
LineEnd::HardBreak { bytes, .. } => bytes as usize,
_ => 0,
};
let body = range.start..range.end - term;
let mut pieces = match max_chars {
Some(n) => Self::wrap_range(text, body, n),
None => vec![body],
};
if term > 0
&& let Some(last) = pieces.last_mut()
{
last.end += term;
}
let last_index = pieces.len() - 1;
for (i, piece) in pieces.into_iter().enumerate() {
let count = text[piece.clone()].chars().count();
out.push(MockLine {
chars: char_cursor..char_cursor + count,
bytes: piece,
end: if i == last_index {
end
} else {
LineEnd::SoftWrap
},
});
char_cursor += count;
}
}
out
}
fn geometry_for(
&self,
text: &str,
lines: &[MockLine],
dropped_lines: usize,
visible_chars: Option<usize>,
rendered_text: Option<String>,
links: Vec<TextLink>,
) -> TextGeometry {
let out = lines
.iter()
.enumerate()
.map(|(index, line)| {
let y = index as f32 * self.line_height;
let mut characters = Vec::new();
let mut position = 0.0f32;
let mut measured_bytes = line.bytes.start;
for (offset, ch) in text[line.bytes.clone()].char_indices() {
if visible_chars.is_some_and(|n| characters.len() >= n) {
break;
}
let width = self.advance_of(ch);
characters.push(CharGeom { position, width });
position += width;
measured_bytes = line.bytes.start + offset + ch.len_utf8();
}
let segments = if characters.is_empty() {
Vec::new()
} else {
vec![TextLineSegment {
byte_range: line.bytes.start..measured_bytes,
char_range: line.chars.start..line.chars.start + characters.len(),
direction: TextDirection::LeftToRight,
rect: [0.0, y, position, self.line_height],
characters,
}]
};
TextLine {
index,
byte_range: line.bytes.clone(),
char_range: line.chars.clone(),
rect: [0.0, y, position, self.line_height],
baseline: y + self.line_height * 0.75,
caret_x: 0.0,
segments,
end: line.end,
truncation: visible_chars.map(|_| LineTruncation {
ellipsis_x: position,
ellipsis_width: self.char_width,
}),
}
})
.collect();
TextGeometry {
lines: out,
dropped_lines,
source_len: text.len(),
rendered_text,
links,
}
}
fn flatten_markup(source: &str) -> (String, Vec<TextLink>) {
let mut rendered = String::with_capacity(source.len());
let mut links = Vec::new();
let bytes = source.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'['
&& let Some(close) = source[i..].find("](").map(|o| i + o)
&& let Some(end) = source[close + 2..].find(')').map(|o| close + 2 + o)
{
let label = &source[i + 1..close];
let url = &source[close + 2..end];
let start = rendered.len();
rendered.push_str(label);
links.push(TextLink {
rendered_byte_range: start..rendered.len(),
url: url.to_string(),
});
i = end + 1;
continue;
}
if bytes[i] == b'*' {
i += if source[i..].starts_with("**") { 2 } else { 1 };
continue;
}
let ch = source[i..]
.chars()
.next()
.expect("byte index on a boundary");
rendered.push(ch);
i += ch.len_utf8();
}
(rendered, links)
}
fn layout_from_lines(
&mut self,
source_key_text: &str,
geometry: TextGeometry,
width: f32,
) -> TextLayout {
let line_count = geometry.lines.len().max(1);
let key = self.take_key(source_key_text);
TextLayout {
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: key,
line_count,
spans: Vec::new(),
raster_scale: 1.0,
geometry: Some(std::rc::Rc::new(geometry)),
}
}
}
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 char_count = text.chars().count();
let width = char_count as f32 * self.char_width;
let (clamped_width, visible) = match max_width {
Some(max) if width > max => {
let budget = (max - self.char_width).max(0.0);
let kept = (budget / self.char_width).floor() as usize;
(kept as f32 * self.char_width + self.char_width, Some(kept))
}
Some(max) => (width.min(max), None),
None => (width, None),
};
let lines = vec![MockLine {
bytes: 0..text.len(),
chars: 0..char_count,
end: LineEnd::EndOfText,
}];
let geometry = self.geometry_for(text, &lines, 0, visible, None, Vec::new());
let mut layout = self.layout_from_lines(text, geometry, clamped_width);
layout.height = self.line_height;
layout.line_count = 1;
layout
}
fn layout_paragraph(
&mut self,
text: &str,
_style: &TextStyle,
max_width: f32,
max_lines: Option<usize>,
) -> TextLayout {
let max_chars = (max_width / self.char_width).floor() as usize;
let mut lines = Self::break_lines(text, Some(max_chars));
let dropped = match max_lines {
Some(n) if lines.len() > n => {
let dropped = lines.len() - n;
lines.truncate(n);
dropped
}
_ => 0,
};
let geometry = self.geometry_for(text, &lines, dropped, None, None, Vec::new());
let width = geometry
.lines
.iter()
.map(|l| l.rect[2])
.fold(0.0f32, f32::max);
self.layout_from_lines(text, geometry, width)
}
fn layout_single_line_markup(
&mut self,
source: &str,
style: &TextStyle,
max_width: Option<f32>,
) -> TextLayout {
let (rendered, links) = Self::flatten_markup(source);
let mut layout = self.layout_single_line(&rendered, style, max_width);
if let Some(geometry) = layout.geometry.as_mut().and_then(std::rc::Rc::get_mut) {
geometry.rendered_text = Some(rendered);
geometry.links = links;
}
layout
}
fn layout_paragraph_markup(
&mut self,
source: &str,
style: &TextStyle,
max_width: f32,
max_lines: Option<usize>,
) -> TextLayout {
let (rendered, links) = Self::flatten_markup(source);
let mut layout = self.layout_paragraph(&rendered, style, max_width, max_lines);
if let Some(geometry) = layout.geometry.as_mut().and_then(std::rc::Rc::get_mut) {
geometry.rendered_text = Some(rendered);
geometry.links = links;
}
layout
}
fn ensure_glyphs(&mut self, layout: &TextLayout) -> Vec<GlyphQuad> {
let Some(geometry) = layout.geometry.as_ref() else {
return Vec::new();
};
let mut quads = Vec::new();
for line in &geometry.lines {
for segment in &line.segments {
for ch in &segment.characters {
if ch.width <= 0.0 {
continue;
}
quads.push(GlyphQuad {
screen: [
segment.rect[0] + ch.position,
line.rect[1],
ch.width,
self.line_height,
],
atlas: [0.0, 0.0, ch.width, self.line_height],
color: [0.0, 0.0, 0.0, 1.0],
is_color: false,
});
}
}
}
quads
}
fn layout_cache_generation(&self) -> u64 {
self.cache_generation
}
fn debug_layout_text(&self, layout_key: u64) -> Option<String> {
self.texts.get(&layout_key).cloned()
}
}
#[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); }
}