Skip to main content

cvkg_render_gpu/api/
text.rs

1use crate::renderer::GpuRenderer;
2use crate::vertex::{InstanceData, Vertex};
3use cvkg_core::Rect;
4use std::sync::Arc;
5
6impl GpuRenderer {
7    /// Inherent method: clear the text shaping cache.
8    pub fn clear_text_cache_impl(&mut self) {
9        self.text.shaped_cache.clear();
10    }
11
12    /// Measure text using the shaped text cache.
13    pub(crate) fn measure_text_impl(&mut self, text: &str, size: f32) -> (f32, f32) {
14        let cache_key = (text.to_string(), (size * 100.0) as u32);
15        if let Some(shaped) = self.text.shaped_cache.get(&cache_key) {
16            return (shaped.width, shaped.height);
17        }
18        let style = cvkg_runic_text::TextStyle::new("Inter", size);
19        let spans = [cvkg_runic_text::TextSpan::new(text, style)];
20        if let Some(shaped) = self.shape_rich_text_impl(
21            &spans,
22            None,
23            cvkg_runic_text::TextAlign::Start,
24            cvkg_runic_text::TextOverflow::Visible,
25        ) {
26            let shaped = std::sync::Arc::new(shaped);
27            let result = (shaped.width, shaped.height);
28            self.text.shaped_cache.put(cache_key, shaped);
29            result
30        } else {
31            (0.0, 0.0)
32        }
33    }
34
35    /// Shape rich text with support for scale factor and fallbacks.
36    pub(crate) fn shape_rich_text_impl(
37        &mut self,
38        spans: &[cvkg_runic_text::TextSpan],
39        max_width: Option<f32>,
40        align: cvkg_runic_text::TextAlign,
41        overflow: cvkg_runic_text::TextOverflow,
42    ) -> Option<cvkg_runic_text::ShapedText> {
43        let sf = self.current_scale_factor();
44        let mut scaled_spans = spans.to_vec();
45        for span in &mut scaled_spans {
46            span.style.font_size *= sf;
47            if span.style.fallback_families.is_empty() {
48                span.style.fallback_families = vec![
49                    "SF Pro".to_string(),
50                    "Inter".to_string(),
51                    "Helvetica Neue".to_string(),
52                    "Helvetica".to_string(),
53                    "Arial".to_string(),
54                    "sans-serif".to_string(),
55                ];
56            }
57        }
58        let scaled_max_width = max_width.map(|w| w * sf);
59        self.text
60            .engine
61            .shape_layout(&scaled_spans, scaled_max_width, align, overflow)
62            .ok()
63    }
64
65    /// Draw shaped text to the renderer buffers.
66    pub(crate) fn draw_shaped_text_impl(
67        &mut self,
68        shaped: &cvkg_runic_text::ShapedText,
69        x: f32,
70        y: f32,
71    ) {
72        for glyph in &shaped.glyphs {
73            let byte_idx = shaped
74                .grapheme_boundaries
75                .get(glyph.cluster as usize)
76                .copied()
77                .unwrap_or(0);
78            let mut span_color = [1.0, 1.0, 1.0, 1.0];
79            for span in &shaped.spans {
80                if byte_idx >= span.byte_offset && byte_idx < span.byte_offset + span.text.len() {
81                    span_color = [
82                        span.style.color[0] as f32 / 255.0,
83                        span.style.color[1] as f32 / 255.0,
84                        span.style.color[2] as f32 / 255.0,
85                        span.style.color[3] as f32 / 255.0,
86                    ];
87                    break;
88                }
89            }
90            let c = self.apply_opacity(span_color);
91
92            let cache_key = glyph.cache_key;
93            let (uv_rect, w, h, x_off, y_off) = if let Some(info) =
94                self.text.glyph_cache.get(&cache_key)
95            {
96                *info
97            } else {
98                if let Some(image) = self.text.engine.rasterize(cache_key) {
99                    let glyph_id = image.glyph_id;
100                    let data_len = image.data.len();
101                    let gw = image.width;
102                    let gh = image.height;
103                    let x_offset = image.x_offset;
104                    let y_offset = image.y_offset;
105                    let (rgba_data, gw, gh) = glyph_image_to_rgba(image);
106                    if gw == 0 || gh == 0 {
107                        let info = (Rect::zero(), 0.0, 0.0, 0.0, 0.0);
108                        self.text.glyph_cache.put(cache_key, info);
109                        continue;
110                    }
111                    if rgba_data.is_empty() {
112                        tracing::warn!(
113                            "Glyph rasterizer returned unsupported pixel format for glyph {} ({} bytes, {}x{}), skipping",
114                            glyph_id,
115                            data_len,
116                            gw,
117                            gh
118                        );
119                        continue;
120                    }
121
122                    let pack_res = self.heim_packer.pack(gw, gh);
123                    let (nx, ny) = if let Some(pos) = pack_res {
124                        pos
125                    } else {
126                        self.reclaim_vram();
127                        match self.heim_packer.pack(gw, gh) {
128                            Some(pos) => pos,
129                            None => {
130                                tracing::error!(
131                                    "Glyph heim critically full after reclaim: cannot pack {}x{} glyph, skipping",
132                                    gw,
133                                    gh
134                                );
135                                continue;
136                            }
137                        }
138                    };
139
140                    self.queue.write_texture(
141                        wgpu::TexelCopyTextureInfo {
142                            texture: &self.mega_heim_tex,
143                            mip_level: 0,
144                            origin: wgpu::Origin3d { x: nx, y: ny, z: 0 },
145                            aspect: wgpu::TextureAspect::All,
146                        },
147                        &rgba_data,
148                        wgpu::TexelCopyBufferLayout {
149                            offset: 0,
150                            bytes_per_row: Some(gw * 4),
151                            rows_per_image: Some(gh),
152                        },
153                        wgpu::Extent3d {
154                            width: gw,
155                            height: gh,
156                            depth_or_array_layers: 1,
157                        },
158                    );
159
160                    let tex_w = self.mega_heim_tex.width() as f32;
161                    let tex_h = self.mega_heim_tex.height() as f32;
162                    let info = (
163                        Rect {
164                            x: nx as f32 / tex_w,
165                            y: ny as f32 / tex_h,
166                            width: gw as f32 / tex_w,
167                            height: gh as f32 / tex_h,
168                        },
169                        gw as f32,
170                        gh as f32,
171                        x_offset,
172                        y_offset,
173                    );
174                    self.text.glyph_cache.put(cache_key, info);
175                    info
176                } else {
177                    (Rect::zero(), 0.0, 0.0, 0.0, 0.0)
178                }
179            };
180
181            if w > 0.0 {
182                let sf = self.current_scale_factor();
183                let glyph_rect = Rect {
184                    x: x + (glyph.x + x_off) / sf,
185                    y: y + (glyph.y - y_off) / sf,
186                    width: w / sf,
187                    height: h / sf,
188                };
189                let tid = self.get_texture_id("__mega_heim");
190                let slice = self
191                    .slice_stack
192                    .last()
193                    .copied()
194                    .map(|(a, o)| [a, o, 1.0, 1.0])
195                    .unwrap_or([0.0, 0.0, 0.0, 1.0]);
196                self.fill_rect_with_full_params_and_slice(
197                    glyph_rect,
198                    c,
199                    6,
200                    tid,
201                    0.0,
202                    uv_rect,
203                    slice,
204                    [glyph.glyph_index as f32, glyph.time_offset],
205                );
206            }
207        }
208    }
209
210    /// Draw text using shaped text cache lookups.
211    pub(crate) fn draw_text_impl(
212        &mut self,
213        text: &str,
214        rect: &cvkg_core::Rect,
215        size: f32,
216        color: [f32; 4],
217        h_align: cvkg_core::TextHAlign,
218        v_align: cvkg_core::TextVAlign,
219    ) {
220        let cache_key = (text.to_string(), (size * 100.0) as u32);
221        let r = (color[0] * 255.0).clamp(0.0, 255.0) as u8;
222        let g = (color[1] * 255.0).clamp(0.0, 255.0) as u8;
223        let b = (color[2] * 255.0).clamp(0.0, 255.0) as u8;
224        let a = (color[3] * 255.0).clamp(0.0, 255.0) as u8;
225        let cached = self.text.shaped_cache.get(&cache_key).cloned();
226        if let Some(shaped) = cached {
227            let color_matches = shaped
228                .spans
229                .first()
230                .map(|s| s.style.color == [r, g, b, a])
231                .unwrap_or(false);
232            if color_matches {
233                self.draw_shaped_text_aligned(&shaped, rect, size, h_align, v_align);
234                return;
235            }
236            let mut shaped = (*shaped).clone();
237            for span in &mut shaped.spans {
238                span.style.color = [r, g, b, a];
239            }
240            self.draw_shaped_text_aligned(&shaped, rect, size, h_align, v_align);
241            return;
242        }
243        let mut style = cvkg_runic_text::TextStyle::new("Inter", size);
244        style.color = [r, g, b, a];
245        let spans = [cvkg_runic_text::TextSpan::new(text, style)];
246        let h_text_align = match h_align {
247            cvkg_core::TextHAlign::Left => cvkg_runic_text::TextAlign::Start,
248            cvkg_core::TextHAlign::Center => cvkg_runic_text::TextAlign::Center,
249            cvkg_core::TextHAlign::Right => cvkg_runic_text::TextAlign::End,
250        };
251        if let Some(shaped) = self.shape_rich_text_impl(
252            &spans,
253            None,
254            h_text_align,
255            cvkg_runic_text::TextOverflow::Visible,
256        ) {
257            let shaped = std::sync::Arc::new(shaped);
258            self.draw_shaped_text_aligned(&shaped, rect, size, h_align, v_align);
259            self.text.shaped_cache.put(cache_key, shaped);
260        }
261    }
262
263    /// Compute baseline x,y from rect + alignment, then draw.
264    fn draw_shaped_text_aligned(
265        &mut self,
266        shaped: &cvkg_runic_text::ShapedText,
267        rect: &cvkg_core::Rect,
268        size: f32,
269        h_align: cvkg_core::TextHAlign,
270        v_align: cvkg_core::TextVAlign,
271    ) {
272        // Calculate text width from glyph advances
273        let text_w: f32 = shaped.glyphs.iter().map(|g| g.advance_width).sum();
274
275        // X position based on horizontal alignment
276        let x = match h_align {
277            cvkg_core::TextHAlign::Left => rect.x,
278            cvkg_core::TextHAlign::Center => rect.x + (rect.width - text_w) / 2.0,
279            cvkg_core::TextHAlign::Right => rect.x + rect.width - text_w,
280        };
281
282        // Y position: baseline offset based on vertical alignment
283        // ascent ≈ size * 0.8 for most fonts
284        let ascent = size * 0.8;
285        let y = match v_align {
286            cvkg_core::TextVAlign::Top => rect.y + ascent,
287            cvkg_core::TextVAlign::Middle => rect.y + (rect.height + ascent * 0.75) / 2.0,
288            cvkg_core::TextVAlign::Bottom => rect.y + rect.height - (size - ascent),
289        };
290
291        self.draw_shaped_text_impl(shaped, x, y);
292    }
293}
294
295fn glyph_image_to_rgba(image: cvkg_runic_text::GlyphImage) -> (Vec<u8>, u32, u32) {
296    let width = image.width;
297    let height = image.height;
298    let pixels = width.saturating_mul(height) as usize;
299
300    if pixels == 0 || image.data.is_empty() {
301        return (Vec::new(), width, height);
302    }
303
304    let (bytes_per_pixel, remainder) = (image.data.len() / pixels, image.data.len() % pixels);
305    if remainder != 0 {
306        tracing::warn!(
307            "Glyph rasterizer returned {} bytes for {}x{} glyph; expected whole pixels ({} bytes per pixel)",
308            image.data.len(),
309            width,
310            height,
311            bytes_per_pixel
312        );
313        return (Vec::new(), width, height);
314    }
315
316    let rgba_data = match bytes_per_pixel {
317        1 => {
318            let mut data = Vec::with_capacity(pixels * 4);
319            for alpha in &image.data {
320                data.push(255);
321                data.push(255);
322                data.push(255);
323                data.push(*alpha);
324            }
325            data
326        }
327        3 => {
328            let mut data = Vec::with_capacity(pixels * 4);
329            for rgb in image.data.chunks_exact(3) {
330                let alpha = rgb.iter().copied().max().unwrap_or(0);
331                data.push(255);
332                data.push(255);
333                data.push(255);
334                data.push(alpha);
335            }
336            data
337        }
338        4 => {
339            let mut data = image.data;
340            for chunk in data.chunks_exact_mut(4) {
341                if chunk[3] == 0 && (chunk[0] > 0 || chunk[1] > 0 || chunk[2] > 0) {
342                    chunk[3] = chunk[0].max(chunk[1]).max(chunk[2]);
343                }
344            }
345            data
346        }
347        _ => {
348            tracing::warn!(
349                "Glyph rasterizer returned unsupported {} bytes per pixel for {}x{} glyph ({} bytes total)",
350                bytes_per_pixel,
351                width,
352                height,
353                image.data.len()
354            );
355            Vec::new()
356        }
357    };
358
359    (rgba_data, width, height)
360}
361
362#[cfg(test)]
363mod tests {
364    use super::glyph_image_to_rgba;
365
366    #[test]
367    fn glyph_image_to_rgba_keeps_rgba_color_data() {
368        let image = cvkg_runic_text::GlyphImage {
369            glyph_id: 1,
370            width: 2,
371            height: 1,
372            data: vec![1, 2, 3, 4, 5, 6, 7, 8],
373            x_offset: 0.0,
374            y_offset: 0.0,
375            cache_key: 42,
376        };
377
378        assert_eq!(
379            glyph_image_to_rgba(image),
380            (vec![1, 2, 3, 4, 5, 6, 7, 8], 2, 1)
381        );
382    }
383
384    #[test]
385    fn glyph_image_to_rgba_expands_grayscale_alpha() {
386        let image = cvkg_runic_text::GlyphImage {
387            glyph_id: 1,
388            width: 3,
389            height: 1,
390            data: vec![0, 128, 255],
391            x_offset: 0.0,
392            y_offset: 0.0,
393            cache_key: 42,
394        };
395
396        assert_eq!(
397            glyph_image_to_rgba(image),
398            (
399                vec![255, 255, 255, 0, 255, 255, 255, 128, 255, 255, 255, 255],
400                3,
401                1
402            )
403        );
404    }
405
406    #[test]
407    fn glyph_image_to_rgba_collapses_subpixel_rgb_to_alpha() {
408        let image = cvkg_runic_text::GlyphImage {
409            glyph_id: 1,
410            width: 2,
411            height: 1,
412            data: vec![0, 128, 255, 255, 0, 64],
413            x_offset: 0.0,
414            y_offset: 0.0,
415            cache_key: 42,
416        };
417
418        assert_eq!(
419            glyph_image_to_rgba(image),
420            (vec![255, 255, 255, 255, 255, 255, 255, 255], 2, 1)
421        );
422    }
423}