From 3a2e28329ce4a1fb7d06b9e17402298e9e84aca2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jul 2026 21:25:42 -0700 Subject: [PATCH] lib-vt: add scroll-to-row viewport scrolling This adds a GHOSTTY_SCROLL_VIEWPORT_ROW tag with a `size_t row` member in the value union. The row is an absolute offset from the top of the scrollable area, clamped to the active area, in the same row space as the scrollbar offset so thumb positions round-trip cleanly: ghostty_terminal_scroll_viewport(term, (GhosttyTerminalScrollViewport){ .tag = GHOSTTY_SCROLL_VIEWPORT_ROW, .value = {.row = 42}, }); The tag is appended to the existing enum and the union fits within the reserved padding, so this is ABI compatible. This also corrects the docs on GHOSTTY_TERMINAL_DATA_SCROLLBAR: the getter is amortized O(1) (total is maintained incrementally, the offset is cached), not "expensive". Since there is intentionally no change callback, the docs now bless polling per frame or per write batch and diffing, which is what Ghostty's own renderer does. Motivation: Embedders building native scrollbars can already read scroll state via GHOSTTY_TERMINAL_DATA_SCROLLBAR, but the write side only exposed top/bottom/delta scrolling. Mapping a scrollbar thumb drag to an absolute position required reading the current offset and computing a delta, which is two calls that must be sequenced atomically by the caller. The core already supports absolute positioning and the macOS app uses it for scroller drags via the scroll_to_row keybinding; this exposes the same operation through the libghostty C API. --- include/ghostty/vt/terminal.h | 36 +++++++++++-- src/terminal/PageList.zig | 8 +-- src/terminal/Terminal.zig | 9 ++++ src/terminal/c/terminal.zig | 98 +++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b22e8aedc..b03653129 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -191,6 +191,21 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Scroll by a delta amount (up is negative). */ GHOSTTY_SCROLL_VIEWPORT_DELTA, + + /** + * Scroll to an absolute row offset from the top of the scrollable + * area. Row 0 is the top of the scrollback and the requested row + * becomes the first visible row of the viewport. The value is + * clamped so the viewport never scrolls beyond the top of the + * active area. If the terminal has no scrollback (e.g. the + * alternate screen is active), the viewport always remains on the + * active area. + * + * This is the same row space as the offset field of + * GhosttyTerminalScrollbar, so a scrollbar position obtained from + * GHOSTTY_TERMINAL_DATA_SCROLLBAR round-trips cleanly. + */ + GHOSTTY_SCROLL_VIEWPORT_ROW, GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalScrollViewportTag; @@ -203,6 +218,9 @@ typedef union { /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ intptr_t delta; + /** Absolute row offset (only used with GHOSTTY_SCROLL_VIEWPORT_ROW). */ + size_t row; + /** Padding for ABI compatibility. Do not use. */ uint64_t _padding[2]; } GhosttyTerminalScrollViewportValue; @@ -767,9 +785,16 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Scrollbar state for the terminal viewport. * - * This may be expensive to calculate depending on where the viewport - * is (arbitrary pins are expensive). The caller should take care to only - * call this as needed and not too frequently. + * This is amortized O(1): the total is maintained incrementally as + * the terminal is modified and the viewport offset is cached. The + * first read after the viewport moves to an arbitrary position that + * isn't an absolute row (e.g. scrolling to a selection) may cost + * O(pages) to compute the offset, after which it is cached again. + * + * There is intentionally no change notification for scroll state. + * Callers building scrollbars should poll this once per frame or + * per write batch and diff the result to detect changes; this is + * what Ghostty's own renderer does. * * Output type: GhosttyTerminalScrollbar * */ @@ -1120,7 +1145,10 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, * 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. + * for up, positive for down). When using GHOSTTY_SCROLL_VIEWPORT_ROW, + * set the row field to the absolute row offset from the top of the + * scrollable area (the same row space as the offset field of + * GhosttyTerminalScrollbar). 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 diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c2e50a080..7562286e8 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3012,9 +3012,11 @@ pub const Scrollbar = struct { /// Return the scrollbar state for this PageList. /// -/// This may be expensive to calculate depending on where the viewport -/// is (arbitrary pins are expensive). The caller should take care to only -/// call this as needed and not too frequently. +/// This is amortized O(1): the total is maintained incrementally and +/// the viewport offset is cached. The first call after the viewport +/// moves to an arbitrary pin (e.g. scrolling to a selection) may cost +/// O(pages) to compute the offset, after which it is cached again. +/// See viewportRowOffset for more details. pub fn scrollbar(self: *PageList) Scrollbar { // If we have no scrollback, special case no scrollbar. // We need to do this because the way PageList works is that diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0a5fa0249..30a1c93fd 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1739,10 +1739,18 @@ pub const ScrollViewport = union(Tag) { /// Scroll by some delta amount, up is negative. delta: isize, + /// Scroll to the given absolute row offset from the top of the + /// scrollable area. A value of zero is the top row. The requested + /// row becomes the first visible row of the viewport, clamped so + /// the viewport never scrolls beyond the top of the active area. + /// This is the same row space as PageList.Scrollbar offset. + row: usize, + pub const Tag = lib.Enum(lib.target, &.{ "top", "bottom", "delta", + "row", }); const c_union = lib.TaggedUnion( @@ -1763,6 +1771,7 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { .top => .{ .top = {} }, .bottom => .{ .active = {} }, .delta => |delta| .{ .delta_row = delta }, + .row => |row| .{ .row = row }, }); } diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 0bacbc068..5cad1aaa8 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -543,6 +543,7 @@ pub fn scroll_viewport( .top => .top, .bottom => .bottom, .delta => .{ .delta = behavior.value.delta }, + .row => .{ .row = behavior.value.row }, }); } @@ -997,8 +998,105 @@ test "scroll_viewport" { } } +test "scroll_viewport row" { + 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.?.terminal; + + // Write 4 rows so that rows "1" and "2" are pushed into scrollback: + // total rows is 4, viewport length is 2. + vt_write(t, "1\r\n2\r\n3\r\n4", 10); + + var viewport_active: bool = false; + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + + // Row 0 is the top of the scrollback. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(!viewport_active); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1\n2", str); + } + + // An absolute row within the scrollback becomes the first visible + // row and round-trips through the scrollbar offset. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 1 } }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3", str); + } + var scrollbar_data: TerminalScrollbar = undefined; + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 4), scrollbar_data.total); + try testing.expectEqual(@as(u64, 1), scrollbar_data.offset); + try testing.expectEqual(@as(u64, 2), scrollbar_data.len); + + // A row past the end clamps to the active area. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("3\n4", str); + } + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 2), scrollbar_data.offset); +} + +test "scroll_viewport row alt screen" { + 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); + + // Enter the alternate screen, which has no scrollback. + vt_write(t, "\x1b[?1049h", 8); + var screen: TerminalScreen = undefined; + try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen))); + try testing.expectEqual(TerminalScreen.alternate, screen); + + // Scrolling to any row keeps the viewport on the active area. + var viewport_active: bool = false; + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + + // With no scrollback the scrollbar covers exactly the active area. + var scrollbar_data: TerminalScrollbar = undefined; + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 2), scrollbar_data.total); + try testing.expectEqual(@as(u64, 0), scrollbar_data.offset); + try testing.expectEqual(@as(u64, 2), scrollbar_data.len); +} + test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .top, .value = undefined }); + scroll_viewport(null, .{ .tag = .row, .value = .{ .row = 1 } }); } test "reset" {