feat(font): add the FreeType-only font backend (font-backend-freetype)

Ghostty can be built with -Dfont-backend=freetype, which uses FreeType
without any font discovery. The Rust port only had fontconfig+freetype
(default on Linux) and the CoreText variants. The new cargo feature
`font-backend-freetype` on ghostty-font (forwarded by ghostty-cli and
the root crate; non-Apple, non-wasm targets) behaves like Zig's mode:
font-family discovery, fallback search and codepoint-map overrides are
skipped (embedded fonts only); +list-fonts and +show-face print Zig's
"built without a font discovery mechanism" message and exit 1; +version
reports `.freetype`. The default backend is unchanged.

Also registers the Rust-only benchmark tooling extensions (--time,
--handler=full, ghostty-synthetic +sgr) in DEVIANT_FIXES.md.

Note: `--all-features` now selects this backend for the workspace.

Test Plan:
- cargo test -p ghostty-font --features font-backend-freetype (198)
- cargo build --features font-backend-freetype && ghostty +list-fonts
This commit is contained in:
ddidderr committed 2026-10-12 15:56:21 +02:00
1 parent 798a31ccac
commit 389266720d
18 files changed
+327 -12

No files matched your search

+5
View File
@@ -58,6 +58,11 @@ coretext-harfbuzz = ["dep:harfbuzz-sys"]
# CoreText for discovery and rendering, no shaping
# (Zig `-Dfont-backend=coretext_noshape`; Apple targets only).
coretext-noshape = []
# FreeType for rendering and HarfBuzz for shaping with no font discovery
# (Zig `-Dfont-backend=freetype`; non-Apple, non-wasm targets only; the
# default there is `fontconfig_freetype`). Not enabled by `--all-features`
# in any meaningful sense: it switches the whole build to the backend.
font-backend-freetype = []
# This crate wraps the freetype / harfbuzz / fontconfig C libraries through
# their `-sys` crates; unsafe is denied by default and allowed on minimal,
+18 -1
View File
@@ -3,6 +3,9 @@
//!
//! Backends (see `src/backend.rs`):
//! * Linux & other non-Apple, non-wasm targets: `fontconfig_freetype`.
//! * Linux & other non-Apple, non-wasm targets with the cargo feature
//! `font-backend-freetype`: `freetype` (FreeType rendering, HarfBuzz
//! shaping, no font discovery; Zig `-Dfont-backend=freetype`).
//! * wasm32: `web_canvas`.
//! * Apple targets: `coretext` (default), or via cargo features
//! `coretext-freetype`, `coretext-harfbuzz`, `coretext-noshape`.
@@ -10,6 +13,8 @@
//! Emitted cfgs:
//! * `font_freetype` - FreeType face / library are compiled.
//! * `font_fontconfig` - fontconfig discovery is compiled.
//! * `font_discovery` - a discovery mechanism (fontconfig or CoreText) exists
//! (Zig: `font.Discover != void`).
//! * `font_harfbuzz` - the HarfBuzz shaper is compiled.
//! * `font_coretext` - CoreText discovery/deferred face (Apple targets).
//! * `font_coretext_face` - the CoreText `Face` is the font face.
@@ -21,6 +26,7 @@ fn main() {
for c in [
"font_freetype",
"font_fontconfig",
"font_discovery",
"font_harfbuzz",
"font_coretext",
"font_coretext_face",
@@ -40,6 +46,15 @@ fn main() {
let ct_hb = feat("CORETEXT_HARFBUZZ");
let ct_noshape = feat("CORETEXT_NOSHAPE");
let ft_only = feat("FONT_BACKEND_FREETYPE");
if ft_only && (wasm || apple) {
println!(
"cargo:warning=feature `font-backend-freetype` only applies to non-Apple, \
non-wasm targets; ignored"
);
}
let ft_only = ft_only && !wasm && !apple;
let on = |c: &str, v: bool| {
if v {
println!("cargo:rustc-cfg={c}");
@@ -49,7 +64,9 @@ fn main() {
let freetype = !wasm && (!apple || ct_ft);
let harfbuzz = !wasm && (!apple || ct_ft || ct_hb);
on("font_freetype", freetype);
on("font_fontconfig", !wasm && !apple);
let fontconfig = !wasm && !apple && !ft_only;
on("font_fontconfig", fontconfig);
on("font_discovery", fontconfig || apple);
on("font_harfbuzz", harfbuzz);
on("font_coretext", apple);
on("font_coretext_face", apple && !ct_ft);
+63 -2
View File
@@ -13,6 +13,9 @@
//! (Zig: `-Dfont-backend=...`). "There is no scenario that is the default"
//! for those; they're only used by people who want to self-compile Ghostty
//! and prefer the freetype aesthetic.
//! * Non-Apple, non-wasm targets with the cargo feature
//! `font-backend-freetype`: [`Backend::Freetype`] (Zig
//! `-Dfont-backend=freetype`: no font discovery).
//! * wasm32: [`Backend::WebCanvas`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -50,6 +53,19 @@ impl Backend {
}
}
/// The Zig tag name (`@tagName`) of the backend.
pub fn name(self) -> &'static str {
match self {
Backend::Freetype => "freetype",
Backend::FontconfigFreetype => "fontconfig_freetype",
Backend::Coretext => "coretext",
Backend::CoretextFreetype => "coretext_freetype",
Backend::CoretextHarfbuzz => "coretext_harfbuzz",
Backend::CoretextNoshape => "coretext_noshape",
Backend::WebCanvas => "web_canvas",
}
}
pub fn has_freetype(self) -> bool {
matches!(
self,
@@ -88,7 +104,20 @@ pub struct Options {
pub backend: Backend,
}
#[cfg(all(not(target_arch = "wasm32"), not(target_vendor = "apple")))]
#[cfg(all(
not(target_arch = "wasm32"),
not(target_vendor = "apple"),
feature = "font-backend-freetype"
))]
pub const OPTIONS: Options = Options {
backend: Backend::Freetype,
};
#[cfg(all(
not(target_arch = "wasm32"),
not(target_vendor = "apple"),
not(feature = "font-backend-freetype")
))]
pub const OPTIONS: Options = Options {
backend: Backend::FontconfigFreetype,
};
@@ -182,13 +211,45 @@ mod tests {
}
}
#[cfg(all(not(target_arch = "wasm32"), not(target_vendor = "apple")))]
#[test]
fn freetype_capabilities() {
let b = Backend::Freetype;
assert!(b.has_freetype());
assert!(!b.has_fontconfig());
assert!(b.has_harfbuzz());
assert!(!b.has_coretext());
assert_eq!(b.name(), "freetype");
}
#[cfg(all(
not(target_arch = "wasm32"),
not(target_vendor = "apple"),
not(feature = "font-backend-freetype")
))]
#[test]
fn linux_backend_capabilities() {
let b = OPTIONS.backend;
assert_eq!(b, Backend::FontconfigFreetype);
assert!(b.has_freetype());
assert!(b.has_fontconfig());
assert!(b.has_harfbuzz());
assert!(!b.has_coretext());
}
#[cfg(all(
not(target_arch = "wasm32"),
not(target_vendor = "apple"),
feature = "font-backend-freetype"
))]
#[test]
fn freetype_only_backend_selected() {
let b = OPTIONS.backend;
assert_eq!(b, Backend::Freetype);
assert!(b.has_freetype());
assert!(!b.has_fontconfig());
assert!(b.has_harfbuzz());
assert!(!b.has_coretext());
// No discovery mechanism, as in Zig (`Discover == void`).
const { assert!(!crate::HAS_DISCOVERY) };
}
}
+49 -2
View File
@@ -10,6 +10,8 @@
use std::collections::HashMap;
#[cfg(font_discovery)]
use crate::collection::{AddOptions, Entry};
use crate::{
Atlas,
CodepointMap,
@@ -17,7 +19,7 @@ use crate::{
Presentation,
SpriteFace,
Style,
collection::{AddOptions, Collection, Entry, GetFaceError, Index, PresentationMode, Special},
collection::{Collection, GetFaceError, Index, PresentationMode, Special},
discovery::Discover,
face::{RenderError as FaceRenderError, RenderOptions},
sprite,
@@ -88,6 +90,7 @@ pub struct CodepointResolver {
/// The descriptor cache is used to cache the descriptor to font face
/// mapping for codepoint maps.
#[cfg_attr(not(font_discovery), allow(dead_code))]
descriptor_cache: DescriptorCache,
/// Set this to a non-null value to enable sprite glyph drawing. If this
@@ -108,6 +111,7 @@ pub struct CodepointResolver {
///
/// As in upstream, descriptors are compared by their `hashcode()` only.
#[derive(Default)]
#[cfg_attr(not(font_discovery), allow(dead_code))]
struct DescriptorCache(HashMap<u64, Option<Index>>);
impl CodepointResolver {
@@ -240,6 +244,27 @@ impl CodepointResolver {
.get_index(cp, Style::Regular, PresentationMode::Any)
}
/// Without a discovery mechanism (the `freetype` backend, Zig
/// `Discover == void`) there is no fallback search.
#[cfg(not(font_discovery))]
#[allow(clippy::unused_self)]
fn discover_fallback(
&mut self,
_cp: u32,
_style: Style,
_p: PresentationMode,
) -> Option<Index> {
None
}
/// Without a discovery mechanism, codepoint overrides cannot find fonts.
#[cfg(not(font_discovery))]
#[allow(clippy::unused_self)]
fn get_index_codepoint_override(&mut self, _cp: u32) -> Option<Index> {
None
}
#[cfg(font_discovery)]
fn discover_fallback(
&mut self,
cp: u32,
@@ -309,6 +334,7 @@ impl CodepointResolver {
/// Checks if the codepoint is in the map of codepoint overrides,
/// finds the override font, and returns it.
#[cfg(font_discovery)]
fn get_index_codepoint_override(&mut self, cp: u32) -> Option<Index> {
// Get our codepoint map. If we have no map set then we have no
// codepoint overrides and we're done.
@@ -459,7 +485,7 @@ mod tests {
use crate::{
Face,
Library,
collection::{LoadOptions, SizeAdjustment},
collection::{AddOptions, LoadOptions, SizeAdjustment},
embedded,
face::{DesiredSize, Options},
};
@@ -534,6 +560,27 @@ mod tests {
assert!(r.get_index(0x1FB00, Style::Regular, None).is_none());
}
/// `freetype` backend (no discovery): codepoint overrides and fallback
/// searches are disabled, so an unmapped codepoint is simply not found.
#[cfg(not(font_discovery))]
#[test]
fn freetype_backend_has_no_discovery_fallback_or_override() {
const { assert!(!crate::HAS_DISCOVERY) };
let lib = Library::init().unwrap();
let mut c = Collection::new();
c.load_options = Some(LoadOptions::new(lib.clone()));
add(&mut c, &lib, embedded::REGULAR, Style::Regular, 12.0);
let mut r = CodepointResolver::new(c);
assert!(r.discover.is_none());
assert!(r.get_index_codepoint_override('A' as u32).is_none());
// Not in the embedded font, and there is no discovery to ask.
assert!(r.get_index(0x4E2D, Style::Regular, None).is_none());
assert!(r.get_index(0x4E2D, Style::Bold, None).is_none());
// Embedded codepoints still resolve.
assert!(r.get_index('A' as u32, Style::Regular, None).is_some());
}
#[test]
fn get_index_disabled_font_style() {
let lib = Library::init().unwrap();
@@ -0,0 +1,51 @@
//! A deferred face represents a single font face with all the information
//! necessary to load it, but defers loading the full face until it is
//! needed.
//!
//! Port of `font/DeferredFace.zig` (`freetype` backend: no discovery, so
//! there is no deferred data and every face must already be loaded).
use crate::{
Face,
Library,
Presentation,
face::{self, Options},
};
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error(transparent)]
Face(#[from] face::FaceError),
}
/// With no discovery mechanism there is no data (all fields are `void` in
/// Zig for the `freetype` backend).
#[derive(Default)]
pub struct DeferredFace {}
impl DeferredFace {
/// Returns the family name of the font.
pub fn family_name(&self) -> Result<String, LoadError> {
Ok(String::new())
}
/// Returns the name of this face.
pub fn name(&self) -> Result<String, LoadError> {
Ok(String::new())
}
/// Load the deferred font face.
pub fn load(&mut self, _lib: &Library, _opts: Options) -> Result<Face, LoadError> {
// Unreachable because we must be already loaded or have the
// proper configuration for one of the other deferred mechanisms.
unreachable!("deferred face without a discovery mechanism");
}
/// Returns true if this face can satisfy the given codepoint and
/// presentation.
pub fn has_codepoint(&self, _cp: u32, _p: Option<Presentation>) -> bool {
// This is unreachable because discovery mechanisms terminate, and
// if we're not using a discovery mechanism, the face MUST be loaded.
unreachable!("deferred face without a discovery mechanism");
}
}
+36
View File
@@ -10,6 +10,42 @@ pub use fc_impl::*;
mod coretext_impl;
#[cfg(font_coretext)]
pub use coretext_impl::*;
/// The `freetype` backend has no discovery mechanism: Zig's
/// `Discover = void`. This is an uninhabited stand-in so that generic code
/// type-checks; it can never be constructed, and the callers check
/// [`crate::HAS_DISCOVERY`] (or `cfg(font_discovery)`) first.
#[cfg(not(any(font_fontconfig, font_coretext)))]
pub struct Discover(std::convert::Infallible);
#[cfg(not(any(font_fontconfig, font_coretext)))]
pub type DiscoverError = std::convert::Infallible;
#[cfg(not(any(font_fontconfig, font_coretext)))]
pub type NextError = std::convert::Infallible;
#[cfg(not(any(font_fontconfig, font_coretext)))]
pub struct DiscoverIterator(std::convert::Infallible);
#[cfg(not(any(font_fontconfig, font_coretext)))]
impl Discover {
#[must_use]
pub fn init() -> Discover {
unreachable!("no font discovery mechanism in this build")
}
pub fn discover(&self, _desc: &Descriptor) -> Result<DiscoverIterator, DiscoverError> {
match self.0 {}
}
}
#[cfg(not(any(font_fontconfig, font_coretext)))]
impl DiscoverIterator {
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Result<Option<crate::deferred_face::DeferredFace>, NextError> {
match self.0 {}
}
}
/// Descriptor is used to search for fonts (canonical definition in `ghostty-config`).
pub use ghostty_config::font_types::Descriptor;
+8
View File
@@ -19,6 +19,9 @@ pub mod config_types;
pub mod coretext_util;
#[cfg(font_fontconfig)]
pub mod deferred_face;
#[cfg(all(not(target_arch = "wasm32"), not(font_fontconfig), not(font_coretext)))]
#[path = "deferred_face_freetype.rs"]
pub mod deferred_face;
#[cfg(font_coretext)]
#[path = "deferred_face_coretext.rs"]
pub mod deferred_face;
@@ -48,6 +51,11 @@ pub mod sprite;
pub use atlas::Atlas;
pub use backend::{Backend, OPTIONS as options};
/// Whether a font discovery mechanism exists in this build (Zig:
/// `font.Discover != void`; false for the `freetype` and `web_canvas`
/// backends).
pub const HAS_DISCOVERY: bool = cfg!(font_discovery);
pub use codepoint_map::CodepointMap;
#[cfg(not(target_arch = "wasm32"))]
pub use codepoint_resolver::CodepointResolver;
+1 -1
View File
@@ -19,5 +19,5 @@ pub mod run;
pub mod web_canvas;
#[cfg(all(test, font_harfbuzz, font_fontconfig))]
#[cfg(all(test, font_harfbuzz))]
pub(crate) mod test_util;
+11 -1
View File
@@ -23,7 +23,6 @@ use crate::{
Style,
codepoint_resolver::CodepointResolver,
collection::{AddOptions, Collection, LoadOptions, SizeAdjustment},
discovery::{Descriptor, Fontconfig},
embedded,
face::{DesiredSize, FontData, Options as FaceOptions},
shape,
@@ -102,7 +101,18 @@ pub(crate) fn test_shaper_with_font(font_req: TestFont) -> TestShaper {
/// Returns None (the test is skipped, like `error.SkipZigTest`) if the font
/// can't be discovered.
#[cfg(not(font_fontconfig))]
pub(crate) fn test_shaper_with_discovered_font(_font_req: &str) -> Option<TestShaper> {
// No fontconfig discovery in this backend (for example `freetype`).
None
}
/// Returns None (the test is skipped, like `error.SkipZigTest`) if the font
/// can't be discovered.
#[cfg(font_fontconfig)]
pub(crate) fn test_shaper_with_discovered_font(font_req: &str) -> Option<TestShaper> {
use crate::discovery::{Descriptor, Fontconfig};
let lib = Library::init().unwrap();
let mut c = Collection::new();
c.load_options = Some(LoadOptions::new(lib.clone()));
+16 -3
View File
@@ -27,13 +27,15 @@ use ghostty_config::{
types_b::FreetypeLoadFlags,
};
#[cfg(font_discovery)]
use crate::discovery::Discover;
use crate::{
Face,
Style,
codepoint_map::CodepointMap,
codepoint_resolver::{CodepointResolver, StyleStatus},
collection::{self, AddOptions, Collection, LoadOptions, SizeAdjustment},
discovery::{Descriptor, Discover},
discovery::Descriptor,
embedded,
face::{self, DesiredSize, FontData},
library::{Library, LibraryError},
@@ -45,7 +47,9 @@ use crate::{
#[derive(Debug, thiserror::Error)]
pub enum RefError {
#[error(transparent)]
#[cfg(font_discovery)]
Discover(#[from] crate::discovery::DiscoverError),
#[cfg(font_discovery)]
#[error("font discovery failed: {0}")]
DiscoverNext(#[from] crate::discovery::NextError),
#[error(transparent)]
@@ -80,6 +84,7 @@ struct Inner {
/// Font discovery mechanism. Initialized once and kept for the
/// lifetime of the set because some font discovery mechanisms
/// (i.e. fontconfig) are unsafe to reinit.
#[cfg(font_discovery)]
font_discover: Option<Discover>,
}
@@ -98,6 +103,7 @@ impl SharedGridSet {
font_lib: Library::init()?,
inner: Mutex::new(Inner {
map: HashMap::new(),
#[cfg(font_discovery)]
font_discover: None,
}),
})
@@ -158,7 +164,10 @@ impl SharedGridSet {
// Init our resolver which just requires setting fields.
let mut resolver = CodepointResolver::new(c);
resolver.styles = styles;
resolver.discover = Some(Self::discover(&mut inner).clone());
#[cfg(font_discovery)]
{
resolver.discover = Some(Self::discover(&mut inner).clone());
}
resolver.codepoint_map = Some(key.codepoint_map.clone());
let grid = Arc::new(SharedGrid::new(resolver)?);
@@ -174,6 +183,7 @@ impl SharedGridSet {
/// Builds the Collection for the given configuration key and
/// initial font size.
#[cfg_attr(not(font_discovery), allow(unused_variables))]
fn collection(
&self,
inner: &mut Inner,
@@ -191,7 +201,9 @@ impl SharedGridSet {
c.load_options = Some(load_options.clone());
c.metric_modifiers.clone_from(&key.metric_modifiers);
// Search for fonts
// Search for fonts. Without a discovery mechanism (the `freetype`
// backend) there is nothing to search with.
#[cfg(font_discovery)]
{
let disco = Self::discover(inner);
for style in Style::ALL {
@@ -392,6 +404,7 @@ impl SharedGridSet {
/// Initialize once and return the font discovery mechanism. This remains
/// initialized throughout the lifetime of the application because some
/// font discovery mechanisms (i.e. fontconfig) are unsafe to reinit.
#[cfg(font_discovery)]
fn discover(inner: &mut Inner) -> &Discover {
inner.font_discover.get_or_insert_with(Discover::init)
}