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