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

@@ -390,6 +390,7 @@ impl Surface {
/// `textCallback`: text input that is pasted verbatim.
pub fn text_callback(&self, text: &str) -> Result<(), ClipboardError> {
let _crash = self.crash_guard();
self.complete_clipboard_paste(text.as_bytes(), true)
}
}
+28 -4
View File
@@ -906,6 +906,33 @@ impl Surface {
}
}
/// Build the key encoding options (Zig: `encodeKeyOpts`). On macOS the
/// `macos-option-as-alt` value comes from the config, or is detected from
/// the keyboard layout when unset.
fn encode_key_opts(&self) -> key_encode::Options {
let mut opts = {
let t = self.lock_terminal();
encode_options_from_terminal(&t)
};
if !cfg!(target_os = "macos") {
return opts;
}
opts.macos_option_as_alt = self.cfg().macos_option_as_alt.unwrap_or_else(|| {
// If we don't have alt pressed, it doesn't matter what this
// config is so we can just say "false" and avoid more expensive
// checks below.
if !self.mouse.borrow().mods.alt {
return ghostty_input::OptionAsAlt::False;
}
// Alt is pressed, we're on macOS. We break some encapsulation
// here and assume libghostty for ease...
self.rt_app.keyboard_layout().detect_option_as_alt()
});
opts
}
/// Encodes the key event into a write request. Returns `None` if
/// nothing was encoded.
fn encode_key(
@@ -914,10 +941,7 @@ impl Surface {
insp_ev: Option<&mut inspector::KeyEvent>,
) -> Option<IoMessage> {
// Build our encoding options, which requires the lock.
let opts = {
let t = self.lock_terminal();
encode_options_from_terminal(&t)
};
let opts = self.encode_key_opts();
let mut data: Vec<u8> = Vec::with_capacity(64);
if let Err(err) = key_encode::encode(&mut data, event, &opts) {
+91 -13
View File
@@ -90,8 +90,11 @@ use ghostty_font::{
use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods};
use ghostty_renderer::{
Health,
Renderer,
generic::Host as RendererHost,
// The generic renderer over the OpenGL API (the GTK runtime's graphics
// API). `ghostty_renderer::Renderer` is the platform default, which is
// Metal on Apple targets; the embedded (macOS) runtime provides its own
// Metal-based surface setup (TODO(integration): L1).
generic::{Host as RendererHost, Renderer},
message::{FontGridChange, Message as RendererMessage},
opengl::OpenGL,
options::Options as RendererOptions,
@@ -258,6 +261,9 @@ pub struct Surface {
/// All our sizing information.
pub(crate) size: Cell<Size>,
/// The dimensions recorded in crash reports (see `ghostty_crash`).
crash_dims: Arc<ghostty_crash::SurfaceDims>,
/// The configuration derived from the main config.
config: RefCell<Rc<DerivedConfig>>,
@@ -468,7 +474,7 @@ impl Surface {
let renderer_state = Arc::clone(termio.renderer_state());
// Create the renderer thread
let render_thread = ghostty_renderer::Thread::new(
let mut render_thread = ghostty_renderer::Thread::new(
&config,
Arc::clone(&renderer),
Arc::clone(&renderer_state),
@@ -478,9 +484,14 @@ impl Surface {
let _ = renderer_link.handle.set(renderer_handle.clone());
// Create the IO thread
let io_thread = IoThread::new()?;
let mut io_thread = IoThread::new()?;
let io_stop = io_thread.stop_handle();
// Crash context shared with the renderer and IO threads.
let crash_dims = ghostty_crash::SurfaceDims::new();
render_thread.crash_dims = Some(Arc::clone(&crash_dims));
io_thread.crash_dims = Some(Arc::clone(&crash_dims));
let surface = Rc::new(Surface {
id,
app: Rc::clone(&app),
@@ -504,6 +515,7 @@ impl Surface {
keyboard: RefCell::new(keyboard::Keyboard::default()),
pressed_key: RefCell::new(None),
size: Cell::new(size),
crash_dims,
config: RefCell::new(derived_config),
config_conditional_state: Cell::new(app.config_conditional_state.get()),
child_exited: Cell::new(false),
@@ -971,8 +983,10 @@ impl Surface {
// If our runtime was below some threshold then we assume that this
// was an abnormal exit and we show an error message.
if info.runtime_ms <= u64::from(self.cfg().abnormal_command_exit_runtime_ms) {
// If the exit code is 0 then it was a good exit.
if info.exit_code != 0 {
// On macOS, our exit code detection doesn't work, possibly
// because of our `login` wrapper. More investigation required.
// Elsewhere, if the exit code is 0 then it was a good exit.
if is_abnormal_exit(info.exit_code, cfg!(target_vendor = "apple")) {
log::warn!("abnormal process exit detected, showing error message");
// Try and show a GUI message. If it returns true, don't do
@@ -1084,13 +1098,17 @@ impl Surface {
t.print_string(&runtime_str)?;
t.set_attribute(Attribute::Unset)?;
let exit_code_str = info.exit_code.to_string();
t.carriage_return();
t.linefeed()?;
t.print_string("Exit Code: ")?;
t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?;
t.print_string(&exit_code_str)?;
t.set_attribute(Attribute::Unset)?;
// We don't print this on macOS because the exit code is always 0
// due to the way we launch the process.
if !cfg!(target_vendor = "apple") {
let exit_code_str = info.exit_code.to_string();
t.carriage_return();
t.linefeed()?;
t.print_string("Exit Code: ")?;
t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?;
t.print_string(&exit_code_str)?;
t.set_attribute(Attribute::Unset)?;
}
t.carriage_return();
t.linefeed()?;
@@ -1436,6 +1454,31 @@ impl Surface {
// ------------------------------------------------------------------
// Fonts and sizes
/// Publish the current size for crash reports (Zig reads the surface
/// size at crash time).
fn sync_crash_dims(&self) {
let s = self.size.get();
let g = s.grid();
self.crash_dims.set(ghostty_crash::DimsSnapshot {
screen_width: s.screen.width,
screen_height: s.screen.height,
grid_columns: u32::from(g.columns),
grid_rows: u32::from(g.rows),
cell_width: s.cell.width,
cell_height: s.cell.height,
});
}
/// Crash context for the main thread while inside a surface callback
/// (Zig: `crashThreadState`).
pub(crate) fn crash_guard(&self) -> ghostty_crash::ThreadGuard {
self.sync_crash_dims();
ghostty_crash::ThreadGuard::new(
ghostty_crash::ThreadType::Main,
Some(Arc::clone(&self.crash_dims)),
)
}
/// Change the cell size for the terminal grid. This can happen as a
/// result of changing the font size at runtime.
fn set_cell_size(&self, size: CellSize) {
@@ -1444,6 +1487,7 @@ impl Surface {
s.cell = size;
self.size.set(s);
self.balance_padding_if_needed();
self.sync_crash_dims();
// Notify the terminal
self.queue_io(IoMessage::Resize(self.size.get()));
@@ -1545,6 +1589,8 @@ impl Surface {
);
}
self.sync_crash_dims();
// Mail the IO thread
self.queue_io(IoMessage::Resize(s));
}
@@ -1624,6 +1670,7 @@ impl Surface {
/// Callback for when the surface is fully visible or not, regardless of
/// focus state.
pub fn occlusion_callback(&self, visible: bool) -> Result<(), CoreError> {
let _crash = self.crash_guard();
self.renderer_handle.send(RendererMessage::Visible(visible));
self.queue_render();
Ok(())
@@ -1666,6 +1713,7 @@ impl Surface {
/// Schedule a render (Zig: `refreshCallback`).
pub fn refresh_callback(&self) -> Result<(), CoreError> {
let _crash = self.crash_guard();
// The point of this callback is to schedule a render, so do that.
self.queue_render();
Ok(())
@@ -1837,6 +1885,13 @@ impl Drop for Surface {
/// (`rt_surface.defaultTermioEnv()`, which already removed the variables it
/// does not want to leak); only if it provides nothing (the Zig error path)
/// do we fall back to the process environment (`getEnvMap`).
/// Whether a quick child exit counts as abnormal. On Darwin our exit code
/// detection doesn't work (the `login` wrapper), so every quick exit is
/// treated as abnormal; elsewhere only non-zero exit codes are.
fn is_abnormal_exit(exit_code: u32, darwin: bool) -> bool {
darwin || exit_code != 0
}
fn rt_surface_env(rt: &dyn RtSurface) -> ghostty_os::EnvMap {
let rt_env = rt.default_termio_env();
if rt_env.is_empty() {
@@ -1854,14 +1909,17 @@ impl CoreSurface for Surface {
}
fn key_callback(&self, event: ghostty_input::KeyEvent<'_>) -> Result<InputEffect, CoreError> {
let _crash = self.crash_guard();
Surface::key_callback(self, event)
}
fn preedit_callback(&self, text: Option<&str>) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::preedit_callback(self, text)
}
fn focus_callback(&self, focused: bool) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::focus_callback(self, focused)
}
@@ -1871,26 +1929,32 @@ impl CoreSurface for Surface {
button: MouseButton,
mods: Mods,
) -> Result<bool, CoreError> {
let _crash = self.crash_guard();
Surface::mouse_button_callback(self, state, button, mods)
}
fn cursor_pos_callback(&self, pos: CursorPos, mods: Option<Mods>) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::cursor_pos_callback(self, pos, mods)
}
fn scroll_callback(&self, xoff: f64, yoff: f64, mods: ScrollMods) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::scroll_callback(self, xoff, yoff, mods)
}
fn mouse_pressure_callback(&self, stage: ghostty_input::MousePressureStage, pressure: f64) {
let _crash = self.crash_guard();
Surface::mouse_pressure_callback(self, stage, pressure);
}
fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::size_callback(self, size)
}
fn content_scale_callback(&self, scale: ContentScale) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::content_scale_callback(self, scale)
}
@@ -1903,6 +1967,7 @@ impl CoreSurface for Surface {
}
fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::color_scheme_callback(self, scheme)
}
@@ -2012,3 +2077,16 @@ impl CoreSurface for Surface {
Surface::render_inspector(self, ui);
}
}
#[cfg(test)]
mod darwin_tests {
use super::*;
#[test]
fn abnormal_exit_detection_per_platform() {
assert!(!is_abnormal_exit(0, false));
assert!(is_abnormal_exit(1, false));
assert!(is_abnormal_exit(0, true));
assert!(is_abnormal_exit(1, true));
}
}