Build a Terminal Emulator with libghostty: Ghostling Architecture

2026-03-23 · 11 min read · gen:2m 35s · tok:19648
#libghostty #terminal-emulator #backend #c-programming #beginner-tutorial #english

Learn to build custom terminal emulators using libghostty’s C API. Explore PTY management, escape sequences, and terminal state machines with Ghostling.

Building Your Own Terminal Emulator with libghostty: A Deep Dive into Ghostling’s Architecture

Every time you open a terminal, a sophisticated dance occurs between your shell, a pseudo-terminal device, and a rendering engine that interprets thousands of escape sequences. Most developers take this complexity for granted. But understanding how terminal emulators work opens doors to building custom developer tools, embedded terminal widgets, and specialized CLI applications that go far beyond what off-the-shelf solutions offer.

Ghostling, a minimal terminal emulator built on libghostty, provides a unique learning opportunity. Unlike bloated implementations with decades of legacy code, Ghostling strips terminal emulation to its essential components while leveraging libghostty’s production-grade C API. This article dissects its architecture, showing you how to build terminal emulation capabilities into your own projects.

By the end, you’ll understand PTY lifecycle management, terminal state machines, escape sequence parsing, and how modern terminal emulators bridge the gap between shell processes and pixel rendering.

Prerequisites

Before diving in, ensure you have:

  • C/C++ fundamentals: Pointers, memory management, structs, and basic build systems (Make/CMake)
  • Linux/POSIX basics: File descriptors, process forking, signals
  • Development environment: GCC/Clang, libghostty headers and libraries installed
  • Optional: Familiarity with Zig (libghostty is written in Zig but exposes a C API)

Install libghostty on Ubuntu/Debian:

1
2
3
4
5
6
7
8
9
# Clone and build libghostty
git clone https://github.com/ghostty-org/ghostty.git
cd ghostty
zig build -Doptimize=ReleaseFast

# Install headers and library
sudo cp zig-out/lib/libghostty.* /usr/local/lib/
sudo cp -r include/ghostty /usr/local/include/
sudo ldconfig

⚠️ Note: The installation commands above are illustrative. Check the official Ghostty documentation for current build instructions, as the project structure and build process may change.

Architecture and Key Concepts

Terminal emulation involves three distinct layers working in concert. Understanding their boundaries is critical before writing any code.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        User Space                                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                           β”‚
β”‚  β”‚  Shell Process   │◄────────stdin─────────┐                   β”‚
β”‚  β”‚  (bash/zsh/fish) β”‚                       β”‚                   β”‚
β”‚  β”‚                  │─────stdout/stderr────►│                   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                       β”‚                   β”‚
β”‚           β”‚                                 β”‚                   β”‚
β”‚           β–Ό                                 β”‚                   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”‚                   β”‚
β”‚  β”‚    PTY Slave     β”‚β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚ TTY Driver
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           β–Ό              Kernel Space                            β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                           β”‚
β”‚  β”‚    PTY Master    β”‚                                           β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           β–Ό         Terminal Emulator (Ghostling)                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚   Input Parser   │───►│  Terminal State │───►│   Screen   β”‚  β”‚
β”‚  β”‚   (libghostty)   β”‚    β”‚     Machine     β”‚    β”‚   Buffer   β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                       β”‚         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                 β–Ό         β”‚
β”‚  β”‚ Keyboard/Mouse   β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚     Input        │───►│  Input Encoder  β”‚    β”‚  Renderer  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                   β”‚                   β”‚         β”‚
β”‚                                   β–Ό                   β–Ό         β”‚
β”‚                          To PTY Master            Display       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The PTY Layer

A pseudo-terminal (PTY) is a kernel-provided abstraction that emulates a hardware terminal. It consists of two endpoints:

  • Master: Controlled by the terminal emulator; receives output from and sends input to the slave
  • Slave: Appears as a regular terminal device (/dev/pts/N) to the shell process

When your shell writes ls output, it goes to the slave. The kernel routes it to the master, where your emulator reads and renders it.

The Parser Layer

