macOS: Scrollbars (#9232)

Completes #111 for macOS

This builds on #9225 and adds the native, macOS GUI scrollbars for
terminals.

This doesn't do GTK, but all the groundwork is there to make this easy
on GTK, depending on how scrollviews work there. I have to look into
that still. But in theory all the information and controls we provide
out of core are generic to _any_ scrollbar drawing.

## Demo


https://github.com/user-attachments/assets/85683bf9-1117-4f32-aaec-d926edd68c39

## Details

- The scrollbars respect your macOS system settings on style, color,
visibility.
- A new configuration `scrollbar` controls whether scrollbars are
visible (defaults to `system` allowing the system to choose).
- There is a new keybind action `scroll_to_row:N` that lets you jump to
an absolute row number N. This is implemented efficiently. This is how
grabbing the knob and scrolling works.
pull/9234/head
Mitchell Hashimoto 2025-10-16 14:09:33 -07:00 committed by GitHub
commit 0e0bbfaa61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 958 additions and 27 deletions

View File

@ -741,6 +741,13 @@ typedef struct {
uint64_t duration; uint64_t duration;
} ghostty_action_command_finished_s; } 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 // apprt.Action.Key
typedef enum { typedef enum {
GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_QUIT,
@ -767,6 +774,7 @@ typedef enum {
GHOSTTY_ACTION_RESET_WINDOW_SIZE, GHOSTTY_ACTION_RESET_WINDOW_SIZE,
GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_INITIAL_SIZE,
GHOSTTY_ACTION_CELL_SIZE, GHOSTTY_ACTION_CELL_SIZE,
GHOSTTY_ACTION_SCROLLBAR,
GHOSTTY_ACTION_RENDER, GHOSTTY_ACTION_RENDER,
GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_INSPECTOR,
GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
@ -809,6 +817,7 @@ typedef union {
ghostty_action_size_limit_s size_limit; ghostty_action_size_limit_s size_limit;
ghostty_action_initial_size_s initial_size; ghostty_action_initial_size_s initial_size;
ghostty_action_cell_size_s cell_size; ghostty_action_cell_size_s cell_size;
ghostty_action_scrollbar_s scrollbar;
ghostty_action_inspector_e inspector; ghostty_action_inspector_e inspector;
ghostty_action_desktop_notification_s desktop_notification; ghostty_action_desktop_notification_s desktop_notification;
ghostty_action_set_title_s set_title; ghostty_action_set_title_s set_title;

View File

@ -141,6 +141,7 @@
Ghostty/Ghostty.Surface.swift, Ghostty/Ghostty.Surface.swift,
Ghostty/InspectorView.swift, Ghostty/InspectorView.swift,
"Ghostty/NSEvent+Extension.swift", "Ghostty/NSEvent+Extension.swift",
Ghostty/SurfaceScrollView.swift,
Ghostty/SurfaceView_AppKit.swift, Ghostty/SurfaceView_AppKit.swift,
Helpers/AppInfo.swift, Helpers/AppInfo.swift,
Helpers/CodableBridge.swift, Helpers/CodableBridge.swift,

View File

@ -100,6 +100,18 @@ extension Ghostty.Action {
let state: State let state: State
let progress: UInt8? 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. // Putting the initializer in an extension preserves the automatic one.

View File

@ -571,6 +571,9 @@ extension Ghostty {
case GHOSTTY_ACTION_REDO: case GHOSTTY_ACTION_REDO:
return redo(app, target: target) return redo(app, target: target)
case GHOSTTY_ACTION_SCROLLBAR:
scrollbar(app, target: target, v: action.action.scrollbar)
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
fallthrough fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: 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( private static func configReload(
_ app: ghostty_app_t, _ app: ghostty_app_t,
target: ghostty_target_s, target: ghostty_target_s,

View File

@ -603,6 +603,17 @@ extension Ghostty {
let str = String(cString: ptr) let str = String(cString: ptr)
return MacShortcuts(rawValue: str) ?? defaultValue return MacShortcuts(rawValue: str) ?? defaultValue
} }
var scrollbar: Scrollbar {
let defaultValue = Scrollbar.system
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = 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 case ask
} }
enum Scrollbar: String {
case system
case never
}
enum ResizeOverlay : String { enum ResizeOverlay : String {
case always case always
case never case never

View File

@ -344,6 +344,10 @@ extension Notification.Name {
/// Toggle maximize of current window /// Toggle maximize of current window
static let ghosttyMaximizeDidToggle = Notification.Name("com.mitchellh.ghostty.maximizeDidToggle") 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 // NOTE: I am moving all of these to Notification.Name extensions over time. This

View File

@ -0,0 +1,243 @@
import SwiftUI
import Combine
/// 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 cancellables: Set<AnyCancellable> = []
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 = false
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)
// 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
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()
})
// 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) {
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)
}
}

View File

@ -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 /// 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, /// 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. /// 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 { struct SurfaceRepresentable: OSViewRepresentable {
/// The view to render for the terminal surface. /// The view to render for the terminal surface.
let view: SurfaceView 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. /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size.
let size: CGSize 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 { func makeOSView(context: Context) -> SurfaceView {
// We need the view as part of the state to be created previously because // On iOS, return the surface view directly
// the view is sent to the Ghostty API so that it can manipulate it return view
// directly since we draw on a render thread.
return view;
} }
func updateOSView(_ view: SurfaceView, context: Context) { func updateOSView(_ view: SurfaceView, context: Context) {
view.sizeDidChange(size) view.sizeDidChange(size)
} }
#endif
} }
/// The configuration for a surface. For any configuration not set, defaults will be chosen from /// The configuration for a surface. For any configuration not set, defaults will be chosen from

View File

@ -1532,6 +1532,7 @@ extension Ghostty {
let macosWindowShadow: Bool let macosWindowShadow: Bool
let windowTitleFontFamily: String? let windowTitleFontFamily: String?
let windowAppearance: NSAppearance? let windowAppearance: NSAppearance?
let scrollbar: Ghostty.Config.Scrollbar
init() { init() {
self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundColor = Color(NSColor.windowBackgroundColor)
@ -1539,6 +1540,7 @@ extension Ghostty {
self.macosWindowShadow = true self.macosWindowShadow = true
self.windowTitleFontFamily = nil self.windowTitleFontFamily = nil
self.windowAppearance = nil self.windowAppearance = nil
self.scrollbar = .system
} }
init(_ config: Ghostty.Config) { init(_ config: Ghostty.Config) {
@ -1547,6 +1549,7 @@ extension Ghostty {
self.macosWindowShadow = config.macosWindowShadow self.macosWindowShadow = config.macosWindowShadow
self.windowTitleFontFamily = config.windowTitleFontFamily self.windowTitleFontFamily = config.windowTitleFontFamily
self.windowAppearance = .init(ghosttyConfig: config) self.windowAppearance = .init(ghosttyConfig: config)
self.scrollbar = config.scrollbar
} }
} }

View File

@ -983,6 +983,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.renderer_health => |health| self.updateRendererHealth(health), .renderer_health => |health| self.updateRendererHealth(health),
.scrollbar => |scrollbar| self.updateScrollbar(scrollbar),
.report_color_scheme => |force| self.reportColorScheme(force), .report_color_scheme => |force| self.reportColorScheme(force),
.present_surface => try self.presentSurface(), .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 /// This should be called anytime `config_conditional_state` changes
/// so that the apprt can reload the configuration. /// so that the apprt can reload the configuration.
fn notifyConfigConditionalState(self: *Surface) void { fn notifyConfigConditionalState(self: *Surface) void {
@ -4814,12 +4827,27 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
}, .unlocked); }, .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 => { .scroll_to_selection => {
self.renderer_state.mutex.lock(); {
defer self.renderer_state.mutex.unlock(); self.renderer_state.mutex.lock();
const sel = self.io.terminal.screen.selection orelse return false; defer self.renderer_state.mutex.unlock();
const tl = sel.topLeft(&self.io.terminal.screen); const sel = self.io.terminal.screen.selection orelse return false;
self.io.terminal.screen.scroll(.{ .pin = tl }); const tl = sel.topLeft(&self.io.terminal.screen);
self.io.terminal.screen.scroll(.{ .pin = tl });
}
try self.queueRender();
}, },
.scroll_page_up => { .scroll_page_up => {

View File

@ -164,6 +164,9 @@ pub const Action = union(Key) {
/// The cell size has changed to the given dimensions in pixels. /// The cell size has changed to the given dimensions in pixels.
cell_size: CellSize, cell_size: CellSize,
/// The scrollbar is updating.
scrollbar: terminal.Scrollbar,
/// The target should be re-rendered. This usually has a specific /// The target should be re-rendered. This usually has a specific
/// surface target but if the app is targeted then all active /// surface target but if the app is targeted then all active
/// surfaces should be redrawn. /// surfaces should be redrawn.
@ -324,6 +327,7 @@ pub const Action = union(Key) {
reset_window_size, reset_window_size,
initial_size, initial_size,
cell_size, cell_size,
scrollbar,
render, render,
inspector, inspector,
show_gtk_inspector, show_gtk_inspector,

View File

@ -728,6 +728,7 @@ pub const Application = extern struct {
.command_finished => return Action.commandFinished(target, value), .command_finished => return Action.commandFinished(target, value),
// Unimplemented // Unimplemented
.scrollbar,
.secure_input, .secure_input,
.close_all_windows, .close_all_windows,
.float_window, .float_window,

View File

@ -104,6 +104,9 @@ pub const Message = union(enum) {
/// of the command. /// of the command.
stop_command: ?u8, stop_command: ?u8,
/// The scrollbar state changed for the surface.
scrollbar: terminal.Scrollbar,
pub const ReportTitleStyle = enum { pub const ReportTitleStyle = enum {
csi_21_t, csi_21_t,

View File

@ -1197,6 +1197,24 @@ input: RepeatableReadableIO = .{},
/// This can be changed at runtime but will only affect new terminal surfaces. /// This can be changed at runtime but will only affect new terminal surfaces.
@"scrollback-limit": usize = 10_000_000, // 10MB @"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 /// 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 /// 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 /// 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 /// See scroll-to-bottom
pub const ScrollToBottom = packed struct { pub const ScrollToBottom = packed struct {
keystroke: bool = true, keystroke: bool = true,

View File

@ -347,6 +347,10 @@ pub const Action = union(enum) {
/// Scroll to the selected text. /// Scroll to the selected text.
scroll_to_selection, 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 the screen up by one page.
scroll_page_up, scroll_page_up,
@ -1077,6 +1081,7 @@ pub const Action = union(enum) {
.scroll_to_top, .scroll_to_top,
.scroll_to_bottom, .scroll_to_bottom,
.scroll_to_selection, .scroll_to_selection,
.scroll_to_row,
.scroll_page_up, .scroll_page_up,
.scroll_page_down, .scroll_page_down,
.scroll_page_fractional, .scroll_page_fractional,

View File

@ -487,6 +487,7 @@ fn actionCommands(action: Action.Key) []const Command {
.esc, .esc,
.cursor_key, .cursor_key,
.set_font_size, .set_font_size,
.scroll_to_row,
.scroll_page_fractional, .scroll_page_fractional,
.scroll_page_lines, .scroll_page_lines,
.adjust_selection, .adjust_selection,

View File

@ -1314,6 +1314,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.draw_mutex.lock(); self.draw_mutex.lock();
defer self.draw_mutex.unlock(); 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. // Let our graphics API do any bookkeeping, etc.
// that it needs to do before / after `drawFrame`. // that it needs to do before / after `drawFrame`.
self.api.drawFrameStart(); self.api.drawFrameStart();

View File

@ -1886,6 +1886,11 @@ pub const Scroll = union(enum) {
/// the scrollback history. /// the scrollback history.
top, 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 /// Scroll up (negative) or down (positive) by the given number of
/// rows. This is clamped to the "top" and "active" top left. /// rows. This is clamped to the "top" and "active" top left.
delta_row: isize, 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 /// pages, etc. This can only be used to move the viewport within the
/// previously allocated pages. /// previously allocated pages.
pub fn scroll(self: *PageList, behavior: Scroll) void { pub fn scroll(self: *PageList, behavior: Scroll) void {
defer self.assertIntegrity();
switch (behavior) { switch (behavior) {
.active => self.viewport = .active, .active => self.viewport = .active,
.top => self.viewport = .top, .top => self.viewport = .top,
@ -1920,6 +1927,93 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
self.viewport = .pin; self.viewport = .pin;
self.viewport_pin_row_offset = null; // invalidate cache 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_prompt => |n| self.scrollPrompt(n),
.delta_row => |n| delta_row: { .delta_row => |n| delta_row: {
switch (self.viewport) { switch (self.viewport) {
@ -2110,6 +2204,21 @@ pub const Scrollbar = struct {
.len = 0, .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. /// Comparison for scrollbars.
pub fn eql(self: Scrollbar, other: Scrollbar) bool { pub fn eql(self: Scrollbar, other: Scrollbar) bool {
return self.total == other.total and return self.total == other.total and
@ -5034,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" { test "PageList scroll clear" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -5089,7 +5619,7 @@ test "PageList: jump zero prompts" {
try testing.expect(s.viewport == .active); try testing.expect(s.viewport == .active);
try testing.expectEqual(Scrollbar{ try testing.expectEqual(Scrollbar{
.total = s.totalRows(), .total = s.total_rows,
.offset = s.total_rows - s.rows, .offset = s.total_rows - s.rows,
.len = s.rows, .len = s.rows,
}, s.scrollbar()); }, s.scrollbar());
@ -5123,7 +5653,7 @@ test "Screen: jump back one prompt" {
} }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?);
try testing.expectEqual(Scrollbar{ try testing.expectEqual(Scrollbar{
.total = s.totalRows(), .total = s.total_rows,
.offset = 1, .offset = 1,
.len = s.rows, .len = s.rows,
}, s.scrollbar()); }, s.scrollbar());
@ -5137,7 +5667,7 @@ test "Screen: jump back one prompt" {
} }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?);
try testing.expectEqual(Scrollbar{ try testing.expectEqual(Scrollbar{
.total = s.totalRows(), .total = s.total_rows,
.offset = 1, .offset = 1,
.len = s.rows, .len = s.rows,
}, s.scrollbar()); }, s.scrollbar());
@ -5148,7 +5678,7 @@ test "Screen: jump back one prompt" {
s.scroll(.{ .delta_prompt = 1 }); s.scroll(.{ .delta_prompt = 1 });
try testing.expect(s.viewport == .active); try testing.expect(s.viewport == .active);
try testing.expectEqual(Scrollbar{ try testing.expectEqual(Scrollbar{
.total = s.totalRows(), .total = s.total_rows,
.offset = s.total_rows - s.rows, .offset = s.total_rows - s.rows,
.len = s.rows, .len = s.rows,
}, s.scrollbar()); }, s.scrollbar());
@ -5157,7 +5687,7 @@ test "Screen: jump back one prompt" {
s.scroll(.{ .delta_prompt = 1 }); s.scroll(.{ .delta_prompt = 1 });
try testing.expect(s.viewport == .active); try testing.expect(s.viewport == .active);
try testing.expectEqual(Scrollbar{ try testing.expectEqual(Scrollbar{
.total = s.totalRows(), .total = s.total_rows,
.offset = s.total_rows - s.rows, .offset = s.total_rows - s.rows,
.len = s.rows, .len = s.rows,
}, s.scrollbar()); }, s.scrollbar());
@ -6027,11 +6557,11 @@ test "PageList erase" {
try testing.expectEqual(@as(usize, 6), s.totalPages()); try testing.expectEqual(@as(usize, 6), s.totalPages());
// Our total rows should be large // 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. // Erase the entire history, we should be back to just our active set.
s.eraseRows(.{ .history = .{} }, null); 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 // We should be back to just one page
try testing.expectEqual(@as(usize, 1), s.totalPages()); try testing.expectEqual(@as(usize, 1), s.totalPages());
@ -6086,7 +6616,7 @@ test "PageList erase row with tracked pin resets to top-left" {
cur_page.data.pauseIntegrityChecks(false); cur_page.data.pauseIntegrityChecks(false);
// Our total rows should be large // 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 // Put a tracked pin in the history
const p = try s.trackPin(s.pin(.{ .history = .{} }).?); const p = try s.trackPin(s.pin(.{ .history = .{} }).?);
@ -6094,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. // Erase the entire history, we should be back to just our active set.
s.eraseRows(.{ .history = .{} }, null); 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 // Our pin should move to the first page
try testing.expectEqual(s.pages.first.?, p.node); try testing.expectEqual(s.pages.first.?, p.node);
@ -6115,7 +6645,7 @@ test "PageList erase row with tracked pin shifts" {
// Erase only a few rows in our active // Erase only a few rows in our active
s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); 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 // Our pin should move to the first page
try testing.expectEqual(s.pages.first.?, p.node); try testing.expectEqual(s.pages.first.?, p.node);
@ -6136,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. // Erase the entire history, we should be back to just our active set.
s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); 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 // Our pin should move to the first page
try testing.expectEqual(s.pages.first.?, p.node); try testing.expectEqual(s.pages.first.?, p.node);
@ -6165,7 +6695,7 @@ test "PageList erase resets viewport to active if moves within active" {
cur_page.data.pauseIntegrityChecks(false); cur_page.data.pauseIntegrityChecks(false);
// Move our viewport to the top // 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); try testing.expect(s.viewport == .top);
// Erase the entire history, we should be back to just our active set. // Erase the entire history, we should be back to just our active set.
@ -6194,7 +6724,7 @@ test "PageList erase resets viewport if inside erased page but not active" {
cur_page.data.pauseIntegrityChecks(false); cur_page.data.pauseIntegrityChecks(false);
// Move our viewport to the top // 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); try testing.expect(s.viewport == .top);
// Erase the entire history, we should be back to just our active set. // Erase the entire history, we should be back to just our active set.
@ -6260,7 +6790,7 @@ test "PageList erase a one-row active" {
} }
s.eraseRows(.{ .active = .{} }, .{ .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 // The row should be empty
{ {

View File

@ -1155,6 +1155,7 @@ pub const Scroll = union(enum) {
active, active,
top, top,
pin: Pin, pin: Pin,
row: usize,
delta_row: isize, delta_row: isize,
delta_prompt: isize, delta_prompt: isize,
}; };
@ -1174,6 +1175,7 @@ pub inline fn scroll(self: *Screen, behavior: Scroll) void {
.active => self.pages.scroll(.{ .active = {} }), .active => self.pages.scroll(.{ .active = {} }),
.top => self.pages.scroll(.{ .top = {} }), .top => self.pages.scroll(.{ .top = {} }),
.pin => |p| self.pages.scroll(.{ .pin = p }), .pin => |p| self.pages.scroll(.{ .pin = p }),
.row => |v| self.pages.scroll(.{ .row = v }),
.delta_row => |v| self.pages.scroll(.{ .delta_row = v }), .delta_row => |v| self.pages.scroll(.{ .delta_row = v }),
.delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }), .delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }),
} }