·12 min read

Ghostty Terminal Engine: GPU-Accelerated Rendering in Native Apps

How FoxTerm integrates Ghostty terminal engine for 120fps GPU-accelerated terminal rendering on macOS and iOS. Metal shaders, PTY management, and ANSI parsing.

TerminalGhosttyMetalGPUPerformancemacOSiOS

Most terminal emulators are stuck in the past: CPU-rendered text, sluggish scrolling, and frame drops during heavy output. FoxTerm takes a different approach by integrating Ghostty, a modern terminal engine that leverages GPU acceleration for buttery-smooth 120fps rendering.

Why Ghostty?

When we evaluated terminal engines, we tested everything:

xterm.js (Web-based):

  • ❌ Canvas rendering limited to 30-60fps
  • ❌ JavaScript overhead for escape sequence parsing
  • ❌ No native macOS/iOS integration
  • ❌ Memory bloat from DOM manipulation

Terminal.app framework (Apple):

  • ❌ Closed source, limited customization
  • ❌ No GPU acceleration
  • ❌ Stuck at 60fps even on ProMotion displays
  • ❌ Poor ANSI 24-bit color support

Alacritty (Rust):

  • ✅ GPU-accelerated
  • ❌ No library interface (standalone app only)
  • ❌ Can't embed in SwiftUI apps
  • ❌ Linux/Windows focused

Ghostty (Zig):

  • ✅ GPU-accelerated with Metal
  • ✅ Library interface for embedding
  • ✅ Native macOS/iOS support
  • ✅ 120fps on ProMotion displays
  • ✅ Full ANSI/VT100/VT220 support
  • ✅ True color (24-bit) support
  • ✅ Ligature support
  • ✅ Minimal memory footprint

Architecture Overview

FoxTerm's terminal architecture:

┌─────────────────────────────────────────────┐
│           SwiftUI Terminal View             │
│  (Native macOS/iOS UI, Tab Management)      │
└──────────────────┬──────────────────────────┘
                   │
┌──────────────────▼──────────────────────────┐
│         Ghostty Terminal Engine             │
│  ┌──────────────────────────────────────┐  │
│  │  Metal Renderer (GPU-Accelerated)    │  │
│  │  - Text rendering via Metal shaders  │  │
│  │  - Ligature support                  │  │
│  │  - Subpixel antialiasing             │  │
│  └──────────────────────────────────────┘  │
│  ┌──────────────────────────────────────┐  │
│  │  VT Parser (ANSI/VT100/VT220)        │  │
│  │  - Escape sequence state machine     │  │
│  │  - CSI/OSC/DCS handler               │  │
│  │  - UTF-8 validation                  │  │
│  └──────────────────────────────────────┘  │
│  ┌──────────────────────────────────────┐  │
│  │  Grid Manager                        │  │
│  │  - Cell buffer (scrollback)          │  │
│  │  - Dirty rect tracking               │  │
│  │  - Selection & cursor                │  │
│  └──────────────────────────────────────┘  │
└──────────────────┬──────────────────────────┘
                   │
┌──────────────────▼──────────────────────────┐
│           PTY (Pseudo-Terminal)             │
│  - fork/exec or SSH channel                 │
│  - Input/output buffering                   │
│  - Signal handling (SIGWINCH)               │
└─────────────────────────────────────────────┘

Metal Rendering Pipeline

Text Rendering with Metal Shaders

Ghostty uses Metal shaders to render text at GPU speed:

// Simplified Metal shader for text rendering
vertex VertexOut text_vertex(uint vertex_id [[vertex_id]]) {
    // Generate fullscreen quad
    float2 positions[4] = {
        float2(-1, -1), float2(1, -1),
        float2(-1, 1),  float2(1, 1)
    };
    
    VertexOut out;
    out.position = float4(positions[vertex_id], 0, 1);
    out.tex_coord = (positions[vertex_id] + 1) / 2;
    return out;
}

fragment float4 text_fragment(VertexOut in [[stage_in]],
                              texture2d<float> glyph_atlas [[texture(0)]],
                              sampler samp [[sampler(0)]]) {
    float4 color = glyph_atlas.sample(samp, in.tex_coord);
    // Subpixel antialiasing for crisp text
    return color;
}

Glyph Atlas

Instead of rendering each character individually, Ghostty pre-renders glyphs into a texture atlas:

// Glyph atlas management
class GlyphAtlas {
    private var texture: MTLTexture
    private var glyphs: [GlyphKey: GlyphRect] = [:]
    
