feat: implement scroll-to-bottom on output option (#9938)

Closes #8408
This commit is contained in:
Mitchell Hashimoto
2026-02-19 14:15:55 -08:00
committed by GitHub
10 changed files with 46 additions and 13 deletions

View File

@@ -1174,7 +1174,7 @@ fn selectionScrollTick(self: *Surface) !void {
}
// Scroll the viewport as required
try t.scrollViewport(.{ .delta = delta });
t.scrollViewport(.{ .delta = delta });
// Next, trigger our drag behavior
const pin = t.screens.active.pages.pin(.{
@@ -2779,7 +2779,7 @@ pub fn keyCallback(
try self.setSelection(null);
}
if (self.config.scroll_to_bottom.keystroke) try self.io.terminal.scrollViewport(.bottom);
if (self.config.scroll_to_bottom.keystroke) self.io.terminal.scrollViewport(.bottom);
try self.queueRender();
}
@@ -3532,7 +3532,7 @@ pub fn scrollCallback(
// Modify our viewport, this requires a lock since it affects
// rendering. We have to switch signs here because our delta
// is negative down but our viewport is positive down.
try self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 });
self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 });
}
}
@@ -5063,7 +5063,7 @@ pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordin
///
/// Precondition: the render_state mutex must be held.
fn scrollToBottom(self: *Surface) !void {
try self.io.terminal.scrollViewport(.{ .bottom = {} });
self.io.terminal.scrollViewport(.{ .bottom = {} });
try self.queueRender();
}

View File

@@ -906,7 +906,7 @@ palette: Palette = .{},
/// anything but modifiers or keybinds that are processed by Ghostty).
///
/// - `output` If set, scroll the surface to the bottom if there is new data
/// to display. (Currently unimplemented.)
/// to display (e.g., when new lines are printed to the terminal).
///
/// The default is `keystroke, no-output`.
@"scroll-to-bottom": ScrollToBottom = .default,

View File

@@ -143,7 +143,7 @@ test "cursor: always block with preedit" {
// If we're scrolled though, then we don't show the cursor.
for (0..100) |_| try term.index();
try term.scrollViewport(.{ .top = {} });
term.scrollViewport(.{ .top = {} });
try state.update(alloc, &term);
// In any bool state

View File

@@ -125,6 +125,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
scrollbar: terminal.Scrollbar,
scrollbar_dirty: bool,
/// Tracks the last bottom-right pin of the screen to detect new output.
/// When the final line changes (node or y differs), new content was added.
/// Used for scroll-to-bottom on output feature.
last_bottom_node: ?usize,
last_bottom_y: terminal.size.CellCountInt,
/// The most recent viewport matches so that we can render search
/// matches in the visible frame. This is provided asynchronously
/// from the search thread so we have the dirty flag to also note
@@ -563,6 +569,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
colorspace: configpkg.Config.WindowColorspace,
blending: configpkg.Config.AlphaBlending,
background_blur: configpkg.Config.BackgroundBlur,
scroll_to_bottom_on_output: bool,
pub fn init(
alloc_gpa: Allocator,
@@ -636,6 +643,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.colorspace = config.@"window-colorspace",
.blending = config.@"alpha-blending",
.background_blur = config.@"background-blur",
.scroll_to_bottom_on_output = config.@"scroll-to-bottom".output,
.arena = arena,
};
}
@@ -699,6 +707,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.focused = true,
.scrollbar = .zero,
.scrollbar_dirty = false,
.last_bottom_node = null,
.last_bottom_y = 0,
.search_matches = null,
.search_selected_match = null,
.search_matches_dirty = false,
@@ -1166,6 +1176,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
return;
}
// If scroll-to-bottom on output is enabled, check if the final line
// changed by comparing the bottom-right pin. If the node pointer or
// y offset changed, new content was added to the screen.
// Update this BEFORE we update our render state so we can
// draw the new scrolled data immediately.
if (self.config.scroll_to_bottom_on_output) scroll: {
const br = state.terminal.screens.active.pages.getBottomRight(.screen) orelse break :scroll;
// If the pin hasn't changed, then don't scroll.
if (self.last_bottom_node == @intFromPtr(br.node) and
self.last_bottom_y == br.y) break :scroll;
// Update tracked pin state for next frame
self.last_bottom_node = @intFromPtr(br.node);
self.last_bottom_y = br.y;
// Scroll
state.terminal.scrollViewport(.bottom);
}
// Update our terminal state
try self.terminal_state.update(self.alloc, state.terminal);

View File

@@ -1625,7 +1625,7 @@ pub const ScrollViewport = union(enum) {
};
/// Scroll the viewport of the terminal grid.
pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void {
pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void {
self.screens.active.scroll(switch (behavior) {
.top => .{ .top = {} },
.bottom => .{ .active = {} },

View File

@@ -1092,7 +1092,7 @@ test "cursor state out of viewport" {
try testing.expectEqual(1, state.cursor.viewport.?.y);
// Scroll the viewport
try t.scrollViewport(.top);
t.scrollViewport(.top);
try state.update(alloc, &t);
// Set a style on the cursor

View File

@@ -358,7 +358,7 @@ test "history search, no active area" {
try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last);
try s.nextSlice("Buzz\r\nFizz");
try t.scrollViewport(.top);
t.scrollViewport(.top);
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();

View File

@@ -641,10 +641,13 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void {
}
/// Scroll the viewport
pub fn scrollViewport(self: *Termio, scroll: terminalpkg.Terminal.ScrollViewport) !void {
pub fn scrollViewport(
self: *Termio,
scroll: terminalpkg.Terminal.ScrollViewport,
) void {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
try self.terminal.scrollViewport(scroll);
self.terminal.scrollViewport(scroll);
}
/// Jump the viewport to the prompt.

View File

@@ -321,7 +321,7 @@ fn drainMailbox(
.resize => |v| self.handleResize(cb, v),
.size_report => |v| try io.sizeReport(data, v),
.clear_screen => |v| try io.clearScreen(data, v.history),
.scroll_viewport => |v| try io.scrollViewport(v),
.scroll_viewport => |v| io.scrollViewport(v),
.selection_scroll => |v| {
if (v) {
self.startScrollTimer(cb);

View File

@@ -232,7 +232,7 @@ pub const StreamHandler = struct {
.erase_display_below => self.terminal.eraseDisplay(.below, value),
.erase_display_above => self.terminal.eraseDisplay(.above, value),
.erase_display_complete => {
try self.terminal.scrollViewport(.{ .bottom = {} });
self.terminal.scrollViewport(.{ .bottom = {} });
self.terminal.eraseDisplay(.complete, value);
},
.erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value),