Files
ghostty-rs/crates/ghostty-font/src/shaper/coretext.rs
T
ddidderr 6f9b3129e1 refactor(config): parse config values from raw bytes like Zig
Zig parses config values (cli/args.zig, Config.zig field parsers,
config/string.zig) from raw byte slices, and each field type decides
whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The
port escape-encoded raw bytes into Strings at the line reader and let
fields parse the escaped text, so fields could observe encoding
artifacts; successive audits kept finding such leaks (RV5-RV8).

Field parsers now receive the original bytes: text fields validate UTF-8
where Zig does, byte/path fields store bytes (EncodedOs/OsString). This
resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md),
each with a regression test: Windows path conversion fails with
BadPathName instead of substituting U+FFFD; Windows process launch uses
strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired-
surrogate encodings like Zig; literal U+FDD0 Unicode key triggers;
selection-word-chars and clipboard-codepoint-map reject invalid UTF-8;
font style / codepoint-map family names use original bytes; C title
actions forward bytes; D-Bus new-window/Flatpak conversions match Zig.
Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md.

Also moves the bundled iTerm2-Color-Schemes license out of
resources/themes/ so +list-themes no longer lists it as a theme
(output is now byte-identical to Ghostty 1.3.1).

Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator.

Test Plan:
- cargo test --workspace --no-fail-fast (3428 passed, exit 0)
- just check-macos (both), just check-windows, just check-wasm
- +show-config --default --docs, +list-keybinds, +list-actions --docs,
  +list-themes --plain compared with /usr/bin/ghostty
2026-10-12 11:57:01 +02:00

507 lines
19 KiB
Rust

