Merge branch 'font-rendering-fix'
This commit is contained in:
Generated
+58
@@ -404,6 +404,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_maths"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||||
|
dependencies = [
|
||||||
|
"libm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -719,6 +728,29 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
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]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -2087,6 +2119,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roxmltree"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2271,10 +2309,12 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|||||||
name = "slerm"
|
name = "slerm"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ab_glyph",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
|
"fontdb",
|
||||||
"libghostty-vt",
|
"libghostty-vt",
|
||||||
"png",
|
"png",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
@@ -2488,6 +2528,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.9.12+spec-1.1.0"
|
version = "0.9.12+spec-1.1.0"
|
||||||
@@ -2570,6 +2625,9 @@ name = "ttf-parser"
|
|||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||||
|
dependencies = [
|
||||||
|
"core_maths",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "type-map"
|
name = "type-map"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ license = "MIT"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
ab_glyph = "0.2"
|
||||||
|
fontdb = "0.23"
|
||||||
eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
|
eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
|
||||||
egui = "0.33"
|
egui = "0.33"
|
||||||
libghostty-vt = { version = "=0.2.1", features = ["png"] }
|
libghostty-vt = { version = "=0.2.1", features = ["png"] }
|
||||||
|
|||||||
+14
-1
@@ -2,7 +2,20 @@
|
|||||||
# Omit shell to use $SHELL. Arguments are separate array entries.
|
# Omit shell to use $SHELL. Arguments are separate array entries.
|
||||||
# shell = ["/bin/bash", "--login"]
|
# shell = ["/bin/bash", "--login"]
|
||||||
# working_directory = "/home/you"
|
# 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
|
font_size = 16
|
||||||
scrollback = 10000
|
scrollback = 10000
|
||||||
window_width = 1100
|
window_width = 1100
|
||||||
|
|||||||
+1
-13
@@ -56,19 +56,7 @@ fn drag_window(ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn fonts(config: &Config) -> Result<egui::FontDefinitions> {
|
pub fn fonts(config: &Config) -> Result<egui::FontDefinitions> {
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
crate::fonts::load(config)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ pub struct Config {
|
|||||||
pub working_directory: Option<PathBuf>,
|
pub working_directory: Option<PathBuf>,
|
||||||
pub font_size: f32,
|
pub font_size: f32,
|
||||||
pub font_path: Option<PathBuf>,
|
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 scrollback: usize,
|
||||||
pub window_width: f32,
|
pub window_width: f32,
|
||||||
pub window_height: f32,
|
pub window_height: f32,
|
||||||
@@ -104,6 +109,11 @@ impl Default for Config {
|
|||||||
working_directory: None,
|
working_directory: None,
|
||||||
font_size: 16.0,
|
font_size: 16.0,
|
||||||
font_path: None,
|
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,
|
scrollback: 10000,
|
||||||
window_width: 1100.0,
|
window_width: 1100.0,
|
||||||
window_height: 720.0,
|
window_height: 720.0,
|
||||||
@@ -160,6 +170,18 @@ impl Config {
|
|||||||
|
|
||||||
pub fn parse(text: &str) -> Result<Self> {
|
pub fn parse(text: &str) -> Result<Self> {
|
||||||
let config: Self = toml::from_str(text)?;
|
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!(
|
ensure!(
|
||||||
!config.shell.is_empty() && !config.shell[0].is_empty(),
|
!config.shell.is_empty() && !config.shell[0].is_empty(),
|
||||||
"shell must contain an executable"
|
"shell must contain an executable"
|
||||||
@@ -281,6 +303,9 @@ mod tests {
|
|||||||
assert_eq!(example.keys, Config::default().keys);
|
assert_eq!(example.keys, Config::default().keys);
|
||||||
for text in [
|
for text in [
|
||||||
"font_size = nan",
|
"font_size = nan",
|
||||||
|
"font_family = ''",
|
||||||
|
"font_fallbacks = [' ']",
|
||||||
|
"font_family = 'Test'\nfont_path = '/font.ttf'",
|
||||||
"shell = []",
|
"shell = []",
|
||||||
"background = '#xx0000'",
|
"background = '#xx0000'",
|
||||||
"font_sze = 16",
|
"font_sze = 16",
|
||||||
|
|||||||
+268
@@ -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,5 +1,6 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod fonts;
|
||||||
mod input;
|
mod input;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod pane;
|
mod pane;
|
||||||
|
|||||||
+181
-11
@@ -8,6 +8,7 @@ use libghostty_vt::{
|
|||||||
alloc::{Allocator, Bytes},
|
alloc::{Allocator, Bytes},
|
||||||
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
||||||
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
|
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
|
||||||
|
screen::CellWide,
|
||||||
style::{RgbColor, Underline},
|
style::{RgbColor, Underline},
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
@@ -140,13 +141,51 @@ struct CachedRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> {
|
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 =
|
let mut job =
|
||||||
egui::text::LayoutJob::simple(text, font.clone(), Color32::PLACEHOLDER, f32::INFINITY);
|
egui::text::LayoutJob::simple(text, font.clone(), Color32::PLACEHOLDER, f32::INFINITY);
|
||||||
job.sections[0].format.line_height = Some(height);
|
job.sections[0].format.line_height = Some(height);
|
||||||
job.sections[0].format.valign = egui::Align::Center;
|
job.sections[0].format.valign = egui::Align::Center;
|
||||||
|
job.sections[0].format.italics = italics;
|
||||||
painter.layout_job(job)
|
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 {
|
pub struct Renderer {
|
||||||
state: RenderState<'static>,
|
state: RenderState<'static>,
|
||||||
rows: RowIterator<'static>,
|
rows: RowIterator<'static>,
|
||||||
@@ -154,8 +193,9 @@ pub struct Renderer {
|
|||||||
placements: PlacementIterator<'static>,
|
placements: PlacementIterator<'static>,
|
||||||
textures: HashMap<u32, (u64, TextureHandle)>,
|
textures: HashMap<u32, (u64, TextureHandle)>,
|
||||||
row_cache: Vec<CachedRow>,
|
row_cache: Vec<CachedRow>,
|
||||||
glyphs: HashMap<String, Arc<egui::Galley>>,
|
glyphs: HashMap<(String, bool, bool), Arc<egui::Galley>>,
|
||||||
font_marker: Option<Arc<egui::Galley>>,
|
font_marker: Option<Arc<egui::Galley>>,
|
||||||
|
synthetic_styles: [(bool, bool); 4],
|
||||||
cached_colors: Option<Colors>,
|
cached_colors: Option<Colors>,
|
||||||
cached_selection: (Color32, Color32),
|
cached_selection: (Color32, Color32),
|
||||||
cached_cell: egui::Vec2,
|
cached_cell: egui::Vec2,
|
||||||
@@ -175,6 +215,7 @@ impl Renderer {
|
|||||||
row_cache: Vec::new(),
|
row_cache: Vec::new(),
|
||||||
glyphs: HashMap::new(),
|
glyphs: HashMap::new(),
|
||||||
font_marker: None,
|
font_marker: None,
|
||||||
|
synthetic_styles: [(false, false); 4],
|
||||||
cached_colors: None,
|
cached_colors: None,
|
||||||
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
|
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
|
||||||
cached_cell: egui::Vec2::ZERO,
|
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
|
// 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.
|
// fonts/DPI/the atlas were reset, invalidating every cached glyph's UVs.
|
||||||
let marker = layout_glyph(&painter, "M".into(), font, cell.y);
|
let marker = layout_glyph(&painter, "M".into(), font, cell.y);
|
||||||
if self
|
let fonts_changed = self
|
||||||
.font_marker
|
.font_marker
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_none_or(|old| !Arc::ptr_eq(old, &marker))
|
.is_none_or(|old| !Arc::ptr_eq(old, &marker))
|
||||||
|| self.cached_cell != cell
|
|| self.cached_cell != cell
|
||||||
|| self.glyphs.len() > 4096
|
|| self.glyphs.len() > 4096;
|
||||||
{
|
if fonts_changed {
|
||||||
self.row_cache.clear();
|
self.row_cache.clear();
|
||||||
self.glyphs.clear();
|
self.glyphs.clear();
|
||||||
}
|
}
|
||||||
self.font_marker = Some(marker);
|
self.font_marker = Some(marker);
|
||||||
self.cached_cell = cell;
|
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 snapshot = self.state.update(term)?;
|
||||||
let colors = snapshot.colors()?;
|
let colors = snapshot.colors()?;
|
||||||
let cols = snapshot.cols()?;
|
let cols = snapshot.cols()?;
|
||||||
@@ -275,27 +344,52 @@ impl Renderer {
|
|||||||
if style.faint {
|
if style.faint {
|
||||||
fg = fg.gamma_multiply(0.6);
|
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)?;
|
c.graphemes_utf8(&mut text)?;
|
||||||
// Placeholder cells are reserved by the Kitty protocol, not printable text.
|
// Placeholder cells are reserved by the Kitty protocol, not printable text.
|
||||||
if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
|
if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
|
||||||
foregrounds.push(separator);
|
foregrounds.push(separator);
|
||||||
} else if text != " " && !text.starts_with('\u{10eeee}') {
|
} 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()
|
galley.clone()
|
||||||
} else {
|
} else {
|
||||||
let galley = layout_glyph(&painter, text.clone(), font, cell.y);
|
let font = &style_fonts[index];
|
||||||
self.glyphs.insert(text.clone(), galley.clone());
|
let galley = layout_styled_glyph(
|
||||||
|
&painter,
|
||||||
|
text.clone(),
|
||||||
|
font,
|
||||||
|
cell.y,
|
||||||
|
synthetic[index].1 && !icon,
|
||||||
|
);
|
||||||
|
self.glyphs.insert(key, galley.clone());
|
||||||
galley
|
galley
|
||||||
};
|
};
|
||||||
foregrounds.push(egui::Shape::galley(pos, galley.clone(), fg));
|
// Keep style emphasis for regular-only fonts; real style
|
||||||
if style.bold {
|
// faces are drawn once. Never distort symbol outlines.
|
||||||
|
if synthetic[index].0 && !icon {
|
||||||
foregrounds.push(egui::Shape::galley(
|
foregrounds.push(egui::Shape::galley(
|
||||||
pos + vec2(0.45, 0.0),
|
pos + vec2(0.45, 0.0),
|
||||||
galley,
|
galley.clone(),
|
||||||
fg,
|
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 [
|
for (enabled, height) in [
|
||||||
@@ -480,6 +574,82 @@ fn paint_images(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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(
|
fn frame(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
renderer: &mut Renderer,
|
renderer: &mut Renderer,
|
||||||
|
|||||||
Reference in New Issue
Block a user