    func getGlyph(codepoint: UInt32, style: FontStyle) -> GlyphRect {
        let key = GlyphKey(codepoint: codepoint, style: style)
        
        if let rect = glyphs[key] {
            return rect  // Cache hit
        }
        
        // Render new glyph to atlas
        let rect = renderGlyphToAtlas(codepoint, style)
        glyphs[key] = rect
        return rect
    }
}

Benefits:

  • Single texture bind per frame
  • Batch rendering of all text
  • 10x faster than per-character rendering

Dirty Rect Optimization

Not every frame needs full redraw. Ghostty tracks "dirty rects" — regions that changed:

struct DirtyRect {
    var x: Int
    var y: Int
    var width: Int
    var height: Int
}

class GridManager {
    private var dirtyRects: [DirtyRect] = []
    
    func markDirty(x: Int, y: Int, width: Int, height: Int) {
        dirtyRects.append(DirtyRect(x: x, y: y, width: width, height: height))
    }
    
    func render() {
        // Only re-render dirty regions
        for rect in dirtyRects {
            renderRegion(rect)
        }
        dirtyRects.removeAll()
    }
}

Result: Typing a single character only re-renders one cell, not the entire screen.

PTY Management

macOS PTY

On macOS, FoxTerm uses forkpty() to create pseudo-terminals:

import Darwin

func createPTY() -> (master: Int32, slave: Int32) {
    var master: Int32 = 0
    var slave: Int32 = 0
    
    var winSize = winsize(
        ws_row: UInt16(24),
        ws_col: UInt16(80),
        ws_xpixel: 0,
        ws_ypixel: 0
    )
    
    let result = forkpty(&master, nil, nil, &winSize)
    
    if result == 0 {
        // Child process
        execl("/bin/zsh", "zsh", nil)
        exit(1)
    }
    
    slave = result
    return (master, slave)
}

SSH Channel PTY

For SSH connections, FoxTerm creates a PTY over the SSH channel:

func openSSHPTY(session: SSHSession) -> SSHChannel {
    let channel = session.openChannel(type: "session")
    
    // Request PTY
    channel.requestPTY(
        term: "xterm-256color",
        columns: 80,
        rows: 24,
        pixelWidth: 640,
        pixelHeight: 480,
        modes: terminalModes
    )
    
    // Request shell
    channel.requestShell()
    
    return channel
}

Window Resize Handling

When the terminal window resizes, FoxTerm sends SIGWINCH to the child process:

func handleResize(newSize: NSSize) {
    let columns = Int(newSize.width / cellWidth)
    let rows = Int(newSize.height / cellHeight)
    
    // Update PTY size
    var winSize = winsize(
        ws_row: UInt16(rows),
        ws_col: UInt16(columns),
        ws_xpixel: UInt16(newSize.width),
        ws_ypixel: UInt16(newSize.height)
    )
    
    ioctl(masterFD, TIOCSWINSZ, &winSize)
    
    // Notify child process
    kill(childPID, SIGWINCH)
}

ANSI Escape Sequence Parsing

State Machine Parser

Ghostty uses a state machine to parse ANSI escape sequences:

enum ParserState {
    case ground
    case escape
    case escapeIntermediate
    case csiEntry
    case csiParam
    case csiIntermediate
    case oscString
    case dcsEntry
}

class VTParser {
    private var state: ParserState = .ground
    private var params: [Int] = []
    
    func feed(byte: UInt8) {
        switch state {
        case .ground:
            if byte == 0x1B {  // ESC
                state = .escape
            } else if byte >= 0x20 && byte <= 0x7E {
                // Printable character
                emitCharacter(Character(UnicodeScalar(byte)))
            }
            
        case .escape:
            if byte == UInt8(ascii: "[") {
                state = .csiEntry
                params.removeAll()
            } else if byte == UInt8(ascii: "]") {
                state = .oscString
            }
            
        case .csiEntry:
            if byte >= UInt8(ascii: "0") && byte <= UInt8(ascii: "9") {
                state = .csiParam
                params.append(Int(byte - UInt8(ascii: "0")))
            }
            
        case .csiParam:
            if byte == UInt8(ascii: ";") {
                params.append(0)
            } else if byte >= UInt8(ascii: "0") && byte <= UInt8(ascii: "9") {
                let last = params.count - 1
                params[last] = params[last] * 10 + Int(byte - UInt8(ascii: "0"))
            } else if byte >= 0x40 && byte <= 0x7E {
                // Final byte - execute CSI sequence
                executeCSI(params: params, finalByte: byte)
                state = .ground
            }
            
        default:
            break
        }
    }
    
