1use std::sync::Arc;
15
16use glam::Vec2;
17
18use crate::color::Color;
19use crate::scene::Sprite;
20use crate::texture::render_texture::RenderTexture;
21
22#[derive(Debug, Clone)]
24pub struct StrokedTextLayer {
25 pub fill: Color,
27 pub stroke: Color,
29 pub stroke_width_ndc: f32,
33}
34
35impl Default for StrokedTextLayer {
36 fn default() -> Self {
37 Self {
38 fill: Color::WHITE,
39 stroke: Color::BLACK,
40 stroke_width_ndc: 0.0,
41 }
42 }
43}
44
45#[expect(
49 clippy::approx_constant,
50 reason = "0.7071 is √2/2 in 4-digit literals — explicit for readability"
51)]
52const STROKE_OFFSETS: [(f32, f32); 8] = [
53 (1.0, 0.0),
54 (0.7071, 0.7071),
55 (0.0, 1.0),
56 (-0.7071, 0.7071),
57 (-1.0, 0.0),
58 (-0.7071, -0.7071),
59 (0.0, -1.0),
60 (0.7071, -0.7071),
61];
62
63#[must_use]
79pub fn stroked_text_sprites(text_rt: &Arc<RenderTexture>, layer: &StrokedTextLayer) -> Vec<Sprite> {
80 let tex = text_rt.as_texture();
81 let mut sprites = Vec::with_capacity(if layer.stroke_width_ndc > 0.0 { 9 } else { 1 });
82
83 if layer.stroke_width_ndc > 0.0 {
84 for (dx, dy) in STROKE_OFFSETS {
85 let mut s = Sprite::from_texture(tex.clone()).with_tint(layer.stroke);
86 s.container.transform.position =
87 Vec2::new(dx * layer.stroke_width_ndc, dy * layer.stroke_width_ndc);
88 sprites.push(s);
89 }
90 }
91
92 let fill_sprite = Sprite::from_texture(tex).with_tint(layer.fill);
93 sprites.push(fill_sprite);
94
95 sprites
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 fn dummy_rt(width: u32, height: u32) -> Arc<RenderTexture> {
103 use crate::application::{AppConfig, Application};
106 use pollster::block_on;
107 let app = block_on(Application::new(AppConfig::default())).expect("Application::new");
108 Arc::new(RenderTexture::new(&app, width, height))
109 }
110
111 #[test]
112 fn zero_stroke_emits_one_sprite() {
113 let rt = dummy_rt(64, 32);
114 let sprites = stroked_text_sprites(
115 &rt,
116 &StrokedTextLayer {
117 stroke_width_ndc: 0.0,
118 ..StrokedTextLayer::default()
119 },
120 );
121 assert_eq!(sprites.len(), 1);
122 assert_eq!(sprites[0].tint, Color::WHITE);
123 }
124
125 #[test]
126 fn positive_stroke_emits_eight_plus_one() {
127 let rt = dummy_rt(64, 32);
128 let sprites = stroked_text_sprites(
129 &rt,
130 &StrokedTextLayer {
131 fill: Color::WHITE,
132 stroke: Color::BLACK,
133 stroke_width_ndc: 0.01,
134 },
135 );
136 assert_eq!(sprites.len(), 9);
137 for stroke_sprite in &sprites[..8] {
139 assert_eq!(stroke_sprite.tint, Color::BLACK);
140 }
141 assert_eq!(sprites[8].tint, Color::WHITE);
142 }
143
144 #[test]
145 fn stroke_sprites_offset_at_configured_radius() {
146 let rt = dummy_rt(64, 32);
147 let r = 0.04_f32;
148 let sprites = stroked_text_sprites(
149 &rt,
150 &StrokedTextLayer {
151 fill: Color::WHITE,
152 stroke: Color::BLACK,
153 stroke_width_ndc: r,
154 },
155 );
156 for s in &sprites[..8] {
158 let p = s.container.transform.position;
159 let dist = (p.x * p.x + p.y * p.y).sqrt();
160 assert!(
161 (dist - r).abs() < 1e-4,
162 "stroke sprite at {p:?} is at radius {dist}, expected {r}",
163 );
164 }
165 let fill_pos = sprites[8].container.transform.position;
167 assert!(fill_pos.length() < 1e-6);
168 }
169
170 #[test]
171 fn stroke_width_scales_linearly() {
172 let rt = dummy_rt(64, 32);
173 for &r in &[0.001_f32, 0.01, 0.05, 0.1] {
174 let sprites = stroked_text_sprites(
175 &rt,
176 &StrokedTextLayer {
177 fill: Color::WHITE,
178 stroke: Color::BLACK,
179 stroke_width_ndc: r,
180 },
181 );
182 let p = sprites[0].container.transform.position;
183 let dist = (p.x * p.x + p.y * p.y).sqrt();
184 assert!((dist - r).abs() < 1e-4);
185 }
186 }
187}