diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index e59f4c4b8..70f98f8a5 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -53,6 +53,45 @@ typedef struct { // future options. } GhosttyTerminalOptions; +/** + * Scroll viewport behavior tag. + * + * @ingroup terminal + */ +typedef enum { + /** Scroll to the top of the scrollback. */ + GHOSTTY_SCROLL_VIEWPORT_TOP, + + /** Scroll to the bottom (active area). */ + GHOSTTY_SCROLL_VIEWPORT_BOTTOM, + + /** Scroll by a delta amount (up is negative). */ + GHOSTTY_SCROLL_VIEWPORT_DELTA, +} GhosttyTerminalScrollViewportTag; + +/** + * Scroll viewport value. + * + * @ingroup terminal + */ +typedef union { + /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ + intptr_t delta; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyTerminalScrollViewportValue; + +/** + * Tagged union for scroll viewport behavior. + * + * @ingroup terminal + */ +typedef struct { + GhosttyTerminalScrollViewportTag tag; + GhosttyTerminalScrollViewportValue value; +} GhosttyTerminalScrollViewport; + /** * Create a new terminal instance. * @@ -102,8 +141,24 @@ void ghostty_terminal_free(GhosttyTerminal terminal); * @ingroup terminal */ void ghostty_terminal_vt_write(GhosttyTerminal terminal, - const uint8_t* data, - size_t len); + const uint8_t* data, + size_t len); + +/** + * Scroll the terminal viewport. + * + * Scrolls the terminal's viewport according to the given behavior. + * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in + * the value union to specify the number of rows to scroll (negative + * for up, positive for down). For other behaviors, the value is ignored. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param behavior The scroll behavior as a tagged union + * + * @ingroup terminal + */ +void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, + GhosttyTerminalScrollViewport behavior); /** @} */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 516c9f882..a4998f1b8 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -146,6 +146,7 @@ comptime { @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); + @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ec08ec72c..1ea915c67 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5,6 +5,7 @@ const Terminal = @This(); const std = @import("std"); const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; @@ -35,6 +36,8 @@ const Page = pagepkg.Page; const Cell = pagepkg.Cell; const Row = pagepkg.Row; +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + const log = std.log.scoped(.terminal); /// Default tabstop interval @@ -1704,7 +1707,7 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { } /// Options for scrolling the viewport of the terminal grid. -pub const ScrollViewport = union(enum) { +pub const ScrollViewport = union(Tag) { /// Scroll to the top of the scrollback top, @@ -1713,6 +1716,23 @@ pub const ScrollViewport = union(enum) { /// Scroll by some delta amount, up is negative. delta: isize, + + pub const Tag = lib.Enum(lib_target, &.{ + "top", + "bottom", + "delta", + }); + + const c_union = lib.TaggedUnion( + lib_target, + @This(), + // Padding: largest variant is isize (8 bytes on 64-bit). + // Use [2]u64 (16 bytes) for future expansion. + [2]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; }; /// Scroll the viewport of the terminal grid. diff --git a/src/terminal/c/AGENTS.md b/src/terminal/c/AGENTS.md new file mode 100644 index 000000000..fa922c6ba --- /dev/null +++ b/src/terminal/c/AGENTS.md @@ -0,0 +1,10 @@ +# libghostty-vt C API + +- C API must be designed with ABI compatibility in mind +- Zig tagged unions must be converted to C ABI compatible unions + via `lib.TaggedUnion`. +- Any functions must be updated all the way through from here to + `src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers + in `include/ghostty/vt.h`. +- In `include/ghostty/vt.h`, always sort the header contents by: + (1) macros, (2) forward declarations, (3) types, (4) functions diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 6d908ed6b..be8da379e 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -56,6 +56,7 @@ pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_vt_write = terminal.vt_write; +pub const terminal_scroll_viewport = terminal.scroll_viewport; test { _ = color; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 4b64c7a80..cf491597b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -67,6 +67,21 @@ pub fn vt_write( stream.nextSlice(ptr[0..len]); } +/// C: GhosttyTerminalScrollViewport +pub const ScrollViewport = ZigTerminal.ScrollViewport.C; + +pub fn scroll_viewport( + terminal_: Terminal, + behavior: ScrollViewport, +) callconv(.c) void { + const t = terminal_ orelse return; + t.scrollViewport(switch (behavior.tag) { + .top => .top, + .bottom => .bottom, + .delta => .{ .delta = behavior.value.delta }, + }); +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -121,6 +136,62 @@ test "free null" { free(null); } +test "scroll_viewport" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + const zt = t.?; + + // Write "hello" on the first line + vt_write(t, "hello", 5); + + // Push "hello" into scrollback with 3 newlines (index = ESC D) + vt_write(t, "\x1bD\x1bD\x1bD", 6); + { + // Viewport should be empty now since hello scrolled off + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll to top: "hello" should be visible again + scroll_viewport(t, .{ .tag = .top, .value = undefined }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } + + // Scroll to bottom: viewport should be empty again + scroll_viewport(t, .{ .tag = .bottom, .value = undefined }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll up by delta to bring "hello" back into view + scroll_viewport(t, .{ .tag = .delta, .value = .{ .delta = -3 } }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } +} + +test "scroll_viewport null" { + scroll_viewport(null, .{ .tag = .top, .value = undefined }); +} + test "vt_write" { var t: Terminal = null; try testing.expectEqual(Result.success, new(