Files
ghostty-rs/docs/rewrite/notes/macos-M1-renderer.md
T
ddidderr 452b782149 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)
2026-10-12 06:02:39 +02:00

8.4 KiB

macOS M1 — renderer (Metal backend + GraphicsApi abstraction)

Scope: crates/ghostty-renderer only. Zig: renderer/Metal.zig, renderer/metal/*, the macOS parts of generic.zig / Thread.zig / renderer.zig / shadertoy.zig.

GraphicsApi abstraction (graphics_api.rs)

Zig's GenericRenderer(comptime GraphicsAPI) duck-types its API; Rust has the GraphicsApi trait with associated types (Target, Texture, Sampler, Buffer<T> (GAT), BufferRef, Pipeline, Shaders, GpuUniforms, Frame, RenderPass<'a>), associated consts (CUSTOM_SHADER_TARGET, CUSTOM_SHADER_Y_IS_DOWN, SWAP_CHAIN_COUNT) and operations (init_*, begin_frame, surface_size, present_last_target, draw_frame_start/end, loop_enter/exit, ...). Helper traits: GpuBuffer, GpuTexture, GpuTarget, GpuShaders, GpuFrame, GpuRenderPass; shared value types Attachment, AttachmentTarget, Draw, Step, Primitive, ImageTextureFormat, TextureKind, ApiError.

  • generic::Renderer<G: GraphicsApi = OpenGL>, image::{State,Image,Entry}<G>, thread::Thread<G = Api>.
  • lib.rs: Api = metal::Metal on target_vendor = "apple", opengl::OpenGL otherwise; Renderer = generic::Renderer<Api> (Backend::default_for_target agrees, tested).
  • The OpenGL backend implements the trait in opengl/api.rs by delegating to its (unchanged) inherent API, so apprt-gtk and tests/egl_headless.rs keep using the GL-typed API. opengl::render_pass::{Attachment,Step,Draw} are now aliases of the generic types; gl::Primitive re-exports graphics_api::Primitive.
  • CPU-side GPU structs shared by every API moved to shader_types.rs (Uniforms keeps the std140 layout with bit-packed bools; Metal converts via GraphicsApi::gpu_uniforms to MetalUniforms, whose bools are four bytes like the MSL struct).
  • Behaviour changes needed for asynchronous (Metal) frames, equal to Zig's frame_sema/frameCompleted: the swap chain hands frame states back from the frame completion callback (SwapChainInner + Condvar); begin_frame(&self) no longer borrows the API; GpuFrame::complete(sync, completion) reports health through a FrameCompletion (inline for GL / sync Metal, on Metal's completion thread otherwise); display_unrealized waits for in-flight frames; font grid changes bump an atlas_epoch so in-flight frames resync atlases.
  • Renderer::display_realized<F>(loader) / last_frame_pixels exist only for Renderer<OpenGL>; the generic hook is display_realized_with(|api| ..).
  • GL: OpenGL::last_target is now Arc<Mutex<Option<Arc<Target>>>> (frames present without borrowing the API); present, present_last_target, clear_last_target, begin_frame take &self; last_target() returns a clone.

Metal (src/metal/, cfg(target_vendor = "apple"), objc2 0.6 + objc2-metal 0.3)

Zig Rust
Metal.zig metal/mod.rs (Metal, ViewInfo, Metal::init(blending, &ViewInfo))
metal/api.zig metal_layout.rs (raw values, plain Rust, unit tested) + objc2_metal types
metal/buffer.zig metal/buffer.rs
metal/Frame.zig metal/frame.rs (command buffer, completion block via block2::RcBlock)
metal/IOSurfaceLayer.zig metal/io_surface_layer.rs (define_class! CALayer subclass IOSurfaceLayer: display, actionForKey:; main-thread set_surface via dispatch2)
metal/Pipeline.zig metal/pipeline.rs (vertex attributes from MetalVertex::ATTRIBUTES instead of reflection)
metal/RenderPass.zig metal/render_pass.rs (buffer slots: vertex 0, uniforms 1, rest from 2)
metal/Sampler.zig, Target.zig, Texture.zig metal/{sampler,target,texture}.rs (Target = IOSurface BGRA + Display P3 colorspace + MTLTexture)
metal/shaders.zig metal/shaders.rs (+ shader_types, metal_layout::PIPELINE_DESCS)
shaders/shaders.metal shaders/shaders.metal (verbatim)
  • The metallib: Zig precompiles shaders.metal with xcrun metal; we embed the source and compile it with newLibraryWithSource at Shaders::init (same as post-process shaders already do). Entry points are looked up by name.
  • View attachment is done with msg_send! on the NSView/UIView (no AppKit dependency): macOS setLayer: then setWantsLayer:, iOS addSublayer:. setClipsToBounds: is guarded with respondsToSelector: (NSView API since macOS 14).
  • Metal::init expects to run on the main thread with the view the apprt (L1) provides; the apprt builds Metal and passes it to Renderer::new(opts, api); Thread::draw_on_app_thread defaults to false on Apple targets.
  • iOS: compiled via target_vendor = "apple" with the UIView branch; not checked here (only macOS targets are installed).

macOS parts of generic / thread

  • display_link.rs: CVDisplayLink wrapper (objc2-core-video, macOS only). On other platforms DisplayLink is uninhabited and the renderer holds None (Zig: DisplayLink = void). Renderer creates it when window-vsync is set (macOS), loop_enter(draw_now) installs the callback and starts it, loop_exit stops, set_focus/set_visible start/stop it, set_macos_display_id, has_vsync (= link running).
  • loop_enter also sets the Metal layer's display callback to draw_frame(true) (holding a Weak<Renderer>).
  • thread.rs: pthread_setname_np("renderer"), QoS class (qos_for(visible, focused), tested on every platform), autorelease pool around drain_mailbox, Message::MacosDisplayId, thread_enter/exit, loop_enter/exit guards.
  • generic.rs: with background-blur = macos-glass-* the background alpha is forced to 0 (macOS only). Renderer::{thread_enter,thread_exit,loop_enter,loop_exit,set_macos_display_id} are new.

Shadertoy MSL

shadertoy::msl_from_compiled (naga msl-out, enabled on all platforms so it is tested on Linux): entry point renamed to ghostty_main (spirv-cross' main0 becomes main0_ in naga, which suffixes names ending in a digit), Globals at [[buffer(1)]], iChannel0 at [[texture(0)]] / [[sampler(0)]] (what the Metal render pass binds), MSL 2.1. Target::Msl no longer returns Unsupported. Known limitation: the unused iChannelTime float array has std140 stride 16 in the CPU struct but naga emits tightly packed float[4] (spirv-cross emits padded types); no Ghostty shader uses it.

Tests

  • Linux (run): metal_layout (resource options / texture usage bits, storage mode, max texture size, surface size clamp, pixel formats, vertex attribute tables, buffer slots, blend state, MetalUniforms offsets == MSL struct, conversion from the std140 uniforms, shader function names exist in shaders.metal), shadertoy shadertoy_to_msl (port of "shadertoy to msl", +focus shader, +file loading), thread::qos_class_policy, backend tests, tests/mock_api.rs (generic renderer over a mock API with asynchronous triple-buffered completion, health reporting, unrealize waiting for in-flight frames). Totals: 97 unit + 9 EGL + 2 mock_api, all passing; the 9 EGL tests are unchanged apart from dropping an unneeded mut.
  • macOS (compile-checked only): metal/target.rs, render_pass.rs, frame.rs unit tests (pixel format fourcc, primitive mapping, health from command buffer status) compile with --all-targets.
  • Not ported: Zig has no tests in the Metal files other than shadertoy to msl (ported) and the CellText size test (covered by shader_types layout tests).

Deviations / notes

  • Buffer::sync (Metal) allocates exactly double the required bytes; Zig multiplies by @sizeOf(T) again.
  • Texture::replace_region rejects too-short data (safe fn), Zig does not check.
  • Image vertex stays 48 bytes (GL layout padded to 16); Metal's stride is size_of of that struct, offsets unchanged.
  • ghostty-core's surface/mod.rs imports ghostty_renderer::Renderer (the platform alias) but builds an OpenGL API (GTK); on macOS that alias is Renderer<Metal>. Until L1 provides the embedded surface, core should import ghostty_renderer::generic::Renderer (= Renderer<OpenGL>, identical type on Linux) so cargo check --target *-apple-darwin -p ghostty-core passes. Thread is generic over the API for this reason.
  • Cross-check recipe (no macOS SDK): GETTEXT_DIR=/nonexistent (skip gettext-sys source build), CC_<target> = a wrapper around zig cc -target aarch64-macos for onig_sys (drop -arch/--target/-mmacosx-version-min), CARGO_BUILD_RUSTC_WRAPPER= (kache is picked up as cc wrapper), ZIG_GLOBAL_CACHE_DIR on a writable fs.