Files
ghostty-rs/crates/ghostty-renderer/src/thread.rs
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

701 lines
24 KiB
Rust

//! The renderer thread (`renderer/Thread.zig`).
//!
//! # Threading model (GTK / OpenGL)
//!
//! Ghostty's OpenGL renderer on GTK cannot draw from the renderer thread: the
//! `GtkGLArea` context is only current on the GTK main thread, inside the
//! `render` signal. So (`must_draw_from_app_thread = true`):
//!
//! ```text
//! termio thread --(wakeup)--> renderer thread --(redraw_surface)--> GTK main thread
//! | |
//! update_frame: lock terminal, GLArea::render ->
//! shape + rebuild CPU cell buffers Renderer::draw_frame:
//! (no GL calls) upload + draw + present
//! ```
//!
//! * The thread owns timers (cursor blink 600ms, draw timer 8ms for custom
//! shader animation), drains its [`Mailbox`], and on every wakeup runs
//! `update_frame` followed by a *draw request*.
//! * A draw request is [`Host::redraw_surface`]; the apprt answers by calling
//! `queue_render()` on the GLArea, whose `render` handler calls
//! [`Renderer::draw_frame`]. Both threads synchronise through the renderer's
//! draw mutex.
//! * [`Handle::draw_now`] is the "draw now" async of Zig (used by the apprt for
//! vsync-driven renderers; unused for OpenGL on Linux).
//!
//! For headless use (tests, no GLArea) [`Thread::draw_on_app_thread`] can be
//! set to `false`, in which case the thread calls `draw_frame` itself; the GL
//! context must then be current on the renderer thread.
//!
//! The event loop (libxev in Zig) is replaced by a `crossbeam_channel::select!`
//! over the wakeup / stop / draw-now channels and the next timer deadline.
use std::{
sync::Arc,
time::{Duration, Instant},
};
use crossbeam_channel::{Receiver, Sender, bounded, never, select};
use ghostty_config::{config::Config, types_a::CustomShaderAnimation};
use ghostty_datastruct::blocking_queue::{BlockingQueue, Timeout};
use crate::{
Api,
generic::{Host, Renderer},
graphics_api::GraphicsApi,
message::Message,
state::SharedState,
};
/// 120 FPS
pub const DRAW_INTERVAL: Duration = Duration::from_millis(8);
pub const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(600);
/// The type used for sending messages to the renderer thread. For now this is
/// hardcoded with a capacity. We can make this a comptime parameter in
/// the future if we want it configurable.
pub type Mailbox = BlockingQueue<Message, 64>;
/// Configuration we need derived from the main config.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DerivedConfig {
pub custom_shader_animation: CustomShaderAnimation,
}
impl DerivedConfig {
pub fn new(config: &Config) -> DerivedConfig {
DerivedConfig {
custom_shader_animation: config.custom_shader_animation,
}
}
}
#[derive(Debug, Clone, Copy)]
struct Flags {
/// This is true when a blinking cursor should be visible and false
/// when it should not be visible. This is toggled on a timer by the
/// thread automatically.
cursor_blink_visible: bool,
/// This is true when the inspector is active.
has_inspector: bool,
/// This is true when the view is visible. This is used to determine
/// if we should be rendering or not.
visible: bool,
/// This is true when the view is focused. This defaults to true
/// and it is up to the apprt to set the correct value.
focused: bool,
}
/// A cloneable handle to a (possibly running) renderer thread. This is what
/// the surface and the apprt keep to talk to the thread from any thread.
#[derive(Clone)]
pub struct Handle {
/// The mailbox that can be used to send this thread messages. Note
/// this is a blocking queue so if it is full you will get errors (or block).
pub mailbox: Arc<Mailbox>,
wakeup: Sender<()>,
stop: Sender<()>,
draw_now: Sender<()>,
}
impl Handle {
/// Push a message. Returns false if the mailbox was full (and `timeout`
/// elapsed). This does NOT wake the thread; call [`Handle::wakeup`]
/// (Zig: `mailbox.push(msg, .{.forever}); thread.wakeup.notify()`).
pub fn push(&self, msg: Message, timeout: Timeout) -> bool {
self.mailbox.push(msg, timeout) > 0
}
/// Push a message (blocking if full) and wake the thread.
pub fn send(&self, msg: Message) -> bool {
let ok = self.push(msg, Timeout::Forever);
self.wakeup();
ok
}
/// Wake the renderer up and force a render safely from any thread.
/// Wakeups coalesce.
pub fn wakeup(&self) {
let _ = self.wakeup.try_send(());
}
/// Force a draw request immediately (does not update frame data).
pub fn draw_now(&self) {
let _ = self.draw_now.try_send(());
}
/// Stop the renderer on the next loop iteration.
pub fn stop(&self) {
let _ = self.stop.try_send(());
}
}
/// A started renderer thread.
pub struct Running {
pub handle: Handle,
join: Option<std::thread::JoinHandle<()>>,
}
impl Running {
/// Stop the thread and wait for it to exit. After this returns the
/// thread has dropped its reference to the renderer, so the caller's
/// `Arc<Renderer>` is the last one (and can be dropped on the GL thread).
pub fn stop_and_join(mut self) {
self.handle.stop();
if let Some(j) = self.join.take()
&& j.join().is_err()
{
log::warn!("renderer thread panicked");
}
}
}
impl Drop for Running {
fn drop(&mut self) {
if let Some(j) = self.join.take() {
self.handle.stop();
let _ = j.join();
}
}
}
/// Runs `Renderer::loop_exit` when the thread leaves its main loop.
struct LoopGuard<G: GraphicsApi>(Arc<Renderer<G>>);
impl<G: GraphicsApi> Drop for LoopGuard<G> {
fn drop(&mut self) {
self.0.loop_exit();
}
}
/// Runs `Renderer::thread_exit` when the thread exits.
struct ThreadGuard<G: GraphicsApi>(Arc<Renderer<G>>);
impl<G: GraphicsApi> Drop for ThreadGuard<G> {
fn drop(&mut self) {
self.0.thread_exit();
}
}
/// Run `f` inside an autorelease pool on Darwin ("This is effectively an
/// @autoreleasepool{} block, which we need in order to ensure that
/// autoreleased objects are properly released."); just calls `f` elsewhere.
fn with_autorelease_pool<R>(f: impl FnOnce() -> R) -> R {
#[cfg(target_vendor = "apple")]
{
objc2::rc::autoreleasepool(|_| f())
}
#[cfg(not(target_vendor = "apple"))]
{
f()
}
}
/// The QoS class for the thread's visibility / focus state
/// (`setQosClass`): "If we aren't visible (our view is fully occluded) then we
/// always drop our rendering priority down because it's just mostly wasted
/// work. If we're not focused, but we're visible, then we set a higher than
/// default priority because framerates still matter but it isn't as important
/// as when we're focused. We are focused and visible, we are the definition of
/// user interactive."
#[cfg(any(target_vendor = "apple", test))]
fn qos_for(visible: bool, focused: bool) -> QosClass {
if !visible {
QosClass::Utility
} else if !focused {
QosClass::UserInitiated
} else {
QosClass::UserInteractive
}
}
/// `ghostty_os::macos::QosClass` (re-declared so the policy can be tested on
/// every platform).
#[cfg(any(target_vendor = "apple", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QosClass {
UserInteractive,
UserInitiated,
Utility,
}
/// The renderer thread state. Construct with [`Thread::new`] then start it
/// with [`Thread::spawn`] (or run [`Thread::thread_main`] on a thread you
/// manage).
pub struct Thread<G: GraphicsApi = Api> {
/// The underlying renderer implementation.
renderer: Arc<Renderer<G>>,
/// Pointer to the shared state that is used to generate the final render.
state: SharedState,
/// The surface/apprt callbacks.
host: Arc<dyn Host>,
/// Configuration we need derived from the main config.
config: DerivedConfig,
/// If true (the default, except on Apple platforms), draws are requested from the apprt via
/// [`Host::redraw_surface`] instead of being performed on this thread
/// (`must_draw_from_app_thread`).
pub draw_on_app_thread: bool,
flags: Flags,
/// The surface dimensions recorded in crash reports.
pub crash_dims: Option<Arc<ghostty_crash::SurfaceDims>>,
handle: Handle,
wakeup_rx: Receiver<()>,
wakeup_tx: Sender<()>,
stop_rx: Receiver<()>,
draw_now_rx: Receiver<()>,
/// Deadline of the cursor blink timer; None when it isn't running.
cursor_deadline: Option<Instant>,
/// Whether the draw timer should keep re-arming, and its next deadline.
draw_active: bool,
draw_deadline: Option<Instant>,
}
impl<G: GraphicsApi> Thread<G> {
/// Initialize the thread. This does not START the thread. This only sets
/// up all the internal state necessary prior to starting the thread. It
/// is up to the caller to start the thread with [`Thread::spawn`].
pub fn new(
config: &Config,
renderer: Arc<Renderer<G>>,
state: SharedState,
host: Arc<dyn Host>,
) -> Thread<G> {
// Wakeups, stop and draw-now coalesce (xev.Async semantics).
let (wakeup_tx, wakeup_rx) = bounded(1);
let (stop_tx, stop_rx) = bounded(1);
let (draw_now_tx, draw_now_rx) = bounded(1);
let handle = Handle {
mailbox: Arc::new(Mailbox::new()),
wakeup: wakeup_tx.clone(),
stop: stop_tx,
draw_now: draw_now_tx,
};
Thread {
renderer,
state,
host,
config: DerivedConfig::new(config),
// Only the GTK apprt must draw from the app thread
// (`apprt.App.must_draw_from_app_thread`); the embedded macOS /
// iOS apprt lets the renderer thread draw (Metal is thread-safe).
draw_on_app_thread: !cfg!(target_vendor = "apple"),
flags: Flags {
cursor_blink_visible: false,
has_inspector: false,
visible: true,
focused: true,
},
crash_dims: None,
handle,
wakeup_rx,
wakeup_tx,
stop_rx,
draw_now_rx,
cursor_deadline: None,
draw_active: false,
draw_deadline: None,
}
}
/// The handle used to message this thread. Available before the thread
/// is started so the surface can queue messages.
pub fn handle(&self) -> Handle {
self.handle.clone()
}
/// Start the thread (named "renderer"). The returned [`Running`] keeps
/// the [`Handle`].
pub fn spawn(self) -> std::io::Result<Running> {
let handle = self.handle.clone();
let join = std::thread::Builder::new()
.name("renderer".into())
.spawn(move || self.thread_main())?;
Ok(Running {
handle,
join: Some(join),
})
}
/// The main entrypoint for the thread.
pub fn thread_main(mut self) {
log::debug!("starting renderer thread");
// Right now, on Darwin, `std.Thread.setName` can only name the current
// thread, and we have no way to get the current thread from within it,
// so instead we use this code to name the thread instead.
#[cfg(target_vendor = "apple")]
ghostty_os::macos::pthread_setname_np(c"renderer");
// Setup our crash metadata
let _crash = ghostty_crash::ThreadGuard::new(
ghostty_crash::ThreadType::Renderer,
self.crash_dims.clone(),
);
// Setup our thread QoS
self.set_qos_class();
// Run our loop start/end callbacks if the renderer cares (Metal's
// display callback, the macOS display link).
let draw_now = self.handle.draw_now.clone();
self.renderer.loop_enter(Arc::new(move || {
let _ = draw_now.try_send(());
}));
let _loop_guard = LoopGuard(Arc::clone(&self.renderer));
// Run our thread start/end callbacks. This is important because some
// renderers have to do per-thread setup. For OpenGL on GTK these are
// no-ops: all GL work is on the main thread.
self.renderer.thread_enter();
let _thread_guard = ThreadGuard(Arc::clone(&self.renderer));
// Send an initial wakeup message so that we render right away.
let _ = self.wakeup_tx.try_send(());
// Start blinking the cursor.
self.cursor_deadline = Some(Instant::now() + CURSOR_BLINK_INTERVAL);
// Start the draw timer
self.sync_draw_timer();
// Run
loop {
let next = [self.cursor_deadline, self.draw_deadline]
.into_iter()
.flatten()
.min();
let timer = next.map_or_else(never, crossbeam_channel::at);
select! {
recv(self.wakeup_rx) -> _ => {
// When we wake up, we check the mailbox. Mailbox producers
// should wake up our thread after publishing.
self.drain_mailbox();
// Render immediately
self.render_callback();
}
recv(self.stop_rx) -> _ => break,
recv(self.draw_now_rx) -> _ => {
// Draw immediately
self.draw_frame(true);
}
recv(timer) -> _ => self.timers_due(),
}
}
log::debug!("starting renderer thread shutdown");
}
/// Fire whichever timers are due.
fn timers_due(&mut self) {
let now = Instant::now();
if self.cursor_deadline.is_some_and(|d| d <= now) {
self.cursor_timer_callback();
}
if self.draw_deadline.is_some_and(|d| d <= now) {
self.draw_callback();
}
}
fn sync_draw_timer(&mut self) {
if !should_draw_timer_run(
self.renderer.has_animations(),
self.config.custom_shader_animation,
self.flags.focused,
) {
// We're skipping the draw timer. Stop it on the next iteration.
self.draw_active = false;
return;
}
// Set our active state so it knows we're running. We set this before
// even checking the active state in case we have a pending shutdown.
self.draw_active = true;
// If our draw timer is already active, then we don't have to do anything.
if self.draw_deadline.is_some() {
return;
}
// Start the timer which loops
self.draw_deadline = Some(Instant::now() + DRAW_INTERVAL);
}
/// Drain the mailbox.
fn drain_mailbox(&mut self) {
with_autorelease_pool(|| self.drain_mailbox_inner());
}
/// Set the QoS class of this thread. Thread QoS classes are only
/// relevant on macOS.
#[cfg_attr(not(target_vendor = "apple"), allow(clippy::unused_self))]
fn set_qos_class(&self) {
#[cfg(target_vendor = "apple")]
{
use ghostty_os::macos::{self, QosClass as OsQosClass};
let class = qos_for(self.flags.visible, self.flags.focused);
let os_class = match class {
QosClass::UserInteractive => OsQosClass::UserInteractive,
QosClass::UserInitiated => OsQosClass::UserInitiated,
QosClass::Utility => OsQosClass::Utility,
};
match macos::set_qos_class(os_class) {
Ok(()) => log::debug!("thread QoS class set class={class:?}"),
Err(err) => log::warn!("error setting QoS class err={err}"),
}
}
}
fn drain_mailbox_inner(&mut self) {
while let Some(message) = self.handle.mailbox.pop() {
match message {
Message::Crash => panic!("crash request, crashing intentionally"),
Message::Visible(v) => {
// If our state didn't change we do nothing.
if self.flags.visible == v {
continue;
}
// Set our visible state
self.flags.visible = v;
// Visibility affects our QoS class
self.set_qos_class();
// If we became visible then we immediately trigger a draw.
// We don't need to update frame data because that should
// still be happening.
if v {
self.draw_frame(false);
}
// Notify the renderer so it can update any state.
self.renderer.set_visible(v);
// Note that we're explicitly today not stopping any
// cursor timers, draw timers, etc. These things have very
// little resource cost and properly maintaining their active
// state across different transitions is going to be bug-prone,
// so its easier to just let them keep firing and have them
// check the visible state themselves to control their behavior.
}
Message::Focus(v) => {
// If our state didn't change we do nothing.
if self.flags.focused == v {
continue;
}
// Set our state
self.flags.focused = v;
// Focus affects our QoS class
self.set_qos_class();
// Set it on the renderer
self.renderer.set_focus(v);
// We always resync our draw timer (may disable it)
self.sync_draw_timer();
if v {
// If we're focused, we immediately show the cursor again
// and then restart the timer.
if self.cursor_deadline.is_none() {
self.flags.cursor_blink_visible = true;
self.cursor_deadline = Some(Instant::now() + CURSOR_BLINK_INTERVAL);
}
} else {
// If we're not focused, then we stop the cursor blink
self.cursor_deadline = None;
}
}
Message::ResetCursorBlink => {
self.flags.cursor_blink_visible = true;
if self.cursor_deadline.is_some() {
self.cursor_deadline = Some(Instant::now() + CURSOR_BLINK_INTERVAL);
}
}
Message::FontGrid(grid) => {
let grid = *grid;
self.renderer.set_font_grid(grid.grid);
grid.set.deref(&grid.old_key);
}
Message::Resize(v) => self.renderer.set_screen_size(v),
Message::ChangeConfig { thread, r#impl } => {
self.config = *thread;
if let Err(err) = self.renderer.change_config(*r#impl) {
log::warn!("error changing renderer config err={err}");
}
// Stop and start the draw timer to capture the new
// has_animations value.
self.sync_draw_timer();
}
Message::SearchViewportMatches(v) => {
self.renderer.set_search_viewport_matches(v);
}
Message::SearchSelectedMatch(v) => {
self.renderer.set_search_selected_match(v);
}
Message::Inspector(v) => {
self.flags.has_inspector = v;
}
Message::MacosDisplayId(id) => self.renderer.set_macos_display_id(id),
}
}
}
/// Trigger a draw. This will not update frame data or anything, it will
/// just trigger a draw/paint.
fn draw_frame(&self, now: bool) {
// If we're invisible, we do not draw.
if !self.flags.visible {
return;
}
// If the renderer is managing a vsync on its own, we only draw
// when we're forced to via `now`.
if !now && self.renderer.has_vsync() {
return;
}
if self.draw_on_app_thread {
let _ = self.host.redraw_surface();
} else if let Err(err) = self.renderer.draw_frame(false) {
log::warn!("error drawing err={err}");
}
}
fn render_callback(&self) {
// Update our frame data
if let Err(err) = self
.renderer
.update_frame(&self.state, self.flags.cursor_blink_visible)
{
log::warn!("error rendering err={err}");
}
// Draw
self.draw_frame(false);
}
fn draw_callback(&mut self) {
// Draw
self.draw_frame(false);
// Only continue if we're still active
self.draw_deadline = if self.draw_active {
Some(Instant::now() + DRAW_INTERVAL)
} else {
None
};
}
fn cursor_timer_callback(&mut self) {
self.flags.cursor_blink_visible = !self.flags.cursor_blink_visible;
let _ = self.wakeup_tx.try_send(());
self.cursor_deadline = Some(Instant::now() + CURSOR_BLINK_INTERVAL);
}
}
/// Whether the draw timer (used for custom shader animation) should run.
fn should_draw_timer_run(
has_animations: bool,
animation: CustomShaderAnimation,
focused: bool,
) -> bool {
// If our renderer supports animations and has them, then we
// can apply draw timer based on custom shader animation configuration.
if has_animations {
match animation {
// Always animate
CustomShaderAnimation::Always => return true,
// Only when focused
CustomShaderAnimation::True => {
if focused {
return true;
}
}
// Never animate
CustomShaderAnimation::False => {}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn draw_timer_policy() {
use CustomShaderAnimation::{Always, False, True};
// No animations: never runs.
for a in [Always, True, False] {
for f in [true, false] {
assert!(!should_draw_timer_run(false, a, f));
}
}
// Animations: always / only-when-focused / never.
assert!(should_draw_timer_run(true, Always, false));
assert!(should_draw_timer_run(true, Always, true));
assert!(should_draw_timer_run(true, True, true));
assert!(!should_draw_timer_run(true, True, false));
assert!(!should_draw_timer_run(true, False, true));
}
#[test]
fn qos_class_policy() {
assert_eq!(qos_for(false, true), QosClass::Utility);
assert_eq!(qos_for(false, false), QosClass::Utility);
assert_eq!(qos_for(true, false), QosClass::UserInitiated);
assert_eq!(qos_for(true, true), QosClass::UserInteractive);
}
#[test]
fn intervals_match_zig() {
assert_eq!(DRAW_INTERVAL, Duration::from_millis(8));
assert_eq!(CURSOR_BLINK_INTERVAL, Duration::from_millis(600));
}
#[test]
fn derived_config_from_config() {
let c = Config {
custom_shader_animation: CustomShaderAnimation::Always,
..Config::default()
};
assert_eq!(
DerivedConfig::new(&c).custom_shader_animation,
CustomShaderAnimation::Always
);
}
}