Skip to main content

wisp/text/
caption.rs

1//! Caption block — wrapped text on top of a rounded rect background
2//! (M-TEXT.9 / AUT-83).
3//!
4//! Composition only; no new shaders. Layout works in NDC throughout:
5//!
6//! 1. Construct a [`CaptionBlock`] with `text`, `style`, `max_width_ndc`,
7//!    and `padding_ndc`.
8//! 2. Call [`CaptionBlock::layout`] with the
9//!    [`crate::text::TextTexturePipeline`] to measure the text and
10//!    produce a list of scene nodes:
11//!    - a background `Graphics` rounded rect sized to the wrapped
12//!      text + padding;
13//!    - a `Sprite` carrying the rendered text texture, positioned
14//!      inside the padding rect.
15//! 3. Caller attaches both nodes to a `Container` (or directly to the
16//!    stage); the container's transform handles position.
17//!
18//! Honors `WispTextStyle::align` (the text texture itself respects
19//! the style's alignment) and `line_height` (cosmic-text handles
20//! wrap + line metrics).
21
22use 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/// Builder for a wrapped caption block with a rounded-rect background.
35#[derive(Debug, Clone)]
36pub struct CaptionBlock {
37    /// The caption's content.
38    pub text: WispText,
39    /// Background fill (e.g. semi-transparent black for legibility).
40    pub background_color: Color,
41    /// Background corner radius in **local NDC**.
42    pub corner_radius_ndc: f32,
43    /// Padding inside the background, in local NDC (uniform on all
44    /// four sides).
45    pub padding_ndc: f32,
46    /// Total width the background should occupy in **local NDC**. The
47    /// text wraps to `width_ndc - 2 × padding_ndc`.
48    pub width_ndc: f32,
49    /// Texture resolution used to rasterize the text. Higher = sharper
50    /// glyphs but more GPU memory + cache cost.
51    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
67/// Result of laying out a [`CaptionBlock`].
68///
69/// `background` and `text_sprite` are positioned in **local NDC**:
70/// the background is at `Rect::new(0, 0, width_ndc, height_ndc)` and
71/// the text sprite is inset by `padding_ndc`. Attach both to a
72/// `Container` and use the container's transform to position the
73/// caption in the scene.
74pub struct CaptionLayout {
75    /// Background pill / card.
76    pub background: Graphics,
77    /// Text rendered into a `RenderTexture`, wrapped in a `Sprite`.
78    pub text_sprite: Sprite,
79    /// Backing render-texture (kept alive for the duration of the
80    /// caller's stage). The sprite holds a `Texture` clone from this.
81    pub text_rt: Arc<RenderTexture>,
82    /// Computed caption height in local NDC: `text_height + 2 × padding`.
83    pub height_ndc: f32,
84}
85
86impl CaptionBlock {
87    /// Construct a default block with text only — defaults match the
88    /// in-app caption style (semi-transparent dark background, small
89    /// rounded corners, ample padding).
90    #[must_use]
91    pub fn from_text(text: WispText) -> Self {
92        Self {
93            text,
94            ..Self::default()
95        }
96    }
97
98    /// Builder — replace the background color.
99    #[must_use]
100    pub fn with_background(mut self, color: Color) -> Self {
101        self.background_color = color;
102        self
103    }
104
105    /// Builder — replace the corner radius.
106    #[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    /// Builder — replace the padding.
113    #[must_use]
114    pub fn with_padding(mut self, padding_ndc: f32) -> Self {
115        self.padding_ndc = padding_ndc;
116        self
117    }
118
119    /// Builder — replace the total width.
120    #[must_use]
121    pub fn with_width(mut self, width_ndc: f32) -> Self {
122        self.width_ndc = width_ndc;
123        self
124    }
125
126    /// Builder — replace the text content.
127    #[must_use]
128    pub fn with_text(mut self, text: WispText) -> Self {
129        self.text = text;
130        self
131    }
132
133    /// Builder — replace the style on the inner text.
134    #[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    /// Layout the caption. Measures the text inside `width_ndc -
141    /// 2*padding`, sizes the background, and produces a
142    /// [`CaptionLayout`] ready to be attached to a container.
143    #[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        // Force-wrap the text to the inner width so cosmic-text breaks
148        // lines correctly. If the caller already set `with_wrap`, we
149        // respect their value over ours.
150        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 anchor at top-left, scale spans the inner padded area.
173        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        // y-scale is negative because glyphon writes +y-down; sprite
177        // samples +y-up. Standard text-texture convention.
178        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    /// Helper used by [`CaptionBlock`] to set the anchor mutably without
191    /// needing a re-construction. Public so other compositions can
192    /// reuse it; identical to [`Sprite::with_anchor`] but takes `&mut self`.
193    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        // Expect height ≥ size_ndc + 2*padding = 0.08 + 0.20 = 0.28
247        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        // Caller-set wrap: 0.4 (narrower than the block's 0.7 - 2*0.05 = 0.6 inner).
269        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        // Just smoke-test that the layout produces a non-empty caption.
277        assert!(layout.height_ndc > 0.0);
278    }
279}