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.engine
60            .shape_layout(&scaled_spans, scaled_max_width, align, overflow)
61            .ok()
62    }
63
64    /// Draw shaped text to the renderer buffers.
65    pub(crate) fn draw_shaped_text_impl(&mut self, shaped: &cvkg_runic_text::ShapedText, x: f32, y: f32) {
66        for glyph in &shaped.glyphs {
67            let byte_idx = shaped
68                .grapheme_boundaries
69                .get(glyph.cluster as usize)
70                .copied()
71                .unwrap_or(0);
72            let mut span_color = [1.0, 1.0, 1.0, 1.0];
73            for span in &shaped.spans {
74                if byte_idx >= span.byte_offset && byte_idx < span.byte_offset + span.text.len() {
75                    span_color = [
76                        span.style.color[0] as f32 / 255.0,
77                        span.style.color[1] as f32 / 255.0,
78                        span.style.color[2] as f32 / 255.0,
79                        span.style.color[3] as f32 / 255.0,
80                    ];
81                    break;
82                }
83            }
84            let c = self.apply_opacity(span_color);
85
86            let cache_key = glyph.cache_key;
87            let (uv_rect, w, h, x_off, y_off) = if let Some(info) = self.text.glyph_cache.get(&cache_key)
88            {
89                *info
90            } else {
91                if let Some(image) = self.text.engine.rasterize(cache_key) {
92                    let glyph_id = image.glyph_id;
93                    let data_len = image.data.len();
94                    let gw = image.width;
95                    let gh = image.height;
96                    let x_offset = image.x_offset;
97                    let y_offset = image.y_offset;
98                    let (rgba_data, gw, gh) = glyph_image_to_rgba(image);
99                    if gw == 0 || gh == 0 {
100                        let info = (Rect::zero(), 0.0, 0.0, 0.0, 0.0);
101                        self.text.glyph_cache.put(cache_key, info);
102                        continue;
103                    }
104                    if rgba_data.is_empty() {
105                        log::warn!(
106                            "Glyph rasterizer returned unsupported pixel format for glyph {} ({} bytes, {}x{}), skipping",
107                            glyph_id,
108                            data_len,
109                            gw,
110                            gh
111                        );
112                        continue;
113                    }
114
115                    let pack_res = self.heim_packer.pack(gw, gh);
116                    let (nx, ny) = if let Some(pos) = pack_res {
117                        pos
118                    } else {
119                        self.reclaim_vram();
120                        match self.heim_packer.pack(gw, gh) {
121                            Some(pos) => pos,
122                            None => {
123                                log::error!(
124                                    "Glyph heim critically full after reclaim: cannot pack {}x{} glyph, skipping",
125                                    gw,
126                                    gh
127                                );
128                                continue;
129                            }
130                        }
131                    };
132                    
133                    self.queue.write_texture(
134                        wgpu::TexelCopyTextureInfo {
135                            texture: &self.mega_heim_tex,
136                            mip_level: 0,
137                            origin: wgpu::Origin3d { x: nx, y: ny, z: 0 },
138                            aspect: wgpu::TextureAspect::All,
139                        },
140                        &rgba_data,
141                        wgpu::TexelCopyBufferLayout {
142                            offset: 0,
143                            bytes_per_row: Some(gw * 4),
144                            rows_per_image: Some(gh),
145                        },
146                        wgpu::Extent3d {
147                            width: gw,
148                            height: gh,
149                            depth_or_array_layers: 1,
150                        },
151                    );
152
153                    let tex_w = self.mega_heim_tex.width() as f32;
154                    let tex_h = self.mega_heim_tex.height() as f32;
155                    let info = (
156                        Rect {
157                            x: nx as f32 / tex_w,
158                            y: ny as f32 / tex_h,
159                            width: gw as f32 / tex_w,
160                            height: gh as f32 / tex_h,
161                        },
162                        gw as f32,
163                        gh as f32,
164                        x_offset,
165                        y_offset,
166                    );
167                    self.text.glyph_cache.put(cache_key, info);
168                    info
169                } else {
170                    (Rect::zero(), 0.0, 0.0, 0.0, 0.0)
171                }
172            };
173
174            if w > 0.0 {
175                let sf = self.current_scale_factor();
176                let baseline_y = y + shaped.ascent / sf;
177                let glyph_rect = Rect {
178                    x: x + (glyph.x + x_off) / sf,
179                    y: baseline_y + (glyph.y - y_off) / sf,
180                    width: w / sf,
181                    height: h / sf,
182                };
183                let tid = self.get_texture_id("__mega_heim");
184                let slice = self
185                    .slice_stack
186                    .last()
187                    .copied()
188                    .map(|(a, o)| [a, o, 1.0, 1.0])
189                    .unwrap_or([0.0, 0.0, 0.0, 1.0]);
190                self.fill_rect_with_full_params_and_slice(
191                    glyph_rect,
192                    c,
193                    6,
194                    tid,
195                    0.0,
196                    uv_rect,
197                    slice,
198                    [glyph.glyph_index as f32, glyph.time_offset],
199                );
200            }
201        }
202    }
203
204    /// Draw text using shaped text cache lookups.
205    pub(crate) fn draw_text_impl(&mut self, text: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
206        let cache_key = (text.to_string(), (size * 100.0) as u32);
207        let r = (color[0] * 255.0).clamp(0.0, 255.0) as u8;
208        let g = (color[1] * 255.0).clamp(0.0, 255.0) as u8;
209        let b = (color[2] * 255.0).clamp(0.0, 255.0) as u8;
210        let a = (color[3] * 255.0).clamp(0.0, 255.0) as u8;
211        let cached = self.text.shaped_cache.get(&cache_key).cloned();
212        if let Some(shaped) = cached {
213            let color_matches = shaped.spans.first()
214                .map(|s| s.style.color == [r, g, b, a])
215                .unwrap_or(false);
216            if color_matches {
217                self.draw_shaped_text_impl(&shaped, x, y);
218                return;
219            }
220            let mut shaped = (*shaped).clone();
221            for span in &mut shaped.spans {
222                span.style.color = [r, g, b, a];
223            }
224            self.draw_shaped_text_impl(&shaped, x, y);
225            return;
226        }
227        let mut style = cvkg_runic_text::TextStyle::new("Inter", size);
228        style.color = [r, g, b, a];
229        let spans = [cvkg_runic_text::TextSpan::new(text, style)];
230        if let Some(shaped) = self.shape_rich_text_impl(
231            &spans,
232            None,
233            cvkg_runic_text::TextAlign::Start,
234            cvkg_runic_text::TextOverflow::Visible,
235        ) {
236            let shaped = std::sync::Arc::new(shaped);
237            self.draw_shaped_text_impl(&shaped, x, y);
238            self.text.shaped_cache.put(cache_key, shaped);
239        }
240    }
241}
242
243fn glyph_image_to_rgba(image: cvkg_runic_text::GlyphImage) -> (Vec<u8>, u32, u32) {
244    let width = image.width;
245    let height = image.height;
246    let pixels = width.saturating_mul(height) as usize;
247
248    if pixels == 0 || image.data.is_empty() {
249        return (Vec::new(), width, height);
250    }
251
252    let (bytes_per_pixel, remainder) = (image.data.len() / pixels, image.data.len() % pixels);
253    if remainder != 0 {
254        log::warn!(
255            "Glyph rasterizer returned {} bytes for {}x{} glyph; expected whole pixels ({} bytes per pixel)",
256            image.data.len(),
257            width,
258            height,
259            bytes_per_pixel
260        );
261        return (Vec::new(), width, height);
262    }
263
264    let rgba_data = match bytes_per_pixel {
265        1 => {
266            let mut data = Vec::with_capacity(pixels * 4);
267            for alpha in &image.data {
268                data.push(255);
269                data.push(255);
270                data.push(255);
271                data.push(*alpha);
272            }
273            data
274        }
275        3 => {
276            let mut data = Vec::with_capacity(pixels * 4);
277            for rgb in image.data.chunks_exact(3) {
278                let alpha = rgb.iter().copied().max().unwrap_or(0);
279                data.push(255);
280                data.push(255);
281                data.push(255);
282                data.push(alpha);
283            }
284            data
285        }
286        4 => {
287            let mut data = image.data;
288            for chunk in data.chunks_exact_mut(4) {
289                if chunk[3] == 0 && (chunk[0] > 0 || chunk[1] > 0 || chunk[2] > 0) {
290                    chunk[3] = chunk[0].max(chunk[1]).max(chunk[2]);
291                }
292            }
293            data
294        }
295        _ => {
296            log::warn!(
297                "Glyph rasterizer returned unsupported {} bytes per pixel for {}x{} glyph ({} bytes total)",
298                bytes_per_pixel,
299                width,
300                height,
301                image.data.len()
302            );
303            Vec::new()
304        }
305    };
306
307    (rgba_data, width, height)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::glyph_image_to_rgba;
313
314    #[test]
315    fn glyph_image_to_rgba_keeps_rgba_color_data() {
316        let image = cvkg_runic_text::GlyphImage {
317            glyph_id: 1,
318            width: 2,
319            height: 1,
320            data: vec![1, 2, 3, 4, 5, 6, 7, 8],
321            x_offset: 0.0,
322            y_offset: 0.0,
323            cache_key: 42,
324        };
325
326        assert_eq!(
327            glyph_image_to_rgba(image),
328            (vec![1, 2, 3, 4, 5, 6, 7, 8], 2, 1)
329        );
330    }
331
332    #[test]
333    fn glyph_image_to_rgba_expands_grayscale_alpha() {
334        let image = cvkg_runic_text::GlyphImage {
335            glyph_id: 1,
336            width: 3,
337            height: 1,
338            data: vec![0, 128, 255],
339            x_offset: 0.0,
340            y_offset: 0.0,
341            cache_key: 42,
342        };
343
344        assert_eq!(
345            glyph_image_to_rgba(image),
346            (
347                vec![255, 255, 255, 0, 255, 255, 255, 128, 255, 255, 255, 255],
348                3,
349                1
350            )
351        );
352    }
353
354    #[test]
355    fn glyph_image_to_rgba_collapses_subpixel_rgb_to_alpha() {
356        let image = cvkg_runic_text::GlyphImage {
357            glyph_id: 1,
358            width: 2,
359            height: 1,
360            data: vec![0, 128, 255, 255, 0, 64],
361            x_offset: 0.0,
362            y_offset: 0.0,
363            cache_key: 42,
364        };
365
366        assert_eq!(
367            glyph_image_to_rgba(image),
368            (vec![255, 255, 255, 255, 255, 255, 255, 255], 2, 1)
369        );
370    }
371}