Raw bytes from the PTY master contain a mix of printable text and control sequences. libghostty’s parser transforms this byte stream into structured events:

  • Print events: Regular characters to display
  • CSI sequences: Cursor movement, colors, scrolling (\x1b[1;31m for red text)
  • OSC sequences: Window titles, clipboard operations
  • DCS sequences: Device control, sixel graphics

The State Machine

Terminal state encompasses cursor position, active colors, scroll region, character attributes, and screen contents. libghostty maintains this state through a well-defined API, letting you query cells, handle reflows on resize, and manage alternate screen buffers.

Step-by-Step Implementation

Setting Up the PTY Infrastructure

Let’s start with the foundation: creating and managing the PTY pair. This code handles the fork/exec sequence that spawns a shell connected to our emulator.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// pty_manager.c - PTY lifecycle management for Ghostling

#define _XOPEN_SOURCE 600
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <termios.h>
#include <signal.h>
#include <errno.h>
#include <string.h>
#include <stdio.h>

typedef struct {
    int master_fd;                // File descriptor for PTY master
    pid_t child_pid;              // Shell process ID
    struct termios orig_termios;  // Original terminal settings for restoration
    int rows;
    int cols;
} PtyContext;

// Initialize PTY and spawn shell process
int pty_init(PtyContext *ctx, int rows, int cols, const char *shell) {
    ctx->rows = rows;
    ctx->cols = cols;
    ctx->master_fd = -1;
    ctx->child_pid = 0;
    
    // Open PTY master using POSIX interface
    ctx->master_fd = posix_openpt(O_RDWR | O_NOCTTY);
    if (ctx->master_fd < 0) {
        perror("posix_openpt failed");
        return -1;
    }
    
    // Grant access and unlock slave
    if (grantpt(ctx->master_fd) < 0 || unlockpt(ctx->master_fd) < 0) {
        perror("PTY setup failed");
        close(ctx->master_fd);
        ctx->master_fd = -1;
        return -1;
    }
    
    // Get slave device name
    char *slave_name = ptsname(ctx->master_fd);
    if (!slave_name) {
        perror("ptsname failed");
        close(ctx->master_fd);
        ctx->master_fd = -1;
        return -1;
    }
    
    // Fork child process for shell
    ctx->child_pid = fork();
    if (ctx->child_pid < 0) {
        perror("fork failed");
        close(ctx->master_fd);
        ctx->master_fd = -1;
        return -1;
    }
    
    if (ctx->child_pid == 0) {
        // Child process: set up slave PTY and exec shell
        close(ctx->master_fd);  // Child doesn't need master
        
        // Create new session, making child the session leader
        if (setsid() < 0) {
            _exit(1);
        }
        
        // Open slave PTY - this becomes our controlling terminal
        int slave_fd = open(slave_name, O_RDWR);
        if (slave_fd < 0) {
            _exit(1);
        }
        
        // Set window size before shell reads it
        struct winsize ws = {
            .ws_row = rows,
            .ws_col = cols,
            .ws_xpixel = 0,
            .ws_ypixel = 0
        };
        ioctl(slave_fd, TIOCSWINSZ, &ws);
        
        // Redirect standard streams to slave PTY
        dup2(slave_fd, STDIN_FILENO);
        dup2(slave_fd, STDOUT_FILENO);
        dup2(slave_fd, STDERR_FILENO);
        
        if (slave_fd > STDERR_FILENO) {
            close(slave_fd);
        }
        
        // Set up clean environment
        setenv("TERM", "xterm-256color", 1);
        setenv("COLORTERM", "truecolor", 1);
        
        // Execute shell
        const char *shell_path = shell ? shell : getenv("SHELL");
        if (!shell_path) shell_path = "/bin/sh";
        
        execlp(shell_path, shell_path, "-l", (char *)NULL);
        _exit(127);  // exec failed
    }
    
    // Parent: set master to non-blocking for async I/O
    int flags = fcntl(ctx->master_fd, F_GETFL);
    fcntl(ctx->master_fd, F_SETFL, flags | O_NONBLOCK);
    
    return 0;
}

// Handle terminal resize - notify shell via SIGWINCH
int pty_resize(PtyContext *ctx, int rows, int cols) {
    ctx->rows = rows;
    ctx->cols = cols;
    
    struct winsize ws = {
        .ws_row = rows,
        .ws_col = cols,
        .ws_xpixel = 0,
        .ws_ypixel = 0
    };
    
    // This triggers SIGWINCH in the shell
    if (ioctl(ctx->master_fd, TIOCSWINSZ, &ws) < 0) {
        perror("TIOCSWINSZ failed");
        return -1;
    }
    
    return 0;
}

// Clean shutdown
void pty_cleanup(PtyContext *ctx) {
    if (ctx->master_fd >= 0) {
        close(ctx->master_fd);
        ctx->master_fd = -1;
    }
    
    if (ctx->child_pid > 0) {
        // Send SIGHUP to shell (standard "hangup" signal)
        kill(ctx->child_pid, SIGHUP);
        ctx->child_pid = 0;
    }
}

πŸ’‘ Tip: Always set TERM=xterm-256color in the child environment. Many terminal features depend on correct terminfo detection, and xterm-256color provides broad compatibility.

⚠️ Warning: The posix_openpt approach shown here is the modern POSIX standard. Avoid the legacy BSD openpty() callβ€”it has portability issues and doesn’t work consistently across all systems.

Integrating libghostty’s Parser and State Machine

Now we connect libghostty to process the byte stream from the PTY master. This is where escape sequences become structured data.

⚠️ Important: The following code demonstrates the conceptual API structure. The actual libghostty API may differβ€”consult the official documentation and header files for the correct function signatures and types.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
// terminal_core.c - libghostty integration for terminal state management

#include <ghostty/ghostty.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <unistd.h>

// Forward declaration from pty_manager.c
typedef struct PtyContext PtyContext;

typedef struct {
    ghostty_surface_t surface;      // libghostty surface handle
    ghostty_config_t config;        // Terminal configuration
    PtyContext *pty;                // Our PTY context from above
    
    // Callbacks for rendering layer
    void (*on_damage)(int row, int col, int width, int height, void *userdata);
    void (*on_title_change)(const char *title, void *userdata);
    void *userdata;
    
    // Buffer for batching PTY reads
    uint8_t read_buffer[65536];
} TerminalCore;

// Callback: libghostty notifies us of screen damage
static void damage_callback(
    ghostty_surface_t surface,
    uint32_t x, uint32_t y,
    uint32_t width, uint32_t height,
    void *userdata
) {
    TerminalCore *core = (TerminalCore *)userdata;
    if (core->on_damage) {
        core->on_damage((int)y, (int)x, (int)width, (int)height, core->userdata);
    }
}

// Callback: window title changed via OSC sequence
static void title_callback(
    ghostty_surface_t surface,
    const char *title,
    void *userdata
) {
    TerminalCore *core = (TerminalCore *)userdata;
    if (core->on_title_change) {
        core->on_title_change(title, core->userdata);
    }
}

// Initialize terminal core with libghostty
int terminal_core_init(TerminalCore *core, PtyContext *pty, int rows, int cols) {
    memset(core, 0, sizeof(*core));
    core->pty = pty;
    
    // Create configuration
    ghostty_config_t config = ghostty_config_new();
    if (!config) {
        fprintf(stderr, "Failed to create ghostty config\n");
        return -1;
    }
    
    // Configure terminal parameters
    ghostty_config_set_int(config, "scrollback-lines", 10000);
    ghostty_config_set_bool(config, "mouse-tracking", true);
    ghostty_config_set_string(config, "term", "xterm-256color");
    
    core->config = config;
    
    // Create surface (the actual terminal state machine)
    ghostty_surface_options_t opts = {
        .rows = rows,
        .cols = cols,
        .config = config,
        .userdata = core,
        .damage_callback = damage_callback,
        .title_callback = title_callback,
    };
    
    core->surface = ghostty_surface_new(&opts);
    if (!core->surface) {
        fprintf(stderr, "Failed to create ghostty surface\n");
        ghostty_config_free(config);
        core->config = NULL;
        return -1;
    }
    
    return 0;
}

// Process incoming data from PTY - this is the main parsing entry point
int terminal_core_process_pty_data(TerminalCore *core) {
    ssize_t bytes_read = read(
        core->pty->master_fd,
        core->read_buffer,
        sizeof(core->read_buffer)
    );
    
    if (bytes_read < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            return 0;  // No data available (non-blocking)
        }
        return -1;  // Real error
    }
    
    if (bytes_read == 0) {
        return -2;  // EOF - shell exited
    }
    
    // Feed data to libghostty parser
    // This triggers the state machine and may invoke damage_callback
    ghostty_surface_feed(core->surface, core->read_buffer, (size_t)bytes_read);
    
    return (int)bytes_read;
}

