core: PageList tracks minimum metadata for rendering a scrollbar (#9225)

Related to #111

This adds the necessary logic and data for the `PageList` data structure
to keep track of **total length** of the screen, **offset** into the
viewport, and **length** of the viewport. These three values are
necessary to _render_ a scrollbar. This PR updates the renderer to grab
this information but stops short of actually drawing a scrollbar (which
we'll do with native UI), in the interest of having a PR that doesn't
contain too many changes.

**This doesn't yet draw a scrollbar, these are just the internal changes
necessary to support it.**

## Background

The `PageList` structure is very core to how we represent terminal
state. It maintains a doubly linked list of "pages" (not literally
virtual memory pages, but close). Each page stores cell information,
styles, hyperlinks, etc fully self-contained in a contiguous sets of VM
pages using offset addresses rather than full pointers. **Pages are not
guaranteed to be equal sizes.** (This is where scrollbars get difficult)

Because it is a linked list structure of non-equal sized nodes, it isn't
amenable to typical scrollbar behavior. A scrollbar needs to know: full
size, offset, and length in order to draw the scrollbar properly.
Getting these values naively is `O(N)` within the data structure that is
on the hottest IO performance path in all of Ghostty.

## Implementation

### PageList

We now maintain two cached values for **total length** and **viewport
offset**.

The total length is relatively straightforward, we just have to be
careful to update it in every operation that could add or remove rows.
I've done this and ensured that every place we update it is covered with
unit test coverage.

The viewport offset is nasty, but I came up with what I believe is a
good solution. The viewport when arbitrarily scrolled is defined as a
direct pointer to the linked list node plus a row offset into that node.
The only way to calculate offset from the top is `O(N)`.

But we have a couple shortcuts:

1. If the viewport is at the bottom (most common) or top, calculating
the offset is `O(1)`: bottom is `total_rows - active_rows`, both readily
available. And top is `0` by definition.

2. Operations on the PageList typically add or remove rows. We don't do
arbitrary linked list surgery. If we instrument those areas with delta
updates to our cache, we can avoid the `O(N)` cost for most operations,
including scrolling a scrollbar. The only expensive operation is a full,
arbitrary jump (new node pointer).

Point 1 was quick to implement, so I focused all the complexity on point
2. Whenever we have an operation that adds or removes rows (for example
pruning the scroll back, adding more, erase rows within the active area,
etc.) then I do the math to calculate the delta change required for the
offset if we've already calculated it, and apply that directly.

### Renderer

The other issue was how to notify the apprts of scrollbar state. Sending
messages on any terminal change within the IO thread is a non-option
because (1) sending messages is slow (2) the terminal changes a lot and
(3) any slowness in the IO thread slows down overall terminal
throughput.

The solution was to **trigger scrollbar notifications with the renderer
vsync**. We read the scrollbar information when we render a frame,
compare it to renderer previous state, and if the scrollbar changed,
send a message to the apprt _after the frame is GPU-renderer_.

The renderer spends _most_ of its time sleeping compared to the IO
thread, and has more opportunities for optimizing its awake time.
Additionally, there's no reason to update the scrollbar information if
the renderer hasn't rendered the new frames because the user can't even
see the stuff the scrollbar wants to scroll to. We're talking about
millisecond scale stuff here at worst but it adds up.

## Performance

No noticeable performance impact for the additional metrics:

<img width="1012" height="738" alt="image"
src="https://github.com/user-attachments/assets/4ed0a3e8-6d76-40c1-b249-e34041c2f6fd"
/>

## AI Usage

I used Amp to help audit the codebase and write tests. I wrote all the
main implementation code manually. I came up with the main design
myself. Relevant threads:

-
https://ampcode.com/threads/T-95fff686-75bb-4553-a2fb-e41fe4cd4b77#message-0-block-0
-
https://ampcode.com/threads/T-48e9a288-b280-4eec-83b7-ca73d029b4ef#message-91-block-0

## Future

This is just the internal changes necessary to _draw_ a scrollbar. There
will be other changes we'll need to add to handle grabbing and actually
jumping the scrollbar. I have a good idea of how to implement those
performantly as well.
pull/9234/head
Mitchell Hashimoto 2025-10-15 19:42:49 -07:00 committed by GitHub
parent 014a2e0042
commit e1b527fb9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 1155 additions and 71 deletions

View File

@ -114,6 +114,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// True if the window is focused
focused: bool,
/// The most recent scrollbar state. We use this as a cache to
/// determine if we need to notify the apprt that there was a
/// scrollbar change.
scrollbar: terminal.Scrollbar,
scrollbar_dirty: bool,
/// The foreground color set by an OSC 10 sequence. If unset then
/// default_foreground_color is used.
foreground_color: ?terminal.color.RGB,
@ -683,6 +689,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.grid_metrics = font_critical.metrics,
.size = options.size,
.focused = true,
.scrollbar = .zero,
.scrollbar_dirty = false,
.foreground_color = null,
.default_foreground_color = options.config.foreground,
.background_color = null,
@ -1087,6 +1095,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
preedit: ?renderer.State.Preedit,
cursor_style: ?renderer.CursorStyle,
color_palette: terminal.color.Palette,
scrollbar: terminal.Scrollbar,
/// If true, rebuild the full screen.
full_rebuild: bool,
@ -1111,6 +1120,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
return;
}
// Get our scrollbar out of the terminal. We synchronize
// the scrollbar read with frame data updates because this
// naturally limits the number of calls to this method (it
// can be expensive) and also makes it so we don't need another
// cross-thread mailbox message within the IO path.
const scrollbar = state.terminal.screen.pages.scrollbar();
// Swap bg/fg if the terminal is reversed
const bg = self.background_color orelse self.default_background_color;
const fg = self.foreground_color orelse self.default_foreground_color;
@ -1238,6 +1254,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.preedit = preedit,
.cursor_style = cursor_style,
.color_palette = state.terminal.color_palette.colors,
.scrollbar = scrollbar,
.full_rebuild = full_rebuild,
};
};
@ -1266,6 +1283,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.draw_mutex.lock();
defer self.draw_mutex.unlock();
// The scrollbar is only emitted during draws so we also
// check the scrollbar cache here and update if needed.
// This is pretty fast.
if (!self.scrollbar.eql(critical.scrollbar)) {
self.scrollbar = critical.scrollbar;
self.scrollbar_dirty = true;
}
// Update our background color
self.uniforms.bg_color = .{
critical.bg.r,

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@ pub const Pin = PageList.Pin;
pub const Point = point.Point;
pub const Screen = @import("Screen.zig");
pub const ScreenType = Terminal.ScreenType;
pub const Scrollbar = PageList.Scrollbar;
pub const Selection = @import("Selection.zig");
pub const SizeReportStyle = csi.SizeReportStyle;
pub const StringMap = @import("StringMap.zig");