onedrop_renderer/
text_pipeline.rs

1//! Text overlay (§6) GPU pipeline.
2//!
3//! Renders `MILK_MSG.INI` message strings as 2D glyph quads atop the
4//! comp output. Glyphs are rasterised on-demand by `ab_glyph` into a
5//! shared `Rgba8Unorm` atlas (alpha stored in `.r`), and the pipeline
6//! emits one draw per frame from a dynamic vertex buffer holding every
7//! glyph of every active message.
8//!
9//! Two bundled fonts come from `epaint_default_fonts`:
10//! - **0 (default)**: `Ubuntu-Light` — clean sans the MD2 default
11//!   message font (`Arial`) maps to.
12//! - **1 (mono)**: `Hack-Regular` — covers presets that ask for a
13//!   monospace face. Mapping ignores bold/italic; MD2 only exposes
14//!   those as `font=...; bold=1` toggles and most preset packs author
15//!   for the default sans anyway.
16//!
17//! The atlas grows append-only inside a fixed `1024×1024` texture; if
18//! a glyph doesn't fit, the renderer falls back to the existing slot
19//! pool (logged once). 1024² is enough for a few thousand glyphs at
20//! `32 px` body size — well above what `MILK_MSG.INI` files use in the
21//! wild (≤ 8 messages).
22
23use std::collections::HashMap;
24
25use ab_glyph::{Font, FontArc, FontVec, Glyph, PxScale, ScaleFont, point};
26use bytemuck::{Pod, Zeroable};
27
28use crate::pipeline_helpers::load_wgsl;
29
30/// Atlas texture side (square). 1024² fits ~3K glyphs at 32px body —
31/// well above typical `MILK_MSG.INI` usage. The atlas grows
32/// append-only inside this footprint; once full, new glyph requests
33/// fall back to existing nearest-size slots.
34pub const TEXT_ATLAS_SIZE: u32 = 1024;
35
36/// Per-glyph vertex pushed to the GPU vertex buffer.
37#[repr(C)]
38#[derive(Debug, Clone, Copy, Default)]
39pub struct TextQuadVertex {
40    /// Clip-space position (`[-1, 1]` per axis). CPU side does the
41    /// `[0, 1]` screen → clip + aspect-correction so the vertex
42    /// shader stays a pass-through.
43    pub pos_clip: [f32; 2],
44    /// Atlas UV (`[0, 1]`).
45    pub uv: [f32; 2],
46    /// Tint colour (`r, g, b, a`). The fragment shader multiplies the
47    /// sampled atlas alpha by `a` to produce a premultiplied output.
48    pub rgba: [f32; 4],
49}
50
51unsafe impl Pod for TextQuadVertex {}
52unsafe impl Zeroable for TextQuadVertex {}
53
54/// One message the engine asks the renderer to draw this frame. POD —
55/// the text/layout/font lookup all happens host-side in
56/// [`TextPipeline::record`].
57#[derive(Debug, Clone)]
58pub struct TextFrame {
59    pub text: String,
60    /// `0` = Ubuntu-Light (default), `1` = Hack mono. Anything else
61    /// snaps to `0`.
62    pub font: u32,
63    /// Body size in pixels of render height. The CPU layout step
64    /// turns this into an `ab_glyph::PxScale`.
65    pub size_px: f32,
66    /// Anchor centre in `[0, 1]` screen coords (top-left origin).
67    pub x: f32,
68    pub y: f32,
69    /// Tint colour. Alpha drives the fade phase (engine-side).
70    pub rgba: [f32; 4],
71}
72
73/// One rasterised glyph's slot in the atlas.
74#[derive(Debug, Clone, Copy)]
75struct GlyphSlot {
76    /// Atlas UV bounds.
77    uv_min: [f32; 2],
78    uv_max: [f32; 2],
79    /// Rasterised pixel dimensions inside the atlas.
80    px_w: u32,
81    px_h: u32,
82    /// Pixel offsets from the glyph's pen origin to the top-left
83    /// corner of the rasterised cell. Equivalent to FreeType's
84    /// `(bearing_x, -bearing_y)`. Used at layout time to position the
85    /// quad against the line's baseline.
86    bearing_x: f32,
87    bearing_y: f32,
88    /// Horizontal advance for the next glyph on the same line, in px.
89    advance: f32,
90}
91
92/// Cache key for one rasterised glyph.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94struct GlyphKey {
95    font: u32,
96    /// `ab_glyph` uses `f32` for px size but maps it to an integer
97    /// PxScale internally; we quantise so the cache hit-rate stays high.
98    /// Half-pixel steps give plenty of fidelity at the sizes MD2 uses
99    /// (`16..80`).
100    size_q: u16,
101    glyph_id: u16,
102}
103
104/// GPU + CPU text atlas. Owned by [`TextPipeline`].
105pub struct TextAtlas {
106    texture: wgpu::Texture,
107    view: wgpu::TextureView,
108    sampler: wgpu::Sampler,
109    width: u32,
110    height: u32,
111    /// CPU-side shadow of the atlas pixels — keeps the atlas
112    /// resident as a single Rgba8 buffer so partial updates don't
113    /// require GPU-side readback. Alpha lands in `.r`, other channels
114    /// stay zero.
115    cpu_pixels: Vec<u8>,
116    /// Next free pixel inside the current row.
117    cursor_x: u32,
118    /// Top of the current row.
119    cursor_y: u32,
120    /// Height of the tallest glyph placed in the current row — used
121    /// to advance `cursor_y` when the row fills up.
122    row_h: u32,
123    glyphs: HashMap<GlyphKey, GlyphSlot>,
124    fonts: Vec<FontArc>,
125    /// Set when [`Self::ensure_glyph`] grew the CPU pixel buffer.
126    /// Drives the queue.write_texture call on the next render.
127    dirty: bool,
128}
129
130impl TextAtlas {
131    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
132        let width = TEXT_ATLAS_SIZE;
133        let height = TEXT_ATLAS_SIZE;
134        let texture = device.create_texture(&wgpu::TextureDescriptor {
135            label: Some("Text Atlas"),
136            size: wgpu::Extent3d {
137                width,
138                height,
139                depth_or_array_layers: 1,
140            },
141            mip_level_count: 1,
142            sample_count: 1,
143            dimension: wgpu::TextureDimension::D2,
144            format: wgpu::TextureFormat::Rgba8Unorm,
145            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
146            view_formats: &[],
147        });
148        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
149        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
150            label: Some("Text Atlas Sampler"),
151            address_mode_u: wgpu::AddressMode::ClampToEdge,
152            address_mode_v: wgpu::AddressMode::ClampToEdge,
153            mag_filter: wgpu::FilterMode::Linear,
154            min_filter: wgpu::FilterMode::Linear,
155            ..Default::default()
156        });
157        let cpu_pixels = vec![0u8; (width * height * 4) as usize];
158        // Upload an initial zero buffer so a render with no glyphs
159        // still has defined atlas contents (avoids undefined-read
160        // warnings in validation layers).
161        queue.write_texture(
162            wgpu::TexelCopyTextureInfo {
163                texture: &texture,
164                mip_level: 0,
165                origin: wgpu::Origin3d::ZERO,
166                aspect: wgpu::TextureAspect::All,
167            },
168            &cpu_pixels,
169            wgpu::TexelCopyBufferLayout {
170                offset: 0,
171                bytes_per_row: Some(width * 4),
172                rows_per_image: Some(height),
173            },
174            wgpu::Extent3d {
175                width,
176                height,
177                depth_or_array_layers: 1,
178            },
179        );
180
181        let fonts = bundled_fonts();
182        Self {
183            texture,
184            view,
185            sampler,
186            width,
187            height,
188            cpu_pixels,
189            cursor_x: 1,
190            cursor_y: 1,
191            row_h: 0,
192            glyphs: HashMap::new(),
193            fonts,
194            dirty: false,
195        }
196    }
197
198    /// Rasterise a glyph into the atlas if not already cached. Returns
199    /// the cached slot — or `None` if rasterising failed (out-of-atlas
200    /// or zero-extent glyph like a space).
201    fn ensure_glyph(
202        &mut self,
203        font: u32,
204        size_px: f32,
205        glyph_id: u16,
206        ch: char,
207    ) -> Option<GlyphSlot> {
208        let font_idx = (font as usize).min(self.fonts.len().saturating_sub(1));
209        let font_arc = self.fonts.get(font_idx)?.clone();
210        let size_q = (size_px * 2.0).round().clamp(8.0, 4096.0) as u16;
211        let key = GlyphKey {
212            font: font_idx as u32,
213            size_q,
214            glyph_id,
215        };
216        if let Some(slot) = self.glyphs.get(&key) {
217            return Some(*slot);
218        }
219
220        let scale = PxScale::from(size_px.max(8.0));
221        let scaled = font_arc.as_scaled(scale);
222        let glyph: Glyph = font_arc
223            .glyph_id(ch)
224            .with_scale_and_position(scale, point(0.0, 0.0));
225        let advance = scaled.h_advance(glyph.id);
226        let v_metrics = scaled.ascent();
227        let Some(outlined) = font_arc.outline_glyph(glyph) else {
228            // Whitespace / no outline — cache an empty slot so the
229            // layout step still walks the advance.
230            let slot = GlyphSlot {
231                uv_min: [0.0, 0.0],
232                uv_max: [0.0, 0.0],
233                px_w: 0,
234                px_h: 0,
235                bearing_x: 0.0,
236                bearing_y: v_metrics,
237                advance,
238            };
239            self.glyphs.insert(key, slot);
240            return Some(slot);
241        };
242
243        let bbox = outlined.px_bounds();
244        let w = bbox.width().ceil() as u32;
245        let h = bbox.height().ceil() as u32;
246        if w == 0 || h == 0 {
247            let slot = GlyphSlot {
248                uv_min: [0.0, 0.0],
249                uv_max: [0.0, 0.0],
250                px_w: 0,
251                px_h: 0,
252                bearing_x: 0.0,
253                bearing_y: v_metrics,
254                advance,
255            };
256            self.glyphs.insert(key, slot);
257            return Some(slot);
258        }
259
260        // Advance the atlas cursor; wrap to a new row if needed.
261        if self.cursor_x + w + 1 > self.width {
262            self.cursor_x = 1;
263            self.cursor_y += self.row_h + 1;
264            self.row_h = 0;
265        }
266        if self.cursor_y + h + 1 > self.height {
267            log::warn!("text atlas exhausted; new glyph dropped");
268            // Reuse the topmost empty slot — produces a square placeholder.
269            let slot = GlyphSlot {
270                uv_min: [0.0, 0.0],
271                uv_max: [0.0, 0.0],
272                px_w: 0,
273                px_h: 0,
274                bearing_x: 0.0,
275                bearing_y: v_metrics,
276                advance,
277            };
278            self.glyphs.insert(key, slot);
279            return Some(slot);
280        }
281
282        let dst_x = self.cursor_x;
283        let dst_y = self.cursor_y;
284        outlined.draw(|gx, gy, cov| {
285            let px = dst_x + gx;
286            let py = dst_y + gy;
287            if px < self.width && py < self.height {
288                let i = ((py * self.width + px) * 4) as usize;
289                let a = (cov.clamp(0.0, 1.0) * 255.0) as u8;
290                self.cpu_pixels[i] = a.max(self.cpu_pixels[i]);
291            }
292        });
293        self.cursor_x += w + 1;
294        self.row_h = self.row_h.max(h);
295        self.dirty = true;
296
297        let inv_w = 1.0 / self.width as f32;
298        let inv_h = 1.0 / self.height as f32;
299        let slot = GlyphSlot {
300            uv_min: [dst_x as f32 * inv_w, dst_y as f32 * inv_h],
301            uv_max: [(dst_x + w) as f32 * inv_w, (dst_y + h) as f32 * inv_h],
302            px_w: w,
303            px_h: h,
304            bearing_x: bbox.min.x,
305            bearing_y: bbox.min.y + v_metrics,
306            advance,
307        };
308        self.glyphs.insert(key, slot);
309        Some(slot)
310    }
311
312    /// Push any pending atlas-pixel updates to the GPU. Cheap when
313    /// `dirty == false` (no-op, common case after warm-up).
314    fn flush(&mut self, queue: &wgpu::Queue) {
315        if !self.dirty {
316            return;
317        }
318        queue.write_texture(
319            wgpu::TexelCopyTextureInfo {
320                texture: &self.texture,
321                mip_level: 0,
322                origin: wgpu::Origin3d::ZERO,
323                aspect: wgpu::TextureAspect::All,
324            },
325            &self.cpu_pixels,
326            wgpu::TexelCopyBufferLayout {
327                offset: 0,
328                bytes_per_row: Some(self.width * 4),
329                rows_per_image: Some(self.height),
330            },
331            wgpu::Extent3d {
332                width: self.width,
333                height: self.height,
334                depth_or_array_layers: 1,
335            },
336        );
337        self.dirty = false;
338    }
339
340    /// Number of glyphs currently cached. Test surface.
341    pub fn glyph_count(&self) -> usize {
342        self.glyphs.len()
343    }
344}
345
346/// Text GPU pipeline. One render pipeline + a growable vertex buffer.
347pub struct TextPipeline {
348    pipeline: wgpu::RenderPipeline,
349    bgl: wgpu::BindGroupLayout,
350    vertex_buffer: wgpu::Buffer,
351    vertex_capacity: u64,
352    atlas: TextAtlas,
353}
354
355impl TextPipeline {
356    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
357        let shader = load_wgsl(device, "Text Shader", include_str!("../shaders/text.wgsl"));
358
359        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
360            label: Some("Text BGL"),
361            entries: &[
362                wgpu::BindGroupLayoutEntry {
363                    binding: 0,
364                    visibility: wgpu::ShaderStages::FRAGMENT,
365                    ty: wgpu::BindingType::Texture {
366                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
367                        view_dimension: wgpu::TextureViewDimension::D2,
368                        multisampled: false,
369                    },
370                    count: None,
371                },
372                wgpu::BindGroupLayoutEntry {
373                    binding: 1,
374                    visibility: wgpu::ShaderStages::FRAGMENT,
375                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
376                    count: None,
377                },
378            ],
379        });
380
381        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
382            label: Some("Text Layout"),
383            bind_group_layouts: &[Some(&bgl)],
384            immediate_size: 0,
385        });
386
387        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
388            label: Some("Text Pipeline"),
389            layout: Some(&layout),
390            vertex: wgpu::VertexState {
391                module: &shader,
392                entry_point: Some("vs_main"),
393                buffers: &[wgpu::VertexBufferLayout {
394                    array_stride: std::mem::size_of::<TextQuadVertex>() as u64,
395                    step_mode: wgpu::VertexStepMode::Vertex,
396                    attributes: &[
397                        wgpu::VertexAttribute {
398                            format: wgpu::VertexFormat::Float32x2,
399                            offset: 0,
400                            shader_location: 0,
401                        },
402                        wgpu::VertexAttribute {
403                            format: wgpu::VertexFormat::Float32x2,
404                            offset: 8,
405                            shader_location: 1,
406                        },
407                        wgpu::VertexAttribute {
408                            format: wgpu::VertexFormat::Float32x4,
409                            offset: 16,
410                            shader_location: 2,
411                        },
412                    ],
413                }],
414                compilation_options: Default::default(),
415            },
416            fragment: Some(wgpu::FragmentState {
417                module: &shader,
418                entry_point: Some("fs_main"),
419                targets: &[Some(wgpu::ColorTargetState {
420                    format,
421                    blend: Some(wgpu::BlendState {
422                        // Premultiplied alpha — the fragment shader
423                        // already multiplies `rgb` by the effective
424                        // alpha, so source factor is `One`.
425                        color: wgpu::BlendComponent {
426                            src_factor: wgpu::BlendFactor::One,
427                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
428                            operation: wgpu::BlendOperation::Add,
429                        },
430                        alpha: wgpu::BlendComponent {
431                            src_factor: wgpu::BlendFactor::One,
432                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
433                            operation: wgpu::BlendOperation::Add,
434                        },
435                    }),
436                    write_mask: wgpu::ColorWrites::ALL,
437                })],
438                compilation_options: Default::default(),
439            }),
440            primitive: wgpu::PrimitiveState {
441                topology: wgpu::PrimitiveTopology::TriangleList,
442                ..Default::default()
443            },
444            depth_stencil: None,
445            multisample: wgpu::MultisampleState::default(),
446            multiview_mask: None,
447            cache: None,
448        });
449
450        // Initial vertex buffer: 256 glyphs × 6 verts ≈ 49 KiB. Grows
451        // on demand inside `record`.
452        let vertex_capacity = 256 * 6 * std::mem::size_of::<TextQuadVertex>() as u64;
453        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
454            label: Some("Text Vertex Buffer"),
455            size: vertex_capacity,
456            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
457            mapped_at_creation: false,
458        });
459
460        let atlas = TextAtlas::new(device, queue);
461
462        Self {
463            pipeline,
464            bgl,
465            vertex_buffer,
466            vertex_capacity,
467            atlas,
468        }
469    }
470
471    /// Borrow the atlas. Test surface.
472    pub fn atlas(&self) -> &TextAtlas {
473        &self.atlas
474    }
475
476    /// Render every queued text frame into `target`. No-op when the
477    /// frame list is empty.
478    #[allow(clippy::too_many_arguments)]
479    pub fn record(
480        &mut self,
481        encoder: &mut wgpu::CommandEncoder,
482        queue: &wgpu::Queue,
483        device: &wgpu::Device,
484        target: &wgpu::TextureView,
485        frames: &[TextFrame],
486        render_w: u32,
487        render_h: u32,
488    ) {
489        if frames.is_empty() {
490            return;
491        }
492        let mut verts: Vec<TextQuadVertex> = Vec::with_capacity(frames.len() * 32 * 6);
493        for f in frames {
494            self.append_text(&mut verts, f, render_w, render_h);
495        }
496        if verts.is_empty() {
497            return;
498        }
499        self.atlas.flush(queue);
500
501        // Grow vertex buffer if needed.
502        let needed = (verts.len() * std::mem::size_of::<TextQuadVertex>()) as u64;
503        if needed > self.vertex_capacity {
504            let mut new_cap = self.vertex_capacity.max(1);
505            while new_cap < needed {
506                new_cap *= 2;
507            }
508            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
509                label: Some("Text Vertex Buffer"),
510                size: new_cap,
511                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
512                mapped_at_creation: false,
513            });
514            self.vertex_capacity = new_cap;
515        }
516        queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&verts));
517
518        let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
519            label: Some("Text BG"),
520            layout: &self.bgl,
521            entries: &[
522                wgpu::BindGroupEntry {
523                    binding: 0,
524                    resource: wgpu::BindingResource::TextureView(&self.atlas.view),
525                },
526                wgpu::BindGroupEntry {
527                    binding: 1,
528                    resource: wgpu::BindingResource::Sampler(&self.atlas.sampler),
529                },
530            ],
531        });
532
533        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
534            label: Some("Text Pass"),
535            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
536                view: target,
537                resolve_target: None,
538                ops: wgpu::Operations {
539                    load: wgpu::LoadOp::Load,
540                    store: wgpu::StoreOp::Store,
541                },
542                depth_slice: None,
543            })],
544            depth_stencil_attachment: None,
545            timestamp_writes: None,
546            occlusion_query_set: None,
547            multiview_mask: None,
548        });
549        pass.set_pipeline(&self.pipeline);
550        pass.set_bind_group(0, Some(&bg), &[]);
551        pass.set_vertex_buffer(0, self.vertex_buffer.slice(..needed));
552        pass.draw(0..verts.len() as u32, 0..1);
553    }
554
555    /// Layout one text frame and append its glyph quads to `verts`.
556    /// Lines are split on `\n`; horizontal anchor centres each line on
557    /// `frame.x`, vertical anchor centres the whole block on `frame.y`.
558    fn append_text(
559        &mut self,
560        verts: &mut Vec<TextQuadVertex>,
561        frame: &TextFrame,
562        render_w: u32,
563        render_h: u32,
564    ) {
565        if frame.text.is_empty() || render_w == 0 || render_h == 0 {
566            return;
567        }
568        let size_px = frame.size_px.max(8.0);
569
570        // Pre-pass: compute per-line widths so we can centre them.
571        let lines: Vec<&str> = frame.text.split('\n').collect();
572        let mut line_widths: Vec<f32> = Vec::with_capacity(lines.len());
573        for line in &lines {
574            let mut w = 0.0_f32;
575            for ch in line.chars() {
576                let gid = self
577                    .atlas
578                    .fonts
579                    .get(frame.font.min(self.atlas.fonts.len() as u32 - 1) as usize)
580                    .map(|f| f.glyph_id(ch).0)
581                    .unwrap_or(0);
582                if let Some(slot) = self.atlas.ensure_glyph(frame.font, size_px, gid, ch) {
583                    w += slot.advance;
584                }
585            }
586            line_widths.push(w);
587        }
588        let line_h = size_px * 1.25;
589        let total_h = line_h * lines.len() as f32;
590
591        // Convert anchor centre `[0,1]` screen → pixel.
592        let anchor_px_x = frame.x * render_w as f32;
593        let anchor_px_y = frame.y * render_h as f32;
594        let mut pen_y = anchor_px_y - total_h * 0.5 + size_px;
595
596        for (line, line_w) in lines.iter().zip(line_widths.iter()) {
597            let mut pen_x = anchor_px_x - line_w * 0.5;
598            for ch in line.chars() {
599                let gid = self
600                    .atlas
601                    .fonts
602                    .get(frame.font.min(self.atlas.fonts.len() as u32 - 1) as usize)
603                    .map(|f| f.glyph_id(ch).0)
604                    .unwrap_or(0);
605                let Some(slot) = self.atlas.ensure_glyph(frame.font, size_px, gid, ch) else {
606                    continue;
607                };
608                if slot.px_w > 0 && slot.px_h > 0 {
609                    let x0_px = pen_x + slot.bearing_x;
610                    let y0_px = pen_y + slot.bearing_y;
611                    let x1_px = x0_px + slot.px_w as f32;
612                    let y1_px = y0_px + slot.px_h as f32;
613                    let x0 = (x0_px / render_w as f32) * 2.0 - 1.0;
614                    let x1 = (x1_px / render_w as f32) * 2.0 - 1.0;
615                    let y0 = 1.0 - (y0_px / render_h as f32) * 2.0;
616                    let y1 = 1.0 - (y1_px / render_h as f32) * 2.0;
617                    let [u0, v0] = slot.uv_min;
618                    let [u1, v1] = slot.uv_max;
619                    let rgba = frame.rgba;
620                    let tl = TextQuadVertex {
621                        pos_clip: [x0, y0],
622                        uv: [u0, v0],
623                        rgba,
624                    };
625                    let tr = TextQuadVertex {
626                        pos_clip: [x1, y0],
627                        uv: [u1, v0],
628                        rgba,
629                    };
630                    let bl = TextQuadVertex {
631                        pos_clip: [x0, y1],
632                        uv: [u0, v1],
633                        rgba,
634                    };
635                    let br = TextQuadVertex {
636                        pos_clip: [x1, y1],
637                        uv: [u1, v1],
638                        rgba,
639                    };
640                    verts.extend_from_slice(&[tl, tr, bl, tr, br, bl]);
641                }
642                pen_x += slot.advance;
643            }
644            pen_y += line_h;
645        }
646    }
647}
648
649/// Load the two bundled fonts: `Ubuntu-Light` (index `0`) + `Hack-Regular`
650/// (index `1`). Failures collapse to a one-font vec so the pipeline
651/// always has something to lay out against; the dropped font logs once.
652fn bundled_fonts() -> Vec<FontArc> {
653    use epaint_default_fonts::{HACK_REGULAR, UBUNTU_LIGHT};
654    let mut out = Vec::new();
655    match FontVec::try_from_vec(UBUNTU_LIGHT.to_vec()) {
656        Ok(f) => out.push(FontArc::from(f)),
657        Err(e) => log::warn!("default font (Ubuntu-Light) load failed: {e}"),
658    }
659    match FontVec::try_from_vec(HACK_REGULAR.to_vec()) {
660        Ok(f) => out.push(FontArc::from(f)),
661        Err(e) => log::warn!("mono font (Hack-Regular) load failed: {e}"),
662    }
663    out
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    #[test]
671    fn bundled_fonts_load_two_faces() {
672        let fonts = bundled_fonts();
673        assert_eq!(fonts.len(), 2, "expected Ubuntu-Light + Hack-Regular");
674    }
675}