1use std::sync::Arc;
23
24use glam::Vec2;
25
26use crate::application::Application;
27use crate::color::Color;
28use crate::math::Rect;
29use crate::scene::{Graphics, Sprite};
30use crate::text::texture::TextTexturePipeline;
31use crate::text::{WispText, WispTextLayout, WispTextStyle};
32use crate::texture::render_texture::RenderTexture;
33
34#[derive(Debug, Clone)]
36pub struct CaptionBlock {
37 pub text: WispText,
39 pub background_color: Color,
41 pub corner_radius_ndc: f32,
43 pub padding_ndc: f32,
46 pub width_ndc: f32,
49 pub text_resolution_px: (u32, u32),
52}
53
54impl Default for CaptionBlock {
55 fn default() -> Self {
56 Self {
57 text: WispText::new(""),
58 background_color: Color::rgba_u8(20, 22, 28, 220),
59 corner_radius_ndc: 0.04,
60 padding_ndc: 0.05,
61 width_ndc: 0.8,
62 text_resolution_px: (1280, 320),
63 }
64 }
65}
66
67pub struct CaptionLayout {
75 pub background: Graphics,
77 pub text_sprite: Sprite,
79 pub text_rt: Arc<RenderTexture>,
82 pub height_ndc: f32,
84}
85
86impl CaptionBlock {
87 #[must_use]
91 pub fn from_text(text: WispText) -> Self {
92 Self {
93 text,
94 ..Self::default()
95 }
96 }
97
98 #[must_use]
100 pub fn with_background(mut self, color: Color) -> Self {
101 self.background_color = color;
102 self
103 }
104
105 #[must_use]
107 pub fn with_radius(mut self, radius_ndc: f32) -> Self {
108 self.corner_radius_ndc = radius_ndc;
109 self
110 }
111
112 #[must_use]
114 pub fn with_padding(mut self, padding_ndc: f32) -> Self {
115 self.padding_ndc = padding_ndc;
116 self
117 }
118
119 #[must_use]
121 pub fn with_width(mut self, width_ndc: f32) -> Self {
122 self.width_ndc = width_ndc;
123 self
124 }
125
126 #[must_use]
128 pub fn with_text(mut self, text: WispText) -> Self {
129 self.text = text;
130 self
131 }
132
133 #[must_use]
135 pub fn with_style(mut self, style: WispTextStyle) -> Self {
136 self.text = self.text.with_style(style);
137 self
138 }
139
140 #[must_use]
144 pub fn layout(&self, app: &Application, pipeline: &TextTexturePipeline) -> CaptionLayout {
145 let text_max_w = (self.width_ndc - 2.0 * self.padding_ndc).max(0.01);
146
147 let mut text = self.text.clone();
151 if text.max_width_ndc.is_none() {
152 text = text.with_wrap(text_max_w);
153 }
154
155 let metrics = pipeline.engine().layout_concrete(&text).metrics();
156 let text_h = metrics.total_height_ndc.max(text.style.size_ndc);
157
158 let height_ndc = text_h + 2.0 * self.padding_ndc;
159
160 let mut bg = Graphics::new();
161 bg.fill(crate::Fill::Solid(self.background_color));
162 bg.draw_rounded_rect(
163 Rect::new(0.0, 0.0, self.width_ndc, height_ndc),
164 self.corner_radius_ndc,
165 );
166
167 let (tex_w, tex_resolution_h) = self.text_resolution_px;
168 let rt = pipeline.render(app, &text, tex_w, tex_resolution_h);
169 let texture = rt.as_texture();
170
171 let mut sprite = Sprite::from_texture(texture);
172 sprite.with_anchor_set(Vec2::new(0.0, 0.0));
174 sprite.container.transform.position =
175 Vec2::new(self.padding_ndc, self.padding_ndc + text_h);
176 sprite.container.transform.scale = Vec2::new(text_max_w, -text_h);
179
180 CaptionLayout {
181 background: bg,
182 text_sprite: sprite,
183 text_rt: rt,
184 height_ndc,
185 }
186 }
187}
188
189impl Sprite {
190 pub fn with_anchor_set(&mut self, anchor: Vec2) -> &mut Self {
194 self.anchor = anchor;
195 self
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::application::{AppConfig, Application};
203 use crate::text::{WispText, WispTextStyle};
204 use pollster::block_on;
205
206 fn boot() -> (Application, TextTexturePipeline) {
207 let app = block_on(Application::new(AppConfig::default())).expect("app");
208 let pipeline = TextTexturePipeline::new(&app, wgpu::TextureFormat::Rgba8UnormSrgb);
209 (app, pipeline)
210 }
211
212 #[test]
213 fn caption_height_grows_with_more_lines() {
214 let (app, pipeline) = boot();
215 let style = WispTextStyle::default().with_size(0.06);
216
217 let short = CaptionBlock::from_text(WispText::new("hello").with_style(style))
218 .with_width(0.6)
219 .with_padding(0.03);
220 let long = CaptionBlock::from_text(
221 WispText::new("hello world this is a much longer caption that must wrap")
222 .with_style(style),
223 )
224 .with_width(0.6)
225 .with_padding(0.03);
226
227 let short_layout = short.layout(&app, &pipeline);
228 let long_layout = long.layout(&app, &pipeline);
229 assert!(
230 long_layout.height_ndc > short_layout.height_ndc,
231 "long caption height {} should exceed short {}",
232 long_layout.height_ndc,
233 short_layout.height_ndc,
234 );
235 }
236
237 #[test]
238 fn caption_height_includes_padding_on_both_sides() {
239 let (app, pipeline) = boot();
240 let style = WispTextStyle::default().with_size(0.08);
241 let block = CaptionBlock::from_text(WispText::new("x").with_style(style))
242 .with_width(0.5)
243 .with_padding(0.1);
244
245 let layout = block.layout(&app, &pipeline);
246 assert!(layout.height_ndc >= 0.28 - 1e-6);
248 }
249
250 #[test]
251 fn builder_methods_chain_and_apply() {
252 let style = WispTextStyle::default().with_size(0.05);
253 let block = CaptionBlock::from_text(WispText::new("abc").with_style(style))
254 .with_background(Color::RED)
255 .with_radius(0.07)
256 .with_padding(0.04)
257 .with_width(0.9);
258 assert_eq!(block.background_color, Color::RED);
259 assert!((block.corner_radius_ndc - 0.07).abs() < 1e-6);
260 assert!((block.padding_ndc - 0.04).abs() < 1e-6);
261 assert!((block.width_ndc - 0.9).abs() < 1e-6);
262 }
263
264 #[test]
265 fn explicit_wrap_overrides_block_inner_width() {
266 let (app, pipeline) = boot();
267 let style = WispTextStyle::default().with_size(0.06);
268 let text = WispText::new("hello world foo bar")
270 .with_style(style)
271 .with_wrap(0.4);
272 let block = CaptionBlock::from_text(text)
273 .with_width(0.7)
274 .with_padding(0.05);
275 let layout = block.layout(&app, &pipeline);
276 assert!(layout.height_ndc > 0.0);
278 }
279}