Skip to main content

astrelis_text/renderer/
hybrid.rs

1//! Hybrid text renderer (backwards-compatible default).
2//!
3//! This module provides [`FontRenderer`], the hybrid text renderer that combines
4//! both bitmap and SDF backends (~16 MB with default atlas size).
5//!
6//! This is the **default and backwards-compatible** renderer that automatically
7//! selects the best rendering mode based on text size and effects.
8//!
9//! # When to Use
10//!
11//! Use `FontRenderer` (the default) when:
12//! - You need both small UI text and large display text
13//! - You want automatic mode selection for optimal quality
14//! - Backwards compatibility with existing code is important
15//!
16//! # Memory Usage
17//!
18//! | Config | Atlas Size | GPU Memory | CPU Memory | Total |
19//! |--------|------------|------------|------------|-------|
20//! | small() | 512x512 | ~0.5 MB | ~0.5 MB | ~1 MB |
21//! | medium() | 1024x1024 | ~2 MB | ~2 MB | ~4 MB |
22//! | large() | 2048x2048 | ~8 MB | ~8 MB | ~16 MB |
23//!
24//! # Example
25//!
26//! ```ignore
27//! use astrelis_text::{FontRenderer, Text, FontSystem, Color};
28//! use astrelis_core::math::Vec2;
29//!
30//! let font_system = FontSystem::with_system_fonts();
31//! let mut renderer = FontRenderer::new(context, font_system);
32//!
33//! // Small text -> automatically uses bitmap
34//! let small_text = Text::new("UI Label").size(14.0);
35//! let mut small_buffer = renderer.prepare(&small_text);
36//! renderer.draw_text(&mut small_buffer, Vec2::new(10.0, 10.0));
37//!
38//! // Large text -> automatically uses SDF
39//! let large_text = Text::new("Title").size(48.0);
40//! let mut large_buffer = renderer.prepare(&large_text);
41//! renderer.draw_text(&mut large_buffer, Vec2::new(100.0, 100.0));
42//!
43//! // Effects always use SDF
44//! renderer.draw_text_with_effects(&mut large_buffer, position, &effects);
45//!
46//! renderer.render(&mut render_pass);
47//! ```
48
49use std::sync::Arc;
50
51use astrelis_core::math::Vec2;
52use astrelis_core::profiling::profile_function;
53use cosmic_text::{CacheKey, Color as CosmicColor, Metrics};
54
55use astrelis_render::{GraphicsContext, Viewport, wgpu};
56
57use crate::effects::TextEffects;
58use crate::font::FontSystem;
59use crate::sdf::{SdfConfig, TextRenderMode};
60use crate::text::{Text, TextMetrics};
61
62use crate::decoration::TextBounds;
63
64use super::bitmap::BitmapBackend;
65use super::sdf::SdfBackend;
66use super::shared::{
67    AtlasEntry, DecorationRenderer, GlyphPlacement, SdfParams, SharedContext, TextBuffer,
68    TextRender, TextRendererConfig, TextVertex,
69};
70use super::{SDF_DEFAULT_SPREAD, orthographic_projection};
71
72/// Helper macro to handle RwLock write poisoning gracefully.
73macro_rules! lock_or_recover {
74    ($lock:expr, $error_msg:expr, $default:expr) => {
75        match $lock.write() {
76            Ok(guard) => guard,
77            Err(e) => {
78                tracing::error!("{}: {}. Attempting recovery.", $error_msg, e);
79                $lock.write().unwrap_or_else(|poisoned| {
80                    tracing::warn!("Clearing poisoned lock");
81                    poisoned.into_inner()
82                })
83            }
84        }
85    };
86    ($lock:expr, $error_msg:expr) => {
87        match $lock.write() {
88            Ok(guard) => guard,
89            Err(e) => {
90                tracing::error!("{}: {}. Returning default.", $error_msg, e);
91                return Default::default();
92            }
93        }
94    };
95}
96
97/// Font renderer for rendering text with WGPU.
98///
99/// This is the **hybrid renderer** that combines both bitmap and SDF backends,
100/// automatically selecting the best mode based on text size and effects.
101///
102/// - Small text (< 24px) without effects: uses bitmap for sharpness
103/// - Large text (>= 24px) or text with effects: uses SDF for quality
104///
105/// This is the backwards-compatible default renderer.
106pub struct FontRenderer {
107    shared: SharedContext,
108    bitmap: BitmapBackend,
109    sdf: SdfBackend,
110    decoration: DecorationRenderer,
111
112    // Render mode configuration
113    render_mode: TextRenderMode,
114
115    // Staging data
116    vertices: Vec<TextVertex>,
117    indices: Vec<u16>,
118}
119
120impl FontRenderer {
121    /// Create a new font renderer.
122    pub fn new(context: Arc<GraphicsContext>, font_system: FontSystem) -> Self {
123        Self::new_with_atlas_size(context, font_system, 2048)
124    }
125
126    /// Create a new font renderer with a custom atlas size.
127    pub fn new_with_atlas_size(
128        context: Arc<GraphicsContext>,
129        font_system: FontSystem,
130        atlas_size: u32,
131    ) -> Self {
132        Self::with_config(
133            context,
134            font_system,
135            TextRendererConfig {
136                atlas_size,
137                ..Default::default()
138            },
139        )
140    }
141
142    /// Create a new font renderer with custom configuration.
143    pub fn with_config(
144        context: Arc<GraphicsContext>,
145        font_system: FontSystem,
146        config: TextRendererConfig,
147    ) -> Self {
148        let shared = SharedContext::new(context, font_system.inner());
149        let bitmap = BitmapBackend::new(&shared, config.atlas_size);
150        let sdf = SdfBackend::new(&shared, config.atlas_size, config.sdf);
151        let decoration =
152            DecorationRenderer::new(&shared.renderer, &shared.uniform_bind_group_layout);
153
154        Self {
155            shared,
156            bitmap,
157            sdf,
158            decoration,
159            render_mode: TextRenderMode::default(),
160            vertices: Vec::new(),
161            indices: Vec::new(),
162        }
163    }
164
165    /// Measure text dimensions without rendering.
166    pub fn measure_text(&self, text: &Text) -> (f32, f32) {
167        profile_function!();
168        let scale = self.shared.scale_factor();
169        let mut font_system = lock_or_recover!(
170            self.shared.font_system,
171            "Font system lock poisoned during measure"
172        );
173        let mut buffer = TextBuffer::new(&mut font_system);
174        buffer.set_text(&mut font_system, text, scale);
175        buffer.layout(&mut font_system);
176        let (width, height) = buffer.bounds();
177        (width / scale, height / scale)
178    }
179
180    /// Get the logical (unscaled) bounds of a prepared text buffer.
181    pub fn buffer_bounds(&self, buffer: &TextBuffer) -> (f32, f32) {
182        let scale = self.shared.scale_factor();
183        let (width, height) = buffer.bounds();
184        (width / scale, height / scale)
185    }
186
187    /// Get font metrics for the given text style.
188    pub fn get_text_metrics(&self, text: &Text) -> TextMetrics {
189        profile_function!();
190        let scale = self.shared.scale_factor();
191        let font_size = text.get_font_size();
192        let line_height_multiplier = text.get_line_height();
193
194        let metrics = Metrics::new(
195            font_size * scale,
196            font_size * scale * line_height_multiplier,
197        );
198
199        let line_height = metrics.line_height / scale;
200        let ascent = font_size * 0.8;
201        let descent = font_size * 0.2;
202
203        TextMetrics {
204            ascent,
205            descent,
206            line_height,
207            baseline_offset: ascent,
208        }
209    }
210
211    /// Get the baseline offset from the top of the text bounding box.
212    pub fn get_baseline_offset(&self, text: &Text) -> f32 {
213        let metrics = self.get_text_metrics(text);
214        metrics.baseline_offset
215    }
216
217    /// Set the text render mode (Bitmap or SDF).
218    pub fn set_render_mode(&mut self, mode: TextRenderMode) {
219        self.render_mode = mode;
220    }
221
222    /// Get the current render mode.
223    pub fn render_mode(&self) -> TextRenderMode {
224        self.render_mode
225    }
226
227    /// Set SDF configuration.
228    pub fn set_sdf_config(&mut self, config: SdfConfig) {
229        if config.mode.is_sdf() {
230            self.render_mode = config.mode;
231        }
232        self.sdf.config = config;
233    }
234
235    /// Get the current SDF configuration.
236    pub fn sdf_config(&self) -> &SdfConfig {
237        &self.sdf.config
238    }
239
240    /// Determine the appropriate render mode based on font size and effects.
241    ///
242    /// - Small text (< 24px) without effects: use Bitmap for sharpness
243    /// - Large text (>= 24px) or text with effects: use SDF for quality
244    pub fn select_render_mode(font_size: f32, has_effects: bool) -> TextRenderMode {
245        if has_effects {
246            return TextRenderMode::SDF {
247                spread: SDF_DEFAULT_SPREAD,
248            };
249        }
250        if font_size >= 24.0 {
251            return TextRenderMode::SDF {
252                spread: SDF_DEFAULT_SPREAD,
253            };
254        }
255        TextRenderMode::Bitmap
256    }
257
258    /// Set the viewport for rendering.
259    pub fn set_viewport(&mut self, viewport: Viewport) {
260        if viewport.scale_factor != self.shared.viewport.scale_factor {
261            tracing::trace!(
262                "FontRenderer scale factor changed: {:?} -> {:?}",
263                self.shared.viewport.scale_factor,
264                viewport.scale_factor
265            );
266            // Clear bitmap atlas on scale factor change
267            self.bitmap.clear();
268            // Note: SDF atlas doesn't need to be cleared (resolution-independent)
269        }
270        self.shared.set_viewport(viewport);
271    }
272
273    /// Prepare text for rendering.
274    pub fn prepare(&mut self, text: &Text) -> TextBuffer {
275        profile_function!();
276        let mut font_system = lock_or_recover!(
277            self.shared.font_system,
278            "Font system lock poisoned during prepare",
279            TextBuffer::default()
280        );
281        let mut buffer = TextBuffer::new(&mut font_system);
282        buffer.set_text(&mut font_system, text, self.shared.scale_factor());
283        buffer.layout(&mut font_system);
284        buffer
285    }
286
287    /// Draw text at a position.
288    ///
289    /// The position represents the **top-left corner** of the text's bounding box.
290    pub fn draw_text(&mut self, buffer: &mut TextBuffer, position: Vec2) {
291        profile_function!();
292
293        if self.render_mode.is_sdf() {
294            // Use default params for SDF without effects
295            let params = SdfParams::default();
296            self.sdf.update_params(&self.shared, &params);
297            self.draw_text_sdf_internal(buffer, position);
298        } else {
299            self.draw_text_bitmap_internal(buffer, position);
300        }
301    }
302
303    /// Draw text with effects at a position using SDF rendering.
304    pub fn draw_text_with_effects(
305        &mut self,
306        buffer: &mut TextBuffer,
307        position: Vec2,
308        effects: &TextEffects,
309    ) {
310        profile_function!();
311
312        // Always use SDF mode when effects are present
313        if effects.has_enabled_effects() && !self.render_mode.is_sdf() {
314            self.render_mode = TextRenderMode::SDF {
315                spread: SDF_DEFAULT_SPREAD,
316            };
317        }
318
319        // Update SDF params from effects
320        let sdf_params = SdfParams::from_effects(effects, &self.sdf.config);
321        self.sdf.update_params(&self.shared, &sdf_params);
322
323        // Use SDF drawing path
324        self.draw_text_sdf_internal(buffer, position);
325    }
326
327    /// Draw text with decoration (underline, strikethrough, background).
328    ///
329    /// This method handles both the text rendering and any decorations.
330    /// Decorations are rendered in the correct order:
331    /// - Background: behind text
332    /// - Text glyphs
333    /// - Underline/strikethrough: on top of text
334    ///
335    /// # Arguments
336    ///
337    /// * `buffer` - The prepared text buffer
338    /// * `position` - Top-left position of the text
339    /// * `text` - The Text object containing decoration configuration
340    pub fn draw_text_with_decoration(
341        &mut self,
342        buffer: &mut TextBuffer,
343        position: Vec2,
344        text: &Text,
345    ) {
346        profile_function!();
347
348        // Queue decoration if present
349        if let Some(decoration) = text.get_decoration() {
350            // Get text bounds from buffer
351            let (width, height) = self.buffer_bounds(buffer);
352            let metrics = self.get_text_metrics(text);
353
354            let bounds = TextBounds::new(
355                position.x,
356                position.y,
357                width,
358                height,
359                metrics.baseline_offset,
360            );
361
362            self.decoration
363                .queue_from_text(&bounds, decoration, self.shared.scale_factor());
364        }
365
366        // Draw the text
367        self.draw_text(buffer, position);
368    }
369
370    /// Internal bitmap text drawing implementation.
371    fn draw_text_bitmap_internal(&mut self, buffer: &mut TextBuffer, position: Vec2) {
372        profile_function!();
373
374        let scale = self.shared.scale_factor();
375        let mut font_system = lock_or_recover!(
376            self.shared.font_system,
377            "Font system lock poisoned in draw_text_bitmap_internal"
378        );
379        buffer.layout(&mut font_system);
380        drop(font_system);
381
382        // Render glyphs
383        for run in buffer.buffer.layout_runs() {
384            for glyph in run.glyphs.iter() {
385                let physical_glyph =
386                    glyph.physical((position.x * scale, position.y * scale + run.line_y), 1.0);
387                let cache_key = physical_glyph.cache_key;
388
389                // Ensure glyph is in atlas
390                let entry = match self.bitmap.ensure_glyph(&self.shared, cache_key) {
391                    Some(e) => e.clone(),
392                    None => continue,
393                };
394
395                // Get glyph placement info
396                let mut font_system = lock_or_recover!(
397                    self.shared.font_system,
398                    "Font system lock poisoned in draw_text_bitmap_internal (glyph loop)"
399                );
400                let mut swash_cache = lock_or_recover!(
401                    self.shared.swash_cache,
402                    "Swash cache lock poisoned in draw_text_bitmap_internal (glyph loop)"
403                );
404
405                if let Some(image) = swash_cache.get_image(&mut font_system, cache_key) {
406                    let x = physical_glyph.x as f32 + image.placement.left as f32;
407                    let y = physical_glyph.y as f32 - image.placement.top as f32;
408                    let w = image.placement.width as f32;
409                    let h = image.placement.height as f32;
410
411                    let x = x / scale;
412                    let y = y / scale;
413                    let w = w / scale;
414                    let h = h / scale;
415
416                    drop(font_system);
417                    drop(swash_cache);
418
419                    let (u0, v0, u1, v1) = entry.uv_coords(self.bitmap.atlas.width());
420
421                    let color = glyph.color_opt.unwrap_or(CosmicColor::rgb(255, 255, 255));
422                    let color_f = [
423                        color.r() as f32 / 255.0,
424                        color.g() as f32 / 255.0,
425                        color.b() as f32 / 255.0,
426                        color.a() as f32 / 255.0,
427                    ];
428
429                    // Pixel snapping for crisp rendering
430                    let x = (x * scale).round() / scale;
431                    let y = (y * scale).round() / scale;
432
433                    // Create quad
434                    let idx = self.vertices.len() as u16;
435
436                    self.vertices.push(TextVertex {
437                        position: [x, y],
438                        tex_coords: [u0, v0],
439                        color: color_f,
440                    });
441                    self.vertices.push(TextVertex {
442                        position: [x + w, y],
443                        tex_coords: [u1, v0],
444                        color: color_f,
445                    });
446                    self.vertices.push(TextVertex {
447                        position: [x + w, y + h],
448                        tex_coords: [u1, v1],
449                        color: color_f,
450                    });
451                    self.vertices.push(TextVertex {
452                        position: [x, y + h],
453                        tex_coords: [u0, v1],
454                        color: color_f,
455                    });
456
457                    self.indices
458                        .extend_from_slice(&[idx, idx + 1, idx + 2, idx, idx + 2, idx + 3]);
459                }
460            }
461        }
462    }
463
464    /// Internal SDF text drawing implementation.
465    fn draw_text_sdf_internal(&mut self, buffer: &mut TextBuffer, position: Vec2) {
466        profile_function!();
467
468        let scale = self.shared.scale_factor();
469        let mut font_system = lock_or_recover!(
470            self.shared.font_system,
471            "Font system lock poisoned in draw_text_sdf_internal"
472        );
473        buffer.layout(&mut font_system);
474        drop(font_system);
475
476        // Render glyphs using SDF atlas
477        for run in buffer.buffer.layout_runs() {
478            for glyph in run.glyphs.iter() {
479                let physical_glyph = glyph.physical((position.x, position.y + run.line_y), 1.0);
480                let cache_key = physical_glyph.cache_key;
481
482                // Ensure glyph is in SDF atlas
483                let sdf_entry = match self.sdf.ensure_glyph(&self.shared, cache_key) {
484                    Some(e) => e.clone(),
485                    None => continue,
486                };
487
488                // Calculate scale factor from base size to target size
489                let target_size = f32::from_bits(cache_key.font_size_bits);
490                let size_scale = target_size / sdf_entry.base_size;
491
492                // Scale placement based on size ratio
493                let scaled_left = sdf_entry.base_placement.left * size_scale;
494                let scaled_top = sdf_entry.base_placement.top * size_scale;
495                let scaled_width = sdf_entry.base_placement.width * size_scale;
496                let scaled_height = sdf_entry.base_placement.height * size_scale;
497
498                let x = physical_glyph.x as f32 + scaled_left;
499                let y = physical_glyph.y as f32 - scaled_top;
500                let w = scaled_width;
501                let h = scaled_height;
502
503                let x = x / scale;
504                let y = y / scale;
505                let w = w / scale;
506                let h = h / scale;
507
508                let (u0, v0, u1, v1) = sdf_entry.entry.uv_coords(self.sdf.atlas.width());
509
510                let color = glyph.color_opt.unwrap_or(CosmicColor::rgb(255, 255, 255));
511                let color_f = [
512                    color.r() as f32 / 255.0,
513                    color.g() as f32 / 255.0,
514                    color.b() as f32 / 255.0,
515                    color.a() as f32 / 255.0,
516                ];
517
518                // Pixel snapping for crisp rendering
519                let x = (x * scale).round() / scale;
520                let y = (y * scale).round() / scale;
521
522                // Create quad
523                let idx = self.vertices.len() as u16;
524
525                self.vertices.push(TextVertex {
526                    position: [x, y],
527                    tex_coords: [u0, v0],
528                    color: color_f,
529                });
530                self.vertices.push(TextVertex {
531                    position: [x + w, y],
532                    tex_coords: [u1, v0],
533                    color: color_f,
534                });
535                self.vertices.push(TextVertex {
536                    position: [x + w, y + h],
537                    tex_coords: [u1, v1],
538                    color: color_f,
539                });
540                self.vertices.push(TextVertex {
541                    position: [x, y + h],
542                    tex_coords: [u0, v1],
543                    color: color_f,
544                });
545
546                self.indices
547                    .extend_from_slice(&[idx, idx + 1, idx + 2, idx, idx + 2, idx + 3]);
548            }
549        }
550    }
551
552    /// Render all queued text to the given render pass.
553    ///
554    /// Automatically selects bitmap or SDF pipeline based on the current render mode.
555    /// Renders in the correct order:
556    /// 1. Background decorations (behind text)
557    /// 2. Text glyphs
558    /// 3. Line decorations (underline, strikethrough - on top of text)
559    pub fn render(&mut self, render_pass: &mut wgpu::RenderPass) {
560        profile_function!();
561
562        debug_assert!(
563            self.shared.viewport.is_valid(),
564            "Viewport size must be set before rendering text."
565        );
566
567        // 1. Render background decorations first (behind text)
568        self.decoration.render_backgrounds(
569            render_pass,
570            &self.shared.renderer,
571            &self.shared.viewport,
572        );
573
574        // 2. Render text glyphs
575        if !self.vertices.is_empty() {
576            // Upload appropriate atlas based on render mode
577            if self.render_mode.is_sdf() {
578                self.sdf.upload_atlas(&self.shared);
579            } else {
580                self.bitmap.upload_atlas(&self.shared);
581            }
582
583            // Create buffers
584            let vertex_buffer = self
585                .shared
586                .renderer
587                .create_vertex_buffer(Some("Text Vertex Buffer"), &self.vertices);
588
589            let index_buffer = self
590                .shared
591                .renderer
592                .create_index_buffer(Some("Text Index Buffer"), &self.indices);
593
594            // Create projection uniform
595            let size = self.shared.viewport.to_logical();
596            let projection = orthographic_projection(size.width, size.height);
597            let uniform_buffer = self
598                .shared
599                .renderer
600                .create_uniform_buffer(Some("Text Projection"), &projection);
601
602            // Create uniform bind group
603            let uniform_bind_group = self.shared.renderer.create_bind_group(
604                Some("Text Uniform Bind Group"),
605                &self.shared.uniform_bind_group_layout,
606                &[wgpu::BindGroupEntry {
607                    binding: 0,
608                    resource: uniform_buffer.as_entire_binding(),
609                }],
610            );
611
612            // Render with appropriate pipeline
613            if self.render_mode.is_sdf() {
614                // SDF pipeline
615                render_pass.set_pipeline(&self.sdf.pipeline);
616                render_pass.set_bind_group(0, &self.sdf.bind_group, &[]);
617                render_pass.set_bind_group(1, &uniform_bind_group, &[]);
618                render_pass.set_bind_group(2, &self.sdf.params_bind_group, &[]);
619            } else {
620                // Bitmap pipeline
621                render_pass.set_pipeline(&self.bitmap.pipeline);
622                render_pass.set_bind_group(0, &self.bitmap.bind_group, &[]);
623                render_pass.set_bind_group(1, &uniform_bind_group, &[]);
624            }
625
626            render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
627            render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
628            render_pass.draw_indexed(0..self.indices.len() as u32, 0, 0..1);
629
630            // Clear for next frame
631            self.vertices.clear();
632            self.indices.clear();
633        }
634
635        // 3. Render line decorations (underline, strikethrough - on top of text)
636        self.decoration
637            .render_lines(render_pass, &self.shared.renderer, &self.shared.viewport);
638    }
639
640    /// Get the font system.
641    pub fn font_system(&self) -> Arc<std::sync::RwLock<cosmic_text::FontSystem>> {
642        self.shared.font_system.clone()
643    }
644
645    /// Get the swash cache.
646    pub fn swash_cache(&self) -> Arc<std::sync::RwLock<cosmic_text::SwashCache>> {
647        self.shared.swash_cache.clone()
648    }
649
650    /// Get the atlas size in pixels.
651    pub fn atlas_size(&self) -> u32 {
652        self.bitmap.atlas.width()
653    }
654
655    /// Get the atlas texture view for binding.
656    pub fn atlas_texture_view(&self) -> &wgpu::TextureView {
657        self.bitmap.atlas.view()
658    }
659
660    /// Get the atlas sampler for binding.
661    pub fn atlas_sampler(&self) -> &wgpu::Sampler {
662        &self.bitmap.sampler
663    }
664
665    /// Check if the atlas has pending changes.
666    pub fn is_atlas_dirty(&self) -> bool {
667        self.bitmap.atlas_dirty
668    }
669
670    /// Upload atlas data to GPU if dirty.
671    pub fn upload_atlas_if_dirty(&mut self) {
672        profile_function!();
673        self.bitmap.upload_atlas(&self.shared);
674    }
675
676    /// Ensure a glyph is in the atlas using a cache key.
677    pub fn ensure_glyph_in_atlas(&mut self, cache_key: CacheKey) -> Option<&AtlasEntry> {
678        self.bitmap.ensure_glyph(&self.shared, cache_key)
679    }
680
681    /// Get glyph placement information.
682    pub fn get_glyph_placement(&mut self, cache_key: CacheKey) -> Option<GlyphPlacement> {
683        let mut font_system = self.shared.font_system.write().ok()?;
684        let mut swash_cache = self.shared.swash_cache.write().ok()?;
685
686        let image = swash_cache
687            .get_image(&mut font_system, cache_key)
688            .as_ref()?;
689
690        let scale = self.shared.scale_factor();
691
692        Some(GlyphPlacement {
693            left: image.placement.left as f32 / scale,
694            top: image.placement.top as f32 / scale,
695            width: image.placement.width as f32 / scale,
696            height: image.placement.height as f32 / scale,
697        })
698    }
699
700    /// Ensure a glyph is in the atlas and get its placement info.
701    pub fn ensure_glyph_with_placement(
702        &mut self,
703        cache_key: CacheKey,
704    ) -> Option<(AtlasEntry, GlyphPlacement)> {
705        let atlas_entry = self.bitmap.ensure_glyph(&self.shared, cache_key)?.clone();
706
707        let mut font_system = self.shared.font_system.write().ok()?;
708        let mut swash_cache = self.shared.swash_cache.write().ok()?;
709
710        let image = swash_cache
711            .get_image(&mut font_system, cache_key)
712            .as_ref()?;
713
714        let scale = self.shared.scale_factor();
715
716        let placement = GlyphPlacement {
717            left: image.placement.left as f32 / scale,
718            top: image.placement.top as f32 / scale,
719            width: image.placement.width as f32 / scale,
720            height: image.placement.height as f32 / scale,
721        };
722
723        Some((atlas_entry, placement))
724    }
725
726    /// Get an atlas entry by cache key (if it exists).
727    pub fn get_atlas_entry(&self, cache_key: CacheKey) -> Option<&AtlasEntry> {
728        self.bitmap.atlas_entries.get(&cache_key)
729    }
730}
731
732impl TextRender for FontRenderer {
733    fn prepare(&mut self, text: &Text) -> TextBuffer {
734        FontRenderer::prepare(self, text)
735    }
736
737    fn draw_text(&mut self, buffer: &mut TextBuffer, position: Vec2) {
738        FontRenderer::draw_text(self, buffer, position)
739    }
740
741    fn render(&mut self, render_pass: &mut wgpu::RenderPass) {
742        FontRenderer::render(self, render_pass)
743    }
744
745    fn measure_text(&self, text: &Text) -> (f32, f32) {
746        FontRenderer::measure_text(self, text)
747    }
748
749    fn set_viewport(&mut self, viewport: Viewport) {
750        FontRenderer::set_viewport(self, viewport)
751    }
752
753    fn buffer_bounds(&self, buffer: &TextBuffer) -> (f32, f32) {
754        FontRenderer::buffer_bounds(self, buffer)
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn test_select_render_mode_small_text_no_effects() {
764        let mode = FontRenderer::select_render_mode(12.0, false);
765        assert!(!mode.is_sdf());
766        assert_eq!(mode, TextRenderMode::Bitmap);
767    }
768
769    #[test]
770    fn test_select_render_mode_large_text_no_effects() {
771        let mode = FontRenderer::select_render_mode(32.0, false);
772        assert!(mode.is_sdf());
773        assert_eq!(mode.spread(), SDF_DEFAULT_SPREAD);
774    }
775
776    #[test]
777    fn test_select_render_mode_small_text_with_effects() {
778        let mode = FontRenderer::select_render_mode(12.0, true);
779        assert!(mode.is_sdf());
780        assert_eq!(mode.spread(), SDF_DEFAULT_SPREAD);
781    }
782
783    #[test]
784    fn test_select_render_mode_boundary() {
785        // Exactly at 24px boundary
786        let mode = FontRenderer::select_render_mode(24.0, false);
787        assert!(mode.is_sdf());
788
789        // Just below boundary
790        let mode = FontRenderer::select_render_mode(23.9, false);
791        assert!(!mode.is_sdf());
792    }
793}