From 59d8c226f7f25c5cb7238ab3f0574da656726970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Fri, 1 May 2026 20:47:13 +0200 Subject: [PATCH 1/4] Add wrappers around libghostty API to reduce boilerplate and better ergonomics These methods: - Adapt the API to be return value oriented rather that out-pointer oriented - Report errors the Zig way --- src/ghostty.zig | 109 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/ghostty.zig b/src/ghostty.zig index de35d0a0..4423378c 100644 --- a/src/ghostty.zig +++ b/src/ghostty.zig @@ -1,4 +1,6 @@ /// Zig bindings for the libghostty-vt C API. +const std = @import("std"); + pub const c = @cImport({ @cInclude("ghostty/vt.h"); }); @@ -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 rs_update(state: RenderState, terminal: Terminal) !void { + try toError(c.ghostty_render_state_update(state, terminal)); +} + +pub const term_resize = c.ghostty_terminal_resize; From 92a23b335d8b74f367a44eb69169e852b67bcdf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Fri, 1 May 2026 20:48:46 +0200 Subject: [PATCH 2/4] Add Emacs <-> Zig logging and debugging utils --- src/emacs.zig | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/emacs.zig b/src/emacs.zig index 82d5fad0..597e0fad 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -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 @@ -263,6 +264,8 @@ 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( @@ -270,6 +273,53 @@ pub const Env = struct { 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] }); + } }; // --------------------------------------------------------------------------- @@ -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; From 3ba1141a84b785461c10612d25e72650928ca086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Fri, 1 May 2026 22:22:55 +0200 Subject: [PATCH 3/4] Zigify everything Meaning: - Avoid out-pointers, prefer return values - Distinguish between optional and errors - Propagate and log/handle errors - Use try/catch and error unions, no C error values --- src/ghostty.zig | 2 +- src/input.zig | 24 ++-- src/kitty_graphics.zig | 33 ++---- src/module.zig | 147 ++++++++++++++++-------- src/render.zig | 250 ++++++++++++++--------------------------- src/terminal.zig | 86 +++++--------- 6 files changed, 233 insertions(+), 309 deletions(-) diff --git a/src/ghostty.zig b/src/ghostty.zig index 4423378c..44a976ae 100644 --- a/src/ghostty.zig +++ b/src/ghostty.zig @@ -258,7 +258,7 @@ pub fn terminalModeGet(term: c.GhosttyTerminal, mode: c.GhosttyMode) !bool { 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 rs_update(state: RenderState, terminal: Terminal) !void { +pub fn renderStateUpdate(state: RenderState, terminal: Terminal) !void { try toError(c.ghostty_render_state_update(state, terminal)); } diff --git a/src/input.zig b/src/input.zig index 65a4100f..2844b9c3 100644 --- a/src/input.zig +++ b/src/input.zig @@ -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); @@ -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]); @@ -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); @@ -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)); @@ -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]); diff --git a/src/kitty_graphics.zig b/src/kitty_graphics.zig index 44b4cece..a4db221a 100644 --- a/src/kitty_graphics.zig +++ b/src/kitty_graphics.zig @@ -12,28 +12,19 @@ const ppm = @import("ppm.zig"); /// Query all visible kitty graphics placements from libghostty and /// emit them to Elisp for display. Called after render_state_update() /// during each redraw. -pub fn emitPlacements(env: emacs.Env, term: *Terminal) void { +pub fn emitPlacements(env: emacs.Env, term: *Terminal) !void { // Obtain the kitty graphics handle from the terminal. - var graphics: gt.KittyGraphics = undefined; - if (gt.c.ghostty_terminal_get( - term.terminal, - gt.DATA_KITTY_GRAPHICS, - @ptrCast(&graphics), - ) != gt.SUCCESS) return; + const graphics = try gt.terminal_data.get(gt.KittyGraphics, term.terminal, gt.DATA_KITTY_GRAPHICS); // Create a placement iterator. var iterator: gt.KittyGraphicsPlacementIterator = undefined; - if (gt.c.ghostty_kitty_graphics_placement_iterator_new(null, &iterator) != gt.SUCCESS) return; + try gt.toError(gt.c.ghostty_kitty_graphics_placement_iterator_new(null, &iterator)); defer gt.c.ghostty_kitty_graphics_placement_iterator_free(iterator); // Populate it from the storage. - if (gt.c.ghostty_kitty_graphics_get( - graphics, - gt.c.GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR, - @ptrCast(&iterator), - ) != gt.SUCCESS) return; + try gt.kitty_graphics_data.read(graphics, gt.c.GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR, &iterator); - // Iterate over all placements. + // Iterate over all placements. Per-placement errors skip that placement only. while (gt.c.ghostty_kitty_graphics_placement_next(iterator)) { emitOnePlacement(env, term, graphics, iterator) catch continue; } @@ -46,21 +37,11 @@ fn emitOnePlacement( iterator: gt.KittyGraphicsPlacementIterator, ) !void { // Get image ID and check if virtual. - var image_id: u32 = 0; - var is_virtual: bool = false; - if (gt.c.ghostty_kitty_graphics_placement_get( - iterator, - gt.c.GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, - @ptrCast(&image_id), - ) != gt.SUCCESS) return error.PlacementQuery; + const image_id = try gt.kitty_placement_data.get(u32, iterator, gt.c.GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID); // Failure to query is_virtual would silently misclassify the // placement as non-virtual; for virtuals, render_info reports // viewport_visible=false and we'd silently drop the image. - if (gt.c.ghostty_kitty_graphics_placement_get( - iterator, - gt.c.GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL, - @ptrCast(&is_virtual), - ) != gt.SUCCESS) return error.PlacementQuery; + const is_virtual = try gt.kitty_placement_data.get(bool, iterator, gt.c.GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL); // Look up the image. const image = gt.c.ghostty_kitty_graphics_image(graphics, image_id) orelse return error.ImageNotFound; diff --git a/src/module.zig b/src/module.zig index d0dd0a29..19a7f240 100644 --- a/src/module.zig +++ b/src/module.zig @@ -627,15 +627,19 @@ fn extractOscColorQueries(env: emacs.Env, term: *Terminal, data: []const u8) voi switch (osc.code) { 10 => { if (!std.mem.eql(u8, osc.payload, "?")) continue; - var fg: gt.ColorRgb = undefined; - if (!term.getColorForeground(&fg)) continue; - sendDynamicColorReply(env, 10, fg, osc.terminator); + const fg = term.getColorForeground() catch |err| { + env.logErrorf("ghostel: getColorForeground failed: {s}", .{@errorName(err)}); + continue; + }; + if (fg) |color| sendDynamicColorReply(env, 10, color, osc.terminator); }, 11 => { if (!std.mem.eql(u8, osc.payload, "?")) continue; - var bg: gt.ColorRgb = undefined; - if (!term.getColorBackground(&bg)) continue; - sendDynamicColorReply(env, 11, bg, osc.terminator); + const bg = term.getColorBackground() catch |err| { + env.logErrorf("ghostel: getColorBackground failed: {s}", .{@errorName(err)}); + continue; + }; + if (bg) |color| sendDynamicColorReply(env, 11, color, osc.terminator); }, 4 => { // Payload is a ';'-separated list of `index;value` pairs. @@ -647,7 +651,10 @@ fn extractOscColorQueries(env: emacs.Env, term: *Terminal, data: []const u8) voi const idx = std.fmt.parseInt(u32, index_tok, 10) catch continue; if (idx >= 256) continue; if (!palette_loaded) { - if (!term.getColorPalette(&palette)) break; + palette = term.getColorPalette() catch |err| { + env.logErrorf("ghostel: getColorPalette failed: {s}", .{@errorName(err)}); + break; + }; palette_loaded = true; } sendPaletteColorReply(env, @intCast(idx), palette[idx], osc.terminator); @@ -700,10 +707,11 @@ fn fnGetTitle(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*an const env = emacs.Env.init(raw_env.?); const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); - if (term.getTitle()) |title| { - return env.makeString(title); - } - return env.nil(); + const title = term.getTitle() catch |err| { + env.signalErrorf("ghostel: getTitle failed: {s}", .{@errorName(err)}); + return env.nil(); + }; + return if (title) |t| env.makeString(t) else env.nil(); } /// (ghostel--get-pwd TERM) @@ -711,10 +719,11 @@ fn fnGetPwd(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyo const env = emacs.Env.init(raw_env.?); const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); - if (term.getPwd()) |pwd| { - return env.makeString(pwd); - } - return env.nil(); + const pwd = term.getPwd() catch |err| { + env.signalErrorf("ghostel: getPwd failed: {s}", .{@errorName(err)}); + return env.nil(); + }; + return if (pwd) |p| env.makeString(p) else env.nil(); } /// (ghostel--redraw TERM &optional FULL) @@ -728,7 +737,13 @@ fn fnRedraw(raw_env: ?*c.emacs_env, nargs: isize, args: [*c]c.emacs_value, _: ?* vt_log_env = env; defer vt_log_env = null; } - render.redraw(env, term, force_full); + + render.redraw(env, term, force_full) catch |err| { + env.logStackTrace(@errorReturnTrace()); + env.signalErrorf("Redraw failed: {s}", .{@errorName(err)}); + return env.nil(); + }; + // `redraw' parks the libghostty viewport one row above the active // area for the next-redraw incremental change detection. Kitty // placement queries report `viewport_row' relative to the current @@ -738,14 +753,19 @@ fn fnRedraw(raw_env: ?*c.emacs_env, nargs: isize, args: [*c]c.emacs_value, _: ?* // Restore to the active area (SCROLL_BOTTOM) for the kitty calls, // then re-park afterwards. term.scrollViewport(gt.SCROLL_BOTTOM, 0); + defer term.scrollViewport(gt.SCROLL_DELTA, -1); + // Clear viewport-region kitty overlays after redraw so the cleared // region is computed against the post-promotion `scrollback_in_buffer`. // Running kitty-clear before redraw would use the pre-promotion viewport // boundary, wiping the overlay on the row that's about to be promoted // into scrollback — exactly the row we want to keep tagged. _ = env.call0(emacs.sym.@"ghostel--kitty-clear"); - kitty_graphics.emitPlacements(env, term); - term.scrollViewport(gt.SCROLL_DELTA, -1); + kitty_graphics.emitPlacements(env, term) catch |err| { + env.logStackTrace(@errorReturnTrace()); + env.logErrorf("ghostel: emitPlacements failed: {s}", .{@errorName(err)}); + }; + return env.nil(); } @@ -776,10 +796,12 @@ fn fnEncodeKey(raw_env: ?*c.emacs_env, nargs: isize, args: [*c]c.emacs_value, _: const key = input.mapKey(key_name); const mods = input.parseMods(mod_str); - if (input.encodeAndSend(env, term, key, mods, utf8)) { - return env.t(); - } - return env.nil(); + const sent = input.encodeAndSend(env, term, key, mods, utf8) catch |err| { + env.logStackTrace(@errorReturnTrace()); + env.signalErrorf("ghostel: encodeAndSend failed: {s}", .{@errorName(err)}); + return env.nil(); + }; + return if (sent) env.t() else env.nil(); } /// (ghostel--mouse-event TERM ACTION BUTTON ROW COL MODS) @@ -797,10 +819,12 @@ fn fnMouseEvent(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?* const col = env.extractInteger(args[4]); const mods = env.extractInteger(args[5]); - if (input.encodeAndSendMouse(env, term, action, button, row, col, mods)) { - return env.t(); - } - return env.nil(); + const sent = input.encodeAndSendMouse(env, term, action, button, row, col, mods) catch |err| { + env.logStackTrace(@errorReturnTrace()); + env.signalErrorf("ghostel: encodeAndSendMouse failed: {s}", .{@errorName(err)}); + return env.nil(); + }; + return if (sent) env.t() else env.nil(); } /// (ghostel--focus-event TERM GAINED) @@ -813,7 +837,11 @@ fn fnFocusEvent(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?* // Only send focus events if the terminal has enabled mode 1004 // Construct mode value manually: DEC private mode 1004 = value & 0x7FFF, ansi=false (bit 15=0) const focus_mode: gt.c.GhosttyMode = 1004; - if (!term.isModeEnabled(focus_mode)) { + const focus_enabled = term.isModeEnabled(focus_mode) catch |err| { + env.signalErrorf("ghostel: isModeEnabled failed: {s}", .{@errorName(err)}); + return env.nil(); + }; + if (!focus_enabled) { return env.nil(); } @@ -840,7 +868,11 @@ fn fnModeEnabled(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ? const env = emacs.Env.init(raw_env.?); const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); const mode: gt.c.GhosttyMode = @intCast(env.extractInteger(args[1])); - return if (term.isModeEnabled(mode)) env.t() else env.nil(); + const enabled = term.isModeEnabled(mode) catch |err| { + env.signalErrorf("ghostel: isModeEnabled failed: {s}", .{@errorName(err)}); + return env.nil(); + }; + return if (enabled) env.t() else env.nil(); } /// (ghostel--alt-screen-p TERM) @@ -848,7 +880,11 @@ fn fnModeEnabled(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ? fn fnAltScreen(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value { const env = emacs.Env.init(raw_env.?); const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); - return if (term.isAltScreen()) env.t() else env.nil(); + const alt = term.isAltScreen() catch |err| { + env.signalErrorf("ghostel: isAltScreen failed: {s}", .{@errorName(err)}); + return env.nil(); + }; + return if (alt) env.t() else env.nil(); } /// (ghostel--set-palette TERM COLORS-STRING) @@ -868,11 +904,10 @@ fn fnSetPalette(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?* }; // Get current palette as base (keeps entries 16-255) - var palette: [256]gt.ColorRgb = undefined; - if (!term.getColorPalette(&palette)) { - env.signalError("ghostel: failed to get current palette"); + var palette = term.getColorPalette() catch |err| { + env.signalErrorf("ghostel: getColorPalette failed: {s}", .{@errorName(err)}); return env.nil(); - } + }; // Parse "#RRGGBB" entries — 7 chars each var idx: usize = 0; @@ -978,11 +1013,14 @@ fn fnDebugState(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?* const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); // Preserve viewport position - const saved_offset: ?usize = if (term.getScrollbar()) |sb| sb.offset else null; - defer if (saved_offset) |off| { + const saved_offset = (term.getScrollbar() catch |err| { + env.signalErrorf("ghostel: getScrollbar failed: {s}", .{@errorName(err)}); + return env.nil(); + }).offset; + defer { term.scrollViewport(gt.SCROLL_TOP, 0); - term.scrollViewport(gt.SCROLL_DELTA, @intCast(off)); - }; + term.scrollViewport(gt.SCROLL_DELTA, @intCast(saved_offset)); + } term.scrollViewport(gt.SCROLL_BOTTOM, 0); var buf: [4096]u8 = undefined; @@ -1043,11 +1081,14 @@ fn fnDebugFeed(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*a const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); // Preserve viewport position - const saved_offset: ?usize = if (term.getScrollbar()) |sb| sb.offset else null; - defer if (saved_offset) |off| { + const saved_offset = (term.getScrollbar() catch |err| { + env.signalErrorf("ghostel: getScrollbar failed: {s}", .{@errorName(err)}); + return env.nil(); + }).offset; + defer { term.scrollViewport(gt.SCROLL_TOP, 0); - term.scrollViewport(gt.SCROLL_DELTA, @intCast(off)); - }; + term.scrollViewport(gt.SCROLL_DELTA, @intCast(saved_offset)); + } term.scrollViewport(gt.SCROLL_BOTTOM, 0); var stack_buf: [4096]u8 = undefined; @@ -1111,11 +1152,14 @@ fn fnCursorPosition(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _ const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); // Preserve viewport position. - const saved_offset: ?usize = if (term.getScrollbar()) |sb| sb.offset else null; - defer if (saved_offset) |off| { + const saved_offset = (term.getScrollbar() catch |err| { + env.signalErrorf("ghostel: getScrollbar failed: {s}", .{@errorName(err)}); + return env.nil(); + }).offset; + defer { term.scrollViewport(gt.SCROLL_TOP, 0); - term.scrollViewport(gt.SCROLL_DELTA, @intCast(off)); - }; + term.scrollViewport(gt.SCROLL_DELTA, @intCast(saved_offset)); + } term.scrollViewport(gt.SCROLL_BOTTOM, 0); // Ensure render state is up to date @@ -1235,8 +1279,12 @@ fn titleChangedCallback(_: gt.Terminal, userdata: ?*anyopaque) callconv(.c) void const term: *Terminal = @ptrCast(@alignCast(userdata)); const env = term.env orelse return; - if (term.getTitle()) |title| { - _ = env.call1(emacs.sym.@"ghostel--set-title", env.makeString(title)); + const title = term.getTitle() catch |err| { + env.logErrorf("ghostel: getTitle failed in titleChangedCallback: {s}", .{@errorName(err)}); + return; + }; + if (title) |t| { + _ = env.call1(emacs.sym.@"ghostel--set-title", env.makeString(t)); } } @@ -1323,7 +1371,10 @@ fn fnUriAt(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyop const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); const row_from_bottom = env.extractInteger(args[1]); const col = env.extractInteger(args[2]); - const total_rows = term.getTotalRows(); + const total_rows = term.getTotalRows() catch |err| { + env.signalErrorf("ghostel: getTotalRows failed: {s}", .{@errorName(err)}); + return env.nil(); + }; if (col < 0 or col >= term.size.cols) return env.nil(); // The Emacs buffer always carries a trailing newline, so the line diff --git a/src/render.zig b/src/render.zig index 3a3acb40..e889d2d2 100644 --- a/src/render.zig +++ b/src/render.zig @@ -79,25 +79,20 @@ fn formatColor(color: gt.ColorRgb, buf: *[7]u8) []const u8 { } /// Read the style for the current cell from the render state. -fn readCellStyle(cells: gt.RenderStateRowCells, raw: gt.c.GhosttyCell) ?CellStyle { +fn readCellStyle(cells: gt.RenderStateRowCells, raw: gt.c.GhosttyCell) !?CellStyle { var style: CellStyle = .{}; - // Read resolved FG color - var fg: gt.ColorRgb = undefined; - if (gt.c.ghostty_render_state_row_cells_get(cells, gt.RS_CELLS_DATA_FG_COLOR, @ptrCast(&fg)) == gt.SUCCESS) { - style.fg = fg; - } - - // Read resolved BG color - var bg: gt.ColorRgb = undefined; - if (gt.c.ghostty_render_state_row_cells_get(cells, gt.RS_CELLS_DATA_BG_COLOR, @ptrCast(&bg)) == gt.SUCCESS) { - style.bg = bg; - } + style.fg = gt.rs_row_cells.get(gt.ColorRgb, cells, gt.RS_CELLS_DATA_FG_COLOR) catch |err| switch (err) { + gt.Error.InvalidValue => null, + else => return err, + }; + style.bg = gt.rs_row_cells.get(gt.ColorRgb, cells, gt.RS_CELLS_DATA_BG_COLOR) catch |err| switch (err) { + gt.Error.InvalidValue => null, + else => return err, + }; // Read style attributes - var gs: gt.Style = undefined; - gs.size = @sizeOf(gt.Style); - if (gt.c.ghostty_render_state_row_cells_get(cells, gt.RS_CELLS_DATA_STYLE, @ptrCast(&gs)) == gt.SUCCESS) { + if (try gt.rs_row_cells.getOpt(gt.Style, cells, gt.RS_CELLS_DATA_STYLE)) |gs| { style.bold = gs.bold; style.italic = gs.italic; style.faint = gs.faint; @@ -111,8 +106,7 @@ fn readCellStyle(cells: gt.RenderStateRowCells, raw: gt.c.GhosttyCell) ?CellStyl } } - var hl: bool = undefined; - if (gt.c.ghostty_cell_get(raw, gt.c.GHOSTTY_CELL_DATA_HAS_HYPERLINK, @ptrCast(&hl)) == gt.SUCCESS) { + if (try gt.cell.getOpt(bool, raw, gt.c.GHOSTTY_CELL_DATA_HAS_HYPERLINK)) |hl| { style.hyperlink = hl; } @@ -232,25 +226,15 @@ fn applyStyle(env: emacs.Env, start: i64, end: i64, style: CellStyle, default_co } /// Check if the current row in the iterator is soft-wrapped. -fn isRowWrapped(term: *Terminal) bool { - var raw_row: gt.c.GhosttyRow = undefined; - if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.c.GHOSTTY_RENDER_STATE_ROW_DATA_RAW, @ptrCast(&raw_row)) != gt.SUCCESS) { - return false; - } - var wrapped: bool = false; - _ = gt.c.ghostty_row_get(raw_row, gt.ROW_DATA_WRAP, @ptrCast(&wrapped)); - return wrapped; +fn isRowWrapped(term: *Terminal) !bool { + const raw_row = try gt.rs_row.get(gt.c.GhosttyRow, term.row_iterator, gt.c.GHOSTTY_RENDER_STATE_ROW_DATA_RAW); + return try gt.row.get(bool, raw_row, gt.ROW_DATA_WRAP); } /// Check if the current row in the iterator is a semantic prompt. -fn isRowPrompt(term: *Terminal) bool { - var raw_row: gt.c.GhosttyRow = undefined; - if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.c.GHOSTTY_RENDER_STATE_ROW_DATA_RAW, @ptrCast(&raw_row)) != gt.SUCCESS) { - return false; - } - var semantic: c_int = 0; - _ = gt.c.ghostty_row_get(raw_row, gt.ROW_DATA_SEMANTIC_PROMPT, @ptrCast(&semantic)); - return semantic != 0; +fn isRowPrompt(term: *Terminal) !bool { + const raw_row = try gt.rs_row.get(gt.c.GhosttyRow, term.row_iterator, gt.c.GHOSTTY_RENDER_STATE_ROW_DATA_RAW); + return try gt.row.get(c_int, raw_row, gt.ROW_DATA_SEMANTIC_PROMPT) != 0; } /// Result from buildRowContent: byte length for make_string, char count for properties. @@ -268,11 +252,11 @@ const RowContent = struct { has_wide: bool, }; -fn getStyleKey(cell: gt.c.GhosttyCell) CellStyleKey { - var key = CellStyleKey{ .style_id = 0, .hyperlink = false }; - _ = gt.c.ghostty_cell_get(cell, gt.c.GHOSTTY_CELL_DATA_STYLE_ID, @ptrCast(&key.style_id)); - _ = gt.c.ghostty_cell_get(cell, gt.c.GHOSTTY_CELL_DATA_HAS_HYPERLINK, @ptrCast(&key.hyperlink)); - return key; +fn getStyleKey(cell: gt.c.GhosttyCell) !CellStyleKey { + return CellStyleKey{ // zig fmt: off + .style_id = try gt.cell.getOpt(gt.c.GhosttyStyleId, cell, gt.c.GHOSTTY_CELL_DATA_STYLE_ID) orelse 0, + .hyperlink = try gt.cell.getOpt(bool, cell, gt.c.GHOSTTY_CELL_DATA_HAS_HYPERLINK) orelse false + }; // zig fmt: on } /// Build text content and style runs for the current row in the iterator. @@ -290,7 +274,7 @@ fn buildRowContent( text_buf: []u8, runs: []RunInfo, run_count: *usize, -) RowContent { +) !RowContent { var text_len: usize = 0; // byte offset var char_len: usize = 0; // character (codepoint) offset // Position at the end of the last non-blank cell; final row length @@ -312,22 +296,12 @@ fn buildRowContent( var current_style_key: ?CellStyleKey = null; run_count.* = 0; - while (gt.c.ghostty_render_state_row_cells_next(term.row_cells)) { - var graphemes_len: u32 = 0; - if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_LEN, @ptrCast(&graphemes_len)) != gt.SUCCESS) { - continue; - } - var raw_cell: gt.c.GhosttyCell = undefined; - if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.c.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, @ptrCast(&raw_cell)) != gt.SUCCESS) { - continue; - } + try gt.rs_row.read(term.row_iterator, gt.RS_ROW_DATA_CELLS, &term.row_cells); + while (gt.rs_row_cells_next(term.row_cells)) { + const graphemes_len = try gt.rs_row_cells.get(u32, term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_LEN); + const raw_cell = try gt.rs_row_cells.get(gt.c.GhosttyCell, term.row_cells, gt.c.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW); - // Read the cell's semantic-content tag once and reuse it for both - // prompt-prefix and input-range tracking. Cells without an explicit - // OSC 133 marker default to OUTPUT, so we only do real work on rows - // that the shell has annotated. - var semantic: c_int = gt.c.GHOSTTY_CELL_SEMANTIC_OUTPUT; - _ = gt.c.ghostty_cell_get(raw_cell, gt.c.GHOSTTY_CELL_DATA_SEMANTIC_CONTENT, @ptrCast(&semantic)); + const semantic = try gt.cell.get(c_int, raw_cell, gt.c.GHOSTTY_CELL_DATA_SEMANTIC_CONTENT); // Track leading prompt characters via cell-level semantic content. if (in_prompt and semantic != gt.c.GHOSTTY_CELL_SEMANTIC_PROMPT) { @@ -344,12 +318,12 @@ fn buildRowContent( // read and compare to detect style run breaks. Only when we detect a // break do we read the cell style, which is a more expensive operation // in such a tight loop. - const style_key: CellStyleKey = getStyleKey(raw_cell); + const style_key: CellStyleKey = try getStyleKey(raw_cell); if (!std.meta.eql(@as(?CellStyleKey, style_key), current_style_key) and run_count.* < runs.len) { runs[run_count.*] = .{ .start_char = char_len, .end_char = char_len + 1, - .style = readCellStyle(term.row_cells, raw_cell), + .style = try readCellStyle(term.row_cells, raw_cell), }; run_count.* += 1; current_style_key = style_key; @@ -361,8 +335,7 @@ fn buildRowContent( // Wide-character spacer tails occupy a terminal cell but must // not produce output — the preceding wide cell already accounts // for 2 visual columns in Emacs. - var wide: c_int = gt.c.GHOSTTY_CELL_WIDE_NARROW; - _ = gt.c.ghostty_cell_get(raw_cell, gt.c.GHOSTTY_CELL_DATA_WIDE, @ptrCast(&wide)); + const wide = try gt.cell.get(c_int, raw_cell, gt.c.GHOSTTY_CELL_DATA_WIDE); if (wide == gt.c.GHOSTTY_CELL_WIDE_SPACER_TAIL) { runs[run_count.* - 1].end_char -= 1; has_wide = true; @@ -384,11 +357,8 @@ fn buildRowContent( continue; } - var codepoints: [16]u32 = undefined; + const codepoints = try gt.rs_row_cells.get([16]u32, term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_BUF); const cp_count = @min(graphemes_len, 16); - if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_BUF, @ptrCast(&codepoints)) != gt.SUCCESS) { - continue; - } for (0..cp_count) |i| { const cp: u21 = @intCast(codepoints[i]); @@ -435,15 +405,11 @@ fn insertAndStyle( env: emacs.Env, term: *Terminal, default_colors: *const BgFg, -) ?RowContent { - if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.RS_ROW_DATA_CELLS, @ptrCast(&term.row_cells)) != gt.SUCCESS) { - return null; - } - +) !RowContent { var runs: [512]RunInfo = undefined; var text_buf: [16384]u8 = undefined; var run_count: usize = 0; - var content = buildRowContent(term, &text_buf, &runs, &run_count); + var content = try buildRowContent(term, &text_buf, &runs, &run_count); // Append the trailing newline to the row buffer so the row // text + newline insert through a single env.insert call @@ -481,7 +447,7 @@ fn insertAndStyle( env.insert("\n"); } const after_insert = env.extractInteger(env.point()); - if (isRowWrapped(term)) { + if (try isRowWrapped(term)) { // Mark newlines from soft-wrapped rows so copy mode can filter them const point = env.point(); const nl_pos = env.makeInteger(env.extractInteger(point) - 1); @@ -495,7 +461,7 @@ fn insertAndStyle( emacs.sym.@"ghostel-prompt", env.t(), ); - } else if (isRowPrompt(term)) { + } else if (try isRowPrompt(term)) { // When OSC 133 marked an input span on this row, paint // `ghostel-prompt' only over the prefix preceding the input — // otherwise the typed input gets covered too, defeating the @@ -536,43 +502,34 @@ fn insertAndStyle( /// the terminal's column width for certain characters (e.g. box-drawing /// glyphs on CJK/pgtk systems where `char-width` returns 2 but the /// terminal treats them as single-width). -fn positionCursorByCell(env: emacs.Env, term: *Terminal, cx: u16, cy: u16) bool { +fn positionCursorByCell(env: emacs.Env, term: *Terminal, cx: u16, cy: u16) !bool { if (cx == 0) return true; // already at column 0 - if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_ROW_ITERATOR, @ptrCast(&term.row_iterator)) != gt.SUCCESS) { - return false; - } + try gt.rs.read(term.render_state, gt.RS_DATA_ROW_ITERATOR, &term.row_iterator); // Advance iterator to cursor row cy. { var ri: u16 = 0; while (ri <= cy) : (ri += 1) { - if (!gt.c.ghostty_render_state_row_iterator_next(term.row_iterator)) { + if (!gt.rs_row_next(term.row_iterator)) { return false; } } } - if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.RS_ROW_DATA_CELLS, @ptrCast(&term.row_cells)) != gt.SUCCESS) { - return false; - } + try gt.rs_row.read(term.row_iterator, gt.RS_ROW_DATA_CELLS, &term.row_cells); // Walk cells 0..cx-1, counting Emacs characters. var col: u16 = 0; var char_count: i64 = 0; while (col < cx) : (col += 1) { - if (!gt.c.ghostty_render_state_row_cells_next(term.row_cells)) break; - - var graphemes_len: u32 = 0; - _ = gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_LEN, @ptrCast(&graphemes_len)); + if (!gt.rs_row_cells_next(term.row_cells)) break; + const graphemes_len = try gt.rs_row_cells.get(u32, term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_LEN); if (graphemes_len == 0) { // Spacer tails produce no Emacs character. - var raw_cell: gt.c.GhosttyCell = undefined; - var wide: c_int = gt.c.GHOSTTY_CELL_WIDE_NARROW; - if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.c.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, @ptrCast(&raw_cell)) == gt.SUCCESS) { - _ = gt.c.ghostty_cell_get(raw_cell, gt.c.GHOSTTY_CELL_DATA_WIDE, @ptrCast(&wide)); - } + const raw_cell = try gt.rs_row_cells.get(gt.c.GhosttyCell, term.row_cells, gt.c.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW); + const wide = try gt.cell.get(c_int, raw_cell, gt.c.GHOSTTY_CELL_DATA_WIDE); if (wide == gt.c.GHOSTTY_CELL_WIDE_SPACER_TAIL) { continue; } @@ -596,35 +553,26 @@ const BgFg = struct { fg: gt.ColorRgb, }; -fn getDefaultColors(term: *Terminal) BgFg { - var bgfg = BgFg{ .fg = gt.ColorRgb{ .r = 204, .g = 204, .b = 204 }, .bg = gt.ColorRgb{ .r = 0, .g = 0, .b = 0 } }; - const color_keys = [_]gt.c.GhosttyRenderStateData{ - gt.RS_DATA_COLOR_FOREGROUND, - gt.RS_DATA_COLOR_BACKGROUND, - }; - var color_values = [_]?*anyopaque{ - @ptrCast(&bgfg.fg), - @ptrCast(&bgfg.bg), - }; - _ = gt.c.ghostty_render_state_get_multi(term.render_state, color_keys.len, &color_keys, @ptrCast(&color_values), null); - - return bgfg; +fn getDefaultColors(term: *Terminal) !BgFg { + // zig fmt: off + const fg , const bg = try gt.rs.getMulti(term.render_state, &[_]gt.Multi{ + .{ gt.RS_DATA_COLOR_FOREGROUND, gt.ColorRgb }, + .{ gt.RS_DATA_COLOR_BACKGROUND, gt.ColorRgb } + }); + // zig fmt: on + return BgFg{ .fg = fg, .bg = bg }; } -pub fn render(env: emacs.Env, term: *Terminal, skip: usize, force_full: bool) void { - if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) { - return; - } - - const default_colors = getDefaultColors(term); +pub fn render(env: emacs.Env, term: *Terminal, skip: usize, force_full: bool) !void { + const default_colors = try getDefaultColors(term); + try gt.renderStateUpdate(term.render_state, term.terminal); + var has_wide_chars: bool = false; // Check dirty state. // force_full overrides: the buffer may have been erased by scrollback // sync / resize / rotation above, so we must rebuild even if // libghostty considers the cells clean. - var dirty: c_int = gt.DIRTY_FALSE; - _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_DIRTY, @ptrCast(&dirty)); - var has_wide_chars: bool = false; + const dirty = try gt.rs.get(c_int, term.render_state, gt.RS_DATA_DIRTY); if (dirty != gt.DIRTY_FALSE or force_full) { // Set buffer default face @@ -636,35 +584,26 @@ pub fn render(env: emacs.Env, term: *Terminal, skip: usize, force_full: bool) vo env.makeString(formatColor(default_colors.bg, &bg_hex)), ); - // Get row iterator - if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_ROW_ITERATOR, @ptrCast(&term.row_iterator)) != gt.SUCCESS) { - return; - } - // Incremental redraw: only update dirty rows when possible. // force_full bypasses partial mode to avoid stale rows after scrolls. const dirty_full = force_full or dirty == gt.DIRTY_FULL; var row_count: usize = 0; - while (gt.c.ghostty_render_state_row_iterator_next(term.row_iterator)) : (row_count += 1) { + + try gt.rs.read(term.render_state, gt.RS_DATA_ROW_ITERATOR, &term.row_iterator); + while (gt.rs_row_next(term.row_iterator)) : (row_count += 1) { defer { // Clear per-row dirty flag - const row_clean: bool = false; - _ = gt.c.ghostty_render_state_row_set(term.row_iterator, gt.RS_ROW_OPT_DIRTY, @ptrCast(&row_clean)); + gt.rs_row.set(term.row_iterator, gt.RS_ROW_OPT_DIRTY, false) catch {}; } if (row_count < skip) continue; // Only process dirty rows - var dirty_row: bool = dirty_full; - if (!dirty_full) { - _ = gt.c.ghostty_render_state_row_get(term.row_iterator, gt.RS_ROW_DATA_DIRTY, @ptrCast(&dirty_row)); - } - + const dirty_row = dirty_full or try gt.rs_row.get(bool, term.row_iterator, gt.RS_ROW_DATA_DIRTY); if (dirty_row) { env.deleteRegion(env.point(), env.lineBeginningPosition2()); - if (insertAndStyle(env, term, &default_colors)) |content| { - has_wide_chars |= content.has_wide; - } + const content = try insertAndStyle(env, term, &default_colors); + has_wide_chars |= content.has_wide; } else { _ = env.forwardLine(1); } @@ -674,52 +613,37 @@ pub fn render(env: emacs.Env, term: *Terminal, skip: usize, force_full: bool) vo env.deleteRegion(env.point(), env.pointMax()); // Reset dirty state - const dirty_false: c_int = gt.DIRTY_FALSE; - _ = gt.c.ghostty_render_state_set(term.render_state, gt.RS_OPT_DIRTY, @ptrCast(&dirty_false)); + try gt.rs.set(term.render_state, gt.RS_OPT_DIRTY, gt.DIRTY_FALSE); } - if (dirty != gt.DIRTY_FALSE) { - if (has_wide_chars) { - _ = env.call2(env.intern("set"), emacs.sym.@"ghostel--has-wide-chars", env.t()); - } + if (dirty != gt.DIRTY_FALSE and has_wide_chars) { + _ = env.call2(env.intern("set"), emacs.sym.@"ghostel--has-wide-chars", env.t()); } } -pub fn renderCursor(env: emacs.Env, term: *Terminal) void { +pub fn renderCursor(env: emacs.Env, term: *Terminal) !void { // Walk to the current viewport start gotoActiveStart(env, term); const active_start_int = env.extractInteger(env.point()); // Batch-fetch cursor style/visibility (always available). - var cursor_visible: bool = true; - var cursor_style: c_int = gt.CURSOR_BLOCK; - { - const cursor_keys = [_]gt.c.GhosttyRenderStateData{ - gt.RS_DATA_CURSOR_VISIBLE, - gt.RS_DATA_CURSOR_VISUAL_STYLE, - }; - var cursor_values = [_]?*anyopaque{ - @ptrCast(&cursor_visible), - @ptrCast(&cursor_style), - }; - _ = gt.c.ghostty_render_state_get_multi(term.render_state, cursor_keys.len, &cursor_keys, @ptrCast(&cursor_values), null); - } + const cursor_visible, const cursor_style = try gt.rs.getMulti(term.render_state, &[_]gt.Multi{ + .{ gt.RS_DATA_CURSOR_VISIBLE, bool }, + .{ gt.RS_DATA_CURSOR_VISUAL_STYLE, c_int }, + }); // Position cursor (active-relative row -> absolute line). // X/Y are only valid when HAS_VALUE is true, so query separately // to avoid stopping the style batch above on NO_VALUE. - var cursor_has_value: bool = false; - _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_HAS_VALUE, @ptrCast(&cursor_has_value)); + const cursor_has_value = try gt.rs.get(bool, term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_HAS_VALUE); if (cursor_has_value) { - var cx: u16 = 0; - var cy: u16 = 0; - _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_X, @ptrCast(&cx)); - _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_Y, @ptrCast(&cy)); + const cx = try gt.rs.get(u16, term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_X); + const cy = try gt.rs.get(u16, term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_Y); env.gotoCharN(active_start_int); _ = env.forwardLine(@as(i64, cy)); - if (!positionCursorByCell(env, term, cx, cy)) { + if (!try positionCursorByCell(env, term, cx, cy)) { env.moveToColumn(@as(i64, cx)); } } @@ -733,8 +657,8 @@ pub fn renderCursor(env: emacs.Env, term: *Terminal) void { // Render content from the current viewport scroll position all the way to // the active area at the current Emacs point. -fn renderToEnd(env: emacs.Env, term: *Terminal, force_full: bool) usize { - const scrollbar = term.getScrollbar() orelse return 0; +fn renderToEnd(env: emacs.Env, term: *Terminal, force_full: bool) !usize { + const scrollbar = try term.getScrollbar(); if (scrollbar.len == 0) return 0; const offset_max = scrollbar.total - scrollbar.len; // Walk from the current viewport position to offset_max in viewport-sized @@ -749,7 +673,7 @@ fn renderToEnd(env: emacs.Env, term: *Terminal, force_full: bool) usize { var rendered_rows: usize = 0; var current_offset = scrollbar.offset; for (0..num_viewports) |_| { - render(env, term, skip, force_full); + try render(env, term, skip, force_full); rendered_rows += (scrollbar.len - skip); const max_step = offset_max - current_offset; @@ -765,7 +689,7 @@ fn renderToEnd(env: emacs.Env, term: *Terminal, force_full: bool) usize { fn commitResize(term: *Terminal) void { if (term.pending_resize) |resize| { - _ = gt.c.ghostty_terminal_resize( + _ = gt.term_resize( term.terminal, resize.cols, resize.rows, @@ -800,7 +724,7 @@ fn gotoActiveStart(env: emacs.Env, term: *Terminal) void { /// /// When `force_full` is true, the viewport region is fully re-rendered /// instead of using the incremental dirty-row path. -pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void { +pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) !void { // Snapshot the buffer's mark across the destructive ops below. Both // paths — full (eraseBuffer / deleteRegion over the viewport) and // partial (per-row deleteRegion + insert) — move every marker in the @@ -833,7 +757,7 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void { // 3. We had some scrollback but the scrollbar ended up at offset = 0, which // means that we got so much scrolling that we scrolled all the way up // and do not know how much we missed. - const scrollbar = term.getScrollbar() orelse return; + const scrollbar = try term.getScrollbar(); const cols_changed = if (term.pending_resize) |resize| resize.cols != term.size.cols else false; const had_scrollback = term.rows_in_buffer > scrollbar.len; const scrollbar_reset = had_scrollback and scrollbar.len + scrollbar.offset == scrollbar.total; @@ -860,7 +784,7 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void { env.gotoChar(env.pointMin()); } - const rendered_rows = renderToEnd(env, term, force_full); + const rendered_rows = try renderToEnd(env, term, force_full); // Now that we rendered, even if we cleared the buffer above, we now have at // least the rows in the active area: term.rows_in_buffer = @max(term.rows_in_buffer, term.size.rows); @@ -878,13 +802,13 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void { commitResize(term); term.scrollViewport(gt.SCROLL_BOTTOM, 0); gotoActiveStart(env, term); - render(env, term, 0, false); + try render(env, term, 0, false); // There is now at least term.size.rows number of rows term.rows_in_buffer = @max(term.rows_in_buffer, term.size.rows); } // Evict old scrollback if libghostty also did - const libghostty_rows = term.getTotalRows(); + const libghostty_rows = try term.getTotalRows(); if (libghostty_rows < term.rows_in_buffer) { env.gotoChar(env.pointMin()); _ = env.forwardLine(@as(i64, @intCast(term.rows_in_buffer - libghostty_rows))); @@ -892,10 +816,10 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void { term.rows_in_buffer = libghostty_rows; } - renderCursor(env, term); + try renderCursor(env, term); // Update working directory from OSC 7 - if (term.getPwd()) |pwd| { + if (try term.getPwd()) |pwd| { _ = env.call1(emacs.sym.@"ghostel--update-directory", env.makeString(pwd)); } diff --git a/src/terminal.zig b/src/terminal.zig index 572349cd..1309617f 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -212,30 +212,20 @@ pub fn enableKittyGraphics( } /// Get the current color palette (256 entries). -pub fn getColorPalette(self: *Self, palette: *[256]gt.ColorRgb) bool { - return gt.c.ghostty_terminal_get( - self.terminal, - gt.DATA_COLOR_PALETTE, - @ptrCast(palette), - ) == gt.SUCCESS; +pub fn getColorPalette(self: *Self) ![256]gt.ColorRgb { + return gt.terminal_data.get([256]gt.ColorRgb, self.terminal, gt.DATA_COLOR_PALETTE); } /// Get the effective foreground color (honouring any OSC 10 override). -pub fn getColorForeground(self: *Self, out: *gt.ColorRgb) bool { - return gt.c.ghostty_terminal_get( - self.terminal, - gt.DATA_COLOR_FOREGROUND, - @ptrCast(out), - ) == gt.SUCCESS; +/// Returns null if no foreground color is configured (NO_VALUE). +pub fn getColorForeground(self: *Self) !?gt.ColorRgb { + return gt.terminal_data.getOpt(gt.ColorRgb, self.terminal, gt.DATA_COLOR_FOREGROUND); } /// Get the effective background color (honouring any OSC 11 override). -pub fn getColorBackground(self: *Self, out: *gt.ColorRgb) bool { - return gt.c.ghostty_terminal_get( - self.terminal, - gt.DATA_COLOR_BACKGROUND, - @ptrCast(out), - ) == gt.SUCCESS; +/// Returns null if no background color is configured (NO_VALUE). +pub fn getColorBackground(self: *Self) !?gt.ColorRgb { + return gt.terminal_data.getOpt(gt.ColorRgb, self.terminal, gt.DATA_COLOR_BACKGROUND); } /// Feed VT data from the PTY into the terminal. @@ -260,69 +250,47 @@ pub fn scrollViewport(self: *Self, tag: c_int, delta: isize) void { gt.c.ghostty_terminal_scroll_viewport(self.terminal, behavior); } -/// Get the terminal title as a borrowed string. -pub fn getTitle(self: *Self) ?[]const u8 { - var title: gt.GhosttyString = undefined; - if (gt.c.ghostty_terminal_get(self.terminal, gt.DATA_TITLE, &title) != gt.SUCCESS) { - return null; - } +/// Get the terminal title as a borrowed string. Returns null if not set. +pub fn getTitle(self: *Self) !?[]const u8 { + const title = try gt.terminal_data.get(gt.GhosttyString, self.terminal, gt.DATA_TITLE); if (title.len == 0) return null; return title.ptr[0..title.len]; } -/// Get the terminal's current working directory (from OSC 7). -pub fn getPwd(self: *Self) ?[]const u8 { - var pwd: gt.GhosttyString = undefined; - if (gt.c.ghostty_terminal_get(self.terminal, gt.DATA_PWD, &pwd) != gt.SUCCESS) { - return null; - } +/// Get the terminal's current working directory (from OSC 7). Returns null if not set. +pub fn getPwd(self: *Self) !?[]const u8 { + const pwd = try gt.terminal_data.get(gt.GhosttyString, self.terminal, gt.DATA_PWD); if (pwd.len == 0) return null; return pwd.ptr[0..pwd.len]; } -/// Check if a terminal mode is enabled. -pub fn isModeEnabled(self: *Self, mode: gt.c.GhosttyMode) bool { - var enabled: bool = false; - if (gt.c.ghostty_terminal_mode_get(self.terminal, mode, &enabled) != gt.SUCCESS) { - return false; - } - return enabled; +/// Check if a terminal mode is enabled. Error.InvalidValue means unknown mode. +pub fn isModeEnabled(self: *Self, mode: gt.c.GhosttyMode) !bool { + return gt.terminalModeGet(self.terminal, mode); } /// Returns true if the terminal is on the alternate screen buffer /// (DEC private modes 1049, 1047, or legacy 47 set). Used to decide /// whether full-screen apps (vim, htop, less) own the viewport. -pub fn isAltScreen(self: *Self) bool { - return self.isModeEnabled(@as(gt.c.GhosttyMode, 1049)) or - self.isModeEnabled(@as(gt.c.GhosttyMode, 1047)) or - self.isModeEnabled(@as(gt.c.GhosttyMode, 47)); +pub fn isAltScreen(self: *Self) !bool { + return try self.isModeEnabled(@as(gt.c.GhosttyMode, 1049)) or + try self.isModeEnabled(@as(gt.c.GhosttyMode, 1047)) or + try self.isModeEnabled(@as(gt.c.GhosttyMode, 47)); } /// Get the total number of rows (scrollback + active screen). -pub fn getTotalRows(self: *Self) usize { - var total: usize = 0; - if (gt.c.ghostty_terminal_get(self.terminal, gt.DATA_TOTAL_ROWS, @ptrCast(&total)) != gt.SUCCESS) { - return self.size.rows; - } - return total; +pub fn getTotalRows(self: *Self) !usize { + return gt.terminal_data.get(usize, self.terminal, gt.DATA_TOTAL_ROWS); } /// Get the number of scrollback rows. -pub fn getScrollbackRows(self: *Self) usize { - var scrollback: usize = 0; - if (gt.c.ghostty_terminal_get(self.terminal, gt.DATA_SCROLLBACK_ROWS, @ptrCast(&scrollback)) != gt.SUCCESS) { - return self.size.rows; - } - return scrollback; +pub fn getScrollbackRows(self: *Self) !usize { + return gt.terminal_data.get(usize, self.terminal, gt.DATA_SCROLLBACK_ROWS); } /// Get the scrollbar state (total, offset, len). -pub fn getScrollbar(self: *Self) ?gt.TerminalScrollbar { - var sb: gt.TerminalScrollbar = undefined; - if (gt.c.ghostty_terminal_get(self.terminal, gt.DATA_SCROLLBAR, @ptrCast(&sb)) != gt.SUCCESS) { - return null; - } - return sb; +pub fn getScrollbar(self: *Self) !gt.TerminalScrollbar { + return gt.terminal_data.get(gt.TerminalScrollbar, self.terminal, gt.DATA_SCROLLBAR); } /// Emacs finalizer — called when the user-ptr is garbage collected. From 1c4cd8f3035e118a587da01c9658e488c9682dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Fri, 1 May 2026 23:00:51 +0200 Subject: [PATCH 4/4] Add some agent instructions --- src/AGENTS.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/CLAUDE.md | 1 + 2 files changed, 75 insertions(+) create mode 100644 src/AGENTS.md create mode 120000 src/CLAUDE.md diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 00000000..98396a8f --- /dev/null +++ b/src/AGENTS.md @@ -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))` diff --git a/src/CLAUDE.md b/src/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/src/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file