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}