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