Skip to main content

pretext_egui/
lib.rs

1use std::collections::{HashMap, VecDeque};
2use std::hash::{Hash, Hasher};
3use std::num::NonZeroUsize;
4
5use ahash::AHashSet;
6pub mod advanced;
7mod advanced_impl;
8mod advanced_types;
9pub mod experimental;
10mod glyph_atlas;
11
12use egui::{ColorImage, FontData, FontDefinitions, FontFamily, TextureHandle, TextureOptions};
13use image::{ImageBuffer, Rgba};
14use lru::LruCache;
15use pretext::font_catalog::FontId;
16use pretext::{
17    BidiDirection, PretextEngine, PretextGlyphRun, PretextParagraphLayout, PretextParagraphOptions,
18    PretextStyle,
19};
20pub use pretext_render::{BaselineMetrics, BaselineMode};
21use pretext_render::{RenderStatsSnapshot, TextRasterRequest, TextRenderCache};
22use resvg::usvg;
23
24#[doc(hidden)]
25pub use crate::advanced_impl::{
26    append_glyph_runs, flush_glyph_scene, new_glyph_scene, paint_emoji_overlays,
27    paint_positioned_text_runs, paint_pretext_paragraph, paint_styled_positioned_text_runs,
28    shaped_text_baseline_metrics, split_builtin_emoji_glyphs, strip_builtin_emoji_glyphs,
29};
30#[doc(hidden)]
31pub use crate::advanced_types::{
32    AtlasWarmupBucket, EmojiAssetId, EmojiOverlay, EmojiOverlayOptions, EmojiOverlayRun,
33    PositionedTextRunRef, PretextFragmentPainter, StyledPositionedTextRunRef, SvgAssetId,
34};
35pub use crate::glyph_atlas::GlyphAtlasStats;
36#[doc(hidden)]
37pub use crate::glyph_atlas::GlyphSceneBuilder;
38use crate::glyph_atlas::{GlyphAtlas, GlyphWarmResult};
39
40const SHAPED_TEXT_TEXTURE_CACHE_CAPACITY: usize = 1024;
41const WARMUP_LINE_HEIGHT_MULTIPLIER: f32 = 1.5;
42
43macro_rules! include_asset {
44    ($path:literal) => {
45        include_bytes!(concat!("../assets/", $path))
46    };
47}
48
49#[derive(Clone, Copy, Debug)]
50pub struct PretextTextureRasterRequest<'a> {
51    pub text: &'a str,
52    pub style: &'a PretextStyle,
53    pub direction: BidiDirection,
54    pub slot_height: f32,
55    pub padding_x: f32,
56    pub padding_y: f32,
57    pub slack_x: f32,
58    pub slack_y: f32,
59    pub baseline_mode: BaselineMode,
60    pub texture_options: TextureOptions,
61}
62
63#[derive(Clone)]
64pub struct PretextTextTexture {
65    pub handle: TextureHandle,
66    pub logical_size: egui::Vec2,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum PretextTextureRasterError {
71    RasterizationFailed,
72}
73
74#[derive(Clone, Debug)]
75pub struct EguiPretextPaintOptions<'a> {
76    pub style: &'a PretextStyle,
77    pub line_height: f32,
78    pub color: egui::Color32,
79    pub fallback_font: egui::FontId,
80    pub fallback_align: egui::Align2,
81    pub emoji_size: f32,
82    pub emoji_slot_height: f32,
83}
84
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
86pub struct EguiPretextRendererStats {
87    pub static_svg_textures: usize,
88    pub shaped_text_textures: usize,
89    pub texture_cache_hits: u64,
90    pub texture_cache_misses: u64,
91    pub texture_uploads: u64,
92    pub texture_upload_bytes: u64,
93    pub atlas_hits: u64,
94    pub atlas_misses: u64,
95    pub atlas_pages: usize,
96    pub atlas_entries: usize,
97    pub warmup_queue_depth: usize,
98    pub mesh_flushes: u64,
99    pub glyph_quads: u64,
100    pub render: RenderStatsSnapshot,
101}
102
103#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
104struct SvgTextureKey {
105    asset_id: SvgAssetId,
106    size: [usize; 2],
107}
108
109#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
110struct ShapedTextTextureKey {
111    raster_cache_id: u64,
112    texture_options: TextureOptions,
113}
114
115#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
116struct AtlasWarmupKey {
117    engine_revision: u64,
118    bucket: AtlasWarmupBucket,
119    size_px_q: u32,
120    pixels_per_point_q: u32,
121}
122
123#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
124struct WarmupGlyphKey {
125    face_id: FontId,
126    glyph_id: u16,
127}
128
129struct AtlasWarmupJob {
130    key: AtlasWarmupKey,
131    size_px: f32,
132    pixels_per_point: f32,
133    glyphs: Vec<WarmupGlyphKey>,
134    cursor: usize,
135}
136
137pub struct EguiPretextRenderer {
138    static_svg_textures: HashMap<SvgTextureKey, TextureHandle>,
139    shaped_text_textures: LruCache<ShapedTextTextureKey, TextureHandle>,
140    glyph_atlas: GlyphAtlas,
141    render_cache: TextRenderCache,
142    texture_cache_hits: u64,
143    texture_cache_misses: u64,
144    texture_uploads: u64,
145    texture_upload_bytes: u64,
146    mesh_flushes: u64,
147    glyph_quads: u64,
148    warmup_engine_revision: Option<u64>,
149    pending_warmups: VecDeque<AtlasWarmupJob>,
150    completed_warmups: AHashSet<AtlasWarmupKey>,
151}
152
153pub struct EguiPretextParagraph<'a> {
154    layout: &'a PretextParagraphLayout,
155    engine: &'a PretextEngine,
156    assets: &'a mut EguiPretextRenderer,
157    paint_options: EguiPretextPaintOptions<'a>,
158    desired_width: Option<f32>,
159    sense: egui::Sense,
160}
161
162impl Default for EguiPretextRenderer {
163    fn default() -> Self {
164        Self {
165            static_svg_textures: HashMap::new(),
166            shaped_text_textures: LruCache::new(
167                NonZeroUsize::new(SHAPED_TEXT_TEXTURE_CACHE_CAPACITY)
168                    .expect("shaped text texture cache capacity"),
169            ),
170            glyph_atlas: GlyphAtlas::default(),
171            render_cache: TextRenderCache::default(),
172            texture_cache_hits: 0,
173            texture_cache_misses: 0,
174            texture_uploads: 0,
175            texture_upload_bytes: 0,
176            mesh_flushes: 0,
177            glyph_quads: 0,
178            warmup_engine_revision: None,
179            pending_warmups: VecDeque::new(),
180            completed_warmups: AHashSet::new(),
181        }
182    }
183}
184
185impl EguiPretextRenderer {
186    pub(crate) fn bundled_font_data() -> Vec<Vec<u8>> {
187        vec![
188            include_asset!("fonts/NotoSans-Regular.ttf").to_vec(),
189            include_asset!("fonts/NotoSansArabic-Regular.ttf").to_vec(),
190            include_asset!("fonts/NotoEmoji-Regular.ttf").to_vec(),
191            include_asset!("fonts/NotoSansMono-Regular.ttf").to_vec(),
192        ]
193    }
194
195    pub(crate) fn svg_bytes(asset_id: SvgAssetId) -> &'static [u8] {
196        match asset_id {
197            SvgAssetId::OpenAiLogo => include_asset!("logos/openai-symbol.svg"),
198            SvgAssetId::ClaudeLogo => include_asset!("logos/claude-symbol.svg"),
199            SvgAssetId::Emoji(EmojiAssetId::Rocket) => include_asset!("emoji_u1f680.svg"),
200            SvgAssetId::Emoji(EmojiAssetId::PartyPopper) => include_asset!("emoji_u1f389.svg"),
201            SvgAssetId::Emoji(EmojiAssetId::CheckMark) => include_asset!("emoji_u2705.svg"),
202        }
203    }
204
205    pub(crate) fn bundled_svg_texture(
206        &mut self,
207        asset_id: SvgAssetId,
208        size: [usize; 2],
209        ctx: &egui::Context,
210    ) -> TextureHandle {
211        let key = SvgTextureKey { asset_id, size };
212        if let Some(texture) = self.static_svg_textures.get(&key) {
213            return texture.clone();
214        }
215
216        let image = rasterize_svg(Self::svg_bytes(asset_id), size)
217            .unwrap_or_else(|| transparent_image(size));
218        let texture = ctx.load_texture(svg_texture_name(key), image, TextureOptions::LINEAR);
219        self.static_svg_textures.insert(key, texture.clone());
220        texture
221    }
222
223    #[doc(hidden)]
224    pub fn emoji_texture(
225        &mut self,
226        emoji_id: EmojiAssetId,
227        size: [usize; 2],
228        ctx: &egui::Context,
229    ) -> TextureHandle {
230        self.bundled_svg_texture(SvgAssetId::Emoji(emoji_id), size, ctx)
231    }
232
233    fn shaped_text_texture(
234        &mut self,
235        engine: &PretextEngine,
236        request: PretextTextureRasterRequest<'_>,
237        ctx: &egui::Context,
238    ) -> Option<PretextTextTexture> {
239        let rasterized = self.render_cache.rasterized_text(
240            engine,
241            text_raster_request(request),
242            ctx.pixels_per_point().max(1.0),
243        )?;
244        let logical_size = egui::vec2(
245            rasterized.logical_size().width,
246            rasterized.logical_size().height,
247        );
248        let key = ShapedTextTextureKey {
249            raster_cache_id: rasterized.cache_id(),
250            texture_options: request.texture_options,
251        };
252        if let Some(texture) = self.shaped_text_textures.get(&key).cloned() {
253            self.texture_cache_hits += 1;
254            return Some(PretextTextTexture {
255                handle: texture,
256                logical_size,
257            });
258        }
259
260        self.texture_cache_misses += 1;
261        let image = alpha_mask_image(rasterized.pixel_size(), rasterized.alpha_pixels().as_ref());
262        let texture = ctx.load_texture(
263            shaped_text_texture_name(key),
264            image,
265            request.texture_options,
266        );
267        self.shaped_text_textures.put(key, texture.clone());
268        self.texture_uploads += 1;
269        self.texture_upload_bytes +=
270            (rasterized.pixel_size()[0] * rasterized.pixel_size()[1] * 4) as u64;
271
272        Some(PretextTextTexture {
273            handle: texture,
274            logical_size,
275        })
276    }
277
278    pub fn rasterize_text_texture(
279        &mut self,
280        engine: &PretextEngine,
281        request: PretextTextureRasterRequest<'_>,
282        ctx: &egui::Context,
283    ) -> Result<PretextTextTexture, PretextTextureRasterError> {
284        self.shaped_text_texture(engine, request, ctx)
285            .ok_or(PretextTextureRasterError::RasterizationFailed)
286    }
287
288    #[allow(clippy::too_many_arguments)]
289    #[doc(hidden)]
290    pub fn paint_line_glyph_runs(
291        &mut self,
292        painter: &egui::Painter,
293        x: f32,
294        y: f32,
295        glyph_runs: &[PretextGlyphRun],
296        style: &PretextStyle,
297        line_height: f32,
298        color: egui::Color32,
299        ctx: &egui::Context,
300        engine: &PretextEngine,
301    ) -> bool {
302        self.glyph_atlas.paint_line_glyph_runs(
303            painter,
304            x,
305            y,
306            glyph_runs,
307            style,
308            line_height,
309            color,
310            ctx,
311            engine,
312            &mut self.texture_uploads,
313            &mut self.texture_upload_bytes,
314        )
315    }
316
317    #[doc(hidden)]
318    pub fn begin_glyph_scene(&self) -> GlyphSceneBuilder {
319        self.glyph_atlas.begin_scene()
320    }
321
322    #[allow(clippy::too_many_arguments)]
323    #[doc(hidden)]
324    pub fn append_line_glyph_runs_to_scene(
325        &mut self,
326        scene: &mut GlyphSceneBuilder,
327        x: f32,
328        y: f32,
329        glyph_runs: &[PretextGlyphRun],
330        style: &PretextStyle,
331        line_height: f32,
332        color: egui::Color32,
333        ctx: &egui::Context,
334        engine: &PretextEngine,
335    ) -> bool {
336        self.glyph_atlas.append_line_glyph_runs(
337            scene,
338            x,
339            y,
340            glyph_runs,
341            style,
342            line_height,
343            color,
344            ctx,
345            engine,
346            &mut self.texture_uploads,
347            &mut self.texture_upload_bytes,
348        )
349    }
350
351    #[doc(hidden)]
352    pub fn flush_glyph_scene(
353        &mut self,
354        painter: &egui::Painter,
355        scene: &mut GlyphSceneBuilder,
356    ) -> bool {
357        let flush_stats = self.glyph_atlas.flush_scene(painter, scene);
358        self.mesh_flushes += flush_stats.mesh_flushes;
359        self.glyph_quads += flush_stats.glyph_quads;
360        flush_stats.painted
361    }
362
363    #[doc(hidden)]
364    pub fn enqueue_atlas_warmup(
365        &mut self,
366        bucket: AtlasWarmupBucket,
367        style: &PretextStyle,
368        seed_texts: &[&str],
369        engine: &PretextEngine,
370        ctx: &egui::Context,
371    ) {
372        self.reset_warmups_if_engine_changed(engine.revision());
373        let pixels_per_point = ctx.pixels_per_point().max(1.0);
374        let key = AtlasWarmupKey {
375            engine_revision: engine.revision(),
376            bucket,
377            size_px_q: quantize_bucket(style.size_px),
378            pixels_per_point_q: quantize_bucket(pixels_per_point),
379        };
380        if self.completed_warmups.contains(&key)
381            || self.pending_warmups.iter().any(|job| job.key == key)
382        {
383            return;
384        }
385
386        let glyphs = collect_warmup_glyphs(engine, style, seed_texts);
387        if glyphs.is_empty() {
388            self.completed_warmups.insert(key);
389            return;
390        }
391
392        self.pending_warmups.push_back(AtlasWarmupJob {
393            key,
394            size_px: style.size_px,
395            pixels_per_point,
396            glyphs,
397            cursor: 0,
398        });
399    }
400
401    #[doc(hidden)]
402    pub fn tick_atlas_warmup(
403        &mut self,
404        ctx: &egui::Context,
405        engine: &PretextEngine,
406        glyph_budget: usize,
407        page_budget: usize,
408    ) -> bool {
409        self.reset_warmups_if_engine_changed(engine.revision());
410        if self.pending_warmups.is_empty() || glyph_budget == 0 {
411            return false;
412        }
413
414        let mut misses = 0usize;
415        while let Some(job) = self.pending_warmups.front_mut() {
416            if self.glyph_atlas.stats().pages >= page_budget {
417                self.pending_warmups.clear();
418                return false;
419            }
420            while job.cursor < job.glyphs.len() {
421                let glyph = job.glyphs[job.cursor];
422                job.cursor += 1;
423                let Some(result) = self.glyph_atlas.warm_glyph(
424                    ctx,
425                    engine,
426                    glyph.face_id,
427                    glyph.glyph_id,
428                    job.size_px,
429                    job.pixels_per_point,
430                    &mut self.texture_uploads,
431                    &mut self.texture_upload_bytes,
432                ) else {
433                    continue;
434                };
435                if result == GlyphWarmResult::Miss {
436                    misses += 1;
437                    if misses >= glyph_budget {
438                        ctx.request_repaint();
439                        return true;
440                    }
441                }
442            }
443
444            let finished = self
445                .pending_warmups
446                .pop_front()
447                .expect("warmup job should exist");
448            self.completed_warmups.insert(finished.key);
449            if misses >= glyph_budget {
450                break;
451            }
452        }
453
454        if !self.pending_warmups.is_empty() {
455            ctx.request_repaint();
456        }
457        !self.pending_warmups.is_empty()
458    }
459
460    #[doc(hidden)]
461    pub fn builtin_emoji_for_grapheme(grapheme: &str) -> Option<EmojiAssetId> {
462        match grapheme {
463            "🚀" => Some(EmojiAssetId::Rocket),
464            "🎉" => Some(EmojiAssetId::PartyPopper),
465            "✅" => Some(EmojiAssetId::CheckMark),
466            _ => None,
467        }
468    }
469
470    #[cfg(test)]
471    pub(crate) fn static_svg_texture_count(&self) -> usize {
472        self.static_svg_textures.len()
473    }
474
475    #[cfg(test)]
476    pub(crate) fn shaped_text_texture_count(&self) -> usize {
477        self.shaped_text_textures.len()
478    }
479
480    #[cfg(test)]
481    pub(crate) fn glyph_path_count(&self) -> usize {
482        self.render_cache.stats_snapshot().glyph_path_entries
483    }
484
485    #[cfg(test)]
486    pub(crate) fn glyph_atlas_entry_count(&self) -> usize {
487        self.glyph_atlas.stats().entries
488    }
489
490    fn stats_snapshot(&self) -> EguiPretextRendererStats {
491        let atlas = self.glyph_atlas.stats();
492        EguiPretextRendererStats {
493            static_svg_textures: self.static_svg_textures.len(),
494            shaped_text_textures: self.shaped_text_textures.len(),
495            texture_cache_hits: self.texture_cache_hits,
496            texture_cache_misses: self.texture_cache_misses,
497            texture_uploads: self.texture_uploads,
498            texture_upload_bytes: self.texture_upload_bytes,
499            atlas_hits: atlas.hits,
500            atlas_misses: atlas.misses,
501            atlas_pages: atlas.pages,
502            atlas_entries: atlas.entries,
503            warmup_queue_depth: self.warmup_queue_depth(),
504            mesh_flushes: self.mesh_flushes,
505            glyph_quads: self.glyph_quads,
506            render: self.render_cache.stats_snapshot(),
507        }
508    }
509
510    pub fn stats(&self) -> EguiPretextRendererStats {
511        self.stats_snapshot()
512    }
513
514    pub fn paint_paragraph(
515        &mut self,
516        painter: &egui::Painter,
517        origin: egui::Pos2,
518        layout: &PretextParagraphLayout,
519        options: &EguiPretextPaintOptions<'_>,
520        ctx: &egui::Context,
521        engine: &PretextEngine,
522    ) {
523        paint_pretext_paragraph(painter, origin, layout, options, ctx, engine, self);
524    }
525
526    #[doc(hidden)]
527    pub fn paint_runs<'a>(
528        &mut self,
529        painter: &egui::Painter,
530        lines: impl IntoIterator<Item = PositionedTextRunRef<'a>>,
531        options: &EguiPretextPaintOptions<'_>,
532        ctx: &egui::Context,
533        engine: &PretextEngine,
534    ) -> bool {
535        paint_positioned_text_runs(painter, lines, options, ctx, engine, self)
536    }
537
538    #[doc(hidden)]
539    pub fn paint_styled_runs<'a, 'b>(
540        &mut self,
541        painter: &egui::Painter,
542        lines: impl IntoIterator<Item = StyledPositionedTextRunRef<'a, 'b>>,
543        ctx: &egui::Context,
544        engine: &PretextEngine,
545    ) -> bool {
546        paint_styled_positioned_text_runs(painter, lines, ctx, engine, self)
547    }
548
549    pub fn paragraph<'a>(
550        &'a mut self,
551        layout: &'a PretextParagraphLayout,
552        style: &'a PretextStyle,
553        line_height: f32,
554        engine: &'a PretextEngine,
555    ) -> EguiPretextParagraph<'a> {
556        EguiPretextParagraph::new(layout, style, line_height, engine, self)
557    }
558
559    fn reset_warmups_if_engine_changed(&mut self, engine_revision: u64) {
560        if self.warmup_engine_revision == Some(engine_revision) {
561            return;
562        }
563        self.warmup_engine_revision = Some(engine_revision);
564        self.pending_warmups.clear();
565        self.completed_warmups.clear();
566    }
567
568    fn warmup_queue_depth(&self) -> usize {
569        self.pending_warmups
570            .iter()
571            .map(|job| job.glyphs.len().saturating_sub(job.cursor))
572            .sum()
573    }
574
575    pub(crate) fn demo_font_definitions() -> FontDefinitions {
576        let mut fonts = FontDefinitions::default();
577        fonts.font_data.insert(
578            "noto-sans".to_owned(),
579            FontData::from_static(include_asset!("fonts/NotoSans-Regular.ttf")).into(),
580        );
581        fonts.font_data.insert(
582            "noto-sans-arabic".to_owned(),
583            FontData::from_static(include_asset!("fonts/NotoSansArabic-Regular.ttf")).into(),
584        );
585        fonts.font_data.insert(
586            "noto-emoji-regular-local".to_owned(),
587            FontData::from_static(include_asset!("fonts/NotoEmoji-Regular.ttf")).into(),
588        );
589        fonts.font_data.insert(
590            "noto-sans-mono".to_owned(),
591            FontData::from_static(include_asset!("fonts/NotoSansMono-Regular.ttf")).into(),
592        );
593
594        let proportional = fonts.families.entry(FontFamily::Proportional).or_default();
595        proportional.insert(0, "noto-sans".to_owned());
596        proportional.insert(1, "noto-sans-arabic".to_owned());
597        proportional.insert(2, "noto-emoji-regular-local".to_owned());
598
599        let monospace = fonts.families.entry(FontFamily::Monospace).or_default();
600        monospace.insert(0, "noto-sans-mono".to_owned());
601        monospace.insert(1, "noto-sans-arabic".to_owned());
602        monospace.insert(2, "noto-emoji-regular-local".to_owned());
603
604        fonts
605    }
606}
607
608fn collect_warmup_glyphs(
609    engine: &PretextEngine,
610    style: &PretextStyle,
611    seed_texts: &[&str],
612) -> Vec<WarmupGlyphKey> {
613    let mut seen = AHashSet::new();
614    let mut output = Vec::new();
615
616    for text in seed_texts {
617        if text.is_empty() {
618            continue;
619        }
620        let prepared = engine.prepare_paragraph(text, style, &PretextParagraphOptions::default());
621        let layout =
622            engine.layout_paragraph(&prepared, 100_000.0, warmup_line_height(style.size_px));
623        for line in &layout.lines {
624            for run in &line.runs.glyph_runs {
625                for glyph in &run.glyphs {
626                    let key = WarmupGlyphKey {
627                        face_id: glyph.face_id,
628                        glyph_id: glyph.glyph_id,
629                    };
630                    if seen.insert(key) {
631                        output.push(key);
632                    }
633                }
634            }
635        }
636    }
637
638    output
639}
640
641impl<'a> EguiPretextPaintOptions<'a> {
642    pub fn new(style: &'a PretextStyle, line_height: f32) -> Self {
643        Self {
644            style,
645            line_height,
646            color: egui::Color32::WHITE,
647            fallback_font: egui::FontId::new(style.size_px, FontFamily::Proportional),
648            fallback_align: egui::Align2::LEFT_TOP,
649            emoji_size: line_height,
650            emoji_slot_height: line_height,
651        }
652    }
653
654    pub fn color(mut self, color: egui::Color32) -> Self {
655        self.color = color;
656        self
657    }
658
659    pub fn fallback_font(mut self, fallback_font: egui::FontId) -> Self {
660        self.fallback_font = fallback_font;
661        self
662    }
663
664    pub fn fallback_align(mut self, fallback_align: egui::Align2) -> Self {
665        self.fallback_align = fallback_align;
666        self
667    }
668
669    pub fn emoji_size(mut self, emoji_size: f32) -> Self {
670        self.emoji_size = emoji_size;
671        self
672    }
673
674    pub fn emoji_slot_height(mut self, emoji_slot_height: f32) -> Self {
675        self.emoji_slot_height = emoji_slot_height;
676        self
677    }
678}
679
680impl<'a> EguiPretextParagraph<'a> {
681    pub fn new(
682        layout: &'a PretextParagraphLayout,
683        style: &'a PretextStyle,
684        line_height: f32,
685        engine: &'a PretextEngine,
686        assets: &'a mut EguiPretextRenderer,
687    ) -> Self {
688        Self {
689            layout,
690            engine,
691            assets,
692            paint_options: EguiPretextPaintOptions::new(style, line_height),
693            desired_width: None,
694            sense: egui::Sense::hover(),
695        }
696    }
697
698    pub fn color(mut self, color: egui::Color32) -> Self {
699        self.paint_options = self.paint_options.color(color);
700        self
701    }
702
703    pub fn fallback_font(mut self, fallback_font: egui::FontId) -> Self {
704        self.paint_options = self.paint_options.fallback_font(fallback_font);
705        self
706    }
707
708    pub fn fallback_align(mut self, fallback_align: egui::Align2) -> Self {
709        self.paint_options = self.paint_options.fallback_align(fallback_align);
710        self
711    }
712
713    pub fn emoji_size(mut self, emoji_size: f32) -> Self {
714        self.paint_options = self.paint_options.emoji_size(emoji_size);
715        self
716    }
717
718    pub fn emoji_slot_height(mut self, emoji_slot_height: f32) -> Self {
719        self.paint_options = self.paint_options.emoji_slot_height(emoji_slot_height);
720        self
721    }
722
723    pub fn desired_width(mut self, desired_width: f32) -> Self {
724        self.desired_width = Some(desired_width);
725        self
726    }
727
728    pub fn sense(mut self, sense: egui::Sense) -> Self {
729        self.sense = sense;
730        self
731    }
732}
733
734impl egui::Widget for EguiPretextParagraph<'_> {
735    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
736        let desired_size = egui::vec2(
737            self.desired_width
738                .unwrap_or_else(|| paragraph_layout_width(self.layout))
739                .max(0.0),
740            self.layout.height.max(0.0),
741        );
742        let (rect, response) = ui.allocate_exact_size(desired_size, self.sense);
743        let painter = ui.painter_at(rect);
744        paint_pretext_paragraph(
745            &painter,
746            rect.min,
747            self.layout,
748            &self.paint_options,
749            ui.ctx(),
750            self.engine,
751            self.assets,
752        );
753        response
754    }
755}
756
757fn text_raster_request(request: PretextTextureRasterRequest<'_>) -> TextRasterRequest<'_> {
758    TextRasterRequest {
759        text: request.text,
760        style: request.style,
761        direction: request.direction,
762        slot_height: request.slot_height,
763        padding_x: request.padding_x,
764        padding_y: request.padding_y,
765        slack_x: request.slack_x,
766        slack_y: request.slack_y,
767        baseline_mode: request.baseline_mode,
768    }
769}
770
771pub(crate) fn paragraph_layout_width(layout: &PretextParagraphLayout) -> f32 {
772    layout
773        .lines
774        .iter()
775        .fold(0.0f32, |max_width, line| max_width.max(line.line.width))
776}
777
778fn svg_texture_name(key: SvgTextureKey) -> String {
779    format!(
780        "pretext-egui/svg/{:?}/{:?}x{:?}",
781        key.asset_id, key.size[0], key.size[1]
782    )
783}
784
785fn shaped_text_texture_name(key: ShapedTextTextureKey) -> String {
786    let mut state = std::collections::hash_map::DefaultHasher::new();
787    key.hash(&mut state);
788    format!("pretext-egui/shaped-text/{:016x}", state.finish())
789}
790
791fn alpha_mask_image(size: [usize; 2], alpha_pixels: &[u8]) -> ColorImage {
792    let pixels = alpha_pixels
793        .iter()
794        .map(|alpha| egui::Color32::from_white_alpha(*alpha))
795        .collect();
796    ColorImage::new(size, pixels)
797}
798
799fn rasterize_svg(svg_bytes: &[u8], size: [usize; 2]) -> Option<ColorImage> {
800    let options = usvg::Options::default();
801    let tree = usvg::Tree::from_data(svg_bytes, &options).ok()?;
802    let mut pixmap = tiny_skia::Pixmap::new(size[0] as u32, size[1] as u32)?;
803    let svg_size = tree.size();
804    let scale_x = size[0] as f32 / svg_size.width();
805    let scale_y = size[1] as f32 / svg_size.height();
806    let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
807
808    resvg::render(&tree, transform, &mut pixmap.as_mut());
809
810    let image = ImageBuffer::<Rgba<u8>, _>::from_raw(
811        size[0] as u32,
812        size[1] as u32,
813        pixmap.data().to_vec(),
814    )?;
815    let pixels = image
816        .pixels()
817        .map(|pixel| egui::Color32::from_rgba_premultiplied(pixel[0], pixel[1], pixel[2], pixel[3]))
818        .collect();
819    Some(ColorImage::new(size, pixels))
820}
821
822fn transparent_image(size: [usize; 2]) -> ColorImage {
823    let pixels = vec![egui::Color32::from_rgba_premultiplied(0, 0, 0, 0); size[0] * size[1]];
824    ColorImage::new(size, pixels)
825}
826
827fn quantize_bucket(value: f32) -> u32 {
828    (value.max(0.0) * 64.0).round() as u32
829}
830
831fn warmup_line_height(size_px: f32) -> f32 {
832    (size_px * WARMUP_LINE_HEIGHT_MULTIPLIER).max(size_px + 4.0)
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838    use egui::{FontId, RawInput, Rect, TextureId};
839    use pretext::{ParagraphDirection, PretextParagraphOptions, WhiteSpaceMode, WordBreakMode};
840
841    fn engine() -> PretextEngine {
842        PretextEngine::builder()
843            .with_font_data(EguiPretextRenderer::bundled_font_data())
844            .include_system_fonts(false)
845            .build()
846    }
847
848    fn default_style() -> PretextStyle {
849        PretextStyle {
850            families: vec![
851                "Noto Sans".to_owned(),
852                "Noto Sans Arabic".to_owned(),
853                "Noto Color Emoji".to_owned(),
854            ],
855            size_px: 16.0,
856            weight: 400,
857            italic: false,
858        }
859    }
860
861    fn mono_style() -> PretextStyle {
862        PretextStyle {
863            families: vec![
864                "Noto Sans Mono".to_owned(),
865                "Noto Sans Arabic".to_owned(),
866                "Noto Color Emoji".to_owned(),
867            ],
868            size_px: 18.0,
869            weight: 400,
870            italic: false,
871        }
872    }
873
874    fn shape_uses_user_texture(shape: &egui::Shape) -> bool {
875        match shape {
876            egui::Shape::Vec(shapes) => shapes.iter().any(shape_uses_user_texture),
877            egui::Shape::Mesh(mesh) => mesh.texture_id != TextureId::default(),
878            _ => shape.texture_id() != TextureId::default(),
879        }
880    }
881
882    fn shape_y_bounds(shape: &egui::Shape) -> Option<(f32, f32)> {
883        match shape {
884            egui::Shape::Vec(shapes) => {
885                shapes
886                    .iter()
887                    .filter_map(shape_y_bounds)
888                    .fold(None, |acc, (min_y, max_y)| match acc {
889                        Some((acc_min, acc_max)) => Some((acc_min.min(min_y), acc_max.max(max_y))),
890                        None => Some((min_y, max_y)),
891                    })
892            }
893            egui::Shape::Mesh(mesh) => {
894                let mut vertices = mesh.vertices.iter();
895                let first = vertices.next()?;
896                let mut min_y = first.pos.y;
897                let mut max_y = first.pos.y;
898                for vertex in vertices {
899                    min_y = min_y.min(vertex.pos.y);
900                    max_y = max_y.max(vertex.pos.y);
901                }
902                Some((min_y, max_y))
903            }
904            _ => None,
905        }
906    }
907
908    #[test]
909    fn ui_fonts_install_sample_text_stack() {
910        let fonts = EguiPretextRenderer::demo_font_definitions();
911        let proportional = fonts
912            .families
913            .get(&FontFamily::Proportional)
914            .expect("proportional family");
915
916        assert_eq!(proportional[0], "noto-sans");
917        assert_eq!(proportional[1], "noto-sans-arabic");
918        assert_eq!(proportional[2], "noto-emoji-regular-local");
919        assert!(fonts.font_data.contains_key("noto-sans"));
920        assert!(fonts.font_data.contains_key("noto-sans-arabic"));
921        assert!(fonts.font_data.contains_key("noto-emoji-regular-local"));
922    }
923
924    #[test]
925    fn installed_ui_fonts_cover_mixed_arabic_and_builtin_emoji_text() {
926        let ctx = egui::Context::default();
927        experimental::demo_assets::install_demo_fonts(&ctx);
928
929        let mut probe = None;
930        let _ = ctx.run_ui(RawInput::default(), |ctx| {
931            let font_id = FontId::new(16.0, FontFamily::Proportional);
932            probe = Some(ctx.fonts_mut(|fonts| {
933                (
934                    fonts.has_glyphs(&font_id, "بدأت الرحلة 🚀"),
935                    fonts.glyph_width(&font_id, '🚀'),
936                )
937            }));
938        });
939        let (supports_sample, rocket_width) = probe.expect("expected probe result");
940
941        assert!(supports_sample);
942        assert!(rocket_width > 0.0);
943    }
944
945    #[test]
946    fn bundled_svg_texture_reuses_canonical_cache_entry() {
947        let ctx = egui::Context::default();
948        let mut assets = EguiPretextRenderer::default();
949        let first =
950            assets.bundled_svg_texture(SvgAssetId::Emoji(EmojiAssetId::Rocket), [96, 96], &ctx);
951        let second =
952            assets.bundled_svg_texture(SvgAssetId::Emoji(EmojiAssetId::Rocket), [96, 96], &ctx);
953
954        assert_eq!(assets.static_svg_texture_count(), 1);
955        assert_eq!(first.id(), second.id());
956    }
957
958    #[test]
959    fn alpha_mask_image_uses_valid_white_alpha_pixels() {
960        let image = alpha_mask_image([3, 1], &[0, 64, 255]);
961
962        assert_eq!(image.pixels[0], egui::Color32::from_white_alpha(0));
963        assert_eq!(image.pixels[1], egui::Color32::from_white_alpha(64));
964        assert_eq!(image.pixels[2], egui::Color32::from_white_alpha(255));
965    }
966
967    #[test]
968    fn shaped_text_texture_reuses_generated_texture_and_glyph_paths() {
969        let ctx = egui::Context::default();
970        let mut assets = EguiPretextRenderer::default();
971        let engine = engine();
972        let style = default_style();
973        let request = PretextTextureRasterRequest {
974            text: "بدأت الرحلة",
975            style: &style,
976            direction: BidiDirection::Rtl,
977            slot_height: 22.0,
978            padding_x: 2.0,
979            padding_y: 2.0,
980            slack_x: 2.0,
981            slack_y: 2.0,
982            baseline_mode: BaselineMode::AutoFontMetrics,
983            texture_options: TextureOptions::NEAREST,
984        };
985
986        let first = assets
987            .rasterize_text_texture(&engine, request, &ctx)
988            .expect("expected texture");
989        let after_first_textures = assets.shaped_text_texture_count();
990        let after_first_paths = assets.glyph_path_count();
991        let second = assets
992            .rasterize_text_texture(&engine, request, &ctx)
993            .expect("expected cached texture");
994
995        assert_eq!(after_first_textures, assets.shaped_text_texture_count());
996        assert_eq!(after_first_paths, assets.glyph_path_count());
997        assert_eq!(first.handle.id(), second.handle.id());
998        assert_ne!(first.handle.id(), TextureId::default());
999    }
1000
1001    #[test]
1002    fn bundled_font_data_drives_pretext_engine() {
1003        let engine = engine();
1004        let prepared = engine.prepare_paragraph(
1005            "emoji ✅🧪 and Arabic العربية",
1006            &default_style(),
1007            &PretextParagraphOptions {
1008                white_space: WhiteSpaceMode::Normal,
1009                word_break: WordBreakMode::Normal,
1010                paragraph_direction: ParagraphDirection::Auto,
1011                letter_spacing: 0.0,
1012            },
1013        );
1014        let layout = engine.layout_paragraph(&prepared, 220.0, 20.0);
1015
1016        assert!(layout.line_count >= 1);
1017    }
1018
1019    #[test]
1020    fn pretext_paragraph_layout_keeps_visual_runs_and_builtin_emoji_overlays() {
1021        let engine = engine();
1022        let style = default_style();
1023        let prepared = engine.prepare_paragraph(
1024            "بدأت الرحلة 🚀 and then kept going",
1025            &style,
1026            &PretextParagraphOptions {
1027                white_space: WhiteSpaceMode::Normal,
1028                word_break: WordBreakMode::Normal,
1029                paragraph_direction: ParagraphDirection::Auto,
1030                letter_spacing: 0.0,
1031            },
1032        );
1033        let layout = prepared.layout(&engine, 220.0, 22.0);
1034
1035        assert!(layout.line_count >= 1);
1036        assert!(paragraph_layout_width(&layout) > 0.0);
1037        assert!(layout
1038            .lines
1039            .iter()
1040            .flat_map(|line| line.runs.visual_runs.iter())
1041            .any(|run| run.direction == BidiDirection::Rtl));
1042        assert!(layout
1043            .lines
1044            .iter()
1045            .flat_map(|line| {
1046                split_builtin_emoji_glyphs(
1047                    &line.runs.visual_runs,
1048                    &line.runs.glyph_runs,
1049                    EmojiOverlayOptions {
1050                        style: &style,
1051                        slot_height: 22.0,
1052                        padding_x: 2.0,
1053                        padding_y: 2.0,
1054                        slack_x: 2.0,
1055                        slack_y: 2.0,
1056                        baseline_mode: BaselineMode::AutoFontMetrics,
1057                    },
1058                    &engine,
1059                )
1060                .1
1061                .into_iter()
1062            })
1063            .flat_map(|overlay| overlay.emojis.into_iter())
1064            .any(|emoji| emoji.emoji_id == EmojiAssetId::Rocket));
1065    }
1066
1067    #[test]
1068    fn pretext_paragraph_layout_from_prepared_uses_layout_paragraph() {
1069        let engine = engine();
1070        let style = default_style();
1071        let prepared = engine.prepare_paragraph(
1072            "Atlas العربية ✅",
1073            &style,
1074            &PretextParagraphOptions {
1075                white_space: WhiteSpaceMode::Normal,
1076                word_break: WordBreakMode::Normal,
1077                paragraph_direction: ParagraphDirection::Auto,
1078                letter_spacing: 0.0,
1079            },
1080        );
1081
1082        let before = engine.runtime_stats();
1083        let layout = prepared.layout(&engine, 240.0, 22.0);
1084        let after = engine.runtime_stats();
1085
1086        assert!(layout.line_count >= 1);
1087        assert!(after.layout_with_runs_calls > before.layout_with_runs_calls);
1088        assert_eq!(
1089            after.layout_with_lines_calls,
1090            before.layout_with_lines_calls
1091        );
1092        assert_eq!(after.line_visual_runs_calls, before.line_visual_runs_calls);
1093        assert_eq!(after.line_glyph_runs_calls, before.line_glyph_runs_calls);
1094        assert_eq!(after.line_runs_calls, before.line_runs_calls);
1095    }
1096
1097    #[test]
1098    fn pretext_paragraph_widget_uses_atlas_and_svg_textures_without_shaped_text_cache() {
1099        let ctx = egui::Context::default();
1100        let mut assets = EguiPretextRenderer::default();
1101        let engine = engine();
1102        let style = default_style();
1103        let prepared = engine.prepare_paragraph(
1104            "Atlas العربية ✅",
1105            &style,
1106            &PretextParagraphOptions {
1107                white_space: WhiteSpaceMode::Normal,
1108                word_break: WordBreakMode::Normal,
1109                paragraph_direction: ParagraphDirection::Auto,
1110                letter_spacing: 0.0,
1111            },
1112        );
1113        let layout = prepared.layout(&engine, 240.0, 22.0);
1114        let desired_width = 180.0;
1115        let mut response_rect = None;
1116
1117        let output = ctx.run_ui(
1118            RawInput {
1119                screen_rect: Some(Rect::from_min_size(
1120                    egui::Pos2::ZERO,
1121                    egui::vec2(480.0, 240.0),
1122                )),
1123                ..Default::default()
1124            },
1125            |ctx| {
1126                egui::CentralPanel::default().show_inside(ctx, |ui| {
1127                    let response = ui.add(
1128                        EguiPretextParagraph::new(&layout, &style, 22.0, &engine, &mut assets)
1129                            .color(egui::Color32::WHITE)
1130                            .emoji_size(18.0)
1131                            .emoji_slot_height(20.0)
1132                            .desired_width(desired_width),
1133                    );
1134                    response_rect = Some(response.rect);
1135                });
1136            },
1137        );
1138        let response_rect = response_rect.expect("paragraph widget should allocate a rect");
1139        let stats = assets.stats();
1140
1141        assert!((response_rect.width() - desired_width).abs() < 0.01);
1142        assert!((response_rect.height() - layout.height).abs() < 0.01);
1143        assert!(stats.atlas_entries > 0);
1144        assert!(stats.static_svg_textures > 0);
1145        assert_eq!(stats.shaped_text_textures, 0);
1146        assert!(output
1147            .shapes
1148            .iter()
1149            .any(|clipped| shape_uses_user_texture(&clipped.shape)));
1150    }
1151
1152    #[test]
1153    fn positioned_text_runs_use_atlas_without_shaped_text_cache() {
1154        let ctx = egui::Context::default();
1155        let mut assets = EguiPretextRenderer::default();
1156        let engine = engine();
1157        let style = default_style();
1158        let prepared = engine.prepare_paragraph(
1159            "Positioned العربية",
1160            &style,
1161            &PretextParagraphOptions {
1162                white_space: WhiteSpaceMode::Normal,
1163                word_break: WordBreakMode::Normal,
1164                paragraph_direction: ParagraphDirection::Auto,
1165                letter_spacing: 0.0,
1166            },
1167        );
1168        let layout = engine.layout_paragraph(&prepared, 320.0, 22.0);
1169        let first_line = &layout.lines[0];
1170        let glyph_runs = &first_line.runs.glyph_runs;
1171        let options = EguiPretextPaintOptions::new(&style, 22.0)
1172            .color(egui::Color32::WHITE)
1173            .fallback_align(egui::Align2::LEFT_TOP);
1174
1175        let output = ctx.run_ui(
1176            RawInput {
1177                screen_rect: Some(Rect::from_min_size(
1178                    egui::Pos2::ZERO,
1179                    egui::vec2(480.0, 240.0),
1180                )),
1181                ..Default::default()
1182            },
1183            |ctx| {
1184                let painter = ctx.layer_painter(egui::LayerId::new(
1185                    egui::Order::Foreground,
1186                    egui::Id::new("positioned-text-runs"),
1187                ));
1188                let _ = paint_positioned_text_runs(
1189                    &painter,
1190                    [PositionedTextRunRef {
1191                        x: 24.0,
1192                        y: 32.0,
1193                        text: &first_line.line.text,
1194                        glyph_runs,
1195                        emoji_overlays: &[],
1196                    }],
1197                    &options,
1198                    ctx,
1199                    &engine,
1200                    &mut assets,
1201                );
1202            },
1203        );
1204        let stats = assets.stats();
1205
1206        assert!(stats.atlas_entries > 0);
1207        assert_eq!(stats.shaped_text_textures, 0);
1208        assert!(output
1209            .shapes
1210            .iter()
1211            .any(|clipped| shape_uses_user_texture(&clipped.shape)));
1212    }
1213
1214    #[test]
1215    fn styled_positioned_text_runs_use_atlas_without_shaped_text_cache() {
1216        let ctx = egui::Context::default();
1217        let mut assets = EguiPretextRenderer::default();
1218        let engine = engine();
1219        let style = default_style();
1220        let mono = mono_style();
1221        let prepared_body = engine.prepare_paragraph(
1222            "Styled body العربية",
1223            &style,
1224            &PretextParagraphOptions {
1225                white_space: WhiteSpaceMode::Normal,
1226                word_break: WordBreakMode::Normal,
1227                paragraph_direction: ParagraphDirection::Auto,
1228                letter_spacing: 0.0,
1229            },
1230        );
1231        let prepared_mono = engine.prepare_paragraph(
1232            "Mono 101",
1233            &mono,
1234            &PretextParagraphOptions {
1235                white_space: WhiteSpaceMode::Normal,
1236                word_break: WordBreakMode::Normal,
1237                paragraph_direction: ParagraphDirection::Auto,
1238                letter_spacing: 0.0,
1239            },
1240        );
1241        let layout_body = engine.layout_paragraph(&prepared_body, 320.0, 22.0);
1242        let layout_mono = engine.layout_paragraph(&prepared_mono, 320.0, 24.0);
1243        let first_body_line = &layout_body.lines[0];
1244        let first_mono_line = &layout_mono.lines[0];
1245        let glyph_runs_body = &first_body_line.runs.glyph_runs;
1246        let glyph_runs_mono = &first_mono_line.runs.glyph_runs;
1247
1248        let output = ctx.run_ui(
1249            RawInput {
1250                screen_rect: Some(Rect::from_min_size(
1251                    egui::Pos2::ZERO,
1252                    egui::vec2(480.0, 240.0),
1253                )),
1254                ..Default::default()
1255            },
1256            |ctx| {
1257                let painter = ctx.layer_painter(egui::LayerId::new(
1258                    egui::Order::Foreground,
1259                    egui::Id::new("styled-positioned-text-runs"),
1260                ));
1261                let _ = paint_styled_positioned_text_runs(
1262                    &painter,
1263                    [
1264                        StyledPositionedTextRunRef {
1265                            x: 24.0,
1266                            y: 32.0,
1267                            text: &first_body_line.line.text,
1268                            glyph_runs: glyph_runs_body,
1269                            emoji_overlays: &[],
1270                            options: EguiPretextPaintOptions::new(&style, 22.0)
1271                                .color(egui::Color32::WHITE)
1272                                .fallback_font(FontId::new(style.size_px, FontFamily::Proportional))
1273                                .fallback_align(egui::Align2::LEFT_TOP),
1274                        },
1275                        StyledPositionedTextRunRef {
1276                            x: 24.0,
1277                            y: 66.0,
1278                            text: &first_mono_line.line.text,
1279                            glyph_runs: glyph_runs_mono,
1280                            emoji_overlays: &[],
1281                            options: EguiPretextPaintOptions::new(&mono, 24.0)
1282                                .color(egui::Color32::LIGHT_GRAY)
1283                                .fallback_font(FontId::new(mono.size_px, FontFamily::Monospace))
1284                                .fallback_align(egui::Align2::LEFT_TOP),
1285                        },
1286                    ],
1287                    ctx,
1288                    &engine,
1289                    &mut assets,
1290                );
1291            },
1292        );
1293        let stats = assets.stats();
1294
1295        assert!(stats.atlas_entries > 0);
1296        assert_eq!(stats.shaped_text_textures, 0);
1297        assert!(output
1298            .shapes
1299            .iter()
1300            .any(|clipped| shape_uses_user_texture(&clipped.shape)));
1301    }
1302
1303    #[test]
1304    fn fragment_painter_falls_back_without_atlas_glyphs() {
1305        let ctx = egui::Context::default();
1306        let mut assets = EguiPretextRenderer::default();
1307        let engine = engine();
1308        let style = default_style();
1309        let mut painted = false;
1310
1311        let output = ctx.run_ui(
1312            RawInput {
1313                screen_rect: Some(Rect::from_min_size(
1314                    egui::Pos2::ZERO,
1315                    egui::vec2(320.0, 120.0),
1316                )),
1317                ..Default::default()
1318            },
1319            |ctx| {
1320                let painter = ctx.layer_painter(egui::LayerId::new(
1321                    egui::Order::Foreground,
1322                    egui::Id::new("fragment-fallback"),
1323                ));
1324                let options = EguiPretextPaintOptions::new(&style, 22.0)
1325                    .color(egui::Color32::WHITE)
1326                    .fallback_font(FontId::new(style.size_px, FontFamily::Proportional))
1327                    .fallback_align(egui::Align2::LEFT_TOP);
1328                let mut fragment_painter = PretextFragmentPainter::new(&assets);
1329                fragment_painter.push_fragment(
1330                    24.0,
1331                    32.0,
1332                    "Fallback only",
1333                    &[],
1334                    &[],
1335                    &options,
1336                    ctx,
1337                    &engine,
1338                    &mut assets,
1339                );
1340                painted = fragment_painter.finish(&painter, ctx, &mut assets);
1341            },
1342        );
1343        let stats = assets.stats();
1344
1345        assert!(painted);
1346        assert_eq!(stats.atlas_entries, 0);
1347        assert_eq!(stats.shaped_text_textures, 0);
1348        assert!(!output.shapes.is_empty());
1349    }
1350
1351    #[test]
1352    fn fragment_painter_keeps_mixed_emoji_in_font_backed_glyph_runs() {
1353        let ctx = egui::Context::default();
1354        experimental::demo_assets::install_demo_fonts(&ctx);
1355        let mut assets = EguiPretextRenderer::default();
1356        let engine = engine();
1357        let style = default_style();
1358        let prepared = engine.prepare_paragraph(
1359            "Mixed emoji 🧪 keeps fallback honest.",
1360            &style,
1361            &PretextParagraphOptions {
1362                white_space: WhiteSpaceMode::Normal,
1363                word_break: WordBreakMode::Normal,
1364                paragraph_direction: ParagraphDirection::Auto,
1365                letter_spacing: 0.0,
1366            },
1367        );
1368        let layout = engine.layout_paragraph(&prepared, 320.0, 22.0);
1369        let first_line = &layout.lines[0];
1370
1371        let mut fallback_count = None;
1372        let mut painted = false;
1373        let output = ctx.run_ui(
1374            RawInput {
1375                screen_rect: Some(Rect::from_min_size(
1376                    egui::Pos2::ZERO,
1377                    egui::vec2(360.0, 120.0),
1378                )),
1379                ..Default::default()
1380            },
1381            |ctx| {
1382                let painter = ctx.layer_painter(egui::LayerId::new(
1383                    egui::Order::Foreground,
1384                    egui::Id::new("fragment-mixed-emoji-fallback"),
1385                ));
1386                let options = EguiPretextPaintOptions::new(&style, 22.0)
1387                    .color(egui::Color32::WHITE)
1388                    .fallback_font(FontId::new(style.size_px, FontFamily::Proportional))
1389                    .fallback_align(egui::Align2::LEFT_TOP);
1390                let mut fragment_painter = PretextFragmentPainter::new(&assets);
1391                fragment_painter.push_fragment(
1392                    24.0,
1393                    32.0,
1394                    &first_line.line.text,
1395                    &first_line.runs.glyph_runs,
1396                    &[],
1397                    &options,
1398                    ctx,
1399                    &engine,
1400                    &mut assets,
1401                );
1402                fallback_count = Some(fragment_painter.pending_fallbacks.len());
1403                painted = fragment_painter.finish(&painter, ctx, &mut assets);
1404            },
1405        );
1406
1407        assert_eq!(
1408            fallback_count,
1409            Some(0),
1410            "expected local emoji fonts to avoid whole-fragment fallback"
1411        );
1412        assert!(painted);
1413        assert!(assets.stats().atlas_entries > 0);
1414        assert!(!output.shapes.is_empty());
1415    }
1416
1417    #[test]
1418    fn fragment_painter_keeps_builtin_overlay_when_sibling_emoji_is_font_backed() {
1419        let ctx = egui::Context::default();
1420        experimental::demo_assets::install_demo_fonts(&ctx);
1421        let mut assets = EguiPretextRenderer::default();
1422        let engine = engine();
1423        let style = default_style();
1424        let prepared = engine.prepare_paragraph(
1425            "Built-in 🚀 plus lab 🧪",
1426            &style,
1427            &PretextParagraphOptions {
1428                white_space: WhiteSpaceMode::Normal,
1429                word_break: WordBreakMode::Normal,
1430                paragraph_direction: ParagraphDirection::Auto,
1431                letter_spacing: 0.0,
1432            },
1433        );
1434        let layout = engine.layout_paragraph(&prepared, 320.0, 22.0);
1435        let first_line = &layout.lines[0];
1436        let (glyph_runs, emoji_overlays) = split_builtin_emoji_glyphs(
1437            &first_line.runs.visual_runs,
1438            &first_line.runs.glyph_runs,
1439            EmojiOverlayOptions {
1440                style: &style,
1441                slot_height: 22.0,
1442                padding_x: 0.0,
1443                padding_y: 0.0,
1444                slack_x: 0.0,
1445                slack_y: 0.0,
1446                baseline_mode: BaselineMode::AutoFontMetrics,
1447            },
1448            &engine,
1449        );
1450
1451        let mut fallback_count = None;
1452        let mut overlay_count = None;
1453        let _ = ctx.run_ui(RawInput::default(), |ctx| {
1454            let options = EguiPretextPaintOptions::new(&style, 22.0)
1455                .color(egui::Color32::WHITE)
1456                .fallback_font(FontId::new(style.size_px, FontFamily::Proportional))
1457                .fallback_align(egui::Align2::LEFT_TOP);
1458            let mut fragment_painter = PretextFragmentPainter::new(&assets);
1459            fragment_painter.push_fragment(
1460                24.0,
1461                32.0,
1462                &first_line.line.text,
1463                &glyph_runs,
1464                &emoji_overlays,
1465                &options,
1466                ctx,
1467                &engine,
1468                &mut assets,
1469            );
1470            fallback_count = Some(fragment_painter.pending_fallbacks.len());
1471            overlay_count = Some(fragment_painter.pending_emoji.len());
1472        });
1473
1474        assert_eq!(fallback_count, Some(0));
1475        assert_eq!(overlay_count, Some(1));
1476    }
1477
1478    #[test]
1479    fn mixed_font_backed_emoji_mesh_stays_inside_line_slot() {
1480        let ctx = egui::Context::default();
1481        experimental::demo_assets::install_demo_fonts(&ctx);
1482        let mut assets = EguiPretextRenderer::default();
1483        let engine = engine();
1484        let style = default_style();
1485        let prepared = engine.prepare_paragraph(
1486            "Mixed emoji 🧪 stays on the same line.",
1487            &style,
1488            &PretextParagraphOptions {
1489                white_space: WhiteSpaceMode::Normal,
1490                word_break: WordBreakMode::Normal,
1491                paragraph_direction: ParagraphDirection::Auto,
1492                letter_spacing: 0.0,
1493            },
1494        );
1495        let layout = engine.layout_paragraph(&prepared, 360.0, 22.0);
1496        let first_line = &layout.lines[0];
1497        let y = 32.0;
1498        let line_bottom = y + 22.0;
1499
1500        let output = ctx.run_ui(
1501            RawInput {
1502                screen_rect: Some(Rect::from_min_size(
1503                    egui::Pos2::ZERO,
1504                    egui::vec2(420.0, 140.0),
1505                )),
1506                ..Default::default()
1507            },
1508            |ctx| {
1509                let painter = ctx.layer_painter(egui::LayerId::new(
1510                    egui::Order::Foreground,
1511                    egui::Id::new("mixed-emoji-line-slot"),
1512                ));
1513                let _ = paint_positioned_text_runs(
1514                    &painter,
1515                    [PositionedTextRunRef {
1516                        x: 24.0,
1517                        y,
1518                        text: &first_line.line.text,
1519                        glyph_runs: &first_line.runs.glyph_runs,
1520                        emoji_overlays: &[],
1521                    }],
1522                    &EguiPretextPaintOptions::new(&style, 22.0)
1523                        .color(egui::Color32::WHITE)
1524                        .fallback_align(egui::Align2::LEFT_TOP),
1525                    ctx,
1526                    &engine,
1527                    &mut assets,
1528                );
1529            },
1530        );
1531        let bounds = output
1532            .shapes
1533            .iter()
1534            .filter_map(|clipped| shape_y_bounds(&clipped.shape))
1535            .fold(None::<(f32, f32)>, |acc, (min_y, max_y)| match acc {
1536                Some((acc_min, acc_max)) => Some((acc_min.min(min_y), acc_max.max(max_y))),
1537                None => Some((min_y, max_y)),
1538            })
1539            .expect("expected painted mesh bounds");
1540
1541        assert!(bounds.0 >= y - 4.0, "unexpected top bound: {:?}", bounds);
1542        assert!(
1543            bounds.1 <= line_bottom + 4.0,
1544            "emoji glyphs spilled below line slot: {:?}",
1545            bounds
1546        );
1547    }
1548
1549    #[test]
1550    fn paint_line_glyph_runs_reuses_cached_atlas_entries() {
1551        let ctx = egui::Context::default();
1552        let mut assets = EguiPretextRenderer::default();
1553        let engine = engine();
1554        let style = default_style();
1555        let prepared = engine.prepare_paragraph(
1556            "Atlas العربية",
1557            &style,
1558            &PretextParagraphOptions {
1559                white_space: WhiteSpaceMode::Normal,
1560                word_break: WordBreakMode::Normal,
1561                paragraph_direction: ParagraphDirection::Auto,
1562                letter_spacing: 0.0,
1563            },
1564        );
1565        let layout = engine.layout_paragraph(&prepared, 240.0, 22.0);
1566        let first_line = &layout.lines[0];
1567        let glyph_runs = &first_line.runs.glyph_runs;
1568
1569        let _ = ctx.run_ui(RawInput::default(), |ctx| {
1570            let painter = ctx.layer_painter(egui::LayerId::new(
1571                egui::Order::Foreground,
1572                egui::Id::new("glyph-atlas-first"),
1573            ));
1574            assert!(crate::advanced::paint_line_glyph_runs(
1575                &mut assets,
1576                &painter,
1577                8.0,
1578                8.0,
1579                glyph_runs,
1580                &style,
1581                22.0,
1582                egui::Color32::WHITE,
1583                ctx,
1584                &engine,
1585            ));
1586        });
1587        let entries_after_first = assets.glyph_atlas_entry_count();
1588        let uploads_after_first = assets.stats().texture_uploads;
1589
1590        let _ = ctx.run_ui(RawInput::default(), |ctx| {
1591            let painter = ctx.layer_painter(egui::LayerId::new(
1592                egui::Order::Foreground,
1593                egui::Id::new("glyph-atlas-second"),
1594            ));
1595            assert!(crate::advanced::paint_line_glyph_runs(
1596                &mut assets,
1597                &painter,
1598                8.0,
1599                8.0,
1600                glyph_runs,
1601                &style,
1602                22.0,
1603                egui::Color32::WHITE,
1604                ctx,
1605                &engine,
1606            ));
1607        });
1608
1609        assert!(entries_after_first > 0);
1610        assert_eq!(entries_after_first, assets.glyph_atlas_entry_count());
1611        assert_eq!(uploads_after_first, assets.stats().texture_uploads);
1612    }
1613}