1use glam::{Vec2, Vec3, Vec4};
8use crate::glyph::{Glyph, GlyphPool, BlendMode, RenderLayer};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
13pub enum TextAlign {
14 #[default]
15 Left,
16 Center,
17 Right,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
21pub enum TextBaseline {
22 #[default]
23 Top,
24 Middle,
25 Bottom,
26}
27
28#[derive(Clone, Debug)]
32pub struct TextSpan {
33 pub text: String,
34 pub color: Vec4,
35 pub emission: f32,
36 pub scale: Vec2,
37 pub layer: RenderLayer,
38 pub blend: BlendMode,
39}
40
41impl TextSpan {
42 pub fn plain(text: &str) -> Self {
43 Self {
44 text: text.into(),
45 color: Vec4::ONE,
46 emission: 0.0,
47 scale: Vec2::ONE,
48 layer: RenderLayer::UI,
49 blend: BlendMode::Normal,
50 }
51 }
52
53 pub fn colored(text: &str, color: Vec4) -> Self {
54 Self { text: text.into(), color, ..Self::plain("") }
55 }
56
57 pub fn glowing(text: &str, color: Vec4, emission: f32) -> Self {
58 Self { text: text.into(), color, emission, ..Self::plain("") }
59 }
60}
61
62pub fn parse_rich_text(markup: &str) -> Vec<TextSpan> {
74 let mut spans = Vec::new();
75 let mut stack: Vec<TextSpan> = Vec::new();
76 let mut cursor = 0_usize;
77 let bytes = markup.as_bytes();
78
79 let mut color = Vec4::ONE;
81 let mut emission = 0.0_f32;
82 let mut scale = Vec2::ONE;
83
84 let mut text_buf = String::new();
85
86 macro_rules! flush {
87 () => {
88 if !text_buf.is_empty() {
89 spans.push(TextSpan {
90 text: std::mem::take(&mut text_buf),
91 color, emission, scale,
92 layer: RenderLayer::UI,
93 blend: BlendMode::Normal,
94 });
95 }
96 }
97 }
98
99 while cursor < bytes.len() {
100 if bytes[cursor] == b'[' {
101 if let Some(end) = markup[cursor..].find(']') {
103 let tag = &markup[cursor+1 .. cursor+end];
104 cursor += end + 1;
105
106 if tag.starts_with('/') {
107 flush!();
109 if let Some(saved) = stack.pop() {
110 color = saved.color;
111 emission = saved.emission;
112 scale = saved.scale;
113 }
114 } else if let Some(rest) = tag.strip_prefix("color:") {
115 flush!();
116 stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
117 let parts: Vec<f32> = rest.split(',')
118 .filter_map(|s| s.trim().parse().ok())
119 .collect();
120 if parts.len() >= 3 {
121 color = Vec4::new(parts[0], parts[1], parts[2], 1.0);
122 }
123 } else if let Some(rest) = tag.strip_prefix("rgba:") {
124 flush!();
125 stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
126 let parts: Vec<f32> = rest.split(',')
127 .filter_map(|s| s.trim().parse().ok())
128 .collect();
129 if parts.len() >= 4 {
130 color = Vec4::new(parts[0], parts[1], parts[2], parts[3]);
131 }
132 } else if let Some(rest) = tag.strip_prefix("emit:") {
133 flush!();
134 stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
135 emission = rest.trim().parse().unwrap_or(0.0);
136 } else if let Some(rest) = tag.strip_prefix("scale:") {
137 flush!();
138 stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
139 let parts: Vec<f32> = rest.split(',')
140 .filter_map(|s| s.trim().parse().ok())
141 .collect();
142 if parts.len() >= 2 {
143 scale = Vec2::new(parts[0], parts[1]);
144 }
145 } else if tag == "bold" {
146 flush!();
147 stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
148 scale = Vec2::new(1.15, 1.15);
149 } else if tag == "wave" {
150 flush!();
151 stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
152 emission = 0.5;
153 color = Vec4::new(0.7, 0.9, 1.0, 1.0);
154 }
155 continue;
156 }
157 }
158
159 text_buf.push(markup[cursor..].chars().next().unwrap_or(' '));
161 cursor += markup[cursor..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
162 }
163 flush!();
164
165 if spans.is_empty() {
166 spans.push(TextSpan::plain(markup));
167 }
168 spans
169}
170
171#[derive(Clone, Debug)]
175pub struct TextBlock {
176 pub spans: Vec<TextSpan>,
177 pub position: Vec3,
178 pub char_width: f32,
179 pub char_height: f32,
180 pub max_width: Option<f32>, pub align: TextAlign,
182 pub baseline: TextBaseline,
183 pub layer: RenderLayer,
184 pub blend: BlendMode,
185 pub visible: bool,
186 pub z_offset: f32,
187}
188
189impl TextBlock {
190 pub fn new(text: &str, position: Vec3) -> Self {
191 Self {
192 spans: vec![TextSpan::plain(text)],
193 position,
194 char_width: 0.6,
195 char_height: 1.0,
196 max_width: None,
197 align: TextAlign::Left,
198 baseline: TextBaseline::Top,
199 layer: RenderLayer::UI,
200 blend: BlendMode::Normal,
201 visible: true,
202 z_offset: 0.0,
203 }
204 }
205
206 pub fn rich(markup: &str, position: Vec3) -> Self {
207 Self {
208 spans: parse_rich_text(markup),
209 ..Self::new("", position)
210 }
211 }
212
213 pub fn with_color(mut self, c: Vec4) -> Self {
214 for s in &mut self.spans { s.color = c; }
215 self
216 }
217
218 pub fn with_scale(mut self, w: f32, h: f32) -> Self {
219 self.char_width = w;
220 self.char_height = h;
221 self
222 }
223
224 pub fn with_align(mut self, a: TextAlign) -> Self { self.align = a; self }
225 pub fn with_max_width(mut self, w: f32) -> Self { self.max_width = Some(w); self }
226 pub fn with_layer(mut self, l: RenderLayer) -> Self { self.layer = l; self }
227
228 pub fn full_text(&self) -> String {
230 self.spans.iter().map(|s| s.text.as_str()).collect()
231 }
232
233 pub fn layout(&self) -> Vec<CharLayout> {
235 let full = self.full_text();
236 let lines = self.wrap_lines(&full);
237 let total_height = lines.len() as f32 * self.char_height;
238
239 let baseline_offset = match self.baseline {
240 TextBaseline::Top => 0.0,
241 TextBaseline::Middle => -total_height * 0.5,
242 TextBaseline::Bottom => -total_height,
243 };
244
245 let mut result = Vec::new();
246 let mut span_iter = SpanCharIter::new(&self.spans);
247
248 for (row, line) in lines.iter().enumerate() {
249 let line_width = line.chars().count() as f32 * self.char_width;
250 let x_offset = match self.align {
251 TextAlign::Left => 0.0,
252 TextAlign::Center => -line_width * 0.5,
253 TextAlign::Right => -line_width,
254 };
255
256 for (col, _ch) in line.chars().enumerate() {
257 let x = self.position.x + x_offset + col as f32 * self.char_width;
258 let y = self.position.y + baseline_offset - row as f32 * self.char_height;
259 let z = self.position.z + self.z_offset;
260
261 if let Some((ch, color, emission, scale)) = span_iter.next() {
262 result.push(CharLayout {
263 ch,
264 position: Vec3::new(x, y, z),
265 color,
266 emission,
267 scale,
268 layer: self.layer,
269 blend: self.blend,
270 });
271 }
272 }
273 }
274 result
275 }
276
277 fn wrap_lines<'a>(&self, text: &'a str) -> Vec<String> {
278 if let Some(max_w) = self.max_width {
279 let max_chars = (max_w / self.char_width.max(0.01)) as usize;
280 wrap_text(text, max_chars)
281 } else {
282 text.lines().map(|l| l.to_string()).collect()
283 }
284 }
285
286 pub fn spawn_into(&self, pool: &mut GlyphPool) -> Vec<crate::glyph::GlyphId> {
288 let mut ids = Vec::new();
289 if !self.visible { return ids; }
290 for cl in self.layout() {
291 let g = Glyph {
292 character: cl.ch,
293 position: cl.position,
294 color: cl.color,
295 emission: cl.emission,
296 scale: cl.scale,
297 layer: cl.layer,
298 blend_mode: cl.blend,
299 visible: true,
300 ..Glyph::default()
301 };
302 ids.push(pool.spawn(g));
303 }
304 ids
305 }
306}
307
308#[derive(Clone, Debug)]
310pub struct CharLayout {
311 pub ch: char,
312 pub position: Vec3,
313 pub color: Vec4,
314 pub emission: f32,
315 pub scale: Vec2,
316 pub layer: RenderLayer,
317 pub blend: BlendMode,
318}
319
320struct SpanCharIter<'a> {
322 spans: &'a [TextSpan],
323 span_idx: usize,
324 char_idx: usize,
325}
326
327impl<'a> SpanCharIter<'a> {
328 fn new(spans: &'a [TextSpan]) -> Self {
329 Self { spans, span_idx: 0, char_idx: 0 }
330 }
331
332 fn next(&mut self) -> Option<(char, Vec4, f32, Vec2)> {
333 loop {
334 let span = self.spans.get(self.span_idx)?;
335 let ch = span.text.chars().nth(self.char_idx);
336 if let Some(ch) = ch {
337 self.char_idx += 1;
338 if ch == '\n' { continue; } return Some((ch, span.color, span.emission, span.scale));
340 } else {
341 self.span_idx += 1;
342 self.char_idx = 0;
343 }
344 }
345 }
346}
347
348pub fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
350 let mut lines = Vec::new();
351 for paragraph in text.split('\n') {
352 if paragraph.is_empty() { lines.push(String::new()); continue; }
353 let words: Vec<&str> = paragraph.split_whitespace().collect();
354 let mut line = String::new();
355 for word in words {
356 if line.is_empty() {
357 if word.len() > max_chars {
359 let mut w = word;
360 while w.len() > max_chars {
361 lines.push(w[..max_chars].to_string());
362 w = &w[max_chars..];
363 }
364 line = w.to_string();
365 } else {
366 line = word.to_string();
367 }
368 } else if line.len() + 1 + word.len() <= max_chars {
369 line.push(' ');
370 line.push_str(word);
371 } else {
372 lines.push(std::mem::take(&mut line));
373 line = word.to_string();
374 }
375 }
376 if !line.is_empty() { lines.push(line); }
377 }
378 if lines.is_empty() { lines.push(String::new()); }
379 lines
380}
381
382pub struct TypewriterBlock {
386 pub block: TextBlock,
387 pub chars_per_sec: f32,
388 revealed_chars: usize,
389 accumulator: f32,
390 pub complete: bool,
391 pause_timer: f32,
392 full_char_count: usize,
393 pub on_char: Option<Box<dyn Fn(char) + Send + Sync>>,
395}
396
397impl TypewriterBlock {
398 pub fn new(block: TextBlock, chars_per_sec: f32) -> Self {
399 let full = block.full_text().chars().count();
400 Self {
401 block,
402 chars_per_sec,
403 revealed_chars: 0,
404 accumulator: 0.0,
405 complete: full == 0,
406 pause_timer: 0.0,
407 full_char_count: full,
408 on_char: None,
409 }
410 }
411
412 pub fn with_char_callback(mut self, f: impl Fn(char) + Send + Sync + 'static) -> Self {
413 self.on_char = Some(Box::new(f));
414 self
415 }
416
417 pub fn tick(&mut self, dt: f32) {
418 if self.complete { return; }
419 if self.pause_timer > 0.0 {
420 self.pause_timer -= dt;
421 return;
422 }
423 self.accumulator += dt * self.chars_per_sec;
424 let new = self.accumulator as usize;
425 self.accumulator -= new as f32;
426
427 let full_text = self.block.full_text();
428 for _ in 0..new {
429 if self.revealed_chars >= self.full_char_count { break; }
430 let ch = full_text.chars().nth(self.revealed_chars).unwrap_or(' ');
431 self.revealed_chars += 1;
432 if let Some(f) = &self.on_char { f(ch); }
433 match ch {
434 '.' | '!' | '?' => self.pause_timer = 0.2,
435 ',' | ';' | ':' => self.pause_timer = 0.08,
436 _ => {}
437 }
438 }
439 if self.revealed_chars >= self.full_char_count {
440 self.complete = true;
441 }
442 }
443
444 pub fn skip(&mut self) {
445 self.revealed_chars = self.full_char_count;
446 self.complete = true;
447 }
448
449 pub fn progress(&self) -> f32 {
450 if self.full_char_count == 0 { 1.0 }
451 else { self.revealed_chars as f32 / self.full_char_count as f32 }
452 }
453
454 pub fn visible_block(&self) -> TextBlock {
456 if self.complete { return self.block.clone(); }
457 let full = self.block.full_text();
458 let visible: String = full.chars().take(self.revealed_chars).collect();
459 TextBlock {
460 spans: vec![TextSpan { text: visible, ..self.block.spans[0].clone() }],
461 ..self.block.clone()
462 }
463 }
464
465 pub fn spawn_into(&self, pool: &mut GlyphPool) -> Vec<crate::glyph::GlyphId> {
467 self.visible_block().spawn_into(pool)
468 }
469}
470
471pub struct ScrollingText {
475 pub lines: Vec<String>,
476 pub max_lines: usize,
477 pub visible: usize,
479 pub scroll_pos: usize,
480 pub position: Vec3,
481 pub char_width: f32,
482 pub char_height: f32,
483 pub color: Vec4,
484 pub layer: RenderLayer,
485 pub auto_scroll: f32,
487 scroll_accum: f32,
488}
489
490impl ScrollingText {
491 pub fn new(position: Vec3, visible: usize) -> Self {
492 Self {
493 lines: Vec::new(),
494 max_lines: 1000,
495 visible,
496 scroll_pos: 0,
497 position,
498 char_width: 0.6,
499 char_height: 1.0,
500 color: Vec4::ONE,
501 layer: RenderLayer::UI,
502 auto_scroll: 0.0,
503 scroll_accum: 0.0,
504 }
505 }
506
507 pub fn push(&mut self, line: impl Into<String>) {
508 self.lines.push(line.into());
509 if self.lines.len() > self.max_lines {
510 self.lines.remove(0);
511 }
512 if self.lines.len() > self.visible {
514 self.scroll_pos = self.lines.len() - self.visible;
515 }
516 }
517
518 pub fn scroll_up(&mut self) { self.scroll_pos = self.scroll_pos.saturating_sub(1); }
519 pub fn scroll_down(&mut self) {
520 let max = self.lines.len().saturating_sub(self.visible);
521 self.scroll_pos = (self.scroll_pos + 1).min(max);
522 }
523
524 pub fn tick(&mut self, dt: f32) {
525 if self.auto_scroll > 0.0 {
526 self.scroll_accum += dt * self.auto_scroll;
527 while self.scroll_accum >= 1.0 {
528 self.scroll_accum -= 1.0;
529 self.scroll_down();
530 }
531 }
532 }
533
534 pub fn spawn_into(&self, pool: &mut GlyphPool) -> Vec<crate::glyph::GlyphId> {
535 let mut ids = Vec::new();
536 let end = (self.scroll_pos + self.visible).min(self.lines.len());
537 for (row, line) in self.lines[self.scroll_pos..end].iter().enumerate() {
538 let y = self.position.y - row as f32 * self.char_height;
539 for (col, ch) in line.chars().enumerate() {
540 let x = self.position.x + col as f32 * self.char_width;
541 let g = Glyph {
542 character: ch,
543 position: Vec3::new(x, y, self.position.z),
544 color: self.color,
545 layer: self.layer,
546 visible: true,
547 ..Glyph::default()
548 };
549 ids.push(pool.spawn(g));
550 }
551 }
552 ids
553 }
554}
555
556pub struct Marquee {
560 pub text: String,
561 pub position: Vec3,
562 pub width: f32, pub char_w: f32,
564 pub speed: f32, offset: f32, pub color: Vec4,
567 pub layer: RenderLayer,
568}
569
570impl Marquee {
571 pub fn new(text: impl Into<String>, position: Vec3, width: f32, speed: f32) -> Self {
572 Self {
573 text: text.into(),
574 position,
575 width,
576 char_w: 0.6,
577 speed,
578 offset: 0.0,
579 color: Vec4::ONE,
580 layer: RenderLayer::UI,
581 }
582 }
583
584 pub fn tick(&mut self, dt: f32) {
585 self.offset += dt * self.speed;
586 let total_width = self.text.chars().count() as f32 * self.char_w;
587 if self.offset > total_width { self.offset = 0.0; }
588 }
589
590 pub fn spawn_into(&self, pool: &mut GlyphPool) -> Vec<crate::glyph::GlyphId> {
591 let mut ids = Vec::new();
592 let max_chars = (self.width / self.char_w.max(0.01)) as usize + 1;
593 let total_chars = self.text.chars().count();
594 if total_chars == 0 { return ids; }
595
596 let start_char = (self.offset / self.char_w) as usize;
597
598 for i in 0..max_chars {
599 let text_idx = (start_char + i) % total_chars;
600 let ch = self.text.chars().nth(text_idx).unwrap_or(' ');
601 let frac = (self.offset / self.char_w).fract();
602 let x = self.position.x + (i as f32 - frac) * self.char_w;
603 if x < self.position.x || x > self.position.x + self.width { continue; }
604 let g = Glyph {
605 character: ch,
606 position: Vec3::new(x, self.position.y, self.position.z),
607 color: self.color,
608 layer: self.layer,
609 visible: true,
610 ..Glyph::default()
611 };
612 ids.push(pool.spawn(g));
613 }
614 ids
615 }
616}
617
618#[cfg(test)]
621mod tests {
622 use super::*;
623
624 #[test]
625 fn wrap_text_basic() {
626 let lines = wrap_text("Hello world foo bar baz", 10);
627 for l in &lines { assert!(l.len() <= 10, "Line too long: {}", l); }
628 assert!(lines.len() >= 2);
629 }
630
631 #[test]
632 fn wrap_text_empty() {
633 let lines = wrap_text("", 20);
634 assert_eq!(lines.len(), 1);
635 }
636
637 #[test]
638 fn wrap_text_single_word() {
639 let lines = wrap_text("Hello", 20);
640 assert_eq!(lines[0], "Hello");
641 }
642
643 #[test]
644 fn wrap_long_word() {
645 let lines = wrap_text("AAAAAAAAAA", 4);
646 assert_eq!(lines[0], "AAAA");
647 assert_eq!(lines[1], "AAAA");
648 assert_eq!(lines[2], "AA");
649 }
650
651 #[test]
652 fn parse_rich_text_plain() {
653 let spans = parse_rich_text("Hello");
654 assert_eq!(spans.len(), 1);
655 assert_eq!(spans[0].text, "Hello");
656 }
657
658 #[test]
659 fn parse_rich_text_color() {
660 let spans = parse_rich_text("[color:1.0,0.0,0.0]Red[/color] Normal");
661 assert!(spans.len() >= 2);
662 let red = &spans[0];
663 assert!((red.color.x - 1.0).abs() < 0.01);
664 assert!((red.color.y - 0.0).abs() < 0.01);
665 }
666
667 #[test]
668 fn parse_rich_text_nested() {
669 let spans = parse_rich_text("[color:0,1,0]Green [bold]BoldGreen[/bold][/color]");
670 assert!(spans.iter().any(|s| (s.color.y - 1.0).abs() < 0.01));
671 }
672
673 #[test]
674 fn text_block_layout() {
675 let block = TextBlock::new("Hello", Vec3::ZERO);
676 let layout = block.layout();
677 assert_eq!(layout.len(), 5); }
679
680 #[test]
681 fn text_block_wrap() {
682 let block = TextBlock::new("Hello World", Vec3::ZERO)
683 .with_max_width(0.6 * 5.0); let layout = block.layout();
685 let max_y = layout.iter().map(|c| c.position.y).fold(f32::MIN, f32::max);
687 let min_y = layout.iter().map(|c| c.position.y).fold(f32::MAX, f32::min);
688 assert!(max_y > min_y, "Expect multiple rows");
689 }
690
691 #[test]
692 fn typewriter_reveals_chars() {
693 let block = TextBlock::new("Hello", Vec3::ZERO);
694 let mut tw = TypewriterBlock::new(block, 20.0);
695 tw.tick(0.1); assert!(tw.revealed_chars > 0);
697 assert!(!tw.complete);
698 }
699
700 #[test]
701 fn typewriter_completes() {
702 let block = TextBlock::new("Hi", Vec3::ZERO);
703 let mut tw = TypewriterBlock::new(block, 100.0);
704 tw.tick(1.0);
705 assert!(tw.complete);
706 }
707
708 #[test]
709 fn typewriter_skip() {
710 let block = TextBlock::new("Long text", Vec3::ZERO);
711 let mut tw = TypewriterBlock::new(block, 2.0);
712 tw.skip();
713 assert!(tw.complete);
714 assert_eq!(tw.progress(), 1.0);
715 }
716
717 #[test]
718 fn scrolling_text_push_and_scroll() {
719 let mut log = ScrollingText::new(Vec3::ZERO, 3);
720 log.push("Line 1");
721 log.push("Line 2");
722 log.push("Line 3");
723 log.push("Line 4");
724 assert_eq!(log.lines.len(), 4);
725 assert!(log.scroll_pos >= 1); }
727
728 #[test]
729 fn marquee_wraps() {
730 let mut m = Marquee::new("ABCDE", Vec3::ZERO, 3.0, 1.0);
731 for _ in 0..300 { m.tick(0.016); }
732 assert!(m.offset < m.text.len() as f32 * m.char_w + 1.0);
734 }
735}