lib-vt: add scroll-to-row viewport scrolling (#13179)

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.
This commit is contained in:
Mitchell Hashimoto
2026-07-03 21:29:38 -07:00
committed by GitHub
4 changed files with 144 additions and 7 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 },
});
}

View File

@@ -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" {