cotis-wgpu 0.1.0-alpha

Desktop wgpu renderer backend for Cotis
Documentation
//! Advanced extension API: glyphon text batching for a single frame.
//!
//! Extension API for custom [`crate::drawable::WgpuDrawable`] implementations; not re-exported in
//! [`crate::prelude`] and may change between releases.
//!
//! [`TextState`] queues text lines during command execution via [`TextState::queue_text`] and
//! renders them in a single glyphon pass at frame end via [`TextState::flush`].
//! All text uses glyphon `Family::SansSerif`; there is no font-loading API.

use std::sync::{Arc, Mutex, MutexGuard};

use cotis_defaults::element_configs::text_config::TextConfig;
use cotis_utils::math::Dimensions;
use glyphon::{
    Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
    TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
};
use wgpu::MultisampleState;

use crate::pipeline::UIPosition;

struct QueuedTextLine {
    line: Buffer,
    left: f32,
    top: f32,
    color: Color,
    bounds: Option<(UIPosition, UIPosition)>,
}

/// Glyphon-backed text batching for a single frame.
///
/// Text is queued during [`crate::drawable::WgpuDrawable::draw`] execution and flushed once
/// per frame. On glyphon prepare/render failure, a warning is logged and queued lines are cleared.
pub struct TextState {
    font_system: Arc<Mutex<FontSystem>>,
    swash_cache: SwashCache,
    viewport: Viewport,
    atlas: TextAtlas,
    text_renderer: TextRenderer,
    measurement_buffer: Buffer,
    lines: Vec<QueuedTextLine>,
    dpi_scale: f32,
}

impl TextState {
    /// Creates glyphon font system, atlas, and text renderer for the given surface format.
    pub fn new(
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        pixel_format: wgpu::TextureFormat,
        size: (u32, u32),
        dpi_scale: f32,
    ) -> Self {
        let font_system = Arc::new(Mutex::new(FontSystem::new()));
        let swash_cache = SwashCache::new();
        let cache = Cache::new(device);
        let viewport = Viewport::new(device, &cache);
        let mut atlas = TextAtlas::new(device, queue, &cache, pixel_format);
        let text_renderer = TextRenderer::new(
            &mut atlas,
            device,
            MultisampleState::default(),
            Some(wgpu::DepthStencilState {
                format: wgpu::TextureFormat::Depth32Float,
                depth_write_enabled: true,
                depth_compare: wgpu::CompareFunction::Less,
                stencil: wgpu::StencilState::default(),
                bias: wgpu::DepthBiasState::default(),
            }),
        );

        let measurement_buffer = {
            let mut locked = lock_font_system(&font_system);
            Buffer::new(&mut locked, Metrics::new(30.0, 42.0))
        };

        let _ = size;

        Self {
            font_system,
            swash_cache,
            viewport,
            atlas,
            text_renderer,
            measurement_buffer,
            lines: Vec::new(),
            dpi_scale,
        }
    }

    /// Updates the DPI scale factor used when measuring text.
    pub fn set_dpi_scale(&mut self, dpi_scale: f32) {
        self.dpi_scale = dpi_scale;
    }

    /// Returns a shared handle to the glyphon font system.
    ///
    /// Used by [`crate::cotis_traits`] to provide layout-time text measurement.
    pub fn font_system(&self) -> Arc<Mutex<FontSystem>> {
        self.font_system.clone()
    }

