Skip to main content

cotis_wgpu/
text.rs

1//! Advanced extension API: glyphon text batching for a single frame.
2//!
3//! Extension API for custom [`crate::drawable::WgpuDrawable`] implementations; not re-exported in
4//! [`crate::prelude`] and may change between releases.
5//!
6//! [`TextState`] queues text lines during command execution via [`TextState::queue_text`] and
7//! renders them in a single glyphon pass at frame end via [`TextState::flush`].
8//! All text uses glyphon `Family::SansSerif`; there is no font-loading API.
9
10use std::sync::{Arc, Mutex, MutexGuard};
11
12use cotis_defaults::element_configs::text_config::TextConfig;
13use cotis_utils::math::Dimensions;
14use glyphon::{
15    Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
16    TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
17};
18use wgpu::MultisampleState;
19
20use crate::pipeline::UIPosition;
21
22struct QueuedTextLine {
23    line: Buffer,
24    left: f32,
25    top: f32,
26    color: Color,
27    bounds: Option<(UIPosition, UIPosition)>,
28}
29
30/// Glyphon-backed text batching for a single frame.
31///
32/// Text is queued during [`crate::drawable::WgpuDrawable::draw`] execution and flushed once
33/// per frame. On glyphon prepare/render failure, a warning is logged and queued lines are cleared.
34pub struct TextState {
35    font_system: Arc<Mutex<FontSystem>>,
36    swash_cache: SwashCache,
37    viewport: Viewport,
38    atlas: TextAtlas,
39    text_renderer: TextRenderer,
40    measurement_buffer: Buffer,
41    lines: Vec<QueuedTextLine>,
42    dpi_scale: f32,
43}
44
45impl TextState {
46    /// Creates glyphon font system, atlas, and text renderer for the given surface format.
47    pub fn new(
48        device: &wgpu::Device,
49        queue: &wgpu::Queue,
50        pixel_format: wgpu::TextureFormat,
51        size: (u32, u32),
52        dpi_scale: f32,
53    ) -> Self {
54        let font_system = Arc::new(Mutex::new(FontSystem::new()));
55        let swash_cache = SwashCache::new();
56        let cache = Cache::new(device);
57        let viewport = Viewport::new(device, &cache);
58        let mut atlas = TextAtlas::new(device, queue, &cache, pixel_format);
59        let text_renderer = TextRenderer::new(
60            &mut atlas,
61            device,
62            MultisampleState::default(),
63            Some(wgpu::DepthStencilState {
64                format: wgpu::TextureFormat::Depth32Float,
65                depth_write_enabled: true,
66                depth_compare: wgpu::CompareFunction::Less,
67                stencil: wgpu::StencilState::default(),
68                bias: wgpu::DepthBiasState::default(),
69            }),
70        );
71
72        let measurement_buffer = {
73            let mut locked = lock_font_system(&font_system);
74            Buffer::new(&mut locked, Metrics::new(30.0, 42.0))
75        };
76
77        let _ = size;
78
79        Self {
80            font_system,
81            swash_cache,
82            viewport,
83            atlas,
84            text_renderer,
85            measurement_buffer,
86            lines: Vec::new(),
87            dpi_scale,
88        }
89    }
90
91    /// Updates the DPI scale factor used when measuring text.
92    pub fn set_dpi_scale(&mut self, dpi_scale: f32) {
93        self.dpi_scale = dpi_scale;
94    }
95
96    /// Returns a shared handle to the glyphon font system.
97    ///
98    /// Used by [`crate::cotis_traits`] to provide layout-time text measurement.
99    pub fn font_system(&self) -> Arc<Mutex<FontSystem>> {
100        self.font_system.clone()
101    }
102
103    /// Queues a line of text for rendering at the end of the current frame.
104    ///
105    /// `font_size` and `line_height` should already include DPI scaling when called from
106    /// [`crate::default_drawables`]. `draw_order` maps to glyphon depth metadata for z-ordering.
107    #[allow(clippy::too_many_arguments)]
108    pub fn queue_text(
109        &mut self,
110        text: &str,
111        font_size: f32,
112        line_height: f32,
113        position: UIPosition,
114        bounds: Option<(UIPosition, UIPosition)>,
115        color: Color,
116        draw_order: f32,
117    ) {
118        let mut font_system = lock_font_system(&self.font_system);
119        let mut line = Buffer::new(&mut font_system, Metrics::new(font_size, line_height));
120        line.set_text(
121            &mut font_system,
122            text,
123            Attrs::new()
124                .family(Family::SansSerif)
125                .metadata((draw_order * 10_000.0) as usize),
126            Shaping::Advanced,
127        );
128        line.shape_until_scroll(&mut font_system, false);
129
130        self.lines.push(QueuedTextLine {
131            line,
132            left: position.x,
133            top: position.y,
134            color,
135            bounds,
136        });
137    }
138
139    /// Prepares and renders all queued text lines, then clears the queue.
140    ///
141    /// On prepare failure, logs a warning and clears queued lines without rendering.
142    /// On render failure, logs a warning but still clears the queue.
143    pub fn flush(
144        &mut self,
145        device: &wgpu::Device,
146        queue: &wgpu::Queue,
147        render_pass: &mut wgpu::RenderPass<'_>,
148        surface_config: &wgpu::SurfaceConfiguration,
149    ) {
150        if self.lines.is_empty() {
151            return;
152        }
153
154        self.atlas.trim();
155        self.viewport.update(
156            queue,
157            Resolution {
158                width: surface_config.width,
159                height: surface_config.height,
160            },
161        );
162
163        let areas = self.lines.iter().map(|text_line| TextArea {
164            buffer: &text_line.line,
165            left: text_line.left,
166            top: text_line.top,
167            scale: 1.0,
168            bounds: match text_line.bounds {
169                Some((position, bounds)) => TextBounds {
170                    left: position.x as i32,
171                    top: position.y as i32,
172                    right: (position.x + bounds.x) as i32,
173                    bottom: (position.y + bounds.y) as i32,
174                },
175                None => TextBounds {
176                    left: 0,
177                    top: 0,
178                    right: surface_config.width as i32,
179                    bottom: surface_config.height as i32,
180                },
181            },
182            default_color: text_line.color,
183            custom_glyphs: &[],
184        });
185
186        let mut font_system = lock_font_system(&self.font_system);
187        if let Err(error) = self.text_renderer.prepare_with_depth(
188            device,
189            queue,
190            &mut font_system,
191            &mut self.atlas,
192            &self.viewport,
193            areas,
194            &mut self.swash_cache,
195            |metadata| metadata as f32 / 10_000.0,
196        ) {
197            log::warn!("glyphon prepare failed: {error}");
198            self.lines.clear();
199            return;
200        }
201
202        if let Err(error) = self
203            .text_renderer
204            .render(&self.atlas, &self.viewport, render_pass)
205        {
206            log::warn!("glyphon render failed: {error}");
207        }
208
209        self.lines.clear();
210    }
211
212    /// Measures the pixel dimensions of `text` using the given layout config.
213    ///
214    /// Font size and line height are multiplied by the current DPI scale. Always uses
215    /// `Family::SansSerif`; `font_id` is not consulted.
216    pub fn measure(&mut self, text: &str, config: &TextConfig) -> Dimensions {
217        let font_size = config.font_size * self.dpi_scale;
218        let line_height = if config.line_height > 0.0 {
219            config.line_height * self.dpi_scale
220        } else {
221            font_size * 1.5
222        };
223
224        let mut font_system = lock_font_system(&self.font_system);
225        self.measurement_buffer.set_metrics_and_size(
226            &mut font_system,
227            Metrics::new(font_size, line_height),
228            None,
229            None,
230        );
231        self.measurement_buffer.set_text(
232            &mut font_system,
233            text,
234            Attrs::new().family(Family::SansSerif),
235            Shaping::Advanced,
236        );
237        self.measurement_buffer
238            .shape_until_scroll(&mut font_system, false);
239
240        let line_width = self
241            .measurement_buffer
242            .layout_runs()
243            .next()
244            .map(|run| run.line_w)
245            .unwrap_or(0.0);
246        Dimensions::new(line_width, self.measurement_buffer.metrics().line_height)
247    }
248}
249
250fn lock_font_system(font_system: &Arc<Mutex<FontSystem>>) -> MutexGuard<'_, FontSystem> {
251    match font_system.lock() {
252        Ok(guard) => guard,
253        Err(error) => {
254            log::error!("font system mutex poisoned: {error}");
255            error.into_inner()
256        }
257    }
258}