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 76771bed0d
commit bab49ee596
124 files changed
+14936 -1242

No files matched your search

+439 -227
View File
@@ -44,10 +44,11 @@ use std::{
collections::HashSet,
sync::{
Arc,
Condvar,
Mutex,
MutexGuard,
PoisonError,
atomic::{AtomicBool, AtomicU8, Ordering},
atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering},
},
time::Instant,
};
@@ -92,56 +93,58 @@ use crate::{
backend::Health,
cell::{self, Contents, Key as CellKey},
cursor::{self, Style as CursorStyle},
display_link::DisplayLink,
graphics_api::{
ApiError,
Attachment,
AttachmentTarget,
Draw,
FrameCompletion,
GpuBuffer,
GpuFrame,
GpuRenderPass,
GpuShaders,
GpuTarget,
GpuTexture,
GraphicsApi,
Primitive,
Step,
TextureKind,
},
image::{self, Gpu},
link,
math,
opengl::{
self,
OpenGL,
Sampler,
Target,
Texture,
buffer::Buffer,
gl::{self, Gl, Primitive},
render_pass::{Attachment, AttachmentTarget, Draw, Step},
shaders::{
BgImage,
BgImageFit,
BgImageInfo,
BgImagePosition,
Bools,
CellBg,
CellText,
CellTextAtlas,
PaddingExtend,
Shaders,
Uniforms,
},
},
opengl::OpenGL,
options::Options,
overlay::{self, Overlay},
row::never_extend_bg,
shader_types::{
BgImage,
BgImageFit,
BgImageInfo,
BgImagePosition,
Bools,
CellBg,
CellText,
CellTextAtlas,
PaddingExtend,
Uniforms,
},
shadertoy::{self, PathItem},
size::{CellSize, GridSize, Padding, Size},
state::{Preedit, SharedState},
};
/// The graphics API used by the renderer on Linux.
pub type GraphicsApi = OpenGL;
/// Errors surfaced by the generic renderer.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Api(#[from] opengl::Error),
Api(#[from] ApiError),
#[error(transparent)]
Gl(#[from] gl::Error),
Shaper(#[from] ghostty_font::shape::ShapeError),
#[error(transparent)]
Texture(#[from] opengl::texture::Error),
#[error(transparent)]
Shaper(#[from] ghostty_font::shaper::harfbuzz::ShapeError),
#[error(transparent)]
ShapeRun(#[from] ghostty_font::shaper::harfbuzz::ShapeRunError),
ShapeRun(#[from] ghostty_font::shape::ShapeRunError),
#[error(transparent)]
Grid(#[from] ghostty_font::shared_grid::Error),
#[error(transparent)]
@@ -317,47 +320,48 @@ impl std::fmt::Debug for DerivedConfig {
/// With OpenGL `swap_chain_count == 1` and frames complete synchronously
/// (`glFinish`), so the semaphore of the Zig swap chain is not needed: the
/// draw mutex already serializes `draw_frame`.
struct FrameState {
uniforms: Buffer<Uniforms>,
cells: Buffer<CellText>,
cells_bg: Buffer<CellBg>,
struct FrameState<G: GraphicsApi> {
uniforms: G::Buffer<G::GpuUniforms>,
cells: G::Buffer<CellText>,
cells_bg: G::Buffer<CellBg>,
grayscale: Texture,
grayscale: G::Texture,
grayscale_modified: usize,
color: Texture,
color: G::Texture,
color_modified: usize,
target: Arc<Target>,
target: Arc<G::Target>,
/// See property of same name on Renderer for explanation.
target_config_modified: usize,
/// Buffer with the vertex data for our background image.
bg_image_buffer: Buffer<BgImage>,
bg_image_buffer: G::Buffer<BgImage>,
/// See property of same name on Renderer for explanation.
bg_image_buffer_modified: usize,
/// Custom shader state, this is None if we have no custom shaders.
custom_shader_state: Option<CustomShaderState>,
custom_shader_state: Option<CustomShaderState<G>>,
/// The swap chain's atlas epoch this frame state last synced with.
atlas_epoch: usize,
}
impl FrameState {
fn new(api: &OpenGL, custom_shaders: bool) -> Result<FrameState, Error> {
let gl = api.gl().ok_or(opengl::Error::NoContext)?;
impl<G: GraphicsApi> FrameState<G> {
fn new(api: &G, custom_shaders: bool) -> Result<FrameState<G>, Error> {
// Uniform buffer contains exactly 1 uniform struct. The
// uniform data will be zeroed so this must be set before
// a frame is drawn.
let uniforms = Buffer::<Uniforms>::init(gl, api.uniform_buffer_options(), 1)?;
let uniforms = api.init_buffer::<G::GpuUniforms>(1)?;
// Create GPU buffers for our cells.
//
// We start them off with a size of 1, which will of course be
// too small, but they will be resized as needed.
let cells = Buffer::<CellText>::init(gl, api.fg_buffer_options(), 1)?;
let cells_bg = Buffer::<CellBg>::init(gl, api.bg_buffer_options(), 1)?;
let cells = api.init_buffer::<CellText>(1)?;
let cells_bg = api.init_buffer::<CellBg>(1)?;
// Create a GPU buffer for our background image info.
let bg_image_buffer = Buffer::<BgImage>::init(gl, api.bg_image_buffer_options(), 1)?;
let bg_image_buffer = api.init_buffer::<BgImage>(1)?;
// Initialize our textures for our font atlas.
//
@@ -393,10 +397,11 @@ impl FrameState {
bg_image_buffer,
bg_image_buffer_modified: 0,
custom_shader_state,
atlas_epoch: 0,
})
}
fn resize(&mut self, api: &OpenGL, width: usize, height: usize) -> Result<(), Error> {
fn resize(&mut self, api: &G, width: usize, height: usize) -> Result<(), Error> {
if let Some(state) = &mut self.custom_shader_state {
state.resize(api, width, height)?;
}
@@ -406,35 +411,32 @@ impl FrameState {
}
/// State relevant to our custom shaders if we have any.
struct CustomShaderState {
struct CustomShaderState<G: GraphicsApi> {
/// When we have a custom shader state, we maintain a front
/// and back texture which we use as a swap chain to render
/// between when multiple custom shaders are defined.
front_texture: Texture,
back_texture: Texture,
front_texture: G::Texture,
back_texture: G::Texture,
/// Shadertoy uses a sampler for accessing the various channel textures.
/// Samplers are immutable and describe sampling properties so we can
/// share the sampler across front/back textures.
sampler: Sampler,
sampler: G::Sampler,
uniforms: Buffer<shadertoy::Uniforms>,
uniforms: G::Buffer<shadertoy::Uniforms>,
}
impl CustomShaderState {
fn new(api: &OpenGL) -> Result<CustomShaderState, Error> {
let gl = api.gl().ok_or(opengl::Error::NoContext)?;
impl<G: GraphicsApi> CustomShaderState<G> {
fn new(api: &G) -> Result<CustomShaderState<G>, Error> {
// Create a GPU buffer to hold our uniforms.
let uniforms = Buffer::<shadertoy::Uniforms>::init(gl, api.uniform_buffer_options(), 1)?;
let uniforms = api.init_buffer::<shadertoy::Uniforms>(1)?;
// Initialize the front and back textures at 1x1 px, this
// is slightly wasteful but it's only done once so whatever.
let front_texture = Texture::init(gl, api.texture_options(), 1, 1, None)?;
let back_texture = Texture::init(gl, api.texture_options(), 1, 1, None)?;
let front_texture = api.init_texture(TextureKind::CustomShader, 1, 1, None)?;
let back_texture = api.init_texture(TextureKind::CustomShader, 1, 1, None)?;
let sampler = Sampler::init(gl, api.sampler_options())
.map_err(|_| gl::Error::CreateFailed("sampler".into()))?;
let sampler = api.init_sampler()?;
Ok(CustomShaderState {
front_texture,
@@ -449,18 +451,35 @@ impl CustomShaderState {
std::mem::swap(&mut self.front_texture, &mut self.back_texture);
}
fn resize(&mut self, api: &OpenGL, width: usize, height: usize) -> Result<(), Error> {
let gl = api.gl().ok_or(opengl::Error::NoContext)?;
self.front_texture = Texture::init(gl, api.texture_options(), width, height, None)?;
self.back_texture = Texture::init(gl, api.texture_options(), width, height, None)?;
fn resize(&mut self, api: &G, width: usize, height: usize) -> Result<(), Error> {
self.front_texture = api.init_texture(TextureKind::CustomShader, width, height, None)?;
self.back_texture = api.init_texture(TextureKind::CustomShader, width, height, None)?;
Ok(())
}
}
/// The frame slots of a [`SwapChain`], shared with the frame completion
/// callbacks (which may run on another thread and hand the frame state back).
struct SwapChainInner<G: GraphicsApi> {
/// `None` while the slot's frame is in flight (or being drawn).
slots: Mutex<Vec<Option<FrameState<G>>>>,
/// Signalled when a frame is returned (Zig: `frame_sema.post`).
returned: Condvar,
/// Bumped to force every frame's atlas textures to re-sync, including
/// the ones that are in flight right now (see `FrameState::atlas_epoch`).
atlas_epoch: AtomicUsize,
}
/// Swap chain which maintains multiple copies of the state needed to render
/// a frame (always one for OpenGL, see [`FrameState`]).
struct SwapChain {
frames: Vec<Option<FrameState>>,
/// a frame, so that we can start building the next frame while the previous
/// frame is still being processed on the GPU (always one for OpenGL, see
/// [`FrameState`]; three for Metal).
///
/// Zig guards the frames with a semaphore; here taking a frame waits (on a
/// condition variable) until its slot has been handed back by the frame's
/// completion callback.
struct SwapChain<G: GraphicsApi> {
inner: Arc<SwapChainInner<G>>,
/// Index of the most recently used frame state struct.
frame_index: usize,
/// Set to true when deinited, so that a defunct swap chain is ignored
@@ -468,14 +487,18 @@ struct SwapChain {
defunct: bool,
}
impl SwapChain {
fn new(api: &OpenGL, custom_shaders: bool) -> Result<SwapChain, Error> {
let mut frames = Vec::with_capacity(opengl::SWAP_CHAIN_COUNT);
for _ in 0..opengl::SWAP_CHAIN_COUNT {
impl<G: GraphicsApi> SwapChain<G> {
fn new(api: &G, custom_shaders: bool) -> Result<SwapChain<G>, Error> {
let mut frames = Vec::with_capacity(G::SWAP_CHAIN_COUNT);
for _ in 0..G::SWAP_CHAIN_COUNT {
frames.push(Some(FrameState::new(api, custom_shaders)?));
}
Ok(SwapChain {
frames,
inner: Arc::new(SwapChainInner {
slots: Mutex::new(frames),
returned: Condvar::new(),
atlas_epoch: AtomicUsize::new(0),
}),
frame_index: 0,
defunct: false,
})
@@ -486,39 +509,91 @@ impl SwapChain {
return;
}
self.defunct = true;
self.frames.clear();
// Wait for all of our inflight draws to complete
// so that we can cleanly deinit our GPU state.
let mut slots = lock(&self.inner.slots);
while slots.iter().any(Option::is_none) {
slots = self
.inner
.returned
.wait(slots)
.unwrap_or_else(PoisonError::into_inner);
}
slots.clear();
}
/// Get the next frame state to draw to.
fn next_frame(&mut self) -> Result<(usize, FrameState), Error> {
if self.defunct || self.frames.is_empty() {
/// Get the next frame state to draw to. This waits until the frame is
/// available. It must always be paired with a [`SwapChain::release_frame`]
/// (directly, or through the frame completion callback).
fn next_frame(&mut self) -> Result<(usize, FrameState<G>), Error> {
if self.defunct {
return Err(Error::Defunct);
}
self.frame_index = (self.frame_index + 1) % self.frames.len();
let frame = self.frames[self.frame_index].take().ok_or(Error::Defunct)?;
Ok((self.frame_index, frame))
let mut slots = lock(&self.inner.slots);
if slots.is_empty() {
return Err(Error::Defunct);
}
self.frame_index = (self.frame_index + 1) % slots.len();
let idx = self.frame_index;
let mut frame = loop {
if let Some(frame) = slots[idx].take() {
break frame;
}
slots = self
.inner
.returned
.wait(slots)
.unwrap_or_else(PoisonError::into_inner);
};
drop(slots);
// Update all our textures so that they sync on the next frame, if
// the font grid changed since this frame state was last used.
let epoch = self.inner.atlas_epoch.load(Ordering::SeqCst);
if frame.atlas_epoch != epoch {
frame.atlas_epoch = epoch;
frame.grayscale_modified = 0;
frame.color_modified = 0;
}
Ok((idx, frame))
}
fn release_frame(&mut self, idx: usize, frame: FrameState) {
if let Some(slot) = self.frames.get_mut(idx) {
/// A handle that hands a frame state back to its slot.
fn releaser(&self) -> Arc<SwapChainInner<G>> {
Arc::clone(&self.inner)
}
fn release_frame(&self, idx: usize, frame: FrameState<G>) {
SwapChainInner::release(&self.inner, idx, frame);
}
/// Make every frame re-upload its atlas textures on its next use.
fn invalidate_atlases(&self) {
self.inner.atlas_epoch.fetch_add(1, Ordering::SeqCst);
}
}
impl<G: GraphicsApi> SwapChainInner<G> {
/// Hand a frame state back to its slot (Zig: `releaseFrame`).
fn release(this: &Arc<Self>, idx: usize, frame: FrameState<G>) {
let mut slots = lock(&this.slots);
if let Some(slot) = slots.get_mut(idx) {
*slot = Some(frame);
}
}
/// Mutable access to all frames (for invalidating atlas textures).
fn frames_mut(&mut self) -> impl Iterator<Item = &mut FrameState> {
self.frames.iter_mut().flatten()
drop(slots);
this.returned.notify_all();
}
}
/// The background image state (`bg_image: ?imagepkg.Image`).
type BgImageState = Option<image::Image>;
type BgImageState<G> = Option<image::Image<G>>;
// ---------------------------------------------------------------------------
// Renderer state
/// State protected by the draw mutex: everything `draw_frame` reads.
struct DrawState {
struct DrawState<G: GraphicsApi> {
/// The configuration we need derived from the main config.
config: DerivedConfig,
@@ -566,13 +641,13 @@ struct DrawState {
font_grid: Arc<SharedGrid>,
/// The images that we may render.
images: image::State,
images: image::State<G>,
/// True when a debug overlay image is currently present (Zig: `overlay != null`).
has_overlay: bool,
/// Background image, if we have one.
bg_image: BgImageState,
bg_image: BgImageState<G>,
/// Background image vertex buffer.
bg_image_buffer: BgImage,
/// This value is used to force-update the swap chain copy
@@ -580,10 +655,10 @@ struct DrawState {
bg_image_buffer_modified: usize,
/// Graphics API state.
api: OpenGL,
api: G,
/// Our swap chain (multiple buffering)
swap_chain: SwapChain,
swap_chain: SwapChain<G>,
/// This value is used to force-update swap chain targets in the
/// event of a config change that requires it (such as blending mode).
@@ -597,7 +672,7 @@ struct DrawState {
has_custom_shaders: bool,
/// Our shader pipelines. None while defunct (display unrealized).
shaders: Option<Shaders>,
shaders: Option<G::Shaders>,
}
/// State only touched by the renderer thread.
@@ -642,17 +717,22 @@ struct ThreadState {
}
/// The generic renderer. See the module docs for the threading model.
pub struct Renderer {
draw: Mutex<DrawState>,
pub struct Renderer<G: GraphicsApi = OpenGL> {
draw: Mutex<DrawState<G>>,
thread: Mutex<ThreadState>,
/// Health of the most recently completed frame.
health: AtomicU8,
health: Arc<AtomicU8>,
/// Mirrors `DrawState::has_custom_shaders` so the renderer thread can
/// read it without the draw lock (`hasAnimations`).
has_animations: AtomicBool,
/// The CVDisplayLink used to drive the rendering loop in sync with the
/// display. Always `None` on platforms that don't support a display link
/// (everything but macOS).
display_link: Mutex<Option<DisplayLink>>,
/// The surface/apprt callbacks.
host: Arc<dyn Host>,
}
@@ -670,8 +750,8 @@ struct PreeditRange {
/// Load the custom shaders (`initShaders`). Errors are logged and result in
/// no custom shaders.
fn load_custom_shaders(config: &DerivedConfig) -> Vec<String> {
match shadertoy::load_from_files(&config.custom_shaders, opengl::CUSTOM_SHADER_TARGET) {
fn load_custom_shaders<G: GraphicsApi>(config: &DerivedConfig) -> Vec<String> {
match shadertoy::load_from_files(&config.custom_shaders, G::CUSTOM_SHADER_TARGET) {
Ok(v) => v,
Err(err) => {
log::warn!("error loading custom shaders err={err}");
@@ -680,11 +760,11 @@ fn load_custom_shaders(config: &DerivedConfig) -> Vec<String> {
}
}
impl DrawState {
impl<G: GraphicsApi> DrawState<G> {
/// Rebuild the shader pipelines (`initShaders`).
fn init_shaders(&mut self) -> Result<(), Error> {
// Load our custom shaders
let custom_shaders = load_custom_shaders(&self.config);
let custom_shaders = load_custom_shaders::<G>(&self.config);
let has_custom_shaders = !custom_shaders.is_empty();
let shaders = self.api.init_shaders(&custom_shaders)?;
@@ -778,7 +858,7 @@ impl DrawState {
fn prep_background_image(&mut self) {
// Then we try to load the background image if we have a path.
if let Some(p) = &self.config.bg_image
&& let Some(image) = load_background_image(p.path())
&& let Some(image) = load_background_image(p.path()).map(image::Image::Pending)
{
// If we have an existing background image, replace it.
// Otherwise, set this as our background image directly.
@@ -795,21 +875,6 @@ impl DrawState {
}
}
fn upload_background_image(&mut self, gpu: &Gpu<'_>) {
// Make sure our bg image is uploaded if it needs to be.
if let Some(bg) = &mut self.bg_image {
if bg.is_unloading() {
self.bg_image = None;
return;
}
if bg.is_pending()
&& let Err(err) = bg.upload(gpu)
{
log::warn!("error uploading background image err={err}");
}
}
}
/// Update custom shader uniforms that depend on terminal state.
///
/// This should be called in `update_frame` when terminal state changes.
@@ -909,7 +974,7 @@ impl DrawState {
let cell = self.size.cell;
let cursor_glyph = self.cells.get_cursor_glyph();
let focused = self.focused;
let y_is_down = opengl::CUSTOM_SHADER_Y_IS_DOWN;
let y_is_down = G::CUSTOM_SHADER_Y_IS_DOWN;
let uniforms = &mut self.custom_shader_uniforms;
@@ -1001,8 +1066,8 @@ impl DrawState {
/// Sync the atlas data to the given texture. This copies the bytes
/// associated with the atlas to the given texture. If the atlas no
/// longer fits into the texture, the texture will be resized.
fn sync_atlas_texture(api: &OpenGL, atlas: &Atlas, texture: &mut Texture) -> Result<(), Error> {
if atlas.size as usize > texture.width {
fn sync_atlas_texture(api: &G, atlas: &Atlas, texture: &mut G::Texture) -> Result<(), Error> {
if atlas.size as usize > texture.width() {
// Reallocate (the old texture is dropped/deleted)
*texture = api.init_atlas_texture(atlas)?;
}
@@ -1012,9 +1077,24 @@ impl DrawState {
}
}
/// Make sure our bg image is uploaded if it needs to be.
fn upload_background_image<G: GraphicsApi>(bg_image: &mut BgImageState<G>, gpu: &Gpu<'_, G>) {
if let Some(bg) = bg_image {
if bg.is_unloading() {
*bg_image = None;
return;
}
if bg.is_pending()
&& let Err(err) = bg.upload(gpu)
{
log::warn!("error uploading background image err={err}");
}
}
}
/// Decode a background image file (`prepBackgroundImage`). Failures are
/// logged and result in no image, as in Zig.
fn load_background_image(path: &str) -> Option<image::Image> {
fn load_background_image(path: &str) -> Option<image::Pending> {
// Read it (Max size of 4 GiB, for now.)
let contents = match std::fs::read(path) {
Ok(c) if u32::try_from(c.len()).is_ok() => c,
@@ -1060,12 +1140,12 @@ fn load_background_image(path: &str) -> Option<image::Image> {
}
};
Some(image::Image::Pending(image::Pending {
Some(image::Pending {
width,
height,
pixel_format: image::PixelFormat::Rgba,
data,
}))
})
}
/// `file_type.zig` (the subset needed here).
@@ -1239,14 +1319,14 @@ fn shaped_cells_for_run(
Ok(())
}
impl Renderer {
impl<G: GraphicsApi> Renderer<G> {
/// Create the renderer (`Renderer.init`).
///
/// `api` must already have its GL context prepared
/// ([`OpenGL::prepare_context`], i.e. Zig `Renderer.surfaceInit`), and the
/// context must be current on the calling thread: swap chain buffers,
/// `api` must already be prepared (OpenGL: [`OpenGL::prepare_context`],
/// i.e. Zig `Renderer.surfaceInit`, with the context current on the
/// calling thread; Metal: attached to its view): swap chain buffers,
/// textures and shaders are created here.
pub fn new(opts: Options<DerivedConfig>, api: OpenGL) -> Result<Renderer, Error> {
pub fn new(opts: Options<DerivedConfig>, api: G) -> Result<Renderer<G>, Error> {
let config = opts.config;
let has_custom_shaders = !config.custom_shaders.is_empty();
@@ -1342,28 +1422,36 @@ impl Renderer {
};
let has_animations = AtomicBool::new(draw.has_custom_shaders);
// The display link is created here but only started in `loop_enter`.
let display_link = if cfg!(target_os = "macos") && draw.config.vsync {
DisplayLink::create_with_active_cg_displays()
} else {
None
};
Ok(Renderer {
display_link: Mutex::new(display_link),
draw: Mutex::new(draw),
thread: Mutex::new(thread),
health: AtomicU8::new(Health::Healthy as u8),
health: Arc::new(AtomicU8::new(Health::Healthy as u8)),
has_animations,
host: opts.host,
})
}
/// This is called by the GTK apprt after the surface is reinitialized
/// This is called by the apprt after the surface is reinitialized
/// due to any of the events mentioned in the doc comment of
/// [`Renderer::display_unrealized`]. The GL context must be current.
pub fn display_realized<F>(&self, get_proc_address: F) -> Result<(), Error>
where
F: FnMut(&str) -> *const std::ffi::c_void,
{
/// [`Renderer::display_unrealized`] (Zig `displayRealized`). `realize` is
/// the API's own hook ("if our API has to do things on realize, let it":
/// OpenGL reloads its context). The GPU context must be current.
pub fn display_realized_with(&self, realize: impl FnOnce(&mut G)) -> Result<(), Error> {
// Lock the draw mutex so that we can
// safely reinitialize our GPU resources.
let mut d = lock(&self.draw);
// If our API has to do things on realize, let it.
d.api.display_realized(get_proc_address);
realize(&mut d.api);
// We assume that the swap chain was deinited in
// `display_unrealized`, in which case it should be
@@ -1398,6 +1486,7 @@ impl Renderer {
//
// This will mark them as defunct so that they
// can't be double-freed or used in draw calls.
d.api.display_unrealized();
d.swap_chain.deinit();
d.shaders = None;
d.api.clear_last_target();
@@ -1423,10 +1512,79 @@ impl Renderer {
/// True if our renderer is using vsync. If true, the renderer or apprt
/// is responsible for triggering draw_now calls to the render thread.
/// That is the only way to trigger a `draw_frame`. (Always false for
/// OpenGL on Linux; there is no display link.)
/// That is the only way to trigger a `draw_frame`. (Only true on macOS
/// with a running display link.)
pub fn has_vsync(&self) -> bool {
false
lock(&self.display_link)
.as_ref()
.is_some_and(DisplayLink::is_running)
}
/// Called by the renderer thread when it begins (`threadEnter`).
pub fn thread_enter(&self) {
lock(&self.draw).api.thread_enter();
}
/// Called by the renderer thread when it exits (`threadExit`).
pub fn thread_exit(&self) {
lock(&self.draw).api.thread_exit();
}
/// Called by the renderer thread when it starts the main loop
/// (`loopEnter`). `draw_now` forces an immediate draw on the renderer
/// thread; the display link calls it on every vsync.
pub fn loop_enter(self: &Arc<Self>, draw_now: Arc<dyn Fn() + Send + Sync>) {
// If our API has to do things on loop enter, let it. (Metal draws
// synchronously from the layer's display callback.)
let weak = Arc::downgrade(self);
lock(&self.draw).api.loop_enter(Arc::new(move || {
if let Some(renderer) = weak.upgrade()
&& let Err(err) = renderer.draw_frame(true)
{
log::warn!("Error drawing frame in display callback, err={err}");
}
}));
// If we don't support a display link we have no work to do.
let guard = lock(&self.display_link);
let Some(link) = guard.as_ref() else {
return;
};
// To setup the display link we set our callback and we can start it
// immediately.
if let Err(err) = link.set_output_callback(draw_now) {
log::warn!("error setting display link callback err={err}");
return;
}
let _ = link.start();
}
/// Called by the renderer thread when it exits the main loop
/// (`loopExit`).
pub fn loop_exit(&self) {
// If our API has to do things on loop exit, let it.
lock(&self.draw).api.loop_exit();
// Stop our display link. If this fails its okay it just means
// that we either never started it or the view its attached to
// is gone which is fine.
if let Some(link) = lock(&self.display_link).as_ref() {
let _ = link.stop();
}
}
/// Called when we get an updated display ID for our display link
/// (`setMacOSDisplayID`).
pub fn set_macos_display_id(&self, id: u32) {
let guard = lock(&self.display_link);
let Some(link) = guard.as_ref() else {
return;
};
log::info!("updating display link display id={id}");
if let Err(err) = link.set_current_cg_display(id) {
log::warn!("error setting display link display id err={err}");
}
}
/// The health of the most recently completed frame.
@@ -1454,13 +1612,31 @@ impl Renderer {
// Flag that we need to update our custom shaders
d.custom_shader_focused_changed = true;
// If we're not focused, then we want to stop the display link
// because it is a waste of resources and we can move to pure
// change-driven updates.
if let Some(link) = lock(&self.display_link).as_ref() {
let _ = if focus { link.start() } else { link.stop() };
}
}
/// Callback when the window is visible or occluded.
///
/// Must be called on the render thread. (There is no display link on
/// Linux, so there is nothing to do.)
pub fn set_visible(&self, _visible: bool) {}
/// Must be called on the render thread.
pub fn set_visible(&self, visible: bool) {
// If we're not visible, then we want to stop the display link
// because it is a waste of resources and we can move to pure
// change-driven updates.
let focused = lock(&self.draw).focused;
if let Some(link) = lock(&self.display_link).as_ref() {
let _ = if visible && focused {
link.start()
} else {
link.stop()
};
}
}
/// Set the new font grid.
///
@@ -1477,10 +1653,7 @@ impl Renderer {
d.font_grid = grid;
// Update all our textures so that they sync on the next frame.
for frame in d.swap_chain.frames_mut() {
frame.grayscale_modified = 0;
frame.color_modified = 0;
}
d.swap_chain.invalidate_atlases();
d.grid_metrics = metrics;
@@ -1561,7 +1734,8 @@ impl Renderer {
if blending_changed {
// We update our API's blending mode.
d.api.blending = d.config.blending;
let blending = d.config.blending;
d.api.set_blending(blending);
// And indicate that we need to reinitialize our shaders.
d.reinitialize_shaders = true;
// And indicate that our swap chain targets need to
@@ -1602,31 +1776,55 @@ impl Renderer {
t.search_matches_dirty = true;
}
/// The callback the graphics API calls when a frame is completed
/// (`renderer.frameCompleted`). Metal calls it from the command buffer's
/// completion handler, so it owns handles instead of borrowing `self`.
fn frame_completion(&self) -> FrameCompletion {
let health = Arc::clone(&self.health);
let host = Arc::clone(&self.host);
Arc::new(move |h| frame_completed(&health, host.as_ref(), h))
}
}
/// Record the health of a completed frame and notify the surface on change.
fn frame_completed(current: &AtomicU8, host: &dyn Host, health: Health) {
// If our health value hasn't changed, then we do nothing. We don't
// do a cmpxchg here because strict atomicity isn't important.
if current.load(Ordering::SeqCst) != health as u8 {
current.store(health as u8, Ordering::SeqCst);
// Our health value changed, so we notify the surface so that it
// can do something about it.
host.renderer_health(health);
}
}
impl Renderer<OpenGL> {
/// This is called by the GTK apprt after the surface is reinitialized
/// due to any of the events mentioned in the doc comment of
/// [`Renderer::display_unrealized`]. The GL context must be current.
pub fn display_realized<F>(&self, get_proc_address: F) -> Result<(), Error>
where
F: FnMut(&str) -> *const std::ffi::c_void,
{
self.display_realized_with(|api| api.display_realized(get_proc_address))
}
/// Read back the pixels (RGBA8, bottom-up like `glReadPixels`) of the
/// most recently presented frame. Intended for tests and screenshots;
/// requires the GL context to be current.
pub fn last_frame_pixels(&self) -> Option<(usize, usize, Vec<u8>)> {
let d = lock(&self.draw);
let target = Arc::clone(d.api.last_target()?);
let target = d.api.last_target()?;
let gl = d.api.gl()?.clone();
let bind = gl.bind_framebuffer(target.framebuffer(), gl::FramebufferTarget::Read);
let bind = gl.bind_framebuffer(
target.framebuffer(),
crate::opengl::gl::FramebufferTarget::Read,
);
let px = gl.read_pixels_rgba8(0, 0, target.width as i32, target.height as i32);
gl.unbind_framebuffer(bind);
Some((target.width, target.height, px))
}
// Callback from the graphics API when a frame is completed.
fn frame_completed(&self, health: Health) {
// If our health value hasn't changed, then we do nothing. We don't
// do a cmpxchg here because strict atomicity isn't important.
if self.health.load(Ordering::SeqCst) != health as u8 {
self.health.store(health as u8, Ordering::SeqCst);
// Our health value changed, so we notify the surface so that it
// can do something about it.
self.host.renderer_health(health);
}
}
}
// ---------------------------------------------------------------------------
@@ -1646,7 +1844,7 @@ struct Critical {
/// (~12 minutes at 120Hz).
const MAX_TERMINAL_STATE_FRAME_COUNT: usize = 100_000;
impl Renderer {
impl<G: GraphicsApi> Renderer<G> {
/// Update the frame data.
///
/// This is the renderer thread half of the renderer: it locks
@@ -1849,8 +2047,16 @@ impl Renderer {
(t.config.background_opacity * 255.0).round() as u8,
];
// (On macOS glass styles the background opacity is removed; that
// is out of scope on Linux.)
// If we're on macOS and have glass styles, we remove
// the background opacity because the glass effect handles
// it.
#[cfg(target_os = "macos")]
if matches!(
t.config.background_blur,
BackgroundBlur::MacosGlassRegular | BackgroundBlur::MacosGlassClear
) {
d.uniforms.bg_color[3] = 0;
}
// Prepare our overlay image for upload (or unload).
d.has_overlay = t.overlay.is_some();
@@ -1922,8 +2128,8 @@ impl Renderer {
/// Update the kitty image state from the terminal (the `kittyUpdate` call in
/// `updateFrame`).
fn update_kitty(
images: &mut image::State,
fn update_kitty<G: GraphicsApi>(
images: &mut image::State<G>,
terminal: &mut ghostty_terminal::terminal::Terminal,
cell_size: CellSize,
) {
@@ -1955,7 +2161,7 @@ enum Selected {
SearchSelected,
}
impl DrawState {
impl<G: GraphicsApi> DrawState<G> {
/// Convert the terminal state to GPU cells stored in CPU memory. These
/// are then synced to the GPU in the next frame. This only updates CPU
/// memory and doesn't touch the GPU.
@@ -2246,7 +2452,7 @@ impl DrawState {
}
}
impl DrawState {
impl<G: GraphicsApi> DrawState<G> {
#[allow(clippy::too_many_arguments)]
fn rebuild_row(
&mut self,
@@ -2968,7 +3174,7 @@ impl DrawState {
// ---------------------------------------------------------------------------
// draw_frame (GL thread)
impl Renderer {
impl<G: GraphicsApi> Renderer<G> {
/// Draw the frame to the screen.
///
/// If `sync` is true, this will synchronously block until
@@ -3000,7 +3206,7 @@ impl Renderer {
result
}
fn draw_frame_locked(&self, d: &mut DrawState, sync: bool) -> Result<(), Error> {
fn draw_frame_locked(&self, d: &mut DrawState<G>, sync: bool) -> Result<(), Error> {
d.api.draw_frame_start();
// Retrieve the most up-to-date surface size from the Graphics API
@@ -3030,30 +3236,45 @@ impl Renderer {
// Get a frame state to draw to.
let (idx, mut frame) = d.swap_chain.next_frame()?;
let result = d.draw_with_frame(
&mut frame,
sync,
(surface_width, surface_height),
size_changed,
);
d.swap_chain.release_frame(idx, frame);
let health = result?;
let result = d.draw_with_frame(&mut frame, (surface_width, surface_height), size_changed);
let frame_ctx = match result {
Ok(frame_ctx) => frame_ctx,
Err(err) => {
// Nothing was submitted: give the frame state back.
d.swap_chain.release_frame(idx, frame);
return Err(err);
}
};
self.has_animations
.store(d.has_custom_shaders, Ordering::Relaxed);
self.frame_completed(health);
// Complete the frame. The frame state is handed back to the swap
// chain, and the health reported, when the frame completes
// (`frameCompleted`): inline for synchronous frames, from the
// graphics API's completion thread otherwise.
let report = self.frame_completion();
let swap_chain = d.swap_chain.releaser();
let pending = Mutex::new(Some(frame));
let completion: FrameCompletion = Arc::new(move |health| {
report(health);
if let Some(frame) = lock(&pending).take() {
SwapChainInner::release(&swap_chain, idx, frame);
}
});
frame_ctx.complete(sync, completion);
Ok(())
}
}
impl DrawState {
impl<G: GraphicsApi> DrawState<G> {
/// Encode a frame into `frame`'s buffers and the swap chain's targets.
/// The returned graphics API frame has not been completed yet.
fn draw_with_frame(
&mut self,
frame: &mut FrameState,
sync: bool,
frame: &mut FrameState<G>,
surface_size: (u32, u32),
size_changed: bool,
) -> Result<Health, Error> {
) -> Result<G::Frame, Error> {
let (surface_width, surface_height) = surface_size;
// If we need to reinitialize our shaders, do so.
@@ -3073,7 +3294,7 @@ impl DrawState {
// if we have a state but don't need it we remove it.
if self.has_custom_shaders {
if frame.custom_shader_state.is_none() {
let mut state = CustomShaderState::new(&self.api)?;
let mut state = CustomShaderState::<G>::new(&self.api)?;
state.resize(&self.api, surface_width as usize, surface_height as usize)?;
frame.custom_shader_state = Some(state);
}
@@ -3094,8 +3315,8 @@ impl DrawState {
// If this frame's target isn't the correct size, or the target
// config has changed (such as when the blending mode changes),
// remove it and replace it with a new one with the right values.
if frame.target.width != self.size.screen.width as usize
|| frame.target.height != self.size.screen.height as usize
if frame.target.width() != self.size.screen.width as usize
|| frame.target.height() != self.size.screen.height as usize
|| frame.target_config_modified != self.target_config_modified
{
frame.resize(
@@ -3108,26 +3329,21 @@ impl DrawState {
self.api.clear_last_target();
}
let gl: Gl = self.api.gl().ok_or(opengl::Error::NoContext)?.clone();
let gpu = Gpu {
gl: &gl,
texture_options: self
.api
.image_texture_options(opengl::ImageTextureFormat::Rgba, true),
buffer_options: self.api.image_buffer_options(),
};
{
let gpu = Gpu { api: &self.api };
// Upload images to the GPU as necessary.
let _ = self.images.upload(&gpu);
// Upload images to the GPU as necessary.
let _ = self.images.upload(&gpu);
// Upload the background image to the GPU as necessary.
self.upload_background_image(&gpu);
// Upload the background image to the GPU as necessary.
upload_background_image(&mut self.bg_image, &gpu);
}
// Update per-frame custom shader uniforms.
self.update_custom_shader_uniforms_for_frame();
// Setup our frame data
frame.uniforms.sync(&[self.uniforms])?;
frame.uniforms.sync(&[G::gpu_uniforms(&self.uniforms)])?;
frame.cells_bg.sync(&self.cells.bg_cells)?;
let fg_lists: Vec<&[CellText]> = self.cells.fg_rows.iter().map(Vec::as_slice).collect();
let fg_count = frame.cells.sync_from_slices(&fg_lists)?;
@@ -3174,17 +3390,18 @@ impl DrawState {
bg_image,
has_overlay,
..
} = self;
} = &*self;
let Some(shaders) = shaders.as_ref() else {
return Err(Error::Defunct);
};
let gpu = Gpu { api };
let frame_ctx = api.begin_frame(Arc::clone(&frame.target));
{
let attachments = [Attachment {
target: match &frame.custom_shader_state {
Some(state) => AttachmentTarget::Texture(&state.back_texture),
None => AttachmentTarget::Target(&frame.target),
None => AttachmentTarget::Target(&*frame.target),
},
clear_color: Some([0.0, 0.0, 0.0, 0.0]),
}];
@@ -3204,7 +3421,7 @@ impl DrawState {
Some(img) => {
if let image::Image::Ready(texture) = img {
pass.step(&Step {
pipeline: &shaders.bg_image,
pipeline: shaders.bg_image(),
uniforms: Some(frame.uniforms.raw()),
buffers: &[Some(frame.bg_image_buffer.raw())],
textures: &[Some(texture)],
@@ -3215,7 +3432,7 @@ impl DrawState {
}
None => {
pass.step(&Step {
pipeline: &shaders.bg_color,
pipeline: shaders.bg_color(),
uniforms: Some(frame.uniforms.raw()),
buffers: &[None, Some(frame.cells_bg.raw())],
textures: &[],
@@ -3229,14 +3446,14 @@ impl DrawState {
// to be behind text AND cell backgrounds.
images.draw(
&gpu,
&shaders.image,
shaders.image(),
&mut pass,
image::DrawPlacements::KittyBelowBg,
);
// Then we draw any opaque cell backgrounds.
pass.step(&Step {
pipeline: &shaders.cell_bg,
pipeline: shaders.cell_bg(),
uniforms: Some(frame.uniforms.raw()),
buffers: &[None, Some(frame.cells_bg.raw())],
textures: &[],
@@ -3247,14 +3464,14 @@ impl DrawState {
// Kitty images between cell backgrounds and text.
images.draw(
&gpu,
&shaders.image,
shaders.image(),
&mut pass,
image::DrawPlacements::KittyBelowText,
);
// Text.
pass.step(&Step {
pipeline: &shaders.cell_text,
pipeline: shaders.cell_text(),
uniforms: Some(frame.uniforms.raw()),
buffers: &[Some(frame.cells.raw()), Some(frame.cells_bg.raw())],
textures: &[Some(&frame.grayscale), Some(&frame.color)],
@@ -3269,7 +3486,7 @@ impl DrawState {
// Kitty images in front of text.
images.draw(
&gpu,
&shaders.image,
shaders.image(),
&mut pass,
image::DrawPlacements::KittyAboveText,
);
@@ -3279,7 +3496,7 @@ impl DrawState {
if *has_overlay {
images.draw(
&gpu,
&shaders.image,
shaders.image(),
&mut pass,
image::DrawPlacements::Overlay,
);
@@ -3290,14 +3507,14 @@ impl DrawState {
// If we have custom shaders, then we render them.
if let Some(state) = &mut frame.custom_shader_state {
let post_count = shaders.post_pipelines.len();
for (i, pipeline) in shaders.post_pipelines.iter().enumerate() {
let post_count = shaders.post_pipelines().len();
for (i, pipeline) in shaders.post_pipelines().iter().enumerate() {
{
let attachments = [Attachment {
target: if i < post_count - 1 {
AttachmentTarget::Texture(&state.front_texture)
} else {
AttachmentTarget::Target(&frame.target)
AttachmentTarget::Target(&*frame.target)
},
clear_color: Some([0.0, 0.0, 0.0, 0.0]),
}];
@@ -3317,8 +3534,8 @@ impl DrawState {
}
}
// Complete the frame: glFinish, error check, present.
Ok(frame_ctx.complete(sync))
// The caller completes the frame (glFinish, error check, present).
Ok(frame_ctx)
}
}
@@ -3433,13 +3650,8 @@ mod tests {
std::fs::write(&path, &bytes).unwrap();
let img = load_background_image(path.to_str().unwrap()).expect("decoded");
match img {
image::Image::Pending(p) => {
assert_eq!((p.width, p.height), (2, 1));
assert_eq!(p.data, [1, 2, 3, 255, 4, 5, 6, 128]);
}
_ => panic!("expected a pending image"),
}
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(img.data, [1, 2, 3, 255, 4, 5, 6, 128]);
// Missing / undecodable files are not an error, just no image.
assert!(load_background_image(dir.join("missing.png").to_str().unwrap()).is_none());