//! CoreText shaper (Apple targets). Port of `font/shaper/coretext.zig`.
//!
//! CoreText shaping differs in subtle ways from HarfBuzz so it may result
//! in inconsistent rendering across platforms. But it also fixes many
//! issues (some macOS specific):
//!
//! - Theta hat offset is incorrect in HarfBuzz but correct by default
//! on macOS applications using CoreText. (See:
//! <https://github.com/harfbuzz/harfbuzz/discussions/4525>)
//!
//! - Hyphens (U+2010) can be synthesized by CoreText but not by HarfBuzz.
//! See: <https://github.com/mitchellh/ghostty/issues/1643>
//!
//! The API mirrors [`crate::shaper::harfbuzz::Shaper`]: the run iterator
//! drives `prepare` / `add_codepoint` / `finalize` through
//! [`RunIteratorHook`](crate::shaper::run::RunIteratorHook) and the caller
//! shapes with [`Shaper::shape_run`]. The cluster/ligature heuristics live
//! in [`super::coretext_cells`] (unit tested on every platform).
#![allow(unsafe_code)]
use std::ptr::{self, NonNull};
use objc2_core_foundation::{
CFArray,
CFAttributedString,
CFDictionary,
CFNumber,
CFRange,
CFRetained,
CFString,
CFType,
CGPoint,
CGSize,
};
use objc2_core_text::{
CTFont,
CTFontDescriptor,
CTLine,
CTRun,
CTRunStatus,
CTTypesetter,
kCTFontAttributeName,
kCTFontFeatureSettingsAttribute,
kCTFontOpenTypeFeatureTag,
kCTFontOpenTypeFeatureValue,
kCTTypesetterOptionForcedEmbeddingLevel,
};
use super::{
cf_release::{Pooled, ReleaseThread, pooled},
coretext_cells::{self as cells, CtRun, RunGlyph, RunState},
};
use crate::{
coretext_util::CfBox,
face::Face,
shape::{self, Cell, DEFAULT_FEATURES, Feature, FeatureList, TextRun},
shared_grid::SharedGrid,
};
#[derive(Debug, thiserror::Error)]
pub enum ShapeError {
#[error("coretext object creation failed")]
CoreTextFailed,
}
/// Errors from [`Shaper::shape_run`].
#[derive(Debug, thiserror::Error)]
pub enum ShapeRunError {
#[error(transparent)]
GetFace(#[from] crate::collection::GetFaceError),
#[error(transparent)]
Shape(#[from] ShapeError),
}
/// Create a CoreFoundation Dictionary suitable for settings the font
/// features of a CoreText font (`makeFeaturesDict`).
fn make_features_dict(feats: &[Feature]) -> Result<CfBox<CFDictionary>, ShapeError> {
let mut items: Vec<CFRetained<CFDictionary<CFString, CFType>>> = Vec::new();
for feat in feats {
// Keys can only be ASCII.
if !feat.tag.is_ascii() {
return Err(ShapeError::CoreTextFailed);
}
let key = CFString::from_str(
std::str::from_utf8(&feat.tag).map_err(|_| ShapeError::CoreTextFailed)?,
);
let value = CFNumber::new_i32(feat.value as i32);
// SAFETY: valid static keys.
let (k_tag, k_val) = unsafe { (kCTFontOpenTypeFeatureTag, kCTFontOpenTypeFeatureValue) };
let dict = CFDictionary::<CFString, CFType>::from_slices(
&[k_tag, k_val],
&[key.as_ref(), value.as_ref()],
);
items.push(dict);
}
let item_refs: Vec<&CFDictionary<CFString, CFType>> = items.iter().map(|d| &**d).collect();
let list = CFArray::from_objects(&item_refs);
// SAFETY: valid static key.
let k = unsafe { kCTFontFeatureSettingsAttribute };
let dict = CFDictionary::<CFString, CFType>::from_slices(&[k], &[list.as_ref()]);
// SAFETY: the attribute dictionary is used as an untyped CFDictionary.
Ok(CfBox::new(unsafe {
CFRetained::cast_unchecked::<CFDictionary>(dict)
}))
}
/// Shaper that uses CoreText.
pub struct Shaper {
/// The string used for shaping the current run.
run_state: RunState,
/// CoreFoundation Dictionary which represents our font feature settings.
features: CfBox<CFDictionary>,
/// A version of the features dictionary with the default features excluded.
features_no_default: CfBox<CFDictionary>,
/// The shared memory used for shaping results.
cell_buf: Vec<Cell>,
/// Cached attributes dict for creating CTTypesetter objects.
/// The values in this never change so we can avoid overhead
/// by just creating it once and saving it for reuse.
typesetter_attr_dict: CfBox<CFDictionary>,
/// List where we cache fonts, so we don't have to remake them for
/// every single shaping operation.
///
/// Fonts are cached as attribute dictionaries to be applied directly to
/// attributed strings.
cached_fonts: Vec<Option<CfBox<CFDictionary>>>,
/// The grid that our cached fonts correspond to.
/// If the grid changes then we need to reset our cache.
cached_font_grid: usize,
/// The list of CoreFoundation objects to release on the dedicated
/// release thread. This is built up over the course of shaping and
/// sent to the release thread when `end_frame` is called.
cf_release_pool: Vec<Pooled>,
/// Dedicated thread for releasing CoreFoundation objects. Some objects,
/// such as those produced by CoreText, have excessively slow release
/// callback logic.
cf_release_thread: Option<ReleaseThread>,
}
impl Shaper {
/// The cell buffer holds the shaped results.
pub fn new(opts: &shape::Options) -> Result<Shaper, ShapeError> {
let mut feature_list = FeatureList::default();
for feature_str in &opts.features {
feature_list.append_from_bytes(&feature_str.to_bytes());
}
// We need to construct two attrs dictionaries for font features;
// one without the default features included, and one with them.
let feats = &feature_list.features;
let mut feats_df: Vec<Feature> = DEFAULT_FEATURES.to_vec();
feats_df.extend_from_slice(feats);
let features = make_features_dict(&feats_df)?;
let features_no_default = make_features_dict(feats)?;
// For now we only support LTR text. If we shape RTL text then
// rendering will be very wrong so we need to explicitly force
// LTR no matter what.
//
// See: https://github.com/mitchellh/ghostty/issues/1737
// See: https://github.com/mitchellh/ghostty/issues/1442
//
// We used to do this by setting the writing direction attribute
// on the attributed string we used, but it seems like that will
// still allow some weird results, for example a single space at
// the end of a line composed of RTL characters will be cause it
// to output a run containing just that space, BEFORE it outputs
// the rest of the line as a separate run, very weirdly with the
// "right to left" flag set in the single space run's run status...
//
// So instead what we do is use a CTTypesetter to create our line,
// using the kCTTypesetterOptionForcedEmbeddingLevel attribute to
// force CoreText not to try doing any sort of BiDi, instead just
// treat all text as embedding level 0 (left to right).
let typesetter_attr_dict = {
let num = CFNumber::new_i32(0);
// SAFETY: valid static key.
let k = unsafe { kCTTypesetterOptionForcedEmbeddingLevel };
let d = CFDictionary::<CFString, CFType>::from_slices(&[k], &[num.as_ref()]);
// SAFETY: used as an untyped CFDictionary.
CfBox::new(unsafe { CFRetained::cast_unchecked::<CFDictionary>(d) })
};
Ok(Shaper {
run_state: RunState::default(),
features,
features_no_default,
cell_buf: Vec::new(),
typesetter_attr_dict,
cached_fonts: Vec::new(),
cached_font_grid: 0,
cf_release_pool: Vec::new(),
cf_release_thread: ReleaseThread::new(),
})
}
/// Send the release pool to the release thread.
pub fn end_frame(&mut self) {
if self.cf_release_pool.is_empty() {
return;
}
// Get all the items in the pool as an owned vec so we can
// send it to the dedicated release thread.
let items = std::mem::take(&mut self.cf_release_pool);
match &self.cf_release_thread {
Some(t) => t.release(items),
// No thread: release inline.
None => drop(items),
}
}
// ---- RunIterator hooks ----
pub fn prepare(&mut self) {
self.run_state.reset();
}
pub fn add_codepoint(&mut self, cp: u32, cluster: u32) {
self.run_state.add_codepoint(cp, cluster);
}
#[allow(clippy::unused_self)]
pub fn finalize(&mut self) {}
/// Convenience: shape `text` as a single run where every item is a
/// `(codepoint, cluster)` pair.
pub fn shape_text(
&mut self,
face: Option<&Face>,
text: &[(u32, u32)],
) -> Result<&[Cell], ShapeError> {
self.prepare();
for &(cp, cluster) in text {
self.add_codepoint(cp, cluster);
}
self.finalize();
self.shape(face)
}
/// Shape the given text run (`shape(run)` in Zig). The run must be the
/// immediately previous run that was iterated since it shares state with
/// the shaper.
///
/// Note that this will accumulate garbage in the release pool. The
/// caller must ensure you're properly calling `end_frame` to release
/// all the objects.
pub fn shape_run(
&mut self,
grid: &SharedGrid,
run: &TextRun,
) -> Result<&[Cell], ShapeRunError> {
// Special fonts aren't shaped and their codepoint == glyph so we
// can just return the codepoints as-is.
if run.font_index.special().is_some() {
cells::special_cells(&self.run_state.codepoints, &mut self.cell_buf);
return Ok(&self.cell_buf);
}
let attr_dict = self.get_font(grid, run.font_index)?;
Ok(self.shape_with_attrs(&attr_dict)?)
}
/// Shape the current run (as built through the hooks) with the given
/// face (uncached), or pass `None` for a special-case font (sprite).
pub fn shape(&mut self, face: Option<&Face>) -> Result<&[Cell], ShapeError> {
let Some(face) = face else {
cells::special_cells(&self.run_state.codepoints, &mut self.cell_buf);
return Ok(&self.cell_buf);
};
let attr_dict = self.make_attr_dict(face);
self.shape_with_attrs(&attr_dict)
}
fn shape_with_attrs(&mut self, attr_dict: &CFDictionary) -> Result<&[Cell], ShapeError> {
let state = &self.run_state;
// Make room for the attributed string, CTTypesetter, and CTLine.
self.cf_release_pool.reserve(4);
// SAFETY: `unichars` is a valid buffer of `len` UTF-16 units.
let str = unsafe {
CFString::with_characters(None, state.unichars.as_ptr(), state.unichars.len() as isize)
}
.ok_or(ShapeError::CoreTextFailed)?;
// Create an attributed string from our string
// SAFETY: valid string and attribute dictionary.
let attr_str = unsafe { CFAttributedString::new(None, Some(&str), Some(attr_dict)) }
.ok_or(ShapeError::CoreTextFailed)?;
// Create a typesetter from the attributed string and the cached
// attr dict. (See comment in new for more info on the attr dict.)
// SAFETY: valid string and options dictionary.
let typesetter = unsafe {
CTTypesetter::with_attributed_string_and_options(
&attr_str,
Some(&self.typesetter_attr_dict),
)
}
.ok_or(ShapeError::CoreTextFailed)?;
// Create a line from the typesetter
// SAFETY: a zero length range means the whole string.
let line: CFRetained<CTLine> = unsafe {
typesetter.line(CFRange {
location: 0,
length: 0,
})
};
// CoreText, despite our insistence with an enforced embedding level,
// may sometimes output runs that are non-monotonic.
//
// CoreText may generate multiple runs even though our input to
// CoreText is already split into runs by our own run iterator.
// The runs as far as I can tell are always sequential to each
// other so we can iterate over them and just append to our
// cell buffer.
// SAFETY: plain CoreText call.
let glyph_runs = unsafe { line.glyph_runs() };
// SAFETY: the glyph runs array contains CTRun objects.
let glyph_runs: &CFArray<CTRun> = unsafe { glyph_runs.cast_unchecked() };
let mut runs: Vec<CtRun> = Vec::with_capacity(glyph_runs.len());
for i in 0..glyph_runs.len() {
let Some(ctrun) = glyph_runs.get(i) else {
continue;
};
runs.push(read_run(&ctrun));
}
cells::cells_from_runs(&self.run_state.codepoints, &runs, &mut self.cell_buf);
// The objects are released on the release thread (see `end_frame`).
self.cf_release_pool.push(pooled(str));
self.cf_release_pool.push(pooled(attr_str));
self.cf_release_pool.push(pooled(typesetter));
self.cf_release_pool.push(pooled(line));
Ok(&self.cell_buf)
}
/// Build an attr dict (kCTFontAttributeName -> font with our features)
/// for the given face.
fn make_attr_dict(&mut self, face: &Face) -> CfBox<CFDictionary> {
let attrs = if face.quirks_disable_default_font_features {
&self.features_no_default
} else {
&self.features
};
// SAFETY: valid attributes dictionary.
let desc = unsafe { CTFontDescriptor::with_attributes(attrs) };
// SAFETY: valid descriptor, null matrix is allowed.
let copied: CFRetained<CTFont> = unsafe {
face.ct_font()
.copy_with_attributes(0.0, ptr::null(), Some(&desc))
};
// Get our font and use that get the attributes to set for the
// attributed string so the whole string uses the same font.
// SAFETY: valid static key.
let k = unsafe { kCTFontAttributeName };
let d = CFDictionary::<CFString, CFType>::from_slices(&[k], &[copied.as_ref()]);
self.cf_release_pool.push(pooled(desc));
self.cf_release_pool.push(pooled(copied));
// SAFETY: used as an untyped CFDictionary.
CfBox::new(unsafe { CFRetained::cast_unchecked::<CFDictionary>(d) })
}
/// Get an attr dict for a font from a specific index.
/// These items are cached.
fn get_font(
&mut self,
grid: &SharedGrid,
index: crate::collection::Index,
) -> Result<CfBox<CFDictionary>, ShapeRunError> {
// If this grid doesn't match the one we've cached fonts for,
// then we reset the cache list since it's no longer valid.
// We use an integer rather than keeping the pointer because we
// don't want anyone to inadvertently use the pointer.
let grid_id = ptr::from_ref(grid) as usize;
if grid_id != self.cached_font_grid {
if self.cached_font_grid > 0 {
// Put all the currently cached fonts in to
// the release pool before clearing the list.
for ft in self.cached_fonts.drain(..).flatten() {
self.cf_release_pool.push(pooled(ft.0));
}
}
self.cached_font_grid = grid_id;
}
let index_int = index.int() as usize;
// The cached fonts are indexed directly by the font index, since
// this number is usually low. Therefore, we set any index we haven't
// seen to null.
if self.cached_fonts.len() <= index_int {
self.cached_fonts.resize_with(index_int + 1, || None);
}
// If we have it, return the cached attr dict.
if let Some(cached) = &self.cached_fonts[index_int] {
return Ok(cached.clone());
}
// The CoreText shaper relies on CoreText and CoreText claims
// that CTFonts are threadsafe. See:
// https://developer.apple.com/documentation/coretext/
//
// Quote:
// All individual functions in Core Text are thread-safe. Font
// objects (CTFont, CTFontDescriptor, and associated objects) can
// be used simultaneously by multiple operations, work queues, or
// threads. However, the layout objects (CTTypesetter, CTFramesetter,
// CTRun, CTLine, CTFrame, and associated objects) should be used in
// a single operation, work queue, or thread.
//
// Because of this, we only hold the grid lock to grab the face and
// set it up, then release it.
let attr_dict = {
let mut guard = grid.write();
let face = guard.resolver.collection.get_face(index)?;
self.make_attr_dict(face)
};
self.cached_fonts[index_int] = Some(attr_dict.clone());
Ok(attr_dict)
}
}
impl Drop for Shaper {
fn drop(&mut self) {
// Release whatever is left in the pool on the release thread
// (it is joined when the thread handle drops).
let items = std::mem::take(&mut self.cf_release_pool);
if !items.is_empty() {
log::debug!("releasing {} remaining CF objects", items.len());
}
let cached = std::mem::take(&mut self.cached_fonts);
let mut all = items;
all.extend(cached.into_iter().flatten().map(|d| pooled(d.0)));
if let Some(t) = &self.cf_release_thread {
t.release(all);
}
}
}
/// Copy a CTRun's glyphs, advances, positions and string indices
/// (`getGlyphsPtr() orelse getGlyphs(alloc)` etc.).
fn read_run(ctrun: &CTRun) -> CtRun {
// SAFETY: plain CoreText getters on a valid run.
let (status, n) = unsafe { (ctrun.status(), ctrun.glyph_count() as usize) };
let non_ltr = status.intersects(CTRunStatus::NonMonotonic | CTRunStatus::RightToLeft);
if n == 0 {
return CtRun {
non_ltr,
glyphs: Vec::new(),
};
}
let full = CFRange {
location: 0,
length: 0,
};
let mut glyphs = vec![0u16; n];
let mut advances = vec![CGSize::new(0.0, 0.0); n];
let mut positions = vec![CGPoint::new(0.0, 0.0); n];
let mut indices = vec![0isize; n];
// SAFETY: each buffer holds `n` elements, which is the glyph count of the
// run; a zero-length range copies everything.
unsafe {
ctrun.glyphs(full, NonNull::new_unchecked(glyphs.as_mut_ptr()));
ctrun.advances(full, NonNull::new_unchecked(advances.as_mut_ptr()));
ctrun.positions(full, NonNull::new_unchecked(positions.as_mut_ptr()));
ctrun.string_indices(full, NonNull::new_unchecked(indices.as_mut_ptr()));
}
CtRun {
non_ltr,
glyphs: (0..n)
.map(|i| RunGlyph {
glyph: glyphs[i],
advance_width: advances[i].width,
x: positions[i].x,
y: positions[i].y,
index: indices[i] as usize,
})
.collect(),
}
}