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
parent
014a2e0042
commit
e1b527fb9a
|
|
@ -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
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue