From fa8057989e8fe754e0dbb5487103252e4568cd61 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Tue, 8 Sep 2026 10:40:09 +0200 Subject: [PATCH] font family --- Cargo.lock | 58 ++++++++++ Cargo.toml | 2 + config.example.toml | 15 ++- src/app.rs | 14 +-- src/config.rs | 25 +++++ src/fonts.rs | 268 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/render.rs | 192 +++++++++++++++++++++++++++++-- 8 files changed, 550 insertions(+), 25 deletions(-) create mode 100644 src/fonts.rs diff --git a/Cargo.lock b/Cargo.lock index b3d6799..5b696a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,6 +404,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -719,6 +728,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2087,6 +2119,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2271,10 +2309,12 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" name = "slerm" version = "0.1.0" dependencies = [ + "ab_glyph", "anyhow", "base64", "eframe", "egui", + "fontdb", "libghostty-vt", "png", "portable-pty", @@ -2488,6 +2528,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -2570,6 +2625,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "type-map" diff --git a/Cargo.toml b/Cargo.toml index 8c46d4b..96293cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ license = "MIT" [dependencies] anyhow = "1" +ab_glyph = "0.2" +fontdb = "0.23" eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] } egui = "0.33" libghostty-vt = { version = "=0.2.1", features = ["png"] } diff --git a/config.example.toml b/config.example.toml index a89b2fd..8a82848 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,7 +2,20 @@ # Omit shell to use $SHELL. Arguments are separate array entries. # shell = ["/bin/bash", "--login"] # working_directory = "/home/you" -font_path = "/usr/local/share/fonts/MononokiNerdFontMono-Regular.ttf" +# Select an installed family; regular, bold, italic and bold italic are matched +# automatically. Install a patched Nerd Font to get its icons. +# font_family = "GeistMono Nerd Font Mono" +# Alternatively load a file; matching style faces are discovered beside it. +# font_path = "/usr/local/share/fonts/MononokiNerdFontMono-Regular.ttf" +# Optional overrides for individual faces: +# font_bold_path = "/path/to/Bold.ttf" +# font_italic_path = "/path/to/Italic.ttf" +# font_bold_italic_path = "/path/to/BoldItalic.ttf" +# Ordered installed fallback families, used for missing glyphs in every style. +# font_fallbacks = ["Symbols Nerd Font Mono"] +# Symbols Nerd Font Mono (or Symbols Nerd Font) is also used automatically when +# installed. Missing styles use the closest available face in the family; +# regular-only fonts retain synthetic bold/italic emphasis. font_size = 16 scrollback = 10000 window_width = 1100 diff --git a/src/app.rs b/src/app.rs index 22e547d..36766f6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -55,19 +55,7 @@ fn drag_window(ui: &mut egui::Ui) { } pub fn fonts(config: &Config) -> Result { - let mut fonts = egui::FontDefinitions::default(); - if let Some(path) = &config.font_path { - let data = std::fs::read(path)?; - fonts - .font_data - .insert("terminal".into(), egui::FontData::from_owned(data).into()); - fonts - .families - .get_mut(&egui::FontFamily::Monospace) - .unwrap() - .insert(0, "terminal".into()); - } - Ok(fonts) + crate::fonts::load(config) } impl App { diff --git a/src/config.rs b/src/config.rs index 5cbcc6d..38bccb4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,11 @@ pub struct Config { pub working_directory: Option, pub font_size: f32, pub font_path: Option, + pub font_family: Option, + pub font_bold_path: Option, + pub font_italic_path: Option, + pub font_bold_italic_path: Option, + pub font_fallbacks: Vec, pub scrollback: usize, pub window_width: f32, pub window_height: f32, @@ -99,6 +104,11 @@ impl Default for Config { working_directory: None, font_size: 16.0, font_path: None, + font_family: None, + font_bold_path: None, + font_italic_path: None, + font_bold_italic_path: None, + font_fallbacks: Vec::new(), scrollback: 10000, window_width: 1100.0, window_height: 720.0, @@ -153,6 +163,18 @@ impl Config { pub fn parse(text: &str) -> Result { let config: Self = toml::from_str(text)?; + ensure!( + config.font_path.is_none() || config.font_family.is_none(), + "set either font_family or font_path, not both" + ); + ensure!( + config + .font_family + .iter() + .chain(&config.font_fallbacks) + .all(|name| !name.trim().is_empty()), + "font family names must not be empty" + ); ensure!( !config.shell.is_empty() && !config.shell[0].is_empty(), "shell must contain an executable" @@ -250,6 +272,9 @@ mod tests { assert_eq!(example.keys, Config::default().keys); for text in [ "font_size = nan", + "font_family = ''", + "font_fallbacks = [' ']", + "font_family = 'Test'\nfont_path = '/font.ttf'", "shell = []", "background = '#xx0000'", "font_sze = 16", diff --git a/src/fonts.rs b/src/fonts.rs new file mode 100644 index 0000000..c64aff9 --- /dev/null +++ b/src/fonts.rs @@ -0,0 +1,268 @@ +use crate::config::Config; +use anyhow::{Context, Result, ensure}; +use egui::{FontData, FontDefinitions, FontFamily}; +use fontdb::{Database, Family, ID, Query, Style, Weight}; +use std::{collections::HashMap, path::Path}; + +pub fn family(bold: bool, italic: bool) -> FontFamily { + match (bold, italic) { + (false, false) => FontFamily::Monospace, + (true, false) => FontFamily::Name("terminal-bold".into()), + (false, true) => FontFamily::Name("terminal-italic".into()), + (true, true) => FontFamily::Name("terminal-bold-italic".into()), + } +} + +fn query(db: &Database, name: &str, bold: bool, italic: bool) -> Option { + db.query(&Query { + families: &[Family::Name(name)], + weight: if bold { Weight::BOLD } else { Weight::NORMAL }, + style: if italic { Style::Italic } else { Style::Normal }, + ..Default::default() + }) +} + +fn load_file(db: &mut Database, path: &Path) -> Result { + let bytes = + std::fs::read(path).with_context(|| format!("Cannot read font {}", path.display()))?; + db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(bytes))) + .first() + .copied() + .with_context(|| format!("No font faces in {}", path.display())) +} + +fn register( + db: &Database, + fonts: &mut FontDefinitions, + loaded: &mut HashMap, + id: ID, +) -> Result { + if let Some(name) = loaded.get(&id) { + return Ok(name.clone()); + } + let face = db.face(id).context("Font face disappeared")?; + let data = db + .with_face_data(id, |bytes, index| { + // Validate with the renderer's parser too, so a bad font becomes a + // reload error rather than a panic inside egui on the next frame. + ab_glyph::FontRef::try_from_slice_and_index(bytes, index) + .with_context(|| format!("Unsupported font: {}", face.post_script_name))?; + let mut data = FontData::from_owned(bytes.to_vec()); + data.index = index; + Ok::<_, anyhow::Error>(data) + }) + .context("Cannot read font face data")??; + let name = format!("terminal-face-{}", loaded.len()); + fonts.font_data.insert(name.clone(), data.into()); + loaded.insert(id, name.clone()); + Ok(name) +} + +pub fn load(config: &Config) -> Result { + let mut db = Database::new(); + // Preserve the built-in default when no family is configured. System + // fonts also supply optional Nerd Font symbols and named fallbacks. + db.load_system_fonts(); + load_from_database(config, db) +} + +fn load_from_database(config: &Config, mut db: Database) -> Result { + let mut primary = config.font_family.clone(); + let regular = if let Some(path) = &config.font_path { + let id = load_file(&mut db, path)?; + primary = db + .face(id) + .and_then(|face| face.families.first()) + .map(|f| f.0.clone()); + // Inspect sibling metadata, not filename suffixes: foundries use + // different style names, and collections contain multiple faces. + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + for entry in std::fs::read_dir(parent)?.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()).is_some_and(|e| { + ["ttf", "otf", "ttc", "otc"] + .iter() + .any(|ext| e.eq_ignore_ascii_case(ext)) + }) { + let _ = db.load_font_file(path); + } + } + Some(id) + } else if let Some(name) = &primary { + Some( + query(&db, name, false, false) + .with_context(|| format!("Font family not found: {name}"))?, + ) + } else { + None + }; + for name in &config.font_fallbacks { + ensure!( + query(&db, name, false, false).is_some(), + "Fallback font family not found: {name}" + ); + } + let mut fallbacks = config.font_fallbacks.clone(); + for name in ["Symbols Nerd Font Mono", "Symbols Nerd Font"] { + if query(&db, name, false, false).is_some() && !fallbacks.iter().any(|f| f == name) { + fallbacks.push(name.into()); + break; + } + } + let mut fonts = FontDefinitions::default(); + let builtin = fonts.families[&FontFamily::Monospace].clone(); + let mut loaded = HashMap::new(); + for (bold, italic, path) in [ + (false, false, None), + (true, false, config.font_bold_path.as_deref()), + (false, true, config.font_italic_path.as_deref()), + (true, true, config.font_bold_italic_path.as_deref()), + ] { + let id = if let Some(path) = path { + Some(load_file(&mut db, path)?) + } else if !bold && !italic { + regular + } else { + primary + .as_ref() + .and_then(|name| query(&db, name, bold, italic)) + .or(regular) + }; + let mut chain = Vec::new(); + if let Some(id) = id { + chain.push(register(&db, &mut fonts, &mut loaded, id)?); + } else { + // A symbols-only fallback must not set the terminal's line metrics. + chain.push(builtin[0].clone()); + } + // A style face may lack symbols present in its regular face. + if let Some(id) = regular { + let name = register(&db, &mut fonts, &mut loaded, id)?; + if !chain.contains(&name) { + chain.push(name); + } + } + for name in &fallbacks { + for id in [ + query(&db, name, bold, italic), + query(&db, name, false, false), + ] + .into_iter() + .flatten() + { + let name = register(&db, &mut fonts, &mut loaded, id)?; + if !chain.contains(&name) { + chain.push(name); + } + } + } + chain.extend(builtin.iter().cloned()); + fonts.families.insert(family(bold, italic), chain); + } + Ok(fonts) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn database() -> Database { + let mut db = Database::new(); + let fonts = FontDefinitions::default(); + let data = &fonts.font_data[&fonts.families[&FontFamily::Monospace][0]]; + db.load_font_data(data.font.to_vec()); + let template = db.faces().next().unwrap().clone(); + for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] { + let mut face = template.clone(); + face.families[0].0 = "Test Family".into(); + face.weight = if bold { Weight::BOLD } else { Weight::NORMAL }; + face.style = if italic { Style::Italic } else { Style::Normal }; + db.push_face_info(face); + } + db + } + + #[test] + fn matches_styles_and_keeps_regular_as_fallback() { + let config = Config { + font_family: Some("Test Family".into()), + ..Default::default() + }; + let db = database(); + let mut ids = std::collections::HashSet::new(); + for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] { + let id = query(&db, "Test Family", bold, italic).unwrap(); + assert!(ids.insert(id), "styles must select distinct faces"); + let face = db.face(id).unwrap(); + assert_eq!(face.weight == Weight::BOLD, bold); + assert_eq!(face.style == Style::Italic, italic); + } + let fonts = load_from_database(&config, db).unwrap(); + let regular = &fonts.families[&family(false, false)][0]; + for (bold, italic) in [(true, false), (false, true), (true, true)] { + let chain = &fonts.families[&family(bold, italic)]; + assert_ne!(&chain[0], regular); + assert_eq!(&chain[1], regular); + } + } + + #[test] + fn rejects_missing_families_and_accepts_regular_only_fallback() { + let mut config = Config { + font_family: Some("Not Installed".into()), + ..Default::default() + }; + assert!(load_from_database(&config, database()).is_err()); + config.font_family = None; + config.font_fallbacks = vec!["Not Installed".into()]; + assert!(load_from_database(&config, database()).is_err()); + let db = database(); + let name = db.faces().next().unwrap().families[0].0.clone(); + config.font_fallbacks = vec![name]; + let fonts = load_from_database(&config, db).unwrap(); + assert_eq!( + fonts.families[&family(false, false)], + fonts.families[&family(true, true)] + ); + } + + #[test] + #[ignore = "requires SLERM_TEST_FONT pointing to a Nerd Font regular face with sibling styles"] + fn installed_nerd_font_family_and_sibling_styles() { + use ab_glyph::Font; + let path = std::env::var_os("SLERM_TEST_FONT").expect("set SLERM_TEST_FONT"); + let config = Config { + font_path: Some(path.into()), + ..Default::default() + }; + let fonts = load(&config).unwrap(); + let mut faces = std::collections::HashSet::new(); + for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] { + let name = &fonts.families[&family(bold, italic)][0]; + assert!(faces.insert(name.clone())); + let data = &fonts.font_data[name]; + let face = ab_glyph::FontRef::try_from_slice_and_index(&data.font, data.index).unwrap(); + for c in ['M', '\u{f121}', '\u{e0a0}', '\u{f0001}'] { + assert_ne!(face.glyph_id(c).0, 0, "missing {c:?} in {name}"); + } + } + let mut db = Database::new(); + let id = load_file(&mut db, config.font_path.as_ref().unwrap()).unwrap(); + let name = db.face(id).unwrap().families[0].0.clone(); + eprintln!("Verified installed family: {name}"); + let named = load(&Config { + font_family: Some(name), + ..Default::default() + }) + .unwrap(); + for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] { + let a = &fonts.font_data[&fonts.families[&family(bold, italic)][0]]; + let b = &named.font_data[&named.families[&family(bold, italic)][0]]; + assert_eq!(a.font, b.font); + assert_eq!(a.index, b.index); + } + } +} diff --git a/src/main.rs b/src/main.rs index 6da6eb3..fd5d744 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod config; +mod fonts; mod input; mod layout; mod pane; diff --git a/src/render.rs b/src/render.rs index 5cb4451..0d8464c 100644 --- a/src/render.rs +++ b/src/render.rs @@ -7,6 +7,7 @@ use libghostty_vt::{ alloc::{Allocator, Bytes}, kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator}, render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator}, + screen::CellWide, style::{RgbColor, Underline}, }; use std::{ @@ -128,13 +129,51 @@ struct CachedRow { } fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc { + layout_styled_glyph(painter, text, font, height, false) +} + +fn layout_styled_glyph( + painter: &Painter, + text: String, + font: &FontId, + height: f32, + italics: bool, +) -> Arc { let mut job = egui::text::LayoutJob::simple(text, font.clone(), Color32::PLACEHOLDER, f32::INFINITY); job.sections[0].format.line_height = Some(height); job.sections[0].format.valign = egui::Align::Center; + job.sections[0].format.italics = italics; painter.layout_job(job) } +fn nerd_icon(text: &str) -> bool { + // Includes the supplementary private-use plane used by Material Design. + text.chars() + .next() + .is_some_and(|c| matches!(c as u32, 0xe000..=0xf8ff | 0xf0000..=0xffffd)) +} + +fn icon_shape(galley: Arc, rect: Rect, color: Color32) -> egui::Shape { + // Use ink bounds: patched fonts can have bearings or outlines that extend + // well beyond their nominal advance. Fit uniformly and center in the cell + // allocation so icons cannot cover neighboring text or rows. + let bounds = galley.mesh_bounds; + let mut shape = egui::epaint::TextShape::new(egui::Pos2::ZERO, galley, color); + if bounds.is_positive() { + let scale = (rect.width() / bounds.width()) + .min(rect.height() / bounds.height()) + .min(1.0); + shape.transform(egui::emath::TSTransform { + scaling: scale, + translation: rect.center().to_vec2() - bounds.center().to_vec2() * scale, + }); + } else { + shape.pos = rect.min; + } + shape.into() +} + pub struct Renderer { state: RenderState<'static>, rows: RowIterator<'static>, @@ -142,8 +181,9 @@ pub struct Renderer { placements: PlacementIterator<'static>, textures: HashMap, row_cache: Vec, - glyphs: HashMap>, + glyphs: HashMap<(String, bool, bool), Arc>, font_marker: Option>, + synthetic_styles: [(bool, bool); 4], cached_colors: Option, cached_cell: egui::Vec2, cached_cols: u16, @@ -162,6 +202,7 @@ impl Renderer { row_cache: Vec::new(), glyphs: HashMap::new(), font_marker: None, + synthetic_styles: [(false, false); 4], cached_colors: None, cached_cell: egui::Vec2::ZERO, cached_cols: 0, @@ -183,18 +224,46 @@ impl Renderer { // Keep one egui galley alive in its cache each frame. A new identity means // fonts/DPI/the atlas were reset, invalidating every cached glyph's UVs. let marker = layout_glyph(&painter, "M".into(), font, cell.y); - if self + let fonts_changed = self .font_marker .as_ref() .is_none_or(|old| !Arc::ptr_eq(old, &marker)) || self.cached_cell != cell - || self.glyphs.len() > 4096 - { + || self.glyphs.len() > 4096; + if fonts_changed { self.row_cache.clear(); self.glyphs.clear(); } self.font_marker = Some(marker); self.cached_cell = cell; + let style_fonts = painter.ctx().fonts(|fonts| { + [(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| { + let family = crate::fonts::family(bold, italic); + if font.family == egui::FontFamily::Monospace + && fonts.definitions().families.contains_key(&family) + { + FontId::new(font.size, family) + } else { + font.clone() + } + }) + }); + if fonts_changed { + self.synthetic_styles = painter.ctx().fonts(|fonts| { + let first_face = |index: usize| { + fonts.definitions().families[&style_fonts[index].family] + .first() + .map(|name| &fonts.definitions().font_data[name]) + }; + std::array::from_fn::<_, 4, _>(|index| { + ( + index & 1 != 0 && first_face(index) == first_face(index & !1), + index & 2 != 0 && first_face(index) == first_face(index & !2), + ) + }) + }); + } + let synthetic = self.synthetic_styles; let snapshot = self.state.update(term)?; let colors = snapshot.colors()?; let cols = snapshot.cols()?; @@ -246,27 +315,52 @@ impl Renderer { if style.faint { fg = fg.gamma_multiply(0.6); } - if !style.invisible && c.graphemes_len()? > 0 { + let wide = c.raw_cell()?.wide()?; + if !style.invisible + && !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail) + && c.graphemes_len()? > 0 + { c.graphemes_utf8(&mut text)?; // Placeholder cells are reserved by the Kitty protocol, not printable text. if let Some(separator) = powerline_shape(&text, cell_rect, fg) { foregrounds.push(separator); } else if text != " " && !text.starts_with('\u{10eeee}') { - let galley = if let Some(galley) = self.glyphs.get(&text) { + let index = usize::from(style.bold) + 2 * usize::from(style.italic); + let icon = nerd_icon(&text); + let key = (text.clone(), style.bold, style.italic); + let galley = if let Some(galley) = self.glyphs.get(&key) { galley.clone() } else { - let galley = layout_glyph(&painter, text.clone(), font, cell.y); - self.glyphs.insert(text.clone(), galley.clone()); + let font = &style_fonts[index]; + let galley = layout_styled_glyph( + &painter, + text.clone(), + font, + cell.y, + synthetic[index].1 && !icon, + ); + self.glyphs.insert(key, galley.clone()); galley }; - foregrounds.push(egui::Shape::galley(pos, galley.clone(), fg)); - if style.bold { + // Keep style emphasis for regular-only fonts; real style + // faces are drawn once. Never distort symbol outlines. + if synthetic[index].0 && !icon { foregrounds.push(egui::Shape::galley( pos + vec2(0.45, 0.0), - galley, + galley.clone(), fg, )); } + foregrounds.push(if icon { + let width = if wide == CellWide::Wide { 2.0 } else { 1.0 }; + icon_shape( + galley, + Rect::from_min_size(pos, vec2(cell.x * width, cell.y)), + fg, + ) + } else { + egui::Shape::galley(pos, galley, fg) + }); } } for (enabled, height) in [ @@ -451,6 +545,82 @@ fn paint_images( mod tests { use super::*; + #[test] + fn sgr_styles_use_distinct_cached_faces_without_double_drawing() { + let ctx = egui::Context::default(); + let config = crate::config::Config { + font_path: std::env::var_os("SLERM_TEST_FONT").map(Into::into), + ..Default::default() + }; + ctx.set_fonts(crate::fonts::load(&config).unwrap()); + let mut term = crate::pane::terminal(&config).unwrap(); + term.resize(20, 2, 8, 16).unwrap(); + term.vt_write(b"M\x1b[1mM\x1b[22;3mM\x1b[1mM\x1b[0mM"); + let mut renderer = Renderer::new().unwrap(); + let rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(200.0, 100.0)); + let font = FontId::monospace(16.0); + frame(&ctx, &mut renderer, &term, rect, &font); + assert_eq!(renderer.glyphs.len(), 4); + assert_eq!( + renderer.row_cache[0].foregrounds.len(), + if config.font_path.is_some() { 5 } else { 7 } + ); + for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] { + let galley = &renderer.glyphs[&("M".into(), bold, italic)]; + assert_eq!( + galley.job.sections[0].format.font_id.family, + crate::fonts::family(bold, italic) + ); + } + let before = renderer.glyphs[&("M".into(), true, true)].clone(); + let mut fonts = crate::fonts::load(&config).unwrap(); + fonts + .families + .get_mut(&crate::fonts::family(true, true)) + .unwrap() + .reverse(); + ctx.set_fonts(fonts); + frame(&ctx, &mut renderer, &term, rect, &font); + assert!(!Arc::ptr_eq( + &before, + &renderer.glyphs[&("M".into(), true, true)] + )); + } + + #[test] + fn icons_fit_ink_bounds_at_different_scales_and_cell_widths() { + let config = crate::config::Config { + font_path: std::env::var_os("SLERM_TEST_FONT").map(Into::into), + ..Default::default() + }; + let fonts = crate::fonts::load(&config).unwrap(); + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + ctx.set_fonts(fonts.clone()); + ctx.set_pixels_per_point(scale); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + let painter = ctx.layer_painter(egui::LayerId::background()); + for text in ["\u{e0a0}", "\u{f121}", "\u{f0001}"] { + assert!(nerd_icon(text)); + for width in [4.0, 9.0, 18.0] { + let rect = Rect::from_min_size(pos2(23.0, 41.0), vec2(width, 18.0)); + let galley = + layout_glyph(&painter, text.into(), &FontId::monospace(20.0), 18.0); + let shape = icon_shape(galley, rect, Color32::WHITE); + let bounds = shape.visual_bounding_rect(); + assert!( + rect.expand(0.001).contains_rect(bounds), + "{text:?}: {bounds:?} outside {rect:?}" + ); + assert!(bounds.center().distance(rect.center()) < 0.001); + } + } + }); + } + assert!(!nerd_icon("M")); + assert!(!nerd_icon("\u{10eeee}")); + } + fn frame( ctx: &egui::Context, renderer: &mut Renderer,