From da7736cd443f60649b54e87adc9c20c0ec89c13e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 21:03:25 -0700 Subject: [PATCH 1/6] renderer: emit scrollbar apprt event when scrollbar changes --- include/ghostty.h | 9 +++++++++ src/Surface.zig | 13 +++++++++++++ src/apprt/action.zig | 4 ++++ src/apprt/surface.zig | 3 +++ src/renderer/generic.zig | 9 +++++++++ src/terminal/PageList.zig | 15 +++++++++++++++ 6 files changed, 53 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 48836ee96..acb6988d6 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -741,6 +741,13 @@ typedef struct { uint64_t duration; } ghostty_action_command_finished_s; +// terminal.Scrollbar +typedef struct { + uint64_t total; + uint64_t offset; + uint64_t len; +} ghostty_action_scrollbar_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_QUIT, @@ -767,6 +774,7 @@ typedef enum { GHOSTTY_ACTION_RESET_WINDOW_SIZE, GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_CELL_SIZE, + GHOSTTY_ACTION_SCROLLBAR, GHOSTTY_ACTION_RENDER, GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, @@ -809,6 +817,7 @@ typedef union { ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; ghostty_action_cell_size_s cell_size; + ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; diff --git a/src/Surface.zig b/src/Surface.zig index 456acad2c..a9052896f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -983,6 +983,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .renderer_health => |health| self.updateRendererHealth(health), + .scrollbar => |scrollbar| self.updateScrollbar(scrollbar), + .report_color_scheme => |force| self.reportColorScheme(force), .present_surface => try self.presentSurface(), @@ -1459,6 +1461,17 @@ fn updateRendererHealth(self: *Surface, health: rendererpkg.Health) void { }; } +/// Called when the scrollbar state changes. +fn updateScrollbar(self: *Surface, scrollbar: terminal.Scrollbar) void { + _ = self.rt_app.performAction( + .{ .surface = self }, + .scrollbar, + scrollbar, + ) catch |err| { + log.warn("failed to notify app of scrollbar change err={}", .{err}); + }; +} + /// This should be called anytime `config_conditional_state` changes /// so that the apprt can reload the configuration. fn notifyConfigConditionalState(self: *Surface) void { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 14a8165f2..e593d4bce 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -164,6 +164,9 @@ pub const Action = union(Key) { /// The cell size has changed to the given dimensions in pixels. cell_size: CellSize, + /// The scrollbar is updating. + scrollbar: terminal.Scrollbar, + /// The target should be re-rendered. This usually has a specific /// surface target but if the app is targeted then all active /// surfaces should be redrawn. @@ -324,6 +327,7 @@ pub const Action = union(Key) { reset_window_size, initial_size, cell_size, + scrollbar, render, inspector, show_gtk_inspector, diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index a46732c16..b71bf1e6e 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -104,6 +104,9 @@ pub const Message = union(enum) { /// of the command. stop_command: ?u8, + /// The scrollbar state changed for the surface. + scrollbar: terminal.Scrollbar, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 876e3f945..6031bede4 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1314,6 +1314,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); + // After the graphics API is complete (so we defer) we want to + // update our scrollbar state. + defer if (self.scrollbar_dirty) { + self.scrollbar_dirty = false; + _ = self.surface_mailbox.push(.{ + .scrollbar = self.scrollbar, + }, .{ .forever = {} }); + }; + // Let our graphics API do any bookkeeping, etc. // that it needs to do before / after `drawFrame`. self.api.drawFrameStart(); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b71c87faa..058314166 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2110,6 +2110,21 @@ pub const Scrollbar = struct { .len = 0, }; + // Sync with: ghostty_action_scrollbar_s + pub const C = extern struct { + total: u64, + offset: u64, + len: u64, + }; + + pub fn cval(self: Scrollbar) C { + return .{ + .total = @intCast(self.total), + .offset = @intCast(self.offset), + .len = @intCast(self.len), + }; + } + /// Comparison for scrollbars. pub fn eql(self: Scrollbar, other: Scrollbar) bool { return self.total == other.total and From 135136f733bec586cb08cbf3073f83e98e679868 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 08:38:00 -0700 Subject: [PATCH 2/6] terminal: PageList scroll to absolute row function --- src/terminal/PageList.zig | 543 +++++++++++++++++++++++++++++++++++++- src/terminal/Screen.zig | 2 + 2 files changed, 531 insertions(+), 14 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 058314166..3aba29128 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1886,6 +1886,11 @@ pub const Scroll = union(enum) { /// the scrollback history. top, + /// Scroll to the given absolute row from the top. A value of zero + /// is the top row. This row will be the first visible row in the viewport. + /// Scrolling into or below the active area will clamp to the active area. + row: usize, + /// Scroll up (negative) or down (positive) by the given number of /// rows. This is clamped to the "top" and "active" top left. delta_row: isize, @@ -1904,6 +1909,8 @@ pub const Scroll = union(enum) { /// pages, etc. This can only be used to move the viewport within the /// previously allocated pages. pub fn scroll(self: *PageList, behavior: Scroll) void { + defer self.assertIntegrity(); + switch (behavior) { .active => self.viewport = .active, .top => self.viewport = .top, @@ -1920,6 +1927,93 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { self.viewport = .pin; self.viewport_pin_row_offset = null; // invalidate cache }, + .row => |n| row: { + // If we're at the top, pin the top. + if (n == 0) { + self.viewport = .top; + break :row; + } + + // If we're below the top of the active area, pin the active area. + if (n >= self.total_rows - self.rows) { + self.viewport = .active; + break :row; + } + + // See if there are any other faster paths we can take. + switch (self.viewport) { + .top, .active => {}, + .pin => if (self.viewport_pin_row_offset) |*v| { + // If we have a pin and we already calculated a row offset, + // then we can efficiently calculate the delta and move + // that much from that pin. + const delta: isize = delta: { + const n_isize: isize = @intCast(n); + const v_isize: isize = @intCast(v.*); + break :delta n_isize - v_isize; + }; + self.scroll(.{ .delta_row = delta }); + return; + }, + } + + // We have an accurate row offset so store it to prevent + // calculating this again. + self.viewport_pin_row_offset = n; + self.viewport = .pin; + + // Slow path, we've just got to traverse the linked list and + // get to our row. As a slight speedup, let's pick the traversal + // that's likely faster based on our absolute row and total rows. + const midpoint = self.total_rows / 2; + if (n < midpoint) { + // Iterate forward from the first node. + var node_it = self.pages.first; + var rem: size.CellCountInt = std.math.cast( + size.CellCountInt, + n, + ) orelse { + self.viewport = .active; + break :row; + }; + while (node_it) |node| : (node_it = node.next) { + if (rem < node.data.size.rows) { + self.viewport_pin.* = .{ + .node = node, + .y = rem, + }; + break :row; + } + + rem -= node.data.size.rows; + } + } else { + // Iterate backwards from the last node. + var node_it = self.pages.last; + var rem: size.CellCountInt = std.math.cast( + size.CellCountInt, + self.total_rows - n, + ) orelse { + self.viewport = .active; + break :row; + }; + while (node_it) |node| : (node_it = node.prev) { + if (rem <= node.data.size.rows) { + self.viewport_pin.* = .{ + .node = node, + .y = node.data.size.rows - rem, + }; + break :row; + } + + rem -= node.data.size.rows; + } + } + + // If we reached here, then we couldn't find the offset. + // This feels impossible? Just clamp to active, screw it lol. + self.viewport = .active; + }, .delta_prompt => |n| self.scrollPrompt(n), .delta_row => |n| delta_row: { switch (self.viewport) { @@ -5049,6 +5143,427 @@ test "PageList scroll to pin at top" { } } +test "PageList scroll to row 0" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .row = 0 }); + try testing.expect(s.viewport == .top); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row in scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(20); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + s.scroll(.{ .row = 5 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 5, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 5, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row in middle" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + const total = s.total_rows; + const midpoint = total / 2; + s.scroll(.{ .row = midpoint }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = midpoint, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(midpoint)), + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(midpoint)), + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = midpoint, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row at active boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(20); + + const active_start = s.total_rows - s.rows; + + s.scroll(.{ .row = active_start }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(active_start)), + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); + + try s.growRows(10); + + try testing.expect(s.viewport == .active); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row beyond active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .row = 1000 }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row without scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + s.scroll(.{ .row = 5 }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row then delta" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(30); + + s.scroll(.{ .row = 10 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 10, + .len = s.rows, + }, s.scrollbar()); + + s.scroll(.{ .delta_row = 5 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); + + s.scroll(.{ .delta_row = -3 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 12, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row with cache fast path down" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + s.scroll(.{ .row = 10 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 10, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + // Verify cache is populated + try testing.expect(s.viewport_pin_row_offset != null); + try testing.expectEqual(@as(usize, 10), s.viewport_pin_row_offset.?); + + // Now scroll to a different row - this should use the fast path + s.scroll(.{ .row = 20 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 20, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 20, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row with cache fast path up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + s.scroll(.{ .row = 30 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 30, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 30, + } }, pt); + } + + // Verify cache is populated + try testing.expect(s.viewport_pin_row_offset != null); + try testing.expectEqual(@as(usize, 30), s.viewport_pin_row_offset.?); + + // Now scroll up to a different row - this should use the fast path + s.scroll(.{ .row = 15 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); +} + test "PageList scroll clear" { const testing = std.testing; const alloc = testing.allocator; @@ -5104,7 +5619,7 @@ test "PageList: jump zero prompts" { try testing.expect(s.viewport == .active); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = s.total_rows - s.rows, .len = s.rows, }, s.scrollbar()); @@ -5138,7 +5653,7 @@ test "Screen: jump back one prompt" { } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = 1, .len = s.rows, }, s.scrollbar()); @@ -5152,7 +5667,7 @@ test "Screen: jump back one prompt" { } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = 1, .len = s.rows, }, s.scrollbar()); @@ -5163,7 +5678,7 @@ test "Screen: jump back one prompt" { s.scroll(.{ .delta_prompt = 1 }); try testing.expect(s.viewport == .active); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = s.total_rows - s.rows, .len = s.rows, }, s.scrollbar()); @@ -5172,7 +5687,7 @@ test "Screen: jump back one prompt" { s.scroll(.{ .delta_prompt = 1 }); try testing.expect(s.viewport == .active); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = s.total_rows - s.rows, .len = s.rows, }, s.scrollbar()); @@ -6042,11 +6557,11 @@ test "PageList erase" { try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large - try testing.expect(s.totalRows() > s.rows); + try testing.expect(s.total_rows > s.rows); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // We should be back to just one page try testing.expectEqual(@as(usize, 1), s.totalPages()); @@ -6101,7 +6616,7 @@ test "PageList erase row with tracked pin resets to top-left" { cur_page.data.pauseIntegrityChecks(false); // Our total rows should be large - try testing.expect(s.totalRows() > s.rows); + try testing.expect(s.total_rows > s.rows); // Put a tracked pin in the history const p = try s.trackPin(s.pin(.{ .history = .{} }).?); @@ -6109,7 +6624,7 @@ test "PageList erase row with tracked pin resets to top-left" { // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -6130,7 +6645,7 @@ test "PageList erase row with tracked pin shifts" { // Erase only a few rows in our active s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -6151,7 +6666,7 @@ test "PageList erase row with tracked pin is erased" { // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -6180,7 +6695,7 @@ test "PageList erase resets viewport to active if moves within active" { cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top - s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); try testing.expect(s.viewport == .top); // Erase the entire history, we should be back to just our active set. @@ -6209,7 +6724,7 @@ test "PageList erase resets viewport if inside erased page but not active" { cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top - s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); try testing.expect(s.viewport == .top); // Erase the entire history, we should be back to just our active set. @@ -6275,7 +6790,7 @@ test "PageList erase a one-row active" { } s.eraseRows(.{ .active = .{} }, .{ .active = .{} }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // The row should be empty { diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 228b87922..81d6d4ab6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1155,6 +1155,7 @@ pub const Scroll = union(enum) { active, top, pin: Pin, + row: usize, delta_row: isize, delta_prompt: isize, }; @@ -1174,6 +1175,7 @@ pub inline fn scroll(self: *Screen, behavior: Scroll) void { .active => self.pages.scroll(.{ .active = {} }), .top => self.pages.scroll(.{ .top = {} }), .pin => |p| self.pages.scroll(.{ .pin = p }), + .row => |v| self.pages.scroll(.{ .row = v }), .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), .delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }), } From c86266cd906d3ac9972012699925e9ee395203aa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 08:22:52 -0700 Subject: [PATCH 3/6] input: scroll_to_row action --- src/Surface.zig | 25 ++++++++++++++++++++----- src/input/Binding.zig | 5 +++++ src/input/command.zig | 1 + 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index a9052896f..e75c4b409 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4827,12 +4827,27 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .unlocked); }, + .scroll_to_row => |n| { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + t.screen.scroll(.{ .row = n }); + } + + try self.queueRender(); + }, + .scroll_to_selection => { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const sel = self.io.terminal.screen.selection orelse return false; - const tl = sel.topLeft(&self.io.terminal.screen); - self.io.terminal.screen.scroll(.{ .pin = tl }); + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screen.selection orelse return false; + const tl = sel.topLeft(&self.io.terminal.screen); + self.io.terminal.screen.scroll(.{ .pin = tl }); + } + + try self.queueRender(); }, .scroll_page_up => { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c44fb0b09..ad07dce55 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -347,6 +347,10 @@ pub const Action = union(enum) { /// Scroll to the selected text. scroll_to_selection, + /// Scroll to the given absolute row in the screen with 0 being + /// the first row. + scroll_to_row: usize, + /// Scroll the screen up by one page. scroll_page_up, @@ -1077,6 +1081,7 @@ pub const Action = union(enum) { .scroll_to_top, .scroll_to_bottom, .scroll_to_selection, + .scroll_to_row, .scroll_page_up, .scroll_page_down, .scroll_page_fractional, diff --git a/src/input/command.zig b/src/input/command.zig index ba55820fc..29c10527e 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -487,6 +487,7 @@ fn actionCommands(action: Action.Key) []const Command { .esc, .cursor_key, .set_font_size, + .scroll_to_row, .scroll_page_fractional, .scroll_page_lines, .adjust_selection, From 2937aff513f81f97ca2635735d6346a21329df37 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 09:18:55 -0700 Subject: [PATCH 4/6] gtk: mark scrollbar as unimplemented --- src/apprt/gtk/class/application.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index d75a0ef7f..d0125d1eb 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -728,6 +728,7 @@ pub const Application = extern struct { .command_finished => return Action.commandFinished(target, value), // Unimplemented + .scrollbar, .secure_input, .close_all_windows, .float_window, From 7207ff08d586d96b1d95872fe397986cd84fe977 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 20:45:30 -0700 Subject: [PATCH 5/6] macos: SurfaceScrollView --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + macos/Sources/Ghostty/Ghostty.Action.swift | 12 + macos/Sources/Ghostty/Ghostty.App.swift | 30 +++ macos/Sources/Ghostty/Package.swift | 4 + macos/Sources/Ghostty/SurfaceScrollView.swift | 229 ++++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 22 +- 6 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 macos/Sources/Ghostty/SurfaceScrollView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 388122f62..ae0051c53 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -141,6 +141,7 @@ Ghostty/Ghostty.Surface.swift, Ghostty/InspectorView.swift, "Ghostty/NSEvent+Extension.swift", + Ghostty/SurfaceScrollView.swift, Ghostty/SurfaceView_AppKit.swift, Helpers/AppInfo.swift, Helpers/CodableBridge.swift, diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 37b1a362d..4921ef8df 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -100,6 +100,18 @@ extension Ghostty.Action { let state: State let progress: UInt8? } + + struct Scrollbar { + let total: UInt64 + let offset: UInt64 + let len: UInt64 + + init(c: ghostty_action_scrollbar_s) { + total = c.total + offset = c.offset + len = c.len + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index bf34b4a91..91829f95c 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -571,6 +571,9 @@ extension Ghostty { case GHOSTTY_ACTION_REDO: return redo(app, target: target) + case GHOSTTY_ACTION_SCROLLBAR: + scrollbar(app, target: target, v: action.action.scrollbar) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -1560,6 +1563,33 @@ extension Ghostty { } } + private static func scrollbar( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_scrollbar_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("scrollbar does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let scrollbar = Ghostty.Action.Scrollbar(c: v) + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.ScrollbarKey: scrollbar + ] + ) + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 85040d390..e8a3d0976 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -344,6 +344,10 @@ extension Notification.Name { /// Toggle maximize of current window static let ghosttyMaximizeDidToggle = Notification.Name("com.mitchellh.ghostty.maximizeDidToggle") + + /// Notification sent when scrollbar updates + static let ghosttyDidUpdateScrollbar = Notification.Name("com.mitchellh.ghostty.didUpdateScrollbar") + static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar" } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift new file mode 100644 index 000000000..642d728d9 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -0,0 +1,229 @@ +import SwiftUI + +/// Wraps a Ghostty surface view in an NSScrollView to provide native macOS scrollbar support. +/// +/// ## Coordinate System +/// AppKit uses a +Y-up coordinate system (origin at bottom-left), while terminals conceptually +/// use +Y-down (row 0 at top). This class handles the inversion when converting between row +/// offsets and pixel positions. +/// +/// ## Architecture +/// - `scrollView`: The outermost NSScrollView that manages scrollbar rendering and behavior +/// - `documentView`: A blank NSView whose height represents total scrollback (in pixels) +/// - `surfaceView`: The actual Ghostty renderer, positioned to fill the visible rect +class SurfaceScrollView: NSView { + private let scrollView: NSScrollView + private let documentView: NSView + private let surfaceView: Ghostty.SurfaceView + private var observers: [NSObjectProtocol] = [] + private var isLiveScrolling = false + + /// The last row position sent via scroll_to_row action. Used to avoid + /// sending redundant actions when the user drags the scrollbar but stays + /// on the same row. + private var lastSentRow: Int? + + init(contentSize: CGSize, surfaceView: Ghostty.SurfaceView) { + self.surfaceView = surfaceView + // The scroll view is our outermost view that controls all our scrollbar + // rendering and behavior. + scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.usesPredominantAxisScrolling = true + + // The document view is what the scrollview is actually going + // to be directly scrolling. We set it up to a "blank" NSView + // with the desired content size. + documentView = NSView(frame: NSRect(origin: .zero, size: contentSize)) + scrollView.documentView = documentView + + // The document view contains our actual surface as a child. + // We synchronize the scrolling of the document with this surface + // so that our primary Ghostty renderer only needs to render the viewport. + documentView.addSubview(surfaceView) + + super.init(frame: .zero) + + // Our scroll view is our only view + addSubview(scrollView) + + // We listen for scroll events through bounds notifications on our NSClipView. + // This is based on: https://christiantietze.de/posts/2018/07/synchronize-nsscrollview/ + scrollView.contentView.postsBoundsChangedNotifications = true + observers.append(NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] notification in + self?.handleScrollChange(notification) + }) + + // Listen for scrollbar updates from Ghostty + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidUpdateScrollbar, + object: surfaceView, + queue: .main + ) { [weak self] notification in + self?.handleScrollbarUpdate(notification) + }) + + // Listen for live scroll events + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.willStartLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.isLiveScrolling = true + }) + + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.didEndLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.isLiveScrolling = false + }) + + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.didLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.handleLiveScroll() + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + + // Force layout to be called to fix up our various subviews. + needsLayout = true + } + + override func layout() { + super.layout() + + // Fill entire bounds with scroll view + scrollView.frame = bounds + + // Use contentSize to account for visible scrollers + // + // Only update sizes if we have a valid (non-zero) content size. The content size + // can be zero when this is added early to a view, or to an invisible hierarchy. + // Practically, this happened in the quick terminal. + let contentSize = scrollView.contentSize + if contentSize.width > 0 && contentSize.height > 0 { + // Keep document width synchronized with content width + documentView.setFrameSize(CGSize( + width: contentSize.width, + height: documentView.frame.height + )) + + // Inform the actual pty of our size change + surfaceView.sizeDidChange(contentSize) + } + + // When our scrollview changes make sure our surface view is synchronized + synchronizeSurfaceView() + } + + // MARK: Scrolling + + private func synchronizeAppearance() { + let scrollbarConfig = surfaceView.derivedConfig.scrollbar + scrollView.hasVerticalScroller = scrollbarConfig != .never + } + + /// Positions the surface view to fill the currently visible rectangle. + /// + /// This is called whenever the scroll position changes. The surface view (which does the + /// actual terminal rendering) always fills exactly the visible portion of the document view, + /// so the renderer only needs to render what's currently on screen. + private func synchronizeSurfaceView() { + let visibleRect = scrollView.contentView.documentVisibleRect + surfaceView.frame = visibleRect + } + + // MARK: Notifications + + /// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized. + private func handleScrollChange(_ notification: Notification) { + synchronizeSurfaceView() + } + + /// Handles live scroll events (user actively dragging the scrollbar). + /// + /// Converts the current scroll position to a row number and sends a `scroll_to_row` action + /// to the terminal core. Only sends actions when the row changes to avoid IPC spam. + private func handleLiveScroll() { + // If our cell height is currently zero then we avoid a div by zero below + // and just don't scroll (there's no where to scroll anyways). This can + // happen with a tiny terminal. + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return } + + // AppKit views are +Y going up, so we calculate from the bottom + let visibleRect = scrollView.contentView.documentVisibleRect + let documentHeight = documentView.frame.height + let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height + let row = Int(scrollOffset / cellHeight) + + // Only send action if the row changed to avoid action spam + guard row != lastSentRow else { return } + lastSentRow = row + + // Use the keybinding action to scroll. + _ = surfaceView.surfaceModel?.perform(action: "scroll_to_row:\(row)") + } + + /// Handles scrollbar state updates from the terminal core. + /// + /// Updates the document view size to reflect total scrollback and adjusts scroll position + /// to match the terminal's viewport. During live scrolling, updates document size but skips + /// programmatic position changes to avoid fighting the user's drag. + /// + /// ## Scrollbar State + /// The scrollbar struct contains: + /// - `total`: Total rows in scrollback + active area + /// - `offset`: First visible row (0 = top of history) + /// - `len`: Number of visible rows (viewport height) + private func handleScrollbarUpdate(_ notification: Notification) { + guard let scrollbar = notification.userInfo?[SwiftUI.Notification.Name.ScrollbarKey] as? Ghostty.Action.Scrollbar else { + return + } + + // Convert row units to pixels using cell height, ignore zero height. + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return } + + // Our width should be the content width to account for visible scrollers. + // We don't do horizontal scrolling in terminals. + let totalHeight = CGFloat(scrollbar.total) * cellHeight + let newSize = CGSize(width: scrollView.contentSize.width, height: totalHeight) + documentView.setFrameSize(newSize) + + // Only update our actual scroll position if we're not actively scrolling. + if !isLiveScrolling { + // Invert coordinate system: terminal offset is from top, AppKit position from bottom + let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight + scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + + // Track the current row position to avoid redundant movements when we + // move the scrollbar. + lastSentRow = Int(scrollbar.offset) + } + + // Always update our scrolled view with the latest dimensions + scrollView.reflectScrolledClipView(scrollView.contentView) + } +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index aca17c0fc..c650bdf8f 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -386,10 +386,6 @@ extension Ghostty { /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. - /// - /// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible - /// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to - /// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with. struct SurfaceRepresentable: OSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView @@ -404,16 +400,26 @@ extension Ghostty { /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. let size: CGSize + #if canImport(AppKit) + func makeOSView(context: Context) -> SurfaceScrollView { + // On macOS, wrap the surface view in a scroll view + return SurfaceScrollView(contentSize: size, surfaceView: view) + } + + func updateOSView(_ scrollView: SurfaceScrollView, context: Context) { + // Our scrollview always takes up the full size. + scrollView.frame.size = size + } + #else func makeOSView(context: Context) -> SurfaceView { - // We need the view as part of the state to be created previously because - // the view is sent to the Ghostty API so that it can manipulate it - // directly since we draw on a render thread. - return view; + // On iOS, return the surface view directly + return view } func updateOSView(_ view: SurfaceView, context: Context) { view.sizeDidChange(size) } + #endif } /// The configuration for a surface. For any configuration not set, defaults will be chosen from From 4b34b2389a38159de8adf3abdc249829a773e944 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 09:49:03 -0700 Subject: [PATCH 6/6] config: add `scrollbar` config to control when scrollbars appear --- macos/Sources/Ghostty/Ghostty.Config.swift | 16 +++++++++++++ macos/Sources/Ghostty/SurfaceScrollView.swift | 16 ++++++++++++- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ src/config/Config.zig | 24 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 0d75922cb..f380345c7 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -603,6 +603,17 @@ extension Ghostty { let str = String(cString: ptr) return MacShortcuts(rawValue: str) ?? defaultValue } + + var scrollbar: Scrollbar { + let defaultValue = Scrollbar.system + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "scrollbar" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return Scrollbar(rawValue: str) ?? defaultValue + } } } @@ -641,6 +652,11 @@ extension Ghostty.Config { case ask } + enum Scrollbar: String { + case system + case never + } + enum ResizeOverlay : String { case always case never diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 642d728d9..44003e85f 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine /// Wraps a Ghostty surface view in an NSScrollView to provide native macOS scrollbar support. /// @@ -16,6 +17,7 @@ class SurfaceScrollView: NSView { private let documentView: NSView private let surfaceView: Ghostty.SurfaceView private var observers: [NSObjectProtocol] = [] + private var cancellables: Set = [] private var isLiveScrolling = false /// The last row position sent via scroll_to_row action. Used to avoid @@ -28,7 +30,7 @@ class SurfaceScrollView: NSView { // The scroll view is our outermost view that controls all our scrollbar // rendering and behavior. scrollView = NSScrollView() - scrollView.hasVerticalScroller = true + scrollView.hasVerticalScroller = false scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.usesPredominantAxisScrolling = true @@ -49,6 +51,9 @@ class SurfaceScrollView: NSView { // Our scroll view is our only view addSubview(scrollView) + // Apply initial scrollbar settings + synchronizeAppearance() + // We listen for scroll events through bounds notifications on our NSClipView. // This is based on: https://christiantietze.de/posts/2018/07/synchronize-nsscrollview/ scrollView.contentView.postsBoundsChangedNotifications = true @@ -93,6 +98,15 @@ class SurfaceScrollView: NSView { ) { [weak self] _ in self?.handleLiveScroll() }) + + // Listen for derived config changes to update scrollbar settings live + surfaceView.$derivedConfig + .sink { [weak self] _ in + DispatchQueue.main.async { [weak self] in + self?.synchronizeAppearance() + } + } + .store(in: &cancellables) } required init?(coder: NSCoder) { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2b3fd261c..410646f6f 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1532,6 +1532,7 @@ extension Ghostty { let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? + let scrollbar: Ghostty.Config.Scrollbar init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) @@ -1539,6 +1540,7 @@ extension Ghostty { self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil + self.scrollbar = .system } init(_ config: Ghostty.Config) { @@ -1547,6 +1549,7 @@ extension Ghostty { self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) + self.scrollbar = config.scrollbar } } diff --git a/src/config/Config.zig b/src/config/Config.zig index bb10ff439..b3085c4c4 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1197,6 +1197,24 @@ input: RepeatableReadableIO = .{}, /// This can be changed at runtime but will only affect new terminal surfaces. @"scrollback-limit": usize = 10_000_000, // 10MB +/// Control when the scrollbar is shown to scroll the scrollback buffer. +/// +/// The default value is `system`. +/// +/// Valid values: +/// +/// * `system` - Respect the system settings for when to show scrollbars. +/// For example, on macOS, this will respect the "Scrollbar behavior" +/// system setting which by default usually only shows scrollbars while +/// actively scrolling or hovering the gutter. +/// +/// * `never` - Never show a scrollbar. You can still scroll using the mouse, +/// keybind actions, etc. but you will not have a visual UI widget showing +/// a scrollbar. +/// +/// This only applies to macOS currently. GTK doesn't yet support scrollbars. +scrollbar: Scrollbar = .system, + /// Match a regular expression against the terminal text and associate clicking /// it with an action. This can be used to match URLs, file paths, etc. Actions /// can be opening using the system opener (e.g. `open` or `xdg-open`) or @@ -8379,6 +8397,12 @@ pub const WindowPadding = struct { } }; +/// See scrollbar +pub const Scrollbar = enum { + system, + never, +}; + /// See scroll-to-bottom pub const ScrollToBottom = packed struct { keystroke: bool = true,