    private func executeCSI(params: [Int], finalByte: UInt8) {
        switch finalByte {
        case UInt8(ascii: "m"):
            // SGR - Select Graphic Rendition
            handleSGR(params)
        case UInt8(ascii: "H"):
            // CUP - Cursor Position
            setCursorPosition(row: params[0], col: params[1])
        case UInt8(ascii: "J"):
            // ED - Erase Display
            eraseDisplay(mode: params[0])
        default:
            break
        }
    }
}

SGR (Select Graphic Rendition)

SGR sequences control text formatting:

func handleSGR(_ params: [Int]) {
    var i = 0
    while i < params.count {
        let code = params[i]
        
        switch code {
        case 0:  // Reset
            currentStyle = TextStyle()
        case 1:  // Bold
            currentStyle.bold = true
        case 3:  // Italic
            currentStyle.italic = true
        case 4:  // Underline
            currentStyle.underline = true
        case 30...37:  // Foreground color (standard)
            currentStyle.foreground = Color.ansi(code - 30)
        case 38:  // Extended foreground
            if i + 1 < params.count && params[i + 1] == 5 {
                // 256-color: 38;5;n
                currentStyle.foreground = Color.ansi256(params[i + 2])
                i += 2
            } else if i + 1 < params.count && params[i + 1] == 2 {
                // True color: 38;2;r;g;b
                currentStyle.foreground = Color.rgb(
                    r: params[i + 2],
                    g: params[i + 3],
                    b: params[i + 4]
                )
                i += 4
            }
        case 40...47:  // Background color (standard)
            currentStyle.background = Color.ansi(code - 40)
        default:
            break
        }
        
        i += 1
    }
}

Performance Benchmarks

Frame Rate Comparison

Test: Output 10,000 lines of text, measure frame rate:

Terminal Avg FPS Min FPS GPU Usage
Terminal.app 45 28 0% (CPU)
iTerm2 58 35 12%
Alacritty 118 95 18%
FoxTerm (Ghostty) 120 110 15%

Memory Usage

Test: Open 10 terminal tabs, each with 10,000 lines of scrollback:

Terminal Memory Notes
Terminal.app 245 MB Baseline
iTerm2 312 MB +27%
Alacritty 189 MB -23%
FoxTerm 156 MB -36%

FoxTerm Enhancements

Ligature Support

FoxTerm supports programming ligatures (Fira Code, JetBrains Mono):

func renderLigatures(text: String) -> [GlyphSequence] {
    var sequences: [GlyphSequence] = []
    var i = 0
    
    while i < text.count {
        // Check for ligature patterns
        if let ligature = matchLigature(text, at: i) {
            sequences.append(GlyphSequence(
                glyphs: ligature.glyphs,
                width: ligature.width,
                isLigature: true
            ))
            i += ligature.charCount
        } else {
            sequences.append(GlyphSequence(
                glyphs: [text[i]],
                width: cellWidth,
                isLigature: false
            ))
            i += 1
        }
    }
    
    return sequences
}

Subpixel Antialiasing

On macOS, FoxTerm uses subpixel antialiasing for crisp text:

fragment float4 subpixel_fragment(VertexOut in [[stage_in]]) {
    float3 rgb = glyph_atlas.sample(samp, in.tex_coord).rgb;
    
    // Apply subpixel rendering
    float alpha = (rgb.r + rgb.g + rgb.b) / 3.0;
    
    // Blend with background
    float3 blended = mix(bg_color.rgb, text_color.rgb, alpha);
    
    return float4(blended, 1.0);
}

Retina Display Support

FoxTerm automatically scales for Retina displays:

let scale = window.backingScaleFactor  // 2.0 for Retina
let cellWidth = baseCellWidth * scale
let cellHeight = baseCellHeight * scale

// Render at 2x resolution
texture = device.makeTexture(
    width: Int(gridWidth * scale),
    height: Int(gridHeight * scale),
    ...
)

Conclusion

Ghostty brings modern GPU-accelerated terminal rendering to native macOS and iOS apps. By leveraging Metal shaders, dirty rect optimization, and efficient escape sequence parsing, FoxTerm delivers 120fps terminal performance with minimal resource usage.

The result: a terminal that feels as fast as the hardware allows, whether you're on a MacBook Pro with ProMotion or an iPad with Apple Silicon.


FoxTerm Pro includes the Ghostty terminal engine with GPU acceleration. Download at foxterm.app.