diff --git a/src/Surface.zig b/src/Surface.zig index 588d52968..fbb4d9119 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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(); } diff --git a/src/config/Config.zig b/src/config/Config.zig index bb86b6bd5..d409fb6fa 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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, diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index bfa92f31d..cddda9871 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -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 diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 19c7b3375..83417429e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -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); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 81c21d3b0..248a2c512 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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 = {} }, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 9d75fe4b7..2332866ac 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -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 diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 76deebcec..f5e6c8601 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -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(); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index dee58dc22..55621854c 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -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. diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 6aa5e1c26..ce4c1f4af 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -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); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index bc3edd185..8c1b5b8ab 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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),