// Send keyboard input to the PTY (and thus to the shell)
int terminal_core_send_input(TerminalCore *core, const char *data, size_t len) {
    ssize_t written = write(core->pty->master_fd, data, len);
    if (written < 0) {
        perror("write to PTY failed");
        return -1;
    }
    
    return (int)written;
}

// Handle keyboard events with proper encoding
int terminal_core_key_event(
    TerminalCore *core,
    uint32_t keycode,
    uint32_t modifiers,
    bool pressed
) {
    if (!pressed) return 0;  // We typically only care about key presses
    
    // Let libghostty encode the key sequence
    char encoded[32];
    size_t len = ghostty_surface_encode_key(
        core->surface,
        keycode,
        modifiers,
        encoded,
        sizeof(encoded)
    );
    
    if (len > 0) {
        return terminal_core_send_input(core, encoded, len);
    }
    
    return 0;
}

// Query a specific cell for rendering
ghostty_cell_t terminal_core_get_cell(TerminalCore *core, int row, int col) {
    return ghostty_surface_get_cell(core->surface, row, col);
}

// Handle resize
void terminal_core_resize(TerminalCore *core, int rows, int cols) {
    ghostty_surface_resize(core->surface, rows, cols);
    pty_resize(core->pty, rows, cols);
}

// Cleanup
void terminal_core_cleanup(TerminalCore *core) {
    if (core->surface) {
        ghostty_surface_free(core->surface);
        core->surface = NULL;
    }
    if (core->config) {
        ghostty_config_free(core->config);
        core->config = NULL;
    }
}

