Skip to main content

damascene_wgpu/
text.rs

1//! Text rendering: MSDF for outline glyphs, RGBA bitmap for colour
2//! glyphs.
3//!
4//! Both paths share one [`damascene_core::text::atlas::GlyphAtlas`] for
5//! shaping (cosmic-text + rustybuzz). After shaping, the recorder walks
6//! the [`ShapedRun`] and routes each glyph by source-font kind:
7//!
8//! - **Outline fonts** (Roboto, Inter, Symbols, Math) — rasterized once
9//!   per `(font, glyph)` into the [`MsdfAtlas`] and rendered through
10//!   `stock::text_msdf` with screen-space-derivative AA. The atlas is
11//!   size-independent: a single MSDF serves every UI size and every
12//!   display scale.
13//!
14//! - **Colour fonts** (NotoColorEmoji, COLR Material Symbols) — swash
15//!   rasterizes the strike that best matches the requested size into
16//!   the legacy RGBA atlas, rendered through `stock::text` (modulate by
17//!   white = passthrough).
18//!
19//! Each [`TextRun`] is one of [`TextRunKind::Msdf`] / [`TextRunKind::Color`];
20//! the renderer reads `kind` to choose pipeline + page bind group.
21
22use std::borrow::Cow;
23
24use damascene_core::ir::TextAnchor;
25use damascene_core::shader::stock_wgsl;
26use damascene_core::text::atlas::{
27    ATLAS_BYTES_PER_PIXEL, AtlasPage, AtlasRect, GlyphAtlas, RunStyle, ShapedGlyph, ShapedRun,
28};
29use damascene_core::text::msdf::apply_weight_variation;
30use damascene_core::text::msdf_atlas::{
31    DEFAULT_BASE_EM, DEFAULT_SPREAD, MSDF_BYTES_PER_PIXEL, MsdfAtlas, MsdfAtlasPage, MsdfGlyphKey,
32    MsdfRect, MsdfSlot,
33};
34use damascene_core::text::msdf_snapshot::{SnapshotError, font_token_hash};
35use damascene_core::text::snap_to_physical;
36use damascene_core::tree::{FontFamily, Rect, TextWrap};
37
38/// The `wght` instances the stock theme's roles render at (Regular /
39/// Medium / Semibold / Bold) — the weights warmups cover for variable
40/// faces. Static faces normalize to one default-instance raster
41/// regardless of this list.
42const STOCK_WEIGHTS: &[u16] = &[400, 500, 600, 700];
43
44use bytemuck::{Pod, Zeroable};
45use cosmic_text::fontdb;
46use ttf_parser::Face;
47
48use damascene_core::color::ColorSpace;
49use damascene_core::paint::{DEFAULT_WORKING_COLOR_SPACE, PhysicalScissor, rgba_f32_in};
50use damascene_core::runtime::TextRecorder;
51
52const INITIAL_INSTANCE_CAPACITY: usize = 256;
53
54const COLOR_INSTANCE_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![
55    1 => Float32x4,  // rect  (xy = top-left logical px, zw = size logical px)
56    2 => Float32x4,  // uv    (xy = uv 0..1, zw = uv size 0..1)
57    3 => Float32x4,  // color (linear rgba 0..1)
58];
59
60const MSDF_INSTANCE_ATTRS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
61    1 => Float32x4,  // rect
62    2 => Float32x4,  // uv
63    3 => Float32x4,  // color
64    4 => Float32x4,  // params (x = atlas-space spread, y/z/w reserved)
65];
66
67const HIGHLIGHT_INSTANCE_ATTRS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![
68    1 => Float32x4,  // rect  (xy = top-left logical px, zw = size logical px)
69    2 => Float32x4,  // color (linear rgba 0..1)
70];
71
72#[repr(C)]
73#[derive(Copy, Clone, Pod, Zeroable, Debug)]
74pub(crate) struct ColorGlyphInstance {
75    pub rect: [f32; 4],
76    pub uv: [f32; 4],
77    pub color: [f32; 4],
78}
79
80#[repr(C)]
81#[derive(Copy, Clone, Pod, Zeroable, Debug)]
82pub(crate) struct MsdfGlyphInstance {
83    pub rect: [f32; 4],
84    pub uv: [f32; 4],
85    pub color: [f32; 4],
86    pub params: [f32; 4],
87}
88
89#[repr(C)]
90#[derive(Copy, Clone, Pod, Zeroable, Debug)]
91pub(crate) struct HighlightInstance {
92    pub rect: [f32; 4],
93    pub color: [f32; 4],
94}
95
96#[derive(Clone, Copy, PartialEq, Eq)]
97pub(crate) enum TextRunKind {
98    Color,
99    Msdf,
100    Highlight,
101}
102
103#[derive(Clone, Copy)]
104pub(crate) struct TextRun {
105    pub kind: TextRunKind,
106    pub page: u32,
107    pub scissor: Option<PhysicalScissor>,
108    pub first: u32,
109    pub count: u32,
110}
111
112struct PageTexture {
113    texture: wgpu::Texture,
114    bind_group: wgpu::BindGroup,
115}
116
117/// The device-scoped half of text rendering, shareable across
118/// [`Runner`](crate::Runner)s (issue #94): the font system + shaping
119/// cache, the CPU-side glyph and MSDF atlases, and their GPU page
120/// textures with bind groups. Everything here is independent of the
121/// swapchain format and MSAA sample count, so one `SharedText` per
122/// `wgpu::Device` can back every window — a multi-window host that
123/// passes the same handle to each `Runner`
124/// ([`Runner::with_shared_text`](crate::Runner::with_shared_text))
125/// pays glyph rasterization, shaping, warm-up, and atlas VRAM once per
126/// device instead of once per window.
127///
128/// Cloning is cheap (an `Arc`); the inner state is mutex-guarded and
129/// locked per record/flush call, so windows can be prepared from one
130/// thread in any order. Each attached `Runner` widens the atlases'
131/// LRU protection window (see
132/// `MsdfAtlas::set_lru_protection_window`) so a page referenced by one
133/// window's in-flight frame can't be recycled by another's prepare.
134///
135/// The default `Runner` constructors create a private `SharedText`
136/// per runner — single-window behavior is unchanged.
137#[derive(Clone)]
138pub struct SharedText(pub(crate) std::sync::Arc<std::sync::Mutex<SharedTextInner>>);
139
140pub(crate) struct SharedTextInner {
141    pub(crate) atlas: GlyphAtlas,
142    pub(crate) msdf_atlas: MsdfAtlas,
143
144    color_pages: Vec<PageTexture>,
145    color_page_bind_layout: wgpu::BindGroupLayout,
146    color_sampler: wgpu::Sampler,
147
148    msdf_pages: Vec<PageTexture>,
149    msdf_page_bind_layout: wgpu::BindGroupLayout,
150    msdf_sampler: wgpu::Sampler,
151
152    /// Number of `TextPaint`s currently attached — mirrored into both
153    /// atlases' LRU protection windows so recycling stays safe under
154    /// any prepare/render interleaving across the attached runners.
155    attached: u32,
156}
157
158impl SharedText {
159    /// A fresh shared text pool for `device`. Pass the same handle to
160    /// every [`Runner`](crate::Runner) created on that device. Do
161    /// **not** share one `SharedText` across devices — the page
162    /// textures belong to the device that created them.
163    pub fn new(device: &wgpu::Device) -> Self {
164        let color_page_bind_layout = create_page_bind_layout(device, "color");
165        let msdf_page_bind_layout = create_page_bind_layout(device, "msdf");
166        let color_sampler = create_page_sampler(device, "color");
167        let msdf_sampler = create_page_sampler(device, "msdf");
168        Self(std::sync::Arc::new(std::sync::Mutex::new(
169            SharedTextInner {
170                atlas: GlyphAtlas::new(),
171                msdf_atlas: MsdfAtlas::new(DEFAULT_BASE_EM, DEFAULT_SPREAD),
172                color_pages: Vec::new(),
173                color_page_bind_layout,
174                color_sampler,
175                msdf_pages: Vec::new(),
176                msdf_page_bind_layout,
177                msdf_sampler,
178                attached: 0,
179            },
180        )))
181    }
182
183    /// Pre-rasterize printable ASCII for the bundled default faces —
184    /// see [`TextPaint::warm_default_glyphs`] for cost and rationale.
185    /// On a shared pool this runs once per *device*: warm the pool
186    /// before (or after) attaching runners, and every attached runner
187    /// is warm. Runners attached to an already-warm pool skip the cost
188    /// in their own `warm_default_glyphs` automatically (rasterized
189    /// glyphs are cache hits).
190    pub fn warm_default_glyphs(&self) {
191        self.lock().warm_default_glyphs();
192    }
193
194    /// Pre-rasterize a chosen set of `(family, char)` glyphs — the
195    /// app-selectable counterpart to [`Self::warm_default_glyphs`]. Use
196    /// it to warm fonts you registered yourself, or a glyph set beyond
197    /// printable ASCII (e.g. the Latin-1 supplement, or the symbols your
198    /// UI actually shows). MSDF keys are size/weight-independent, so each
199    /// glyph is rasterized once and reused at every size.
200    pub fn warm_glyphs(&self, families: &[FontFamily], chars: &[char]) {
201        self.lock().warm_msdf_for_chars(chars, families);
202    }
203
204    /// Serialize the resident outline-glyph atlas into a portable
205    /// snapshot blob. Glyphs are keyed by a content hash of each font's
206    /// bytes, so the blob reloads across runs (and processes) regardless
207    /// of font-registration order. Persist it however suits the app —
208    /// embed via `include_bytes!`, or cache to disk — and reload with
209    /// [`Self::import_msdf_snapshot`] to skip regenerating those glyphs.
210    ///
211    /// This is the app-driven equivalent of the built-in
212    /// `prebaked-default-fonts` bake, for fonts damascene can't bake at
213    /// its own build time (anything you `register_font`).
214    pub fn export_msdf_snapshot(&self) -> Vec<u8> {
215        self.lock().export_msdf_snapshot()
216    }
217
218    /// Load a snapshot produced by [`Self::export_msdf_snapshot`],
219    /// resolving each font by content hash against the fonts currently
220    /// loaded; sections whose font isn't present are skipped, and
221    /// already-resident glyphs are left untouched. Returns the number of
222    /// glyphs loaded, or [`SnapshotError`] if the blob is unreadable or
223    /// its bake parameters don't match this renderer (in which case
224    /// nothing is loaded and you should warm live instead).
225    pub fn import_msdf_snapshot(&self, bytes: &[u8]) -> Result<usize, SnapshotError> {
226        self.lock().import_msdf_snapshot(bytes)
227    }
228
229    pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, SharedTextInner> {
230        // Glyph rasterization can't poison anything we can't keep
231        // using; recover the guard rather than propagating panics
232        // across windows.
233        match self.0.lock() {
234            Ok(g) => g,
235            Err(poisoned) => poisoned.into_inner(),
236        }
237    }
238}
239
240pub(crate) struct TextPaint {
241    /// Device-scoped shared half: atlases, page textures, bind groups.
242    shared: SharedText,
243
244    // Per-window bind-group snapshots, cloned from the shared pool at
245    // `flush` so `render` never takes the lock (and a page texture
246    // created by another window's later flush can't shift indices
247    // under this window's recorded runs — wgpu resources are
248    // internally ref-counted, so clones are cheap handles).
249    color_page_bgs: Vec<wgpu::BindGroup>,
250    msdf_page_bgs: Vec<wgpu::BindGroup>,
251
252    // Colour-bitmap path (NotoColorEmoji, COLR fonts).
253    color_instances: Vec<ColorGlyphInstance>,
254    color_instance_buf: wgpu::Buffer,
255    color_instance_capacity: usize,
256    color_pipeline: wgpu::RenderPipeline,
257
258    // MSDF outline path.
259    msdf_instances: Vec<MsdfGlyphInstance>,
260    msdf_instance_buf: wgpu::Buffer,
261    msdf_instance_capacity: usize,
262    msdf_pipeline: wgpu::RenderPipeline,
263
264    // Inline-run highlight path (solid quads behind glyphs).
265    highlight_instances: Vec<HighlightInstance>,
266    highlight_instance_buf: wgpu::Buffer,
267    highlight_instance_capacity: usize,
268    highlight_pipeline: wgpu::RenderPipeline,
269
270    // Pipeline layouts + sample count retained so the three
271    // swapchain-format-bound pipelines above can be rebuilt in place when
272    // the host renegotiates the surface format (`set_target_format`). The
273    // layouts reference the shared pool's page bind-group layouts, which
274    // outlive the pipelines they feed.
275    color_pipeline_layout: wgpu::PipelineLayout,
276    msdf_pipeline_layout: wgpu::PipelineLayout,
277    highlight_pipeline_layout: wgpu::PipelineLayout,
278    sample_count: u32,
279
280    runs: Vec<TextRun>,
281
282    /// Working color space glyph + highlight colors are converted into.
283    /// Kept in sync with [`RunnerCore::working_color_space`](damascene_core::runtime::RunnerCore::working_color_space)
284    /// by the owning `Runner`. Per-window: two windows sharing a pool
285    /// can composite in different spaces.
286    working_color_space: ColorSpace,
287}
288
289impl Drop for TextPaint {
290    fn drop(&mut self) {
291        let mut inner = self.shared.lock();
292        inner.attached = inner.attached.saturating_sub(1);
293        let n = inner.attached.max(1);
294        inner.atlas.set_lru_protection_window(n);
295        inner.msdf_atlas.set_lru_protection_window(n);
296    }
297}
298
299/// Page bind-group layout for either glyph-page kind — one filterable
300/// 2D texture + one filtering sampler.
301fn create_page_bind_layout(device: &wgpu::Device, kind: &str) -> wgpu::BindGroupLayout {
302    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
303        label: Some(&format!("damascene_wgpu::text::{kind}_page_bind_layout")),
304        entries: &[
305            wgpu::BindGroupLayoutEntry {
306                binding: 0,
307                visibility: wgpu::ShaderStages::FRAGMENT,
308                ty: wgpu::BindingType::Texture {
309                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
310                    view_dimension: wgpu::TextureViewDimension::D2,
311                    multisampled: false,
312                },
313                count: None,
314            },
315            wgpu::BindGroupLayoutEntry {
316                binding: 1,
317                visibility: wgpu::ShaderStages::FRAGMENT,
318                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
319                count: None,
320            },
321        ],
322    })
323}
324
325fn create_page_sampler(device: &wgpu::Device, kind: &str) -> wgpu::Sampler {
326    device.create_sampler(&wgpu::SamplerDescriptor {
327        label: Some(&format!("damascene_wgpu::text::{kind}_sampler")),
328        address_mode_u: wgpu::AddressMode::ClampToEdge,
329        address_mode_v: wgpu::AddressMode::ClampToEdge,
330        address_mode_w: wgpu::AddressMode::ClampToEdge,
331        mag_filter: wgpu::FilterMode::Linear,
332        min_filter: wgpu::FilterMode::Linear,
333        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
334        ..Default::default()
335    })
336}
337
338impl TextPaint {
339    pub(crate) fn new(
340        device: &wgpu::Device,
341        target_format: wgpu::TextureFormat,
342        sample_count: u32,
343        frame_bind_layout: &wgpu::BindGroupLayout,
344    ) -> Self {
345        Self::with_shared(
346            device,
347            target_format,
348            sample_count,
349            frame_bind_layout,
350            SharedText::new(device),
351        )
352    }
353
354    /// Build the per-window half against an existing shared pool. The
355    /// pool's page bind-group layouts feed this window's pipeline
356    /// layouts, so the shared page bind groups bind directly into the
357    /// window's pipelines.
358    pub(crate) fn with_shared(
359        device: &wgpu::Device,
360        target_format: wgpu::TextureFormat,
361        sample_count: u32,
362        frame_bind_layout: &wgpu::BindGroupLayout,
363        shared: SharedText,
364    ) -> Self {
365        let (color_pipeline_layout, msdf_pipeline_layout) = {
366            let mut inner = shared.lock();
367            inner.attached += 1;
368            let n = inner.attached;
369            inner.atlas.set_lru_protection_window(n);
370            inner.msdf_atlas.set_lru_protection_window(n);
371            (
372                device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
373                    label: Some("damascene_wgpu::text::color_pipeline_layout"),
374                    bind_group_layouts: &[
375                        Some(frame_bind_layout),
376                        Some(&inner.color_page_bind_layout),
377                    ],
378                    immediate_size: 0,
379                }),
380                device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
381                    label: Some("damascene_wgpu::text::msdf_pipeline_layout"),
382                    bind_group_layouts: &[
383                        Some(frame_bind_layout),
384                        Some(&inner.msdf_page_bind_layout),
385                    ],
386                    immediate_size: 0,
387                }),
388            )
389        };
390
391        let color_pipeline =
392            build_color_pipeline(device, &color_pipeline_layout, target_format, sample_count);
393        let msdf_pipeline =
394            build_msdf_pipeline(device, &msdf_pipeline_layout, target_format, sample_count);
395
396        let color_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
397            label: Some("damascene_wgpu::text::color_instance_buf"),
398            size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<ColorGlyphInstance>()) as u64,
399            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
400            mapped_at_creation: false,
401        });
402        let msdf_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
403            label: Some("damascene_wgpu::text::msdf_instance_buf"),
404            size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<MsdfGlyphInstance>()) as u64,
405            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
406            mapped_at_creation: false,
407        });
408
409        // ---- Inline-run highlight pipeline (`stock::text_highlight`) ----
410        // Solid colour quads only — no page texture, just frame uniforms.
411        let highlight_pipeline_layout =
412            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
413                label: Some("damascene_wgpu::text::highlight_pipeline_layout"),
414                bind_group_layouts: &[Some(frame_bind_layout)],
415                immediate_size: 0,
416            });
417        let highlight_pipeline = build_highlight_pipeline(
418            device,
419            &highlight_pipeline_layout,
420            target_format,
421            sample_count,
422        );
423        let highlight_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
424            label: Some("damascene_wgpu::text::highlight_instance_buf"),
425            size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<HighlightInstance>()) as u64,
426            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
427            mapped_at_creation: false,
428        });
429
430        Self {
431            shared,
432            color_page_bgs: Vec::new(),
433            msdf_page_bgs: Vec::new(),
434            color_instances: Vec::with_capacity(INITIAL_INSTANCE_CAPACITY),
435            color_instance_buf,
436            color_instance_capacity: INITIAL_INSTANCE_CAPACITY,
437            color_pipeline,
438            msdf_instances: Vec::with_capacity(INITIAL_INSTANCE_CAPACITY),
439            msdf_instance_buf,
440            msdf_instance_capacity: INITIAL_INSTANCE_CAPACITY,
441            msdf_pipeline,
442            highlight_instances: Vec::with_capacity(INITIAL_INSTANCE_CAPACITY),
443            highlight_instance_buf,
444            highlight_instance_capacity: INITIAL_INSTANCE_CAPACITY,
445            highlight_pipeline,
446            color_pipeline_layout,
447            msdf_pipeline_layout,
448            highlight_pipeline_layout,
449            sample_count,
450            runs: Vec::new(),
451            working_color_space: DEFAULT_WORKING_COLOR_SPACE,
452        }
453    }
454
455    /// The shared pool this paint records into — for `Runner` to hand
456    /// out so further runners can attach to it.
457    pub(crate) fn shared(&self) -> &SharedText {
458        &self.shared
459    }
460
461    /// Update the working color space subsequent glyph / highlight color
462    /// packing converts into. Called by `Runner::set_working_color_space`.
463    pub(crate) fn set_working_color_space(&mut self, space: ColorSpace) {
464        self.working_color_space = space;
465    }
466
467    /// Rebuild the three swapchain-format-bound pipelines for a new target
468    /// format, preserving atlases, page textures, instance buffers, and
469    /// samplers. Called by `Runner::set_target_format` on live surface-format
470    /// renegotiation (e.g. SDR ↔ HDR). The pipeline layouts and page
471    /// bind-group layouts are unchanged, so the cached page bind groups stay
472    /// valid — only the pipelines, which carry the `ColorTargetState.format`,
473    /// are recreated.
474    pub(crate) fn set_target_format(
475        &mut self,
476        device: &wgpu::Device,
477        target_format: wgpu::TextureFormat,
478    ) {
479        self.color_pipeline = build_color_pipeline(
480            device,
481            &self.color_pipeline_layout,
482            target_format,
483            self.sample_count,
484        );
485        self.msdf_pipeline = build_msdf_pipeline(
486            device,
487            &self.msdf_pipeline_layout,
488            target_format,
489            self.sample_count,
490        );
491        self.highlight_pipeline = build_highlight_pipeline(
492            device,
493            &self.highlight_pipeline_layout,
494            target_format,
495            self.sample_count,
496        );
497    }
498
499    pub(crate) fn frame_begin(&mut self) {
500        self.color_instances.clear();
501        self.msdf_instances.clear();
502        self.highlight_instances.clear();
503        self.runs.clear();
504    }
505
506    #[allow(clippy::too_many_arguments)]
507    fn record_inner(
508        &mut self,
509        rect: Rect,
510        scissor: Option<PhysicalScissor>,
511        runs: &[(String, RunStyle)],
512        size: f32,
513        line_height: f32,
514        wrap: TextWrap,
515        anchor: TextAnchor,
516        scale_factor: f32,
517    ) -> std::ops::Range<usize> {
518        // Shape at the *logical* size: MSDF is unhinted so size doesn't
519        // affect glyph IDs/advances beyond a uniform scale; we want
520        // logical-px positions out so quads land on logical pixels and
521        // the SDF shader handles screen-pixel AA via fwidth.
522        let avail = wrap_available_width(rect.w, scale_factor, wrap, anchor);
523        let runs_ref: Vec<(&str, RunStyle)> = runs
524            .iter()
525            .map(|(text, style)| (text.as_str(), style.clone()))
526            .collect();
527        // One lock per recorded text op: shaping and atlas slot
528        // lookups both touch the shared pool. Uncontended in the
529        // single-window case; in a multi-window host windows prepare
530        // sequentially on the event-loop thread, so contention stays
531        // momentary.
532        let shared = self.shared.clone();
533        let mut inner = shared.lock();
534        let shaped = {
535            damascene_core::profile_span!("paint::text::shape_runs");
536            inner.atlas.shape_runs_with_line_height(
537                &runs_ref,
538                size,
539                line_height,
540                wrap,
541                anchor,
542                avail,
543            )
544        };
545        damascene_core::profile_span!("paint::text::emit_shaped");
546        self.emit_shaped_glyphs(&mut inner, rect, scissor, &shaped, wrap, scale_factor)
547    }
548
549    fn emit_shaped_glyphs(
550        &mut self,
551        inner: &mut SharedTextInner,
552        rect: Rect,
553        scissor: Option<PhysicalScissor>,
554        shaped: &ShapedRun,
555        wrap: TextWrap,
556        scale_factor: f32,
557    ) -> std::ops::Range<usize> {
558        let runs_start = self.runs.len();
559        if shaped.glyphs.is_empty() && shaped.highlights.is_empty() && shaped.decorations.is_empty()
560        {
561            return runs_start..runs_start;
562        }
563
564        // Layout came back in logical px (we shaped at logical size).
565        // For NoWrap text we vertically center the whole laid-out
566        // block — buttons / badges hand us a control-height rect with
567        // a single-line label, and centering reads as "right". Using
568        // `layout.height` (rather than one line-height) keeps
569        // multi-line NoWrap text — a code block body, a label with an
570        // embedded `\n` — flush to the top of its hugged rect instead
571        // of being pushed down by `(N-1) * line_height / 2`.
572        let v_offset = match wrap {
573            TextWrap::NoWrap => ((rect.h - shaped.layout.height).max(0.0)) * 0.5,
574            TextWrap::Wrap => 0.0,
575        };
576        let origin_x = rect.x;
577        let origin_y = rect.y + v_offset;
578
579        // Inline-run highlights ride at the front of the run sequence
580        // so they paint *behind* the glyphs on the same scissor / z
581        // band. Each shaped highlight already represents one line's
582        // span of one styled run; we emit them all into a single
583        // Highlight TextRun.
584        if !shaped.highlights.is_empty() {
585            let first = self.highlight_instances.len() as u32;
586            for h in &shaped.highlights {
587                self.highlight_instances.push(HighlightInstance {
588                    rect: [origin_x + h.x, origin_y + h.y, h.w, h.h],
589                    color: rgba_f32_in(h.color, self.working_color_space),
590                });
591            }
592            let count = self.highlight_instances.len() as u32 - first;
593            if count > 0 {
594                self.runs.push(TextRun {
595                    kind: TextRunKind::Highlight,
596                    page: 0,
597                    scissor,
598                    first,
599                    count,
600                });
601            }
602        }
603
604        // Walk shaped glyphs. Each becomes either a colour or MSDF
605        // instance, emitted into its own per-kind run. A run breaks
606        // whenever the kind+page combination changes.
607        let mut current: Option<(TextRunKind, u32, u32)> = None; // (kind, page, run_first)
608
609        for glyph in &shaped.glyphs {
610            let font_id = glyph.key.font;
611            let is_color = inner.atlas.is_color_font(font_id);
612            if is_color {
613                // Rasterize colour bitmaps at physical px so hidpi
614                // emoji stay crisp; the quad divides back to logical.
615                let color_key = glyph.key.at_scale(scale_factor);
616                inner.atlas.ensure_color_glyph(color_key);
617                let Some(slot) = inner.atlas.slot(color_key) else {
618                    continue;
619                };
620                if slot.rect.w == 0 || slot.rect.h == 0 {
621                    continue;
622                }
623                let page = slot.page;
624                let next_kind = TextRunKind::Color;
625                self.maybe_close_run(&mut current, next_kind, page, scissor);
626                self.push_color_glyph(inner, glyph, slot, origin_x, origin_y, scale_factor);
627            } else {
628                let mkey = MsdfGlyphKey {
629                    font: font_id,
630                    glyph_id: glyph.key.glyph_id,
631                    weight: inner.atlas.msdf_raster_weight(font_id, glyph.key.weight),
632                };
633                let Some(slot) = ensure_msdf(inner, mkey, font_id, glyph.key.weight) else {
634                    // Whitespace or .notdef without outline — no quad,
635                    // advance is already baked into cosmic-text positions.
636                    continue;
637                };
638                let page = slot.page;
639                let next_kind = TextRunKind::Msdf;
640                self.maybe_close_run(&mut current, next_kind, page, scissor);
641                self.push_msdf_glyph(inner, glyph, slot, origin_x, origin_y, scale_factor);
642            }
643        }
644
645        // Close the trailing open run, if any.
646        if let Some((kind, page, first)) = current {
647            let count = self.instance_count_after(kind, first);
648            if count > 0 {
649                self.runs.push(TextRun {
650                    kind,
651                    page,
652                    scissor,
653                    first,
654                    count,
655                });
656            }
657        }
658
659        // Decoration rects (underline / strikethrough). Appended
660        // *after* the glyph runs so they paint on top — the existing
661        // Highlight pipeline draws solid rgba quads, which is exactly
662        // what an underline or strikethrough bar is.
663        if !shaped.decorations.is_empty() {
664            let first = self.highlight_instances.len() as u32;
665            for d in &shaped.decorations {
666                // Snap the bar's top to a device row and its thickness
667                // to whole rows (≥1) — a fractional 1px underline
668                // otherwise smears across two half-covered rows.
669                let top = snap_to_physical(origin_y + d.y, scale_factor);
670                let h = snap_to_physical(d.h, scale_factor).max(1.0 / scale_factor.max(1.0));
671                self.highlight_instances.push(HighlightInstance {
672                    rect: [origin_x + d.x, top, d.w, h],
673                    color: rgba_f32_in(d.color, self.working_color_space),
674                });
675            }
676            let count = self.highlight_instances.len() as u32 - first;
677            if count > 0 {
678                self.runs.push(TextRun {
679                    kind: TextRunKind::Highlight,
680                    page: 0,
681                    scissor,
682                    first,
683                    count,
684                });
685            }
686        }
687
688        runs_start..self.runs.len()
689    }
690
691    fn maybe_close_run(
692        &mut self,
693        current: &mut Option<(TextRunKind, u32, u32)>,
694        next_kind: TextRunKind,
695        next_page: u32,
696        scissor: Option<PhysicalScissor>,
697    ) {
698        let new_start = match next_kind {
699            TextRunKind::Color => self.color_instances.len() as u32,
700            TextRunKind::Msdf => self.msdf_instances.len() as u32,
701            TextRunKind::Highlight => self.highlight_instances.len() as u32,
702        };
703        let needs_close = match current {
704            Some((kind, page, _)) => !same_kind(*kind, next_kind) || *page != next_page,
705            None => false,
706        };
707        if needs_close {
708            let (kind, page, first) = current.take().unwrap();
709            let count = self.instance_count_after(kind, first);
710            if count > 0 {
711                self.runs.push(TextRun {
712                    kind,
713                    page,
714                    scissor,
715                    first,
716                    count,
717                });
718            }
719        }
720        if current.is_none() {
721            *current = Some((next_kind, next_page, new_start));
722        }
723    }
724
725    fn instance_count_after(&self, kind: TextRunKind, first: u32) -> u32 {
726        let len = match kind {
727            TextRunKind::Color => self.color_instances.len() as u32,
728            TextRunKind::Msdf => self.msdf_instances.len() as u32,
729            TextRunKind::Highlight => self.highlight_instances.len() as u32,
730        };
731        len.saturating_sub(first)
732    }
733
734    fn push_color_glyph(
735        &mut self,
736        inner: &SharedTextInner,
737        glyph: &ShapedGlyph,
738        slot: damascene_core::text::atlas::GlyphSlot,
739        origin_x: f32,
740        origin_y: f32,
741        scale_factor: f32,
742    ) {
743        // Colour-bitmap atlas slots are in physical px — the recorder
744        // ensured this glyph via `GlyphKey::at_scale`, so the bitmap's
745        // pixel bounds map 1:1 to physical pixels. Glyph positions came
746        // out of shape() in *logical* px (we shape at logical size), so
747        // divide bitmap pixel metrics by scale_factor to produce a
748        // logical-px quad.
749        //
750        // The atlas quantizes sizes to whole px (so animated sizes
751        // don't mint a bitmap per frame); scale the quad by the
752        // requested-physical/rasterized ratio so it renders at the
753        // exact requested size.
754        let physical_em = glyph.key.size() * scale_factor;
755        let ratio = if slot.raster_size > 0.0 {
756            physical_em / slot.raster_size
757        } else {
758            1.0
759        };
760        let bx = origin_x + glyph.x + slot.offset.0 as f32 * ratio / scale_factor;
761        let by = origin_y + glyph.y - slot.offset.1 as f32 * ratio / scale_factor;
762        let bw = slot.rect.w as f32 * ratio / scale_factor;
763        let bh = slot.rect.h as f32 * ratio / scale_factor;
764        let atlas_page = inner
765            .atlas
766            .page(slot.page)
767            .expect("shaped glyph references missing colour atlas page");
768        let page_w = atlas_page.width as f32;
769        let page_h = atlas_page.height as f32;
770        let uv = [
771            slot.rect.x as f32 / page_w,
772            slot.rect.y as f32 / page_h,
773            slot.rect.w as f32 / page_w,
774            slot.rect.h as f32 / page_h,
775        ];
776        let inst_color = if slot.is_color {
777            [1.0, 1.0, 1.0, 1.0]
778        } else {
779            rgba_f32_in(glyph.color, self.working_color_space)
780        };
781        self.color_instances.push(ColorGlyphInstance {
782            rect: [bx, by, bw, bh],
783            uv,
784            color: inst_color,
785        });
786    }
787
788    fn push_msdf_glyph(
789        &mut self,
790        inner: &SharedTextInner,
791        glyph: &ShapedGlyph,
792        slot: MsdfSlot,
793        origin_x: f32,
794        origin_y: f32,
795        scale_factor: f32,
796    ) {
797        // MSDF slot metrics are in **base-em pixels**. Multiply by the
798        // ratio of logical-em / base-em to get logical px.
799        let logical_em = glyph.key.size();
800        let base_em = inner.msdf_atlas.base_em() as f32;
801        let scale = logical_em / base_em;
802        let bx = origin_x + glyph.x + slot.bearing_x * scale;
803        // Snap the *baseline* to a whole device row (browsers do the
804        // same); the bearing then offsets the quad from a stable
805        // anchor, so horizontal features render identically on every
806        // line instead of each line getting its own blur phase. X
807        // stays fractional — see `snap_to_physical`.
808        let baseline = snap_to_physical(origin_y + glyph.y, scale_factor);
809        let by = baseline + slot.bearing_y * scale;
810        let bw = slot.rect.w as f32 * scale;
811        let bh = slot.rect.h as f32 * scale;
812        let atlas_page = inner
813            .msdf_atlas
814            .page(slot.page)
815            .expect("shaped glyph references missing MSDF atlas page");
816        let page_w = atlas_page.width as f32;
817        let page_h = atlas_page.height as f32;
818        let uv = [
819            slot.rect.x as f32 / page_w,
820            slot.rect.y as f32 / page_h,
821            slot.rect.w as f32 / page_w,
822            slot.rect.h as f32 / page_h,
823        ];
824        let color = rgba_f32_in(glyph.color, self.working_color_space);
825        self.msdf_instances.push(MsdfGlyphInstance {
826            rect: [bx, by, bw, bh],
827            uv,
828            color,
829            params: [slot.spread, 0.0, 0.0, 0.0],
830        });
831    }
832
833    /// Pre-rasterize printable ASCII (0x20–0x7E) for the bundled
834    /// proportional and monospace default faces (Inter Variable +
835    /// JetBrains Mono Variable). Call once at host startup to absorb
836    /// the per-glyph SDF generation cost up-front instead of having
837    /// the first frame that introduces each character pay it as a
838    /// 20-30ms paint hitch. Glyphs in MSDF are size-independent
839    /// (`MsdfGlyphKey` carries no size) but weight-dependent: variable
840    /// faces rasterize a distinct MTSDF per `wght` instance, so the
841    /// warmup covers Inter at every stock-theme weight
842    /// (400/500/600/700) and JetBrains Mono at 400 — bold code is rare
843    /// enough to rasterize lazily. Roughly ~475 rasterizations ×
844    /// ~200µs each one-time cost (or a snapshot import under
845    /// `prebaked-default-fonts`). On a shared pool ([`SharedText`])
846    /// the cost is per *device*: a second runner attached to a warm
847    /// pool finds every glyph already cached.
848    pub fn warm_default_glyphs(&mut self) {
849        self.shared.clone().lock().warm_default_glyphs();
850    }
851
852    /// Pre-rasterize a chosen set of `(family, char)` glyphs — see
853    /// [`SharedText::warm_glyphs`].
854    pub fn warm_glyphs(&mut self, families: &[FontFamily], chars: &[char]) {
855        self.shared
856            .clone()
857            .lock()
858            .warm_msdf_for_chars(chars, families);
859    }
860
861    /// Serialize the resident outline-glyph atlas into a portable
862    /// snapshot blob — see [`SharedText::export_msdf_snapshot`].
863    pub fn export_msdf_snapshot(&self) -> Vec<u8> {
864        self.shared.clone().lock().export_msdf_snapshot()
865    }
866
867    /// Load a snapshot from [`Self::export_msdf_snapshot`] — see
868    /// [`SharedText::import_msdf_snapshot`].
869    pub fn import_msdf_snapshot(&self, bytes: &[u8]) -> Result<usize, SnapshotError> {
870        self.shared.clone().lock().import_msdf_snapshot(bytes)
871    }
872
873    /// Sync atlas pages to GPU textures, snapshot their bind groups,
874    /// and upload instance data.
875    pub(crate) fn flush(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
876        {
877            let shared = self.shared.clone();
878            let mut inner = shared.lock();
879            inner.flush_pages(device, queue);
880            // Snapshot the page bind groups this window's recorded runs
881            // reference. Clones are cheap Arc bumps; holding them here
882            // keeps `render` lock-free and pins the textures for the
883            // frame even if the shared pool grows afterwards.
884            self.color_page_bgs = inner
885                .color_pages
886                .iter()
887                .map(|p| p.bind_group.clone())
888                .collect();
889            self.msdf_page_bgs = inner
890                .msdf_pages
891                .iter()
892                .map(|p| p.bind_group.clone())
893                .collect();
894        }
895
896        // Colour instance buffer.
897        if self.color_instances.len() > self.color_instance_capacity {
898            let new_cap = self.color_instances.len().next_power_of_two();
899            self.color_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
900                label: Some("damascene_wgpu::text::color_instance_buf (resized)"),
901                size: (new_cap * std::mem::size_of::<ColorGlyphInstance>()) as u64,
902                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
903                mapped_at_creation: false,
904            });
905            self.color_instance_capacity = new_cap;
906        }
907        if !self.color_instances.is_empty() {
908            queue.write_buffer(
909                &self.color_instance_buf,
910                0,
911                bytemuck::cast_slice(&self.color_instances),
912            );
913        }
914
915        // MSDF instance buffer.
916        if self.msdf_instances.len() > self.msdf_instance_capacity {
917            let new_cap = self.msdf_instances.len().next_power_of_two();
918            self.msdf_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
919                label: Some("damascene_wgpu::text::msdf_instance_buf (resized)"),
920                size: (new_cap * std::mem::size_of::<MsdfGlyphInstance>()) as u64,
921                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
922                mapped_at_creation: false,
923            });
924            self.msdf_instance_capacity = new_cap;
925        }
926        if !self.msdf_instances.is_empty() {
927            queue.write_buffer(
928                &self.msdf_instance_buf,
929                0,
930                bytemuck::cast_slice(&self.msdf_instances),
931            );
932        }
933
934        // Highlight instance buffer.
935        if self.highlight_instances.len() > self.highlight_instance_capacity {
936            let new_cap = self.highlight_instances.len().next_power_of_two();
937            self.highlight_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
938                label: Some("damascene_wgpu::text::highlight_instance_buf (resized)"),
939                size: (new_cap * std::mem::size_of::<HighlightInstance>()) as u64,
940                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
941                mapped_at_creation: false,
942            });
943            self.highlight_instance_capacity = new_cap;
944        }
945        if !self.highlight_instances.is_empty() {
946            queue.write_buffer(
947                &self.highlight_instance_buf,
948                0,
949                bytemuck::cast_slice(&self.highlight_instances),
950            );
951        }
952    }
953
954    pub(crate) fn run(&self, index: usize) -> TextRun {
955        self.runs[index]
956    }
957
958    pub(crate) fn pipeline_for(&self, kind: TextRunKind) -> &wgpu::RenderPipeline {
959        match kind {
960            TextRunKind::Color => &self.color_pipeline,
961            TextRunKind::Msdf => &self.msdf_pipeline,
962            TextRunKind::Highlight => &self.highlight_pipeline,
963        }
964    }
965
966    pub(crate) fn instance_buf_for(&self, kind: TextRunKind) -> &wgpu::Buffer {
967        match kind {
968            TextRunKind::Color => &self.color_instance_buf,
969            TextRunKind::Msdf => &self.msdf_instance_buf,
970            TextRunKind::Highlight => &self.highlight_instance_buf,
971        }
972    }
973
974    /// Page bind group for textured glyph kinds, from the per-window
975    /// snapshot taken at [`Self::flush`]. `Highlight` runs are painted
976    /// from a frame-uniform-only pipeline and have no page binding —
977    /// callers must check the run kind before invoking.
978    pub(crate) fn page_bind_group(&self, kind: TextRunKind, page: u32) -> &wgpu::BindGroup {
979        match kind {
980            TextRunKind::Color => &self.color_page_bgs[page as usize],
981            TextRunKind::Msdf => &self.msdf_page_bgs[page as usize],
982            TextRunKind::Highlight => unreachable!("highlight runs carry no page binding"),
983        }
984    }
985}
986
987impl SharedTextInner {
988    /// Mirror CPU atlas pages to GPU textures: create textures for new
989    /// pages and upload the dirty regions. Called under the pool lock
990    /// from each attached window's flush; dirty rects drain to whoever
991    /// flushes first, and the upload is queue-ordered before that
992    /// window's submit (later windows re-reference the same textures).
993    fn flush_pages(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
994        // Colour pages.
995        let color_dirty = self.atlas.take_dirty();
996        while self.color_pages.len() < self.atlas.pages().len() {
997            let i = self.color_pages.len();
998            let page = &self.atlas.pages()[i];
999            self.color_pages.push(create_color_page(
1000                device,
1001                &self.color_page_bind_layout,
1002                &self.color_sampler,
1003                page.width,
1004                page.height,
1005            ));
1006        }
1007        for (page_idx, rect) in color_dirty {
1008            let page = &self.atlas.pages()[page_idx];
1009            upload_color_region(queue, &self.color_pages[page_idx].texture, page, rect);
1010        }
1011
1012        // MSDF pages.
1013        let msdf_dirty = self.msdf_atlas.take_dirty();
1014        while self.msdf_pages.len() < self.msdf_atlas.pages().len() {
1015            let i = self.msdf_pages.len();
1016            let page = &self.msdf_atlas.pages()[i];
1017            self.msdf_pages.push(create_msdf_page(
1018                device,
1019                &self.msdf_page_bind_layout,
1020                &self.msdf_sampler,
1021                page.width,
1022                page.height,
1023            ));
1024        }
1025        for (page_idx, rect) in msdf_dirty {
1026            let page = &self.msdf_atlas.pages()[page_idx];
1027            upload_msdf_region(queue, &self.msdf_pages[page_idx].texture, page, rect);
1028        }
1029    }
1030
1031    /// See [`TextPaint::warm_default_glyphs`].
1032    pub(crate) fn warm_default_glyphs(&mut self) {
1033        // With `prebaked-default-fonts`, load the compile-time-baked atlas
1034        // instead of regenerating glyphs. Falls through to live warmup if
1035        // the snapshot is empty, stale, or its fonts aren't loaded here.
1036        #[cfg(feature = "prebaked-default-fonts")]
1037        if self.warm_from_prebaked() {
1038            return;
1039        }
1040        let chars: Vec<char> = (0x20u32..=0x7Eu32).filter_map(char::from_u32).collect();
1041        // Inter renders at every stock role weight from the first
1042        // frame (Regular body, Medium labels, Semibold titles, Bold
1043        // display); mono bold is rare, so it warms lazily.
1044        self.warm_msdf_weights(&chars, &[FontFamily::Inter], STOCK_WEIGHTS);
1045        self.warm_msdf_weights(&chars, &[FontFamily::JetBrainsMono], &[400]);
1046    }
1047
1048    /// Import the compile-time-baked default-font atlas. Returns `true`
1049    /// if at least one glyph was loaded (warmup is then complete);
1050    /// `false` if nothing applied, so the caller generates live.
1051    #[cfg(feature = "prebaked-default-fonts")]
1052    fn warm_from_prebaked(&mut self) -> bool {
1053        use damascene_core::prebaked::{DEFAULT_ATLAS, TOKEN_INTER, TOKEN_JETBRAINS_MONO};
1054        let inter = self.resolve_family_font_id(FontFamily::Inter);
1055        let mono = self.resolve_family_font_id(FontFamily::JetBrainsMono);
1056        let id_of = |token| match token {
1057            TOKEN_INTER => inter,
1058            TOKEN_JETBRAINS_MONO => mono,
1059            _ => None,
1060        };
1061        matches!(self.msdf_atlas.import_snapshot(DEFAULT_ATLAS, id_of), Ok(n) if n > 0)
1062    }
1063
1064    /// Resolve a [`FontFamily`] to the first matching `fontdb::ID` at
1065    /// `Weight::NORMAL` (a variable family is one face for every
1066    /// weight; per-weight rasterization happens via `wght` variation,
1067    /// not face selection).
1068    fn resolve_family_font_id(&self, family: FontFamily) -> Option<fontdb::ID> {
1069        self.atlas.font_system().db().query(&fontdb::Query {
1070            families: &[fontdb::Family::Name(family.family_name())],
1071            weight: fontdb::Weight::NORMAL,
1072            ..fontdb::Query::default()
1073        })
1074    }
1075
1076    /// Pre-rasterize the MSDF for each `(family, char)` pair at every
1077    /// stock-theme weight. Variable faces rasterize one MTSDF per
1078    /// `wght` instance; static faces normalize every weight to the
1079    /// single default-instance bucket, so their extra passes fall out
1080    /// as residency hits in `ensure_many`.
1081    pub(crate) fn warm_msdf_for_chars(&mut self, chars: &[char], families: &[FontFamily]) {
1082        self.warm_msdf_weights(chars, families, STOCK_WEIGHTS);
1083    }
1084
1085    /// [`Self::warm_msdf_for_chars`] with an explicit `wght` list —
1086    /// used by the default warmup to warm Inter at every stock weight
1087    /// but the mono face at Regular only.
1088    pub(crate) fn warm_msdf_weights(
1089        &mut self,
1090        chars: &[char],
1091        families: &[FontFamily],
1092        weights: &[u16],
1093    ) {
1094        for family in families {
1095            let Some(font_id) = self.resolve_family_font_id(*family) else {
1096                continue;
1097            };
1098            let face_index = self
1099                .atlas
1100                .font_system()
1101                .db()
1102                .face(font_id)
1103                .map(|f| f.index)
1104                .unwrap_or(0);
1105            let Some(font) = self
1106                .atlas
1107                .font_system_mut()
1108                .get_font(font_id, fontdb::Weight::NORMAL)
1109            else {
1110                continue;
1111            };
1112            for &weight in weights {
1113                let Ok(mut face) = Face::parse(font.data(), face_index) else {
1114                    continue;
1115                };
1116                let applied = apply_weight_variation(&mut face, weight);
1117                let keys: Vec<MsdfGlyphKey> = chars
1118                    .iter()
1119                    .filter_map(|&ch| face.glyph_index(ch))
1120                    .map(|glyph_id| MsdfGlyphKey {
1121                        font: font_id,
1122                        glyph_id: glyph_id.0,
1123                        weight: applied,
1124                    })
1125                    .collect();
1126                // Batched so the `parallel-raster` feature can rasterize
1127                // the whole family's glyphs across rayon's pool in one
1128                // shot; serial otherwise. Packing stays on this thread.
1129                // Already-resident keys (e.g. a static face's repeated
1130                // weight-0 bucket) are skipped inside.
1131                self.msdf_atlas.ensure_many(&keys, &face);
1132            }
1133        }
1134    }
1135
1136    /// Export resident outline glyphs as a portable snapshot, keyed by a
1137    /// content hash of each font's bytes so it reloads across runs
1138    /// regardless of font-load order. See [`TextPaint::export_msdf_snapshot`].
1139    pub(crate) fn export_msdf_snapshot(&self) -> Vec<u8> {
1140        let db = self.atlas.font_system().db();
1141        self.msdf_atlas
1142            .export_snapshot(|id| db.with_face_data(id, |data, _| font_token_hash(data)))
1143    }
1144
1145    /// Import a content-hash-keyed snapshot, resolving each section's font
1146    /// against those currently loaded. See [`TextPaint::import_msdf_snapshot`].
1147    pub(crate) fn import_msdf_snapshot(&mut self, bytes: &[u8]) -> Result<usize, SnapshotError> {
1148        // Map every loaded face's content hash to its runtime id, then
1149        // resolve the snapshot's tokens through it. Hashing is once-per
1150        // import over a handful of registered fonts.
1151        let by_hash: std::collections::HashMap<u64, fontdb::ID> = {
1152            let db = self.atlas.font_system().db();
1153            db.faces()
1154                .filter_map(|f| {
1155                    db.with_face_data(f.id, |data, _| font_token_hash(data))
1156                        .map(|h| (h, f.id))
1157                })
1158                .collect()
1159        };
1160        self.msdf_atlas
1161            .import_snapshot(bytes, |t| by_hash.get(&t).copied())
1162    }
1163}
1164
1165/// Resident-or-rasterize for one MSDF glyph against the shared pool.
1166fn ensure_msdf(
1167    inner: &mut SharedTextInner,
1168    key: MsdfGlyphKey,
1169    font_id: fontdb::ID,
1170    weight: fontdb::Weight,
1171) -> Option<MsdfSlot> {
1172    // touch (rather than slot) stamps the page as used this frame
1173    // so the LRU page recycler skips it.
1174    if let Some(slot) = inner.msdf_atlas.touch(key) {
1175        return Some(slot);
1176    }
1177    // Look up font bytes + face index, parse a ttf-parser Face,
1178    // then ask MsdfAtlas to rasterize. We can't borrow font_system
1179    // mutably (for get_font) and immutably (for db().face()) at
1180    // once, so we hop: get_font yields an Arc that owns the bytes,
1181    // then a separate immutable borrow for the face_index lookup.
1182    let font = inner.atlas.font_system_mut().get_font(font_id, weight)?;
1183    let face_index = inner.atlas.font_system().db().face(font_id)?.index;
1184    let mut face = Face::parse(font.data(), face_index).ok()?;
1185    // key.weight is the normalized wght instance for this raster
1186    // (`GlyphAtlas::msdf_raster_weight`) — activate it on the face so
1187    // fdsm extracts the matching outlines instead of the default
1188    // (Regular) instance.
1189    let _applied = apply_weight_variation(&mut face, key.weight);
1190    debug_assert_eq!(
1191        _applied, key.weight,
1192        "normalized key weight must be applicable to its face"
1193    );
1194    inner.msdf_atlas.ensure(key, &face)
1195}
1196
1197fn same_kind(a: TextRunKind, b: TextRunKind) -> bool {
1198    a == b
1199}
1200
1201/// Build the colour-bitmap (`stock::text`) pipeline. Shared by `new` and
1202/// `set_target_format` so the descriptor stays a single source of truth —
1203/// only `target_format` varies across the two call sites.
1204fn build_color_pipeline(
1205    device: &wgpu::Device,
1206    layout: &wgpu::PipelineLayout,
1207    target_format: wgpu::TextureFormat,
1208    sample_count: u32,
1209) -> wgpu::RenderPipeline {
1210    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1211        label: Some("stock::text"),
1212        source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(stock_wgsl::TEXT)),
1213    });
1214    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1215        label: Some("damascene_wgpu::text::color_pipeline"),
1216        layout: Some(layout),
1217        vertex: wgpu::VertexState {
1218            module: &shader,
1219            entry_point: Some("vs_main"),
1220            compilation_options: Default::default(),
1221            buffers: &[
1222                Some(wgpu::VertexBufferLayout {
1223                    array_stride: (2 * std::mem::size_of::<f32>()) as u64,
1224                    step_mode: wgpu::VertexStepMode::Vertex,
1225                    attributes: &[wgpu::VertexAttribute {
1226                        shader_location: 0,
1227                        format: wgpu::VertexFormat::Float32x2,
1228                        offset: 0,
1229                    }],
1230                }),
1231                Some(wgpu::VertexBufferLayout {
1232                    array_stride: std::mem::size_of::<ColorGlyphInstance>() as u64,
1233                    step_mode: wgpu::VertexStepMode::Instance,
1234                    attributes: &COLOR_INSTANCE_ATTRS,
1235                }),
1236            ],
1237        },
1238        fragment: Some(wgpu::FragmentState {
1239            module: &shader,
1240            entry_point: Some("fs_main"),
1241            compilation_options: Default::default(),
1242            targets: &[Some(wgpu::ColorTargetState {
1243                format: target_format,
1244                blend: Some(premultiplied_blend()),
1245                write_mask: wgpu::ColorWrites::ALL,
1246            })],
1247        }),
1248        primitive: triangle_strip(),
1249        depth_stencil: None,
1250        multisample: wgpu::MultisampleState {
1251            count: sample_count,
1252            mask: !0,
1253            alpha_to_coverage_enabled: false,
1254        },
1255        multiview_mask: None,
1256        cache: None,
1257    })
1258}
1259
1260/// Build the MSDF outline (`stock::text_msdf`) pipeline. See
1261/// [`build_color_pipeline`] for the new/set_target_format sharing rationale.
1262fn build_msdf_pipeline(
1263    device: &wgpu::Device,
1264    layout: &wgpu::PipelineLayout,
1265    target_format: wgpu::TextureFormat,
1266    sample_count: u32,
1267) -> wgpu::RenderPipeline {
1268    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1269        label: Some("stock::text_msdf"),
1270        source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(stock_wgsl::TEXT_MSDF)),
1271    });
1272    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1273        label: Some("damascene_wgpu::text::msdf_pipeline"),
1274        layout: Some(layout),
1275        vertex: wgpu::VertexState {
1276            module: &shader,
1277            entry_point: Some("vs_main"),
1278            compilation_options: Default::default(),
1279            buffers: &[
1280                Some(wgpu::VertexBufferLayout {
1281                    array_stride: (2 * std::mem::size_of::<f32>()) as u64,
1282                    step_mode: wgpu::VertexStepMode::Vertex,
1283                    attributes: &[wgpu::VertexAttribute {
1284                        shader_location: 0,
1285                        format: wgpu::VertexFormat::Float32x2,
1286                        offset: 0,
1287                    }],
1288                }),
1289                Some(wgpu::VertexBufferLayout {
1290                    array_stride: std::mem::size_of::<MsdfGlyphInstance>() as u64,
1291                    step_mode: wgpu::VertexStepMode::Instance,
1292                    attributes: &MSDF_INSTANCE_ATTRS,
1293                }),
1294            ],
1295        },
1296        fragment: Some(wgpu::FragmentState {
1297            module: &shader,
1298            entry_point: Some("fs_main"),
1299            compilation_options: Default::default(),
1300            targets: &[Some(wgpu::ColorTargetState {
1301                format: target_format,
1302                blend: Some(premultiplied_blend()),
1303                write_mask: wgpu::ColorWrites::ALL,
1304            })],
1305        }),
1306        primitive: triangle_strip(),
1307        depth_stencil: None,
1308        multisample: wgpu::MultisampleState {
1309            count: sample_count,
1310            mask: !0,
1311            alpha_to_coverage_enabled: false,
1312        },
1313        multiview_mask: None,
1314        cache: None,
1315    })
1316}
1317
1318/// Build the inline-run highlight (`stock::text_highlight`) pipeline. See
1319/// [`build_color_pipeline`] for the new/set_target_format sharing rationale.
1320fn build_highlight_pipeline(
1321    device: &wgpu::Device,
1322    layout: &wgpu::PipelineLayout,
1323    target_format: wgpu::TextureFormat,
1324    sample_count: u32,
1325) -> wgpu::RenderPipeline {
1326    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1327        label: Some("stock::text_highlight"),
1328        source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(stock_wgsl::TEXT_HIGHLIGHT)),
1329    });
1330    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1331        label: Some("damascene_wgpu::text::highlight_pipeline"),
1332        layout: Some(layout),
1333        vertex: wgpu::VertexState {
1334            module: &shader,
1335            entry_point: Some("vs_main"),
1336            compilation_options: Default::default(),
1337            buffers: &[
1338                Some(wgpu::VertexBufferLayout {
1339                    array_stride: (2 * std::mem::size_of::<f32>()) as u64,
1340                    step_mode: wgpu::VertexStepMode::Vertex,
1341                    attributes: &[wgpu::VertexAttribute {
1342                        shader_location: 0,
1343                        format: wgpu::VertexFormat::Float32x2,
1344                        offset: 0,
1345                    }],
1346                }),
1347                Some(wgpu::VertexBufferLayout {
1348                    array_stride: std::mem::size_of::<HighlightInstance>() as u64,
1349                    step_mode: wgpu::VertexStepMode::Instance,
1350                    attributes: &HIGHLIGHT_INSTANCE_ATTRS,
1351                }),
1352            ],
1353        },
1354        fragment: Some(wgpu::FragmentState {
1355            module: &shader,
1356            entry_point: Some("fs_main"),
1357            compilation_options: Default::default(),
1358            targets: &[Some(wgpu::ColorTargetState {
1359                format: target_format,
1360                blend: Some(premultiplied_blend()),
1361                write_mask: wgpu::ColorWrites::ALL,
1362            })],
1363        }),
1364        primitive: triangle_strip(),
1365        depth_stencil: None,
1366        multisample: wgpu::MultisampleState {
1367            count: sample_count,
1368            mask: !0,
1369            alpha_to_coverage_enabled: false,
1370        },
1371        multiview_mask: None,
1372        cache: None,
1373    })
1374}
1375
1376fn premultiplied_blend() -> wgpu::BlendState {
1377    wgpu::BlendState {
1378        color: wgpu::BlendComponent {
1379            src_factor: wgpu::BlendFactor::One,
1380            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
1381            operation: wgpu::BlendOperation::Add,
1382        },
1383        alpha: wgpu::BlendComponent {
1384            src_factor: wgpu::BlendFactor::One,
1385            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
1386            operation: wgpu::BlendOperation::Add,
1387        },
1388    }
1389}
1390
1391fn triangle_strip() -> wgpu::PrimitiveState {
1392    wgpu::PrimitiveState {
1393        topology: wgpu::PrimitiveTopology::TriangleStrip,
1394        strip_index_format: None,
1395        front_face: wgpu::FrontFace::Ccw,
1396        cull_mode: None,
1397        polygon_mode: wgpu::PolygonMode::Fill,
1398        unclipped_depth: false,
1399        conservative: false,
1400    }
1401}
1402
1403fn create_color_page(
1404    device: &wgpu::Device,
1405    layout: &wgpu::BindGroupLayout,
1406    sampler: &wgpu::Sampler,
1407    width: u32,
1408    height: u32,
1409) -> PageTexture {
1410    let texture = device.create_texture(&wgpu::TextureDescriptor {
1411        label: Some("damascene_wgpu::text::color_page"),
1412        size: wgpu::Extent3d {
1413            width,
1414            height,
1415            depth_or_array_layers: 1,
1416        },
1417        mip_level_count: 1,
1418        sample_count: 1,
1419        dimension: wgpu::TextureDimension::D2,
1420        format: wgpu::TextureFormat::Rgba8UnormSrgb,
1421        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1422        view_formats: &[],
1423    });
1424    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1425    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1426        label: Some("damascene_wgpu::text::color_page_bg"),
1427        layout,
1428        entries: &[
1429            wgpu::BindGroupEntry {
1430                binding: 0,
1431                resource: wgpu::BindingResource::TextureView(&view),
1432            },
1433            wgpu::BindGroupEntry {
1434                binding: 1,
1435                resource: wgpu::BindingResource::Sampler(sampler),
1436            },
1437        ],
1438    });
1439    PageTexture {
1440        texture,
1441        bind_group,
1442    }
1443}
1444
1445fn create_msdf_page(
1446    device: &wgpu::Device,
1447    layout: &wgpu::BindGroupLayout,
1448    sampler: &wgpu::Sampler,
1449    width: u32,
1450    height: u32,
1451) -> PageTexture {
1452    let texture = device.create_texture(&wgpu::TextureDescriptor {
1453        label: Some("damascene_wgpu::text::msdf_page"),
1454        size: wgpu::Extent3d {
1455            width,
1456            height,
1457            depth_or_array_layers: 1,
1458        },
1459        mip_level_count: 1,
1460        sample_count: 1,
1461        dimension: wgpu::TextureDimension::D2,
1462        // MSDF distance encodes per-channel; storing them in a *linear*
1463        // texture avoids the sRGB EOTF being applied to distance bytes.
1464        format: wgpu::TextureFormat::Rgba8Unorm,
1465        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1466        view_formats: &[],
1467    });
1468    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1469    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1470        label: Some("damascene_wgpu::text::msdf_page_bg"),
1471        layout,
1472        entries: &[
1473            wgpu::BindGroupEntry {
1474                binding: 0,
1475                resource: wgpu::BindingResource::TextureView(&view),
1476            },
1477            wgpu::BindGroupEntry {
1478                binding: 1,
1479                resource: wgpu::BindingResource::Sampler(sampler),
1480            },
1481        ],
1482    });
1483    PageTexture {
1484        texture,
1485        bind_group,
1486    }
1487}
1488
1489impl TextRecorder for TextPaint {
1490    fn record(
1491        &mut self,
1492        rect: Rect,
1493        scissor: Option<PhysicalScissor>,
1494        style: &RunStyle,
1495        text: &str,
1496        size: f32,
1497        line_height: f32,
1498        wrap: TextWrap,
1499        anchor: TextAnchor,
1500        scale_factor: f32,
1501    ) -> std::ops::Range<usize> {
1502        self.record_inner(
1503            rect,
1504            scissor,
1505            &[(text.to_string(), style.clone())],
1506            size,
1507            line_height,
1508            wrap,
1509            anchor,
1510            scale_factor,
1511        )
1512    }
1513
1514    fn record_runs(
1515        &mut self,
1516        rect: Rect,
1517        scissor: Option<PhysicalScissor>,
1518        runs: &[(String, RunStyle)],
1519        size: f32,
1520        line_height: f32,
1521        wrap: TextWrap,
1522        anchor: TextAnchor,
1523        scale_factor: f32,
1524    ) -> std::ops::Range<usize> {
1525        self.record_inner(
1526            rect,
1527            scissor,
1528            runs,
1529            size,
1530            line_height,
1531            wrap,
1532            anchor,
1533            scale_factor,
1534        )
1535    }
1536}
1537
1538fn wrap_available_width(
1539    rect_w: f32,
1540    _scale_factor: f32,
1541    wrap: TextWrap,
1542    anchor: TextAnchor,
1543) -> Option<f32> {
1544    // We shape at logical px now, so the available width is logical
1545    // too — no scale_factor multiplication.
1546    match (wrap, anchor) {
1547        (TextWrap::Wrap, _) => Some(rect_w),
1548        (TextWrap::NoWrap, TextAnchor::Start) => None,
1549        (TextWrap::NoWrap, TextAnchor::Middle | TextAnchor::End) => Some(rect_w),
1550    }
1551}
1552
1553fn upload_color_region(
1554    queue: &wgpu::Queue,
1555    texture: &wgpu::Texture,
1556    page: &AtlasPage,
1557    rect: AtlasRect,
1558) {
1559    if rect.w == 0 || rect.h == 0 {
1560        return;
1561    }
1562    let bpp = ATLAS_BYTES_PER_PIXEL as usize;
1563    let row_bytes = rect.w as usize * bpp;
1564    let mut bytes = Vec::with_capacity(row_bytes * rect.h as usize);
1565    for row in 0..rect.h {
1566        let y = rect.y + row;
1567        let start = (y as usize * page.width as usize + rect.x as usize) * bpp;
1568        let end = start + row_bytes;
1569        bytes.extend_from_slice(&page.pixels[start..end]);
1570    }
1571    queue.write_texture(
1572        wgpu::TexelCopyTextureInfo {
1573            texture,
1574            mip_level: 0,
1575            origin: wgpu::Origin3d {
1576                x: rect.x,
1577                y: rect.y,
1578                z: 0,
1579            },
1580            aspect: wgpu::TextureAspect::All,
1581        },
1582        &bytes,
1583        wgpu::TexelCopyBufferLayout {
1584            offset: 0,
1585            bytes_per_row: Some(rect.w * ATLAS_BYTES_PER_PIXEL),
1586            rows_per_image: Some(rect.h),
1587        },
1588        wgpu::Extent3d {
1589            width: rect.w,
1590            height: rect.h,
1591            depth_or_array_layers: 1,
1592        },
1593    );
1594}
1595
1596fn upload_msdf_region(
1597    queue: &wgpu::Queue,
1598    texture: &wgpu::Texture,
1599    page: &MsdfAtlasPage,
1600    rect: MsdfRect,
1601) {
1602    if rect.w == 0 || rect.h == 0 {
1603        return;
1604    }
1605    let bpp = MSDF_BYTES_PER_PIXEL as usize;
1606    let row_bytes = rect.w as usize * bpp;
1607    let mut bytes = Vec::with_capacity(row_bytes * rect.h as usize);
1608    for row in 0..rect.h {
1609        let y = rect.y + row;
1610        let start = (y as usize * page.width as usize + rect.x as usize) * bpp;
1611        let end = start + row_bytes;
1612        bytes.extend_from_slice(&page.pixels[start..end]);
1613    }
1614    queue.write_texture(
1615        wgpu::TexelCopyTextureInfo {
1616            texture,
1617            mip_level: 0,
1618            origin: wgpu::Origin3d {
1619                x: rect.x,
1620                y: rect.y,
1621                z: 0,
1622            },
1623            aspect: wgpu::TextureAspect::All,
1624        },
1625        &bytes,
1626        wgpu::TexelCopyBufferLayout {
1627            offset: 0,
1628            bytes_per_row: Some(rect.w * MSDF_BYTES_PER_PIXEL),
1629            rows_per_image: Some(rect.h),
1630        },
1631        wgpu::Extent3d {
1632            width: rect.w,
1633            height: rect.h,
1634            depth_or_array_layers: 1,
1635        },
1636    );
1637}