    /// Queues a line of text for rendering at the end of the current frame.
    ///
    /// `font_size` and `line_height` should already include DPI scaling when called from
    /// [`crate::default_drawables`]. `draw_order` maps to glyphon depth metadata for z-ordering.
    #[allow(clippy::too_many_arguments)]
    pub fn queue_text(
        &mut self,
        text: &str,
        font_size: f32,
        line_height: f32,
        position: UIPosition,
        bounds: Option<(UIPosition, UIPosition)>,
        color: Color,
        draw_order: f32,
    ) {
        let mut font_system = lock_font_system(&self.font_system);
        let mut line = Buffer::new(&mut font_system, Metrics::new(font_size, line_height));
        line.set_text(
            &mut font_system,
            text,
            Attrs::new()
                .family(Family::SansSerif)
                .metadata((draw_order * 10_000.0) as usize),
            Shaping::Advanced,
        );
        line.shape_until_scroll(&mut font_system, false);

        self.lines.push(QueuedTextLine {
            line,
            left: position.x,
            top: position.y,
            color,
            bounds,
        });
    }

    /// Prepares and renders all queued text lines, then clears the queue.
    ///
    /// On prepare failure, logs a warning and clears queued lines without rendering.
    /// On render failure, logs a warning but still clears the queue.
    pub fn flush(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        render_pass: &mut wgpu::RenderPass<'_>,
        surface_config: &wgpu::SurfaceConfiguration,
    ) {
        if self.lines.is_empty() {
            return;
        }

        self.atlas.trim();
        self.viewport.update(
            queue,
            Resolution {
                width: surface_config.width,
                height: surface_config.height,
            },
        );

        let areas = self.lines.iter().map(|text_line| TextArea {
            buffer: &text_line.line,
            left: text_line.left,
            top: text_line.top,
            scale: 1.0,
            bounds: match text_line.bounds {
                Some((position, bounds)) => TextBounds {
                    left: position.x as i32,
                    top: position.y as i32,
                    right: (position.x + bounds.x) as i32,
                    bottom: (position.y + bounds.y) as i32,
                },
                None => TextBounds {
                    left: 0,
                    top: 0,
                    right: surface_config.width as i32,
                    bottom: surface_config.height as i32,
                },
            },
            default_color: text_line.color,
            custom_glyphs: &[],
        });

        let mut font_system = lock_font_system(&self.font_system);
        if let Err(error) = self.text_renderer.prepare_with_depth(
            device,
            queue,
            &mut font_system,
            &mut self.atlas,
            &self.viewport,
            areas,
            &mut self.swash_cache,
            |metadata| metadata as f32 / 10_000.0,
        ) {
            log::warn!("glyphon prepare failed: {error}");
            self.lines.clear();
            return;
        }

        if let Err(error) = self
            .text_renderer
            .render(&self.atlas, &self.viewport, render_pass)
        {
            log::warn!("glyphon render failed: {error}");
        }

        self.lines.clear();
    }

    /// Measures the pixel dimensions of `text` using the given layout config.
    ///
    /// Font size and line height are multiplied by the current DPI scale. Always uses
    /// `Family::SansSerif`; `font_id` is not consulted.
    pub fn measure(&mut self, text: &str, config: &TextConfig) -> Dimensions {
        let font_size = config.font_size * self.dpi_scale;
        let line_height = if config.line_height > 0.0 {
            config.line_height * self.dpi_scale
        } else {
            font_size * 1.5
        };

        let mut font_system = lock_font_system(&self.font_system);
        self.measurement_buffer.set_metrics_and_size(
            &mut font_system,
            Metrics::new(font_size, line_height),
            None,
            None,
        );
        self.measurement_buffer.set_text(
            &mut font_system,
            text,
            Attrs::new().family(Family::SansSerif),
            Shaping::Advanced,
        );
        self.measurement_buffer
            .shape_until_scroll(&mut font_system, false);

        let line_width = self
            .measurement_buffer
            .layout_runs()
            .next()
            .map(|run| run.line_w)
            .unwrap_or(0.0);
        Dimensions::new(line_width, self.measurement_buffer.metrics().line_height)
    }
}

fn lock_font_system(font_system: &Arc<Mutex<FontSystem>>) -> MutexGuard<'_, FontSystem> {
    match font_system.lock() {
        Ok(guard) => guard,
        Err(error) => {
            log::error!("font system mutex poisoned: {error}");
            error.into_inner()
        }
    }
}