Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# ghostel/src — Zig coding principles

## Error handling

- **Errors are always errors.** Never swallow with bare `catch {}` or `catch continue` unless you can prove the specific error code means "no value" (see below). Log or propagate every real error.
- **Know your error codes before mapping them.** `GHOSTTY_INVALID_VALUE` can mean either a programmer error (null handle, bad enum) or "no value configured" depending on which data key you query. Check the libghostty header comment for the specific data key before deciding.
- `GHOSTTY_NO_VALUE` → always means "optional absence" — map to `null` via `getOpt`.
- `GHOSTTY_INVALID_VALUE` → usually a programmer error, but some cell-level keys use it to mean "no per-cell value, use terminal default" — check the libghostty header comment for the specific key. When it means absence, use `catch |err| switch (err) { gt.Error.InvalidValue => null, else => return err }`.

## C ABI boundary (module.zig callbacks)

Functions with `callconv(.c)` cannot propagate Zig errors — handle them explicitly at the call site:

```zig
// For paths that can fail deep in the call stack (redraw, encode, emitPlacements):
something.deepWork() catch |err| {
env.logStackTrace(@errorReturnTrace());
env.signalErrorf("ghostel: deepWork failed: {s}", .{@errorName(err)});
return env.nil();
};

// For simple one-call getters (getTitle, getScrollbar, getTotalRows, etc.):
const val = term.getSomething() catch |err| {
env.signalErrorf("ghostel: getSomething failed: {s}", .{@errorName(err)});
return env.nil();
};

// For void C callbacks (callconv(.c) returning void), use logErrorf instead of signalErrorf:
const val = term.getSomething() catch |err| {
env.logErrorf("ghostel: getSomething failed: {s}", .{@errorName(err)});
return;
};

// For per-item errors inside a loop where items are independent, log and continue:
const val = term.getSomething() catch |err| {
env.logErrorf("ghostel: getSomething failed: {s}", .{@errorName(err)});
continue;
};
```

## Accessor pattern (ghostty.zig)

All libghostty `_get` functions that follow `(obj, key, *anyopaque) -> GhosttyResult` are wrapped by `Accessor()`:

```zig
// Returns !T — propagates all errors
const val = try gt.terminal_data.get(T, obj, KEY_CONSTANT);

// Returns !?T — maps NO_VALUE to null, propagates other errors
const opt = try gt.terminal_data.getOpt(T, obj, KEY_CONSTANT);

// Writes into an existing pointer (e.g. to repopulate an iterator in-place)
try gt.kitty_graphics_data.read(obj, KEY_CONSTANT, &existing_ptr);
```

Available Accessors (see `ghostty.zig`): `terminal_data`, `row`, `cell`, `rs`, `rs_row`, `rs_row_cells`, `kitty_graphics_data`, `kitty_placement_data`.

`ghostty_terminal_mode_get` has a different signature — use `gt.terminalModeGet(terminal, mode) !bool`.

## Out-pointers

Do not add new functions with out-pointer + bool/null return patterns. Always return `!T` or `!?T`:
- Use the `Accessor` wrappers for C getter functions.
- For `_new` constructor calls (opaque object creation), the out-pointer is inherent to the C ABI — use `try gt.toError(gt.c.ghostty_X_new(null, &handle))`.

## C ABI callbacks — do not change calling convention

Any function with `callconv(.c)` is part of a fixed ABI contract with libghostty or Emacs. Do not change its signature, calling convention, or return type without understanding the ABI contract on both sides.

## Build and format workflow

After editing any `.zig` file:
1. `zig build` — must pass before moving on
2. Format via Emacs: `(with-current-buffer (find-file-noselect "/path/to/file.zig") (indent-region (point-min) (point-max)) (save-buffer))`
1 change: 1 addition & 0 deletions src/CLAUDE.md
56 changes: 56 additions & 0 deletions src/emacs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/// Provides type-safe access to emacs_env functions, cached symbol
/// interning, and helper methods for common operations.
const std = @import("std");
const builtin = @import("builtin");