πŸ“ Note: The ghostty_surface_feed() function is the heart of terminal emulation. It parses the byte stream, updates internal state, and triggers callbacks for any visible changes. This single call handles hundreds of escape sequence types.

Building the Rendering Pipeline

The final piece connects terminal state to pixels. This example uses a simple framebuffer approach, but the pattern adapts to any graphics API.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
// renderer.c - Convert terminal state to visual output

#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>

// Forward declarations (these types come from libghostty)
typedef struct ghostty_cell ghostty_cell_t;
typedef struct ghostty_color ghostty_color_t;
typedef struct ghostty_cursor ghostty_cursor_t;

// Color type enumeration (simplified representation)
typedef enum {
    GHOSTTY_COLOR_DEFAULT_FG,
    GHOSTTY_COLOR_DEFAULT_BG,
    GHOSTTY_COLOR_INDEXED,
    GHOSTTY_COLOR_RGB
} ghostty_color_type_t;

// Cell flag for inverse video
#define GHOSTTY_CELL_INVERSE 0x01

typedef struct {
    TerminalCore *core;
    
    // Framebuffer
    uint32_t *pixels;
    int pixel_width;
    int pixel_height;
    
    // Font metrics
    int cell_width;
    int cell_height;
    int rows;
    int cols;
    
    // Dirty tracking for partial updates
    bool *dirty_rows;
    
    // Color palette (256 colors + default fg/bg)
    uint32_t palette[258];
} Renderer;

// Convert libghostty color to RGBA
static uint32_t color_to_rgba(ghostty_color_t color, uint32_t *palette) {
    switch (color.type) {
        case GHOSTTY_COLOR_DEFAULT_FG:
            return palette[256];
        case GHOSTTY_COLOR_DEFAULT_BG:
            return palette[257];
        case GHOSTTY_COLOR_INDEXED:
            return palette[color.index];
        case GHOSTTY_COLOR_RGB:
            return ((uint32_t)color.r << 24) | 
                   ((uint32_t)color.g << 16) | 
                   ((uint32_t)color.b << 8) | 0xFF;
        default:
            return 0xFFFFFFFF;
    }
}

