feat(macos): port Ghostty's macOS code paths (Metal, CoreText, Darwin)

Ports the macOS-specific Zig code behind cfg(target_os = "macos"):
- renderer: a GraphicsApi trait with generic::Renderer<G>; Metal backend
  (all renderer/metal/* files, shaders.metal verbatim, runtime library
  compile, IOSurface layer, CVDisplayLink), shadertoy -> MSL via naga;
  OpenGL remains the backend elsewhere.
- font: CoreText face, shaper and discovery with build.rs backend
  selection (coretext default; coretext-freetype / coretext-harfbuzz /
  coretext-noshape features), no fontconfig/freetype/harfbuzz on Apple.
- input: KeymapDarwin via UCKeyTranslate, option-as-alt, mac keycodes.
- os: macos, mach, cf_release_thread, NSProcessInfo args and macOS
  branches; config: macOS defaults, keybinds, Application Support paths.
- termio/core: login(1) wrapping, openpty/execvpe, Darwin child exit
  handling, os_log logging.

Compile-checked for aarch64/x86_64-apple-darwin (clippy -D warnings
clean) using the cross setup in docs/rewrite/notes/macos-M1.md; cannot
run here. The embedded runtime/C API (embedded.zig) is the next step.

Also includes the cross-platform integration edits shared by the crash,
verification, Windows and macOS work (Cargo target-gating, thread
crash-context hooks, apprt-gtk surface/IME refactor, termio/os cfg
gates), a cfg fix so ghostty-config builds on wasm32, and an allow for
the intentional recursion in the crash overflow test.

Test Plan:
- cargo test --workspace (3224 passed)
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check --target aarch64-apple-darwin -p ghostty-renderer (with
  the documented cross env)
This commit is contained in:
ddidderr committed 2026-10-12 06:02:39 +02:00
1 parent 3c8fae1089
commit 452b782149
124 files changed
+14936 -1242

No files matched your search

@@ -0,0 +1,81 @@
//! Dedicated thread for releasing CoreFoundation objects.
//!
//! Pools of CF objects (strings, attributed strings, typesetters, lines,
//! fonts) are sent here so that their (excessively slow) release callback
//! logic doesn't block the shaping/renderer thread. Equivalent of
//! `os/cf_release_thread.zig` for the shaper's needs: a mailbox of owned
//! pools and a thread that drops them.
//!
//! TODO(integration): this is a self-contained version living in the font
//! crate; it can be replaced by `ghostty_os::cf_release_thread` once the os
//! crate's port lands and the font crate depends on it.
#![allow(unsafe_code)]
use std::{
sync::mpsc::{Sender, channel},
thread::JoinHandle,
};
use objc2_core_foundation::{CFRetained, CFType};
use crate::coretext_util::CfBox;
/// An owned CF object queued for release.
pub(crate) type Pooled = CfBox<CFType>;
/// Convert any retained CF object into a pool entry.
pub(crate) fn pooled<T: objc2_core_foundation::Type>(v: CFRetained<T>) -> Pooled {
// SAFETY: every CF object is a CFType; the retain count is carried over
// by the cast and released exactly once when the `Pooled` drops.
CfBox::new(unsafe { CFRetained::cast_unchecked::<CFType>(v) })
}
pub(crate) struct ReleaseThread {
tx: Option<Sender<Vec<Pooled>>>,
handle: Option<JoinHandle<()>>,
}
impl ReleaseThread {
pub(crate) fn new() -> Option<ReleaseThread> {
let (tx, rx) = channel::<Vec<Pooled>>();
let handle = std::thread::Builder::new()
.name("cf_release".into())
.spawn(move || {
// Dropping each pool releases every object in it.
while let Ok(pool) = rx.recv() {
drop(pool);
}
log::debug!("cf release thread exited");
})
.inspect_err(|e| log::warn!("error spawning cf release thread: {e}"))
.ok()?;
Some(ReleaseThread {
tx: Some(tx),
handle: Some(handle),
})
}
/// Send a pool for release. If the thread is gone the pool is released
/// inline.
pub(crate) fn release(&self, pool: Vec<Pooled>) {
if pool.is_empty() {
return;
}
if let Some(tx) = &self.tx
&& let Err(e) = tx.send(pool)
{
// Receiver gone: release here.
drop(e.0);
}
}
}
impl Drop for ReleaseThread {
fn drop(&mut self) {
// Closing the channel stops the thread after it drains the mailbox.
self.tx.take();
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
+499
View File
@@ -0,0 +1,499 @@
//! 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]) -> CfBox<CFDictionary> {
let mut items: Vec<CFRetained<CFDictionary<CFString, CFType>>> = Vec::new();
for feat in feats {
// Keys can only be ASCII.
let key = CFString::from_str(&String::from_utf8_lossy(&feat.tag));
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.
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_string(feature_str);
}
// 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(),
}
}
@@ -0,0 +1,400 @@
//! Platform-independent part of the CoreText shaper
//! (`font/shaper/coretext.zig`): building the UTF-16 string / codepoint
//! table for a run, and converting CoreText glyph runs to shaped cells.
//!
//! The CoreText calls themselves live in `shaper/coretext.rs` (Apple
//! targets); this module takes plain data so the (subtle) cluster /
//! ligature heuristics can be unit tested on every platform.
use crate::shape::Cell;
/// One entry of the codepoint table. Surrogate pairs occupy two entries so
/// that indices match the UTF-16 string handed to CoreText; the second one
/// has `codepoint == 0`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Codepoint {
pub codepoint: u32,
pub cluster: u32,
}
/// The state of the current run (`RunState`).
#[derive(Debug, Default)]
pub struct RunState {
pub codepoints: Vec<Codepoint>,
pub unichars: Vec<u16>,
}
impl RunState {
pub fn reset(&mut self) {
self.codepoints.clear();
self.unichars.clear();
}
/// `RunIteratorHook.addCodepoint`.
pub fn add_codepoint(&mut self, cp: u32, cluster: u32) {
// Build our UTF-16 string for CoreText
let (units, len) = crate::face::coretext_math::utf16_units(cp);
self.unichars.extend_from_slice(&units[..len]);
// Build our reverse lookup table for codepoints to clusters
self.codepoints.push(Codepoint {
codepoint: cp,
cluster,
});
// If the UTF-16 codepoint is a pair then we need to insert
// a dummy entry so that the CTRunGetStringIndices() function
// maps correctly.
if len == 2 {
self.codepoints.push(Codepoint {
codepoint: 0,
cluster,
});
}
}
}
/// Special fonts aren't shaped and their codepoint == glyph so we can just
/// return the codepoints as-is.
pub fn special_cells(codepoints: &[Codepoint], out: &mut Vec<Cell>) {
out.clear();
for entry in codepoints {
// We use null codepoints to pad out our list so indices match
// the UTF-16 string we constructed for CoreText. We don't want
// to emit these if this is a special font, since they're not
// part of the original run.
if entry.codepoint == 0 {
continue;
}
out.push(Cell {
x: entry.cluster as u16,
glyph_index: entry.codepoint,
..Cell::default()
});
}
}
/// One glyph of a CoreText run (the zipped glyphs / advances / positions /
/// string indices arrays).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RunGlyph {
pub glyph: u16,
pub advance_width: f64,
pub x: f64,
pub y: f64,
/// Index into the UTF-16 string (and therefore the codepoint table).
pub index: usize,
}
/// One CoreText glyph run of the line.
#[derive(Debug, Clone, Default)]
pub struct CtRun {
/// True if the run status has `non_monotonic` or `right_to_left`.
pub non_ltr: bool,
pub glyphs: Vec<RunGlyph>,
}
#[derive(Debug, Clone, Copy, Default)]
struct Offset {
cluster: u32,
x: f64,
}
/// Convert CoreText runs to cells (the loop at the end of `Shaper.shape`).
pub fn cells_from_runs(codepoints: &[Codepoint], runs: &[CtRun], out: &mut Vec<Cell>) {
// This keeps track of the current x offset (sum of advance.width) and
// the furthest cluster we've seen so far (max).
let mut run_offset = Offset::default();
// This keeps track of the cell starting x and cluster.
let mut cell_offset = Offset::default();
out.clear();
// CoreText, despite our insistence with an enforced embedding level,
// may sometimes output runs that are non-monotonic. In order to fix
// this, we check the run status for each run and if any aren't ltr
// we set this to true, which indicates that we must sort our buffer.
let mut non_ltr = false;
// 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.
for run in runs {
if run.non_ltr {
non_ltr = true;
}
for g in &run.glyphs {
let index = g.index;
// Our cluster is also our cell X position. If the cluster changes
// then we need to reset our current cell offsets.
let cluster = codepoints[index].cluster;
if cell_offset.cluster != cluster {
// We previously asserted that the new cluster is greater
// than cell_offset.cluster, but this isn't always true.
// See e.g. the "shape Chakma vowel sign with ligature
// (vowel sign renders first)" test.
let is_after_glyph_from_current_or_next_clusters = cluster <= run_offset.cluster;
let is_first_codepoint_in_cluster = {
let mut i = index;
let mut result = true;
while i > 0 {
i -= 1;
let codepoint = codepoints[i];
// Skip surrogate pair padding
if codepoint.codepoint == 0 {
continue;
}
result = codepoint.cluster != cluster;
break;
}
result
};
// We need to reset the `cell_offset` at the start of a new
// cluster, but we do that conditionally if the codepoint
// `is_first_codepoint_in_cluster` and the cluster is not
// `is_after_glyph_from_current_or_next_clusters`, which is
// a heuristic to detect ligatures and avoid positioning
// glyphs that mark ligatures incorrectly. The idea is that
// if the first codepoint in a cluster doesn't appear in
// the stream, it's very likely that it combined with
// codepoints from a previous cluster into a ligature.
// Then, the subsequent codepoints are very likely marking
// glyphs that are placed relative to that ligature, so if
// we were to reset the `cell_offset` to align it with the
// grid, the positions would be off. The
// `!is_after_glyph_from_current_or_next_clusters` check is
// needed in case these marking glyphs come from a later
// cluster but are rendered first (see the Chakma and
// Bengali tests). In that case when we get to the
// codepoint that `is_first_codepoint_in_cluster`, but in a
// cluster that
// `is_after_glyph_from_current_or_next_clusters`, we don't
// want to reset to the grid and cause the positions to be
// off. (Note that we could go back and align the cells to
// the grid starting from the one from the cluster that
// rendered out of order, but that is more complicated so
// we don't do that for now. Also, it's TBD if there are
// exceptions to this heuristic for detecting ligatures,
// but using the logging below seems to show it works
// well.)
if is_first_codepoint_in_cluster && !is_after_glyph_from_current_or_next_clusters {
cell_offset = Offset {
cluster,
x: run_offset.x,
};
}
}
let x_offset = g.x - cell_offset.x;
out.push(Cell {
x: cell_offset.cluster as u16,
x_offset: x_offset.round() as i16,
y_offset: g.y.round() as i16,
glyph_index: u32::from(g.glyph),
});
// Add our advances to keep track of our run offsets.
// Advances apply to the NEXT cell.
run_offset.x += g.advance_width;
run_offset.cluster = run_offset.cluster.max(cluster);
}
}
// If our buffer contains some non-ltr sections we need to sort it :/
if non_ltr {
// This is EXCEPTIONALLY rare. Only happens for languages with
// complex shaping which we don't even really support properly
// right now, so are very unlikely to be used heavily by users
// of Ghostty.
//
// Zig's `std.mem.sort` is not stable but Rust's `sort_by_key` is;
// equal keys keep their relative order which is a valid result.
out.sort_by_key(|c| c.x);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn state(cps: &[(u32, u32)]) -> RunState {
let mut s = RunState::default();
for &(cp, cl) in cps {
s.add_codepoint(cp, cl);
}
s
}
fn glyph(glyph: u16, adv: f64, x: f64, y: f64, index: usize) -> RunGlyph {
RunGlyph {
glyph,
advance_width: adv,
x,
y,
index,
}
}
#[test]
fn add_codepoint_bmp() {
let s = state(&[(0x41, 0), (0x42, 1)]);
assert_eq!(s.unichars, vec![0x41, 0x42]);
assert_eq!(s.codepoints.len(), 2);
}
#[test]
fn add_codepoint_surrogate_pair_pads_table() {
let s = state(&[(0x1F978, 0), (0x41, 2)]);
assert_eq!(s.unichars, vec![0xD83E, 0xDD78, 0x41]);
assert_eq!(
s.codepoints,
vec![
Codepoint {
codepoint: 0x1F978,
cluster: 0
},
Codepoint {
codepoint: 0,
cluster: 0
},
Codepoint {
codepoint: 0x41,
cluster: 2
},
]
);
}
#[test]
fn special_skips_padding() {
let s = state(&[(0x1F978, 3), (0x41, 5)]);
let mut out = Vec::new();
special_cells(&s.codepoints, &mut out);
assert_eq!(out.len(), 2);
assert_eq!(out[0].x, 3);
assert_eq!(out[0].glyph_index, 0x1F978);
assert_eq!(out[1].x, 5);
assert_eq!(out[1].glyph_index, 0x41);
}
#[test]
fn simple_run_one_glyph_per_cluster() {
let s = state(&[(0x41, 0), (0x42, 1), (0x43, 2)]);
let run = CtRun {
non_ltr: false,
glyphs: vec![
glyph(10, 8.0, 0.0, 0.0, 0),
glyph(11, 8.0, 8.0, 0.0, 1),
glyph(12, 8.0, 16.0, 0.0, 2),
],
};
let mut out = Vec::new();
cells_from_runs(&s.codepoints, &[run], &mut out);
assert_eq!(out.len(), 3);
for (i, c) in out.iter().enumerate() {
assert_eq!(c.x as usize, i);
assert_eq!(c.x_offset, 0);
assert_eq!(c.y_offset, 0);
assert_eq!(c.glyph_index, 10 + i as u32);
}
}
#[test]
fn ligature_glyph_spans_clusters() {
// "fi" shaped to one glyph (index 0) -> a single cell at cluster 0;
// the following char starts a new cell at cluster 2.
let s = state(&[(0x66, 0), (0x69, 1), (0x41, 2)]);
let run = CtRun {
non_ltr: false,
glyphs: vec![glyph(50, 16.0, 0.0, 0.0, 0), glyph(11, 8.0, 16.0, 0.0, 2)],
};
let mut out = Vec::new();
cells_from_runs(&s.codepoints, &[run], &mut out);
assert_eq!(out.len(), 2);
assert_eq!(out[0].x, 0);
assert_eq!(out[1].x, 2);
assert_eq!(out[1].x_offset, 0);
}
#[test]
fn mark_after_ligature_keeps_ligature_cell() {
// Clusters 0,1 ligate; a mark in cluster 2 positioned relative to
// the ligature: first codepoint of cluster 2 isn't "first" in the
// stream (it's index 2, preceded by cluster 1) so it IS first in
// cluster; cluster 2 > run_offset.cluster(0) so offset resets.
let s = state(&[(0x66, 0), (0x69, 1), (0x301, 2)]);
let run = CtRun {
non_ltr: false,
glyphs: vec![glyph(50, 16.0, 0.0, 0.0, 0), glyph(99, 0.0, 14.0, 3.0, 2)],
};
let mut out = Vec::new();
cells_from_runs(&s.codepoints, &[run], &mut out);
assert_eq!(out.len(), 2);
assert_eq!(out[1].x, 2);
// cell offset reset to run_offset.x = 16 => x_offset = 14 - 16
assert_eq!(out[1].x_offset, -2);
assert_eq!(out[1].y_offset, 3);
}
#[test]
fn glyph_from_earlier_cluster_does_not_reset_offset() {
// Glyph for cluster 1 comes after glyph for cluster 2 (rendered out
// of order): cluster 1 <= run_offset.cluster (2) => no reset, the
// cell stays at the cluster 2 offset.
let s = state(&[(0x41, 0), (0x42, 1), (0x43, 2)]);
let run = CtRun {
non_ltr: false,
glyphs: vec![
glyph(1, 8.0, 0.0, 0.0, 0),
glyph(3, 8.0, 8.0, 0.0, 2),
glyph(2, 8.0, 16.0, 0.0, 1),
],
};
let mut out = Vec::new();
cells_from_runs(&s.codepoints, &[run], &mut out);
assert_eq!(out.len(), 3);
// cluster 2's glyph: reset (2 > 0): offset x = 8
assert_eq!(out[1].x, 2);
// cluster 1 glyph after cluster 2: 1 <= 2 so no reset, stays at 2
assert_eq!(out[2].x, 2);
assert_eq!(out[2].x_offset, 8);
}
#[test]
fn non_ltr_sorts_by_x() {
let s = state(&[(0x41, 0), (0x42, 1)]);
let run = CtRun {
non_ltr: true,
glyphs: vec![glyph(2, 8.0, 0.0, 0.0, 1), glyph(1, 8.0, 8.0, 0.0, 0)],
};
let mut out = Vec::new();
cells_from_runs(&s.codepoints, &[run], &mut out);
assert!(out.windows(2).all(|w| w[0].x <= w[1].x));
}
#[test]
fn surrogate_padding_skipped_when_looking_back() {
// Emoji (pair) at cluster 0 followed by 'A' at cluster 1. The 'A'
// glyph looks back past the padding entry to the real codepoint.
let s = state(&[(0x1F978, 0), (0x41, 1)]);
let run = CtRun {
non_ltr: false,
glyphs: vec![glyph(7, 16.0, 0.0, 0.0, 0), glyph(8, 8.0, 16.0, 0.0, 2)],
};
let mut out = Vec::new();
cells_from_runs(&s.codepoints, &[run], &mut out);
assert_eq!(out[1].x, 1);
assert_eq!(out[1].x_offset, 0);
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
use super::hb;
use crate::{
face::freetype::Face,
face::Face,
shape::{self, Cell, DEFAULT_FEATURES, FeatureList, TextRun},
shared_grid::SharedGrid,
};
+1 -1
View File
@@ -6,7 +6,7 @@ use std::ptr::NonNull;
use harfbuzz_sys as sys;
use crate::face::freetype::HbFont;
use crate::face::HbFont;
/// A HarfBuzz feature (`hb_feature_t`) applied globally.
pub type Feature = sys::hb_feature_t;
+8 -3
View File
@@ -2,10 +2,15 @@
#[cfg(not(target_arch = "wasm32"))]
pub mod cache;
#[cfg(font_coretext_shaper)]
mod cf_release;
#[cfg(font_coretext_shaper)]
pub mod coretext;
pub mod coretext_cells;
pub mod feature;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(font_harfbuzz)]
pub mod harfbuzz;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(font_harfbuzz)]
mod hb;
#[cfg(not(target_arch = "wasm32"))]
pub mod noop;
@@ -14,5 +19,5 @@ pub mod run;
pub mod web_canvas;
#[cfg(all(test, not(target_arch = "wasm32")))]
#[cfg(all(test, font_harfbuzz, font_fontconfig))]
pub(crate) mod test_util;
+35 -5
View File
@@ -3,10 +3,25 @@
//! font's glyph index.
use crate::{
face::freetype::Face,
shape::{self, Cell},
face::Face,
shape::{self, Cell, TextRun},
shared_grid::SharedGrid,
};
/// Never produced (the no-op shaper can't fail to initialize); exists so the
/// API matches the other shapers (`shape::ShapeError`).
#[derive(Debug, thiserror::Error)]
pub enum ShapeError {}
/// Errors from [`Shaper::shape_run`].
#[derive(Debug, thiserror::Error)]
pub enum ShapeRunError {
#[error(transparent)]
GetFace(#[from] crate::collection::GetFaceError),
#[error(transparent)]
Shape(#[from] ShapeError),
}
#[derive(Debug, Clone, Copy)]
struct Codepoint {
codepoint: u32,
@@ -24,8 +39,9 @@ pub struct Shaper {
}
impl Shaper {
pub fn new(_opts: &shape::Options) -> Shaper {
Shaper::default()
#[allow(clippy::unnecessary_wraps)]
pub fn new(_opts: &shape::Options) -> Result<Shaper, ShapeError> {
Ok(Shaper::default())
}
pub fn end_frame(&self) {}
@@ -45,6 +61,20 @@ impl Shaper {
pub fn finalize(&mut self) {}
/// Shape the given text run (looks the face up in `grid`).
pub fn shape_run(
&mut self,
grid: &SharedGrid,
run: &TextRun,
) -> Result<&[Cell], ShapeRunError> {
if run.font_index.special().is_none() {
let mut guard = grid.write();
let face = guard.resolver.collection.get_face(run.font_index)?;
return Ok(self.shape(Some(face)));
}
Ok(self.shape(None))
}
/// Shape the current run. `face` is `None` for special fonts (sprite),
/// whose codepoint == glyph index.
///
@@ -76,7 +106,7 @@ mod tests {
#[test]
fn noop_special_passthrough() {
let mut s = Shaper::new(&shape::Options::default());
let mut s = Shaper::new(&shape::Options::default()).unwrap();
s.prepare();
s.add_codepoint(0x41, 0);
s.add_codepoint(0x42, 2);
+14
View File
@@ -160,6 +160,7 @@ pub trait RunIteratorHook {
fn finalize(&mut self);
}
#[cfg(font_harfbuzz)]
impl RunIteratorHook for crate::shaper::harfbuzz::Shaper {
fn prepare(&mut self) {
Self::prepare(self);
@@ -172,6 +173,19 @@ impl RunIteratorHook for crate::shaper::harfbuzz::Shaper {
}
}
#[cfg(font_coretext_shaper)]
impl RunIteratorHook for crate::shaper::coretext::Shaper {
fn prepare(&mut self) {
Self::prepare(self);
}
fn add_codepoint(&mut self, cp: u32, cluster: u32) {
Self::add_codepoint(self, cp, cluster);
}
fn finalize(&mut self) {
Self::finalize(self);
}
}
impl RunIteratorHook for crate::shaper::noop::Shaper {
fn prepare(&mut self) {
Self::prepare(self);