pub const c = @cImport({
// Ensure struct timespec is fully defined on Linux (glibc gates it
Expand Down Expand Up @@ -263,13 +264,62 @@ pub const Env = struct {
return func(self.raw, str.ptr, @intCast(str.len));
}

// --- Logging and debugging ---

/// Signal an error with a message string.
pub fn signalError(self: Env, msg: []const u8) void {
self.nonLocalExitSignal(
self.intern("error"),
self.call1(self.intern("list"), self.makeString(msg)),
);
}

/// Signal an error with a formatted message string.
pub fn signalErrorf(self: Env, comptime fmt: []const u8, args: anytype) void {
callFmt(self, Env.signalError, fmt, args);
}

pub fn message(self: Env, msg: []const u8) void {
_ = self.call1(sym.message, self.makeString(msg));
}

pub fn messagef(self: Env, comptime fmt: []const u8, args: anytype) void {
callFmt(self, Env.message, fmt, args);
}

pub fn logError(self: Env, msg: []const u8) void {
_ = self.call3(sym.@"display-warning", sym.ghostel, self.makeString(msg), sym.@":error");
}

pub fn logErrorf(self: Env, comptime fmt: []const u8, args: anytype) void {
callFmt(self, Env.logError, fmt, args);
}

/// Writes stack trace as Emacs messages if in debug mode
pub fn logStackTrace(self: Env, stack_trace: ?*std.builtin.StackTrace) void {
if (comptime builtin.mode == .Debug) {
if (stack_trace) |trace| {
var buffer: [4096]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
const debug_info = std.debug.getSelfDebugInfo() catch |err| {
self.logErrorf("Unable to get debug info: {s}", .{@errorName(err)});
return;
};
std.debug.writeStackTrace(trace.*, &writer, debug_info, .no_color) catch |err| {
self.logErrorf("Unable to print stack trace: {s}", .{@errorName(err)});
return;
};
self.logError(buffer[0..writer.end]);
}
}
}

fn callFmt(self: Env, func: anytype, comptime fmt: []const u8, args: anytype) void {
var buffer: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
writer.print(fmt, args) catch {};
@call(.auto, func, .{ self, buffer[0..writer.end] });
}
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -354,6 +404,12 @@ pub const Sym = struct {
@"ghostel--kitty-display-image": Value,
@"ghostel--kitty-display-virtual": Value,
@"ghostel--kitty-clear": Value,

// Debugging and logging
message: Value,
@"display-warning": Value,
@":error": Value,
ghostel: Value,
};

pub var sym: Sym = undefined;
Expand Down
109 changes: 109 additions & 0 deletions src/ghostty.zig
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/// Zig bindings for the libghostty-vt C API.
const std = @import("std");

pub const c = @cImport({
@cInclude("ghostty/vt.h");
});
Expand Down Expand Up @@ -154,3 +156,110 @@ pub const FormatterTerminalOptions = c.GhosttyFormatterTerminalOptions;
pub const FormatterTerminalExtra = c.GhosttyFormatterTerminalExtra;
pub const FormatterScreenExtra = c.GhosttyFormatterScreenExtra;
pub const FORMATTER_PLAIN = c.GHOSTTY_FORMATTER_FORMAT_PLAIN;

pub const Error = error{ OutOfMemory, InvalidValue, NoValue, OutOfSpace, Unknown };

pub fn toError(c_error: c_int) Error!void {
switch (c_error) {
SUCCESS => return,
OUT_OF_MEMORY => return Error.OutOfMemory,
INVALID_VALUE => return Error.InvalidValue,
NO_VALUE => return Error.NoValue,
OUT_OF_SPACE => return Error.OutOfSpace,
else => return Error.Unknown,
}
}

pub const Multi = struct { c_uint, type };

fn Accessor(comptime Target: type, getter: anytype, setter: anytype, multi_getter: anytype) type {
return struct {
pub fn get(comptime T: type, target: Target, data: c_uint) !T {
comptime if (@TypeOf(getter) == void) @compileError("Not readable");
var value: T = undefined;
try toError(@call(.auto, getter, .{ target, data, @as(?*anyopaque, @ptrCast(&value)) }));
return value;
}

pub fn getOpt(comptime T: type, target: Target, data: c_uint) !?T {
comptime if (@TypeOf(getter) == void) @compileError("Not readable");
if (get(T, target, data)) |value| {
return value;
} else |err| return switch (err) {
Error.NoValue => null,
else => err,
};
}

fn MultiValues(comptime data: anytype) type {
var fields: [data.len]std.builtin.Type.StructField = undefined;
for (data, 0..) |d, i| {
fields[i] = std.builtin.Type.StructField{
.name = std.fmt.comptimePrint("{d}", .{i}),
.type = d[1],
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(d[1]),
};
}

// zig fmt: off
return @Type(std.builtin.Type{.@"struct" = .{
.layout = .auto,
.fields = &fields,
.decls = &[_]std.builtin.Type.Declaration{},
.is_tuple = true
}});
// zig fmt: on
}

pub fn getMulti(target: Target, comptime keys_types: []const Multi) !MultiValues(keys_types) {
comptime if (@TypeOf(getter) == void) @compileError("Not multi gettable");
var keys: [keys_types.len]c_uint = undefined;
var values: MultiValues(keys_types) = undefined;
var ptrs: [keys_types.len]?*anyopaque = undefined;
inline for (keys_types, 0..) |key_type, i| {
keys[i] = key_type[0];
ptrs[i] = &values[i];
}

var num_written: usize = 0;
try toError(@call(.auto, multi_getter, .{ target, keys_types.len, &keys, &ptrs, &num_written }));
return if (num_written == keys_types.len) values else error.IncompleteRead;
}

pub fn read(target: Target, data: c_uint, out_ptr: anytype) !void {
comptime if (@TypeOf(getter) == void) @compileError("Not readable");
try toError(@call(.auto, getter, .{ target, data, @as(?*anyopaque, @ptrCast(out_ptr)) }));
}

pub fn set(target: Target, data: c_uint, value: anytype) !void {
comptime if (@TypeOf(setter) == void) @compileError("Not writable");
try toError(@call(.auto, setter, .{ target, data, @as(?*const anyopaque, @ptrCast(&value)) }));
}
};
}

pub const terminal_data = Accessor(c.GhosttyTerminal, c.ghostty_terminal_get, void, void);
pub const kitty_graphics_data = Accessor(c.GhosttyKittyGraphics, c.ghostty_kitty_graphics_get, void, void);
pub const kitty_placement_data = Accessor(c.GhosttyKittyGraphicsPlacementIterator, c.ghostty_kitty_graphics_placement_get, void, void);
pub const row = Accessor(c.GhosttyRow, c.ghostty_row_get, void, void);
pub const cell = Accessor(c.GhosttyCell, c.ghostty_cell_get, void, void);
pub const rs = Accessor(RenderState, c.ghostty_render_state_get, c.ghostty_render_state_set, c.ghostty_render_state_get_multi);
pub const rs_row = Accessor(RenderStateRowIterator, c.ghostty_render_state_row_get, c.ghostty_render_state_row_set, void);
pub const rs_row_cells = Accessor(RenderStateRowCells, c.ghostty_render_state_row_cells_get, void, void);

pub fn terminalModeGet(term: c.GhosttyTerminal, mode: c.GhosttyMode) !bool {
var enabled: bool = false;
try toError(c.ghostty_terminal_mode_get(term, mode, &enabled));
return enabled;
}

pub const rs_row_cells_next = c.ghostty_render_state_row_cells_next;
pub const rs_row_next = c.ghostty_render_state_row_iterator_next;

pub fn renderStateUpdate(state: RenderState, terminal: Terminal) !void {
try toError(c.ghostty_render_state_update(state, terminal));
}

pub const term_resize = c.ghostty_terminal_resize;
24 changes: 12 additions & 12 deletions src/input.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ const Key = gt.c.GhosttyKey;
const Mods = gt.c.GhosttyMods;

/// Encode a key event and send the result to the PTY via Elisp callback.
/// Returns true if the key was encoded and sent.
pub fn encodeAndSend(env: emacs.Env, term: *Terminal, key: Key, mods: Mods, utf8: ?[]const u8) bool {
/// Returns true if bytes were sent, false if the key produces no output (not an error).
pub fn encodeAndSend(env: emacs.Env, term: *Terminal, key: Key, mods: Mods, utf8: ?[]const u8) !bool {
// Sync encoder options from terminal state (cursor key mode, kitty flags, etc.)
gt.c.ghostty_key_encoder_setopt_from_terminal(term.key_encoder, term.terminal);

// Create key event
var event: gt.c.GhosttyKeyEvent = undefined;
if (gt.c.ghostty_key_event_new(null, &event) != gt.SUCCESS) return false;
try gt.toError(gt.c.ghostty_key_event_new(null, &event));
defer gt.c.ghostty_key_event_free(event);

gt.c.ghostty_key_event_set_action(event, gt.c.GHOSTTY_KEY_ACTION_PRESS);
Expand All @@ -33,15 +33,15 @@ pub fn encodeAndSend(env: emacs.Env, term: *Terminal, key: Key, mods: Mods, utf8
// Encode
var buf: [128]u8 = undefined;
var written: usize = 0;
const result = gt.c.ghostty_key_encoder_encode(
try gt.toError(gt.c.ghostty_key_encoder_encode(
term.key_encoder,
event,
&buf,
buf.len,
&written,
);
));

if (result != gt.SUCCESS or written == 0) return false;
if (written == 0) return false;

// Send encoded bytes to the PTY via Elisp
const str = env.makeString(buf[0..written]);
Expand All @@ -51,8 +51,8 @@ pub fn encodeAndSend(env: emacs.Env, term: *Terminal, key: Key, mods: Mods, utf8
}

/// Encode a mouse event and send the result to the PTY.
/// Returns true if the event was encoded and sent.
pub fn encodeAndSendMouse(env: emacs.Env, term: *Terminal, action: i64, button: i64, row: i64, col: i64, mods_val: i64) bool {
/// Returns true if bytes were sent, false if the event produces no output (not an error).
pub fn encodeAndSendMouse(env: emacs.Env, term: *Terminal, action: i64, button: i64, row: i64, col: i64, mods_val: i64) !bool {
// Sync encoder options (tracking mode, format) from terminal
gt.c.ghostty_mouse_encoder_setopt_from_terminal(term.mouse_encoder, term.terminal);

Expand All @@ -72,7 +72,7 @@ pub fn encodeAndSendMouse(env: emacs.Env, term: *Terminal, action: i64, button:

// Create event
var event: gt.c.GhosttyMouseEvent = undefined;
if (gt.c.ghostty_mouse_event_new(null, &event) != gt.SUCCESS) return false;
try gt.toError(gt.c.ghostty_mouse_event_new(null, &event));
defer gt.c.ghostty_mouse_event_free(event);

gt.c.ghostty_mouse_event_set_action(event, @intCast(action));
Expand All @@ -92,15 +92,15 @@ pub fn encodeAndSendMouse(env: emacs.Env, term: *Terminal, action: i64, button:
// Encode
var buf: [128]u8 = undefined;
var written: usize = 0;
const result = gt.c.ghostty_mouse_encoder_encode(
try gt.toError(gt.c.ghostty_mouse_encoder_encode(
term.mouse_encoder,
event,
&buf,
buf.len,
&written,
);
));

if (result != gt.SUCCESS or written == 0) return false;
if (written == 0) return false;

// Send to PTY
const str = env.makeString(buf[0..written]);
Expand Down
Loading
Loading