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.
This commit is contained in:
Mitchell Hashimoto
2026-07-03 21:25:42 -07:00
parent d560c64548
commit 3a2e28329c
4 changed files with 144 additions and 7 deletions

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