// Initialize default xterm-256color palette
static void init_palette(uint32_t *palette) {
    // Standard 16 colors
    static const uint32_t base16[] = {
        0x000000FF, 0xCD0000FF, 0x00CD00FF, 0xCDCD00FF,
        0x0000EEFF, 0xCD00CDFF, 0x00CDCDFF, 0xE5E5E5FF,
        0x7F7F7FFF, 0xFF0000FF, 0x00FF00FF, 0xFFFF00FF,
        0x5C5CFFFF, 0xFF00FFFF, 0x00FFFFFF, 0xFFFFFFFF,
    };
    memcpy(palette, base16, sizeof(base16));
    
    // 216 color cube (6x6x6)
    for (int r = 0; r < 6; r++) {
        for (int g = 0; g < 6; g++) {
            for (int b = 0; b < 6; b++) {
                int idx = 16 + r * 36 + g * 6 + b;
                uint8_t rv = r ? (uint8_t)(r * 40 + 55) : 0;
                uint8_t gv = g ? (uint8_t)(g * 40 + 55) : 0;
                uint8_t bv = b ? (uint8_t)(b * 40 + 55) : 0;
                palette[idx] = ((uint32_t)rv << 24) | 
                               ((uint32_t)gv << 16) | 
                               ((uint32_t)bv << 8) | 0xFF;
            }
        }
    }
    
    // 24 grayscale
    for (int i = 0; i < 24; i++) {
        uint8_t v = (uint8_t)(i * 10 + 8);
        palette[232 + i] = ((uint32_t)v << 24) | 
                           ((uint32_t)v << 16) | 
                           ((uint32_t)v << 8) | 0xFF;
    }
    
    // Default foreground (white) and background (black)
    palette[256] = 0xFFFFFFFF;
    palette[257] = 0x000000FF;
}

int renderer_init(Renderer *r, TerminalCore *core, int rows, int cols) {
    memset(r, 0, sizeof(*r));
    r->core = core;
    r->rows = rows;
    r->cols = cols;
    r->cell_width = 8;    // placeholder; query real font metrics in production
    r->cell_height = 16;  // placeholder; ascent + descent + line gap

    r->fb_width  = cols * r->cell_width;
    r->fb_height = rows * r->cell_height;
    r->framebuffer = calloc((size_t)r->fb_width * r->fb_height, sizeof(uint32_t));
    if (!r->framebuffer) {
        return -1;
    }

    init_palette(r->palette);
    r->dirty = true;   // force a full paint on the first frame
    return 0;
}

void renderer_free(Renderer *r) {
    free(r->framebuffer);
    r->framebuffer = NULL;
}

The render pass walks libghostty’s cell grid and blits each glyph into the framebuffer. libghostty tells you which rows changed through its damage callback, so a real implementation only touches dirty rows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Called from the damage callback: repaint rows [start, end)
void renderer_paint(Renderer *r, int start_row, int end_row) {
    for (int row = start_row; row < end_row; row++) {
        for (int col = 0; col < r->cols; col++) {
            GhosttyCell cell;
            if (ghostty_terminal_get_cell(r->core->terminal, row, col, &cell) != 0)
                continue;

            uint32_t fg = resolve_color(cell.fg, r->palette);
            uint32_t bg = resolve_color(cell.bg, r->palette);
            if (cell.attrs & GHOSTTY_CELL_INVERSE) {
                uint32_t tmp = fg; fg = bg; bg = tmp;
            }

            // Fill the cell background, then rasterize the glyph on top.
            fill_cell(r, row, col, bg);
            if (cell.codepoint != 0 && cell.codepoint != ' ')
                draw_glyph(r, row, col, cell.codepoint, fg,
                           cell.attrs & GHOSTTY_CELL_BOLD);
        }
    }
    r->dirty = false;
}

πŸ’‘ Never repaint the whole grid on every read from the PTY. A yes flood or a cat of a large file will fire thousands of writes per second; only the damage-tracked rows need redrawing, and coalescing several damage events into one paint per frame keeps you at 60 fps.

With the PTY, the parser/state machine, and the renderer wired together, the event loop from the previous section drives everything: read from the PTY master, feed the bytes to ghostty_terminal_feed, let libghostty raise damage/bell/title callbacks, and paint once per frame.

Production Configuration

