Merge branch 'font-rendering-fix'

This commit is contained in:
Gregor Lohaus
2026-09-08 10:44:03 +02:00
8 changed files with 550 additions and 25 deletions
Generated
+58
View File
@@ -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"
+2
View File
@@ -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"] }
+14 -1
View File
@@ -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
+1 -13
View File
@@ -56,19 +56,7 @@ fn drag_window(ui: &mut egui::Ui) {
}
pub fn fonts(config: &Config) -> Result<egui::FontDefinitions> {
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 {
+25
View File
@@ -15,6 +15,11 @@ pub struct Config {
pub working_directory: Option<PathBuf>,
pub font_size: f32,
pub font_path: Option<PathBuf>,
pub font_family: Option<String>,
pub font_bold_path: Option<PathBuf>,
pub font_italic_path: Option<PathBuf>,
pub font_bold_italic_path: Option<PathBuf>,
pub font_fallbacks: Vec<String>,
pub scrollback: usize,
pub window_width: f32,
pub window_height: f32,
@@ -104,6 +109,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,
@@ -160,6 +170,18 @@ impl Config {
pub fn parse(text: &str) -> Result<Self> {
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"
@@ -281,6 +303,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",
+268
View File
@@ -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<ID> {
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<ID> {
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, String>,
id: ID,
) -> Result<String> {
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<FontDefinitions> {
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<FontDefinitions> {
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);
}
}
}
+1
View File
@@ -1,5 +1,6 @@
mod app;
mod config;
mod fonts;
mod input;
mod layout;
mod pane;
+181 -11
View File
@@ -8,6 +8,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::{
@@ -140,13 +141,51 @@ struct CachedRow {
}
fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> {
layout_styled_glyph(painter, text, font, height, false)
}
fn layout_styled_glyph(
painter: &Painter,
text: String,
font: &FontId,
height: f32,
italics: bool,
) -> Arc<egui::Galley> {
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<egui::Galley>, 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>,
@@ -154,8 +193,9 @@ pub struct Renderer {
placements: PlacementIterator<'static>,
textures: HashMap<u32, (u64, TextureHandle)>,
row_cache: Vec<CachedRow>,
glyphs: HashMap<String, Arc<egui::Galley>>,
glyphs: HashMap<(String, bool, bool), Arc<egui::Galley>>,
font_marker: Option<Arc<egui::Galley>>,
synthetic_styles: [(bool, bool); 4],
cached_colors: Option<Colors>,
cached_selection: (Color32, Color32),
cached_cell: egui::Vec2,
@@ -175,6 +215,7 @@ impl Renderer {
row_cache: Vec::new(),
glyphs: HashMap::new(),
font_marker: None,
synthetic_styles: [(false, false); 4],
cached_colors: None,
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
cached_cell: egui::Vec2::ZERO,
@@ -201,18 +242,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()?;
@@ -275,27 +344,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 [
@@ -480,6 +574,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,