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