The skeleton above is a working terminal, but a few things separate a demo from something you’d use daily:

  • Real font rasterization. Swap the cell_width = 8 placeholder for FreeType or HarfBuzz. Measure the font’s advance width and line height at your target DPI, build a glyph atlas keyed on (codepoint, bold, italic), and cache it. Grapheme clusters and wide (CJK / emoji) characters need ghostty_cell width flags respected β€” a wide cell occupies two columns.
  • Scrollback. libghostty keeps the active screen; persist evicted rows into a ring buffer and offset your render origin when the user scrolls.
  • Resize. On SIGWINCH (GUI resize), recompute rows/cols from pixel size, call ghostty_terminal_resize, ioctl(pty, TIOCSWINSZ, &ws), and reallocate the framebuffer.
  • Clipboard & OSC 52. Handle the OSC 52 sequence libghostty surfaces so remote tmux/nvim can set the system clipboard.
  • Config reload. Keep colors, font, and keybindings in a struct you can rebuild on SIGHUP without restarting the PTY.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
static void on_sigwinch(Renderer *r, PtyManager *pty) {
    int px_w, px_h;
    window_get_pixel_size(&px_w, &px_h);

    int cols = px_w / r->cell_width;
    int rows = px_h / r->cell_height;

    ghostty_terminal_resize(r->core->terminal, rows, cols);

    struct winsize ws = { .ws_row = rows, .ws_col = cols,
                          .ws_xpixel = px_w, .ws_ypixel = px_h };
    ioctl(pty->master_fd, TIOCSWINSZ, &ws);

    renderer_free(r);
    renderer_init(r, r->core, rows, cols);
}

Common Mistakes and Troubleshooting

Garbled output / half-parsed escape sequences. You’re treating each read() as a complete message. TTY reads split mid-sequence constantly. Feed every byte you read to ghostty_terminal_feed and let it buffer partial sequences internally β€” never parse the raw buffer yourself.

Zombie shell processes. You forked a shell but never waitpid() on SIGCHLD. Install the handler (as in the event loop), reap the child, and exit cleanly when it dies.

TERM mismatches. If you advertise TERM=xterm-256color but don’t implement the sequences programs expect (bracketed paste, alt-screen, mouse reporting), apps like vim and htop misbehave. Start from xterm and only claim 256color once the palette path works.

Input isn’t reaching the shell. You forgot to set the PTY to raw mode, or you’re writing keystrokes to the wrong fd. The GUI writes user input to the PTY master; the shell reads from the slave.

Flicker or tearing. You’re painting directly to a visible surface. Render into the offscreen framebuffer, then present it in one operation synchronized to vblank.

100% CPU on output floods. Full-grid repaints per PTY read (see the tip above). Batch damage, cap to one paint per frame.

Performance and Scalability

  • Damage-driven rendering is the single biggest win: repaint only the rows libghostty flags, and coalesce events into one frame.
  • Glyph atlas caching turns per-cell rasterization into a texture blit; a cold draw_glyph is orders of magnitude slower than a cached lookup.
  • Batch PTY reads. Use a 64 KB read buffer and drain the fd in a loop before painting, so a burst becomes one feed + one frame rather than thousands.
  • Avoid per-cell allocations in the render path. Preallocate the framebuffer and glyph cache; the hot loop should allocate nothing.
  • Throttle to the display refresh. A timerfd or the compositor’s frame callback bounds work to ~16 ms regardless of how fast the child writes.
  • Profile with a flood test: time (yes | head -c 100000000) inside your terminal should stay responsive and well under one core.

Conclusion and Next Steps

You’ve built a terminal from three composable layers: a PTY that runs the shell, libghostty doing the hard VT parsing and screen-state work, and a renderer that only redraws what changed. The key design decision is letting libghostty own correctness β€” the parser, the state machine, the escape-sequence coverage β€” so your code is just plumbing and pixels.

From here:

  • Replace the placeholder rasterizer with FreeType + HarfBuzz and a glyph atlas.
  • Add scrollback, selection, and OSC 52 clipboard support.
  • Wire real windowing (GLFW, SDL, or a Wayland/X11 backend) and GPU-accelerated presentation.
  • Implement mouse reporting and bracketed paste for full tmux/nvim compatibility.
  • Read Ghostty’s own source for how a production embedder handles config, ligatures, and Kitty graphics.

Additional Resources