diff --git a/Cargo.lock b/Cargo.lock index e57b05c89..94bfe892a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1735,6 +1735,14 @@ dependencies = [ "serde", ] +[[package]] +name = "font_variations" +version = "0.1.0" +dependencies = [ + "eframe", + "env_logger", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index 59ed25fec..53bc73737 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -58,6 +58,41 @@ impl GlyphInfo { }; } +/// Result of resolving a `char` to a [`GlyphId`] within a single [`FontFace`]. +/// +/// Location-independent: only depends on the font's charmap and `FontTweak`, +/// not on variable-font variation coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(super) enum GlyphIdResolution { + /// A real, visible glyph. + Glyph(GlyphId), + + /// A valid char, but rendered as zero-width (control chars, joiners, …). + Invisible, +} + +/// A precomputed hash of a [`skrifa::instance::Location`]. +/// +/// Used as a cache key so that we don't have to re-hash the coordinate list +/// for every glyph lookup. Compute once per text run and reuse for every glyph +/// in the run. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub(crate) struct LocationHash(u64); + +impl nohash_hasher::IsEnabled for LocationHash {} + +impl LocationHash { + #[inline] + pub fn new(location: &skrifa::instance::Location) -> Self { + if location.coords().is_empty() { + // Fast path for the (common) default-coords case. + Self(0) + } else { + Self(crate::util::hash(location)) + } + } +} + // Subpixel binning, taken from cosmic-text: // https://github.com/pop-os/cosmic-text/blob/974ddaed96b334f560b606ebe5d2ca2d2f9f23ef/src/glyph_cache.rs @@ -131,10 +166,12 @@ struct GlyphCacheKey(u64); impl nohash_hasher::IsEnabled for GlyphCacheKey {} impl GlyphCacheKey { + #[inline] fn new(glyph_id: GlyphId, metrics: &StyledMetrics, bin: SubpixelBin) -> Self { let StyledMetrics { pixels_per_point, px_scale_factor, + location_hash, .. } = *metrics; debug_assert!( @@ -150,6 +187,7 @@ impl GlyphCacheKey { pixels_per_point.to_bits(), px_scale_factor.to_bits(), bin, + location_hash, ))) } } @@ -161,7 +199,6 @@ struct DependentFontData<'a> { charmap: skrifa::charmap::Charmap<'a>, outline_glyphs: skrifa::outline::OutlineGlyphCollection<'a>, metrics: skrifa::metrics::Metrics, - glyph_metrics: skrifa::metrics::GlyphMetrics<'a>, hinting_instance: Option, } @@ -204,7 +241,9 @@ impl FontCell { if let Some(hinting_instance) = &mut font_data.hinting_instance { let size = skrifa::instance::Size::new(metrics.scale); - if hinting_instance.size() != size { + if hinting_instance.size() != size + || hinting_instance.location().coords() != location.coords() + { hinting_instance .reconfigure( &font_data.outline_glyphs, @@ -323,7 +362,18 @@ pub struct FontFace { /// `ShaperData` is `Copy` — lives outside the `self_cell`. shaper_data: harfrust::ShaperData, - glyph_info_cache: ahash::HashMap, + /// Location-independent: `char → GlyphId | Invisible`. + /// + /// Only depends on the font's charmap + `FontTweak`. A miss means the char + /// is not in this face's repertoire and the fallback chain should be tried. + glyph_id_cache: ahash::HashMap, + + /// Location-dependent: `(char, LocationHash) → unscaled advance width`. + /// + /// Variable fonts can vary advance widths per axis (HVAR table), so this + /// must be re-keyed per resolved [`skrifa::instance::Location`]. + advance_width_cache: ahash::HashMap<(char, LocationHash), OrderedFloat>, + glyph_alloc_cache: ahash::HashMap, } @@ -345,14 +395,11 @@ impl FontFace { // Note: We use default location here during initialization because // the actual weight will be applied via the stored location during rendering. // The metrics won't be significantly different at this unscaled size. + // TODO(emilk): heed location for vertical metrics too (HVAR/MVAR). let metrics = skrifa_font.metrics( skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default(), ); - let glyph_metrics = skrifa_font.glyph_metrics( - skrifa::instance::Size::unscaled(), - skrifa::instance::LocationRef::default(), - ); let hinting_enabled = tweak.hinting.unwrap_or(options.font_hinting); let hinting_instance = hinting_enabled @@ -374,7 +421,6 @@ impl FontFace { charmap, outline_glyphs: glyphs, metrics, - glyph_metrics, hinting_instance, }) })?; @@ -389,7 +435,8 @@ impl FontFace { tweak, subpixel_binning, shaper_data, - glyph_info_cache: Default::default(), + glyph_id_cache: Default::default(), + advance_width_cache: Default::default(), glyph_alloc_cache: Default::default(), }) } @@ -423,65 +470,86 @@ impl FontFace { .filter_map(|(chr, _)| char::from_u32(chr).filter(|c| !self.ignore_character(*c))) } - /// `\n` will result in `None` - pub(super) fn glyph_info(&mut self, c: char) -> Option { - if let Some(glyph_info) = self.glyph_info_cache.get(&c) { - return Some(*glyph_info); + /// Resolve a `char` to a [`GlyphId`] within this face. + /// + /// Location-independent. Returns `None` when this face cannot represent + /// the char (the caller should try the fallback chain). + /// + /// `\t` and thin spaces share `' '`s glyph id (they just have a custom advance). + pub(super) fn glyph_id_resolution(&mut self, c: char) -> Option { + if let Some(resolution) = self.glyph_id_cache.get(&c) { + return Some(*resolution); } if self.ignore_character(c) { return None; // these will result in the replacement character when rendering } - if c == '\t' - && let Some(space) = self.glyph_info(' ') - { - let glyph_info = GlyphInfo { - advance_width_unscaled: (self.tweak.tab_size * space.advance_width_unscaled.0) - .into(), - ..space - }; - self.glyph_info_cache.insert(c, glyph_info); - return Some(glyph_info); - } - - if (c == '\u{2009}' || c == '\u{202F}') - && let Some(space) = self.glyph_info(' ') - { - // Thin space (U+2009) and narrow no-break space (U+202F), - // often used as thousands separator: 1 234 567 890 - let advance_width = self.tweak.thin_space_width * space.advance_width_unscaled.0; - let glyph_info = GlyphInfo { - advance_width_unscaled: advance_width.into(), - ..space - }; - self.glyph_info_cache.insert(c, glyph_info); - return Some(glyph_info); - } - - if invisible_char(c) { - let glyph_info = GlyphInfo::INVISIBLE; - self.glyph_info_cache.insert(c, glyph_info); - return Some(glyph_info); - } - - let font_data = self.font.borrow_dependent(); - - // Add new character: - let glyph_id = font_data - .charmap - .map(c) - .filter(|id| *id != GlyphId::NOTDEF)?; - - let glyph_info = GlyphInfo { - id: Some(glyph_id), - advance_width_unscaled: font_data - .glyph_metrics - .advance_width(glyph_id) - .unwrap_or_default() - .into(), + let resolution = if c == '\t' || c == '\u{2009}' || c == '\u{202F}' { + // `\t` and thin spaces are rendered as a space glyph with a custom advance. + self.glyph_id_resolution(' ')? + } else if invisible_char(c) { + GlyphIdResolution::Invisible + } else { + let glyph_id = self + .font + .borrow_dependent() + .charmap + .map(c) + .filter(|id| *id != GlyphId::NOTDEF)?; + GlyphIdResolution::Glyph(glyph_id) + }; + + self.glyph_id_cache.insert(c, resolution); + Some(resolution) + } + + /// Unscaled advance width for `c` at the given variation location. + /// + /// Location-dependent (variable fonts can vary advances via HVAR). + /// Cached per `(char, LocationHash)`. + fn advance_width_unscaled(&mut self, c: char, metrics: &StyledMetrics) -> f32 { + let cache_key = (c, metrics.location_hash); + if let Some(advance) = self.advance_width_cache.get(&cache_key) { + return advance.0; + } + + let advance = match c { + '\t' => self.tweak.tab_size * self.advance_width_unscaled(' ', metrics), + '\u{2009}' | '\u{202F}' => { + // Thin space (U+2009) and narrow no-break space (U+202F), + // often used as thousands separator. + self.tweak.thin_space_width * self.advance_width_unscaled(' ', metrics) + } + _ => { + let Some(GlyphIdResolution::Glyph(glyph_id)) = self.glyph_id_resolution(c) else { + return 0.0; + }; + let font_data = self.font.borrow_dependent(); + let glyph_metrics = font_data + .skrifa + .glyph_metrics(skrifa::instance::Size::unscaled(), &metrics.location); + glyph_metrics.advance_width(glyph_id).unwrap_or_default() + } + }; + + self.advance_width_cache.insert(cache_key, advance.into()); + advance + } + + /// `\n` will result in `None`. + /// + /// Caller must pass [`StyledMetrics`] resolved against *this* face so that + /// variable-font advance widths are looked up at the correct location. + pub(super) fn glyph_info(&mut self, c: char, metrics: &StyledMetrics) -> Option { + let resolution = self.glyph_id_resolution(c)?; + let glyph_info = match resolution { + GlyphIdResolution::Invisible => GlyphInfo::INVISIBLE, + GlyphIdResolution::Glyph(glyph_id) => GlyphInfo { + id: Some(glyph_id), + advance_width_unscaled: self.advance_width_unscaled(c, metrics).into(), + }, }; - self.glyph_info_cache.insert(c, glyph_info); Some(glyph_info) } @@ -510,6 +578,7 @@ impl FontFace { // argument (probably from TextFormat). let settings = std::iter::chain(self.tweak.coords.as_ref(), coords.as_ref()); let location = axes.location(settings); + let location_hash = LocationHash::new(&location); StyledMetrics { pixels_per_point, @@ -519,6 +588,7 @@ impl FontFace { ascent, row_height: ascent - descent + line_gap, location, + location_hash, } } @@ -594,7 +664,7 @@ pub struct Font<'a> { impl Font<'_> { pub fn preload_characters(&mut self, s: &str) { for c in s.chars() { - self.glyph_info(c); + self.resolve_face(c); } } @@ -626,19 +696,23 @@ impl Font<'_> { .unwrap_or_default() } - /// Width of this character in points. + /// Width of this character in points, at the font's default variation location. pub fn glyph_width(&mut self, c: char, font_size: f32) -> f32 { - let (key, glyph_info) = self.glyph_info(c); - if let Some(font) = &self.fonts_by_id.get(&key) { - glyph_info.advance_width_unscaled.0 * font.font.px_scale_factor(font_size) - } else { - 0.0 - } + let face_key = self.resolve_face(c); + let Some(font_face) = self.fonts_by_id.get_mut(&face_key) else { + return 0.0; + }; + let metrics = font_face.styled_metrics(1.0, font_size, &VariationCoords::default()); + let Some(glyph_info) = font_face.glyph_info(c, &metrics) else { + return 0.0; + }; + glyph_info.advance_width_unscaled.0 * font_face.font.px_scale_factor(font_size) } /// Can we display this glyph? pub fn has_glyph(&mut self, c: char) -> bool { - self.glyph_info(c) != self.cached_family.replacement_glyph // TODO(emilk): this is a false negative if the user asks about the replacement character itself 🤦‍♂️ + // TODO(emilk): this is a false negative if the user asks about the replacement character itself 🤦‍♂️ + self.resolve_face(c) != self.cached_family.replacement_face_key } /// Can we display all the glyphs in this text? @@ -646,21 +720,52 @@ impl Font<'_> { s.chars().all(|c| self.has_glyph(c)) } - /// `\n` will (intentionally) show up as the replacement character. - pub(crate) fn glyph_info(&mut self, c: char) -> (FontFaceKey, GlyphInfo) { - if let Some(font_index_glyph_info) = self.cached_family.glyph_info_cache.get(&c) { - return *font_index_glyph_info; + /// Find which face in the fallback chain owns `c`. + /// + /// Location-independent — fallback choice depends only on charmap support. + /// Falls back to the replacement-glyph face when no fallback face has `c`. + #[inline] + pub(crate) fn resolve_face(&mut self, c: char) -> FontFaceKey { + if let Some(font_key) = self.cached_family.face_cache.get(&c) { + return *font_key; } + self.resolve_face_slow(c) + } - let font_index_glyph_info = self + #[cold] + fn resolve_face_slow(&mut self, c: char) -> FontFaceKey { + let font_key = self .cached_family - .glyph_info_no_cache_or_fallback(c, self.fonts_by_id); - let font_index_glyph_info = - font_index_glyph_info.unwrap_or(self.cached_family.replacement_glyph); - self.cached_family - .glyph_info_cache - .insert(c, font_index_glyph_info); - font_index_glyph_info + .find_face_for_char(c, self.fonts_by_id) + .unwrap_or(self.cached_family.replacement_face_key); + self.cached_family.face_cache.insert(c, font_key); + font_key + } + + /// Resolve `c` to its (face, [`GlyphInfo`]) at the given face's location. + /// + /// `\n` will (intentionally) show up as the replacement character. + /// + /// `metrics` must be the resolved [`StyledMetrics`] for the face that ends + /// up owning `c`. Most callers pass the metrics of their text run's primary + /// face — that is correct as long as `c` is in that face. For correct + /// fallback-face advances, resolve the face first with [`Self::resolve_face`] + /// and build metrics for that face. + pub(crate) fn glyph_info( + &mut self, + c: char, + metrics: &StyledMetrics, + ) -> (FontFaceKey, GlyphInfo) { + let face_key = self.resolve_face(c); + let Some(face) = self.fonts_by_id.get_mut(&face_key) else { + return (face_key, GlyphInfo::INVISIBLE); + }; + let glyph_info = face.glyph_info(c, metrics).unwrap_or_else(|| { + // `c` is in no face — render the replacement character instead. + face.glyph_info(self.cached_family.replacement_char, metrics) + .unwrap_or(GlyphInfo::INVISIBLE) + }); + (face_key, glyph_info) } } @@ -693,6 +798,12 @@ pub struct StyledMetrics { /// Resolved variation coordinates. pub location: skrifa::instance::Location, + + /// Precomputed hash of [`Self::location`]. + /// + /// Hashed once per run of text so per-glyph cache lookups don't have to + /// re-hash the full coordinate list. + pub(crate) location_hash: LocationHash, } /// Code points that will always be invisible (zero width). diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 980e56aee..23fe62387 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -11,7 +11,7 @@ use crate::{ TextureAtlas, text::{ Galley, LayoutJob, LayoutSection, TextOptions, VariationCoords, - font::{Font, FontFace, GlyphInfo}, + font::{Font, FontFace}, }, }; use emath::{NumExt as _, OrderedFloat}; @@ -457,9 +457,20 @@ pub(super) struct CachedFamily { /// Lazily calculated. pub characters: Option>>, - pub replacement_glyph: (FontFaceKey, GlyphInfo), + /// The face used when no face in [`Self::fonts`] supports a char. + pub replacement_face_key: FontFaceKey, - pub glyph_info_cache: ahash::HashMap, + /// The char that [`Self::replacement_face_key`] actually contains. + /// + /// When the user asks about a char that no fallback face supports we + /// render this char in its place. + pub replacement_char: char, + + /// Cache: `char → which face in the fallback chain owns this char`. + /// + /// Location-independent (fallback choice depends only on charmap support, + /// not on variation coordinates). + pub face_cache: ahash::HashMap, } impl CachedFamily { @@ -467,49 +478,59 @@ impl CachedFamily { fonts: Vec, fonts_by_id: &mut nohash_hasher::IntMap, ) -> Self { + const PRIMARY_REPLACEMENT_CHAR: char = '◻'; // white medium square + const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback + if fonts.is_empty() { return Self { fonts, characters: None, - replacement_glyph: (FontFaceKey::INVALID, GlyphInfo::INVISIBLE), - glyph_info_cache: Default::default(), + replacement_face_key: FontFaceKey::INVALID, + replacement_char: PRIMARY_REPLACEMENT_CHAR, + face_cache: Default::default(), }; } let mut slf = Self { fonts, characters: None, - replacement_glyph: (FontFaceKey::INVALID, GlyphInfo::INVISIBLE), - glyph_info_cache: Default::default(), + replacement_face_key: FontFaceKey::INVALID, + replacement_char: PRIMARY_REPLACEMENT_CHAR, + face_cache: Default::default(), }; - const PRIMARY_REPLACEMENT_CHAR: char = '◻'; // white medium square - const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback - - let replacement_glyph = slf - .glyph_info_no_cache_or_fallback(PRIMARY_REPLACEMENT_CHAR, fonts_by_id) - .or_else(|| slf.glyph_info_no_cache_or_fallback(FALLBACK_REPLACEMENT_CHAR, fonts_by_id)) + let (replacement_face_key, replacement_char) = slf + .find_face_for_char(PRIMARY_REPLACEMENT_CHAR, fonts_by_id) + .map(|key| (key, PRIMARY_REPLACEMENT_CHAR)) + .or_else(|| { + slf.find_face_for_char(FALLBACK_REPLACEMENT_CHAR, fonts_by_id) + .map(|key| (key, FALLBACK_REPLACEMENT_CHAR)) + }) .unwrap_or_else(|| { log::warn!( "Failed to find replacement characters {PRIMARY_REPLACEMENT_CHAR:?} or {FALLBACK_REPLACEMENT_CHAR:?}. Will use empty glyph." ); - (FontFaceKey::INVALID, GlyphInfo::INVISIBLE) + (FontFaceKey::INVALID, PRIMARY_REPLACEMENT_CHAR) }); - slf.replacement_glyph = replacement_glyph; + slf.replacement_face_key = replacement_face_key; + slf.replacement_char = replacement_char; slf } - pub(crate) fn glyph_info_no_cache_or_fallback( - &mut self, + /// Walk the fallback chain and return the first face whose charmap supports `c`. + /// + /// Pure — does not touch any cache. Callers that want memoisation should + /// insert into [`Self::face_cache`] themselves. + pub(crate) fn find_face_for_char( + &self, c: char, fonts_by_id: &mut nohash_hasher::IntMap, - ) -> Option<(FontFaceKey, GlyphInfo)> { + ) -> Option { for font_key in &self.fonts { let font_face = fonts_by_id.get_mut(font_key).expect("Nonexistent font ID"); - if let Some(glyph_info) = font_face.glyph_info(c) { - self.glyph_info_cache.insert(c, (*font_key, glyph_info)); - return Some((*font_key, glyph_info)); + if font_face.glyph_id_resolution(c).is_some() { + return Some(*font_key); } } None diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index dfa913ea7..77ca74fea 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -261,7 +261,7 @@ fn layout_shaped_run( if chr == '\t' { let tweak = font.fonts_by_id.get(&run.font_key).map(|ff| ff.tweak()); let tab_size = tweak.map_or(4.0, |t| t.tab_size); - let (_, space_info) = font.glyph_info(' '); + let (_, space_info) = font.glyph_info(' ', face_metrics); let space_width_px = space_info.advance_width_unscaled.0 * px_scale; advance_width_px = tab_size * space_width_px; } @@ -271,7 +271,7 @@ fn layout_shaped_run( if chr == '\u{2009}' || chr == '\u{202F}' { let tweak = font.fonts_by_id.get(&run.font_key).map(|ff| ff.tweak()); let thin_space_width = tweak.map_or(0.5, |t| t.thin_space_width); - let (_, space_info) = font.glyph_info(' '); + let (_, space_info) = font.glyph_info(' ', face_metrics); let space_width_px = space_info.advance_width_unscaled.0 * px_scale; advance_width_px = thin_space_width * space_width_px; } @@ -308,7 +308,7 @@ fn layout_shaped_run( } // Use the fallback font face (not run.font_key which returned NOTDEF). - let (fallback_key, glyph_info) = font.glyph_info(chr); + let fallback_key = font.resolve_face(chr); let fallback_metrics = font .fonts_by_id .get(&fallback_key) @@ -316,6 +316,7 @@ fn layout_shaped_run( ff.styled_metrics(ctx.pixels_per_point, ctx.font_size, &Default::default()) }) .unwrap_or_default(); + let (_, glyph_info) = font.glyph_info(chr, &fallback_metrics); let advance_width_px = glyph_info.advance_width_unscaled.0 * fallback_metrics.px_scale_factor; let (glyph_alloc, physical_x) = @@ -774,12 +775,14 @@ fn replace_last_glyph_with_overflow_character( let mut font = fonts.font(§ion.format.font_id.family); let font_size = section.format.font_id.size; - let (font_id, glyph_info) = font.glyph_info(overflow_character); - let mut font_face = font.fonts_by_id.get_mut(&font_id); - let font_face_metrics = font_face - .as_mut() + let font_id = font.resolve_face(overflow_character); + let font_face_metrics = font + .fonts_by_id + .get(&font_id) .map(|f| f.styled_metrics(pixels_per_point, font_size, §ion.format.coords)) .unwrap_or_default(); + let (_, glyph_info) = font.glyph_info(overflow_character, &font_face_metrics); + let mut font_face = font.fonts_by_id.get_mut(&font_id); let overflow_glyph_x = if let Some(prev_glyph) = row.glyphs.last() { prev_glyph.max_x() + extra_letter_spacing @@ -1371,7 +1374,7 @@ fn segment_into_runs(font: &mut Font<'_>, text: &str, out: &mut Vec) { let byte_end = byte_offset + grapheme_str.len(); let base_char = grapheme_str.chars().next().unwrap_or(' '); - let (font_key, _) = font.glyph_info(base_char); + let font_key = font.resolve_face(base_char); if let Some(last_run) = out.last_mut() && last_run.font_key == font_key diff --git a/examples/font_variations/Cargo.toml b/examples/font_variations/Cargo.toml new file mode 100644 index 000000000..789625f06 --- /dev/null +++ b/examples/font_variations/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "font_variations" +version = "0.1.0" +authors = ["Emil Ernerfeldt "] +license = "MIT OR Apache-2.0" +edition = "2024" +rust-version = "1.92" +publish = false + +[lints] +workspace = true + + +[dependencies] +eframe = { workspace = true, features = [ + "default", + "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO +] } +env_logger = { workspace = true, features = ["auto-color", "humantime"] } diff --git a/examples/font_variations/data/Recursive-VariableFont.ttf b/examples/font_variations/data/Recursive-VariableFont.ttf new file mode 100644 index 000000000..367e2df5a Binary files /dev/null and b/examples/font_variations/data/Recursive-VariableFont.ttf differ diff --git a/examples/font_variations/src/main.rs b/examples/font_variations/src/main.rs new file mode 100644 index 000000000..dc6cc5200 --- /dev/null +++ b/examples/font_variations/src/main.rs @@ -0,0 +1,129 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +#![expect(rustdoc::missing_crate_level_docs)] // it's an example + +use eframe::egui; +use eframe::epaint::text::{FontInsert, InsertFontFamily}; + +fn main() -> eframe::Result { + env_logger::init(); + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([600.0, 500.0]), + ..Default::default() + }; + eframe::run_native( + "egui example: font variations", + options, + Box::new(|cc| Ok(Box::new(MyApp::new(cc)))), + ) +} + +struct MyApp { + /// Weight axis (wght): 300..1000 + weight: f32, + + /// Casual axis (CASL): 0..1 + casual: f32, + + /// Monospace axis (MONO): 0..1 + mono: f32, + + /// Slant axis (slnt): -15..0 + slant: f32, + + /// Cursive axis (CRSV): 0..1 + cursive: f32, + + preview_text: String, + font_size: f32, +} + +impl MyApp { + fn new(cc: &eframe::CreationContext<'_>) -> Self { + cc.egui_ctx.add_font(FontInsert::new( + "Recursive", + egui::FontData::from_static({ + #[expect(clippy::large_include_file, reason = "intentional for the example")] + { + include_bytes!("../data/Recursive-VariableFont.ttf") + } + }), + vec![ + InsertFontFamily { + family: egui::FontFamily::Proportional, + priority: egui::epaint::text::FontPriority::Highest, + }, + InsertFontFamily { + family: egui::FontFamily::Monospace, + priority: egui::epaint::text::FontPriority::Highest, + }, + ], + )); + + Self { + weight: 400.0, + casual: 0.0, + mono: 0.0, + slant: 0.0, + cursive: 0.5, + preview_text: "The quick brown fox jumps over the lazy dog.\n\ + ABCDEFGHIJKLMNOPQRSTUVWXYZ\n\ + abcdefghijklmnopqrstuvwxyz\n\ + 0123456789 !@#$%^&*()" + .to_owned(), + font_size: 24.0, + } + } +} + +impl eframe::App for MyApp { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show_inside(ui, |ui| { + ui.heading("Font Variations (Recursive)"); + ui.add_space(4.0); + + egui::Grid::new("variation_sliders") + .num_columns(2) + .spacing([16.0, 8.0]) + .show(ui, |ui| { + ui.label("Weight (wght):"); + ui.add(egui::Slider::new(&mut self.weight, 300.0..=1000.0)); + ui.end_row(); + + ui.label("Casual (CASL):"); + ui.add(egui::Slider::new(&mut self.casual, 0.0..=1.0)); + ui.end_row(); + + ui.label("Monospace (MONO):"); + ui.add(egui::Slider::new(&mut self.mono, 0.0..=1.0)); + ui.end_row(); + + ui.label("Slant (slnt):"); + ui.add(egui::Slider::new(&mut self.slant, -15.0..=0.0)); + ui.end_row(); + + ui.label("Cursive (CRSV):"); + ui.add(egui::Slider::new(&mut self.cursive, 0.0..=1.0)); + ui.end_row(); + + ui.label("Font size:"); + ui.add(egui::Slider::new(&mut self.font_size, 8.0..=80.0)); + ui.end_row(); + }); + + ui.separator(); + + let rich = egui::RichText::new(&self.preview_text) + .size(self.font_size) + .variation("wght", self.weight) + .variation("CASL", self.casual) + .variation("MONO", self.mono) + .variation("slnt", self.slant) + .variation("CRSV", self.cursive); + + ui.label(rich); + + ui.add_space(8.0); + ui.text_edit_multiline(&mut self.preview_text); + }); + } +}